commons-proxy 2.0.0
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/LICENSE +21 -0
- package/README.md +757 -0
- package/bin/cli.js +146 -0
- package/package.json +97 -0
- package/public/Complaint Details.pdf +0 -0
- package/public/Cyber Crime Portal.pdf +0 -0
- package/public/app.js +229 -0
- package/public/css/src/input.css +523 -0
- package/public/css/style.css +1 -0
- package/public/favicon.png +0 -0
- package/public/index.html +549 -0
- package/public/js/components/account-manager.js +356 -0
- package/public/js/components/add-account-modal.js +414 -0
- package/public/js/components/claude-config.js +420 -0
- package/public/js/components/dashboard/charts.js +605 -0
- package/public/js/components/dashboard/filters.js +362 -0
- package/public/js/components/dashboard/stats.js +110 -0
- package/public/js/components/dashboard.js +236 -0
- package/public/js/components/logs-viewer.js +100 -0
- package/public/js/components/models.js +36 -0
- package/public/js/components/server-config.js +349 -0
- package/public/js/config/constants.js +102 -0
- package/public/js/data-store.js +375 -0
- package/public/js/settings-store.js +58 -0
- package/public/js/store.js +99 -0
- package/public/js/translations/en.js +367 -0
- package/public/js/translations/id.js +412 -0
- package/public/js/translations/pt.js +308 -0
- package/public/js/translations/tr.js +358 -0
- package/public/js/translations/zh.js +373 -0
- package/public/js/utils/account-actions.js +189 -0
- package/public/js/utils/error-handler.js +96 -0
- package/public/js/utils/model-config.js +42 -0
- package/public/js/utils/ui-logger.js +143 -0
- package/public/js/utils/validators.js +77 -0
- package/public/js/utils.js +69 -0
- package/public/proxy-server-64.png +0 -0
- package/public/views/accounts.html +361 -0
- package/public/views/dashboard.html +484 -0
- package/public/views/logs.html +97 -0
- package/public/views/models.html +331 -0
- package/public/views/settings.html +1327 -0
- package/src/account-manager/credentials.js +378 -0
- package/src/account-manager/index.js +462 -0
- package/src/account-manager/onboarding.js +112 -0
- package/src/account-manager/rate-limits.js +369 -0
- package/src/account-manager/storage.js +160 -0
- package/src/account-manager/strategies/base-strategy.js +109 -0
- package/src/account-manager/strategies/hybrid-strategy.js +339 -0
- package/src/account-manager/strategies/index.js +79 -0
- package/src/account-manager/strategies/round-robin-strategy.js +76 -0
- package/src/account-manager/strategies/sticky-strategy.js +138 -0
- package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
- package/src/account-manager/strategies/trackers/index.js +9 -0
- package/src/account-manager/strategies/trackers/quota-tracker.js +120 -0
- package/src/account-manager/strategies/trackers/token-bucket-tracker.js +155 -0
- package/src/auth/database.js +169 -0
- package/src/auth/oauth.js +548 -0
- package/src/auth/token-extractor.js +117 -0
- package/src/cli/accounts.js +648 -0
- package/src/cloudcode/index.js +29 -0
- package/src/cloudcode/message-handler.js +510 -0
- package/src/cloudcode/model-api.js +248 -0
- package/src/cloudcode/rate-limit-parser.js +235 -0
- package/src/cloudcode/request-builder.js +93 -0
- package/src/cloudcode/session-manager.js +47 -0
- package/src/cloudcode/sse-parser.js +121 -0
- package/src/cloudcode/sse-streamer.js +293 -0
- package/src/cloudcode/streaming-handler.js +615 -0
- package/src/config.js +125 -0
- package/src/constants.js +407 -0
- package/src/errors.js +242 -0
- package/src/fallback-config.js +29 -0
- package/src/format/content-converter.js +193 -0
- package/src/format/index.js +20 -0
- package/src/format/request-converter.js +255 -0
- package/src/format/response-converter.js +120 -0
- package/src/format/schema-sanitizer.js +673 -0
- package/src/format/signature-cache.js +88 -0
- package/src/format/thinking-utils.js +648 -0
- package/src/index.js +148 -0
- package/src/modules/usage-stats.js +205 -0
- package/src/providers/anthropic-provider.js +258 -0
- package/src/providers/base-provider.js +157 -0
- package/src/providers/cloudcode.js +94 -0
- package/src/providers/copilot.js +399 -0
- package/src/providers/github-provider.js +287 -0
- package/src/providers/google-provider.js +192 -0
- package/src/providers/index.js +211 -0
- package/src/providers/openai-compatible.js +265 -0
- package/src/providers/openai-provider.js +271 -0
- package/src/providers/openrouter-provider.js +325 -0
- package/src/providers/setup.js +83 -0
- package/src/server.js +870 -0
- package/src/utils/claude-config.js +245 -0
- package/src/utils/helpers.js +51 -0
- package/src/utils/logger.js +142 -0
- package/src/utils/native-module-helper.js +162 -0
- package/src/webui/index.js +1134 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model API for Cloud Code
|
|
3
|
+
*
|
|
4
|
+
* Handles model listing and quota retrieval from the Cloud Code API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
|
9
|
+
ANTIGRAVITY_HEADERS,
|
|
10
|
+
LOAD_CODE_ASSIST_ENDPOINTS,
|
|
11
|
+
LOAD_CODE_ASSIST_HEADERS,
|
|
12
|
+
getModelFamily
|
|
13
|
+
} from '../constants.js';
|
|
14
|
+
import { logger } from '../utils/logger.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check if a model is supported (Claude or Gemini)
|
|
18
|
+
* @param {string} modelId - Model ID to check
|
|
19
|
+
* @returns {boolean} True if model is supported
|
|
20
|
+
*/
|
|
21
|
+
function isSupportedModel(modelId) {
|
|
22
|
+
const family = getModelFamily(modelId);
|
|
23
|
+
return family === 'claude' || family === 'gemini';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* List available models in Anthropic API format
|
|
28
|
+
* Fetches models dynamically from the Cloud Code API
|
|
29
|
+
*
|
|
30
|
+
* @param {string} token - OAuth access token
|
|
31
|
+
* @returns {Promise<{object: string, data: Array<{id: string, object: string, created: number, owned_by: string, description: string}>}>} List of available models
|
|
32
|
+
*/
|
|
33
|
+
export async function listModels(token) {
|
|
34
|
+
const data = await fetchAvailableModels(token);
|
|
35
|
+
if (!data || !data.models) {
|
|
36
|
+
return { object: 'list', data: [] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const modelList = Object.entries(data.models)
|
|
40
|
+
.filter(([modelId]) => isSupportedModel(modelId))
|
|
41
|
+
.map(([modelId, modelData]) => ({
|
|
42
|
+
id: modelId,
|
|
43
|
+
object: 'model',
|
|
44
|
+
created: Math.floor(Date.now() / 1000),
|
|
45
|
+
owned_by: 'anthropic',
|
|
46
|
+
description: modelData.displayName || modelId
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
object: 'list',
|
|
51
|
+
data: modelList
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fetch available models with quota info from Cloud Code API
|
|
57
|
+
* Returns model quotas including remaining fraction and reset time
|
|
58
|
+
*
|
|
59
|
+
* @param {string} token - OAuth access token
|
|
60
|
+
* @param {string} [projectId] - Optional project ID for accurate quota info
|
|
61
|
+
* @returns {Promise<Object>} Raw response from fetchAvailableModels API
|
|
62
|
+
*/
|
|
63
|
+
export async function fetchAvailableModels(token, projectId = null) {
|
|
64
|
+
const headers = {
|
|
65
|
+
'Authorization': `Bearer ${token}`,
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
...ANTIGRAVITY_HEADERS
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Include project ID in body for accurate quota info (per Quotio implementation)
|
|
71
|
+
const body = projectId ? { project: projectId } : {};
|
|
72
|
+
|
|
73
|
+
for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
|
|
74
|
+
try {
|
|
75
|
+
const url = `${endpoint}/v1internal:fetchAvailableModels`;
|
|
76
|
+
const response = await fetch(url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers,
|
|
79
|
+
body: JSON.stringify(body)
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
const errorText = await response.text();
|
|
84
|
+
logger.warn(`[CloudCode] fetchAvailableModels error at ${endpoint}: ${response.status}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return await response.json();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
logger.warn(`[CloudCode] fetchAvailableModels failed at ${endpoint}:`, error.message);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error('Failed to fetch available models from all endpoints');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get model quotas for an account
|
|
99
|
+
* Extracts quota info (remaining fraction and reset time) for each model
|
|
100
|
+
*
|
|
101
|
+
* @param {string} token - OAuth access token
|
|
102
|
+
* @param {string} [projectId] - Optional project ID for accurate quota info
|
|
103
|
+
* @returns {Promise<Object>} Map of modelId -> { remainingFraction, resetTime }
|
|
104
|
+
*/
|
|
105
|
+
export async function getModelQuotas(token, projectId = null) {
|
|
106
|
+
const data = await fetchAvailableModels(token, projectId);
|
|
107
|
+
if (!data || !data.models) return {};
|
|
108
|
+
|
|
109
|
+
const quotas = {};
|
|
110
|
+
for (const [modelId, modelData] of Object.entries(data.models)) {
|
|
111
|
+
// Only include Claude and Gemini models
|
|
112
|
+
if (!isSupportedModel(modelId)) continue;
|
|
113
|
+
|
|
114
|
+
if (modelData.quotaInfo) {
|
|
115
|
+
quotas[modelId] = {
|
|
116
|
+
// When remainingFraction is missing but resetTime is present, quota is exhausted (0%)
|
|
117
|
+
remainingFraction: modelData.quotaInfo.remainingFraction ?? (modelData.quotaInfo.resetTime ? 0 : null),
|
|
118
|
+
resetTime: modelData.quotaInfo.resetTime ?? null
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return quotas;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Parse tier ID string to determine subscription level
|
|
128
|
+
* @param {string} tierId - The tier ID from the API
|
|
129
|
+
* @returns {'free' | 'pro' | 'ultra' | 'unknown'} The subscription tier
|
|
130
|
+
*/
|
|
131
|
+
export function parseTierId(tierId) {
|
|
132
|
+
if (!tierId) return 'unknown';
|
|
133
|
+
const lower = tierId.toLowerCase();
|
|
134
|
+
|
|
135
|
+
if (lower.includes('ultra')) {
|
|
136
|
+
return 'ultra';
|
|
137
|
+
}
|
|
138
|
+
if (lower === 'standard-tier') {
|
|
139
|
+
// standard-tier = "Gemini Code Assist" (paid, project-based)
|
|
140
|
+
return 'pro';
|
|
141
|
+
}
|
|
142
|
+
if (lower.includes('pro') || lower.includes('premium')) {
|
|
143
|
+
return 'pro';
|
|
144
|
+
}
|
|
145
|
+
if (lower === 'free-tier' || lower.includes('free')) {
|
|
146
|
+
return 'free';
|
|
147
|
+
}
|
|
148
|
+
return 'unknown';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get subscription tier for an account
|
|
153
|
+
* Calls loadCodeAssist API to discover project ID and subscription tier
|
|
154
|
+
*
|
|
155
|
+
* @param {string} token - OAuth access token
|
|
156
|
+
* @returns {Promise<{tier: string, projectId: string|null}>} Subscription tier (free/pro/ultra) and project ID
|
|
157
|
+
*/
|
|
158
|
+
export async function getSubscriptionTier(token) {
|
|
159
|
+
const headers = {
|
|
160
|
+
'Authorization': `Bearer ${token}`,
|
|
161
|
+
'Content-Type': 'application/json',
|
|
162
|
+
...LOAD_CODE_ASSIST_HEADERS
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) {
|
|
166
|
+
try {
|
|
167
|
+
const url = `${endpoint}/v1internal:loadCodeAssist`;
|
|
168
|
+
const response = await fetch(url, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers,
|
|
171
|
+
body: JSON.stringify({
|
|
172
|
+
metadata: {
|
|
173
|
+
ideType: 'IDE_UNSPECIFIED',
|
|
174
|
+
platform: 'PLATFORM_UNSPECIFIED',
|
|
175
|
+
pluginType: 'GEMINI',
|
|
176
|
+
duetProject: 'rising-fact-p41fc'
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
logger.warn(`[CloudCode] loadCodeAssist error at ${endpoint}: ${response.status}`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const data = await response.json();
|
|
187
|
+
|
|
188
|
+
// Debug: Log all tier-related fields from the response
|
|
189
|
+
logger.debug(`[CloudCode] loadCodeAssist tier data: paidTier=${JSON.stringify(data.paidTier)}, currentTier=${JSON.stringify(data.currentTier)}, allowedTiers=${JSON.stringify(data.allowedTiers?.map(t => ({ id: t?.id, isDefault: t?.isDefault })))}`);
|
|
190
|
+
|
|
191
|
+
// Extract project ID
|
|
192
|
+
let projectId = null;
|
|
193
|
+
if (typeof data.cloudaicompanionProject === 'string') {
|
|
194
|
+
projectId = data.cloudaicompanionProject;
|
|
195
|
+
} else if (data.cloudaicompanionProject?.id) {
|
|
196
|
+
projectId = data.cloudaicompanionProject.id;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Extract subscription tier
|
|
200
|
+
// Priority: paidTier > currentTier > allowedTiers
|
|
201
|
+
// - paidTier.id: "g1-pro-tier", "g1-ultra-tier" (Google One subscription)
|
|
202
|
+
// - currentTier.id: "standard-tier" (pro), "free-tier" (free)
|
|
203
|
+
// - allowedTiers: fallback when currentTier is missing
|
|
204
|
+
// Note: paidTier is sometimes missing from the response even for Pro accounts
|
|
205
|
+
let tier = 'unknown';
|
|
206
|
+
let tierId = null;
|
|
207
|
+
let tierSource = null;
|
|
208
|
+
|
|
209
|
+
// 1. Check paidTier first (Google One AI subscription - most reliable)
|
|
210
|
+
if (data.paidTier?.id) {
|
|
211
|
+
tierId = data.paidTier.id;
|
|
212
|
+
tier = parseTierId(tierId);
|
|
213
|
+
tierSource = 'paidTier';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 2. Fall back to currentTier if paidTier didn't give us a tier
|
|
217
|
+
if (tier === 'unknown' && data.currentTier?.id) {
|
|
218
|
+
tierId = data.currentTier.id;
|
|
219
|
+
tier = parseTierId(tierId);
|
|
220
|
+
tierSource = 'currentTier';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 3. Fall back to allowedTiers (find the default or first non-free tier)
|
|
224
|
+
if (tier === 'unknown' && Array.isArray(data.allowedTiers) && data.allowedTiers.length > 0) {
|
|
225
|
+
// First look for the default tier
|
|
226
|
+
let defaultTier = data.allowedTiers.find(t => t?.isDefault);
|
|
227
|
+
if (!defaultTier) {
|
|
228
|
+
defaultTier = data.allowedTiers[0];
|
|
229
|
+
}
|
|
230
|
+
if (defaultTier?.id) {
|
|
231
|
+
tierId = defaultTier.id;
|
|
232
|
+
tier = parseTierId(tierId);
|
|
233
|
+
tierSource = 'allowedTiers';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
logger.debug(`[CloudCode] Subscription detected: ${tier} (tierId: ${tierId}, source: ${tierSource}), Project: ${projectId}`);
|
|
238
|
+
|
|
239
|
+
return { tier, projectId };
|
|
240
|
+
} catch (error) {
|
|
241
|
+
logger.warn(`[CloudCode] loadCodeAssist failed at ${endpoint}:`, error.message);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Fallback: return default values if all endpoints fail
|
|
246
|
+
logger.warn('[CloudCode] Failed to detect subscription tier from all endpoints. Defaulting to free.');
|
|
247
|
+
return { tier: 'free', projectId: null };
|
|
248
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limit Parser for Cloud Code
|
|
3
|
+
*
|
|
4
|
+
* Parses reset times from HTTP headers and error messages.
|
|
5
|
+
* Supports various formats: Retry-After, x-ratelimit-reset,
|
|
6
|
+
* quotaResetDelay, quotaResetTimeStamp, and duration strings.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatDuration } from '../utils/helpers.js';
|
|
10
|
+
import { logger } from '../utils/logger.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse reset time from HTTP response or error
|
|
14
|
+
* Checks headers first, then error message body
|
|
15
|
+
* Returns milliseconds or null if not found
|
|
16
|
+
*
|
|
17
|
+
* @param {Response|Error} responseOrError - HTTP Response object or Error
|
|
18
|
+
* @param {string} errorText - Optional error body text
|
|
19
|
+
*/
|
|
20
|
+
export function parseResetTime(responseOrError, errorText = '') {
|
|
21
|
+
let resetMs = null;
|
|
22
|
+
|
|
23
|
+
// If it's a Response object, check headers first
|
|
24
|
+
if (responseOrError && typeof responseOrError.headers?.get === 'function') {
|
|
25
|
+
const headers = responseOrError.headers;
|
|
26
|
+
|
|
27
|
+
// Standard Retry-After header (seconds or HTTP date)
|
|
28
|
+
const retryAfter = headers.get('retry-after');
|
|
29
|
+
if (retryAfter) {
|
|
30
|
+
const seconds = parseInt(retryAfter, 10);
|
|
31
|
+
if (!isNaN(seconds)) {
|
|
32
|
+
resetMs = seconds * 1000;
|
|
33
|
+
logger.debug(`[CloudCode] Retry-After header: ${seconds}s`);
|
|
34
|
+
} else {
|
|
35
|
+
// Try parsing as HTTP date
|
|
36
|
+
const date = new Date(retryAfter);
|
|
37
|
+
if (!isNaN(date.getTime())) {
|
|
38
|
+
resetMs = date.getTime() - Date.now();
|
|
39
|
+
if (resetMs > 0) {
|
|
40
|
+
logger.debug(`[CloudCode] Retry-After date: ${retryAfter}`);
|
|
41
|
+
} else {
|
|
42
|
+
resetMs = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// x-ratelimit-reset (Unix timestamp in seconds)
|
|
49
|
+
if (!resetMs) {
|
|
50
|
+
const ratelimitReset = headers.get('x-ratelimit-reset');
|
|
51
|
+
if (ratelimitReset) {
|
|
52
|
+
const resetTimestamp = parseInt(ratelimitReset, 10) * 1000;
|
|
53
|
+
resetMs = resetTimestamp - Date.now();
|
|
54
|
+
if (resetMs > 0) {
|
|
55
|
+
logger.debug(`[CloudCode] x-ratelimit-reset: ${new Date(resetTimestamp).toISOString()}`);
|
|
56
|
+
} else {
|
|
57
|
+
resetMs = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// x-ratelimit-reset-after (seconds)
|
|
63
|
+
if (!resetMs) {
|
|
64
|
+
const resetAfter = headers.get('x-ratelimit-reset-after');
|
|
65
|
+
if (resetAfter) {
|
|
66
|
+
const seconds = parseInt(resetAfter, 10);
|
|
67
|
+
if (!isNaN(seconds) && seconds > 0) {
|
|
68
|
+
resetMs = seconds * 1000;
|
|
69
|
+
logger.debug(`[CloudCode] x-ratelimit-reset-after: ${seconds}s`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// If no header found, try parsing from error message/body
|
|
76
|
+
if (!resetMs) {
|
|
77
|
+
const msg = (responseOrError instanceof Error ? responseOrError.message : errorText) || '';
|
|
78
|
+
|
|
79
|
+
// Try to extract "quotaResetDelay" first (e.g. "754.431528ms" or "1.5s")
|
|
80
|
+
// This is Google's preferred format for rate limit reset delay
|
|
81
|
+
const quotaDelayMatch = msg.match(/quotaResetDelay[:\s"]+(\d+(?:\.\d+)?)(ms|s)/i);
|
|
82
|
+
if (quotaDelayMatch) {
|
|
83
|
+
const value = parseFloat(quotaDelayMatch[1]);
|
|
84
|
+
const unit = quotaDelayMatch[2].toLowerCase();
|
|
85
|
+
resetMs = unit === 's' ? Math.ceil(value * 1000) : Math.ceil(value);
|
|
86
|
+
logger.debug(`[CloudCode] Parsed quotaResetDelay from body: ${resetMs}ms`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Try to extract "quotaResetTimeStamp" (ISO format like "2025-12-31T07:00:47Z")
|
|
90
|
+
if (!resetMs) {
|
|
91
|
+
const quotaTimestampMatch = msg.match(/quotaResetTimeStamp[:\s"]+(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i);
|
|
92
|
+
if (quotaTimestampMatch) {
|
|
93
|
+
const resetTime = new Date(quotaTimestampMatch[1]).getTime();
|
|
94
|
+
if (!isNaN(resetTime)) {
|
|
95
|
+
resetMs = resetTime - Date.now();
|
|
96
|
+
// Even if expired or 0, we found a timestamp, so rely on it.
|
|
97
|
+
// But if it's negative, it means "now", so treat as small wait.
|
|
98
|
+
logger.debug(`[CloudCode] Parsed quotaResetTimeStamp: ${quotaTimestampMatch[1]} (Delta: ${resetMs}ms)`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Try to extract "retry-after-ms" or "retryDelay" - check seconds format first (e.g. "7739.23s")
|
|
104
|
+
// Added stricter regex to avoid partial matches
|
|
105
|
+
if (!resetMs) {
|
|
106
|
+
const secMatch = msg.match(/(?:retry[-_]?after[-_]?ms|retryDelay)[:\s"]+([\d.]+)(?:s\b|s")/i);
|
|
107
|
+
if (secMatch) {
|
|
108
|
+
resetMs = Math.ceil(parseFloat(secMatch[1]) * 1000);
|
|
109
|
+
logger.debug(`[CloudCode] Parsed retry seconds from body (precise): ${resetMs}ms`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!resetMs) {
|
|
114
|
+
// Check for ms (explicit "ms" suffix or implicit if no suffix)
|
|
115
|
+
const msMatch = msg.match(/(?:retry[-_]?after[-_]?ms|retryDelay)[:\s"]+(\d+)(?:\s*ms)?(?![\w.])/i);
|
|
116
|
+
if (msMatch) {
|
|
117
|
+
resetMs = parseInt(msMatch[1], 10);
|
|
118
|
+
logger.debug(`[CloudCode] Parsed retry-after-ms from body: ${resetMs}ms`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Try to extract seconds value like "retry after 60 seconds"
|
|
123
|
+
if (!resetMs) {
|
|
124
|
+
const secMatch = msg.match(/retry\s+(?:after\s+)?(\d+)\s*(?:sec|s\b)/i);
|
|
125
|
+
if (secMatch) {
|
|
126
|
+
resetMs = parseInt(secMatch[1], 10) * 1000;
|
|
127
|
+
logger.debug(`[CloudCode] Parsed retry seconds from body: ${secMatch[1]}s`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Try to extract duration like "1h23m45s" or "23m45s" or "45s"
|
|
132
|
+
if (!resetMs) {
|
|
133
|
+
const durationMatch = msg.match(/(\d+)h(\d+)m(\d+)s|(\d+)m(\d+)s|(\d+)s/i);
|
|
134
|
+
if (durationMatch) {
|
|
135
|
+
if (durationMatch[1]) {
|
|
136
|
+
const hours = parseInt(durationMatch[1], 10);
|
|
137
|
+
const minutes = parseInt(durationMatch[2], 10);
|
|
138
|
+
const seconds = parseInt(durationMatch[3], 10);
|
|
139
|
+
resetMs = (hours * 3600 + minutes * 60 + seconds) * 1000;
|
|
140
|
+
} else if (durationMatch[4]) {
|
|
141
|
+
const minutes = parseInt(durationMatch[4], 10);
|
|
142
|
+
const seconds = parseInt(durationMatch[5], 10);
|
|
143
|
+
resetMs = (minutes * 60 + seconds) * 1000;
|
|
144
|
+
} else if (durationMatch[6]) {
|
|
145
|
+
resetMs = parseInt(durationMatch[6], 10) * 1000;
|
|
146
|
+
}
|
|
147
|
+
if (resetMs) {
|
|
148
|
+
logger.debug(`[CloudCode] Parsed duration from body: ${formatDuration(resetMs)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Try to extract ISO timestamp or Unix timestamp
|
|
154
|
+
if (!resetMs) {
|
|
155
|
+
const isoMatch = msg.match(/reset[:\s"]+(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i);
|
|
156
|
+
if (isoMatch) {
|
|
157
|
+
const resetTime = new Date(isoMatch[1]).getTime();
|
|
158
|
+
if (!isNaN(resetTime)) {
|
|
159
|
+
resetMs = resetTime - Date.now();
|
|
160
|
+
if (resetMs > 0) {
|
|
161
|
+
logger.debug(`[CloudCode] Parsed ISO reset time: ${isoMatch[1]}`);
|
|
162
|
+
} else {
|
|
163
|
+
resetMs = null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// SANITY CHECK: Handle very small or negative reset times
|
|
171
|
+
// For sub-second rate limits (common with per-second quotas), add a small buffer
|
|
172
|
+
// For negative or zero, use a reasonable minimum
|
|
173
|
+
if (resetMs !== null) {
|
|
174
|
+
if (resetMs <= 0) {
|
|
175
|
+
logger.debug(`[CloudCode] Reset time invalid (${resetMs}ms), using 500ms default`);
|
|
176
|
+
resetMs = 500;
|
|
177
|
+
} else if (resetMs < 500) {
|
|
178
|
+
// Very short reset - add 200ms buffer for network latency
|
|
179
|
+
logger.debug(`[CloudCode] Short reset time (${resetMs}ms), adding 200ms buffer`);
|
|
180
|
+
resetMs = resetMs + 200;
|
|
181
|
+
}
|
|
182
|
+
// Note: No longer enforcing 2s minimum - this was causing cascading failures
|
|
183
|
+
// when all accounts had short rate limits simultaneously
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return resetMs;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Parse the rate limit reason from error text
|
|
191
|
+
* Used for smart backoff by error type (matches opencode-cloudcode-auth)
|
|
192
|
+
*
|
|
193
|
+
* @param {string} errorText - Error message/body text
|
|
194
|
+
* @returns {'RATE_LIMIT_EXCEEDED' | 'QUOTA_EXHAUSTED' | 'MODEL_CAPACITY_EXHAUSTED' | 'SERVER_ERROR' | 'UNKNOWN'} Error reason
|
|
195
|
+
*/
|
|
196
|
+
export function parseRateLimitReason(errorText) {
|
|
197
|
+
const lower = (errorText || '').toLowerCase();
|
|
198
|
+
|
|
199
|
+
// Check for quota exhaustion (daily/hourly limits)
|
|
200
|
+
if (lower.includes('quota_exhausted') ||
|
|
201
|
+
lower.includes('quotaresetdelay') ||
|
|
202
|
+
lower.includes('quotaresettimestamp') ||
|
|
203
|
+
lower.includes('resource_exhausted') ||
|
|
204
|
+
lower.includes('daily limit') ||
|
|
205
|
+
lower.includes('quota exceeded')) {
|
|
206
|
+
return 'QUOTA_EXHAUSTED';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Check for model capacity issues (temporary, retry quickly)
|
|
210
|
+
if (lower.includes('model_capacity_exhausted') ||
|
|
211
|
+
lower.includes('capacity_exhausted') ||
|
|
212
|
+
lower.includes('model is currently overloaded') ||
|
|
213
|
+
lower.includes('service temporarily unavailable')) {
|
|
214
|
+
return 'MODEL_CAPACITY_EXHAUSTED';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Check for rate limiting (per-minute limits)
|
|
218
|
+
if (lower.includes('rate_limit_exceeded') ||
|
|
219
|
+
lower.includes('rate limit') ||
|
|
220
|
+
lower.includes('too many requests') ||
|
|
221
|
+
lower.includes('throttl')) {
|
|
222
|
+
return 'RATE_LIMIT_EXCEEDED';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Check for server errors
|
|
226
|
+
if (lower.includes('internal server error') ||
|
|
227
|
+
lower.includes('server error') ||
|
|
228
|
+
lower.includes('503') ||
|
|
229
|
+
lower.includes('502') ||
|
|
230
|
+
lower.includes('504')) {
|
|
231
|
+
return 'SERVER_ERROR';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return 'UNKNOWN';
|
|
235
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request Builder for Cloud Code
|
|
3
|
+
*
|
|
4
|
+
* Builds request payloads and headers for the Cloud Code API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import {
|
|
9
|
+
ANTIGRAVITY_HEADERS,
|
|
10
|
+
ANTIGRAVITY_SYSTEM_INSTRUCTION,
|
|
11
|
+
getModelFamily,
|
|
12
|
+
isThinkingModel
|
|
13
|
+
} from '../constants.js';
|
|
14
|
+
import { convertAnthropicToGoogle } from '../format/index.js';
|
|
15
|
+
import { deriveSessionId } from './session-manager.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the wrapped request body for Cloud Code API
|
|
19
|
+
*
|
|
20
|
+
* @param {Object} anthropicRequest - The Anthropic-format request
|
|
21
|
+
* @param {string} projectId - The project ID to use
|
|
22
|
+
* @returns {Object} The Cloud Code API request payload
|
|
23
|
+
*/
|
|
24
|
+
export function buildCloudCodeRequest(anthropicRequest, projectId) {
|
|
25
|
+
const model = anthropicRequest.model;
|
|
26
|
+
const googleRequest = convertAnthropicToGoogle(anthropicRequest);
|
|
27
|
+
|
|
28
|
+
// Use stable session ID derived from first user message for cache continuity
|
|
29
|
+
googleRequest.sessionId = deriveSessionId(anthropicRequest);
|
|
30
|
+
|
|
31
|
+
// Build system instruction parts array with [ignore] tags to prevent model from
|
|
32
|
+
// identifying as "Antigravity" (fixes GitHub issue #76)
|
|
33
|
+
// Reference: CLIProxyAPI, gcli2api, AIClient-2-API all use this approach
|
|
34
|
+
const systemParts = [
|
|
35
|
+
{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION },
|
|
36
|
+
{ text: `Please ignore the following [ignore]${ANTIGRAVITY_SYSTEM_INSTRUCTION}[/ignore]` }
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Append any existing system instructions from the request
|
|
40
|
+
if (googleRequest.systemInstruction && googleRequest.systemInstruction.parts) {
|
|
41
|
+
for (const part of googleRequest.systemInstruction.parts) {
|
|
42
|
+
if (part.text) {
|
|
43
|
+
systemParts.push({ text: part.text });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const payload = {
|
|
49
|
+
project: projectId,
|
|
50
|
+
model: model,
|
|
51
|
+
request: googleRequest,
|
|
52
|
+
userAgent: 'commons-proxy',
|
|
53
|
+
requestType: 'agent', // CLIProxyAPI v6.6.89 compatibility
|
|
54
|
+
requestId: 'agent-' + crypto.randomUUID()
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Inject systemInstruction with role: "user" at the top level (CLIProxyAPI v6.6.89 behavior)
|
|
58
|
+
payload.request.systemInstruction = {
|
|
59
|
+
role: 'user',
|
|
60
|
+
parts: systemParts
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return payload;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build headers for Cloud Code API requests
|
|
68
|
+
*
|
|
69
|
+
* @param {string} token - OAuth access token
|
|
70
|
+
* @param {string} model - Model name
|
|
71
|
+
* @param {string} accept - Accept header value (default: 'application/json')
|
|
72
|
+
* @returns {Object} Headers object
|
|
73
|
+
*/
|
|
74
|
+
export function buildHeaders(token, model, accept = 'application/json') {
|
|
75
|
+
const headers = {
|
|
76
|
+
'Authorization': `Bearer ${token}`,
|
|
77
|
+
'Content-Type': 'application/json',
|
|
78
|
+
...ANTIGRAVITY_HEADERS
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const modelFamily = getModelFamily(model);
|
|
82
|
+
|
|
83
|
+
// Add interleaved thinking header only for Claude thinking models
|
|
84
|
+
if (modelFamily === 'claude' && isThinkingModel(model)) {
|
|
85
|
+
headers['anthropic-beta'] = 'interleaved-thinking-2025-05-14';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (accept !== 'application/json') {
|
|
89
|
+
headers['Accept'] = accept;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return headers;
|
|
93
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Management for Cloud Code
|
|
3
|
+
*
|
|
4
|
+
* Handles session ID derivation for prompt caching continuity.
|
|
5
|
+
* Session IDs are derived from the first user message to ensure
|
|
6
|
+
* the same conversation uses the same session across turns.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Derive a stable session ID from the first user message in the conversation.
|
|
13
|
+
* This ensures the same conversation uses the same session ID across turns,
|
|
14
|
+
* enabling prompt caching (cache is scoped to session + organization).
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} anthropicRequest - The Anthropic-format request
|
|
17
|
+
* @returns {string} A stable session ID (32 hex characters) or random UUID if no user message
|
|
18
|
+
*/
|
|
19
|
+
export function deriveSessionId(anthropicRequest) {
|
|
20
|
+
const messages = anthropicRequest.messages || [];
|
|
21
|
+
|
|
22
|
+
// Find the first user message
|
|
23
|
+
for (const msg of messages) {
|
|
24
|
+
if (msg.role === 'user') {
|
|
25
|
+
let content = '';
|
|
26
|
+
|
|
27
|
+
if (typeof msg.content === 'string') {
|
|
28
|
+
content = msg.content;
|
|
29
|
+
} else if (Array.isArray(msg.content)) {
|
|
30
|
+
// Extract text from content blocks
|
|
31
|
+
content = msg.content
|
|
32
|
+
.filter(block => block.type === 'text' && block.text)
|
|
33
|
+
.map(block => block.text)
|
|
34
|
+
.join('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (content) {
|
|
38
|
+
// Hash the content with SHA256, return first 32 hex chars
|
|
39
|
+
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
|
40
|
+
return hash.substring(0, 32);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fallback to random UUID if no user message found
|
|
46
|
+
return crypto.randomUUID();
|
|
47
|
+
}
|