diff --git a/FAQ.md b/FAQ.md index d6d3aac..1b3f985 100644 --- a/FAQ.md +++ b/FAQ.md @@ -40,7 +40,7 @@ does not reconstruct observations for intervals that were missed. ## If manual refresh works, should automatic refresh also work? -Usually for browser-session providers other than Kimi, as long as their +Usually for browser-session providers other than Kimi and Grok, as long as their permission and session remain valid, and for API-key providers while the saved key and host permission remain active. However, manual refresh bypasses scheduled backoff, while automatic refresh @@ -49,7 +49,11 @@ recovery. Cursor manual refresh may additionally request Grok Bot and extra-credit JSON through an already-open Cursor page, or by briefly opening one inactive spending tab when none is open; automatic refresh never opens or injects into a page. Scheduled refresh keeps last-good page values until Grok -Bot's weekly reset and explains when they could not be refreshed. +Bot's weekly reset and explains when they could not be refreshed. Grok +Connect or manual Refresh read grok.com from a page because extension-background +requests are rejected; they prefer an already-open grok.com tab and may briefly +open one inactive tab if none is open. Scheduled Grok refresh may inject into +an already-open grok.com tab and never opens a new one. Kimi has an additional limitation described below. @@ -70,6 +74,18 @@ or injects into a Cursor page. Last-good Grok Bot and extra-credit values stay on the card until Grok Bot's weekly reset; the card says why they could not be refreshed. +## Why does Grok need a grok.com page? + +Grok's session and usage endpoints reject extension-background requests when +the Origin is not `https://grok.com`. AI Limits therefore reads them only by +running bundled exact-origin code in a grok.com page. Connect or manual Refresh +prefer an already-open tab. If none is open, they may briefly create one +inactive `https://grok.com/` tab, wait up to 10 seconds, and close only the tab +they created. Scheduled refresh may inject into an already-open grok.com tab +and never opens a new one. The function does not inspect rendered content, +browser storage, or cookie values directly; Chrome attaches the signed-in Grok +cookies to those same-origin requests. + ## Why doesn't Kimi automatic refresh always work, and why can manual refresh open a background tab? Kimi automatic refresh is best-effort and may not always work; a manual Connect diff --git a/FAQ.zh-CN.md b/FAQ.zh-CN.md index cf04053..34825a9 100644 --- a/FAQ.zh-CN.md +++ b/FAQ.zh-CN.md @@ -36,13 +36,16 @@ Chrome 完全关闭或设备休眠时,AI Limits 无法刷新。恢复运行后 ## 手动刷新成功后,自动刷新也一定能成功吗? -除 Kimi 外的浏览器会话服务只要权限和会话仍然有效,通常可以;API 密钥服务 +除 Kimi 和 Grok 外的浏览器会话服务只要权限和会话仍然有效,通常可以;API 密钥服务 只要保存的密钥和主机权限仍处于有效状态,通常也可以。但手动刷新可以绕过 定时退避,自动刷新则会遵守退避时间。Kimi 手动刷新还可能执行交互式会话 恢复。Cursor 手动刷新还可能通过已经打开的 Cursor 页面请求 Grok Bot 和额外 Credits JSON;如果当前没有打开的标签页,可能会短暂打开一个非活动用量页。自动 刷新绝不会打开标签页或向页面注入代码。定时刷新会保留上次有效的页面数值, -直到 Grok Bot 的每周重置,并说明为何未能刷新。 +直到 Grok Bot 的每周重置,并说明为何未能刷新。Grok 的 Connect 或手动 Refresh +会从 grok.com 页面读取用量,因为扩展后台请求会被拒绝;它们会优先使用已经 +打开的 grok.com 标签页,如果没有则可能短暂打开一个非活动标签页。Grok +定时刷新可能向已经打开的 grok.com 标签页注入代码,但绝不会新建标签页。 Kimi 还有下述额外限制。 @@ -60,6 +63,16 @@ Limits 只会在 Connect 或明确的手动 Refresh 期间尝试请求。它会 上次有效的 Grok Bot 和额外 Credits 会保留在卡片上,直到 Grok Bot 的每周 重置;卡片会说明为何未能刷新。 +## 为什么 Grok 需要 grok.com 页面? + +Grok 的会话和用量接口会拒绝 Origin 不是 `https://grok.com` 的扩展后台请求。 +因此 AI Limits 只会在 grok.com 页面中运行捆绑的精确来源代码来读取这些接口。 +Connect 或手动 Refresh 会优先使用已经打开的标签页;如果当前没有打开的标签页, +可能会短暂创建一个非活动的 `https://grok.com/` 标签页,最多等待 10 秒,并且 +只关闭它自己创建的标签页。定时刷新可能向已经打开的 grok.com 标签页注入代码, +但绝不会新建标签页。该函数不会直接检查页面渲染内容、浏览器存储或 Cookie 值; +Chrome 会把已登录的 Grok Cookie 附加到这些同源请求。 + ## 为什么 Kimi 自动刷新不一定成功,而手动刷新可能打开后台标签页? Kimi 自动刷新属于尽力而为,并不保证每次都能成功;手动 Connect 或 Refresh diff --git a/PRIVACY.md b/PRIVACY.md index 55f3dba..4a16946 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -3,7 +3,7 @@ Last updated: August 26, 2026 AI Limits is a locally running Chrome extension by TiantianFlow. This policy -describes version 0.4.3. +describes version 0.4.4. AI Limits is an independent project. It is not affiliated with, endorsed by, or authorized by any supported provider, its parent company, or its affiliates. @@ -52,9 +52,10 @@ data, or other relay keys. ChatGPT, Claude, Kimi, Cursor, Grok, Mistral, and Perplexity use signed-in browser sessions. AI Limits sends read-only requests to those providers' own web-session services, and browser cookies may accompany requests to the same -provider origin. Requests normally run directly from the extension; the manual -Cursor enrichment described below runs in an already-open exact-origin page or, -if none is open, in one inactive spending tab created for that attempt. ChatGPT +provider origin. Requests normally run directly from the extension; the Cursor +and Grok page-origin reads described below run in an already-open exact-origin +page or, if none is open during Connect or manual Refresh, in one inactive tab +created for that attempt. ChatGPT and Kimi access credentials, the derived ChatGPT account identifier, and the selected Claude organization UUID and capabilities may be held in memory long enough to complete the related collection attempt and request sequence. Those @@ -79,6 +80,31 @@ background worker is interrupted. AI Limits attempts to remove it when recovery finishes and also performs best-effort abandoned-lease cleanup when the background worker starts. +For Grok, grok.com rejects extension-background session and usage requests +when the Origin is not `https://grok.com`. AI Limits therefore does not fetch +grok.com from the extension worker. It checks for one already-open tab matching +`https://grok.com/*` and uses the optional `scripting` permission to run a +bundled function in that page's main JavaScript world. During Connect or an +explicit manual Refresh only, if none exists, the same path may create one +inactive `https://grok.com/` tab. That create-wait-read path has a 10-second +deadline. When it finishes or times out, the extension attempts best-effort +cleanup of only the tab and lease it owns; browser shutdown or API errors can +delay or prevent cleanup. The function verifies the exact `https://grok.com` +origin, sends the signed-in session, usage-pool, subscription, and chat-mode +rate-limit requests, and returns only those responses for schema validation in +the extension context. It does not read rendered page content, local or session +storage, or cookie values directly. Chrome does attach the signed-in Grok +cookies to these fixed same-origin requests. AI Limits never activates a Grok +tab. Scheduled refresh may inject into an already-open grok.com tab and never +opens a new one. Raw page-probe bodies are request-local and are not persisted. + +While that owned Grok tab is active, the extension stores only a generated lease +identifier with the temporary tab ID and creation timestamp in +`chrome.storage.session`. This transient metadata supports cleanup if the +background worker is interrupted. AI Limits attempts to remove it when the +owned tab is released and also performs best-effort abandoned-lease cleanup +when the background worker starts. + For Cursor, the base monthly and on-demand request runs from the extension. During Connect or an explicit manual Refresh only, AI Limits checks for one already-open tab matching `https://cursor.com/*`. If one exists, it uses the @@ -108,9 +134,10 @@ owned tab is released and also performs best-effort abandoned-lease cleanup when the background worker starts. For conservative Chrome Web Store disclosure, AI Limits classifies its -current-tab exact-provider-origin checks as **Web history** access. For Kimi and -Cursor, the extension learns only whether a tab at that fixed provider origin -is currently open so it can complete the user-requested provider operation. +current-tab exact-provider-origin checks as **Web history** access. For Kimi, +Cursor, and Grok, the extension learns only whether a tab at that fixed +provider origin is currently open so it can complete the user-requested +provider operation. This open-state result is used locally for that attempt and is never retained, transmitted, or assembled into a list of visited pages. diff --git a/README.md b/README.md index 31df610..0d9715d 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,8 @@ is asked only for the exact instance origin entered in onboarding. Same-origin instances share that browser-global grant only; their credentials, labels, usage, and History remain independent. The optional `cookies` is requested only for Kimi session access. `scripting` is requested -for Kimi interactive recovery and for Cursor's manual/connect-only page -enrichment. Fixed API-key providers receive only optional access to their own +for Kimi interactive recovery, Cursor's manual/connect-only page enrichment, +and Grok page-origin session and usage reads. Fixed API-key providers receive only optional access to their own API origins. Public setup pages are opened normally and do not receive extension host access. The extension does not request the broad `tabs` permission. @@ -174,7 +174,7 @@ pnpm verify:zip ``` The command rebuilds the extension, creates -`.output/ai-limits-0.4.3-chrome.zip`, opens the archive, and verifies its +`.output/ai-limits-0.4.4-chrome.zip`, opens the archive, and verifies its manifest, entrypoints, permissions, and forbidden-file rules. ## Provider compatibility diff --git a/README.zh-CN.md b/README.zh-CN.md index 30ab6ce..268debd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -97,8 +97,9 @@ API 密钥连接时才会逐个请求。New API、LiteLLM、ClawRouter、sub2api LLM Proxy 因来源可配置而声明动态可选主机能力,但 Chrome 实际只会请求设置中 输入的那个实例来源。同一来源的多个实例只共享这一浏览器全局授权;凭据、标签、 用量和 History 始终相互独立。可选的 -`cookies` 权限只用于 Kimi 会话访问。`scripting` 权限用于 Kimi 交互式恢复, -以及 Cursor 仅在 Connect 或手动刷新期间执行的页面增强。固定 API 密钥服务只会 +`cookies` 权限只用于 Kimi 会话访问。`scripting` 权限用于 Kimi 交互式恢复、 +Cursor 仅在 Connect 或手动刷新期间执行的页面增强,以及 Grok 在 grok.com +页面中读取会话和用量。固定 API 密钥服务只会 获得各自 API 来源的可选访问权限;公开设置页面按普通网页打开,不会获得扩展 主机访问权限。扩展不会请求范围更广的 `tabs` 权限。 @@ -143,7 +144,7 @@ macOS 文件选择器中正常显示。点击扩展工具栏图标即可打开 pnpm verify:zip ``` -该命令会重新构建扩展,创建 `.output/ai-limits-0.4.3-chrome.zip`,打开压缩包, +该命令会重新构建扩展,创建 `.output/ai-limits-0.4.4-chrome.zip`,打开压缩包, 并验证清单、入口文件、权限和禁止包含的文件规则。 ## 服务兼容性 diff --git a/SECURITY.md b/SECURITY.md index 1225817..bbbcfc8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,6 +45,19 @@ values directly. Chrome attaches signed-in Cursor cookies to those same-origin requests. Scheduled refresh never injects into a Cursor page, and no raw dashboard JSON is persisted. +Grok collection may use `chrome.scripting` to run a bundled function in the +main JavaScript world of one already-open exact-origin grok.com page, or, on +Connect or manual Refresh only, of one inactive grok.com tab created for that +attempt. The returned session, usage-pool, subscription, and rate-limit +payloads are treated as untrusted and must pass the same extension-context +schemas and semantic validation as any provider response. The function +verifies `https://grok.com`, does not inspect rendered content, browser +storage, or cookie values directly, and never falls back to an +extension-background grok.com fetch. Chrome attaches signed-in Grok cookies to +those same-origin requests. Scheduled refresh may inject into an already-open +grok.com tab and never opens a new one. No raw page-probe bodies are +persisted. + Chrome's trusted extension contexts include the background worker and the side panel. The side-panel code does not request or read the credential record, but Chrome storage change events can expose local change objects to trusted diff --git a/SUPPORTED_PROVIDERS.md b/SUPPORTED_PROVIDERS.md index 92f1cd3..011932f 100644 --- a/SUPPORTED_PROVIDERS.md +++ b/SUPPORTED_PROVIDERS.md @@ -26,7 +26,7 @@ Balance presentation is explicit by provider: | Claude | Signed-in browser session | General and model-specific limits plus extra usage when reported | **OBSERVED** | Uses private web-session interfaces. Organization selection is request-local; a provider-supplied display label may be retained. | | Kimi | Browser session with targeted credential recovery | Subscription total and Kimi Code limits | **OBSERVED** | May read the exact legacy `kimi-auth` cookie or `localStorage.access_token`. Manual Connect or Refresh may briefly open one inactive Kimi tab so Kimi can refresh its own session. Automatic refresh never opens a tab and may defer when no usable session is available. | | Cursor | Signed-in browser session plus manual page enrichment | Cursor-model and other-model monthly limits, Grok Bot weekly usage, and on-demand/extra-credit data when reported | **OBSERVED** | Base usage refreshes in the background. Connect or manual Refresh can request Grok Bot and extra-credit JSON through one already-open `cursor.com` tab, or by briefly opening one inactive spending tab when none is open. Chrome attaches signed-in Cursor cookies to those fixed same-origin requests, but AI Limits does not inspect cookie values directly. It never activates a Cursor tab and closes only the tab it created. Automatic refresh never opens or injects. Last-good page values stay visible until Grok Bot's weekly reset, and the card explains when they could not be refreshed. | -| Grok | Signed-in browser session | Weekly or monthly usage pool as the subscription limit, with Grok Build and Chat composition when those buckets account for the whole pool, plus the SuperGrok-family plan label | **OBSERVED** | Uses private web-session usage interfaces on `grok.com`. Short-window per-mode rate-limit responses are not rendered. The browser may attach Grok cookies; account identifiers and raw subscription payloads are not persisted. This is consumer Grok, not Cursor's Grok Bot. | +| Grok | Signed-in browser session plus page-origin reads | Weekly or monthly usage pool as the subscription limit, with Grok Build and Chat composition when those buckets account for the whole pool, plus the SuperGrok-family plan label | **OBSERVED** | grok.com rejects extension-background session and usage requests, so AI Limits reads them from an already-open grok.com tab. Connect or manual Refresh may briefly open one inactive grok.com tab when none is open. Chrome attaches signed-in Grok cookies to those same-origin requests, but AI Limits does not inspect cookie values directly. It never activates a Grok tab and closes only the tab it created. Scheduled refresh may inject into an already-open grok.com tab and never opens a new one. Short-window per-mode rate-limit responses are not rendered for SuperGrok pool accounts. This is consumer Grok, not Cursor's Grok Bot. | | Mistral | Signed-in browser session | Month-to-date spend, token totals, and available credits | **INFERRED** | Chrome attaches same-origin cookies, but cannot expose the CSRF cookie value needed to construct `X-CSRFTOKEN`; live testing must confirm the service accepts the request without that header. | | Perplexity | Signed-in browser session | Recurring, purchased, and promotional credit pools plus plan inference | **INFERRED** | Uses a private billing endpoint and Chrome-attached same-origin cookies. Grant expiry and waterfall attribution follow the inferred wire contract. | | ElevenLabs | User-created API key | Subscription credit and voice-capacity limits | **OBSERVED** | The guide recommends **User → Read**. AI Limits calls the documented subscription endpoint and stores the validated key locally; no browser session is reused. | diff --git a/SUPPORTED_PROVIDERS.zh-CN.md b/SUPPORTED_PROVIDERS.zh-CN.md index 9f8daa1..d3f0c53 100644 --- a/SUPPORTED_PROVIDERS.zh-CN.md +++ b/SUPPORTED_PROVIDERS.zh-CN.md @@ -24,7 +24,7 @@ History 只保存在当前 Chrome 配置文件中。 | Claude | 浏览器登录会话 | 通用及模型专属限制,以及服务商报告的 Extra usage | **OBSERVED** | 使用未公开的网站会话接口。组织选择只在请求期间使用;服务商提供的显示标签可能被保留。 | | Kimi | 浏览器会话及定向凭据恢复 | 订阅总用量和 Kimi Code 限制 | **OBSERVED** | 可能读取名称完全匹配的旧版 `kimi-auth` Cookie 或 `localStorage.access_token`。手动 Connect 或 Refresh 可能短暂打开一个非活动 Kimi 标签页,让 Kimi 自己刷新会话。自动刷新绝不会打开标签页;没有可用会话时可能延后。 | | Cursor | 浏览器登录会话及手动页面增强 | Cursor 模型和其他模型的独立月度限制、Grok Bot 每周用量,以及服务商报告的按量支出/额外 Credits 数据 | **OBSERVED** | 基础用量在后台刷新。Connect 或手动 Refresh 可通过一个已经打开的 `cursor.com` 标签页请求 Grok Bot 和额外 Credits JSON;如果当前没有打开的标签页,可能会短暂打开一个非活动的用量页。Chrome 会把已登录的 Cursor Cookie 附加到这些固定的同源请求,但 AI Limits 不会直接检查 Cookie 值。扩展不会激活 Cursor 标签页,并且只关闭它自己创建的标签页。自动刷新绝不会打开标签页或注入代码。上次有效的页面数值会保留到 Grok Bot 的每周重置,卡片会说明为何未能刷新。 | -| Grok | 浏览器登录会话 | 以每周或每月用量池为订阅限额,并在 Grok Build 与 Chat 桶合计等于整池时显示构成,以及 SuperGrok 系列套餐标签 | **OBSERVED** | 使用 `grok.com` 上未公开的网站会话接口。按聊天模式返回的短窗速率限制不会显示。浏览器可能附带 Grok Cookie;账号标识和原始订阅响应不会持久化。这是面向消费者的 Grok,不是 Cursor 的 Grok Bot。 | +| Grok | 浏览器登录会话及页面来源读取 | 以每周或每月用量池为订阅限额,并在 Grok Build 与 Chat 桶合计等于整池时显示构成,以及 SuperGrok 系列套餐标签 | **OBSERVED** | grok.com 会拒绝扩展后台的会话和用量请求,因此 AI Limits 从已经打开的 grok.com 标签页读取。Connect 或手动 Refresh 在没有打开的标签页时可能会短暂打开一个非活动 grok.com 标签页。Chrome 会把已登录的 Grok Cookie 附加到这些同源请求,但 AI Limits 不会直接检查 Cookie 值。扩展不会激活 Grok 标签页,并且只关闭它自己创建的标签页。定时刷新可能向已经打开的 grok.com 标签页注入代码,但绝不会新建标签页。SuperGrok 用量池账号不会显示按聊天模式返回的短窗速率限制。这是面向消费者的 Grok,不是 Cursor 的 Grok Bot。 | | Mistral | 浏览器登录会话 | 本月至今支出、令牌总量和可用额度 | **INFERRED** | Chrome 会附加同源 Cookie,但无法暴露构造 `X-CSRFTOKEN` 所需的 CSRF Cookie 值;需要实时测试确认服务能接受缺少该请求头的请求。 | | Perplexity | 浏览器登录会话 | 周期性、已购买和促销额度池,以及套餐推断 | **INFERRED** | 使用私有账单接口和 Chrome 附加的同源 Cookie。额度过期与瀑布式归因遵循推断的响应契约。 | | ElevenLabs | 用户创建的 API 密钥 | 订阅 Credits 和语音容量限制 | **OBSERVED** | 指南建议使用 **User → Read**。AI Limits 调用有文档的订阅接口并在本地保存验证成功的密钥,不复用浏览器会话。 | diff --git a/i18n/presentation.ts b/i18n/presentation.ts index 231e4b4..4d67991 100644 --- a/i18n/presentation.ts +++ b/i18n/presentation.ts @@ -232,7 +232,11 @@ export function localizeConnectionDisclosure(providerKind: ProviderKind): string export function localizeManualRefreshDisclosure( providerKind: ProviderKind, ): string | undefined { - if (providerKind !== "kimi" && providerKind !== "cursor") { + if ( + providerKind !== "kimi" && + providerKind !== "cursor" && + providerKind !== "grok" + ) { return undefined; } return translate(`providers.${providerKind}.manualRefreshDisclosure`); diff --git a/locales/en.yml b/locales/en.yml index a0599fa..d7a4326 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -636,7 +636,8 @@ providers: grok: name: Grok connect: Connect Grok - connectionDisclosure: Reads usage from your signed-in browser session, stores normalized usage locally, and refreshes about every 15 minutes. + connectionDisclosure: Reads usage from an already-open grok.com page. On Connect or manual Refresh, AI Limits may run bundled read-only code there, or briefly open one inactive grok.com tab if none is open. It does not inspect page content, browser storage, or cookie values directly; Chrome attaches signed-in Grok cookies to those same-origin requests. Scheduled refresh may inject into an already-open grok.com tab and never opens a new one. + manualRefreshDisclosure: Connect and manual Refresh prefer an already-open grok.com tab. If none is open, they may briefly open one inactive grok.com tab. Scheduled refresh may inject into an already-open grok.com tab and never opens a new one. capabilities: usagePool: Usage pool planTier: Plan tier diff --git a/locales/zh_CN.yml b/locales/zh_CN.yml index eb3c841..fb52089 100644 --- a/locales/zh_CN.yml +++ b/locales/zh_CN.yml @@ -636,7 +636,8 @@ providers: grok: name: Grok connect: 连接 Grok - connectionDisclosure: 从你已登录的浏览器会话读取用量,在本地存储标准化用量,并大约每 15 分钟刷新一次。 + connectionDisclosure: 从已经打开的 grok.com 页面读取用量。在连接或手动刷新时,AI Limits 可能在该页面中运行捆绑的只读代码;如果当前没有打开的标签页,可能会短暂打开一个非活动 grok.com 标签页。它不会直接检查页面内容、浏览器存储或 cookie 值;Chrome 会把已登录的 Grok cookie 附加到这些同源请求。定时刷新可能向已经打开的 grok.com 标签页注入代码,但绝不会新建标签页。 + manualRefreshDisclosure: 连接和手动刷新会优先使用已经打开的 grok.com 标签页。如果当前没有打开的标签页,它们可能会短暂打开一个非活动 grok.com 标签页。定时刷新可能向已经打开的 grok.com 标签页注入代码,但绝不会新建标签页。 capabilities: usagePool: 用量池 planTier: 套餐等级 diff --git a/package.json b/package.json index 2b7fda8..6b2b091 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-limits", - "version": "0.4.3", + "version": "0.4.4", "license": "MIT", "private": true, "type": "module", diff --git a/providers/definitions.ts b/providers/definitions.ts index 79c3ab2..ad24b62 100644 --- a/providers/definitions.ts +++ b/providers/definitions.ts @@ -42,7 +42,7 @@ export const providerDefinitions = { credentialKind: "none", configKind: "fixed", optionalOrigins: ["https://grok.com/*"], - optionalPermissions: [], + optionalPermissions: ["scripting"], }, mistral: { cardinality: "single", diff --git a/providers/grok/adapter.test.ts b/providers/grok/adapter.test.ts index 415ee8d..136ed3c 100644 --- a/providers/grok/adapter.test.ts +++ b/providers/grok/adapter.test.ts @@ -719,6 +719,32 @@ describe("Grok adapter", () => { expect(rateLimitBodies(injectedFetch)).toEqual([]); }); + test("treats SuperGrok omitted credit_usage_percent as a 0% weekly pool", async () => { + const injectedFetch = signedInFetch(usageFixture(), { subscriptions: [] }, { + pool: grpcWebResponse( + encodeGrokCreditsConfigResponse({ + isUnifiedBillingUser: true, + currentPeriodType: USAGE_PERIOD_WEEKLY, + currentPeriodEndMs: SYNTHETIC_PERIOD_END, + }), + ), + }); + + const result = await grokAdapter.collect(context(injectedFetch)); + expect(result).toMatchObject({ + ok: true, + snapshot: { + metrics: [ + expect.objectContaining({ + id: "weekly-pool", + usedRatio: 0, + }), + ], + }, + }); + expect(rateLimitBodies(injectedFetch)).toEqual([]); + }); + test("maps an aborted request to a temporary error", async () => { const controller = new AbortController(); controller.abort(new DOMException("Timed out", "AbortError")); diff --git a/providers/grok/credits-config.test.ts b/providers/grok/credits-config.test.ts index 8ff0701..10e4f18 100644 --- a/providers/grok/credits-config.test.ts +++ b/providers/grok/credits-config.test.ts @@ -148,4 +148,36 @@ describe("Grok credits protobuf", () => { expect(inspected.metric.cycle).not.toHaveProperty("startedAt"); } }); + + test("treats an omitted credit_usage_percent with a valid period as zero usage", () => { + const decoded = decodeGrokCreditsConfigResponse( + encodeGrokCreditsConfigResponse({ + isUnifiedBillingUser: true, + currentPeriodType: USAGE_PERIOD_WEEKLY, + currentPeriodEndMs: SYNTHETIC_PERIOD_END, + }), + ); + expect(decoded).toMatchObject({ + ok: true, + config: { currentPeriodType: USAGE_PERIOD_WEEKLY }, + }); + if (!decoded.ok) { + return; + } + expect(decoded.config.creditUsagePercent).toBeUndefined(); + expect(inspectDecodedCreditsConfig(decoded.config)).toEqual({ + kind: "metric", + metric: { + type: "quota", + id: "weekly-pool", + label: "Weekly usage pool", + scope: "general", + usedRatio: 0, + cycle: { + cadence: "calendar", + resetsAt: SYNTHETIC_PERIOD_END, + }, + }, + }); + }); }); diff --git a/providers/grok/package.test.ts b/providers/grok/package.test.ts new file mode 100644 index 0000000..1ce2078 --- /dev/null +++ b/providers/grok/package.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { ProviderInstanceRecord } from "../../domain/model"; +import type { CollectionContext, CollectionResult } from "../types"; +import { createGrokPackage } from "./package"; +import type { GrokPageProbe } from "./page-session"; + +const NOW = 1_800_000_000_000; + +const instance: ProviderInstanceRecord = { + id: "grok:default", + providerKind: "grok", + config: { kind: "fixed" }, + access: "granted", + createdAt: 1, + history: [], +}; + +function services(interaction: "allowed" | "forbidden") { + return { + fetch: vi.fn(), + now: NOW, + signal: new AbortController().signal, + interaction, + }; +} + +function successfulCollection(): CollectionResult { + return { + ok: true, + snapshot: { + providerKind: "grok", + source: "web-session", + fetchedAt: NOW, + metrics: [], + }, + }; +} + +const pageRead: GrokPageProbe = { + kind: "read", + session: { + ok: true, + status: 200, + contentType: "application/json", + json: { status: "authenticated", session: { userId: "user-test" } }, + }, + pool: { ok: false, status: 404, contentType: "application/json" }, + rateLimits: { + fast: { ok: false, status: 404, contentType: "application/json" }, + expert: { ok: false, status: 404, contentType: "application/json" }, + heavy: { ok: false, status: 404, contentType: "application/json" }, + auto: { ok: false, status: 404, contentType: "application/json" }, + }, + subscriptions: { + ok: true, + status: 200, + contentType: "application/json", + json: { subscriptions: [] }, + }, +}; + +describe("Grok provider package", () => { + test("startup cleanup failure does not block package registration", async () => { + const collect = vi.fn<(context: CollectionContext) => Promise>(); + const findPageSession = vi.fn(); + const providerPackage = createGrokPackage({ + collect, + findPageSession, + cleanupAbandonedOwnedTab: vi.fn().mockRejectedValue(new Error("cleanup-private")), + }); + + await expect(providerPackage.startup?.()).resolves.toBeUndefined(); + expect(findPageSession).not.toHaveBeenCalled(); + expect(collect).not.toHaveBeenCalled(); + }); + + test("page-probe failures never fall back to a background grok.com fetch", async () => { + const collect = vi.fn<(context: CollectionContext) => Promise>(); + const runtime = services("allowed"); + const providerPackage = createGrokPackage({ + collect, + findPageSession: vi.fn().mockResolvedValue({ + kind: "inject_empty", + detail: "injection.result was null", + }), + }); + + await expect(providerPackage.collect(instance, runtime)).resolves.toEqual({ + ok: false, + health: { + kind: "provider_changed", + message: "page-probe: inject_empty: injection.result was null", + }, + }); + expect(collect).not.toHaveBeenCalled(); + expect(runtime.fetch).not.toHaveBeenCalled(); + }); + + test("scheduled collection reuses existing tabs and never opens an owned tab", async () => { + const collect = vi.fn<(context: CollectionContext) => Promise>(); + const findPageSession = vi.fn().mockResolvedValue({ kind: "no_tab" }); + const providerPackage = createGrokPackage({ collect, findPageSession }); + + await expect( + providerPackage.collect(instance, services("forbidden")), + ).resolves.toEqual({ + ok: false, + health: { + kind: "temporary_error", + message: "page-probe: no_tab", + }, + }); + expect(findPageSession).toHaveBeenCalledWith({ allowOwnedTab: false }); + expect(collect).not.toHaveBeenCalled(); + }); + + test("interactive collection may open an owned grok.com tab when none is open", async () => { + const collect = vi + .fn<(context: CollectionContext) => Promise>() + .mockResolvedValue(successfulCollection()); + const findPageSession = vi.fn().mockResolvedValue(pageRead); + const providerPackage = createGrokPackage({ collect, findPageSession }); + const runtime = services("allowed"); + + await expect(providerPackage.collect(instance, runtime)).resolves.toEqual( + successfulCollection(), + ); + expect(findPageSession).toHaveBeenCalledWith({ allowOwnedTab: true }); + expect(collect).toHaveBeenCalledTimes(1); + expect(runtime.fetch).not.toHaveBeenCalled(); + expect(collect.mock.calls[0]?.[0]?.fetch).not.toBe(runtime.fetch); + }); +}); diff --git a/providers/grok/package.ts b/providers/grok/package.ts new file mode 100644 index 0000000..d4ad4f7 --- /dev/null +++ b/providers/grok/package.ts @@ -0,0 +1,168 @@ +import type { ProviderInstanceRecord } from "../../domain/model"; +import { normalizeFixedConfig } from "../package-factories"; +import type { + CollectionResult, + ProviderCollector, + ProviderPackage, + ProviderRuntimeServices, +} from "../types"; +import { providerDefinitions } from "../definitions"; +import { + cleanupAbandonedOwnedTabs, + openOwnedBackgroundTab, + releaseOwnedBackgroundTab, + type ListenerCleanup, + type RemovedListener, + type UpdatedListener, +} from "../tab-ensure"; +import { grokAdapter } from "./adapter"; +import { + GROK_OWNED_TAB_LEASE_PREFIX, + GROK_OWNED_TAB_TIMEOUT_MS, + GROK_OWNED_TAB_URL, + fetchFromGrokPageRead, + findGrokPageSession, + healthFromGrokPageProbe, + type GrokPageProbe, +} from "./page-session"; + +interface GrokPackageDependencies { + collect: ProviderCollector<"grok">["collect"]; + findPageSession(options: { + allowOwnedTab: boolean; + }): Promise; + cleanupAbandonedOwnedTab?(): Promise; +} + +function matchesGrok(instance: ProviderInstanceRecord): boolean { + return ( + instance.providerKind === "grok" && + normalizeFixedConfig(instance.config) !== undefined + ); +} + +export function createGrokPackage( + dependencies: GrokPackageDependencies, +): ProviderPackage { + return { + kind: "grok", + cardinality: providerDefinitions.grok.cardinality, + credentialKind: providerDefinitions.grok.credentialKind, + configKind: providerDefinitions.grok.configKind, + normalizeConfig: normalizeFixedConfig, + requiredPermissions: (config) => + normalizeFixedConfig(config) + ? { + origins: [...providerDefinitions.grok.optionalOrigins], + permissions: [...providerDefinitions.grok.optionalPermissions], + } + : undefined, + async startup(): Promise { + try { + await dependencies.cleanupAbandonedOwnedTab?.(); + } catch { + // Startup cleanup must never block background registration. + } + }, + async collect(instance, services): Promise { + if (!matchesGrok(instance)) { + return { ok: false, health: { kind: "provider_changed" } }; + } + if (services.signal.aborted) { + return { ok: false, health: { kind: "temporary_error" } }; + } + + let probe: GrokPageProbe; + try { + probe = await dependencies.findPageSession({ + allowOwnedTab: services.interaction === "allowed", + }); + } catch { + probe = { kind: "inject_threw", detail: "findPageSession threw" }; + } + if (probe.kind !== "read") { + console.debug("[grok] page-probe:", probe); + return { ok: false, health: healthFromGrokPageProbe(probe) }; + } + return dependencies.collect({ + fetch: fetchFromGrokPageRead(probe, services.signal), + now: services.now, + signal: services.signal, + }); + }, + }; +} + +function grokOwnedTabBrowser() { + return { + createTab: (details: { url: string; active: false }) => + browser.tabs.create(details), + getTab: (tabId: number) => browser.tabs.get(tabId), + removeTab: (tabId: number) => browser.tabs.remove(tabId), + addUpdatedListener: (listener: UpdatedListener): ListenerCleanup => { + browser.tabs.onUpdated.addListener(listener); + return () => browser.tabs.onUpdated.removeListener(listener); + }, + addRemovedListener: (listener: RemovedListener): ListenerCleanup => { + browser.tabs.onRemoved.addListener(listener); + return () => browser.tabs.onRemoved.removeListener(listener); + }, + storageSession: browser.storage.session, + }; +} + +export const grokPackage = createGrokPackage({ + collect: (context) => grokAdapter.collect(context), + findPageSession: ({ allowOwnedTab }) => { + const tabs = grokOwnedTabBrowser(); + return findGrokPageSession({ + hasPagePermission: async () => { + try { + return Boolean( + await browser.permissions.contains({ + origins: [...providerDefinitions.grok.optionalOrigins], + permissions: [...providerDefinitions.grok.optionalPermissions], + }), + ); + } catch { + return false; + } + }, + queryTabs: (details) => browser.tabs.query(details), + executeScript: (details) => browser.scripting.executeScript(details), + openOwnedTab: allowOwnedTab + ? async () => { + const owned = await openOwnedBackgroundTab({ + url: GROK_OWNED_TAB_URL, + leaseKeyPrefix: GROK_OWNED_TAB_LEASE_PREFIX, + deadline: Date.now() + GROK_OWNED_TAB_TIMEOUT_MS, + ...tabs, + }); + if (!owned) return undefined; + return { + tabId: owned.tabId, + release: () => + releaseOwnedBackgroundTab({ + tabId: owned.tabId, + leaseKey: owned.leaseKey, + removeTab: tabs.removeTab, + storageSession: tabs.storageSession, + }), + }; + } + : undefined, + }); + }, + cleanupAbandonedOwnedTab: () => { + const tabs = grokOwnedTabBrowser(); + return cleanupAbandonedOwnedTabs({ + storageSession: tabs.storageSession, + getTab: tabs.getTab, + removeTab: tabs.removeTab, + leaseKeyPrefix: GROK_OWNED_TAB_LEASE_PREFIX, + remainsOnOwnedUrl: (url) => + url === GROK_OWNED_TAB_URL || + url?.startsWith("https://grok.com/") === true, + }); + }, +}); diff --git a/providers/grok/page-session.test.ts b/providers/grok/page-session.test.ts new file mode 100644 index 0000000..3e9dafe --- /dev/null +++ b/providers/grok/page-session.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + EMPTY_GRPC_WEB_UNARY, + GRPC_WEB_CONTENT_TYPE, +} from "./grpc-web"; +import { + clearGrokPageSessionStashAtExpectedOrigin, + fetchFromGrokPageRead, + findGrokPageSession, + healthFromGrokPageProbe, + rankGrokTabs, + readGrokPageSessionAtExpectedOrigin, + readGrokPageSessionStashAtExpectedOrigin, + startGrokPageSessionStashAtExpectedOrigin, + tabLooksAsleep, + type GrokPageRead, +} from "./page-session"; + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const granted = vi.fn().mockResolvedValue(true); + +const SESSION = { status: "authenticated", session: { userId: "user-test" } }; + +function pageRead(overrides: Partial> = {}): GrokPageRead { + return { + kind: "read", + session: { ok: true, status: 200, contentType: "application/json", json: SESSION }, + pool: { + ok: true, + status: 200, + contentType: GRPC_WEB_CONTENT_TYPE, + bodyBase64: btoa("pool"), + }, + rateLimits: { + fast: { ok: true, status: 200, contentType: "application/json", json: { modelName: "fast" } }, + expert: { ok: true, status: 200, contentType: "application/json", json: { modelName: "expert" } }, + heavy: { ok: true, status: 200, contentType: "application/json", json: { modelName: "heavy" } }, + auto: { ok: true, status: 200, contentType: "application/json", json: { modelName: "auto" } }, + }, + subscriptions: { + ok: true, + status: 200, + contentType: "application/json", + json: { subscriptions: [] }, + }, + ...overrides, + }; +} + +describe("Grok page session bridge", () => { + test("reads session, pool, subscriptions, and chat modes from the exact Grok origin", async () => { + const session = SESSION; + const subscriptions = { subscriptions: [] }; + const fetchPage = vi.fn(async (url, init) => { + const href = String(url); + if (href.endsWith("/api/auth/session")) return jsonResponse(session); + if (href.includes("GetGrokCreditsConfig")) { + return new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "content-type": GRPC_WEB_CONTENT_TYPE }, + }); + } + if (href.endsWith("/rest/subscriptions")) return jsonResponse(subscriptions); + if (href.endsWith("/rest/rate-limits")) { + return jsonResponse({ modelName: JSON.parse(String(init?.body)).modelName }); + } + return jsonResponse({}, 404); + }); + + const result = await readGrokPageSessionAtExpectedOrigin( + fetchPage, + "https://grok.com", + ); + expect(result).toEqual({ + session: { + ok: true, + status: 200, + contentType: "application/json", + json: session, + }, + pool: { + ok: true, + status: 200, + contentType: GRPC_WEB_CONTENT_TYPE, + bodyBase64: btoa(String.fromCharCode(1, 2, 3)), + }, + rateLimits: { + fast: { + ok: true, + status: 200, + contentType: "application/json", + json: { modelName: "fast" }, + }, + expert: { + ok: true, + status: 200, + contentType: "application/json", + json: { modelName: "expert" }, + }, + heavy: { + ok: true, + status: 200, + contentType: "application/json", + json: { modelName: "heavy" }, + }, + auto: { + ok: true, + status: 200, + contentType: "application/json", + json: { modelName: "auto" }, + }, + }, + subscriptions: { + ok: true, + status: 200, + contentType: "application/json", + json: subscriptions, + }, + }); + expect(fetchPage).toHaveBeenCalledWith( + "https://grok.com/api/auth/session", + expect.objectContaining({ method: "GET", credentials: "include" }), + ); + expect(fetchPage).toHaveBeenCalledWith( + "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig", + expect.objectContaining({ + method: "POST", + credentials: "include", + body: EMPTY_GRPC_WEB_UNARY, + }), + ); + }); + + test("refuses to read from any non-Grok origin", async () => { + const fetchPage = vi.fn(); + await expect( + readGrokPageSessionAtExpectedOrigin(fetchPage, "https://lookalike.example"), + ).resolves.toEqual({ + declined: "wrong_origin", + origin: "https://lookalike.example", + }); + expect(fetchPage).not.toHaveBeenCalled(); + }); + + test("keeps injected functions self-contained for executeScript serialization", () => { + const forbidden = [ + "SESSION_ENDPOINT", + "RATE_LIMITS_ENDPOINT", + "SUBSCRIPTIONS_ENDPOINT", + "POOL_CONNECT_ENDPOINT", + "GROK_RATE_LIMIT_MODEL_NAMES", + "EXPECTED_ORIGIN", + "GRPC_WEB_CONTENT_TYPE", + "EMPTY_GRPC_WEB_UNARY", + "STASH_KEY", + "GROK_OWNED_TAB_URL", + ]; + const fetchers = [ + readGrokPageSessionAtExpectedOrigin, + startGrokPageSessionStashAtExpectedOrigin, + ]; + const sources = [ + ...fetchers, + readGrokPageSessionStashAtExpectedOrigin, + clearGrokPageSessionStashAtExpectedOrigin, + ].map((fn) => Function.prototype.toString.call(fn)); + for (const source of sources) { + for (const name of forbidden) { + expect(source).not.toMatch(new RegExp(`\\b${name}\\b`)); + } + } + for (const fn of fetchers) { + expect(Function.prototype.toString.call(fn)).toContain("https://grok.com"); + } + }); + + test("does not inject when scripting or host permission is missing", async () => { + const executeScript = vi.fn(); + await expect( + findGrokPageSession({ + hasPagePermission: vi.fn().mockResolvedValue(false), + queryTabs: vi.fn(), + executeScript, + }), + ).resolves.toEqual({ kind: "permission_missing" }); + expect(executeScript).not.toHaveBeenCalled(); + }); + + test("returns no_tab when owned-tab creation is unavailable", async () => { + const executeScript = vi.fn(); + await expect( + findGrokPageSession({ + hasPagePermission: granted, + queryTabs: vi.fn().mockResolvedValue([]), + executeScript, + openOwnedTab: vi.fn().mockResolvedValue(undefined), + }), + ).resolves.toEqual({ kind: "no_tab" }); + expect(executeScript).not.toHaveBeenCalled(); + }); + + test("names an empty executeScript result separately from a throw", async () => { + await expect( + findGrokPageSession({ + hasPagePermission: granted, + queryTabs: vi.fn().mockResolvedValue([ + { id: 7, url: "https://grok.com/", status: "complete" }, + ]), + executeScript: vi.fn().mockResolvedValue([]), + }), + ).resolves.toEqual({ + kind: "inject_empty", + detail: "executeScript returned no frames", + }); + }); + + test("names a null injection.result without reading started", async () => { + await expect( + findGrokPageSession({ + hasPagePermission: granted, + queryTabs: vi.fn().mockResolvedValue([ + { id: 7, url: "https://grok.com/", status: "complete" }, + ]), + executeScript: vi.fn().mockResolvedValue([{ result: null }]), + }), + ).resolves.toEqual({ + kind: "inject_empty", + detail: "injection.result was null", + }); + }); + + test("names an undefined injection.result separately from a throw", async () => { + await expect( + findGrokPageSession({ + hasPagePermission: granted, + queryTabs: vi.fn().mockResolvedValue([ + { id: 7, url: "https://grok.com/", status: "complete" }, + ]), + executeScript: vi.fn().mockResolvedValue([{}]), + }), + ).resolves.toEqual({ + kind: "inject_empty", + detail: "injection.result was undefined", + }); + }); + + test("falls back to a two-step stash when the direct read returns nothing usable", async () => { + const payload = { + session: { ok: true, status: 200, contentType: "application/json", json: SESSION }, + pool: { ok: true, status: 200, contentType: GRPC_WEB_CONTENT_TYPE, bodyBase64: "AA==" }, + rateLimits: { + fast: { ok: true, status: 200, contentType: "application/json", json: {} }, + expert: { ok: true, status: 200, contentType: "application/json", json: {} }, + heavy: { ok: true, status: 200, contentType: "application/json", json: {} }, + auto: { ok: true, status: 200, contentType: "application/json", json: {} }, + }, + subscriptions: { ok: true, status: 200, contentType: "application/json", json: {} }, + }; + const executeScript = vi.fn().mockImplementation(async ({ func }) => { + if (func === readGrokPageSessionAtExpectedOrigin) return [{}]; + if (func === startGrokPageSessionStashAtExpectedOrigin) { + return [{ result: { started: true } }]; + } + if (func === readGrokPageSessionStashAtExpectedOrigin) { + return [{ result: { status: "ready", ...payload } }]; + } + if (func === clearGrokPageSessionStashAtExpectedOrigin) { + return [{ result: { cleared: true } }]; + } + return [{}]; + }); + + await expect( + findGrokPageSession({ + hasPagePermission: granted, + queryTabs: vi.fn().mockResolvedValue([{ id: 9, url: "https://grok.com/", status: "complete" }]), + executeScript, + now: () => 0, + delay: async () => undefined, + }), + ).resolves.toEqual({ + kind: "read", + ...payload, + }); + }); + + test("prefers awake grok.com tabs over discarded ones", () => { + expect( + rankGrokTabs([ + { id: 1, discarded: true, status: "unloaded" }, + { id: 2, status: "complete" }, + ]), + ).toEqual([2, 1]); + expect(tabLooksAsleep({ discarded: true })).toBe(true); + }); + + test("reconstructs adapter fetch from a page read and never hits the network", async () => { + const read = pageRead(); + const signal = new AbortController().signal; + const reconstructed = fetchFromGrokPageRead(read, signal); + const session = await reconstructed("https://grok.com/api/auth/session", { + method: "GET", + }); + await expect(session.json()).resolves.toEqual(SESSION); + const usage = await reconstructed("https://grok.com/rest/rate-limits", { + method: "POST", + body: JSON.stringify({ modelName: "fast" }), + }); + await expect(usage.json()).resolves.toEqual({ modelName: "fast" }); + const missing = await reconstructed("https://example.com/other"); + expect(missing.status).toBe(404); + }); + + test("names page-probe failures instead of a generic provider_changed", () => { + expect(healthFromGrokPageProbe({ kind: "permission_missing" })).toEqual({ + kind: "temporary_error", + message: "page-probe: permission_missing", + }); + expect( + healthFromGrokPageProbe({ + kind: "inject_empty", + detail: "injection.result was null", + }), + ).toEqual({ + kind: "provider_changed", + message: "page-probe: inject_empty: injection.result was null", + }); + }); +}); diff --git a/providers/grok/page-session.ts b/providers/grok/page-session.ts new file mode 100644 index 0000000..98fd8f9 --- /dev/null +++ b/providers/grok/page-session.ts @@ -0,0 +1,918 @@ +import type { ProviderHealth } from "../../domain/model"; + +export const GROK_OWNED_TAB_URL = "https://grok.com/"; +export const GROK_OWNED_TAB_LEASE_PREFIX = "grokSessionTabLease"; +export const GROK_OWNED_TAB_TIMEOUT_MS = 10_000; + +export type GrokJsonEndpointResult = { + readonly ok: boolean; + readonly status: number; + readonly contentType: string; + readonly json?: unknown; + readonly text?: string; +}; + +export type GrokBinaryEndpointResult = { + readonly ok: boolean; + readonly status: number; + readonly contentType: string; + readonly grpcStatus?: string; + readonly grpcMessage?: string; + readonly bodyBase64?: string; +}; + +export type GrokPageRead = { + readonly kind: "read"; + readonly session: GrokJsonEndpointResult; + readonly pool: GrokBinaryEndpointResult; + readonly rateLimits: Readonly>; + readonly subscriptions: GrokJsonEndpointResult; +}; + +export type GrokPageProbe = + | { readonly kind: "no_tab" } + | { readonly kind: "permission_missing" } + | { readonly kind: "inject_threw"; readonly detail: string } + | { readonly kind: "inject_empty"; readonly detail: string } + | { readonly kind: "inject_unusable"; readonly detail: string } + | { readonly kind: "wrong_origin"; readonly origin: string } + | { readonly kind: "tab_asleep" } + | GrokPageRead; + +interface GrokTab { + id?: number; + url?: string; + discarded?: boolean; + status?: string; +} + +interface GrokScriptResult { + result?: unknown; +} + +type InjectedGrokFunc = + | typeof readGrokPageSessionAtExpectedOrigin + | typeof startGrokPageSessionStashAtExpectedOrigin + | typeof readGrokPageSessionStashAtExpectedOrigin + | typeof clearGrokPageSessionStashAtExpectedOrigin; + +export interface GrokPageBridge { + hasPagePermission(): Promise; + queryTabs(details: { url: string }): Promise; + executeScript(details: { + target: { tabId: number }; + world: "MAIN"; + func: InjectedGrokFunc; + }): Promise; + now?(): number; + delay?(ms: number): Promise; + openOwnedTab?(): Promise<{ tabId: number; release: () => void } | undefined>; +} + +const STASH_BUDGET_MS = 8_000; +const STASH_POLL_MS = 200; + +function emptyJsonResult(): GrokJsonEndpointResult { + return { ok: false, status: 0, contentType: "none" }; +} + +function emptyBinaryResult(): GrokBinaryEndpointResult { + return { ok: false, status: 0, contentType: "none" }; +} + +export function tabLooksAsleep(tab: GrokTab): boolean { + if (tab.discarded === true) return true; + return tab.status !== undefined && tab.status !== "complete"; +} + +export function rankGrokTabs(tabs: readonly GrokTab[]): number[] { + return tabs + .flatMap((tab) => + tab.id === undefined + ? [] + : [{ id: tab.id, url: tab.url, asleep: tabLooksAsleep(tab) }], + ) + .sort((left, right) => Number(left.asleep) - Number(right.asleep)) + .map((tab) => tab.id); +} + +export async function readGrokPageSessionAtExpectedOrigin( + fetchPage: typeof globalThis.fetch = globalThis.fetch, + currentOrigin: string = globalThis.location.origin, +): Promise< + | Omit + | { readonly declined: "wrong_origin"; readonly origin: string } +> { + if (currentOrigin !== "https://grok.com") { + return { declined: "wrong_origin", origin: currentOrigin }; + } + + const readJson = async ( + url: string, + init: RequestInit, + ): Promise => { + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 8_000); + try { + const response = await fetchPage(url, { + ...init, + credentials: "include", + signal: abortController.signal, + }); + const contentType = response.headers.get("content-type") ?? "none"; + const text = await response.text(); + try { + return { + ok: response.ok, + status: response.status, + contentType, + json: JSON.parse(text) as unknown, + }; + } catch { + return { + ok: response.ok, + status: response.status, + contentType, + text: text.slice(0, 160), + }; + } + } catch { + return { ok: false, status: 0, contentType: "none" }; + } finally { + clearTimeout(timeoutId); + } + }; + + const readBinary = async ( + url: string, + init: RequestInit, + ): Promise => { + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 8_000); + try { + const response = await fetchPage(url, { + ...init, + credentials: "include", + signal: abortController.signal, + }); + const bytes = new Uint8Array(await response.arrayBuffer()); + let binary = ""; + for (let index = 0; index < bytes.length; index += 1) { + binary += String.fromCharCode(bytes[index]!); + } + const grpcStatus = response.headers.get("grpc-status") ?? undefined; + const grpcMessage = response.headers.get("grpc-message") ?? undefined; + return { + ok: response.ok, + status: response.status, + contentType: response.headers.get("content-type") ?? "none", + ...(grpcStatus === undefined || grpcStatus.trim() === "" + ? {} + : { grpcStatus }), + ...(grpcMessage === undefined || grpcMessage.trim() === "" + ? {} + : { grpcMessage }), + bodyBase64: btoa(binary), + }; + } catch { + return { ok: false, status: 0, contentType: "none" }; + } finally { + clearTimeout(timeoutId); + } + }; + + const jsonHeaders = { + Accept: "application/json", + }; + const [session, pool, subscriptions, fast, expert, heavy, auto] = + await Promise.all([ + readJson("https://grok.com/api/auth/session", { + method: "GET", + headers: jsonHeaders, + }), + readBinary( + "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig", + { + method: "POST", + headers: { + Accept: "application/grpc-web+proto", + "Content-Type": "application/grpc-web+proto", + "X-Grpc-Web": "1", + }, + body: new Uint8Array([0, 0, 0, 0, 0]), + }, + ), + readJson("https://grok.com/rest/subscriptions", { + method: "GET", + headers: jsonHeaders, + }), + readJson("https://grok.com/rest/rate-limits", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ modelName: "fast" }), + }), + readJson("https://grok.com/rest/rate-limits", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ modelName: "expert" }), + }), + readJson("https://grok.com/rest/rate-limits", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ modelName: "heavy" }), + }), + readJson("https://grok.com/rest/rate-limits", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ modelName: "auto" }), + }), + ]); + + return { + session, + pool, + rateLimits: { fast, expert, heavy, auto }, + subscriptions, + }; +} + +export function startGrokPageSessionStashAtExpectedOrigin( + currentOrigin: string = globalThis.location.origin, +): + | { readonly started: true } + | { readonly declined: "wrong_origin"; readonly origin: string } { + if (currentOrigin !== "https://grok.com") { + return { declined: "wrong_origin", origin: currentOrigin }; + } + const stashKey = "__aiLimitsGrokSessionV1"; + const target = globalThis as typeof globalThis & Record; + target[stashKey] = { status: "pending" }; + + const readJson = async ( + url: string, + init: RequestInit, + ): Promise => { + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 8_000); + try { + const response = await globalThis.fetch(url, { + ...init, + credentials: "include", + signal: abortController.signal, + }); + const contentType = response.headers.get("content-type") ?? "none"; + const text = await response.text(); + try { + return { + ok: response.ok, + status: response.status, + contentType, + json: JSON.parse(text) as unknown, + }; + } catch { + return { + ok: response.ok, + status: response.status, + contentType, + text: text.slice(0, 160), + }; + } + } catch { + return { ok: false, status: 0, contentType: "none" }; + } finally { + clearTimeout(timeoutId); + } + }; + + const readBinary = async ( + url: string, + init: RequestInit, + ): Promise => { + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 8_000); + try { + const response = await globalThis.fetch(url, { + ...init, + credentials: "include", + signal: abortController.signal, + }); + const bytes = new Uint8Array(await response.arrayBuffer()); + let binary = ""; + for (let index = 0; index < bytes.length; index += 1) { + binary += String.fromCharCode(bytes[index]!); + } + const grpcStatus = response.headers.get("grpc-status") ?? undefined; + const grpcMessage = response.headers.get("grpc-message") ?? undefined; + return { + ok: response.ok, + status: response.status, + contentType: response.headers.get("content-type") ?? "none", + ...(grpcStatus === undefined || grpcStatus.trim() === "" + ? {} + : { grpcStatus }), + ...(grpcMessage === undefined || grpcMessage.trim() === "" + ? {} + : { grpcMessage }), + bodyBase64: btoa(binary), + }; + } catch { + return { ok: false, status: 0, contentType: "none" }; + } finally { + clearTimeout(timeoutId); + } + }; + + const jsonHeaders = { + Accept: "application/json", + }; + void Promise.all([ + readJson("https://grok.com/api/auth/session", { + method: "GET", + headers: jsonHeaders, + }), + readBinary( + "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig", + { + method: "POST", + headers: { + Accept: "application/grpc-web+proto", + "Content-Type": "application/grpc-web+proto", + "X-Grpc-Web": "1", + }, + body: new Uint8Array([0, 0, 0, 0, 0]), + }, + ), + readJson("https://grok.com/rest/subscriptions", { + method: "GET", + headers: jsonHeaders, + }), + readJson("https://grok.com/rest/rate-limits", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ modelName: "fast" }), + }), + readJson("https://grok.com/rest/rate-limits", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ modelName: "expert" }), + }), + readJson("https://grok.com/rest/rate-limits", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ modelName: "heavy" }), + }), + readJson("https://grok.com/rest/rate-limits", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ modelName: "auto" }), + }), + ]) + .then(([session, pool, subscriptions, fast, expert, heavy, auto]) => { + target[stashKey] = { + status: "ready", + session, + pool, + rateLimits: { fast, expert, heavy, auto }, + subscriptions, + }; + }) + .catch(() => { + target[stashKey] = { status: "failed" }; + }); + return { started: true }; +} + +export function readGrokPageSessionStashAtExpectedOrigin(): unknown { + const stashKey = "__aiLimitsGrokSessionV1"; + return (globalThis as typeof globalThis & Record)[stashKey]; +} + +export function clearGrokPageSessionStashAtExpectedOrigin(): { + readonly cleared: true; +} { + const stashKey = "__aiLimitsGrokSessionV1"; + const target = globalThis as typeof globalThis & Record; + try { + delete target[stashKey]; + } catch { + target[stashKey] = undefined; + } + return { cleared: true }; +} + +export function sanitizeInjectDetail(value: unknown): string { + const text = + value instanceof Error + ? value.message + : typeof value === "string" + ? value + : Object.prototype.toString.call(value); + const compact = text.replace(/\s+/g, " ").trim().slice(0, 160); + return compact.length > 0 ? compact : "unknown"; +} + +export function isThenable(value: unknown): boolean { + return ( + (typeof value === "object" || typeof value === "function") && + value !== null && + typeof (value as { then?: unknown }).then === "function" + ); +} + +function describeInjectedValue(value: unknown): string { + if (value === undefined) return "undefined"; + if (value === null) return "null"; + if (Array.isArray(value)) return `array(length=${value.length})`; + if (isThenable(value)) return `thenable typeof=${typeof value}`; + return typeof value; +} + +function asWrongOrigin( + value: unknown, +): { readonly kind: "wrong_origin"; readonly origin: string } | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const record = value as { declined?: unknown; origin?: unknown }; + if (record.declined !== "wrong_origin" || typeof record.origin !== "string") { + return undefined; + } + const origin = record.origin.trim(); + if (origin.length === 0 || origin.length > 128) return undefined; + return { kind: "wrong_origin", origin }; +} + +function asJsonEndpoint(value: unknown): GrokJsonEndpointResult { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return emptyJsonResult(); + } + const record = value as { + ok?: unknown; + status?: unknown; + contentType?: unknown; + json?: unknown; + text?: unknown; + }; + const status = + typeof record.status === "number" && + Number.isInteger(record.status) && + record.status >= 0 && + record.status <= 599 + ? record.status + : 0; + const contentType = + typeof record.contentType === "string" && record.contentType.trim() + ? record.contentType.slice(0, 128) + : "none"; + return { + ok: record.ok === true, + status, + contentType, + ...(Object.hasOwn(record, "json") ? { json: record.json } : {}), + ...(typeof record.text === "string" + ? { text: record.text.slice(0, 160) } + : {}), + }; +} + +function asBinaryEndpoint(value: unknown): GrokBinaryEndpointResult { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return emptyBinaryResult(); + } + const record = value as { + ok?: unknown; + status?: unknown; + contentType?: unknown; + grpcStatus?: unknown; + grpcMessage?: unknown; + bodyBase64?: unknown; + }; + const status = + typeof record.status === "number" && + Number.isInteger(record.status) && + record.status >= 0 && + record.status <= 599 + ? record.status + : 0; + const contentType = + typeof record.contentType === "string" && record.contentType.trim() + ? record.contentType.slice(0, 128) + : "none"; + return { + ok: record.ok === true, + status, + contentType, + ...(typeof record.grpcStatus === "string" && record.grpcStatus.trim() + ? { grpcStatus: record.grpcStatus.slice(0, 32) } + : {}), + ...(typeof record.grpcMessage === "string" && record.grpcMessage.trim() + ? { grpcMessage: record.grpcMessage.slice(0, 160) } + : {}), + ...(typeof record.bodyBase64 === "string" + ? { bodyBase64: record.bodyBase64 } + : {}), + }; +} + +function asRateLimits( + value: unknown, +): Readonly> { + const record = + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; + return { + fast: asJsonEndpoint(record.fast), + expert: asJsonEndpoint(record.expert), + heavy: asJsonEndpoint(record.heavy), + auto: asJsonEndpoint(record.auto), + }; +} + +function asReadResult(value: unknown): GrokPageRead | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const record = value as { + declined?: unknown; + session?: unknown; + pool?: unknown; + rateLimits?: unknown; + subscriptions?: unknown; + }; + if (record.declined !== undefined) return undefined; + if ( + record.session === undefined && + record.pool === undefined && + record.subscriptions === undefined + ) { + return undefined; + } + return { + kind: "read", + session: asJsonEndpoint(record.session), + pool: asBinaryEndpoint(record.pool), + rateLimits: asRateLimits(record.rateLimits), + subscriptions: asJsonEndpoint(record.subscriptions), + }; +} + +type InjectFailure = Extract< + GrokPageProbe, + { kind: "inject_threw" | "inject_empty" | "inject_unusable" } +>; + +async function runInjected( + executeScript: GrokPageBridge["executeScript"], + tabId: number, + func: InjectedGrokFunc, +): Promise<{ readonly kind: "value"; readonly value: unknown } | InjectFailure> { + try { + const frames = await executeScript({ + target: { tabId }, + world: "MAIN", + func, + }); + if (!Array.isArray(frames) || frames.length === 0) { + return { kind: "inject_empty", detail: "executeScript returned no frames" }; + } + if ( + !Object.hasOwn(frames[0] as object, "result") && + frames[0]?.result === undefined + ) { + return { kind: "inject_empty", detail: "injection.result was undefined" }; + } + const result = frames[0]?.result; + if (result === undefined) { + return { kind: "inject_empty", detail: "injection.result was undefined" }; + } + if (result === null) { + return { kind: "inject_empty", detail: "injection.result was null" }; + } + if (isThenable(result)) { + return { kind: "inject_unusable", detail: describeInjectedValue(result) }; + } + return { kind: "value", value: result }; + } catch (error) { + return { kind: "inject_threw", detail: sanitizeInjectDetail(error) }; + } +} + +async function readStashFallback( + executeScript: GrokPageBridge["executeScript"], + tabId: number, + now: () => number, + delay: (ms: number) => Promise, +): Promise< + | Extract + | InjectFailure +> { + const started = await runInjected( + executeScript, + tabId, + startGrokPageSessionStashAtExpectedOrigin, + ); + if (started.kind !== "value") return started; + const wrongOrigin = asWrongOrigin(started.value); + if (wrongOrigin) return wrongOrigin; + if ( + typeof started.value !== "object" || + started.value === null || + Array.isArray(started.value) || + (started.value as { started?: unknown }).started !== true + ) { + return { + kind: "inject_unusable", + detail: `two-step start ${describeInjectedValue(started.value)}`, + }; + } + + const deadline = now() + STASH_BUDGET_MS; + try { + while (now() < deadline) { + await delay(STASH_POLL_MS); + const polled = await runInjected( + executeScript, + tabId, + readGrokPageSessionStashAtExpectedOrigin, + ); + if (polled.kind !== "value") return polled; + if (polled.value === undefined || polled.value === null) continue; + if (typeof polled.value !== "object" || Array.isArray(polled.value)) { + return { + kind: "inject_unusable", + detail: `two-step stash ${describeInjectedValue(polled.value)}`, + }; + } + const stash = polled.value as { + status?: unknown; + session?: unknown; + pool?: unknown; + rateLimits?: unknown; + subscriptions?: unknown; + }; + if (stash.status === "pending") continue; + if (stash.status === "failed") { + return { kind: "inject_empty", detail: "two-step stash failed" }; + } + if (stash.status === "ready") { + const read = asReadResult({ + session: stash.session, + pool: stash.pool, + rateLimits: stash.rateLimits, + subscriptions: stash.subscriptions, + }); + if (read) return read; + return { + kind: "inject_unusable", + detail: "two-step stash ready payload was unusable", + }; + } + } + return { kind: "inject_empty", detail: "two-step stash timed out" }; + } finally { + await runInjected( + executeScript, + tabId, + clearGrokPageSessionStashAtExpectedOrigin, + ); + } +} + +async function injectIntoTabIds( + tabIds: readonly number[], + executeScript: GrokPageBridge["executeScript"], + now: () => number, + delay: (ms: number) => Promise, +): Promise<{ + probe?: GrokPageRead; + lastWrongOrigin?: { kind: "wrong_origin"; origin: string }; + firstFailure?: InjectFailure; +}> { + let lastWrongOrigin: { kind: "wrong_origin"; origin: string } | undefined; + let firstFailure: InjectFailure | undefined; + for (const tabId of tabIds) { + const direct = await runInjected( + executeScript, + tabId, + readGrokPageSessionAtExpectedOrigin, + ); + if (direct.kind === "value") { + const wrongOrigin = asWrongOrigin(direct.value); + if (wrongOrigin) { + lastWrongOrigin = wrongOrigin; + continue; + } + if (!isThenable(direct.value)) { + const read = asReadResult(direct.value); + if (read) return { probe: read }; + } + firstFailure ??= { + kind: "inject_unusable", + detail: describeInjectedValue(direct.value), + }; + } else if (direct.kind === "inject_threw") { + firstFailure ??= direct; + continue; + } else { + firstFailure ??= direct; + } + + const fallback = await readStashFallback(executeScript, tabId, now, delay); + if (fallback.kind === "read") return { probe: fallback }; + if (fallback.kind === "wrong_origin") { + lastWrongOrigin = fallback; + continue; + } + firstFailure = fallback.detail.startsWith("two-step stash") + ? fallback + : (firstFailure ?? fallback); + } + return { lastWrongOrigin, firstFailure }; +} + +export async function findGrokPageSession({ + hasPagePermission, + queryTabs, + executeScript, + now = Date.now, + delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + openOwnedTab, +}: GrokPageBridge): Promise { + try { + if (!(await hasPagePermission())) return { kind: "permission_missing" }; + const tabs = await queryTabs({ url: "https://grok.com/*" }); + const candidates = tabs.filter((tab) => tab.id !== undefined); + if (candidates.length === 0) { + if (!openOwnedTab) return { kind: "no_tab" }; + const owned = await openOwnedTab(); + if (!owned) return { kind: "no_tab" }; + try { + const opened = await injectIntoTabIds( + [owned.tabId], + executeScript, + now, + delay, + ); + return ( + opened.probe ?? + opened.lastWrongOrigin ?? + opened.firstFailure ?? { kind: "no_tab" } + ); + } finally { + owned.release(); + } + } + const onlyAsleep = candidates.every(tabLooksAsleep); + const existing = await injectIntoTabIds( + rankGrokTabs(candidates), + executeScript, + now, + delay, + ); + if (existing.probe) return existing.probe; + if (existing.lastWrongOrigin) return existing.lastWrongOrigin; + if (onlyAsleep) return { kind: "tab_asleep" }; + return ( + existing.firstFailure ?? { + kind: "inject_empty", + detail: "no injection attempt produced a result", + } + ); + } catch (error) { + return { kind: "inject_threw", detail: sanitizeInjectDetail(error) }; + } +} + +export function healthFromGrokPageProbe( + probe: Exclude, +): ProviderHealth { + const detail = + probe.kind === "inject_threw" || + probe.kind === "inject_empty" || + probe.kind === "inject_unusable" + ? probe.detail + : probe.kind === "wrong_origin" + ? probe.origin + : undefined; + const message = + detail === undefined + ? `page-probe: ${probe.kind}` + : `page-probe: ${probe.kind}: ${detail}`; + if ( + probe.kind === "no_tab" || + probe.kind === "tab_asleep" || + probe.kind === "permission_missing" + ) { + return { kind: "temporary_error", message }; + } + return { kind: "provider_changed", message }; +} + +function bytesFromBase64(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function jsonResponse(result: GrokJsonEndpointResult): Response { + const body = + result.json !== undefined + ? JSON.stringify(result.json) + : (result.text ?? ""); + return new Response(body, { + status: result.status === 0 ? 599 : result.status, + headers: { "content-type": result.contentType }, + }); +} + +function binaryResponse(result: GrokBinaryEndpointResult): Response { + const headers = new Headers({ "content-type": result.contentType }); + if (result.grpcStatus !== undefined) { + headers.set("grpc-status", result.grpcStatus); + } + if (result.grpcMessage !== undefined) { + headers.set("grpc-message", result.grpcMessage); + } + const body = + result.bodyBase64 === undefined + ? new Uint8Array() + : bytesFromBase64(result.bodyBase64); + const copy = new Uint8Array(body.byteLength); + copy.set(body); + return new Response(copy, { + status: result.status === 0 ? 599 : result.status, + headers, + }); +} + +function rateLimitMode(init?: RequestInit): string | undefined { + try { + const parsed = JSON.parse(String(init?.body ?? "{}")) as { + modelName?: unknown; + }; + return typeof parsed.modelName === "string" ? parsed.modelName : undefined; + } catch { + return undefined; + } +} + +export function fetchFromGrokPageRead( + read: GrokPageRead, + signal: AbortSignal, +): typeof globalThis.fetch { + return async (input, init) => { + if (signal.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } + const href = String(input); + if (href.includes("/api/auth/session")) { + return jsonResponse(read.session); + } + if (href.includes("GetGrokCreditsConfig")) { + return binaryResponse(read.pool); + } + if (href.includes("/rest/rate-limits")) { + const mode = rateLimitMode(init); + return jsonResponse( + mode === undefined + ? emptyJsonResult() + : (read.rateLimits[mode] ?? emptyJsonResult()), + ); + } + if (href.includes("/rest/subscriptions")) { + return jsonResponse(read.subscriptions); + } + return new Response("{}", { + status: 404, + headers: { "content-type": "application/json" }, + }); + }; +} diff --git a/providers/grok/usage-pool.test.ts b/providers/grok/usage-pool.test.ts index a443606..b2b424c 100644 --- a/providers/grok/usage-pool.test.ts +++ b/providers/grok/usage-pool.test.ts @@ -163,9 +163,18 @@ describe("normalizeUsagePool", () => { }, }), ).toEqual({ - kind: "unparseable", - message: - "Grok usage-pool JSON missing required field: credit_usage_percent", + kind: "metric", + metric: { + type: "quota", + id: "weekly-pool", + label: "Weekly usage pool", + scope: "general", + usedRatio: 0, + cycle: { + cadence: "calendar", + resetsAt: WEEKLY_END, + }, + }, }); }); diff --git a/providers/grok/usage-pool.ts b/providers/grok/usage-pool.ts index 67459e2..25e9005 100644 --- a/providers/grok/usage-pool.ts +++ b/providers/grok/usage-pool.ts @@ -60,18 +60,11 @@ export function inspectUsagePool(value: unknown): UsagePoolInspection { }; } - if (!hasPercent) { - return { - kind: "unparseable", - message: - "Grok usage-pool JSON missing required field: credit_usage_percent", - }; - } - const percent = pickNumber( - config, - "creditUsagePercent", - "credit_usage_percent", - ); + // proto3 omits credit_usage_percent at 0%. A valid current_period without + // a percent is still a usable zero-usage window. + const percent = hasPercent + ? pickNumber(config, "creditUsagePercent", "credit_usage_percent") + : 0; if (percent === undefined) { return { kind: "unparseable", @@ -172,14 +165,7 @@ export function inspectDecodedCreditsConfig( }; } - if (!hasPercent) { - return { - kind: "unparseable", - message: - "Grok usage-pool missing required field: credit_usage_percent", - }; - } - const percent = config.creditUsagePercent; + const percent = hasPercent ? config.creditUsagePercent : 0; if (percent === undefined || !Number.isFinite(percent)) { return { kind: "unparseable", diff --git a/providers/package-factories.ts b/providers/package-factories.ts index c993271..b546a23 100644 --- a/providers/package-factories.ts +++ b/providers/package-factories.ts @@ -46,7 +46,7 @@ function adapterContext( } export function createBrowserSessionPackage< - Kind extends Exclude, + Kind extends Exclude, >({ kind, adapter, diff --git a/providers/registry.test.ts b/providers/registry.test.ts index b435cf0..4ae3f4a 100644 --- a/providers/registry.test.ts +++ b/providers/registry.test.ts @@ -98,6 +98,21 @@ describe("provider registry", () => { ); }); + test("scheduled Grok collection never falls back to a background grok.com fetch", async () => { + const fetch = vi.fn(); + + const result = await providerRegistry.grok.collect(instance("grok", fixed), { + ...services, + fetch, + interaction: "forbidden", + }); + expect(result).toMatchObject({ + ok: false, + health: { message: expect.stringMatching(/^page-probe:/) }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + test("is exhaustive and owns exact cardinality, config, credentials, and permissions", () => { expect(providerKinds).toEqual([ "chatgpt", @@ -189,7 +204,10 @@ describe("provider registry", () => { configKind: "fixed", accepted: fixed, rejected: undefined, - permissions: { origins: ["https://grok.com/*"] }, + permissions: { + origins: ["https://grok.com/*"], + permissions: ["scripting"], + }, }, { kind: "mistral", @@ -390,7 +408,7 @@ describe("provider registry", () => { credentialKind: "none", configKind: "fixed", optionalOrigins: ["https://grok.com/*"], - optionalPermissions: [], + optionalPermissions: ["scripting"], }, mistral: { cardinality: "single", @@ -569,7 +587,7 @@ describe("provider registry", () => { services, credential, ); - if (kind !== "cursor") { + if (kind !== "cursor" && kind !== "grok") { expect(adapterSpies[kind]).toHaveBeenCalledTimes(1); } } diff --git a/providers/registry.ts b/providers/registry.ts index a1ece78..e520445 100644 --- a/providers/registry.ts +++ b/providers/registry.ts @@ -6,7 +6,7 @@ import { deepInfraAdapter } from "./deepinfra/adapter"; import { deepSeekAdapter } from "./deepseek/adapter"; import { elevenLabsAdapter } from "./elevenlabs/adapter"; import { fireworksAdapter } from "./fireworks/adapter"; -import { grokAdapter } from "./grok/adapter"; +import { grokPackage } from "./grok/package"; import { groqCloudAdapter } from "./groqcloud/adapter"; import { openAiAdapter } from "./openai/adapter"; import { openRouterAdapter } from "./openrouter/adapter"; @@ -105,15 +105,7 @@ export const providerRegistry = { }), kimi: kimiPackage, cursor: cursorPackage, - grok: createBrowserSessionPackage({ - kind: "grok", - adapter: grokAdapter, - cardinality: providerDefinitions.grok.cardinality, - requiredPermissions: fixedPermissions( - providerDefinitions.grok.optionalOrigins, - providerDefinitions.grok.optionalPermissions, - ), - }), + grok: grokPackage, mistral: createBrowserSessionPackage({ kind: "mistral", adapter: mistralAdapter, diff --git a/scripts/artifact-contract.mjs b/scripts/artifact-contract.mjs index 686b00f..cc60a99 100644 --- a/scripts/artifact-contract.mjs +++ b/scripts/artifact-contract.mjs @@ -113,8 +113,8 @@ export function validateBuildManifest(manifest, packageVersion) { if (manifest.manifest_version !== 3) { errors.push("Expected manifest_version to be 3."); } - if (manifest.version !== packageVersion || manifest.version !== "0.4.3") { - errors.push("Expected manifest version 0.4.3 derived from package.json."); + if (manifest.version !== packageVersion || manifest.version !== "0.4.4") { + errors.push("Expected manifest version 0.4.4 derived from package.json."); } if (manifest.default_locale !== "en") { errors.push('Expected manifest default_locale to be "en".'); diff --git a/scripts/release-version.test.js b/scripts/release-version.test.js index 33750c2..4f2bbfa 100644 --- a/scripts/release-version.test.js +++ b/scripts/release-version.test.js @@ -4,15 +4,15 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -const buildVersion = "0.4.3"; -const documentedReleaseVersion = "0.4.3"; +const buildVersion = "0.4.4"; +const documentedReleaseVersion = "0.4.4"; function read(relativePath) { return readFileSync(path.join(process.cwd(), relativePath), "utf8"); } describe("release version", () => { - it("uses 0.4.3 as the package and verified manifest version", () => { + it("uses 0.4.4 as the package and verified manifest version", () => { const packageJson = JSON.parse(read("package.json")); const artifactContract = read("scripts/artifact-contract.mjs"); @@ -25,7 +25,7 @@ describe("release version", () => { ); }); - it("names the 0.4.3 archive in build documentation", () => { + it("names the 0.4.4 archive in build documentation", () => { for (const relativePath of ["README.md", "README.zh-CN.md"]) { const contents = read(relativePath); @@ -34,7 +34,7 @@ describe("release version", () => { } }); - it("keeps the current Store archive reference at 0.4.3", () => { + it("keeps the current Store archive reference at 0.4.4", () => { expect(read("store-assets/chrome-web-store/listing-en.md")).toContain( `ai-limits-${documentedReleaseVersion}-chrome.zip`, ); diff --git a/scripts/verify-build.test.js b/scripts/verify-build.test.js index ada7140..52b3bfe 100644 --- a/scripts/verify-build.test.js +++ b/scripts/verify-build.test.js @@ -13,7 +13,7 @@ function validManifest() { name: "__MSG_manifest_name__", description: "__MSG_manifest_description__", default_locale: "en", - version: "0.4.3", + version: "0.4.4", minimum_chrome_version: "116", permissions: ["storage", "alarms", "sidePanel"], optional_permissions: ["cookies", "scripting"], @@ -34,7 +34,7 @@ describe("built Chrome artifact contract", () => { it("accepts the static and dynamic optional provider origins and the store description", () => { expect(EXPECTED_DESCRIPTION.length).toBeLessThanOrEqual(132); expect(EXPECTED_OPTIONAL_ORIGINS).toHaveLength(19); - expect(validateBuildManifest(validManifest(), "0.4.3")).toEqual([]); + expect(validateBuildManifest(validManifest(), "0.4.4")).toEqual([]); }); it("rejects missing API access, broad ElevenLabs webpage access, and tabs", () => { @@ -42,19 +42,19 @@ describe("built Chrome artifact contract", () => { missingApi.optional_host_permissions = missingApi.optional_host_permissions.filter( (origin) => origin !== "https://api.elevenlabs.io/*", ); - expect(validateBuildManifest(missingApi, "0.4.3")).toContain( + expect(validateBuildManifest(missingApi, "0.4.4")).toContain( "Expected the exact static and dynamic optional provider origins.", ); const broadPage = validManifest(); broadPage.optional_host_permissions.push("https://elevenlabs.io/*"); - expect(validateBuildManifest(broadPage, "0.4.3")).toContain( + expect(validateBuildManifest(broadPage, "0.4.4")).toContain( "Expected the exact static and dynamic optional provider origins.", ); const tabs = validManifest(); tabs.permissions.push("tabs"); - expect(validateBuildManifest(tabs, "0.4.3")).toContain( + expect(validateBuildManifest(tabs, "0.4.4")).toContain( "Expected permissions to be exactly alarms, sidePanel, storage.", ); }); diff --git a/store-assets/chrome-web-store/listing-en.md b/store-assets/chrome-web-store/listing-en.md index 3223e6e..c9c08ff 100644 --- a/store-assets/chrome-web-store/listing-en.md +++ b/store-assets/chrome-web-store/listing-en.md @@ -1,6 +1,6 @@ # Chrome Web Store listing -This document describes AI Limits version 0.4.3. It is the complete +This document describes AI Limits version 0.4.4. It is the complete Chrome Web Store submission pack, including the paste-ready listing copy and the reviewer reference material. It is not a claim that any provider has approved the extension. @@ -138,8 +138,10 @@ These origins are requested one provider at a time after the user clicks already-open exact-origin page, or briefly open one inactive spending tab if none is open, to request Grok Bot, credit-grant, and aggregated included-usage JSON. -- `https://grok.com/*`: reads the signed-in user's Grok session, rate-limit, and - subscription responses. This is consumer Grok on grok.com, not Cursor's Grok Bot. +- `https://grok.com/*`: reads the signed-in user's Grok session, usage-pool, + rate-limit, and subscription responses from an already-open grok.com page. + During Connect or manual Refresh, if none is open, it may briefly open one + inactive grok.com tab. This is consumer Grok on grok.com, not Cursor's Grok Bot. - `https://admin.mistral.ai/*` and `https://console.mistral.ai/*`: read month-to-date usage and credits from the signed-in Mistral session. - `https://www.perplexity.ai/*`: reads credit pools from the signed-in @@ -167,7 +169,7 @@ These origins are requested one provider at a time after the user clicks - `cookies`: requested only with Kimi access; reads only Kimi's exact legacy `kimi-auth` cookie when available. This is checked before Kimi page storage. -- `scripting`: requested with Kimi or Cursor access. For Kimi, it reads only +- `scripting`: requested with Kimi, Cursor, or Grok access. For Kimi, it reads only the exact `access_token` browser-storage entry from an already-open or recovery-created matching Kimi page. If a credential is missing or rejected, interactive recovery may create one inactive Kimi homepage tab. Recovery @@ -183,10 +185,20 @@ These origins are requested one provider at a time after the user clicks returns only their JSON, and does not inspect rendered content, browser storage, or cookie values directly. Chrome attaches the signed-in Cursor cookies to those same-origin requests. Scheduled refresh never opens or - injects into a Cursor page. + injects into a Cursor page. For Grok, Connect or manual Refresh can run a + bundled exact-origin function in one already-open `grok.com` page. If none is + open, that interactive path may create one inactive `https://grok.com/` tab, + wait up to 10 seconds, and close only the tab it created. It never activates + a tab and never closes a tab the user opened. That function sends the + signed-in session, usage-pool, subscription, and chat-mode rate-limit + requests and returns only those responses. It does not inspect rendered + content, browser storage, or cookie values directly. Chrome attaches the + signed-in Grok cookies to those same-origin requests. Scheduled refresh may + inject into an already-open grok.com tab and never opens a new one. The manifest does not request the broad `tabs` permission. Scheduled refresh -does not create or activate provider tabs and never injects into provider pages. +does not create or activate provider tabs. Grok scheduled refresh may inject +into an already-open grok.com tab; Cursor scheduled refresh never injects. ## Data-disclosure answers @@ -201,7 +213,8 @@ does not create or activate provider tabs and never injects into provider pages. subscription, and organization-response JSON plus Kimi's exact `access_token` browser-storage entry and Cursor's manual page-context dashboard JSON responses (Grok Bot, credit-grant, and aggregated included - usage). It does not read rendered page text, prompts, + usage), plus Grok page-origin session, usage-pool, subscription, and + rate-limit responses. It does not read rendered page text, prompts, conversations, or generated responses. - **Account identifiers and personally identifiable information:** Yes, narrowly handled. A ChatGPT account identifier derived from the access @@ -218,11 +231,12 @@ does not create or activate provider tabs and never injects into provider pages. - **Web history:** Yes, narrowly and conservatively classified. During Kimi collection, AI Limits may check for an already-open tab matching the exact Kimi origin. During Cursor Connect or manual Refresh, it may check for one - already-open tab matching the exact Cursor origin. This reveals only whether - that fixed provider-origin tab is currently open, is used locally for the - requested provider operation, and is never retained, transmitted, or - assembled into a list of visited pages. AI Limits does not query arbitrary - page URLs, titles, or Chrome browsing history. + already-open tab matching the exact Cursor origin. During Grok collection, it + may check for one already-open tab matching the exact Grok origin. This + reveals only whether that fixed provider-origin tab is currently open, is + used locally for the requested provider operation, and is never retained, + transmitted, or assembled into a list of visited pages. AI Limits does not + query arbitrary page URLs, titles, or Chrome browsing history. - **User activity:** No clicks, keystrokes, pointer movement, scrolling, or general browsing activity is monitored. - **Health information, personal communications, and location:** No. @@ -254,7 +268,7 @@ The full policy is in [PRIVACY.md](../../PRIVACY.md). ## Reviewer prerequisites - Chrome 116 or newer. -- The validated `ai-limits-0.4.3-chrome.zip` upload artifact. +- The validated `ai-limits-0.4.4-chrome.zip` upload artifact. - Reviewer-owned test accounts signed in to the desired browser-session provider sites in the same Chrome profile, plus temporary reviewer-created keys for each API-key provider under test. ElevenLabs should use @@ -300,10 +314,17 @@ The full policy is in [PRIVACY.md](../../PRIVACY.md). collected Grok Bot, extra-credit, and detail values may remain visible as earlier page values until Grok Bot's weekly reset, and the card should explain why they were not refreshed. -6. **Grok:** sign in at `grok.com`, click **Connect** on Grok, approve only the - Grok origin, and confirm a SuperGrok-family or Free plan plus the reported - query window appears. Use the card's **Refresh** action once. No extra - `cookies` or `scripting` permission should be requested. +6. **Grok:** sign in at `grok.com`. You do **not** have to pre-open a Grok + tab. Click **Connect** on Grok, approve the Grok origin plus `scripting`, + and confirm a SuperGrok-family or Free plan plus the reported usage pool + appears. If no `grok.com` tab was already open, one inactive + `https://grok.com/` tab may appear, be read, and close; confirm it is never + activated and that no tab the reviewer opened is closed. Use the card's + **Refresh** action once with a grok.com tab already open and confirm the + existing tab is reused rather than a second tab being created. Then close + all grok.com tabs and start a **scheduled** cycle (or wait for automatic + refresh): no Grok tab should open. Reopen grok.com and confirm scheduled or + manual refresh can update usage from that existing tab. 7. **Mistral:** sign in at `admin.mistral.ai`, click **Connect** on Mistral, approve only the Mistral origins, and confirm month-to-date spend and any available credits. Record whether the service accepts the request without an diff --git a/store-assets/chrome-web-store/listing-zh_CN.md b/store-assets/chrome-web-store/listing-zh_CN.md index c54c709..9e31d27 100644 --- a/store-assets/chrome-web-store/listing-zh_CN.md +++ b/store-assets/chrome-web-store/listing-zh_CN.md @@ -1,6 +1,6 @@ # Chrome 应用商店上架说明 -本文档说明 AI Limits 0.4.3 版本。这是完整的 Chrome 应用商店提交包,包含可直接粘贴的上架文案以及审核参考材料。本文档并不表示任何服务提供商已批准本扩展。 +本文档说明 AI Limits 0.4.4 版本。这是完整的 Chrome 应用商店提交包,包含可直接粘贴的上架文案以及审核参考材料。本文档并不表示任何服务提供商已批准本扩展。 ## 商店配置 @@ -66,7 +66,7 @@ AI Limits 是 TiantianFlow 独立开发的项目,与任何已连接的服务 - `https://claude.ai/*`:读取已登录用户的 Claude 组织用量响应。 - `https://www.kimi.com/*`:读取 Kimi 用量/订阅响应,并支持有边界的交互式会话访问与恢复流程。 - `https://cursor.com/*`:读取已登录用户的 Cursor 基础用量。仅在 **连接** 或手动 **刷新** 期间,还可以在一个已经打开的精确来源页面中运行捆绑的只读代码;如果当前没有打开的标签页,可能会短暂打开一个非活动用量页,以请求 Grok Bot、额度授予和汇总包含用量 JSON。 -- `https://grok.com/*`:读取已登录用户的 Grok 会话、速率限制和订阅响应。这是 grok.com 上的消费者版 Grok,不是 Cursor 的 Grok Bot。 +- `https://grok.com/*`:从已经打开的 grok.com 页面读取已登录用户的 Grok 会话、用量池、速率限制和订阅响应。仅在 **连接** 或手动 **刷新** 期间,如果当前没有打开的标签页,可能会短暂打开一个非活动 grok.com 标签页。这是 grok.com 上的消费者版 Grok,不是 Cursor 的 Grok Bot。 - `https://admin.mistral.ai/*` 和 `https://console.mistral.ai/*`:从已登录的 Mistral 会话读取本月用量和额度。 - `https://www.perplexity.ai/*`:从已登录的 Perplexity 会话读取额度池。 - `https://api.elevenlabs.io/*`:仅在只读订阅请求中,将用户创建的 API 密钥作为 `xi-api-key` 请求头发送,然后标准化每月额度和受支持的语音限制。扩展不会请求 `https://elevenlabs.io/*`;设置页面按普通网页打开,不会获得扩展主机访问权限。 @@ -77,17 +77,17 @@ AI Limits 是 TiantianFlow 独立开发的项目,与任何已连接的服务 ### 可选 API 权限 - `cookies`:仅在获得 Kimi 访问权限时请求;在可用时只读取 Kimi 精确的旧版 `kimi-auth` cookie。此项检查发生在读取 Kimi 页面存储之前。 -- `scripting`:在获得 Kimi 或 Cursor 访问权限时请求。对于 Kimi,它只从一个已经打开或由恢复流程创建的匹配 Kimi 页面中,读取精确的 `access_token` 浏览器存储条目。如果凭据缺失或被拒绝,交互式恢复可能会创建一个不活动的 Kimi 首页标签页。恢复流程在 10 秒后停止等待凭据,并仅对其自己创建的标签页尝试尽力清理;关闭过程或浏览器 API 错误可能延迟或阻止清理。对于 Cursor,**连接** 或手动 **刷新** 可以在一个已经打开的 `cursor.com` 页面中运行捆绑的精确来源函数。如果当前没有打开的标签页,该交互路径可能会创建一个不活动的 `https://cursor.com/dashboard/spending` 标签页,最多等待 10 秒,并且只关闭它自己创建的标签页。它绝不会激活标签页,也绝不会关闭用户自己打开的标签页。该函数仅向 Grok Bot、额度授予和汇总用量仪表板端点发送固定正文为 `{}` 的 POST 请求,只返回它们的 JSON,并且不会直接检查渲染内容、浏览器存储或 cookie 值。Chrome 会把已登录的 Cursor cookie 附加到这些同源请求。定时刷新绝不会打开标签页或向 Cursor 页面注入代码。 +- `scripting`:在获得 Kimi、Cursor 或 Grok 访问权限时请求。对于 Kimi,它只从一个已经打开或由恢复流程创建的匹配 Kimi 页面中,读取精确的 `access_token` 浏览器存储条目。如果凭据缺失或被拒绝,交互式恢复可能会创建一个不活动的 Kimi 首页标签页。恢复流程在 10 秒后停止等待凭据,并仅对其自己创建的标签页尝试尽力清理;关闭过程或浏览器 API 错误可能延迟或阻止清理。对于 Cursor,**连接** 或手动 **刷新** 可以在一个已经打开的 `cursor.com` 页面中运行捆绑的精确来源函数。如果当前没有打开的标签页,该交互路径可能会创建一个不活动的 `https://cursor.com/dashboard/spending` 标签页,最多等待 10 秒,并且只关闭它自己创建的标签页。它绝不会激活标签页,也绝不会关闭用户自己打开的标签页。该函数仅向 Grok Bot、额度授予和汇总用量仪表板端点发送固定正文为 `{}` 的 POST 请求,只返回它们的 JSON,并且不会直接检查渲染内容、浏览器存储或 cookie 值。Chrome 会把已登录的 Cursor cookie 附加到这些同源请求。定时刷新绝不会打开标签页或向 Cursor 页面注入代码。对于 Grok,**连接** 或手动 **刷新** 可以在一个已经打开的 `grok.com` 页面中运行捆绑的精确来源函数。如果当前没有打开的标签页,该交互路径可能会创建一个不活动的 `https://grok.com/` 标签页,最多等待 10 秒,并且只关闭它自己创建的标签页。该函数发送已登录的会话、用量池、订阅和聊天模式速率限制请求,只返回这些响应,并且不会直接检查渲染内容、浏览器存储或 cookie 值。Chrome 会把已登录的 Grok cookie 附加到这些同源请求。定时刷新可能向已经打开的 grok.com 标签页注入代码,但绝不会新建标签页。 -清单不会请求范围更广的 `tabs` 权限。定时刷新不会创建或激活服务标签页,也绝不会向服务页面注入代码。 +清单不会请求范围更广的 `tabs` 权限。定时刷新不会创建或激活服务标签页。Grok 定时刷新可能向已经打开的 grok.com 标签页注入代码;Cursor 定时刷新绝不会注入。 ## 数据披露答案 - **身份验证信息:** 是。这包括用户创建、并在验证成功后由 AI Limits 保存在本地的服务商 API 密钥。其他服务的浏览器会话 JSON、cookie 和访问凭据仅在所选采集序列期间使用,不会被持久化。API 密钥保存在单独的受信任上下文凭据记录中,只发送到所选服务的 API,绝不会发送给开发者。 -- **网站内容:** 是。AI Limits 会处理私有的服务会话、用量、订阅和组织响应 JSON,以及 Kimi 精确的 `access_token` 浏览器存储条目和 Cursor 的手动页面上下文仪表板 JSON 响应(Grok Bot、额度授予和汇总包含用量)。它不会读取已渲染的页面文本、提示词、对话或生成的回复。 +- **网站内容:** 是。AI Limits 会处理私有的服务会话、用量、订阅和组织响应 JSON,以及 Kimi 精确的 `access_token` 浏览器存储条目、Cursor 的手动页面上下文仪表板 JSON 响应(Grok Bot、额度授予和汇总包含用量),以及 Grok 页面来源的会话、用量池、订阅和速率限制响应。它不会读取已渲染的页面文本、提示词、对话或生成的回复。 - **账户标识符和个人身份信息:** 是,但处理范围很窄。从访问凭据派生的 ChatGPT 账户标识,以及 Claude 的组织 UUID/能力信息,仅在请求期间使用,不会被持久化。Claude 的组织名称可能会被标准化并存储为可见的套餐标签。类似电子邮件形态的账户标签会被丢弃,当前适配器不会查询 Claude 或 Cursor 的电子邮件端点。 - **财务和支付信息:** 是,仅限于用量响应中包含的、由服务商报告的用量额度余额、额外用量金额或限额,以及按需支出数值/限额。这些标准化计数和余额可能会保留在本地的分实例 History 中。不会访问或保留支付卡、银行信息、原始服务响应或交易历史。 -- **网络历史:** 是,分类范围很窄且偏保守。在 Kimi 采集期间,AI Limits 可能会检查是否已有匹配 Kimi 精确来源的已打开标签页。在 Cursor **连接** 或手动 **刷新** 期间,它可能会检查是否已有一个匹配 Cursor 精确来源的已打开标签页。这只会揭示该固定服务来源的标签页当前是否打开,仅在本地用于所请求的服务操作,绝不会被保留、传输或汇编成已访问页面列表。AI Limits 不会查询任意页面网址、标题或 Chrome 浏览历史。 +- **网络历史:** 是,分类范围很窄且偏保守。在 Kimi 采集期间,AI Limits 可能会检查是否已有匹配 Kimi 精确来源的已打开标签页。在 Cursor **连接** 或手动 **刷新** 期间,它可能会检查是否已有一个匹配 Cursor 精确来源的已打开标签页。在 Grok 采集期间,它可能会检查是否已有一个匹配 Grok 精确来源的已打开标签页。这只会揭示该固定服务来源的标签页当前是否打开,仅在本地用于所请求的服务操作,绝不会被保留、传输或汇编成已访问页面列表。AI Limits 不会查询任意页面网址、标题或 Chrome 浏览历史。 - **用户活动:** 不会监控点击、按键、指针移动、滚动或一般浏览活动。 - **健康信息、私人通信和位置:** 否。 - **出售或无关共享:** 否。数据不会被出售,也不会发送给开发者、广告商、数据经纪人或无关第三方。 @@ -100,7 +100,7 @@ AI Limits 是 TiantianFlow 独立开发的项目,与任何已连接的服务 ## 审核前提条件 - Chrome 116 或更高版本。 -- 经过校验的 `ai-limits-0.4.3-chrome.zip` 上传产物。 +- 经过校验的 `ai-limits-0.4.4-chrome.zip` 上传产物。 - 审核人员自有的测试账号,并已在同一 Chrome 配置文件中登录所需的浏览器会话服务站点;还需为待测 API 密钥服务创建临时密钥。ElevenLabs 应使用 **User → Read**。扩展不内置、也不提供任何凭据或服务账号。 - 能够访问各服务来源的网络。私有端点行为和账号权益可能影响哪些用量窗口会显示。 - 仓库公开之后、提交 Chrome 应用商店之前,请在未登录的浏览器中确认主页、隐私政策和支持网址均可访问。 @@ -112,7 +112,7 @@ AI Limits 是 TiantianFlow 独立开发的项目,与任何已连接的服务 3. **Claude:** 在 `claude.ai` 登录,在 Claude 上点击 **连接**,只批准 Claude 来源,并确认出现组织套餐以及可用的用量窗口或额外用量额度。使用该卡片的 **刷新** 操作一次。 4. **Kimi:** 在 `www.kimi.com` 登录,在 Kimi 上点击 **连接**,批准 Kimi 来源以及 `cookies` 和 `scripting`,并确认出现可用用量和订阅数据。如果在交互式刷新期间需要会话恢复,可能会出现一个不活动的 Kimi 标签页。确认恢复流程在 10 秒后停止等待凭据并尝试尽力清理;不要把关闭过程或浏览器 API 错误期间的延迟清理当作保证被违反。 5. **Cursor:** 在 `cursor.com` 登录。**不必**预先打开一个 Cursor 标签页。在 Cursor 上点击 **连接**,并批准 Cursor 来源以及 `scripting`。确认可能会出现 Cursor 模型与其他模型的月度限额、Grok Bot 每周用量,以及按 Cursor Models 和 Other Models 分组的包含用量明细(输入、输出、费用和服务商合计)。如果当时没有已经打开的 `cursor.com` 标签页,可能会出现一个不活动的 `https://cursor.com/dashboard/spending` 标签页,读取后关闭;确认它不会被激活,并且审核人员自己打开的标签页不会被关闭。在已经打开一个 Cursor 标签页的情况下再使用该卡片的 **刷新** 操作一次,确认会复用现有标签页而不会再创建第二个。然后关闭所有 Cursor 标签页并启动一次**定时**周期(或等待自动刷新):基础的每月/按需用量应仍然可用,不应打开 Cursor 标签页,也不应向页面注入代码。此前采集到的 Grok Bot、额外额度和明细可能作为更早的页面数值继续显示,直到 Grok Bot 的每周重置,并且卡片应说明为何未能刷新。 -6. **Grok:** 在 `grok.com` 登录,在 Grok 上点击 **连接**,只批准 Grok 来源,并确认出现 SuperGrok 系列或 Free 套餐以及所报告的查询窗口。使用该卡片的 **刷新** 操作一次。不应再请求额外的 `cookies` 或 `scripting` 权限。 +6. **Grok:** 在 `grok.com` 登录。**不必**预先打开一个 Grok 标签页。在 Grok 上点击 **连接**,批准 Grok 来源以及 `scripting`,并确认出现 SuperGrok 系列或 Free 套餐以及所报告的用量池。如果当时没有已经打开的 `grok.com` 标签页,可能会出现一个不活动的 `https://grok.com/` 标签页,读取后关闭;确认它不会被激活,并且审核人员自己打开的标签页不会被关闭。在已经打开一个 grok.com 标签页的情况下再使用该卡片的 **刷新** 操作一次,确认会复用现有标签页而不会再创建第二个。然后关闭所有 grok.com 标签页并启动一次**定时**周期(或等待自动刷新):不应打开 Grok 标签页。重新打开 grok.com 后,确认定时或手动刷新可以从该现有标签页更新用量。 7. **Mistral:** 在 `admin.mistral.ai` 登录,连接 Mistral,仅批准 Mistral 来源,并确认显示本月至今支出和可用额度。记录服务是否接受缺少 `X-CSRFTOKEN` 请求头的请求。 8. **Perplexity:** 在 `www.perplexity.ai` 登录,连接 Perplexity,仅批准该来源,并确认显示周期性、已购买和促销额度池。 9. **ElevenLabs:** 如条件允许,请在未登录时点击 **连接 ElevenLabs**。登录后,使用仍打开的指南中的 **打开 API 密钥页面**,创建一个名为 **AI Limits**、权限为 **User → Read** 且不含生成/写入范围的密钥,粘贴密钥,然后选择 **验证并连接**,并只批准 `https://api.elevenlabs.io/*`。确认套餐、每月额度和可用语音限制。关闭所有 ElevenLabs 标签页并再次刷新;不应打开任何标签页。如有可能,在 ElevenLabs 撤销该密钥,并确认 AI Limits 停止定时尝试、保留过期数据,并提供 **更换密钥**。