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.
Files changed (2) hide show
  1. package/index.js +284 -11
  2. 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
- // GET /api/auth/pool - Get account pool for Google (or specified provider)
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
- const quota = {
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', GEMINI_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: GEMINI_CLIENT_ID,
1825
+ client_id: clientId,
1553
1826
  client_secret: GEMINI_CLIENT_SECRET,
1554
1827
  code,
1555
1828
  grant_type: 'authorization_code',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-studio-server",
3
- "version": "1.3.7",
3
+ "version": "1.3.8",
4
4
  "description": "Backend server for OpenCode Studio - manages opencode configurations",
5
5
  "main": "index.js",
6
6
  "bin": {