add letta
This commit is contained in:
parent
417e6ed1f7
commit
b7f2142411
22 changed files with 809 additions and 51 deletions
|
|
@ -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
215
src/auth/letta-oauth.js
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -23,5 +23,7 @@ export {
|
|||
refreshIFlowTokens,
|
||||
// Orchids OAuth
|
||||
importOrchidsToken,
|
||||
handleOrchidsOAuth
|
||||
handleOrchidsOAuth,
|
||||
handleLettaOAuth,
|
||||
refreshLettaToken
|
||||
} from './index.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}`);
|
||||
}
|
||||
|
|
|
|||
408
src/providers/openai/letta-core.js
Normal file
408
src/providers/openai/letta-core.js
Normal 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' }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +79,9 @@ export const PROVIDER_MODELS = {
|
|||
'gpt-5.1-codex-max',
|
||||
'gpt-5.2',
|
||||
'gpt-5.2-codex'
|
||||
],
|
||||
'openai-letta': [
|
||||
'letta-agent'
|
||||
]
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:',
|
||||
|
|
|
|||
|
|
@ -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 += `
|
||||
|
|
|
|||
|
|
@ -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' : '未知路径');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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-...'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue