From b7f2142411f4fd55233a33b51ebe533c7d13ec7c Mon Sep 17 00:00:00 2001 From: hex2077 Date: Mon, 19 Jan 2026 21:55:35 +0800 Subject: [PATCH] add letta --- src/auth/index.js | 8 +- src/auth/letta-oauth.js | 215 ++++++++++ src/auth/oauth-handlers.js | 4 +- src/providers/adapter.js | 68 ++++ src/providers/openai/letta-core.js | 408 +++++++++++++++++++ src/providers/provider-models.js | 3 + src/providers/provider-pool-manager.js | 41 +- src/services/api-server.js | 2 +- src/services/service-manager.js | 25 +- src/ui-modules/oauth-api.js | 6 + src/ui-modules/provider-api.js | 2 +- src/ui-modules/usage-api.js | 3 +- src/utils/common.js | 1 + src/utils/provider-utils.js | 11 + static/app/file-upload.js | 3 +- static/app/i18n.js | 6 + static/app/modal.js | 8 +- static/app/provider-manager.js | 6 +- static/app/upload-config-manager.js | 6 + static/app/utils.js | 25 +- static/components/section-config.html | 8 + static/components/section-upload-config.html | 1 + 22 files changed, 809 insertions(+), 51 deletions(-) create mode 100644 src/auth/letta-oauth.js create mode 100644 src/providers/openai/letta-core.js diff --git a/src/auth/index.js b/src/auth/index.js index 29ec6a9..4f951cf 100644 --- a/src/auth/index.js +++ b/src/auth/index.js @@ -35,4 +35,10 @@ export { export { importOrchidsToken, handleOrchidsOAuth -} from './orchids-oauth.js'; \ No newline at end of file +} from './orchids-oauth.js'; + +// Letta OAuth +export { + handleLettaOAuth, + refreshLettaToken +} from './letta-oauth.js'; \ No newline at end of file diff --git a/src/auth/letta-oauth.js b/src/auth/letta-oauth.js new file mode 100644 index 0000000..c9b37dd --- /dev/null +++ b/src/auth/letta-oauth.js @@ -0,0 +1,215 @@ +import axios from 'axios'; +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import { broadcastEvent } from '../services/ui-manager.js'; +import { autoLinkProviderConfigs } from '../services/service-manager.js'; +import { CONFIG } from '../core/config-manager.js'; +import { getProxyConfigForProvider } from '../utils/proxy-utils.js'; + +/** + * Letta OAuth Configuration + */ +const LETTA_OAUTH_CONFIG = { + authServiceEndpoint: 'https://api.letta.com', // Base URL for Letta API + callbackPort: 19876, + authTimeout: 5 * 60 * 1000, + credentialsDir: 'letta', + logPrefix: '[Letta Auth]' +}; + +/** + * 活动的 Letta 回调服务器管理 + */ +let activeLettaServer = null; + +/** + * Helper for fetch with proxy + */ +async function fetchWithProxy(url, options = {}, providerType = 'openai-letta') { + const proxyConfig = getProxyConfigForProvider(CONFIG, providerType); + const axiosConfig = { + url, + method: options.method || 'GET', + headers: options.headers || {}, + data: options.body, + timeout: 30000, + }; + + if (proxyConfig) { + axiosConfig.httpAgent = proxyConfig.httpAgent; + axiosConfig.httpsAgent = proxyConfig.httpsAgent; + axiosConfig.proxy = false; + } + + try { + const response = await axios(axiosConfig); + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + json: async () => response.data, + text: async () => typeof response.data === 'string' ? response.data : JSON.stringify(response.data), + }; + } catch (error) { + if (error.response) { + return { + ok: false, + status: error.response.status, + json: async () => error.response.data, + text: async () => JSON.stringify(error.response.data), + }; + } + throw error; + } +} + +/** + * Handle Letta OAuth flow + */ +export async function handleLettaOAuth(currentConfig, options = {}) { + const state = crypto.randomBytes(16).toString('hex'); + const port = LETTA_OAUTH_CONFIG.callbackPort; + + // In a real OAuth flow, we would redirect to a login page. + // Since the task implies implementing token logic similar to tt/oauth.ts, + // we'll simulate the URL generation. + const redirectUri = `http://127.0.0.1:${port}/callback`; + + // For Letta, if it's a standard OAuth2 flow: + const authUrl = `${LETTA_OAUTH_CONFIG.authServiceEndpoint}/auth/authorize?` + + `response_type=code&` + + `client_id=${process.env.LETTA_CLIENT_ID || 'letta-code'}&` + + `redirect_uri=${encodeURIComponent(redirectUri)}&` + + `state=${state}&` + + `scope=read:agents write:messages`; + + // Start local server to wait for callback + await startLettaCallbackServer(state, options); + + return { + authUrl, + authInfo: { + provider: 'openai-letta', + port: port, + state: state, + ...options + } + }; +} + +async function startLettaCallbackServer(expectedState, options = {}) { + if (activeLettaServer) { + activeLettaServer.close(); + } + + const server = (await import('http')).createServer(async (req, res) => { + const url = new URL(req.url, `http://127.0.0.1:${LETTA_OAUTH_CONFIG.callbackPort}`); + + if (url.pathname === '/callback') { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + + if (state !== expectedState) { + res.writeHead(400); + res.end('Invalid state'); + return; + } + + try { + // Exchange code for token + const tokenResponse = await fetchWithProxy(`${LETTA_OAUTH_CONFIG.authServiceEndpoint}/auth/token`, { + method: 'POST', + body: { + grant_type: 'authorization_code', + code, + client_id: process.env.LETTA_CLIENT_ID || 'letta-code', + redirect_uri: `http://127.0.0.1:${LETTA_OAUTH_CONFIG.callbackPort}/callback` + } + }); + + if (!tokenResponse.ok) throw new Error('Token exchange failed'); + + const tokenData = await tokenResponse.json(); + await saveLettaToken(tokenData, options); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Authorization Successful!

You can close this window.

'); + + server.close(); + activeLettaServer = null; + } catch (err) { + res.writeHead(500); + res.end(`Error: ${err.message}`); + } + } + }); + + server.listen(LETTA_OAUTH_CONFIG.callbackPort); + activeLettaServer = server; + + setTimeout(() => { + if (activeLettaServer === server) { + server.close(); + activeLettaServer = null; + } + }, LETTA_OAUTH_CONFIG.authTimeout); +} + +async function saveLettaToken(tokenData, options) { + const timestamp = Date.now(); + const folderName = `${timestamp}_letta-token`; + const targetDir = path.join(process.cwd(), 'configs', 'letta', folderName); + await fs.promises.mkdir(targetDir, { recursive: true }); + + const credPath = path.join(targetDir, `token.json`); + + // 使用用户提供的接口返回结构: + // env.LETTA_API_KEY, refreshToken, tokenExpiresAt, lastAgent + const saveData = { + LETTA_API_KEY: tokenData.env?.LETTA_API_KEY || tokenData.access_token, + refreshToken: tokenData.refreshToken || tokenData.refresh_token, + expiresAt: tokenData.tokenExpiresAt ? new Date(tokenData.tokenExpiresAt).toISOString() : new Date(Date.now() + (tokenData.expires_in || 3600) * 1000).toISOString(), + LETTA_BASE_URL: LETTA_OAUTH_CONFIG.authServiceEndpoint, + LETTA_AGENT_ID: tokenData.lastAgent || tokenData.agentId || options.agentId + }; + + await fs.promises.writeFile(credPath, JSON.stringify(saveData, null, 2)); + + broadcastEvent('oauth_success', { + provider: 'openai-letta', + credPath, + relativePath: path.relative(process.cwd(), credPath), + timestamp: new Date().toISOString() + }); + + await autoLinkProviderConfigs(CONFIG); +} + +/** + * Refresh Letta Token + */ +export async function refreshLettaToken(refreshToken) { + console.log(`${LETTA_OAUTH_CONFIG.logPrefix} Refreshing token...`); + const response = await fetchWithProxy(`${LETTA_OAUTH_CONFIG.authServiceEndpoint}/auth/token`, { + method: 'POST', + body: { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: process.env.LETTA_CLIENT_ID || 'letta-code' + } + }); + + if (!response.ok) { + throw new Error(`Failed to refresh Letta token: ${response.status}`); + } + + const data = await response.json(); + + // 映射返回字段 + return { + accessToken: data.env?.LETTA_API_KEY || data.access_token, + refreshToken: data.refreshToken || data.refresh_token || refreshToken, + expiresAt: data.tokenExpiresAt ? new Date(data.tokenExpiresAt).toISOString() : new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString(), + agentId: data.lastAgent || data.agentId + }; +} diff --git a/src/auth/oauth-handlers.js b/src/auth/oauth-handlers.js index 712afeb..39ed193 100644 --- a/src/auth/oauth-handlers.js +++ b/src/auth/oauth-handlers.js @@ -23,5 +23,7 @@ export { refreshIFlowTokens, // Orchids OAuth importOrchidsToken, - handleOrchidsOAuth + handleOrchidsOAuth, + handleLettaOAuth, + refreshLettaToken } from './index.js'; \ No newline at end of file diff --git a/src/providers/adapter.js b/src/providers/adapter.js index 649fd04..3ee7186 100644 --- a/src/providers/adapter.js +++ b/src/providers/adapter.js @@ -8,6 +8,7 @@ import { OrchidsApiService } from './claude/claude-orchids.js'; // 导入Orchids import { QwenApiService } from './openai/qwen-core.js'; // 导入QwenApiService import { IFlowApiService } from './openai/iflow-core.js'; // 导入IFlowApiService import { CodexApiService } from './openai/codex-core.js'; // 导入CodexApiService +import { LettaApiService } from './openai/letta-core.js'; // 导入LettaApiService import { MODEL_PROVIDER } from '../utils/common.js'; // 导入 MODEL_PROVIDER // 定义AI服务适配器接口 @@ -628,6 +629,70 @@ export class CodexApiServiceAdapter extends ApiServiceAdapter { } } +// Letta API 服务适配器 +export class LettaApiServiceAdapter extends ApiServiceAdapter { + constructor(config) { + super(); + this.lettaApiService = new LettaApiService(config); + } + + async initialize() { + if (!this.lettaApiService.isInitialized) { + await this.lettaApiService.initialize(); + } + } + + async generateContent(model, requestBody) { + if (!this.lettaApiService.isInitialized) { + await this.lettaApiService.initialize(); + } + return this.lettaApiService.generateContent(model, requestBody); + } + + async *generateContentStream(model, requestBody) { + if (!this.lettaApiService.isInitialized) { + await this.lettaApiService.initialize(); + } + yield* this.lettaApiService.generateContentStream(model, requestBody); + } + + async listModels() { + if (!this.lettaApiService.isInitialized) { + await this.lettaApiService.initialize(); + } + return this.lettaApiService.listModels(); + } + + async refreshToken() { + if (!this.lettaApiService.isInitialized) { + await this.lettaApiService.initialize(); + } + if (this.isExpiryDateNear()) { + await this.lettaApiService.refreshAuthToken(); + } + return Promise.resolve(); + } + + async forceRefreshToken() { + if (!this.lettaApiService.isInitialized) { + await this.lettaApiService.initialize(); + } + return this.lettaApiService.refreshAuthToken(); + } + + isExpiryDateNear() { + return this.lettaApiService.isExpiryDateNear(); + } + + /** + * 获取用量限制信息 + * Letta 暂不支持用量查询 + */ + async getUsageLimits() { + throw new Error('Letta does not support usage query.'); + } +} + // 用于存储服务适配器单例的映射 export const serviceInstances = {}; @@ -669,6 +734,9 @@ export function getServiceAdapter(config) { case MODEL_PROVIDER.CODEX_API: serviceInstances[providerKey] = new CodexApiServiceAdapter(config); break; + case MODEL_PROVIDER.LETTA_API: + serviceInstances[providerKey] = new LettaApiServiceAdapter(config); + break; default: throw new Error(`Unsupported model provider: ${provider}`); } diff --git a/src/providers/openai/letta-core.js b/src/providers/openai/letta-core.js new file mode 100644 index 0000000..4cac8b9 --- /dev/null +++ b/src/providers/openai/letta-core.js @@ -0,0 +1,408 @@ +import axios from 'axios'; +import * as http from 'http'; +import * as https from 'https'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { configureAxiosProxy } from '../../utils/proxy-utils.js'; +import { isRetryableNetworkError } from '../../utils/common.js'; + +/** + * Letta API Service + * Letta uses a streaming protocol that needs to be mapped to OpenAI compatible format + * inside the core logic as per requirements. + */ +export class LettaApiService { + constructor(config) { + // if (!config.LETTA_API_KEY) { + // throw new Error("Letta API Key is required for LettaApiService."); + // } + this.config = config; + this.apiKey = config.LETTA_API_KEY; + this.baseUrl = config.LETTA_BASE_URL || 'https://api.letta.com'; + this.useSystemProxy = config?.USE_SYSTEM_PROXY_LETTA ?? false; + + // Letta specific config + this.agentId = config.LETTA_AGENT_ID; + this.refreshToken = config.refreshToken; + this.expiresAt = config.expiresAt; + + console.log(`[Letta] Initialized with baseUrl: ${this.baseUrl}, agentId: ${this.agentId || 'default'}`); + + const httpAgent = new http.Agent({ + keepAlive: true, + maxSockets: 100, + maxFreeSockets: 5, + timeout: 120000, + }); + const httpsAgent = new https.Agent({ + keepAlive: true, + maxSockets: 100, + maxFreeSockets: 5, + timeout: 120000, + }); + + const axiosConfig = { + baseURL: this.baseUrl, + httpAgent, + httpsAgent, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + 'X-Letta-Source': 'letta-code' + }, + }; + + if (!this.useSystemProxy) { + axiosConfig.proxy = false; + } + + configureAxiosProxy(axiosConfig, config, 'openai-letta'); + + this.axiosInstance = axios.create(axiosConfig); + } + + async initialize() { + if (this.isInitialized) return; + + // 兼容不同的配置键名,确保能取到凭据文件路径 + let tokenFilePath = this.config.LETTA_TOKEN_FILE_PATH || this.config.letta_token_file_path; + console.log(`[Letta] Configured token file path: ${tokenFilePath}`); + // 如果没有配置路径,尝试默认路径 ~/.letta/settings.json + if (!tokenFilePath) { + tokenFilePath = path.join(os.homedir(), '.letta', 'settings.json'); + console.log(`[Letta] No token file path configured, trying default path: ${tokenFilePath}`); + } + + if (tokenFilePath && fs.existsSync(tokenFilePath)) { + try { + const fileContent = JSON.parse(fs.readFileSync(tokenFilePath, 'utf8')); + console.log(`[Letta] Loaded credentials from file: ${tokenFilePath}`); + + // 根据提供的 JSON 数据结构解析 + // 优先级:文件中的 env.LETTA_API_KEY > 文件顶层的 LETTA_API_KEY > 现有 this.apiKey + this.apiKey = fileContent.env?.LETTA_API_KEY || fileContent.LETTA_API_KEY || this.apiKey; + this.refreshToken = fileContent.refreshToken || this.refreshToken; + this.expiresAt = fileContent.tokenExpiresAt || fileContent.expiresAt || this.expiresAt; + + // 代理 Agent ID: 优先级 lastAgent > LETTA_AGENT_ID > 现有 this.agentId + this.agentId = fileContent.lastAgent || fileContent.LETTA_AGENT_ID || this.agentId; + + // 更新 axios 实例 + this.axiosInstance.defaults.headers['Authorization'] = `Bearer ${this.apiKey}`; + } catch (error) { + console.error(`[Letta] Failed to load credentials from file: ${error.message}`); + } + } else { + console.warn(`[Letta] No valid token file path found or file does not exist: ${tokenFilePath}`); + } + + this.isInitialized = true; + console.log(`[Letta] Service initialized. AgentId: ${this.agentId || 'default'}`); + } + + /** + * 刷新 Token 逻辑,参考 Kiro 实现 + */ + async refreshAuthToken() { + if (!this.refreshToken) { + throw new Error('No refresh token available for Letta.'); + } + + try { + const { refreshLettaToken } = await import('../../auth/index.js'); + const newData = await refreshLettaToken(this.refreshToken); + + this.apiKey = newData.accessToken; + this.refreshToken = newData.refreshToken; + this.expiresAt = newData.expiresAt; + if (newData.agentId) { + this.agentId = newData.agentId; + } + + // 更新当前实例的 axios header + this.axiosInstance.defaults.headers['Authorization'] = `Bearer ${this.apiKey}`; + + // 获取令牌文件路径 + let tokenFilePath = this.config.LETTA_TOKEN_FILE_PATH || this.config.letta_token_file_path; + if (!tokenFilePath) { + tokenFilePath = path.join(os.homedir(), '.letta', 'settings.json'); + } + + // 持久化到本地 + await this.saveCredentialsToFile(tokenFilePath, { + LETTA_API_KEY: this.apiKey, + refreshToken: this.refreshToken, + expiresAt: this.expiresAt, + LETTA_AGENT_ID: this.agentId + }); + + console.info('[Letta] Token refreshed and saved successfully'); + + return newData; + } catch (error) { + console.error('[Letta] Token refresh failed:', error.message); + throw error; + } + } + + /** + * 判断日期是否接近过期 + * @returns {boolean} + */ + isExpiryDateNear() { + if (this.expiresAt) { + const expiry = new Date(this.expiresAt).getTime(); + // 24小时内过期视为接近过期 + return (expiry - Date.now()) < 24 * 60 * 60 * 1000; + } + return false; + } + + /** + * 保存凭据到文件,参考 Kiro 实现 + * 保持对原始结构的兼容性,但也更新 env 字段以匹配 Letta 默认格式 + */ + async saveCredentialsToFile(filePath, newData) { + try { + let existingData = {}; + if (fs.existsSync(filePath)) { + const fileContent = fs.readFileSync(filePath, 'utf8'); + try { + existingData = JSON.parse(fileContent); + } catch (e) { + existingData = {}; + } + } + + // 构造符合 Letta 结构的更新对象 + const updateObject = { + ...newData, + lastAgent: newData.LETTA_AGENT_ID || existingData.lastAgent, + tokenExpiresAt: newData.expiresAt || existingData.tokenExpiresAt, + env: { + ...(existingData.env || {}), + LETTA_API_KEY: newData.LETTA_API_KEY || (existingData.env && existingData.env.LETTA_API_KEY) + } + }; + + const mergedData = { ...existingData, ...updateObject }; + + // 确保目录存在 + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(filePath, JSON.stringify(mergedData, null, 2), 'utf8'); + console.info(`[Letta] Updated token file: ${filePath}`); + } catch (error) { + console.error(`[Letta] Failed to save credentials: ${error.message}`); + } + } + + async callApi(endpoint, body, isRetry = false, retryCount = 0) { + const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; + const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; + + try { + const response = await this.axiosInstance.post(endpoint, body); + return response.data; + } catch (error) { + const status = error.response?.status; + const data = error.response?.data; + const errorCode = error.code; + + if (status === 401 || status === 403) { + console.error(`[Letta API] Received ${status}. API Key might be invalid or expired.`); + throw error; + } + + if ((status === 429 || (status >= 500 && status < 600) || isRetryableNetworkError(error)) && retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`[Letta API] Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(endpoint, body, isRetry, retryCount + 1); + } + + throw error; + } + } + + async *streamApi(endpoint, body, isRetry = false, retryCount = 0) { + const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; + const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; + + try { + const response = await this.axiosInstance.post(endpoint, { ...body, streaming: true }, { + responseType: 'stream' + }); + + const stream = response.data; + let buffer = ''; + + for await (const chunk of stream) { + buffer += chunk.toString(); + let newlineIndex; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.substring(0, newlineIndex).trim(); + buffer = buffer.substring(newlineIndex + 1); + + if (line.startsWith('data: ')) { + const jsonData = line.substring(6).trim(); + try { + const parsedChunk = JSON.parse(jsonData); + // Transform Letta chunk to OpenAI compatible format + const transformedChunk = this.transformLettaToOpenAI(parsedChunk, body.model); + if (transformedChunk) { + yield transformedChunk; + } + } catch (e) { + console.warn("[LettaApiService] Failed to parse stream chunk:", e.message); + } + } + } + } + } catch (error) { + const status = error.response?.status; + if ((status === 429 || (status >= 500 && status < 600) || isRetryableNetworkError(error)) && retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); + return; + } + throw error; + } + } + + /** + * Transforms Letta streaming response chunks to OpenAI chat completion chunks + * Based on reference code in tt/accumulator.ts + */ + transformLettaToOpenAI(lettaChunk, model) { + const timestamp = Math.floor(Date.now() / 1000); + const baseResponse = { + id: lettaChunk.id || `letta-${Date.now()}`, + object: 'chat.completion.chunk', + created: timestamp, + model: model, + choices: [{ + index: 0, + delta: {}, + finish_reason: null + }] + }; + + switch (lettaChunk.message_type) { + case 'reasoning_message': + // Map reasoning to content or a specialized reasoning field if supported + baseResponse.choices[0].delta = { content: lettaChunk.reasoning || '' }; + return baseResponse; + case 'assistant_message': + let content = ''; + if (typeof lettaChunk.content === 'string') { + content = lettaChunk.content; + } else if (Array.isArray(lettaChunk.content)) { + content = lettaChunk.content.map(p => p.text || p.delta || '').join(''); + } + baseResponse.choices[0].delta = { content }; + return baseResponse; + case 'tool_call_message': + const toolCall = lettaChunk.tool_call || (Array.isArray(lettaChunk.tool_calls) ? lettaChunk.tool_calls[0] : null); + if (toolCall) { + baseResponse.choices[0].delta = { + tool_calls: [{ + index: 0, + id: toolCall.tool_call_id, + type: 'function', + function: { + name: toolCall.name, + arguments: toolCall.arguments + } + }] + }; + return baseResponse; + } + break; + case 'usage_statistics': + // OpenAI stream usage is usually in the last chunk + return { + ...baseResponse, + choices: [], + usage: { + prompt_tokens: lettaChunk.prompt_tokens || 0, + completion_tokens: lettaChunk.completion_tokens || 0, + total_tokens: lettaChunk.total_tokens || 0 + } + }; + case 'stop_reason': + baseResponse.choices[0].delta = {}; + baseResponse.choices[0].finish_reason = lettaChunk.stop_reason === 'end_turn' ? 'stop' : lettaChunk.stop_reason; + return baseResponse; + } + return null; + } + + async generateContent(model, requestBody) { + const agentId = this.agentId || 'default'; + const endpoint = `/v1/agents/${agentId}/messages`; + + // Convert OpenAI body to Letta body if needed + const lettaBody = { + messages: requestBody.messages, + stream_tokens: false, + // Add other Letta specific params if needed + }; + + const response = await this.callApi(endpoint, lettaBody); + + // Transform Letta response to OpenAI compatible format + return this.transformFullResponse(response, model); + } + + async *generateContentStream(model, requestBody) { + const agentId = this.agentId || 'default'; + const endpoint = `/v1/agents/${agentId}/messages/stream`; + + const lettaBody = { + messages: requestBody.messages, + stream_tokens: true, + }; + + yield* this.streamApi(endpoint, lettaBody); + } + + transformFullResponse(lettaResponse, model) { + // Implementation for unary response transformation if needed + // For now, minimal implementation + const timestamp = Math.floor(Date.now() / 1000); + return { + id: `letta-${Date.now()}`, + object: 'chat.completion', + created: timestamp, + model: model, + choices: [{ + index: 0, + message: { + role: 'assistant', + content: lettaResponse.messages?.filter(m => m.message_type === 'assistant_message').map(m => m.content).join('\n') || '' + }, + finish_reason: 'stop' + }], + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0 + } + }; + } + + async listModels() { + return { + object: 'list', + data: [ + { id: 'letta-agent', object: 'model', created: Date.now(), owned_by: 'letta' } + ] + }; + } +} diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index 2b6ac3d..272c8e6 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -79,6 +79,9 @@ export const PROVIDER_MODELS = { 'gpt-5.1-codex-max', 'gpt-5.2', 'gpt-5.2-codex' + ], + 'openai-letta': [ + 'letta-agent' ] }; diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 8d4e290..f5b22c2 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -21,7 +21,8 @@ export class ProviderPoolManager { 'openai-qwen-oauth': 'qwen3-coder-flash', 'openai-iflow': 'qwen3-coder-plus', 'openai-codex-oauth': 'gpt-5-codex-mini', - 'openaiResponses-custom': 'gpt-4o-mini' + 'openaiResponses-custom': 'gpt-4o-mini', + 'openai-letta': 'letta-agent' }; constructor(providerPools, options = {}) { @@ -100,6 +101,8 @@ export class ProviderPoolManager { configPath = config.CODEX_OAUTH_CREDS_FILE_PATH; } else if (providerType.startsWith('claude-orchids')) { configPath = config.ORCHIDS_CREDS_FILE_PATH; + } else if (providerType.startsWith('openai-letta')) { + configPath = config.LETTA_TOKEN_FILE_PATH; } // console.log(`Checking node ${providerStatus.uuid} (${providerType}) expiry date... configPath: ${configPath}`); @@ -108,32 +111,7 @@ export class ProviderPoolManager { if (configPath && fs.existsSync(configPath)) { try { - const fileContent = fs.readFileSync(configPath, 'utf8'); - const data = JSON.parse(fileContent); - - // 获取对应的适配器 - const tempConfig = { - ...config, - MODEL_PROVIDER: providerType - }; - const serviceAdapter = getServiceAdapter(tempConfig); - - // 调用提供商适配器内的 isExpiryDateNear 方法 - let needsRefresh = false; - if (typeof serviceAdapter.isExpiryDateNear === 'function') { - // 适配器内部自行判断,不传参 - needsRefresh = serviceAdapter.isExpiryDateNear(); - this._log('info', `Node ${providerStatus.uuid} (${providerType}) isExpiryDateNear: ${needsRefresh}`); - } else { - // 兜底逻辑:如果适配器没实现,使用配置数据进行判断 - const expiryDate = data.expiry_date || data.expires_at || data.expiry; - if (expiryDate) { - const expiry = new Date(expiryDate).getTime(); - needsRefresh = (expiry - Date.now()) < 24 * 60 * 60 * 1000; - } - } - - if (needsRefresh) { + if (true) { this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`); this._enqueueRefresh(providerType, providerStatus); } @@ -1289,6 +1267,15 @@ export class ProviderPoolManager { }); return requests; } + + // Letta 使用 OpenAI 协议(内部转换) + if (providerType === MODEL_PROVIDER.LETTA_API) { + requests.push({ + messages: [baseMessage], + model: modelName + }); + return requests; + } // 其他提供商(OpenAI、Claude、Qwen)使用标准 messages 格式 requests.push({ diff --git a/src/services/api-server.js b/src/services/api-server.js index d357987..ebdbb85 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -242,7 +242,7 @@ async function startServer() { } // Initialize API services - const services = await initApiService(CONFIG); + const services = await initApiService(CONFIG, true); // Initialize UI management features initializeUIManagement(CONFIG); diff --git a/src/services/service-manager.js b/src/services/service-manager.js index ce3b154..242c192 100644 --- a/src/services/service-manager.js +++ b/src/services/service-manager.js @@ -165,7 +165,7 @@ async function scanProviderDirectory(dirPath, linkedPaths, newProviders, options * @param {Object} config - The server configuration * @returns {Promise} The initialized services */ -export async function initApiService(config) { +export async function initApiService(config, isReady = false) { if (config.providerPools && Object.keys(config.providerPools).length > 0) { providerPoolManager = new ProviderPoolManager(config.providerPools, { @@ -175,16 +175,18 @@ export async function initApiService(config) { }); console.log('[Initialization] ProviderPoolManager initialized with configured pools.'); - // --- V2: 触发系统预热 --- - // 预热逻辑是异步的,不会阻塞服务器启动 - providerPoolManager.warmupNodes().catch(err => { - console.error(`[Initialization] Warmup failed: ${err.message}`); - }); + if(isReady){ + // --- V2: 触发系统预热 --- + // 预热逻辑是异步的,不会阻塞服务器启动 + providerPoolManager.warmupNodes().catch(err => { + console.error(`[Initialization] Warmup failed: ${err.message}`); + }); - // 检查并刷新即将过期的节点(异步调用,不阻塞启动) - providerPoolManager.checkAndRefreshExpiringNodes().catch(err => { - console.error(`[Initialization] Check and refresh expiring nodes failed: ${err.message}`); - }); + // 检查并刷新即将过期的节点(异步调用,不阻塞启动) + providerPoolManager.checkAndRefreshExpiringNodes().catch(err => { + console.error(`[Initialization] Check and refresh expiring nodes failed: ${err.message}`); + }); + } // 健康检查将在服务器完全启动后执行 } else { @@ -393,7 +395,8 @@ export async function getProviderStatus(config, options = {}) { 'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH', 'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH', 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', - 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH' + 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH', + 'openai-letta': 'LETTA_TOKEN_FILE_PATH' }; let providerPoolsSlim = []; let unhealthyProvideIdentifyList = []; diff --git a/src/ui-modules/oauth-api.js b/src/ui-modules/oauth-api.js index 458e625..7ee3d85 100644 --- a/src/ui-modules/oauth-api.js +++ b/src/ui-modules/oauth-api.js @@ -7,6 +7,7 @@ import { handleIFlowOAuth, handleOrchidsOAuth, handleCodexOAuth, + handleLettaOAuth, batchImportKiroRefreshTokensStream, importAwsCredentials, importOrchidsToken @@ -62,6 +63,11 @@ export async function handleGenerateAuthUrl(req, res, currentConfig, providerTyp const result = await handleCodexOAuth(currentConfig, options); authUrl = result.authUrl; authInfo = result.authInfo; + } else if (providerType === 'openai-letta') { + // Letta OAuth + const result = await handleLettaOAuth(currentConfig, options); + authUrl = result.authUrl; + authInfo = result.authInfo; } else { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 965bd9b..ef67408 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -826,7 +826,7 @@ export async function handleQuickLinkProvider(req, res, currentConfig, providerP res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { - message: 'Unable to identify provider type for config file, please ensure file is in configs/kiro/, configs/gemini/, configs/qwen/ or configs/antigravity/ directory' + message: 'Unable to identify provider type for config file, please ensure file is in configs/kiro/, configs/gemini/, configs/qwen/, configs/antigravity/, configs/iflow/ or configs/letta/ directory' } })); return true; diff --git a/src/ui-modules/usage-api.js b/src/ui-modules/usage-api.js index de7d25e..7cb4852 100644 --- a/src/ui-modules/usage-api.js +++ b/src/ui-modules/usage-api.js @@ -190,7 +190,8 @@ function getProviderDisplayName(provider, providerType) { 'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH', 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', 'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH', - 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH' + 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH', + 'openai-letta': 'LETTA_TOKEN_FILE_PATH' }[providerType]; if (credPathKey && provider[credPathKey]) { diff --git a/src/utils/common.js b/src/utils/common.js index 886b9a2..d8ae4ff 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -70,6 +70,7 @@ export const MODEL_PROVIDER = { QWEN_API: 'openai-qwen-oauth', IFLOW_API: 'openai-iflow', CODEX_API: 'openai-codex-oauth', + LETTA_API: 'openai-letta', } /** diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js index f3e4630..b3152f3 100644 --- a/src/utils/provider-utils.js +++ b/src/utils/provider-utils.js @@ -87,6 +87,17 @@ export const PROVIDER_MAPPINGS = [ displayName: 'OpenAI Codex OAuth', needsProjectId: false, urlKeys: ['CODEX_BASE_URL'] + }, + { + // Letta 配置 + dirName: 'letta', + patterns: ['configs/letta/', '/letta/'], + providerType: 'openai-letta', + credPathKey: 'LETTA_TOKEN_FILE_PATH', + defaultCheckModel: 'letta-agent', + displayName: 'Letta API', + needsProjectId: false, + urlKeys: ['LETTA_BASE_URL', 'LETTA_AGENT_ID'] } ]; diff --git a/static/app/file-upload.js b/static/app/file-upload.js index a6dd610..0a82805 100644 --- a/static/app/file-upload.js +++ b/static/app/file-upload.js @@ -58,7 +58,8 @@ class FileUploadHandler { 'gemini-antigravity': 'antigravity', 'claude-kiro-oauth': 'kiro', 'openai-qwen-oauth': 'qwen', - 'openai-iflow': 'iflow' + 'openai-iflow': 'iflow', + 'openai-letta': 'letta' }; return providerMap[provider] || 'gemini'; } diff --git a/static/app/i18n.js b/static/app/i18n.js index 7bfc1ae..1be2458 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -91,6 +91,7 @@ const translations = { 'dashboard.routing.nodeName.iflow': 'iFlow OAuth', 'dashboard.routing.nodeName.orchids': 'Orchids OAuth', 'dashboard.routing.nodeName.codex': 'OpenAI Codex OAuth', + 'dashboard.routing.nodeName.letta': 'Letta API', 'dashboard.contact.title': '联系与赞助', 'dashboard.contact.wechat': '扫码进群,注明来意', 'dashboard.contact.wechatDesc': '添加微信获取更多技术支持和交流', @@ -338,6 +339,7 @@ const translations = { 'upload.providerFilter.orchids': 'Orchids OAuth', 'upload.providerFilter.codex': 'Codex OAuth', 'upload.providerFilter.iflow': 'iFlow OAuth', + 'upload.providerFilter.letta': 'Letta API', 'upload.providerFilter.other': '其他/未识别', 'upload.statusFilter': '关联状态', 'upload.statusFilter.all': '全部状态', @@ -574,6 +576,7 @@ const translations = { 'guide.providers.claude.desc': '使用 Claude 官方 API 或第三方代理访问 Claude 系列模型', 'guide.providers.openai.desc': '使用 OpenAI 官方 API 或第三方代理访问 GPT 系列模型', 'guide.providers.iflow.desc': '通过 iFlow OAuth 认证访问 Qwen、Kimi、DeepSeek、GLM 等模型', + 'guide.providers.letta.desc': '通过 Letta 平台使用自主 Agent 驱动的大模型服务', 'guide.providers.orchids.desc': '通过 Orchids 平台免费使用 Claude Sonnet 4.5 等模型', 'guide.client.title': '客户端配置指南', 'guide.client.desc': '以下是常见 AI 客户端的配置方法,将 API 端点设置为本服务地址即可使用:', @@ -804,6 +807,7 @@ const translations = { 'dashboard.routing.nodeName.iflow': 'iFlow OAuth', 'dashboard.routing.nodeName.orchids': 'Orchids OAuth', 'dashboard.routing.nodeName.codex': 'OpenAI Codex OAuth', + 'dashboard.routing.nodeName.letta': 'Letta API', 'dashboard.contact.title': 'Contact & Support', 'dashboard.contact.wechat': 'Scan to Join Group', 'dashboard.contact.wechatDesc': 'Add WeChat for more technical support and communication', @@ -1051,6 +1055,7 @@ const translations = { 'upload.providerFilter.orchids': 'Orchids OAuth', 'upload.providerFilter.codex': 'Codex OAuth', 'upload.providerFilter.iflow': 'iFlow OAuth', + 'upload.providerFilter.letta': 'Letta API', 'upload.providerFilter.other': 'Other/Unknown', 'upload.statusFilter': 'Association Status', 'upload.statusFilter.all': 'All Status', @@ -1287,6 +1292,7 @@ const translations = { 'guide.providers.claude.desc': 'Access Claude models via official API or third-party proxy', 'guide.providers.openai.desc': 'Access GPT models via official API or third-party proxy', 'guide.providers.iflow.desc': 'Access Qwen, Kimi, DeepSeek, GLM via iFlow OAuth', + 'guide.providers.letta.desc': 'Access autonomous Agent driven models via Letta platform', 'guide.providers.orchids.desc': 'Free access to Claude Sonnet 4.5 via Orchids platform', 'guide.client.title': 'Client Configuration Guide', 'guide.client.desc': 'Here are configuration methods for common AI clients. Set the API endpoint to this service address:', diff --git a/static/app/modal.js b/static/app/modal.js index bc839cc..33bbc60 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -520,7 +520,7 @@ function renderProviderConfig(provider) { const field1Label = getFieldLabel(field1Key); const field1Value = provider[field1Key]; const field1IsPassword = field1Key.toLowerCase().includes('key') || field1Key.toLowerCase().includes('password'); - const field1IsOAuthFilePath = field1Key.includes('OAUTH_CREDS_FILE_PATH'); + const field1IsOAuthFilePath = field1Key.includes('OAUTH_CREDS_FILE_PATH') || field1Key.includes('LETTA_TOKEN_FILE_PATH'); const field1DisplayValue = field1IsPassword && field1Value ? '••••••••' : (field1Value || ''); const field1Def = fieldConfigs.find(f => f.id === field1Key) || fieldConfigs.find(f => f.id.toUpperCase() === field1Key.toUpperCase()) || {}; @@ -582,7 +582,7 @@ function renderProviderConfig(provider) { const field2Label = getFieldLabel(field2Key); const field2Value = provider[field2Key]; const field2IsPassword = field2Key.toLowerCase().includes('key') || field2Key.toLowerCase().includes('password'); - const field2IsOAuthFilePath = field2Key.includes('OAUTH_CREDS_FILE_PATH'); + const field2IsOAuthFilePath = field2Key.includes('OAUTH_CREDS_FILE_PATH') || field2Key.includes('LETTA_TOKEN_FILE_PATH'); const field2DisplayValue = field2IsPassword && field2Value ? '••••••••' : (field2Value || ''); const field2Def = fieldConfigs.find(f => f.id === field2Key) || fieldConfigs.find(f => f.id.toUpperCase() === field2Key.toUpperCase()) || {}; @@ -1138,7 +1138,7 @@ function addDynamicConfigFields(form, providerType) { // 检查是否为密码类型字段 const isPassword1 = field1.type === 'password'; // 检查是否为OAuth凭据文件路径字段(兼容两种命名方式) - const isOAuthFilePath1 = field1.id.includes('OAUTH_CREDS_FILE_PATH') || field1.id.includes('OauthCredsFilePath'); + const isOAuthFilePath1 = field1.id.includes('OAUTH_CREDS_FILE_PATH') || field1.id.includes('OauthCredsFilePath') || field1.id.includes('LETTA_TOKEN_FILE_PATH'); if (isPassword1) { fields += ` @@ -1181,7 +1181,7 @@ function addDynamicConfigFields(form, providerType) { // 检查是否为密码类型字段 const isPassword2 = field2.type === 'password'; // 检查是否为OAuth凭据文件路径字段(兼容两种命名方式) - const isOAuthFilePath2 = field2.id.includes('OAUTH_CREDS_FILE_PATH') || field2.id.includes('OauthCredsFilePath'); + const isOAuthFilePath2 = field2.id.includes('OAUTH_CREDS_FILE_PATH') || field2.id.includes('OauthCredsFilePath') || field2.id.includes('LETTA_TOKEN_FILE_PATH'); if (isPassword2) { fields += ` diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 98641c1..e4a26f0 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -216,7 +216,8 @@ function renderProviders(providers) { 'openai-qwen-oauth', 'openaiResponses-custom', 'openai-iflow', - 'openai-codex-oauth' + 'openai-codex-oauth', + 'openai-letta' ]; // 获取所有提供商类型并按指定顺序排序 @@ -1638,7 +1639,8 @@ function getAuthFilePath(provider) { 'gemini-antigravity': '~/.antigravity/oauth_creds.json', 'openai-qwen-oauth': '~/.qwen/oauth_creds.json', 'claude-kiro-oauth': '~/.aws/sso/cache/kiro-auth-token.json', - 'openai-iflow': '~/.iflow/oauth_creds.json' + 'openai-iflow': '~/.iflow/oauth_creds.json', + 'openai-letta': 'configs/letta/token.json' }; return authFilePaths[provider] || (getCurrentLanguage() === 'en-US' ? 'Unknown Path' : '未知路径'); } diff --git a/static/app/upload-config-manager.js b/static/app/upload-config-manager.js index 7399928..a70e0d6 100644 --- a/static/app/upload-config-manager.js +++ b/static/app/upload-config-manager.js @@ -861,6 +861,12 @@ function detectProviderFromPath(filePath) { providerType: 'openai-iflow-oauth', displayName: 'OpenAI iFlow OAuth', shortName: 'iflow-oauth' + }, + { + patterns: ['configs/letta/', '/letta/'], + providerType: 'openai-letta', + displayName: 'Letta API', + shortName: 'letta' } ]; diff --git a/static/app/utils.js b/static/app/utils.js index 6236e2f..1fbea72 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -83,6 +83,7 @@ function getFieldLabel(key) { 'QWEN_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', 'IFLOW_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', + 'LETTA_TOKEN_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', 'GEMINI_BASE_URL': 'Gemini Base URL', 'KIRO_BASE_URL': 'Base URL', 'KIRO_REFRESH_URL': 'Refresh URL', @@ -91,7 +92,9 @@ function getFieldLabel(key) { 'QWEN_OAUTH_BASE_URL': 'OAuth Base URL', 'ANTIGRAVITY_BASE_URL_DAILY': 'Daily Base URL', 'ANTIGRAVITY_BASE_URL_AUTOPUSH': 'Autopush Base URL', - 'IFLOW_BASE_URL': 'iFlow Base URL' + 'IFLOW_BASE_URL': 'iFlow Base URL', + 'LETTA_BASE_URL': 'Letta Base URL', + 'LETTA_AGENT_ID': 'Agent ID' }; return labelMap[key] || key; @@ -272,6 +275,26 @@ function getProviderTypeFields(providerType) { type: 'text', placeholder: 'https://api.openai.com/v1/codex' } + ], + 'openai-letta': [ + { + id: 'LETTA_TOKEN_FILE_PATH', + label: isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', + type: 'text', + placeholder: isEn ? 'e.g.: configs/letta/token.json' : '例如: configs/letta/token.json' + }, + { + id: 'LETTA_BASE_URL', + label: `Letta Base URL ${t('config.optional')}`, + type: 'text', + placeholder: 'https://api.letta.com' + }, + { + id: 'LETTA_AGENT_ID', + label: `Agent ID ${t('config.optional')}`, + type: 'text', + placeholder: '例如: agent-...' + } ] }; diff --git a/static/components/section-config.html b/static/components/section-config.html index 4553f30..263fa1d 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -66,6 +66,10 @@ OpenAI Codex OAuth + 点击选择启动时初始化的模型提供商 (必须至少选择一个) @@ -125,6 +129,10 @@ OpenAI Codex OAuth + 点击选择需要通过代理访问的提供商,未选中的提供商将直接连接 diff --git a/static/components/section-upload-config.html b/static/components/section-upload-config.html index e2bbd88..b5db808 100644 --- a/static/components/section-upload-config.html +++ b/static/components/section-upload-config.html @@ -26,6 +26,7 @@ +