add letta

This commit is contained in:
hex2077 2026-01-19 21:55:35 +08:00
parent 417e6ed1f7
commit b7f2142411
22 changed files with 809 additions and 51 deletions

View file

@ -35,4 +35,10 @@ export {
export {
importOrchidsToken,
handleOrchidsOAuth
} from './orchids-oauth.js';
} from './orchids-oauth.js';
// Letta OAuth
export {
handleLettaOAuth,
refreshLettaToken
} from './letta-oauth.js';

215
src/auth/letta-oauth.js Normal file
View file

@ -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('<h1>Authorization Successful!</h1><p>You can close this window.</p>');
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
};
}

View file

@ -23,5 +23,7 @@ export {
refreshIFlowTokens,
// Orchids OAuth
importOrchidsToken,
handleOrchidsOAuth
handleOrchidsOAuth,
handleLettaOAuth,
refreshLettaToken
} from './index.js';

View file

@ -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}`);
}

View file

@ -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' }
]
};
}
}

View file

@ -79,6 +79,9 @@ export const PROVIDER_MODELS = {
'gpt-5.1-codex-max',
'gpt-5.2',
'gpt-5.2-codex'
],
'openai-letta': [
'letta-agent'
]
};

View file

@ -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({

View file

@ -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);

View file

@ -165,7 +165,7 @@ async function scanProviderDirectory(dirPath, linkedPaths, newProviders, options
* @param {Object} config - The server configuration
* @returns {Promise<Object>} 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 = [];

View file

@ -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({

View file

@ -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;

View file

@ -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]) {

View file

@ -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',
}
/**

View file

@ -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']
}
];

View file

@ -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';
}

View file

@ -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:',

View file

@ -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 += `

View file

@ -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' : '未知路径');
}

View file

@ -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'
}
];

View file

@ -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 <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'https://api.letta.com'
},
{
id: 'LETTA_AGENT_ID',
label: `Agent ID <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: '例如: agent-...'
}
]
};

View file

@ -66,6 +66,10 @@
<i class="fas fa-code"></i>
<span>OpenAI Codex OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="openai-letta">
<i class="fas fa-microchip"></i>
<span>Letta API</span>
</button>
</div>
<small class="form-text" data-i18n="config.modelProviderHelp">点击选择启动时初始化的模型提供商 (必须至少选择一个)</small>
</div>
@ -125,6 +129,10 @@
<i class="fas fa-code"></i>
<span>OpenAI Codex OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="openai-letta">
<i class="fas fa-microchip"></i>
<span>Letta API</span>
</button>
</div>
<small class="form-text" data-i18n="config.proxy.enabledProvidersNote">点击选择需要通过代理访问的提供商,未选中的提供商将直接连接</small>
</div>

View file

@ -26,6 +26,7 @@
<option value="claude-orchids-oauth" data-i18n="upload.providerFilter.orchids">Orchids OAuth</option>
<option value="openai-codex-oauth" data-i18n="upload.providerFilter.codex">Codex OAuth</option>
<option value="openai-iflow-oauth" data-i18n="upload.providerFilter.iflow">iFlow OAuth</option>
<option value="openai-letta" data-i18n="upload.providerFilter.letta">Letta API</option>
<option value="other" data-i18n="upload.providerFilter.other">其他/未识别</option>
</select>
</div>