diff --git a/plugin/lib/anthropic.ts b/plugin/lib/anthropic.ts new file mode 100644 index 0000000..c04a3a6 --- /dev/null +++ b/plugin/lib/anthropic.ts @@ -0,0 +1,224 @@ +/** + * Anthropic Claude 额度查询模块 + * + * [输入]: auth.json → anthropic (OAuth access/refresh tokens) + * [输出]: 格式化的 5-hour / 7-day 限额使用情况 + * [定位]: 被 mystatus.ts 调用,处理 Anthropic Claude Pro/Max 账号 + * [同步]: mystatus.ts, types.ts, utils.ts, i18n.ts + * + * 技术背景: + * Anthropic 提供了一个内部端点供 Claude Code 使用,用于查询订阅配额: + * GET https://api.anthropic.com/api/oauth/usage + * 认证需要通过 OAuth access token (sk-ant-oat01-...) 发送 Bearer header, + * 并附带 anthropic-beta 和 User-Agent 头部,否则会返回 401/403。 + * 注意:该端点目前为内部/未公开接口,行为可能随版本变化。 + */ + +import { t } from "./i18n"; +import { type QueryResult, type AnthropicAuthData } from "./types"; +import { + createProgressBar, + calcRemainPercent, + formatDuration, + fetchWithTimeout, +} from "./utils"; + +// ============================================================================ +// 常量 +// ============================================================================ + +const ANTHROPIC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; +const ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; + +/** + * Claude Code 官方 OAuth client_id,用于 refresh token 交换。 + * 来源:Claude Code 官方客户端 OAuth 流程逆向分析(公开已知)。 + */ +const CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; + +/** + * Anthropic beta 标头:激活 oauth/usage 端点所必需,否则返回 401。 + */ +const ANTHROPIC_BETA = "oauth-2025-04-20"; + +/** + * User-Agent 需要标识为 claude-code,否则服务器会更积极地限速(429)。 + */ +const ANTHROPIC_USER_AGENT = "claude-code/1.0.17"; + +// ============================================================================ +// 类型定义 +// ============================================================================ + +interface AnthropicUsageResponse { + five_hour?: { utilization: number; resets_at: string }; + seven_day?: { utilization: number; resets_at: string }; + extra_usage?: unknown; +} + +// ============================================================================ +// Token 刷新 +// ============================================================================ + +/** + * 使用 refresh token 获取新的 access token。 + * Anthropic 使用 refresh token rotation,每次刷新后旧的 refresh token 失效。 + * 注意:此处无法将新 token 写回 auth.json,调用方应提示用户重新登录。 + */ +async function refreshAccessToken( + refreshToken: string, +): Promise { + try { + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLAUDE_CODE_CLIENT_ID, + }); + + const response = await fetch(ANTHROPIC_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params, + }); + + if (!response.ok) return null; + + const data = (await response.json()) as { access_token?: string }; + return data.access_token ?? null; + } catch { + return null; + } +} + +// ============================================================================ +// API 调用 +// ============================================================================ + +/** + * 查询 Anthropic Claude 订阅配额。 + * 使用 oauth/usage 内部端点(Claude Code HUD 所用的同一端点)。 + */ +async function fetchAnthropicUsage( + accessToken: string, +): Promise { + const response = await fetchWithTimeout(ANTHROPIC_USAGE_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "anthropic-beta": ANTHROPIC_BETA, + "User-Agent": ANTHROPIC_USER_AGENT, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(t.anthropicApiError(response.status, errorText)); + } + + return response.json() as Promise; +} + +// ============================================================================ +// 格式化输出 +// ============================================================================ + +/** + * 将 ISO 时间戳转换为剩余时间(秒) + */ +function secondsUntil(isoTime: string): number { + try { + return Math.max(0, Math.floor((new Date(isoTime).getTime() - Date.now()) / 1000)); + } catch { + return 0; + } +} + +/** + * 格式化单个使用窗口(5h / 7d) + */ +function formatWindow( + label: string, + utilization: number, + resets_at: string, +): string[] { + const remainPercent = calcRemainPercent(utilization); + const progressBar = createProgressBar(remainPercent); + const resetSecs = secondsUntil(resets_at); + const resetStr = resetSecs > 0 ? formatDuration(resetSecs) : t.resetsSoon; + + return [ + label, + `${progressBar} ${t.remaining(remainPercent)}`, + t.resetIn(resetStr), + ]; +} + +/** + * 格式化 Anthropic 使用情况输出 + */ +function formatAnthropicUsage(data: AnthropicUsageResponse): string { + const lines: string[] = []; + + lines.push(`${t.account} Claude Pro/Max`); + lines.push(""); + + if (data.five_hour) { + lines.push(...formatWindow(t.anthropicFiveHourLimit, data.five_hour.utilization, data.five_hour.resets_at)); + } + + if (data.seven_day) { + if (data.five_hour) lines.push(""); + lines.push(...formatWindow(t.anthropicSevenDayLimit, data.seven_day.utilization, data.seven_day.resets_at)); + } + + if (!data.five_hour && !data.seven_day) { + lines.push(t.anthropicNoLimits); + } + + return lines.join("\n"); +} + +// ============================================================================ +// 导出接口 +// ============================================================================ + +export type { AnthropicAuthData }; + +/** + * 查询 Anthropic Claude 订阅配额 + * @param authData Anthropic OAuth 认证数据 + * @returns 查询结果,如果账号不存在或无效返回 null + */ +export async function queryAnthropicUsage( + authData: AnthropicAuthData | undefined, +): Promise { + if (!authData || authData.type !== "oauth") return null; + + let accessToken = authData.access; + + // 如果 access token 过期或缺失,尝试刷新 + if (!accessToken || (authData.expires && authData.expires < Date.now())) { + if (!authData.refresh) { + return { success: false, error: t.anthropicTokenExpired }; + } + const refreshed = await refreshAccessToken(authData.refresh); + if (!refreshed) { + return { success: false, error: t.anthropicRefreshFailed }; + } + accessToken = refreshed; + } + + try { + const usage = await fetchAnthropicUsage(accessToken); + return { + success: true, + output: formatAnthropicUsage(usage), + }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/plugin/lib/google.ts b/plugin/lib/google.ts index 18471db..11a2baa 100644 --- a/plugin/lib/google.ts +++ b/plugin/lib/google.ts @@ -78,13 +78,11 @@ const MODELS_TO_DISPLAY: ModelConfig[] = [ ]; // 获取 Antigravity 账号文件路径 +// Note: opencode always stores this file under ~/.config/opencode/ on all +// platforms, including Windows. Using APPDATA on Windows was incorrect and +// caused quota lookups to silently fail for Windows users. function getAntigravityAccountsPath(): string { - const home = homedir(); - const configDir = - process.platform === "win32" - ? process.env.APPDATA || join(home, "AppData", "Roaming") - : join(home, ".config"); - return join(configDir, "opencode", "antigravity-accounts.json"); + return join(homedir(), ".config", "opencode", "antigravity-accounts.json"); } const GOOGLE_TOKEN_REFRESH_URL = "https://oauth2.googleapis.com/token"; diff --git a/plugin/lib/i18n.ts b/plugin/lib/i18n.ts index 2c15d4b..db0d697 100644 --- a/plugin/lib/i18n.ts +++ b/plugin/lib/i18n.ts @@ -4,7 +4,7 @@ * [输入]: 系统语言环境 * [输出]: 翻译函数和当前语言 * [定位]: 被所有平台模块共享使用 - * [同步]: openai.ts, zhipu.ts, mystatus.ts, utils.ts + * [同步]: openai.ts, zhipu.ts, anthropic.ts, mystatus.ts, utils.ts */ // ============================================================================ @@ -71,7 +71,7 @@ const translations = { tokenExpired: "⚠️ OAuth 授权已过期,请在 OpenCode 中使用一次 OpenAI 模型以刷新授权。", noAccounts: - "未找到任何已配置的账号。\n\n支持的账号类型:\n- OpenAI (Plus/Team/Pro 订阅用户)\n- 智谱 AI (Coding Plan)\n- Z.ai (Coding Plan)\n- Google Cloud (Antigravity)", + "未找到任何已配置的账号。\n\n支持的账号类型:\n- OpenAI (Plus/Team/Pro 订阅用户)\n- Anthropic (Claude Pro/Max)\n- 智谱 AI (Coding Plan)\n- Z.ai (Coding Plan)\n- Google Cloud (Antigravity)", queryFailed: "❌ 查询失败的账号:\n", // 平台标题 @@ -90,6 +90,18 @@ const translations = { zaiAccountName: "Z.ai", noQuotaData: "暂无配额数据", + // Anthropic 相关 + anthropicTitle: "## Anthropic 账号额度", + anthropicApiError: (status: number, text: string) => + `Anthropic API 请求失败 (${status}): ${text}`, + anthropicTokenExpired: + "⚠️ Anthropic OAuth token 已过期且无 refresh token,请在 OpenCode 中重新登录 Anthropic。", + anthropicRefreshFailed: + "⚠️ Anthropic token 刷新失败,请在 OpenCode 中重新登录 Anthropic。", + anthropicFiveHourLimit: "5 小时限额", + anthropicSevenDayLimit: "7 天限额", + anthropicNoLimits: "(未检测到限额数据 — 可能是 API Key 订阅或无限制计划)", + // Google 相关 googleTitle: "## Google Cloud 账号额度", googleApiError: (status: number, text: string) => @@ -149,7 +161,7 @@ const translations = { tokenExpired: "⚠️ OAuth token expired. Please use an OpenAI model in OpenCode to refresh authorization.", noAccounts: - "No configured accounts found.\n\nSupported account types:\n- OpenAI (Plus/Team/Pro subscribers)\n- Zhipu AI (Coding Plan)\n- Z.ai (Coding Plan)\n- Google Cloud (Antigravity)", + "No configured accounts found.\n\nSupported account types:\n- OpenAI (Plus/Team/Pro subscribers)\n- Anthropic (Claude Pro/Max)\n- Zhipu AI (Coding Plan)\n- Z.ai (Coding Plan)\n- Google Cloud (Antigravity)", queryFailed: "❌ Failed to query accounts:\n", // 平台标题 @@ -168,6 +180,18 @@ const translations = { zaiAccountName: "Z.ai", noQuotaData: "No quota data available", + // Anthropic 相关 + anthropicTitle: "## Anthropic Account Quota", + anthropicApiError: (status: number, text: string) => + `Anthropic API request failed (${status}): ${text}`, + anthropicTokenExpired: + "⚠️ Anthropic OAuth token expired and no refresh token available. Re-authenticate Anthropic in OpenCode.", + anthropicRefreshFailed: + "⚠️ Anthropic token refresh failed. Re-authenticate Anthropic in OpenCode.", + anthropicFiveHourLimit: "5-hour limit", + anthropicSevenDayLimit: "7-day limit", + anthropicNoLimits: "(No rolling-window limits found — may be API key plan or unlimited)", + // Google 相关 googleTitle: "## Google Cloud Account Quota", googleApiError: (status: number, text: string) => diff --git a/plugin/lib/types.ts b/plugin/lib/types.ts index 01543f8..15d8628 100644 --- a/plugin/lib/types.ts +++ b/plugin/lib/types.ts @@ -2,7 +2,7 @@ * 共享类型定义 * * [定位]: 被所有平台模块共享使用的类型 - * [同步]: openai.ts, zhipu.ts, google.ts, mystatus.ts + * [同步]: openai.ts, zhipu.ts, google.ts, anthropic.ts, mystatus.ts */ // ============================================================================ @@ -32,6 +32,17 @@ export interface OpenAIAuthData { expires?: number; } +/** + * Anthropic Claude OAuth 认证数据 + * 对应 auth.json 中的 "anthropic" 键 + */ +export interface AnthropicAuthData { + type: string; + access?: string; + refresh?: string; + expires?: number; +} + /** * 智谱 AI API 认证数据 */ @@ -98,6 +109,7 @@ export interface AntigravityAccountsFile { */ export interface AuthData { openai?: OpenAIAuthData; + anthropic?: AnthropicAuthData; "zhipuai-coding-plan"?: ZhipuAuthData; "zai-coding-plan"?: ZhipuAuthData; "github-copilot"?: CopilotAuthData; diff --git a/plugin/mystatus.ts b/plugin/mystatus.ts index 00eddee..0f93858 100644 --- a/plugin/mystatus.ts +++ b/plugin/mystatus.ts @@ -4,7 +4,7 @@ * [输入]: ~/.local/share/opencode/auth.json 和 ~/.config/opencode/antigravity-accounts.json 中的认证信息 * [输出]: 带进度条的额度使用情况展示 * [定位]: 通过 mystatus 工具查询各账号额度 - * [同步]: lib/openai.ts, lib/zhipu.ts, lib/google.ts, lib/types.ts, lib/i18n.ts + * [同步]: lib/openai.ts, lib/zhipu.ts, lib/google.ts, lib/anthropic.ts, lib/types.ts, lib/i18n.ts */ import { type Plugin, tool } from "@opencode-ai/plugin"; @@ -17,6 +17,7 @@ import { type AuthData, type QueryResult } from "./lib/types"; import { queryOpenAIUsage } from "./lib/openai"; import { queryZaiUsage, queryZhipuUsage } from "./lib/zhipu"; import { queryGoogleUsage } from "./lib/google"; +import { queryAnthropicUsage } from "./lib/anthropic"; import { queryCopilotUsage } from "./lib/copilot"; // ============================================================================ @@ -28,7 +29,7 @@ export const MyStatusPlugin: Plugin = async () => { tool: { mystatus: tool({ description: - "Query account quota usage for all configured AI platforms. Returns remaining quota percentages, usage stats, and reset countdowns with visual progress bars. Currently supports OpenAI (ChatGPT/Codex), Zhipu AI, Z.ai, Google Antigravity, and GitHub Copilot.", + "Query account quota usage for all configured AI platforms. Returns remaining quota percentages, usage stats, and reset countdowns with visual progress bars. Currently supports OpenAI (ChatGPT/Codex), Anthropic (Claude Pro/Max), Zhipu AI, Z.ai, Google Antigravity, and GitHub Copilot.", args: {}, async execute() { // 1. 读取 auth.json @@ -46,9 +47,10 @@ export const MyStatusPlugin: Plugin = async () => { } // 2. 并行查询所有平台(Google 不依赖 authData) - const [openaiResult, zhipuResult, zaiResult, googleResult, copilotResult] = + const [openaiResult, anthropicResult, zhipuResult, zaiResult, googleResult, copilotResult] = await Promise.all([ queryOpenAIUsage(authData.openai), + queryAnthropicUsage(authData.anthropic), queryZhipuUsage(authData["zhipuai-coding-plan"]), queryZaiUsage(authData["zai-coding-plan"]), queryGoogleUsage(), @@ -62,6 +64,9 @@ export const MyStatusPlugin: Plugin = async () => { // 处理 OpenAI 结果 collectResult(openaiResult, t.openaiTitle, results, errors); + // 处理 Anthropic 结果 + collectResult(anthropicResult, t.anthropicTitle, results, errors); + // 处理智谱结果 collectResult(zhipuResult, t.zhipuTitle, results, errors);