opencode-studio-server 1.3.6 → 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 +433 -30
  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 || {};
@@ -681,8 +835,25 @@ app.post('/api/auth/profiles/:provider', (req, res) => {
681
835
  const dir = path.join(AUTH_PROFILES_DIR, namespace);
682
836
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
683
837
 
684
- const profilePath = path.join(dir, `${name || Date.now()}.json`);
838
+ if (!auth[provider]) {
839
+ return res.status(400).json({ error: 'No current auth for provider' });
840
+ }
841
+
842
+ const profileName = name || auth[provider].email || `profile-${Date.now()}`;
843
+ const profilePath = path.join(dir, `${profileName}.json`);
685
844
  atomicWriteFileSync(profilePath, JSON.stringify(auth[provider], null, 2));
845
+
846
+ const metadata = loadPoolMetadata();
847
+ if (!metadata[namespace]) metadata[namespace] = {};
848
+ metadata[namespace][profileName] = {
849
+ ...(metadata[namespace][profileName] || {}),
850
+ email: auth[provider].email || metadata[namespace][profileName]?.email || null,
851
+ createdAt: metadata[namespace][profileName]?.createdAt || Date.now(),
852
+ lastUsed: Date.now(),
853
+ usageCount: metadata[namespace][profileName]?.usageCount || 0
854
+ };
855
+ savePoolMetadata(metadata);
856
+
686
857
  res.json({ success: true, name: path.basename(profilePath, '.json') });
687
858
  });
688
859
 
@@ -714,6 +885,22 @@ app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
714
885
  const cp = getConfigPath();
715
886
  const ap = path.join(path.dirname(cp), 'auth.json');
716
887
  atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
888
+
889
+ const metadata = loadPoolMetadata();
890
+ if (!metadata[namespace]) metadata[namespace] = {};
891
+ metadata[namespace][name] = {
892
+ ...(metadata[namespace][name] || {}),
893
+ email: profileData.email || metadata[namespace][name]?.email || null,
894
+ createdAt: metadata[namespace][name]?.createdAt || Date.now(),
895
+ lastUsed: Date.now(),
896
+ usageCount: (metadata[namespace][name]?.usageCount || 0) + 1
897
+ };
898
+ if (!metadata._quota) metadata._quota = {};
899
+ if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
900
+ const today = new Date().toISOString().split('T')[0];
901
+ metadata._quota[namespace][today] = (metadata._quota[namespace][today] || 0) + 1;
902
+ savePoolMetadata(metadata);
903
+
717
904
  res.json({ success: true });
718
905
  });
719
906
 
@@ -726,6 +913,30 @@ app.delete('/api/auth/profiles/:provider/:name', (req, res) => {
726
913
 
727
914
  const profilePath = path.join(AUTH_PROFILES_DIR, namespace, `${name}.json`);
728
915
  if (fs.existsSync(profilePath)) fs.unlinkSync(profilePath);
916
+
917
+ const studio = loadStudioConfig();
918
+ if (studio.activeProfiles && studio.activeProfiles[provider] === name) {
919
+ delete studio.activeProfiles[provider];
920
+ saveStudioConfig(studio);
921
+
922
+ const authCfg = loadAuthConfig() || {};
923
+ delete authCfg[provider];
924
+ if (provider === 'google') {
925
+ const key = activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini';
926
+ delete authCfg.google;
927
+ delete authCfg[key];
928
+ }
929
+ const cp = getConfigPath();
930
+ const ap = path.join(path.dirname(cp), 'auth.json');
931
+ atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
932
+ }
933
+
934
+ const metadata = loadPoolMetadata();
935
+ if (metadata[namespace]?.[name]) {
936
+ delete metadata[namespace][name];
937
+ savePoolMetadata(metadata);
938
+ }
939
+
729
940
  res.json({ success: true });
730
941
  });
731
942
 
@@ -741,6 +952,13 @@ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
741
952
  const newPath = path.join(AUTH_PROFILES_DIR, namespace, `${newName}.json`);
742
953
  if (fs.existsSync(oldPath)) fs.renameSync(oldPath, newPath);
743
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
+
744
962
  // Update active profile name if it was the one renamed
745
963
  const studio = loadStudioConfig();
746
964
  if (studio.activeProfiles && studio.activeProfiles[provider] === name) {
@@ -770,10 +988,13 @@ app.post('/api/auth/login', (req, res) => {
770
988
  let cmd = 'opencode auth login';
771
989
  if (provider) cmd += ` ${provider}`;
772
990
 
991
+ const cp = getConfigPath();
992
+ const configDir = cp ? path.dirname(cp) : process.cwd();
993
+ const safeDir = configDir.replace(/"/g, '\\"');
773
994
  const platform = process.platform;
774
995
 
775
996
  if (platform === 'win32') {
776
- const terminalCmd = `start "" cmd /c "call ${cmd} || pause"`;
997
+ const terminalCmd = `start "" /d "${safeDir}" cmd /c "call ${cmd} || pause"`;
777
998
  console.log('Executing terminal command:', terminalCmd);
778
999
  exec(terminalCmd, (err) => {
779
1000
  if (err) {
@@ -783,7 +1004,7 @@ app.post('/api/auth/login', (req, res) => {
783
1004
  res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
784
1005
  });
785
1006
  } else if (platform === 'darwin') {
786
- const terminalCmd = `osascript -e 'tell application "Terminal" to do script "${cmd}"'`;
1007
+ const terminalCmd = `osascript -e 'tell application "Terminal" to do script "cd ${safeDir} && ${cmd}"'`;
787
1008
  console.log('Executing terminal command:', terminalCmd);
788
1009
  exec(terminalCmd, (err) => {
789
1010
  if (err) {
@@ -794,11 +1015,11 @@ app.post('/api/auth/login', (req, res) => {
794
1015
  });
795
1016
  } else {
796
1017
  const linuxTerminals = [
797
- { name: 'x-terminal-emulator', cmd: `x-terminal-emulator -e "${cmd}"` },
798
- { name: 'gnome-terminal', cmd: `gnome-terminal -- bash -c "${cmd}; read -p 'Press Enter to close...'"` },
799
- { name: 'konsole', cmd: `konsole -e bash -c "${cmd}; read -p 'Press Enter to close...'"` },
800
- { name: 'xfce4-terminal', cmd: `xfce4-terminal -e "bash -c \\"${cmd}; read -p 'Press Enter to close...'\\"" ` },
801
- { name: 'xterm', cmd: `xterm -e "bash -c '${cmd}; read -p Press_Enter_to_close...'"` }
1018
+ { name: 'x-terminal-emulator', cmd: `x-terminal-emulator -e "bash -c 'cd ${safeDir} && ${cmd}'"` },
1019
+ { name: 'gnome-terminal', cmd: `gnome-terminal -- bash -c "cd ${safeDir} && ${cmd}; read -p 'Press Enter to close...'"` },
1020
+ { name: 'konsole', cmd: `konsole -e bash -c "cd ${safeDir} && ${cmd}; read -p 'Press Enter to close...'"` },
1021
+ { name: 'xfce4-terminal', cmd: `xfce4-terminal -e "bash -c \"cd ${safeDir} && ${cmd}; read -p 'Press Enter to close...'\"" ` },
1022
+ { name: 'xterm', cmd: `xterm -e "bash -c 'cd ${safeDir} && ${cmd}; read -p Press_Enter_to_close...'"` }
802
1023
  ];
803
1024
 
804
1025
  const tryTerminal = (index) => {
@@ -828,18 +1049,57 @@ app.post('/api/auth/login', (req, res) => {
828
1049
  }
829
1050
  });
830
1051
 
1052
+
831
1053
  app.delete('/api/auth/:provider', (req, res) => {
832
1054
  const { provider } = req.params;
833
1055
  const authCfg = loadAuthConfig() || {};
834
- delete authCfg[provider];
1056
+ const studio = loadStudioConfig();
1057
+ const activePlugin = studio.activeGooglePlugin;
1058
+
1059
+ if (provider === 'google') {
1060
+ delete authCfg.google;
1061
+ delete authCfg['google.gemini'];
1062
+ delete authCfg['google.antigravity'];
1063
+
1064
+ if (studio.activeProfiles) delete studio.activeProfiles.google;
1065
+ saveStudioConfig(studio);
1066
+
1067
+ const geminiDir = path.join(AUTH_PROFILES_DIR, 'google.gemini');
1068
+ const antiDir = path.join(AUTH_PROFILES_DIR, 'google.antigravity');
1069
+ [geminiDir, antiDir].forEach(dir => {
1070
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
1071
+ });
1072
+
1073
+ const metadata = loadPoolMetadata();
1074
+ delete metadata['google.gemini'];
1075
+ delete metadata['google.antigravity'];
1076
+ savePoolMetadata(metadata);
1077
+
1078
+ if (activePlugin === 'antigravity' && fs.existsSync(ANTIGRAVITY_ACCOUNTS_PATH)) {
1079
+ fs.rmSync(ANTIGRAVITY_ACCOUNTS_PATH, { force: true });
1080
+ }
1081
+ } else {
1082
+ delete authCfg[provider];
1083
+ if (studio.activeProfiles) delete studio.activeProfiles[provider];
1084
+ saveStudioConfig(studio);
1085
+
1086
+ const providerDir = path.join(AUTH_PROFILES_DIR, provider);
1087
+ if (fs.existsSync(providerDir)) fs.rmSync(providerDir, { recursive: true, force: true });
1088
+
1089
+ const metadata = loadPoolMetadata();
1090
+ delete metadata[provider];
1091
+ savePoolMetadata(metadata);
1092
+ }
1093
+
1094
+ if (provider === 'google' && activePlugin) {
1095
+ const key = activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini';
1096
+ delete authCfg[key];
1097
+ }
1098
+
835
1099
  const cp = getConfigPath();
836
1100
  const ap = path.join(path.dirname(cp), 'auth.json');
837
1101
  atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
838
-
839
- const studio = loadStudioConfig();
840
- if (studio.activeProfiles) delete studio.activeProfiles[provider];
841
- saveStudioConfig(studio);
842
-
1102
+
843
1103
  res.json({ success: true });
844
1104
  });
845
1105
 
@@ -864,6 +1124,74 @@ function savePoolMetadata(metadata) {
864
1124
  atomicWriteFileSync(POOL_METADATA_FILE, JSON.stringify(metadata, null, 2));
865
1125
  }
866
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
+
867
1195
  function getAccountStatus(meta, now) {
868
1196
  if (!meta) return 'ready';
869
1197
  if (meta.cooldownUntil && meta.cooldownUntil > now) return 'cooldown';
@@ -892,11 +1220,18 @@ function buildAccountPool(provider) {
892
1220
  files.forEach(file => {
893
1221
  const name = file.replace('.json', '');
894
1222
  const meta = providerMeta[name] || {};
895
- const status = name === activeProfile ? 'active' : getAccountStatus(meta, now);
1223
+ let profileEmail = null;
1224
+ try {
1225
+ const raw = fs.readFileSync(path.join(profileDir, file), 'utf8');
1226
+ const parsed = JSON.parse(raw);
1227
+ profileEmail = parsed?.email || null;
1228
+ } catch {}
1229
+ let status = getAccountStatus(meta, now);
1230
+ if (name === activeProfile && status === 'ready') status = 'active';
896
1231
 
897
1232
  profiles.push({
898
1233
  name,
899
- email: meta.email || null,
1234
+ email: meta.email || profileEmail || null,
900
1235
  status,
901
1236
  lastUsed: meta.lastUsed || 0,
902
1237
  usageCount: meta.usageCount || 0,
@@ -927,12 +1262,7 @@ function buildAccountPool(provider) {
927
1262
  };
928
1263
  }
929
1264
 
930
- // GET /api/auth/pool - Get account pool for Google (or specified provider)
931
- app.get('/api/auth/pool', (req, res) => {
932
- const provider = req.query.provider || 'google';
933
- const pool = buildAccountPool(provider);
934
-
935
- // Also include quota estimate (local tracking)
1265
+ function getPoolQuota(provider, pool) {
936
1266
  const metadata = loadPoolMetadata();
937
1267
  const activePlugin = getActiveGooglePlugin();
938
1268
  const namespace = provider === 'google'
@@ -948,7 +1278,7 @@ app.get('/api/auth/pool', (req, res) => {
948
1278
  const remaining = Math.max(0, dailyLimit - todayUsage);
949
1279
  const percentage = Math.round((remaining / dailyLimit) * 100);
950
1280
 
951
- const quota = {
1281
+ return {
952
1282
  dailyLimit,
953
1283
  remaining,
954
1284
  used: todayUsage,
@@ -961,7 +1291,14 @@ app.get('/api/auth/pool', (req, res) => {
961
1291
  limit: Math.floor(dailyLimit / Math.max(1, pool.totalAccounts))
962
1292
  }))
963
1293
  };
964
-
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);
965
1302
  res.json({ pool, quota });
966
1303
  });
967
1304
 
@@ -1022,8 +1359,15 @@ app.post('/api/auth/pool/rotate', (req, res) => {
1022
1359
  if (!metadata[namespace]) metadata[namespace] = {};
1023
1360
  metadata[namespace][next.name] = {
1024
1361
  ...metadata[namespace][next.name],
1025
- lastUsed: now
1362
+ lastUsed: now,
1363
+ usageCount: (metadata[namespace][next.name]?.usageCount || 0) + 1
1026
1364
  };
1365
+
1366
+ if (!metadata._quota) metadata._quota = {};
1367
+ if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
1368
+ const today = new Date().toISOString().split('T')[0];
1369
+ metadata._quota[namespace][today] = (metadata._quota[namespace][today] || 0) + 1;
1370
+
1027
1371
  savePoolMetadata(metadata);
1028
1372
 
1029
1373
  res.json({
@@ -1034,6 +1378,31 @@ app.post('/api/auth/pool/rotate', (req, res) => {
1034
1378
  });
1035
1379
  });
1036
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
+
1037
1406
  // PUT /api/auth/pool/:name/cooldown - Mark account as in cooldown
1038
1407
  app.put('/api/auth/pool/:name/cooldown', (req, res) => {
1039
1408
  const { name } = req.params;
@@ -1374,6 +1743,14 @@ app.get('/api/auth/google/plugin', (req, res) => {
1374
1743
 
1375
1744
  const GEMINI_CLIENT_ID = process.env.GEMINI_CLIENT_ID || "";
1376
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
+
1377
1754
  const GEMINI_SCOPES = [
1378
1755
  "https://www.googleapis.com/auth/cloud-platform",
1379
1756
  "https://www.googleapis.com/auth/userinfo.email",
@@ -1399,14 +1776,19 @@ app.post('/api/auth/google/start', async (req, res) => {
1399
1776
  if (oauthCallbackServer) {
1400
1777
  return res.status(400).json({ error: 'OAuth flow already in progress' });
1401
1778
  }
1402
-
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
+
1403
1785
  const { verifier, challenge } = generatePKCE();
1404
1786
  const state = encodeOAuthState({ verifier });
1405
1787
 
1406
1788
  pendingOAuthState = { verifier, status: 'pending', startedAt: Date.now() };
1407
1789
 
1408
1790
  const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
1409
- authUrl.searchParams.set('client_id', GEMINI_CLIENT_ID);
1791
+ authUrl.searchParams.set('client_id', clientId);
1410
1792
  authUrl.searchParams.set('response_type', 'code');
1411
1793
  authUrl.searchParams.set('redirect_uri', GEMINI_REDIRECT_URI);
1412
1794
  authUrl.searchParams.set('scope', GEMINI_SCOPES.join(' '));
@@ -1440,7 +1822,7 @@ app.post('/api/auth/google/start', async (req, res) => {
1440
1822
  method: 'POST',
1441
1823
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1442
1824
  body: new URLSearchParams({
1443
- client_id: GEMINI_CLIENT_ID,
1825
+ client_id: clientId,
1444
1826
  client_secret: GEMINI_CLIENT_SECRET,
1445
1827
  code,
1446
1828
  grant_type: 'authorization_code',
@@ -1471,8 +1853,8 @@ app.post('/api/auth/google/start', async (req, res) => {
1471
1853
  const ap = path.join(path.dirname(cp), 'auth.json');
1472
1854
  const authCfg = fs.existsSync(ap) ? JSON.parse(fs.readFileSync(ap, 'utf8')) : {};
1473
1855
 
1474
- const studio = loadStudioConfig();
1475
- const activePlugin = studio.activeGooglePlugin || 'gemini';
1856
+ const studioConfig = loadStudioConfig();
1857
+ const activePlugin = studioConfig.activeGooglePlugin || 'gemini';
1476
1858
  const namespace = activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini';
1477
1859
 
1478
1860
  const credentials = {
@@ -1486,6 +1868,27 @@ app.post('/api/auth/google/start', async (req, res) => {
1486
1868
  authCfg[namespace] = credentials;
1487
1869
 
1488
1870
  atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
1871
+
1872
+ const profileDir = path.join(AUTH_PROFILES_DIR, namespace);
1873
+ if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
1874
+ const profileName = email || `google-${Date.now()}`;
1875
+ const profilePath = path.join(profileDir, `${profileName}.json`);
1876
+ atomicWriteFileSync(profilePath, JSON.stringify(credentials, null, 2));
1877
+
1878
+ const metadata = loadPoolMetadata();
1879
+ if (!metadata[namespace]) metadata[namespace] = {};
1880
+ metadata[namespace][profileName] = {
1881
+ ...(metadata[namespace][profileName] || {}),
1882
+ email: email || null,
1883
+ createdAt: metadata[namespace][profileName]?.createdAt || Date.now(),
1884
+ lastUsed: Date.now(),
1885
+ usageCount: metadata[namespace][profileName]?.usageCount || 0
1886
+ };
1887
+ savePoolMetadata(metadata);
1888
+
1889
+ if (!studioConfig.activeProfiles) studioConfig.activeProfiles = {};
1890
+ studioConfig.activeProfiles.google = profileName;
1891
+ saveStudioConfig(studioConfig);
1489
1892
 
1490
1893
  pendingOAuthState = { ...pendingOAuthState, status: 'success', email };
1491
1894
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-studio-server",
3
- "version": "1.3.6",
3
+ "version": "1.3.8",
4
4
  "description": "Backend server for OpenCode Studio - manages opencode configurations",
5
5
  "main": "index.js",
6
6
  "bin": {