opencode-studio-server 1.3.7 → 1.3.9

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 +349 -11
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -69,6 +69,159 @@ 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();
224
+ importExistingAuth();
72
225
 
73
226
  let pendingActionMemory = null;
74
227
 
@@ -576,6 +729,7 @@ app.get('/api/auth/providers', (req, res) => {
576
729
  });
577
730
 
578
731
  app.get('/api/auth', (req, res) => {
732
+ syncAntigravityPool();
579
733
  const authCfg = loadAuthConfig() || {};
580
734
  const studio = loadStudioConfig();
581
735
  const ac = studio.activeProfiles || {};
@@ -646,6 +800,7 @@ app.get('/api/auth', (req, res) => {
646
800
  });
647
801
 
648
802
  app.get('/api/auth/profiles', (req, res) => {
803
+ syncAntigravityPool();
649
804
  const authCfg = loadAuthConfig() || {};
650
805
  const studio = loadStudioConfig();
651
806
  const ac = studio.activeProfiles || {};
@@ -798,6 +953,13 @@ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
798
953
  const newPath = path.join(AUTH_PROFILES_DIR, namespace, `${newName}.json`);
799
954
  if (fs.existsSync(oldPath)) fs.renameSync(oldPath, newPath);
800
955
 
956
+ const metadata = loadPoolMetadata();
957
+ if (metadata[namespace]?.[name]) {
958
+ metadata[namespace][newName] = metadata[namespace][name];
959
+ delete metadata[namespace][name];
960
+ savePoolMetadata(metadata);
961
+ }
962
+
801
963
  // Update active profile name if it was the one renamed
802
964
  const studio = loadStudioConfig();
803
965
  if (studio.activeProfiles && studio.activeProfiles[provider] === name) {
@@ -913,6 +1075,10 @@ app.delete('/api/auth/:provider', (req, res) => {
913
1075
  delete metadata['google.gemini'];
914
1076
  delete metadata['google.antigravity'];
915
1077
  savePoolMetadata(metadata);
1078
+
1079
+ if (activePlugin === 'antigravity' && fs.existsSync(ANTIGRAVITY_ACCOUNTS_PATH)) {
1080
+ fs.rmSync(ANTIGRAVITY_ACCOUNTS_PATH, { force: true });
1081
+ }
916
1082
  } else {
917
1083
  delete authCfg[provider];
918
1084
  if (studio.activeProfiles) delete studio.activeProfiles[provider];
@@ -959,6 +1125,138 @@ function savePoolMetadata(metadata) {
959
1125
  atomicWriteFileSync(POOL_METADATA_FILE, JSON.stringify(metadata, null, 2));
960
1126
  }
961
1127
 
1128
+ function loadAntigravityAccounts() {
1129
+ if (!fs.existsSync(ANTIGRAVITY_ACCOUNTS_PATH)) return null;
1130
+ try {
1131
+ return JSON.parse(fs.readFileSync(ANTIGRAVITY_ACCOUNTS_PATH, 'utf8'));
1132
+ } catch {
1133
+ return null;
1134
+ }
1135
+ }
1136
+
1137
+ function listAntigravityAccounts() {
1138
+ const data = loadAntigravityAccounts();
1139
+ if (!data?.accounts || !Array.isArray(data.accounts)) return [];
1140
+ return data.accounts.map(a => ({
1141
+ email: a.email || null,
1142
+ refreshToken: a.refreshToken || null
1143
+ }));
1144
+ }
1145
+
1146
+ function syncAntigravityPool() {
1147
+ const accounts = listAntigravityAccounts();
1148
+ const namespace = 'google.antigravity';
1149
+ const profileDir = path.join(AUTH_PROFILES_DIR, namespace);
1150
+
1151
+ if (!accounts.length) {
1152
+ if (fs.existsSync(profileDir)) fs.rmSync(profileDir, { recursive: true, force: true });
1153
+ const metadata = loadPoolMetadata();
1154
+ if (metadata[namespace]) {
1155
+ delete metadata[namespace];
1156
+ savePoolMetadata(metadata);
1157
+ }
1158
+ return;
1159
+ }
1160
+
1161
+ if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
1162
+
1163
+ const metadata = loadPoolMetadata();
1164
+ if (!metadata[namespace]) metadata[namespace] = {};
1165
+
1166
+ const seen = new Set();
1167
+ accounts.forEach((account, idx) => {
1168
+ const name = account.email || `account-${idx + 1}`;
1169
+ seen.add(name);
1170
+ const profilePath = path.join(profileDir, `${name}.json`);
1171
+ if (!fs.existsSync(profilePath)) {
1172
+ atomicWriteFileSync(profilePath, JSON.stringify({ email: account.email }, null, 2));
1173
+ }
1174
+ if (!metadata[namespace][name]) {
1175
+ metadata[namespace][name] = {
1176
+ email: account.email || null,
1177
+ createdAt: Date.now(),
1178
+ lastUsed: 0,
1179
+ usageCount: 0
1180
+ };
1181
+ }
1182
+ });
1183
+
1184
+ const profileFiles = fs.existsSync(profileDir) ? fs.readdirSync(profileDir).filter(f => f.endsWith('.json')) : [];
1185
+ profileFiles.forEach(file => {
1186
+ const name = file.replace('.json', '');
1187
+ if (!seen.has(name)) {
1188
+ fs.unlinkSync(path.join(profileDir, file));
1189
+ if (metadata[namespace]?.[name]) delete metadata[namespace][name];
1190
+ }
1191
+ });
1192
+
1193
+ savePoolMetadata(metadata);
1194
+ }
1195
+
1196
+ function importExistingAuth() {
1197
+ const authCfg = loadAuthConfig();
1198
+ if (!authCfg) return;
1199
+
1200
+ const studio = loadStudioConfig();
1201
+ const activeProfiles = studio.activeProfiles || {};
1202
+ let changed = false;
1203
+
1204
+ // Standard providers to check
1205
+ const providers = ['google', 'openai', 'anthropic', 'xai', 'openrouter', 'github-copilot', 'mistral', 'deepseek', 'amazon-bedrock', 'azure'];
1206
+
1207
+ providers.forEach(provider => {
1208
+ if (!authCfg[provider]) return; // No creds for this provider
1209
+
1210
+ // Determine namespace
1211
+ // For auto-import, we target the standard 'google' namespace unless antigravity plugin is active?
1212
+ // Actually, auth.json 'google' key usually means Gemini/Vertex standard auth.
1213
+ const namespace = provider === 'google' && studio.activeGooglePlugin === 'antigravity'
1214
+ ? 'google.antigravity'
1215
+ : (provider === 'google' ? 'google.gemini' : provider);
1216
+
1217
+ const profileDir = path.join(AUTH_PROFILES_DIR, namespace);
1218
+
1219
+ // If we already have an active profile for this provider, skip import
1220
+ if (activeProfiles[provider]) return;
1221
+
1222
+ // If directory exists and has files, check if empty
1223
+ if (fs.existsSync(profileDir) && fs.readdirSync(profileDir).filter(f => f.endsWith('.json')).length > 0) {
1224
+ return;
1225
+ }
1226
+
1227
+ // Import!
1228
+ if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
1229
+
1230
+ const email = authCfg[provider].email || null;
1231
+ const name = email || `imported-${Date.now()}`;
1232
+ const profilePath = path.join(profileDir, `${name}.json`);
1233
+
1234
+ console.log(`[AutoImport] Importing existing ${provider} credentials as ${name}`);
1235
+ atomicWriteFileSync(profilePath, JSON.stringify(authCfg[provider], null, 2));
1236
+
1237
+ // Set as active
1238
+ if (!studio.activeProfiles) studio.activeProfiles = {};
1239
+ studio.activeProfiles[provider] = name;
1240
+ changed = true;
1241
+
1242
+ // Update metadata
1243
+ const metadata = loadPoolMetadata();
1244
+ if (!metadata[namespace]) metadata[namespace] = {};
1245
+ metadata[namespace][name] = {
1246
+ email: email,
1247
+ createdAt: Date.now(),
1248
+ lastUsed: Date.now(),
1249
+ usageCount: 0,
1250
+ imported: true
1251
+ };
1252
+ savePoolMetadata(metadata);
1253
+ });
1254
+
1255
+ if (changed) {
1256
+ saveStudioConfig(studio);
1257
+ }
1258
+ }
1259
+
962
1260
  function getAccountStatus(meta, now) {
963
1261
  if (!meta) return 'ready';
964
1262
  if (meta.cooldownUntil && meta.cooldownUntil > now) return 'cooldown';
@@ -1029,12 +1327,7 @@ function buildAccountPool(provider) {
1029
1327
  };
1030
1328
  }
1031
1329
 
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)
1330
+ function getPoolQuota(provider, pool) {
1038
1331
  const metadata = loadPoolMetadata();
1039
1332
  const activePlugin = getActiveGooglePlugin();
1040
1333
  const namespace = provider === 'google'
@@ -1050,7 +1343,7 @@ app.get('/api/auth/pool', (req, res) => {
1050
1343
  const remaining = Math.max(0, dailyLimit - todayUsage);
1051
1344
  const percentage = Math.round((remaining / dailyLimit) * 100);
1052
1345
 
1053
- const quota = {
1346
+ return {
1054
1347
  dailyLimit,
1055
1348
  remaining,
1056
1349
  used: todayUsage,
@@ -1063,7 +1356,14 @@ app.get('/api/auth/pool', (req, res) => {
1063
1356
  limit: Math.floor(dailyLimit / Math.max(1, pool.totalAccounts))
1064
1357
  }))
1065
1358
  };
1066
-
1359
+ }
1360
+
1361
+ // GET /api/auth/pool - Get account pool for Google (or specified provider)
1362
+ app.get('/api/auth/pool', (req, res) => {
1363
+ const provider = req.query.provider || 'google';
1364
+ syncAntigravityPool();
1365
+ const pool = buildAccountPool(provider);
1366
+ const quota = getPoolQuota(provider, pool);
1067
1367
  res.json({ pool, quota });
1068
1368
  });
1069
1369
 
@@ -1143,6 +1443,31 @@ app.post('/api/auth/pool/rotate', (req, res) => {
1143
1443
  });
1144
1444
  });
1145
1445
 
1446
+ // POST /api/auth/pool/limit - Set daily quota limit
1447
+ app.post('/api/auth/pool/limit', (req, res) => {
1448
+ const { provider, limit } = req.body;
1449
+
1450
+ if (!provider || typeof limit !== 'number' || limit < 0) {
1451
+ return res.status(400).json({ error: 'Invalid provider or limit' });
1452
+ }
1453
+
1454
+ const activePlugin = getActiveGooglePlugin();
1455
+ const namespace = provider === 'google'
1456
+ ? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
1457
+ : provider;
1458
+
1459
+ const metadata = loadPoolMetadata();
1460
+ if (!metadata._quota) metadata._quota = {};
1461
+ if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
1462
+
1463
+ metadata._quota[namespace].dailyLimit = limit;
1464
+ // Reset exhaustion if limit increased significantly? Maybe user wants to retry.
1465
+ // For now, simple update.
1466
+
1467
+ savePoolMetadata(metadata);
1468
+ res.json({ success: true, limit });
1469
+ });
1470
+
1146
1471
  // PUT /api/auth/pool/:name/cooldown - Mark account as in cooldown
1147
1472
  app.put('/api/auth/pool/:name/cooldown', (req, res) => {
1148
1473
  const { name } = req.params;
@@ -1483,6 +1808,14 @@ app.get('/api/auth/google/plugin', (req, res) => {
1483
1808
 
1484
1809
  const GEMINI_CLIENT_ID = process.env.GEMINI_CLIENT_ID || "";
1485
1810
  const GEMINI_CLIENT_SECRET = process.env.GEMINI_CLIENT_SECRET || "";
1811
+
1812
+ function getGeminiClientId() {
1813
+ if (GEMINI_CLIENT_ID) return GEMINI_CLIENT_ID;
1814
+ const opencodeCfg = loadConfig();
1815
+ const oauth = opencodeCfg?.mcp?.google?.oauth;
1816
+ return oauth?.clientId || "";
1817
+ }
1818
+
1486
1819
  const GEMINI_SCOPES = [
1487
1820
  "https://www.googleapis.com/auth/cloud-platform",
1488
1821
  "https://www.googleapis.com/auth/userinfo.email",
@@ -1508,14 +1841,19 @@ app.post('/api/auth/google/start', async (req, res) => {
1508
1841
  if (oauthCallbackServer) {
1509
1842
  return res.status(400).json({ error: 'OAuth flow already in progress' });
1510
1843
  }
1511
-
1844
+
1845
+ const clientId = getGeminiClientId();
1846
+ if (!clientId) {
1847
+ return res.status(400).json({ error: 'Missing Gemini OAuth client_id. Set GEMINI_CLIENT_ID or mcp.google.oauth.clientId.' });
1848
+ }
1849
+
1512
1850
  const { verifier, challenge } = generatePKCE();
1513
1851
  const state = encodeOAuthState({ verifier });
1514
1852
 
1515
1853
  pendingOAuthState = { verifier, status: 'pending', startedAt: Date.now() };
1516
1854
 
1517
1855
  const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
1518
- authUrl.searchParams.set('client_id', GEMINI_CLIENT_ID);
1856
+ authUrl.searchParams.set('client_id', clientId);
1519
1857
  authUrl.searchParams.set('response_type', 'code');
1520
1858
  authUrl.searchParams.set('redirect_uri', GEMINI_REDIRECT_URI);
1521
1859
  authUrl.searchParams.set('scope', GEMINI_SCOPES.join(' '));
@@ -1549,7 +1887,7 @@ app.post('/api/auth/google/start', async (req, res) => {
1549
1887
  method: 'POST',
1550
1888
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1551
1889
  body: new URLSearchParams({
1552
- client_id: GEMINI_CLIENT_ID,
1890
+ client_id: clientId,
1553
1891
  client_secret: GEMINI_CLIENT_SECRET,
1554
1892
  code,
1555
1893
  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.9",
4
4
  "description": "Backend server for OpenCode Studio - manages opencode configurations",
5
5
  "main": "index.js",
6
6
  "bin": {