opencode-studio-server 1.3.7 → 1.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +284 -11
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -69,6 +69,158 @@ app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
|
|
69
69
|
const HOME_DIR = os.homedir();
|
|
70
70
|
const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
|
|
71
71
|
const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
|
|
72
|
+
const ANTIGRAVITY_ACCOUNTS_PATH = path.join(HOME_DIR, '.config', 'opencode', 'antigravity-accounts.json');
|
|
73
|
+
const LOG_DIR = path.join(HOME_DIR, '.local', 'share', 'opencode', 'log');
|
|
74
|
+
|
|
75
|
+
let logWatcher = null;
|
|
76
|
+
let currentLogFile = null;
|
|
77
|
+
let logReadStream = null;
|
|
78
|
+
|
|
79
|
+
function setupLogWatcher() {
|
|
80
|
+
if (!fs.existsSync(LOG_DIR)) return;
|
|
81
|
+
|
|
82
|
+
// Find latest log file
|
|
83
|
+
const getLatestLog = () => {
|
|
84
|
+
try {
|
|
85
|
+
const files = fs.readdirSync(LOG_DIR)
|
|
86
|
+
.filter(f => f.endsWith('.log'))
|
|
87
|
+
.map(f => ({ name: f, time: fs.statSync(path.join(LOG_DIR, f)).mtime.getTime() }))
|
|
88
|
+
.sort((a, b) => b.time - a.time);
|
|
89
|
+
return files[0] ? path.join(LOG_DIR, files[0].name) : null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const startTailing = (filePath) => {
|
|
96
|
+
if (currentLogFile === filePath) return;
|
|
97
|
+
if (logReadStream) logReadStream.destroy();
|
|
98
|
+
|
|
99
|
+
currentLogFile = filePath;
|
|
100
|
+
console.log(`Watching log file: ${filePath}`);
|
|
101
|
+
|
|
102
|
+
let fileSize = 0;
|
|
103
|
+
try {
|
|
104
|
+
fileSize = fs.statSync(filePath).size;
|
|
105
|
+
} catch {}
|
|
106
|
+
|
|
107
|
+
// Tail from end initially
|
|
108
|
+
let start = Math.max(0, fileSize - 10000);
|
|
109
|
+
|
|
110
|
+
const checkFile = () => {
|
|
111
|
+
try {
|
|
112
|
+
const stat = fs.statSync(filePath);
|
|
113
|
+
if (stat.size > fileSize) {
|
|
114
|
+
const stream = fs.createReadStream(filePath, {
|
|
115
|
+
start: fileSize,
|
|
116
|
+
end: stat.size,
|
|
117
|
+
encoding: 'utf8'
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
stream.on('data', (chunk) => {
|
|
121
|
+
const lines = chunk.split('\n');
|
|
122
|
+
lines.forEach(processLogLine);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
fileSize = stat.size;
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// File might be rotated/deleted
|
|
129
|
+
if (e.code === 'ENOENT') {
|
|
130
|
+
clearInterval(tailInterval);
|
|
131
|
+
const newLog = getLatestLog();
|
|
132
|
+
if (newLog && newLog !== currentLogFile) startTailing(newLog);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const tailInterval = setInterval(checkFile, 2000);
|
|
138
|
+
logWatcher = tailInterval;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const initialLog = getLatestLog();
|
|
142
|
+
if (initialLog) startTailing(initialLog);
|
|
143
|
+
|
|
144
|
+
// Watch dir for new logs
|
|
145
|
+
fs.watch(LOG_DIR, (eventType, filename) => {
|
|
146
|
+
if (filename && filename.endsWith('.log')) {
|
|
147
|
+
const latest = getLatestLog();
|
|
148
|
+
if (latest && latest !== currentLogFile) {
|
|
149
|
+
if (logWatcher) clearInterval(logWatcher);
|
|
150
|
+
startTailing(latest);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function processLogLine(line) {
|
|
157
|
+
// Detect LLM usage: service=llm providerID=... modelID=...
|
|
158
|
+
// Example: service=llm providerID=openai modelID=gpt-5.2-codex sessionID=...
|
|
159
|
+
const isUsage = line.includes('service=llm') && line.includes('stream');
|
|
160
|
+
const isError = line.includes('service=llm') && (line.includes('error=') || line.includes('status=429'));
|
|
161
|
+
|
|
162
|
+
if (!isUsage && !isError) return;
|
|
163
|
+
|
|
164
|
+
const providerMatch = line.match(/providerID=([^\s]+)/);
|
|
165
|
+
const modelMatch = line.match(/modelID=([^\s]+)/);
|
|
166
|
+
|
|
167
|
+
if (providerMatch) {
|
|
168
|
+
const provider = providerMatch[1];
|
|
169
|
+
const model = modelMatch ? modelMatch[1] : 'unknown';
|
|
170
|
+
|
|
171
|
+
// Map provider to pool namespace
|
|
172
|
+
let namespace = provider;
|
|
173
|
+
if (provider === 'google') {
|
|
174
|
+
const activePlugin = getActiveGooglePlugin();
|
|
175
|
+
namespace = activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const metadata = loadPoolMetadata();
|
|
179
|
+
if (!metadata._quota) metadata._quota = {};
|
|
180
|
+
if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
|
|
181
|
+
|
|
182
|
+
const today = new Date().toISOString().split('T')[0];
|
|
183
|
+
|
|
184
|
+
if (isUsage) {
|
|
185
|
+
// Increment usage
|
|
186
|
+
metadata._quota[namespace][today] = (metadata._quota[namespace][today] || 0) + 1;
|
|
187
|
+
|
|
188
|
+
// Also update active account usage if possible
|
|
189
|
+
const studio = loadStudioConfig();
|
|
190
|
+
const activeProfile = studio.activeProfiles?.[provider];
|
|
191
|
+
if (activeProfile && metadata[namespace]?.[activeProfile]) {
|
|
192
|
+
metadata[namespace][activeProfile].usageCount = (metadata[namespace][activeProfile].usageCount || 0) + 1;
|
|
193
|
+
metadata[namespace][activeProfile].lastUsed = Date.now();
|
|
194
|
+
}
|
|
195
|
+
} else if (isError) {
|
|
196
|
+
// Check for quota exhaustion patterns
|
|
197
|
+
const errorMsg = line.match(/error=(.+)/)?.[1] || '';
|
|
198
|
+
const isQuotaError = line.includes('status=429') ||
|
|
199
|
+
errorMsg.toLowerCase().includes('quota') ||
|
|
200
|
+
errorMsg.toLowerCase().includes('rate limit');
|
|
201
|
+
|
|
202
|
+
if (isQuotaError) {
|
|
203
|
+
console.log(`[LogWatcher] Detected quota exhaustion for ${namespace}`);
|
|
204
|
+
// Mark exhausted for today
|
|
205
|
+
metadata._quota[namespace].exhausted = true;
|
|
206
|
+
metadata._quota[namespace].exhaustedDate = today;
|
|
207
|
+
|
|
208
|
+
// Adaptive limit learning: if we hit a limit, maybe that's the ceiling?
|
|
209
|
+
// Only update if we have meaningful usage (>5) to avoid false positives on glitches
|
|
210
|
+
const currentUsage = metadata._quota[namespace][today] || 0;
|
|
211
|
+
if (currentUsage > 5) {
|
|
212
|
+
// Update daily limit to current usage (maybe round up)
|
|
213
|
+
metadata._quota[namespace].dailyLimit = currentUsage;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
savePoolMetadata(metadata);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Start watcher on server start
|
|
223
|
+
setupLogWatcher();
|
|
72
224
|
|
|
73
225
|
let pendingActionMemory = null;
|
|
74
226
|
|
|
@@ -576,6 +728,7 @@ app.get('/api/auth/providers', (req, res) => {
|
|
|
576
728
|
});
|
|
577
729
|
|
|
578
730
|
app.get('/api/auth', (req, res) => {
|
|
731
|
+
syncAntigravityPool();
|
|
579
732
|
const authCfg = loadAuthConfig() || {};
|
|
580
733
|
const studio = loadStudioConfig();
|
|
581
734
|
const ac = studio.activeProfiles || {};
|
|
@@ -646,6 +799,7 @@ app.get('/api/auth', (req, res) => {
|
|
|
646
799
|
});
|
|
647
800
|
|
|
648
801
|
app.get('/api/auth/profiles', (req, res) => {
|
|
802
|
+
syncAntigravityPool();
|
|
649
803
|
const authCfg = loadAuthConfig() || {};
|
|
650
804
|
const studio = loadStudioConfig();
|
|
651
805
|
const ac = studio.activeProfiles || {};
|
|
@@ -798,6 +952,13 @@ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
|
|
|
798
952
|
const newPath = path.join(AUTH_PROFILES_DIR, namespace, `${newName}.json`);
|
|
799
953
|
if (fs.existsSync(oldPath)) fs.renameSync(oldPath, newPath);
|
|
800
954
|
|
|
955
|
+
const metadata = loadPoolMetadata();
|
|
956
|
+
if (metadata[namespace]?.[name]) {
|
|
957
|
+
metadata[namespace][newName] = metadata[namespace][name];
|
|
958
|
+
delete metadata[namespace][name];
|
|
959
|
+
savePoolMetadata(metadata);
|
|
960
|
+
}
|
|
961
|
+
|
|
801
962
|
// Update active profile name if it was the one renamed
|
|
802
963
|
const studio = loadStudioConfig();
|
|
803
964
|
if (studio.activeProfiles && studio.activeProfiles[provider] === name) {
|
|
@@ -913,6 +1074,10 @@ app.delete('/api/auth/:provider', (req, res) => {
|
|
|
913
1074
|
delete metadata['google.gemini'];
|
|
914
1075
|
delete metadata['google.antigravity'];
|
|
915
1076
|
savePoolMetadata(metadata);
|
|
1077
|
+
|
|
1078
|
+
if (activePlugin === 'antigravity' && fs.existsSync(ANTIGRAVITY_ACCOUNTS_PATH)) {
|
|
1079
|
+
fs.rmSync(ANTIGRAVITY_ACCOUNTS_PATH, { force: true });
|
|
1080
|
+
}
|
|
916
1081
|
} else {
|
|
917
1082
|
delete authCfg[provider];
|
|
918
1083
|
if (studio.activeProfiles) delete studio.activeProfiles[provider];
|
|
@@ -959,6 +1124,74 @@ function savePoolMetadata(metadata) {
|
|
|
959
1124
|
atomicWriteFileSync(POOL_METADATA_FILE, JSON.stringify(metadata, null, 2));
|
|
960
1125
|
}
|
|
961
1126
|
|
|
1127
|
+
function loadAntigravityAccounts() {
|
|
1128
|
+
if (!fs.existsSync(ANTIGRAVITY_ACCOUNTS_PATH)) return null;
|
|
1129
|
+
try {
|
|
1130
|
+
return JSON.parse(fs.readFileSync(ANTIGRAVITY_ACCOUNTS_PATH, 'utf8'));
|
|
1131
|
+
} catch {
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function listAntigravityAccounts() {
|
|
1137
|
+
const data = loadAntigravityAccounts();
|
|
1138
|
+
if (!data?.accounts || !Array.isArray(data.accounts)) return [];
|
|
1139
|
+
return data.accounts.map(a => ({
|
|
1140
|
+
email: a.email || null,
|
|
1141
|
+
refreshToken: a.refreshToken || null
|
|
1142
|
+
}));
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function syncAntigravityPool() {
|
|
1146
|
+
const accounts = listAntigravityAccounts();
|
|
1147
|
+
const namespace = 'google.antigravity';
|
|
1148
|
+
const profileDir = path.join(AUTH_PROFILES_DIR, namespace);
|
|
1149
|
+
|
|
1150
|
+
if (!accounts.length) {
|
|
1151
|
+
if (fs.existsSync(profileDir)) fs.rmSync(profileDir, { recursive: true, force: true });
|
|
1152
|
+
const metadata = loadPoolMetadata();
|
|
1153
|
+
if (metadata[namespace]) {
|
|
1154
|
+
delete metadata[namespace];
|
|
1155
|
+
savePoolMetadata(metadata);
|
|
1156
|
+
}
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
|
|
1161
|
+
|
|
1162
|
+
const metadata = loadPoolMetadata();
|
|
1163
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
1164
|
+
|
|
1165
|
+
const seen = new Set();
|
|
1166
|
+
accounts.forEach((account, idx) => {
|
|
1167
|
+
const name = account.email || `account-${idx + 1}`;
|
|
1168
|
+
seen.add(name);
|
|
1169
|
+
const profilePath = path.join(profileDir, `${name}.json`);
|
|
1170
|
+
if (!fs.existsSync(profilePath)) {
|
|
1171
|
+
atomicWriteFileSync(profilePath, JSON.stringify({ email: account.email }, null, 2));
|
|
1172
|
+
}
|
|
1173
|
+
if (!metadata[namespace][name]) {
|
|
1174
|
+
metadata[namespace][name] = {
|
|
1175
|
+
email: account.email || null,
|
|
1176
|
+
createdAt: Date.now(),
|
|
1177
|
+
lastUsed: 0,
|
|
1178
|
+
usageCount: 0
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
const profileFiles = fs.existsSync(profileDir) ? fs.readdirSync(profileDir).filter(f => f.endsWith('.json')) : [];
|
|
1184
|
+
profileFiles.forEach(file => {
|
|
1185
|
+
const name = file.replace('.json', '');
|
|
1186
|
+
if (!seen.has(name)) {
|
|
1187
|
+
fs.unlinkSync(path.join(profileDir, file));
|
|
1188
|
+
if (metadata[namespace]?.[name]) delete metadata[namespace][name];
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
savePoolMetadata(metadata);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
962
1195
|
function getAccountStatus(meta, now) {
|
|
963
1196
|
if (!meta) return 'ready';
|
|
964
1197
|
if (meta.cooldownUntil && meta.cooldownUntil > now) return 'cooldown';
|
|
@@ -1029,12 +1262,7 @@ function buildAccountPool(provider) {
|
|
|
1029
1262
|
};
|
|
1030
1263
|
}
|
|
1031
1264
|
|
|
1032
|
-
|
|
1033
|
-
app.get('/api/auth/pool', (req, res) => {
|
|
1034
|
-
const provider = req.query.provider || 'google';
|
|
1035
|
-
const pool = buildAccountPool(provider);
|
|
1036
|
-
|
|
1037
|
-
// Also include quota estimate (local tracking)
|
|
1265
|
+
function getPoolQuota(provider, pool) {
|
|
1038
1266
|
const metadata = loadPoolMetadata();
|
|
1039
1267
|
const activePlugin = getActiveGooglePlugin();
|
|
1040
1268
|
const namespace = provider === 'google'
|
|
@@ -1050,7 +1278,7 @@ app.get('/api/auth/pool', (req, res) => {
|
|
|
1050
1278
|
const remaining = Math.max(0, dailyLimit - todayUsage);
|
|
1051
1279
|
const percentage = Math.round((remaining / dailyLimit) * 100);
|
|
1052
1280
|
|
|
1053
|
-
|
|
1281
|
+
return {
|
|
1054
1282
|
dailyLimit,
|
|
1055
1283
|
remaining,
|
|
1056
1284
|
used: todayUsage,
|
|
@@ -1063,7 +1291,14 @@ app.get('/api/auth/pool', (req, res) => {
|
|
|
1063
1291
|
limit: Math.floor(dailyLimit / Math.max(1, pool.totalAccounts))
|
|
1064
1292
|
}))
|
|
1065
1293
|
};
|
|
1066
|
-
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// GET /api/auth/pool - Get account pool for Google (or specified provider)
|
|
1297
|
+
app.get('/api/auth/pool', (req, res) => {
|
|
1298
|
+
const provider = req.query.provider || 'google';
|
|
1299
|
+
syncAntigravityPool();
|
|
1300
|
+
const pool = buildAccountPool(provider);
|
|
1301
|
+
const quota = getPoolQuota(provider, pool);
|
|
1067
1302
|
res.json({ pool, quota });
|
|
1068
1303
|
});
|
|
1069
1304
|
|
|
@@ -1143,6 +1378,31 @@ app.post('/api/auth/pool/rotate', (req, res) => {
|
|
|
1143
1378
|
});
|
|
1144
1379
|
});
|
|
1145
1380
|
|
|
1381
|
+
// POST /api/auth/pool/limit - Set daily quota limit
|
|
1382
|
+
app.post('/api/auth/pool/limit', (req, res) => {
|
|
1383
|
+
const { provider, limit } = req.body;
|
|
1384
|
+
|
|
1385
|
+
if (!provider || typeof limit !== 'number' || limit < 0) {
|
|
1386
|
+
return res.status(400).json({ error: 'Invalid provider or limit' });
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1390
|
+
const namespace = provider === 'google'
|
|
1391
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1392
|
+
: provider;
|
|
1393
|
+
|
|
1394
|
+
const metadata = loadPoolMetadata();
|
|
1395
|
+
if (!metadata._quota) metadata._quota = {};
|
|
1396
|
+
if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
|
|
1397
|
+
|
|
1398
|
+
metadata._quota[namespace].dailyLimit = limit;
|
|
1399
|
+
// Reset exhaustion if limit increased significantly? Maybe user wants to retry.
|
|
1400
|
+
// For now, simple update.
|
|
1401
|
+
|
|
1402
|
+
savePoolMetadata(metadata);
|
|
1403
|
+
res.json({ success: true, limit });
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1146
1406
|
// PUT /api/auth/pool/:name/cooldown - Mark account as in cooldown
|
|
1147
1407
|
app.put('/api/auth/pool/:name/cooldown', (req, res) => {
|
|
1148
1408
|
const { name } = req.params;
|
|
@@ -1483,6 +1743,14 @@ app.get('/api/auth/google/plugin', (req, res) => {
|
|
|
1483
1743
|
|
|
1484
1744
|
const GEMINI_CLIENT_ID = process.env.GEMINI_CLIENT_ID || "";
|
|
1485
1745
|
const GEMINI_CLIENT_SECRET = process.env.GEMINI_CLIENT_SECRET || "";
|
|
1746
|
+
|
|
1747
|
+
function getGeminiClientId() {
|
|
1748
|
+
if (GEMINI_CLIENT_ID) return GEMINI_CLIENT_ID;
|
|
1749
|
+
const opencodeCfg = loadConfig();
|
|
1750
|
+
const oauth = opencodeCfg?.mcp?.google?.oauth;
|
|
1751
|
+
return oauth?.clientId || "";
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1486
1754
|
const GEMINI_SCOPES = [
|
|
1487
1755
|
"https://www.googleapis.com/auth/cloud-platform",
|
|
1488
1756
|
"https://www.googleapis.com/auth/userinfo.email",
|
|
@@ -1508,14 +1776,19 @@ app.post('/api/auth/google/start', async (req, res) => {
|
|
|
1508
1776
|
if (oauthCallbackServer) {
|
|
1509
1777
|
return res.status(400).json({ error: 'OAuth flow already in progress' });
|
|
1510
1778
|
}
|
|
1511
|
-
|
|
1779
|
+
|
|
1780
|
+
const clientId = getGeminiClientId();
|
|
1781
|
+
if (!clientId) {
|
|
1782
|
+
return res.status(400).json({ error: 'Missing Gemini OAuth client_id. Set GEMINI_CLIENT_ID or mcp.google.oauth.clientId.' });
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1512
1785
|
const { verifier, challenge } = generatePKCE();
|
|
1513
1786
|
const state = encodeOAuthState({ verifier });
|
|
1514
1787
|
|
|
1515
1788
|
pendingOAuthState = { verifier, status: 'pending', startedAt: Date.now() };
|
|
1516
1789
|
|
|
1517
1790
|
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
1518
|
-
authUrl.searchParams.set('client_id',
|
|
1791
|
+
authUrl.searchParams.set('client_id', clientId);
|
|
1519
1792
|
authUrl.searchParams.set('response_type', 'code');
|
|
1520
1793
|
authUrl.searchParams.set('redirect_uri', GEMINI_REDIRECT_URI);
|
|
1521
1794
|
authUrl.searchParams.set('scope', GEMINI_SCOPES.join(' '));
|
|
@@ -1549,7 +1822,7 @@ app.post('/api/auth/google/start', async (req, res) => {
|
|
|
1549
1822
|
method: 'POST',
|
|
1550
1823
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1551
1824
|
body: new URLSearchParams({
|
|
1552
|
-
client_id:
|
|
1825
|
+
client_id: clientId,
|
|
1553
1826
|
client_secret: GEMINI_CLIENT_SECRET,
|
|
1554
1827
|
code,
|
|
1555
1828
|
grant_type: 'authorization_code',
|