From 1f25d6fa558d5e25dc3af39a089ef30fb227b798 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:56:12 +0200 Subject: [PATCH] fix(usage): route usage readings through the assigned connection proxy --- .../16773-usage-readers-connection-proxy.md | 1 + open-sse/services/connectionProxyFetch.ts | 113 +++++++++ open-sse/services/conolUsage.ts | 37 ++- open-sse/services/usage.ts | 20 +- open-sse/services/usage/chatplayground.ts | 20 +- open-sse/services/usage/claude.ts | 99 +++++--- open-sse/services/usage/codex.ts | 25 +- open-sse/services/usage/command-code.ts | 22 +- open-sse/services/usage/cursor.ts | 87 ++++--- open-sse/services/usage/devinCli.ts | 35 ++- open-sse/services/usage/github.ts | 24 +- open-sse/services/usage/syntx.ts | 37 +-- tests/unit/usage-connection-proxy.test.ts | 215 ++++++++++++++++++ 13 files changed, 615 insertions(+), 120 deletions(-) create mode 100644 changelog.d/fixes/16773-usage-readers-connection-proxy.md create mode 100644 open-sse/services/connectionProxyFetch.ts create mode 100644 tests/unit/usage-connection-proxy.test.ts diff --git a/changelog.d/fixes/16773-usage-readers-connection-proxy.md b/changelog.d/fixes/16773-usage-readers-connection-proxy.md new file mode 100644 index 000000000000..f7e1488ae817 --- /dev/null +++ b/changelog.d/fixes/16773-usage-readers-connection-proxy.md @@ -0,0 +1 @@ +- **fix(usage):** route usage readings through the assigned connection proxy ([#16773](https://github.com/diegosouzapw/OmniRoute/pull/16773)) — thanks @maxmad64bis diff --git a/open-sse/services/connectionProxyFetch.ts b/open-sse/services/connectionProxyFetch.ts new file mode 100644 index 000000000000..b9a31c7c2fe9 --- /dev/null +++ b/open-sse/services/connectionProxyFetch.ts @@ -0,0 +1,113 @@ +/** + * connectionProxyFetch.ts — run quota fetcher requests through the proxy + * assigned to the connection. + * + * Quota fetchers call provider consoles directly with the global fetch. On an + * installation behind a proxy, that reads the quota of the wrong egress + * address and exposes the origin. This helper resolves the proxy bound to the + * connection and runs the fetch inside that proxy context, so the reading + * uses the assigned egress path. Without an assigned proxy the fetch runs + * unchanged. A proxy or resolution failure returns null so the reading is + * reported, never retried outside the proxy. + */ + +// NOTE: proxyFetch and error are imported lazily inside the functions below +// (not statically at the top level) so that bundling this module (e.g. the +// MCP server bundle) does not initialize the proxy transport at load time. + +type ProxyValue = unknown; + +export interface ProxyResolver { + resolveProxyForConnection: (connectionId: string) => Promise<{ proxy?: ProxyValue } | null>; +} + +interface ConnectionProxyFetchDeps { + resolveProxyForConnection?: ProxyResolver["resolveProxyForConnection"]; + fetchFn?: typeof globalThis.fetch; +} + +async function defaultResolveProxyForConnection( + connectionId: string +): Promise<{ proxy?: ProxyValue } | null> { + const override = connectionProxyTestOverride.resolveProxyForConnection; + if (override) return override(connectionId); + const { resolveProxyForConnection } = await import("@/lib/db/settings"); + return resolveProxyForConnection(connectionId); +} + +/** + * Test-only override for the connection proxy resolution (avoids seeding the + * database in unit tests). Production code never sets it. + */ +export const connectionProxyTestOverride: { + resolveProxyForConnection: + ((connectionId: string) => Promise<{ proxy?: ProxyValue } | null>) | null; +} = { resolveProxyForConnection: null }; + +/** + * Resolve the proxy assigned to a quota connection. Null means no proxy is + * assigned and the fetch runs unchanged. + */ +export async function resolveConnectionProxy( + connectionId: string, + deps: ConnectionProxyFetchDeps = {} +): Promise { + const resolve = deps.resolveProxyForConnection ?? defaultResolveProxyForConnection; + const resolved = await resolve(connectionId); + return resolved?.proxy ?? null; +} + +/** + * Fetch a quota URL through the connection proxy context. Returns the + * response, or null when no proxy is assigned, when the connection has no + * identifier to resolve a proxy for, and when the proxied fetch fails — the + * caller reports the missing reading instead of refetching outside the proxy. + */ +export async function fetchWithConnectionProxy( + connectionId: string, + url: string, + init?: RequestInit, + deps: ConnectionProxyFetchDeps = {} +): Promise { + const fetchFn = deps.fetchFn ?? globalThis.fetch; + const { runWithProxyContext } = await import("../utils/proxyFetch.ts"); + const { sanitizeErrorMessage } = await import("../utils/error.ts"); + // Importing the proxy transport replaces globalThis.fetch with its own + // interceptor as a side effect. When the caller provided the fetch (a test + // mock, or any fetch installed before this call), put it back so the next + // request of the same quota reading keeps using it instead of the + // interceptor. In production the global fetch is already the interceptor + // (installed once at startup), so this is a no-op there. + if (deps.fetchFn === undefined && globalThis.fetch !== fetchFn) { + globalThis.fetch = fetchFn; + } + if (!connectionId) { + console.warn( + "[ConnectionProxyFetch] Missing connection identifier: reporting an unknown reading instead of fetching outside a proxy." + ); + return null; + } + let proxy: ProxyValue | null; + try { + proxy = await resolveConnectionProxy(connectionId, deps); + } catch (error) { + console.warn( + `[ConnectionProxyFetch] Proxy resolution failed for ${connectionId}: ${sanitizeErrorMessage( + error instanceof Error ? error.message : String(error) + )}` + ); + return null; + } + const task = () => fetchFn(url, init); + if (!proxy) return task(); + try { + return await runWithProxyContext(proxy, task); + } catch (error) { + console.warn( + `[ConnectionProxyFetch] Proxied quota fetch failed for ${connectionId}: ${sanitizeErrorMessage( + error instanceof Error ? error.message : String(error) + )}` + ); + return null; + } +} diff --git a/open-sse/services/conolUsage.ts b/open-sse/services/conolUsage.ts index 1e3302a7a439..f53175376991 100644 --- a/open-sse/services/conolUsage.ts +++ b/open-sse/services/conolUsage.ts @@ -1,3 +1,4 @@ +import { fetchWithConnectionProxy } from "./connectionProxyFetch.ts"; import { normalizeConolCookie } from "./conolAuth.ts"; interface UsageQuota { @@ -77,7 +78,8 @@ function readProviderValue(data: unknown, keys: readonly string[]): string { export async function getConolUsage( apiKey: unknown, - providerSpecificData?: unknown + providerSpecificData?: unknown, + connectionId?: string ): Promise { const raw = readProviderValue(providerSpecificData, [ @@ -89,15 +91,30 @@ export async function getConolUsage( if (!cookie) return { message: "Missing Conol session cookie" }; try { - const response = await fetch("https://conol.ai/api/billing/balance", { - method: "GET", - headers: { - accept: "application/json", - cookie, - referer: "https://conol.ai/home", - }, - signal: AbortSignal.timeout(15_000), - }); + const response = connectionId + ? await fetchWithConnectionProxy( + connectionId, + "https://conol.ai/api/billing/balance", + { + method: "GET", + headers: { + accept: "application/json", + cookie, + referer: "https://conol.ai/home", + }, + signal: AbortSignal.timeout(15_000), + } + ) + : await globalThis.fetch("https://conol.ai/api/billing/balance", { + method: "GET", + headers: { + accept: "application/json", + cookie, + referer: "https://conol.ai/home", + }, + signal: AbortSignal.timeout(15_000), + }); + if (!response) return { message: "Conol balance request failed" }; if (response.status === 401 || response.status === 403) { return { message: "Conol session expired or is invalid" }; } diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index aca43f727310..5cc05897e37f 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -148,7 +148,7 @@ export async function getUsageForProvider( switch (provider) { case "github": - return await getGitHubUsage(accessToken, providerSpecificData); + return await getGitHubUsage(accessToken, providerSpecificData, id || ""); case "antigravity": case "agy": return await getAntigravityUsage( @@ -160,16 +160,18 @@ export async function getUsageForProvider( options ); case "claude": - return await getClaudeUsage(accessToken, { forceRefresh: options.forceRefresh === true }); + return await getClaudeUsage(accessToken, id || "", { + forceRefresh: options.forceRefresh === true, + }); case "codex": // /me/status and the quota-cache refresh reach this fetch with no // caller-side pacing. Gate the start here so those paths, and any later // one, cannot burst the usage endpoint. Callers that already acquired the // shared gate wait at most one more interval. await throttleQuotaFetch(); - return await getCodexUsage(accessToken, providerSpecificData); + return await getCodexUsage(accessToken, providerSpecificData, id || ""); case "cursor": - return await getCursorUsage(accessToken || "", providerSpecificData); + return await getCursorUsage(accessToken || "", providerSpecificData, id || ""); case "kiro": case "amazon-q": return await getKiroUsage(accessToken, providerSpecificData); @@ -268,25 +270,25 @@ export async function getUsageForProvider( case "volcengine-coding-plan": return await getVolcenginePlanUsage(apiKey || "", provider, providerSpecificData); case "command-code": - return await getCommandCodeUsage(apiKey || accessToken || ""); + return await getCommandCodeUsage(apiKey || accessToken || "", id || ""); case "conol-web": case "cnl": - return await getConolUsage(apiKey || accessToken, providerSpecificData); + return await getConolUsage(apiKey || accessToken, providerSpecificData, id || ""); case "syntx": case "stx": - return await getSyntxUsage(apiKey || accessToken, providerSpecificData); + return await getSyntxUsage(apiKey, accessToken, providerSpecificData, id || ""); case "agentrouter": return await getAgentrouterUsage(id, connection); case "kilocode": return await getKilocodeUsage(id, connection); case "devin-cli": // Devin CLI tokens live in `accessToken` (oauth import) or `apiKey`. - return await getDevinCliUsage(apiKey || accessToken); + return await getDevinCliUsage(apiKey || accessToken, id || ""); case "anthropic": return await getAnthropicApiKeyUsage(apiKey); case "chatplayground": case "cpl": - return await getChatPlaygroundUsage(apiKey || accessToken, providerSpecificData); + return await getChatPlaygroundUsage(apiKey || accessToken, providerSpecificData, id || ""); default: return { message: `Usage API not implemented for ${provider}` }; } diff --git a/open-sse/services/usage/chatplayground.ts b/open-sse/services/usage/chatplayground.ts index b0db90bcf856..a3ee8c24d5b0 100644 --- a/open-sse/services/usage/chatplayground.ts +++ b/open-sse/services/usage/chatplayground.ts @@ -5,6 +5,7 @@ * Lifetime subscription provides 300 credits daily. */ import { type UsageQuota } from "./quota.ts"; +import { fetchWithConnectionProxy } from "../connectionProxyFetch.ts"; import { resolveChatPlaygroundAuth } from "../chatplaygroundAuth.ts"; import { CHATPLAYGROUND_USER_URL } from "../chatplaygroundModels.ts"; import { sanitizeErrorMessage } from "../../utils/error.ts"; @@ -234,7 +235,8 @@ export function buildChatPlaygroundQuotas( export async function getChatPlaygroundUsage( apiKey?: string, - providerSpecificData?: Record | null + providerSpecificData?: Record | null, + connectionId?: string ): Promise<{ plan?: string; quotas?: Record; @@ -246,10 +248,18 @@ export async function getChatPlaygroundUsage( providerSpecificData, }); - const res = await fetch(CHATPLAYGROUND_USER_URL, { - method: "GET", - headers: auth.headers, - }); + const res = connectionId + ? await fetchWithConnectionProxy(connectionId, CHATPLAYGROUND_USER_URL, { + method: "GET", + headers: auth.headers, + }) + : await globalThis.fetch(CHATPLAYGROUND_USER_URL, { + method: "GET", + headers: auth.headers, + }); + if (!res) { + throw new Error("proxied request failed"); + } if (!res.ok) { const errText = await res.text().catch(() => ""); diff --git a/open-sse/services/usage/claude.ts b/open-sse/services/usage/claude.ts index a489d53016ba..883814dd0271 100644 --- a/open-sse/services/usage/claude.ts +++ b/open-sse/services/usage/claude.ts @@ -22,9 +22,20 @@ import { } from "../claudeUsageCooldown.ts"; import { boundedMap } from "../../../src/lib/quota/boundedMap.ts"; import { sanitizeErrorMessage } from "../../utils/errorSanitization.ts"; +import { fetchWithConnectionProxy } from "../connectionProxyFetch.ts"; import { toRecord } from "./scalars.ts"; import { normalizeClaudeUsageQuotas, type NormalizedClaudeUsageQuotas } from "./claudeQuota.ts"; +/** Run one usage request through the proxy when an id is present, else directly. */ +async function proxiedOrDirect( + connectionId: string | undefined, + url: string, + init: RequestInit +): Promise { + if (connectionId) return fetchWithConnectionProxy(connectionId, url, init); + return globalThis.fetch(url, init); +} + // Claude API config const CLAUDE_CONFIG = { oauthUsageUrl: "https://api.anthropic.com/api/oauth/usage", @@ -113,8 +124,13 @@ function staleFromCache(cached: CachedClaudeUsage): ClaudeUsageResult { */ export function getClaudeUsage( accessToken?: string, - options: { forceRefresh?: boolean } = {} + connectionIdOrOptions?: string | { forceRefresh?: boolean }, + maybeOptions: { forceRefresh?: boolean } = {} ): Promise { + const connectionId = + typeof connectionIdOrOptions === "string" ? connectionIdOrOptions : undefined; + const options = + typeof connectionIdOrOptions === "string" ? maybeOptions : (connectionIdOrOptions ?? {}); if (!accessToken) { return Promise.resolve({ message: "Claude connected. Access token not available.", @@ -141,14 +157,22 @@ export function getClaudeUsage( }); } - const request: Promise = fetchClaudeUsage(accessToken, key).finally(() => { + const request: Promise = fetchClaudeUsage( + accessToken, + key, + connectionId + ).finally(() => { if (inFlightUsage.get(key) === request) inFlightUsage.delete(key); }); inFlightUsage.set(key, request); return request; } -async function fetchClaudeUsage(accessToken: string, key: string): Promise { +async function fetchClaudeUsage( + accessToken: string, + key: string, + connectionId?: string +): Promise { // Skip the OAuth usage call while this token is cooling down from a recent 429 // (chat with the same token still works — only the quota endpoint is throttled). if (isClaudeOauthUsageCoolingDown(accessToken)) { @@ -167,24 +191,30 @@ async function fetchClaudeUsage(accessToken: string, key: string): Promise ctrl.abort(), 10_000); - let oauthResponse: Response; + const oauthInit: RequestInit = { + method: "GET", + headers: { + Accept: "application/json, text/plain, */*", + "Accept-Encoding": "gzip, compress, deflate, br", + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": `claude-code/${getClaudeCodeVersion()}`, + "anthropic-beta": "oauth-2025-04-20", + }, + signal: ctrl.signal, + }; + let oauthResponse: Response | null; try { - oauthResponse = await fetch(CLAUDE_CONFIG.oauthUsageUrl, { - method: "GET", - headers: { - Accept: "application/json, text/plain, */*", - "Accept-Encoding": "gzip, compress, deflate, br", - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": `claude-code/${getClaudeCodeVersion()}`, - "anthropic-beta": "oauth-2025-04-20", - }, - signal: ctrl.signal, - }); + oauthResponse = await proxiedOrDirect(connectionId, CLAUDE_CONFIG.oauthUsageUrl, oauthInit); } finally { clearTimeout(timer); } + if (!oauthResponse) { + const legacy = await getClaudeUsageLegacy(accessToken, connectionId); + return { ...legacy, bootstrap: await bootstrapPromise }; + } + if (oauthResponse.ok) { const data = toRecord(await oauthResponse.json()); const { quotas, modelQuotas } = normalizeClaudeUsageQuotas(data); @@ -238,7 +268,7 @@ async function fetchClaudeUsage(accessToken: string, key: string): Promise = {} + providerSpecificData: Record = {}, + connectionId?: string ) { try { // Use persisted workspace ID from OAuth - NO FALLBACK @@ -54,10 +56,23 @@ export async function getCodexUsage( headers["chatgpt-account-id"] = accountId; } - const response = await codexUsageFetch(CODEX_CONFIG.usageUrl, { - method: "GET", - headers, - }); + const response = connectionId + ? await fetchWithConnectionProxy( + connectionId, + CODEX_CONFIG.usageUrl, + { + method: "GET", + headers, + }, + { fetchFn: codexUsageFetch } + ) + : await codexUsageFetch(CODEX_CONFIG.usageUrl, { + method: "GET", + headers, + }); + if (!response) { + throw new Error("Codex API error: proxied request failed"); + } if (!response.ok) { if (response.status === 401 || response.status === 403) { diff --git a/open-sse/services/usage/command-code.ts b/open-sse/services/usage/command-code.ts index 4ad7e199e293..5882f981af9c 100644 --- a/open-sse/services/usage/command-code.ts +++ b/open-sse/services/usage/command-code.ts @@ -12,6 +12,7 @@ */ import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { fetchWithConnectionProxy } from "../connectionProxyFetch.ts"; import { toNumber, toRecord } from "./scalars.ts"; import { createQuotaFromUsage, parseResetTime, type UsageQuota } from "./quota.ts"; @@ -59,12 +60,13 @@ function orgQuery(orgId: string | null | undefined): string { async function fetchJson( path: string, - apiKey: string + apiKey: string, + connectionId?: string ): Promise<{ ok: boolean; status: number; body: JsonRecord | null }> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { - const response = await fetch(`${COMMAND_CODE_API_BASE}${path}`, { + const commandInit: RequestInit = { method: "GET", headers: { Authorization: `Bearer ${apiKey}`, @@ -72,7 +74,11 @@ async function fetchJson( "Content-Type": "application/json", }, signal: controller.signal, - }); + }; + const response = connectionId + ? await fetchWithConnectionProxy(connectionId, `${COMMAND_CODE_API_BASE}${path}`, commandInit) + : await globalThis.fetch(`${COMMAND_CODE_API_BASE}${path}`, commandInit); + if (!response) return { ok: false, status: 0, body: null }; const text = await response.text(); let body: JsonRecord | null = null; if (text) { @@ -107,7 +113,7 @@ function windowQuota(window: unknown, displayName: string): UsageQuota | null { /** * Command Code Usage — monthly credit pool + 5h/weekly rolling windows. */ -export async function getCommandCodeUsage(apiKey: string) { +export async function getCommandCodeUsage(apiKey: string, connectionId?: string) { if (!apiKey) { return { message: "Command Code API key not available. Add a key to view usage." }; } @@ -115,7 +121,7 @@ export async function getCommandCodeUsage(apiKey: string) { try { let orgId: string | null = null; try { - const whoami = await fetchJson("/alpha/whoami", apiKey); + const whoami = await fetchJson("/alpha/whoami", apiKey, connectionId); if (whoami.status === 401 || whoami.status === 403) { return { message: @@ -132,7 +138,7 @@ export async function getCommandCodeUsage(apiKey: string) { } const q = orgQuery(orgId); - const creditsRes = await fetchJson(`/alpha/billing/credits${q}`, apiKey); + const creditsRes = await fetchJson(`/alpha/billing/credits${q}`, apiKey, connectionId); if (creditsRes.status === 401 || creditsRes.status === 403) { return { @@ -154,7 +160,7 @@ export async function getCommandCodeUsage(apiKey: string) { let periodEnd: string | null = null; try { - const subRes = await fetchJson(`/alpha/billing/subscriptions${q}`, apiKey); + const subRes = await fetchJson(`/alpha/billing/subscriptions${q}`, apiKey, connectionId); if (subRes.ok && subRes.body) { const data = toRecord(subRes.body.data); if (typeof data.planId === "string" && data.planId.trim()) { @@ -173,7 +179,7 @@ export async function getCommandCodeUsage(apiKey: string) { try { const sinceQ = periodStart != null ? `${q ? `${q}&` : "?"}since=${encodeURIComponent(periodStart)}` : q; - const summaryRes = await fetchJson(`/alpha/usage/summary${sinceQ}`, apiKey); + const summaryRes = await fetchJson(`/alpha/usage/summary${sinceQ}`, apiKey, connectionId); if (summaryRes.ok && summaryRes.body) { const cost = toNumber(summaryRes.body.totalCost, Number.NaN); if (Number.isFinite(cost) && cost >= 0) { diff --git a/open-sse/services/usage/cursor.ts b/open-sse/services/usage/cursor.ts index a0d15ee0b1c6..a64b0d9177e7 100644 --- a/open-sse/services/usage/cursor.ts +++ b/open-sse/services/usage/cursor.ts @@ -8,6 +8,7 @@ */ import { toRecord, toNumber, clampPercentage } from "./scalars.ts"; +import { fetchWithConnectionProxy } from "../connectionProxyFetch.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; const REQUEST_TIMEOUT_MS = 12_000; @@ -124,13 +125,20 @@ function buildPlanUsageQuotas( async function fetchJson( url: string, - init: RequestInit + init: RequestInit, + connectionId?: string ): Promise<{ ok: true; data: Record } | { ok: false }> { try { - const response = await fetch(url, { - ...init, - signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); + const response = connectionId + ? await fetchWithConnectionProxy(connectionId, url, { + ...init, + signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + : await globalThis.fetch(url, { + ...init, + signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response) return { ok: false }; if (!response.ok) return { ok: false }; const data = toRecord(await response.json().catch(() => null)); if (Object.keys(data).length === 0) return { ok: false }; @@ -234,10 +242,11 @@ function tryAuthUsage(data: Record): CursorUsageResult | null { async function fetchCookieDashboardUsage( accessToken: string, - userId: string + userId: string, + connectionId?: string ): Promise { try { - const response = await fetch(CURSOR_COOKIE_USAGE_CONFIG.usageUrl, { + const cookieInit: RequestInit = { method: "POST", redirect: "manual", headers: { @@ -250,7 +259,20 @@ async function fetchCookieDashboardUsage( }, body: "{}", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); + }; + const response = connectionId + ? await fetchWithConnectionProxy( + connectionId, + CURSOR_COOKIE_USAGE_CONFIG.usageUrl, + cookieInit + ) + : await globalThis.fetch(CURSOR_COOKIE_USAGE_CONFIG.usageUrl, cookieInit); + if (!response) { + return { + plan: "Cursor", + message: "Cursor connected. No active plan usage returned.", + }; + } if (response.status >= 300 && response.status < 400) { return { @@ -301,7 +323,8 @@ async function fetchCookieDashboardUsage( */ export async function getCursorUsage( accessToken: string, - providerSpecificData?: unknown + providerSpecificData?: unknown, + connectionId?: string ): Promise { if (!accessToken) { return { message: `Cursor access token missing. ${REAUTH_HINT}` }; @@ -309,33 +332,45 @@ export async function getCursorUsage( const auth = bearerHeaders(accessToken); - const period = await fetchJson(CURSOR_PERIOD_USAGE_URL, { - method: "POST", - headers: { - ...auth, - "Content-Type": "application/json", - "Connect-Protocol-Version": "1", + const period = await fetchJson( + CURSOR_PERIOD_USAGE_URL, + { + method: "POST", + headers: { + ...auth, + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + }, + body: "{}", }, - body: "{}", - }); + connectionId + ); if (period.ok) { const mapped = tryPeriodUsage(period.data); if (mapped) return mapped; } - const summary = await fetchJson(CURSOR_USAGE_SUMMARY_URL, { - method: "GET", - headers: auth, - }); + const summary = await fetchJson( + CURSOR_USAGE_SUMMARY_URL, + { + method: "GET", + headers: auth, + }, + connectionId + ); if (summary.ok) { const mapped = tryUsageSummary(summary.data); if (mapped) return mapped; } - const authUsage = await fetchJson(CURSOR_AUTH_USAGE_URL, { - method: "GET", - headers: auth, - }); + const authUsage = await fetchJson( + CURSOR_AUTH_USAGE_URL, + { + method: "GET", + headers: auth, + }, + connectionId + ); if (authUsage.ok) { const mapped = tryAuthUsage(authUsage.data); if (mapped) return mapped; @@ -354,5 +389,5 @@ export async function getCursorUsage( }; } - return fetchCookieDashboardUsage(accessToken, userId); + return fetchCookieDashboardUsage(accessToken, userId, connectionId); } diff --git a/open-sse/services/usage/devinCli.ts b/open-sse/services/usage/devinCli.ts index 3a882cc43faf..bc28830af3d6 100644 --- a/open-sse/services/usage/devinCli.ts +++ b/open-sse/services/usage/devinCli.ts @@ -29,6 +29,7 @@ */ import { parseResetTime, type UsageQuota } from "./quota.ts"; +import { fetchWithConnectionProxy } from "../connectionProxyFetch.ts"; const SEAT_MANAGEMENT_API_BASE = process.env.DEVIN_SEAT_API_URL?.trim() || "https://server.codeium.com"; @@ -211,30 +212,40 @@ function percentQuota( // ─── Fetcher ───────────────────────────────────────────────────────────────── -export async function getDevinCliUsage(token: string | null | undefined) { +export async function getDevinCliUsage(token: string | null | undefined, connectionId?: string) { if (!token?.trim()) { return { message: "Devin token not available. Import a Devin token to view usage." }; } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - let response: Response; + const seatInit: RequestInit = { + method: "POST", + headers: { + "Content-Type": "application/proto", + "Connect-Protocol-Version": CONNECT_PROTOCOL_VERSION, + Authorization: `Basic ${token}-${token}`, + }, + body: new Uint8Array(buildGetUserStatusRequest(token.trim())), + signal: controller.signal, + }; + let response: Response | null; try { - response = await fetch(`${SEAT_MANAGEMENT_API_BASE}${GET_USER_STATUS_PATH}`, { - method: "POST", - headers: { - "Content-Type": "application/proto", - "Connect-Protocol-Version": CONNECT_PROTOCOL_VERSION, - Authorization: `Basic ${token}-${token}`, - }, - body: new Uint8Array(buildGetUserStatusRequest(token.trim())), - signal: controller.signal, - }); + response = connectionId + ? await fetchWithConnectionProxy( + connectionId, + `${SEAT_MANAGEMENT_API_BASE}${GET_USER_STATUS_PATH}`, + seatInit + ) + : await globalThis.fetch(`${SEAT_MANAGEMENT_API_BASE}${GET_USER_STATUS_PATH}`, seatInit); } catch (error) { return { message: `Devin usage error: ${(error as Error).message}` }; } finally { clearTimeout(timer); } + if (!response) { + return { message: "Devin quota response could not be parsed." }; + } if (!response.ok) { return { message: `Devin GetUserStatus failed (${response.status})` }; diff --git a/open-sse/services/usage/github.ts b/open-sse/services/usage/github.ts index 308d11df689c..ce5f66f9f251 100644 --- a/open-sse/services/usage/github.ts +++ b/open-sse/services/usage/github.ts @@ -13,6 +13,7 @@ */ import { getGitHubCopilotInternalUserHeaders } from "../../config/providerHeaderProfiles.ts"; +import { fetchWithConnectionProxy } from "../connectionProxyFetch.ts"; import { toRecord, toNumber, getFieldValue, clampPercentage, toDisplayLabel } from "./scalars.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; @@ -28,16 +29,31 @@ export function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is Usa * GitHub Copilot Usage * Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API */ -export async function getGitHubUsage(accessToken?: string, providerSpecificData?: JsonRecord) { +export async function getGitHubUsage( + accessToken?: string, + providerSpecificData?: JsonRecord, + connectionId?: string +) { try { if (!accessToken) { throw new Error("No GitHub access token available. Please re-authorize the connection."); } // copilot_internal/user API requires GitHub OAuth token, not copilotToken - const response = await fetch("https://api.github.com/copilot_internal/user", { - headers: getGitHubCopilotInternalUserHeaders(`token ${accessToken}`), - }); + const response = connectionId + ? await fetchWithConnectionProxy( + connectionId, + "https://api.github.com/copilot_internal/user", + { + headers: getGitHubCopilotInternalUserHeaders(`token ${accessToken}`), + } + ) + : await globalThis.fetch("https://api.github.com/copilot_internal/user", { + headers: getGitHubCopilotInternalUserHeaders(`token ${accessToken}`), + }); + if (!response) { + return { message: "GitHub Copilot connected. Unable to parse quota data." }; + } if (!response.ok) { const error = await response.text(); diff --git a/open-sse/services/usage/syntx.ts b/open-sse/services/usage/syntx.ts index 4fee3812ebef..5ccc874659b9 100644 --- a/open-sse/services/usage/syntx.ts +++ b/open-sse/services/usage/syntx.ts @@ -6,6 +6,7 @@ * GET https://api.syntx.ai/api/v1/llm/limits → plan, window_6h/7d percent_left */ import { SYNTX_API_BASE, looksLikeJwt, resolveSyntxToken, syntxAuthHeaders } from "../syntxAuth.ts"; +import { fetchWithConnectionProxy } from "../connectionProxyFetch.ts"; import { parseResetTime, type UsageQuota } from "./quota.ts"; import { sanitizeErrorMessage } from "../../utils/error.ts"; @@ -125,7 +126,8 @@ export function buildSyntxUsageResult(subscription: SyntxSubscription, limits: S export async function getSyntxUsage( apiKey?: unknown, accessToken?: unknown, - providerSpecificData?: unknown + providerSpecificData?: unknown, + connectionId?: string ) { const token = resolveSyntxToken({ apiKey, accessToken, providerSpecificData }); if (!looksLikeJwt(token)) { @@ -134,18 +136,27 @@ export async function getSyntxUsage( try { const headers = syntxAuthHeaders(token); - const [subRes, limRes] = await Promise.all([ - fetch(`${SYNTX_API_BASE}/api/v1/user/subscription?ref=false`, { - method: "GET", - headers, - signal: AbortSignal.timeout(15_000), - }), - fetch(`${SYNTX_API_BASE}/api/v1/llm/limits`, { - method: "GET", - headers, - signal: AbortSignal.timeout(15_000), - }), - ]); + const subUrl = `${SYNTX_API_BASE}/api/v1/user/subscription?ref=false`; + const limUrl = `${SYNTX_API_BASE}/api/v1/llm/limits`; + const subInit: RequestInit = { + method: "GET", + headers, + signal: AbortSignal.timeout(15_000), + }; + const limInit: RequestInit = { + method: "GET", + headers, + signal: AbortSignal.timeout(15_000), + }; + const [subRes, limRes] = connectionId + ? await Promise.all([ + fetchWithConnectionProxy(connectionId, subUrl, subInit), + fetchWithConnectionProxy(connectionId, limUrl, limInit), + ]) + : await Promise.all([globalThis.fetch(subUrl, subInit), globalThis.fetch(limUrl, limInit)]); + if (!subRes || !limRes) { + return { message: "SYNTX limits failed (HTTP unknown)" }; + } if ( subRes.status === 401 || diff --git a/tests/unit/usage-connection-proxy.test.ts b/tests/unit/usage-connection-proxy.test.ts new file mode 100644 index 000000000000..ee216aa50186 --- /dev/null +++ b/tests/unit/usage-connection-proxy.test.ts @@ -0,0 +1,215 @@ +/** + * usage-connection-proxy.test.ts — usage readers run through the connection proxy. + * + * Guard: with a proxy assigned to the connection, the upstream usage request + * runs inside the connection proxy context instead of a direct fetch; + * failures surface as the existing message and never retry outside the proxy. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + connectionProxyTestOverride, + fetchWithConnectionProxy, +} from "../../open-sse/services/connectionProxyFetch.ts"; +import { hasAmbientProxyContext } from "../../open-sse/utils/proxyFetch.ts"; +import { getUsageForProvider } from "../../open-sse/services/usage.ts"; + +const ASSIGNED_PROXY = { type: "socks5", host: "127.0.0.1", port: 9 }; +const BALANCE_BODY = JSON.stringify({ + dailyCredits: 10, + subscriptionCredits: 20, + subscriptionAmount: 100, + extraCredits: 5, + total: 35, +}); +const originalFetch = globalThis.fetch; + +function balanceResponse(): Response { + return new Response(BALANCE_BODY, { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function useAssignedProxy(): void { + connectionProxyTestOverride.resolveProxyForConnection = async () => ({ + proxy: ASSIGNED_PROXY, + }); +} + +function useNoProxy(): void { + connectionProxyTestOverride.resolveProxyForConnection = async () => ({ proxy: null }); +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; + connectionProxyTestOverride.resolveProxyForConnection = null; +}); + +test("conol reading with an assigned proxy runs inside the proxy context", async () => { + useAssignedProxy(); + let observedInContext: boolean | null = null; + let observedCookie = ""; + const cookie = "session-token=abc123"; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + observedInContext = hasAmbientProxyContext(); + observedCookie = String((init?.headers as Record | undefined)?.cookie ?? ""); + return balanceResponse(); + }) as typeof globalThis.fetch; + const result = (await getUsageForProvider({ + provider: "conol-web", + apiKey: cookie, + id: "conn-proxy", + })) as { plan?: string; quotas?: Record }; + assert.equal(observedInContext, true); + assert.equal(observedCookie, cookie); + assert.equal(result.plan, "Subscription"); + assert.equal(result.quotas?.credits.remaining, 35); +}); + +test("conol reading without a proxy calls the console directly", async () => { + useNoProxy(); + let observedInContext: boolean | null = null; + let observedUrl = ""; + const cookie = "session-token=abc123"; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + observedInContext = hasAmbientProxyContext(); + observedUrl = String(url); + void init; + return balanceResponse(); + }) as typeof globalThis.fetch; + const result = (await getUsageForProvider({ + provider: "conol-web", + apiKey: cookie, + id: "conn-direct", + })) as { plan?: string; quotas?: Record }; + assert.equal(observedInContext, false); + assert.equal(observedUrl, "https://conol.ai/api/billing/balance"); + assert.equal(result.plan, "Subscription"); + assert.equal(result.quotas?.credits.remaining, 35); +}); + +test("a proxied conol failure surfaces the existing message without a direct retry", async () => { + useAssignedProxy(); + let calls = 0; + let observedInContext: boolean | null = null; + globalThis.fetch = (async () => { + calls += 1; + observedInContext = hasAmbientProxyContext(); + throw new Error("proxy blocked the console domain"); + }) as typeof globalThis.fetch; + const result = (await getUsageForProvider({ + provider: "conol-web", + apiKey: "session-token=abc123", + id: "conn-blocked", + })) as { message?: string }; + assert.equal(observedInContext, true); + assert.equal(result.message, "Conol balance request failed"); + assert.equal(calls, 1); +}); + +test("syntx reading with two bearer tokens sends the JWT to both requests", async () => { + useNoProxy(); + const plain = "plainA"; + const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiIxIn0.x"; + const seen: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + seen.push(String((init?.headers as Record | undefined)?.authorization ?? "")); + if (String(url).includes("/llm/limits")) { + return new Response( + JSON.stringify({ + plan: "pro", + window_6h: { percent_left: 80 }, + window_7d: { percent_left: 70 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return new Response(JSON.stringify({ tokens: "100", type: "pro" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof globalThis.fetch; + const result = (await getUsageForProvider({ + provider: "syntx", + apiKey: plain, + accessToken: jwt, + })) as { plan?: string; message?: string | null }; + assert.equal(seen.length, 2); + assert.deepEqual(seen, [`Bearer ${jwt}`, `Bearer ${jwt}`]); + assert.equal(result.message, null); + assert.equal(result.plan, "SYNTX Pro"); +}); + +test("a reading without a connection identifier makes no fetch", async () => { + useAssignedProxy(); + const { fetchWithConnectionProxy: proxiedFetch } = + await import("../../open-sse/services/connectionProxyFetch.ts"); + let calls = 0; + const fetchFn = (async () => { + calls += 1; + return balanceResponse(); + }) as typeof globalThis.fetch; + const response = await proxiedFetch("", "https://conol.ai/api/billing/balance", undefined, { + resolveProxyForConnection: async () => ({ proxy: ASSIGNED_PROXY }), + fetchFn, + }); + assert.equal(response, null); + assert.equal(calls, 0); +}); + +test("usage leaves route every proxied fetch through the connection proxy helper", async () => { + const here = dirname(fileURLToPath(import.meta.url)); + const root = join(here, "..", "..", "open-sse", "services"); + const leaves = [ + join(root, "conolUsage.ts"), + join(root, "usage", "github.ts"), + join(root, "usage", "claude.ts"), + join(root, "usage", "codex.ts"), + join(root, "usage", "cursor.ts"), + join(root, "usage", "syntx.ts"), + join(root, "usage", "devinCli.ts"), + join(root, "usage", "chatplayground.ts"), + join(root, "usage", "command-code.ts"), + ]; + for (const file of leaves) { + const source = readFileSync(file, "utf8"); + // Only the module seam initializer (`... => fetch(...)`) and qualified + // `globalThis.fetch` / `codexUsageFetch(...)` calls may remain. + const scrubbed = source + .replace(/=>\s*fetch\([^)]*\)/g, "") + .replace(/codexUsageFetch\(/g, "") + .replace(/globalThis\.fetch\(/g, ""); + assert.ok( + !/(^|[^a-zA-Z_.])fetch\(/.test(scrubbed), + `${file} must not call bare fetch directly` + ); + assert.ok( + source.includes("fetchWithConnectionProxy("), + `${file} must route through the connection proxy helper` + ); + } +}); + +test("the helper reports a missing identifier instead of fetching directly", async () => { + let calls = 0; + const fetchFn = (async (): Promise => { + calls += 1; + return balanceResponse(); + }) as typeof globalThis.fetch; + const response = await fetchWithConnectionProxy( + "", + "https://console.example/api/balance", + undefined, + { + resolveProxyForConnection: async () => ({ proxy: ASSIGNED_PROXY }), + fetchFn, + } + ); + assert.equal(response, null); + assert.equal(calls, 0); +});