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 {
|
export {
|
||||||
importOrchidsToken,
|
importOrchidsToken,
|
||||||
handleOrchidsOAuth
|
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,
|
refreshIFlowTokens,
|
||||||
// Orchids OAuth
|
// Orchids OAuth
|
||||||
importOrchidsToken,
|
importOrchidsToken,
|
||||||
handleOrchidsOAuth
|
handleOrchidsOAuth,
|
||||||
|
handleLettaOAuth,
|
||||||
|
refreshLettaToken
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
|
@ -8,6 +8,7 @@ import { OrchidsApiService } from './claude/claude-orchids.js'; // 导入Orchids
|
||||||
import { QwenApiService } from './openai/qwen-core.js'; // 导入QwenApiService
|
import { QwenApiService } from './openai/qwen-core.js'; // 导入QwenApiService
|
||||||
import { IFlowApiService } from './openai/iflow-core.js'; // 导入IFlowApiService
|
import { IFlowApiService } from './openai/iflow-core.js'; // 导入IFlowApiService
|
||||||
import { CodexApiService } from './openai/codex-core.js'; // 导入CodexApiService
|
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
|
import { MODEL_PROVIDER } from '../utils/common.js'; // 导入 MODEL_PROVIDER
|
||||||
|
|
||||||
// 定义AI服务适配器接口
|
// 定义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 = {};
|
export const serviceInstances = {};
|
||||||
|
|
||||||
|
|
@ -669,6 +734,9 @@ export function getServiceAdapter(config) {
|
||||||
case MODEL_PROVIDER.CODEX_API:
|
case MODEL_PROVIDER.CODEX_API:
|
||||||
serviceInstances[providerKey] = new CodexApiServiceAdapter(config);
|
serviceInstances[providerKey] = new CodexApiServiceAdapter(config);
|
||||||
break;
|
break;
|
||||||
|
case MODEL_PROVIDER.LETTA_API:
|
||||||
|
serviceInstances[providerKey] = new LettaApiServiceAdapter(config);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported model provider: ${provider}`);
|
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.1-codex-max',
|
||||||
'gpt-5.2',
|
'gpt-5.2',
|
||||||
'gpt-5.2-codex'
|
'gpt-5.2-codex'
|
||||||
|
],
|
||||||
|
'openai-letta': [
|
||||||
|
'letta-agent'
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ export class ProviderPoolManager {
|
||||||
'openai-qwen-oauth': 'qwen3-coder-flash',
|
'openai-qwen-oauth': 'qwen3-coder-flash',
|
||||||
'openai-iflow': 'qwen3-coder-plus',
|
'openai-iflow': 'qwen3-coder-plus',
|
||||||
'openai-codex-oauth': 'gpt-5-codex-mini',
|
'openai-codex-oauth': 'gpt-5-codex-mini',
|
||||||
'openaiResponses-custom': 'gpt-4o-mini'
|
'openaiResponses-custom': 'gpt-4o-mini',
|
||||||
|
'openai-letta': 'letta-agent'
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(providerPools, options = {}) {
|
constructor(providerPools, options = {}) {
|
||||||
|
|
@ -100,6 +101,8 @@ export class ProviderPoolManager {
|
||||||
configPath = config.CODEX_OAUTH_CREDS_FILE_PATH;
|
configPath = config.CODEX_OAUTH_CREDS_FILE_PATH;
|
||||||
} else if (providerType.startsWith('claude-orchids')) {
|
} else if (providerType.startsWith('claude-orchids')) {
|
||||||
configPath = config.ORCHIDS_CREDS_FILE_PATH;
|
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}`);
|
// console.log(`Checking node ${providerStatus.uuid} (${providerType}) expiry date... configPath: ${configPath}`);
|
||||||
|
|
@ -108,32 +111,7 @@ export class ProviderPoolManager {
|
||||||
|
|
||||||
if (configPath && fs.existsSync(configPath)) {
|
if (configPath && fs.existsSync(configPath)) {
|
||||||
try {
|
try {
|
||||||
const fileContent = fs.readFileSync(configPath, 'utf8');
|
if (true) {
|
||||||
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) {
|
|
||||||
this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`);
|
this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`);
|
||||||
this._enqueueRefresh(providerType, providerStatus);
|
this._enqueueRefresh(providerType, providerStatus);
|
||||||
}
|
}
|
||||||
|
|
@ -1289,6 +1267,15 @@ export class ProviderPoolManager {
|
||||||
});
|
});
|
||||||
return requests;
|
return requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Letta 使用 OpenAI 协议(内部转换)
|
||||||
|
if (providerType === MODEL_PROVIDER.LETTA_API) {
|
||||||
|
requests.push({
|
||||||
|
messages: [baseMessage],
|
||||||
|
model: modelName
|
||||||
|
});
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
|
||||||
// 其他提供商(OpenAI、Claude、Qwen)使用标准 messages 格式
|
// 其他提供商(OpenAI、Claude、Qwen)使用标准 messages 格式
|
||||||
requests.push({
|
requests.push({
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@ async function startServer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize API services
|
// Initialize API services
|
||||||
const services = await initApiService(CONFIG);
|
const services = await initApiService(CONFIG, true);
|
||||||
|
|
||||||
// Initialize UI management features
|
// Initialize UI management features
|
||||||
initializeUIManagement(CONFIG);
|
initializeUIManagement(CONFIG);
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ async function scanProviderDirectory(dirPath, linkedPaths, newProviders, options
|
||||||
* @param {Object} config - The server configuration
|
* @param {Object} config - The server configuration
|
||||||
* @returns {Promise<Object>} The initialized services
|
* @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) {
|
if (config.providerPools && Object.keys(config.providerPools).length > 0) {
|
||||||
providerPoolManager = new ProviderPoolManager(config.providerPools, {
|
providerPoolManager = new ProviderPoolManager(config.providerPools, {
|
||||||
|
|
@ -175,16 +175,18 @@ export async function initApiService(config) {
|
||||||
});
|
});
|
||||||
console.log('[Initialization] ProviderPoolManager initialized with configured pools.');
|
console.log('[Initialization] ProviderPoolManager initialized with configured pools.');
|
||||||
|
|
||||||
// --- V2: 触发系统预热 ---
|
if(isReady){
|
||||||
// 预热逻辑是异步的,不会阻塞服务器启动
|
// --- V2: 触发系统预热 ---
|
||||||
providerPoolManager.warmupNodes().catch(err => {
|
// 预热逻辑是异步的,不会阻塞服务器启动
|
||||||
console.error(`[Initialization] Warmup failed: ${err.message}`);
|
providerPoolManager.warmupNodes().catch(err => {
|
||||||
});
|
console.error(`[Initialization] Warmup failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
// 检查并刷新即将过期的节点(异步调用,不阻塞启动)
|
// 检查并刷新即将过期的节点(异步调用,不阻塞启动)
|
||||||
providerPoolManager.checkAndRefreshExpiringNodes().catch(err => {
|
providerPoolManager.checkAndRefreshExpiringNodes().catch(err => {
|
||||||
console.error(`[Initialization] Check and refresh expiring nodes failed: ${err.message}`);
|
console.error(`[Initialization] Check and refresh expiring nodes failed: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 健康检查将在服务器完全启动后执行
|
// 健康检查将在服务器完全启动后执行
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -393,7 +395,8 @@ export async function getProviderStatus(config, options = {}) {
|
||||||
'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH',
|
'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH',
|
||||||
'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH',
|
'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH',
|
||||||
'gemini-antigravity': 'ANTIGRAVITY_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 providerPoolsSlim = [];
|
||||||
let unhealthyProvideIdentifyList = [];
|
let unhealthyProvideIdentifyList = [];
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
handleIFlowOAuth,
|
handleIFlowOAuth,
|
||||||
handleOrchidsOAuth,
|
handleOrchidsOAuth,
|
||||||
handleCodexOAuth,
|
handleCodexOAuth,
|
||||||
|
handleLettaOAuth,
|
||||||
batchImportKiroRefreshTokensStream,
|
batchImportKiroRefreshTokensStream,
|
||||||
importAwsCredentials,
|
importAwsCredentials,
|
||||||
importOrchidsToken
|
importOrchidsToken
|
||||||
|
|
@ -62,6 +63,11 @@ export async function handleGenerateAuthUrl(req, res, currentConfig, providerTyp
|
||||||
const result = await handleCodexOAuth(currentConfig, options);
|
const result = await handleCodexOAuth(currentConfig, options);
|
||||||
authUrl = result.authUrl;
|
authUrl = result.authUrl;
|
||||||
authInfo = result.authInfo;
|
authInfo = result.authInfo;
|
||||||
|
} else if (providerType === 'openai-letta') {
|
||||||
|
// Letta OAuth
|
||||||
|
const result = await handleLettaOAuth(currentConfig, options);
|
||||||
|
authUrl = result.authUrl;
|
||||||
|
authInfo = result.authInfo;
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -826,7 +826,7 @@ export async function handleQuickLinkProvider(req, res, currentConfig, providerP
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
error: {
|
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;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,8 @@ function getProviderDisplayName(provider, providerType) {
|
||||||
'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH',
|
'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH',
|
||||||
'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH',
|
'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH',
|
||||||
'openai-qwen-oauth': 'QWEN_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];
|
}[providerType];
|
||||||
|
|
||||||
if (credPathKey && provider[credPathKey]) {
|
if (credPathKey && provider[credPathKey]) {
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export const MODEL_PROVIDER = {
|
||||||
QWEN_API: 'openai-qwen-oauth',
|
QWEN_API: 'openai-qwen-oauth',
|
||||||
IFLOW_API: 'openai-iflow',
|
IFLOW_API: 'openai-iflow',
|
||||||
CODEX_API: 'openai-codex-oauth',
|
CODEX_API: 'openai-codex-oauth',
|
||||||
|
LETTA_API: 'openai-letta',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,17 @@ export const PROVIDER_MAPPINGS = [
|
||||||
displayName: 'OpenAI Codex OAuth',
|
displayName: 'OpenAI Codex OAuth',
|
||||||
needsProjectId: false,
|
needsProjectId: false,
|
||||||
urlKeys: ['CODEX_BASE_URL']
|
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',
|
'gemini-antigravity': 'antigravity',
|
||||||
'claude-kiro-oauth': 'kiro',
|
'claude-kiro-oauth': 'kiro',
|
||||||
'openai-qwen-oauth': 'qwen',
|
'openai-qwen-oauth': 'qwen',
|
||||||
'openai-iflow': 'iflow'
|
'openai-iflow': 'iflow',
|
||||||
|
'openai-letta': 'letta'
|
||||||
};
|
};
|
||||||
return providerMap[provider] || 'gemini';
|
return providerMap[provider] || 'gemini';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ const translations = {
|
||||||
'dashboard.routing.nodeName.iflow': 'iFlow OAuth',
|
'dashboard.routing.nodeName.iflow': 'iFlow OAuth',
|
||||||
'dashboard.routing.nodeName.orchids': 'Orchids OAuth',
|
'dashboard.routing.nodeName.orchids': 'Orchids OAuth',
|
||||||
'dashboard.routing.nodeName.codex': 'OpenAI Codex OAuth',
|
'dashboard.routing.nodeName.codex': 'OpenAI Codex OAuth',
|
||||||
|
'dashboard.routing.nodeName.letta': 'Letta API',
|
||||||
'dashboard.contact.title': '联系与赞助',
|
'dashboard.contact.title': '联系与赞助',
|
||||||
'dashboard.contact.wechat': '扫码进群,注明来意',
|
'dashboard.contact.wechat': '扫码进群,注明来意',
|
||||||
'dashboard.contact.wechatDesc': '添加微信获取更多技术支持和交流',
|
'dashboard.contact.wechatDesc': '添加微信获取更多技术支持和交流',
|
||||||
|
|
@ -338,6 +339,7 @@ const translations = {
|
||||||
'upload.providerFilter.orchids': 'Orchids OAuth',
|
'upload.providerFilter.orchids': 'Orchids OAuth',
|
||||||
'upload.providerFilter.codex': 'Codex OAuth',
|
'upload.providerFilter.codex': 'Codex OAuth',
|
||||||
'upload.providerFilter.iflow': 'iFlow OAuth',
|
'upload.providerFilter.iflow': 'iFlow OAuth',
|
||||||
|
'upload.providerFilter.letta': 'Letta API',
|
||||||
'upload.providerFilter.other': '其他/未识别',
|
'upload.providerFilter.other': '其他/未识别',
|
||||||
'upload.statusFilter': '关联状态',
|
'upload.statusFilter': '关联状态',
|
||||||
'upload.statusFilter.all': '全部状态',
|
'upload.statusFilter.all': '全部状态',
|
||||||
|
|
@ -574,6 +576,7 @@ const translations = {
|
||||||
'guide.providers.claude.desc': '使用 Claude 官方 API 或第三方代理访问 Claude 系列模型',
|
'guide.providers.claude.desc': '使用 Claude 官方 API 或第三方代理访问 Claude 系列模型',
|
||||||
'guide.providers.openai.desc': '使用 OpenAI 官方 API 或第三方代理访问 GPT 系列模型',
|
'guide.providers.openai.desc': '使用 OpenAI 官方 API 或第三方代理访问 GPT 系列模型',
|
||||||
'guide.providers.iflow.desc': '通过 iFlow OAuth 认证访问 Qwen、Kimi、DeepSeek、GLM 等模型',
|
'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.providers.orchids.desc': '通过 Orchids 平台免费使用 Claude Sonnet 4.5 等模型',
|
||||||
'guide.client.title': '客户端配置指南',
|
'guide.client.title': '客户端配置指南',
|
||||||
'guide.client.desc': '以下是常见 AI 客户端的配置方法,将 API 端点设置为本服务地址即可使用:',
|
'guide.client.desc': '以下是常见 AI 客户端的配置方法,将 API 端点设置为本服务地址即可使用:',
|
||||||
|
|
@ -804,6 +807,7 @@ const translations = {
|
||||||
'dashboard.routing.nodeName.iflow': 'iFlow OAuth',
|
'dashboard.routing.nodeName.iflow': 'iFlow OAuth',
|
||||||
'dashboard.routing.nodeName.orchids': 'Orchids OAuth',
|
'dashboard.routing.nodeName.orchids': 'Orchids OAuth',
|
||||||
'dashboard.routing.nodeName.codex': 'OpenAI Codex OAuth',
|
'dashboard.routing.nodeName.codex': 'OpenAI Codex OAuth',
|
||||||
|
'dashboard.routing.nodeName.letta': 'Letta API',
|
||||||
'dashboard.contact.title': 'Contact & Support',
|
'dashboard.contact.title': 'Contact & Support',
|
||||||
'dashboard.contact.wechat': 'Scan to Join Group',
|
'dashboard.contact.wechat': 'Scan to Join Group',
|
||||||
'dashboard.contact.wechatDesc': 'Add WeChat for more technical support and communication',
|
'dashboard.contact.wechatDesc': 'Add WeChat for more technical support and communication',
|
||||||
|
|
@ -1051,6 +1055,7 @@ const translations = {
|
||||||
'upload.providerFilter.orchids': 'Orchids OAuth',
|
'upload.providerFilter.orchids': 'Orchids OAuth',
|
||||||
'upload.providerFilter.codex': 'Codex OAuth',
|
'upload.providerFilter.codex': 'Codex OAuth',
|
||||||
'upload.providerFilter.iflow': 'iFlow OAuth',
|
'upload.providerFilter.iflow': 'iFlow OAuth',
|
||||||
|
'upload.providerFilter.letta': 'Letta API',
|
||||||
'upload.providerFilter.other': 'Other/Unknown',
|
'upload.providerFilter.other': 'Other/Unknown',
|
||||||
'upload.statusFilter': 'Association Status',
|
'upload.statusFilter': 'Association Status',
|
||||||
'upload.statusFilter.all': 'All 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.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.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.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.providers.orchids.desc': 'Free access to Claude Sonnet 4.5 via Orchids platform',
|
||||||
'guide.client.title': 'Client Configuration Guide',
|
'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:',
|
'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 field1Label = getFieldLabel(field1Key);
|
||||||
const field1Value = provider[field1Key];
|
const field1Value = provider[field1Key];
|
||||||
const field1IsPassword = field1Key.toLowerCase().includes('key') || field1Key.toLowerCase().includes('password');
|
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 field1DisplayValue = field1IsPassword && field1Value ? '••••••••' : (field1Value || '');
|
||||||
const field1Def = fieldConfigs.find(f => f.id === field1Key) || fieldConfigs.find(f => f.id.toUpperCase() === field1Key.toUpperCase()) || {};
|
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 field2Label = getFieldLabel(field2Key);
|
||||||
const field2Value = provider[field2Key];
|
const field2Value = provider[field2Key];
|
||||||
const field2IsPassword = field2Key.toLowerCase().includes('key') || field2Key.toLowerCase().includes('password');
|
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 field2DisplayValue = field2IsPassword && field2Value ? '••••••••' : (field2Value || '');
|
||||||
const field2Def = fieldConfigs.find(f => f.id === field2Key) || fieldConfigs.find(f => f.id.toUpperCase() === field2Key.toUpperCase()) || {};
|
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';
|
const isPassword1 = field1.type === 'password';
|
||||||
// 检查是否为OAuth凭据文件路径字段(兼容两种命名方式)
|
// 检查是否为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) {
|
if (isPassword1) {
|
||||||
fields += `
|
fields += `
|
||||||
|
|
@ -1181,7 +1181,7 @@ function addDynamicConfigFields(form, providerType) {
|
||||||
// 检查是否为密码类型字段
|
// 检查是否为密码类型字段
|
||||||
const isPassword2 = field2.type === 'password';
|
const isPassword2 = field2.type === 'password';
|
||||||
// 检查是否为OAuth凭据文件路径字段(兼容两种命名方式)
|
// 检查是否为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) {
|
if (isPassword2) {
|
||||||
fields += `
|
fields += `
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,8 @@ function renderProviders(providers) {
|
||||||
'openai-qwen-oauth',
|
'openai-qwen-oauth',
|
||||||
'openaiResponses-custom',
|
'openaiResponses-custom',
|
||||||
'openai-iflow',
|
'openai-iflow',
|
||||||
'openai-codex-oauth'
|
'openai-codex-oauth',
|
||||||
|
'openai-letta'
|
||||||
];
|
];
|
||||||
|
|
||||||
// 获取所有提供商类型并按指定顺序排序
|
// 获取所有提供商类型并按指定顺序排序
|
||||||
|
|
@ -1638,7 +1639,8 @@ function getAuthFilePath(provider) {
|
||||||
'gemini-antigravity': '~/.antigravity/oauth_creds.json',
|
'gemini-antigravity': '~/.antigravity/oauth_creds.json',
|
||||||
'openai-qwen-oauth': '~/.qwen/oauth_creds.json',
|
'openai-qwen-oauth': '~/.qwen/oauth_creds.json',
|
||||||
'claude-kiro-oauth': '~/.aws/sso/cache/kiro-auth-token.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' : '未知路径');
|
return authFilePaths[provider] || (getCurrentLanguage() === 'en-US' ? 'Unknown Path' : '未知路径');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -861,6 +861,12 @@ function detectProviderFromPath(filePath) {
|
||||||
providerType: 'openai-iflow-oauth',
|
providerType: 'openai-iflow-oauth',
|
||||||
displayName: 'OpenAI iFlow OAuth',
|
displayName: 'OpenAI iFlow OAuth',
|
||||||
shortName: '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凭据文件路径',
|
'QWEN_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径',
|
||||||
'ANTIGRAVITY_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凭据文件路径',
|
'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',
|
'GEMINI_BASE_URL': 'Gemini Base URL',
|
||||||
'KIRO_BASE_URL': 'Base URL',
|
'KIRO_BASE_URL': 'Base URL',
|
||||||
'KIRO_REFRESH_URL': 'Refresh URL',
|
'KIRO_REFRESH_URL': 'Refresh URL',
|
||||||
|
|
@ -91,7 +92,9 @@ function getFieldLabel(key) {
|
||||||
'QWEN_OAUTH_BASE_URL': 'OAuth Base URL',
|
'QWEN_OAUTH_BASE_URL': 'OAuth Base URL',
|
||||||
'ANTIGRAVITY_BASE_URL_DAILY': 'Daily Base URL',
|
'ANTIGRAVITY_BASE_URL_DAILY': 'Daily Base URL',
|
||||||
'ANTIGRAVITY_BASE_URL_AUTOPUSH': 'Autopush 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;
|
return labelMap[key] || key;
|
||||||
|
|
@ -272,6 +275,26 @@ function getProviderTypeFields(providerType) {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
placeholder: 'https://api.openai.com/v1/codex'
|
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>
|
<i class="fas fa-code"></i>
|
||||||
<span>OpenAI Codex OAuth</span>
|
<span>OpenAI Codex OAuth</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="provider-tag" data-value="openai-letta">
|
||||||
|
<i class="fas fa-microchip"></i>
|
||||||
|
<span>Letta API</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="form-text" data-i18n="config.modelProviderHelp">点击选择启动时初始化的模型提供商 (必须至少选择一个)</small>
|
<small class="form-text" data-i18n="config.modelProviderHelp">点击选择启动时初始化的模型提供商 (必须至少选择一个)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -125,6 +129,10 @@
|
||||||
<i class="fas fa-code"></i>
|
<i class="fas fa-code"></i>
|
||||||
<span>OpenAI Codex OAuth</span>
|
<span>OpenAI Codex OAuth</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="provider-tag" data-value="openai-letta">
|
||||||
|
<i class="fas fa-microchip"></i>
|
||||||
|
<span>Letta API</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="form-text" data-i18n="config.proxy.enabledProvidersNote">点击选择需要通过代理访问的提供商,未选中的提供商将直接连接</small>
|
<small class="form-text" data-i18n="config.proxy.enabledProvidersNote">点击选择需要通过代理访问的提供商,未选中的提供商将直接连接</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
<option value="claude-orchids-oauth" data-i18n="upload.providerFilter.orchids">Orchids OAuth</option>
|
<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-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-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>
|
<option value="other" data-i18n="upload.providerFilter.other">其他/未识别</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue