Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions plugin/lib/anthropic.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<AnthropicUsageResponse> {
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<AnthropicUsageResponse>;
}

// ============================================================================
// 格式化输出
// ============================================================================

/**
* 将 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<QueryResult | null> {
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),
};
}
}
10 changes: 4 additions & 6 deletions plugin/lib/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
30 changes: 27 additions & 3 deletions plugin/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* [输入]: 系统语言环境
* [输出]: 翻译函数和当前语言
* [定位]: 被所有平台模块共享使用
* [同步]: openai.ts, zhipu.ts, mystatus.ts, utils.ts
* [同步]: openai.ts, zhipu.ts, anthropic.ts, mystatus.ts, utils.ts
*/

// ============================================================================
Expand Down Expand Up @@ -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",

// 平台标题
Expand All @@ -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) =>
Expand Down Expand Up @@ -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",

// 平台标题
Expand All @@ -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) =>
Expand Down
14 changes: 13 additions & 1 deletion plugin/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* 共享类型定义
*
* [定位]: 被所有平台模块共享使用的类型
* [同步]: openai.ts, zhipu.ts, google.ts, mystatus.ts
* [同步]: openai.ts, zhipu.ts, google.ts, anthropic.ts, mystatus.ts
*/

// ============================================================================
Expand Down Expand Up @@ -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 认证数据
*/
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 8 additions & 3 deletions plugin/mystatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

// ============================================================================
Expand All @@ -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
Expand All @@ -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(),
Expand All @@ -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);

Expand Down