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('
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