diff --git a/AGENTS.md b/AGENTS.md index 44d08ae955..209e9640aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,3 +101,13 @@ const table = sqliteTable("session", { ## Type Checking - Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly. + +## graphify + +This project has a graphify knowledge graph at graphify-out/. + +Rules: +- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- For cross-module "how does X relate to Y" questions, prefer `graphify query ""`, `graphify path "" ""`, or `graphify explain ""` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files +- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index cd47e91708..da05af2a1e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -738,20 +738,23 @@ export function Prompt(props: PromptProps) { const messageID = MessageID.ascending() let inputText = store.prompt.input - // Expand pasted text inline before submitting + // Expand pasted text inline before submitting. + // 用虚拟文本字符串内容定位、从右向左替换;不依赖 extmark.start/end 偏移 + // 在用户编辑后是否被 opentui 准确追踪(实测会出现偏移漂移导致 hint 字符残留)。 const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) for (const extmark of sortedExtmarks) { const partIndex = store.extmarkToPartIndex.get(extmark.id) - if (partIndex !== undefined) { - const part = store.prompt.parts[partIndex] - if (part?.type === "text" && part.text) { - const before = inputText.slice(0, extmark.start) - const after = inputText.slice(extmark.end) - inputText = before + part.text + after - } - } + if (partIndex === undefined) continue + const part = store.prompt.parts[partIndex] + if (part?.type !== "text" || !part.text) continue + const virtualText = part.source?.text?.value + if (!virtualText) continue + // lastIndexOf 配合 high→low 遍历,保证多个相同 hint 时一一对应 + const idx = inputText.lastIndexOf(virtualText) + if (idx === -1) continue + inputText = inputText.slice(0, idx) + part.text + inputText.slice(idx + virtualText.length) } // Filter out text parts (pasted content) since they're now expanded inline diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts index 9e95f36cc8..018d7a2033 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts @@ -7,54 +7,61 @@ export interface QuotaAuth { quotaUrl: string token: string provider: "github-proxy" | "github-copilot" - /** github-copilot 直连模式使用 "token " 认证头格式 */ - authHeaderPrefix: "Bearer" | "token" } export interface QuotaInfo { - remaining: number + /** 已用量(consumed count)。proxy 直接读响应字段;copilot 由 entitlement-remaining 换算 */ + used: number entitlement: number accounts_active: number accounts_total: number } -export async function readQuotaAuth(stateDir: string): Promise { +/** + * 按 providerID 精确读取对应的 QuotaAuth。 + * providerID 以 "github-proxy" 或 "github-copilot" 开头均可(支持子变体)。 + */ +export async function readQuotaAuthForProvider( + stateDir: string, + providerID: string, +): Promise { try { const text = await readFile(path.join(stateDir, "auth.json"), "utf-8") const data = JSON.parse(text) as Record - // 优先:github-proxy(带 proxyUrl 的内置 /copilot/quota 端点) - const proxyEntry = data["github-proxy"] as Record | undefined - if (proxyEntry?.type === "api") { - const meta = proxyEntry.metadata as Record | undefined - const proxyUrl = meta?.proxyUrl - const apiKey = proxyEntry.key as string | undefined - if (proxyUrl && apiKey) { - return { - quotaUrl: `${proxyUrl.replace(/\/+$/, "")}/copilot/quota`, - token: apiKey, - provider: "github-proxy", - authHeaderPrefix: "Bearer", - } + if (providerID.startsWith("github-proxy")) { + const entry = data["github-proxy"] as Record | undefined + if (entry?.type === "api") { + const meta = entry.metadata as Record | undefined + const proxyUrl = meta?.proxyUrl + const apiKey = entry.key as string | undefined + if (proxyUrl && apiKey) + return { + quotaUrl: `${proxyUrl.replace(/\/+$/, "")}/copilot/quota`, + token: apiKey, + provider: "github-proxy", + } } + return null } - // 回退:github-copilot 直连模式(GitHub API 取 quota) - const copilotEntry = data["github-copilot"] as Record | undefined - if (copilotEntry?.type === "oauth") { - const refresh = copilotEntry.refresh as string | undefined - if (refresh) { - const enterpriseUrl = copilotEntry.enterpriseUrl as string | undefined - const apiBase = enterpriseUrl - ? `https://api.${enterpriseUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}` - : "https://api.github.com" - return { - quotaUrl: `${apiBase}/copilot_internal/user`, - token: refresh, - provider: "github-copilot", - authHeaderPrefix: "token", + if (providerID.startsWith("github-copilot")) { + const entry = data["github-copilot"] as Record | undefined + if (entry?.type === "oauth") { + const refresh = entry.refresh as string | undefined + if (refresh) { + const enterpriseUrl = entry.enterpriseUrl as string | undefined + const apiBase = enterpriseUrl + ? `https://api.${enterpriseUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}` + : "https://api.github.com" + return { + quotaUrl: `${apiBase}/copilot_internal/user`, + token: refresh, + provider: "github-copilot", + } } } + return null } return null @@ -63,31 +70,27 @@ export async function readQuotaAuth(stateDir: string): Promise } } -/** 从 GitHub Copilot API 响应解析 quota(百分比模式) */ +/** 从 GitHub Copilot API 响应解析 quota(snake_case 字段) */ export function parseCopilotQuota(data: Record): QuotaInfo | null { - const snapshots = data.quotaSnapshots as Record | undefined - const premium = snapshots?.premiumInteractions as Record | undefined + const snapshots = data.quota_snapshots as Record | undefined + const premium = snapshots?.premium_interactions as Record | undefined if (!premium) return null - const percentRemaining = typeof premium.percentRemaining === "number" ? premium.percentRemaining : null - if (percentRemaining === null) return null + const actualRemaining = typeof premium.remaining === "number" ? premium.remaining : null + const entitlement = typeof premium.entitlement === "number" ? premium.entitlement : null + if (actualRemaining === null || entitlement === null) return null - // 百分比转 entitlement/remaining 数值,与 proxy 格式统一 - return { - remaining: 100 - percentRemaining, - entitlement: 100, - accounts_active: 0, - accounts_total: 0, - } + // GitHub API 返回「剩余量」,换算为「已用量」 + return { used: entitlement - actualRemaining, entitlement, accounts_active: 0, accounts_total: 0 } } -/** 从 github-proxy 自定义 /copilot/quota 端点响应解析 */ +/** 从 github-proxy /copilot/quota 端点解析 quota */ export function parseProxyQuota(data: Record): QuotaInfo | null { const remaining = typeof data.remaining === "number" ? data.remaining : null const entitlement = typeof data.entitlement === "number" ? data.entitlement : null if (remaining === null || entitlement === null) return null return { - remaining, + used: entitlement - remaining, entitlement, accounts_active: typeof data.accounts_active === "number" ? data.accounts_active : 0, accounts_total: typeof data.accounts_total === "number" ? data.accounts_total : 0, @@ -97,8 +100,8 @@ export function parseProxyQuota(data: Record): QuotaInfo | null export async function fetchQuota(auth: QuotaAuth): Promise { try { const resp = await fetch(auth.quotaUrl, { - headers: { Authorization: `${auth.authHeaderPrefix} ${auth.token}` }, - signal: AbortSignal.timeout(5_000), + headers: { Authorization: `Bearer ${auth.token}` }, + signal: AbortSignal.timeout(2_000), }) if (!resp.ok) return null const data = (await resp.json()) as Record diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota.tsx index 1fd9a7fe94..4602def794 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota.tsx @@ -1,18 +1,24 @@ // quota.tsx — 在 session prompt 右侧显示 Copilot premium request 配额。 -// 支持两种 auth 来源: -// 1. github-proxy(type:"api",metadata.proxyUrl + key 走 /copilot/quota) -// 2. github-copilot(type:"oauth",refresh token 走 GitHub /copilot_internal/user) +// +// Provider 选择策略: +// 从当前 session 最后一条 AssistantMessage 取 providerID(响应式); +// 无消息时降级到 config.model 解析的 providerID; +// 按 providerID 精确读取对应 auth,不再依次尝试所有来源。 +// +// 颜色规则(按已用量绝对值): +// used ≤ 100 → success(绿);≤ 200 → warning(黄);> 200 → error(红) // // 重要:opentui Slot 在初始渲染时若返回空内容,会永久跳过本插件。 // 因此组件在数据就绪前显示 "⊘ …" 占位。 +import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" -import { createSignal, onCleanup } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { Global } from "@opencode-ai/core/global" -import { fetchQuota, readQuotaAuth, type QuotaAuth, type QuotaInfo } from "./quota-fetch" +import { fetchQuota, readQuotaAuthForProvider, type QuotaInfo } from "./quota-fetch" const id = "internal:session-quota" -function QuotaView(props: { api: TuiPluginApi }) { +function QuotaView(props: { api: TuiPluginApi; session_id: string }) { const theme = () => props.api.theme.current const [label, setLabel] = createSignal("⊘ …") const [tone, setTone] = createSignal<"muted" | "success" | "warning" | "error">("muted") @@ -29,42 +35,49 @@ function QuotaView(props: { api: TuiPluginApi }) { return t.textMuted } } - const [quotaAuth, setQuotaAuth] = createSignal(null) + + // 从最后一条 AssistantMessage 取 providerID;无消息时从 config.model 解析 + const providerID = createMemo(() => { + const messages = props.api.state.session.messages(props.session_id) + const last = messages.findLast((m): m is AssistantMessage => m.role === "assistant") + if (last) return last.providerID + const configModel = props.api.state.config.model ?? "" + const slash = configModel.indexOf("/") + return slash > 0 ? configModel.slice(0, slash) : configModel + }) function applyQuota(q: QuotaInfo) { - // 后端 remaining 字段实际是 used(已用量),需翻转为真正的剩余量 - const actual = q.entitlement - q.remaining - const pct = Math.round((actual / Math.max(q.entitlement, 1)) * 100) - setTone(pct > 30 ? "success" : pct > 10 ? "warning" : "error") + // q.used 是已消费量,直接用于显示和颜色判断 + setTone(q.used <= 100 ? "success" : q.used <= 200 ? "warning" : "error") if (q.accounts_total > 0) { - setLabel(`[${q.accounts_active}/${q.accounts_total} | ${actual}/${q.entitlement}]`) + setLabel(`[${q.accounts_active}/${q.accounts_total} | ${q.used}/${q.entitlement}]`) } else { - setLabel(`⊘ ${actual}/${q.entitlement}`) + setLabel(`⊘ ${q.used}/${q.entitlement}`) } } - // 启动:读 auth → 首次拉取 - // 注意:auth.json 位于 Global.Path.data(XDG_DATA_HOME), - // 不能用 props.api.state.path.state(XDG_STATE_HOME),二者是不同目录。 - readQuotaAuth(Global.Path.data).then((auth) => { + async function refresh() { + const pid = providerID() + if (!pid) return + // 注意:auth.json 位于 Global.Path.data(XDG_DATA_HOME), + // 不能用 props.api.state.path.state(XDG_STATE_HOME),二者是不同目录。 + const auth = await readQuotaAuthForProvider(Global.Path.data, pid) if (!auth) { setLabel("") return } - setQuotaAuth(auth) - fetchQuota(auth).then((q) => { - if (q) applyQuota(q) - else setLabel("") - }) - }) - - // 每 60 秒刷新 - const timer = setInterval(async () => { - const auth = quotaAuth() - if (!auth) return const q = await fetchQuota(auth) if (q) applyQuota(q) - }, 60_000) + else setLabel("") + } + + // providerID 变化时立即重新拉取(含首次挂载) + createEffect(() => { + void refresh() + }) + + // 每 60 秒刷新一次 + const timer = setInterval(() => void refresh(), 60_000) onCleanup(() => clearInterval(timer)) // 必须返回非空内容(即使是占位符),否则 opentui Slot 永久跳过本插件 @@ -79,8 +92,8 @@ const tui: TuiPlugin = async (api) => { api.slots.register({ order: 100, slots: { - session_prompt_right() { - return + session_prompt_right(_ctx, props) { + return }, }, }) diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index d24d9b9dae..5b2e976337 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -162,6 +162,38 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { delete headers["x-api-key"] delete headers["authorization"] + // 临时调试:定位 premium 配额异常消耗。记录每次发出的 x-initiator + 请求形态。 + // 用完即删,不要长期保留。 + try { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body + const lastMsg = body?.messages?.[body.messages.length - 1] ?? body?.input?.[body.input.length - 1] + const lastRole = lastMsg?.role ?? "unknown" + const lastContentTypes = Array.isArray(lastMsg?.content) + ? lastMsg.content.map((p: any) => p?.type).join(",") + : typeof lastMsg?.content === "string" + ? "string" + : "none" + const lastTextSample = + Array.isArray(lastMsg?.content) + ? lastMsg.content.find((p: any) => p?.type === "text" || p?.type === "input_text")?.text?.slice(0, 80) + : typeof lastMsg?.content === "string" + ? lastMsg.content.slice(0, 80) + : undefined + log.info("copilot fetch", { + url: url.replace(/^https?:\/\/[^/]+/, ""), + xInitiator: headers["x-initiator"], + hookHeader: (init?.headers as Record)?.["x-initiator"], + isAgent, + isVision, + lastRole, + lastContentTypes, + lastTextSample, + msgCount: body?.messages?.length ?? body?.input?.length, + }) + } catch (err) { + log.error("copilot fetch debug log failed", { err }) + } + return fetch(request, { ...init, headers, diff --git a/packages/opencode/test/cli/cmd/tui/feature-plugins/session/quota-fetch.test.ts b/packages/opencode/test/cli/cmd/tui/feature-plugins/session/quota-fetch.test.ts index 7f9d1cdd27..619b5c9b27 100644 --- a/packages/opencode/test/cli/cmd/tui/feature-plugins/session/quota-fetch.test.ts +++ b/packages/opencode/test/cli/cmd/tui/feature-plugins/session/quota-fetch.test.ts @@ -1,8 +1,8 @@ -// quota-fetch.test.ts — TUI Quota 自动化测试,覆盖 quota.tsx 抽离的纯逻辑 -// - readQuotaAuth:2 种 auth 来源(github-proxy / github-copilot)+ 异常分支 +// quota-fetch.test.ts — TUI Quota 自动化测试,覆盖 quota-fetch.ts 纯逻辑 +// - readQuotaAuthForProvider:按 providerID 精确选 auth // - parseProxyQuota / parseCopilotQuota:正常解析 + 字段缺失返回 null // - fetchQuota:正常 200 + 非 200 + fetch 抛错(含 timeout) -// 不覆盖:Solid 组件渲染、setInterval 调度、opentui Slot 逻辑(需手测,见 08-test-plan §5.2) +// 不覆盖:Solid 组件渲染、setInterval 调度、opentui Slot 逻辑(需手测) import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import { mkdtemp, rm, writeFile } from "node:fs/promises" import path from "node:path" @@ -11,7 +11,7 @@ import { fetchQuota, parseCopilotQuota, parseProxyQuota, - readQuotaAuth, + readQuotaAuthForProvider, type QuotaAuth, } from "@/cli/cmd/tui/feature-plugins/session/quota-fetch" @@ -21,7 +21,7 @@ afterEach(() => { globalThis.fetch = originalFetch }) -describe("readQuotaAuth", () => { +describe("readQuotaAuthForProvider", () => { let dir: string beforeEach(async () => { dir = await mkdtemp(path.join(os.tmpdir(), "quota-auth-")) @@ -30,7 +30,7 @@ describe("readQuotaAuth", () => { await rm(dir, { recursive: true, force: true }) }) - test("github-proxy 优先,去尾斜杠后拼 /copilot/quota", async () => { + test("github-proxy → 去尾斜杠后拼 /copilot/quota", async () => { await writeFile( path.join(dir, "auth.json"), JSON.stringify({ @@ -41,33 +41,52 @@ describe("readQuotaAuth", () => { }, }), ) - const auth = await readQuotaAuth(dir) - expect(auth).toEqual({ + expect(await readQuotaAuthForProvider(dir, "github-proxy")).toEqual({ quotaUrl: "http://internal:8000/copilot/quota", token: "sk-test", provider: "github-proxy", - authHeaderPrefix: "Bearer", }) }) - test("缺 proxyUrl 时降级到 github-copilot oauth", async () => { + test("github-copilot → 返回 copilot auth", async () => { await writeFile( path.join(dir, "auth.json"), JSON.stringify({ - "github-proxy": { type: "api", key: "sk-test" }, // 无 metadata "github-copilot": { type: "oauth", refresh: "gho_test" }, }), ) - const auth = await readQuotaAuth(dir) - expect(auth).toEqual({ + expect(await readQuotaAuthForProvider(dir, "github-copilot")).toEqual({ quotaUrl: "https://api.github.com/copilot_internal/user", token: "gho_test", provider: "github-copilot", - authHeaderPrefix: "token", }) }) - test("github-copilot enterpriseUrl 注入 api. 子域", async () => { + test("github-copilot 子变体(如 github-copilot-custom)→ 匹配 copilot 条目", async () => { + await writeFile( + path.join(dir, "auth.json"), + JSON.stringify({ + "github-copilot": { type: "oauth", refresh: "gho_sub" }, + }), + ) + const auth = await readQuotaAuthForProvider(dir, "github-copilot-custom") + expect(auth?.provider).toBe("github-copilot") + expect(auth?.token).toBe("gho_sub") + }) + + test("github-proxy 子变体(如 github-proxy-v2)→ 匹配 proxy 条目", async () => { + await writeFile( + path.join(dir, "auth.json"), + JSON.stringify({ + "github-proxy": { type: "api", key: "sk-v2", metadata: { proxyUrl: "http://p:8000" } }, + }), + ) + const auth = await readQuotaAuthForProvider(dir, "github-proxy-v2") + expect(auth?.provider).toBe("github-proxy") + expect(auth?.token).toBe("sk-v2") + }) + + test("github-copilot enterpriseUrl → 注入 api. 子域", async () => { await writeFile( path.join(dir, "auth.json"), JSON.stringify({ @@ -78,35 +97,63 @@ describe("readQuotaAuth", () => { }, }), ) - const auth = await readQuotaAuth(dir) + const auth = await readQuotaAuthForProvider(dir, "github-copilot") expect(auth?.quotaUrl).toBe("https://api.ghes.corp.io/copilot_internal/user") }) - test("auth.json 不存在 → 返回 null", async () => { - expect(await readQuotaAuth(dir)).toBeNull() + test("github-proxy 缺 proxyUrl → null", async () => { + await writeFile( + path.join(dir, "auth.json"), + JSON.stringify({ "github-proxy": { type: "api", key: "sk-test" } }), + ) + expect(await readQuotaAuthForProvider(dir, "github-proxy")).toBeNull() }) - test("auth.json 非法 JSON → 返回 null", async () => { - await writeFile(path.join(dir, "auth.json"), "{not json") - expect(await readQuotaAuth(dir)).toBeNull() + test("github-copilot 缺 refresh → null", async () => { + await writeFile( + path.join(dir, "auth.json"), + JSON.stringify({ "github-copilot": { type: "oauth" } }), + ) + expect(await readQuotaAuthForProvider(dir, "github-copilot")).toBeNull() + }) + + test("未知 providerID → null", async () => { + await writeFile( + path.join(dir, "auth.json"), + JSON.stringify({ "github-proxy": { type: "api", key: "sk", metadata: { proxyUrl: "http://p:8000" } } }), + ) + expect(await readQuotaAuthForProvider(dir, "anthropic")).toBeNull() + }) + + test("auth.json 不存在 → null", async () => { + expect(await readQuotaAuthForProvider(dir, "github-proxy")).toBeNull() }) - test("无任何 provider 配置 → 返回 null", async () => { - await writeFile(path.join(dir, "auth.json"), JSON.stringify({ other: { type: "api" } })) - expect(await readQuotaAuth(dir)).toBeNull() + test("auth.json 非法 JSON → null", async () => { + await writeFile(path.join(dir, "auth.json"), "{not json") + expect(await readQuotaAuthForProvider(dir, "github-copilot")).toBeNull() }) }) describe("parseProxyQuota", () => { - test("完整字段解析", () => { + test("完整字段解析 → used = entitlement - remaining", () => { expect( parseProxyQuota({ remaining: 30, entitlement: 100, accounts_active: 2, accounts_total: 5 }), - ).toEqual({ remaining: 30, entitlement: 100, accounts_active: 2, accounts_total: 5 }) + ).toEqual({ used: 70, entitlement: 100, accounts_active: 2, accounts_total: 5 }) + }) + + test("remaining 0 时 used = entitlement(全部用完)", () => { + expect(parseProxyQuota({ remaining: 0, entitlement: 50 })).toEqual({ + used: 50, + entitlement: 50, + accounts_active: 0, + accounts_total: 0, + }) }) test("accounts 字段缺失时填 0", () => { expect(parseProxyQuota({ remaining: 10, entitlement: 50 })).toEqual({ - remaining: 10, + used: 40, entitlement: 50, accounts_active: 0, accounts_total: 0, @@ -120,22 +167,42 @@ describe("parseProxyQuota", () => { }) describe("parseCopilotQuota", () => { - test("percentRemaining 翻转为 used 数值,entitlement 固定 100", () => { + test("snake_case 字段解析 → used = entitlement - remaining", () => { + expect( + parseCopilotQuota({ + quota_snapshots: { premium_interactions: { remaining: 30, entitlement: 300 } }, + }), + ).toEqual({ used: 270, entitlement: 300, accounts_active: 0, accounts_total: 0 }) + }) + + test("overage(remaining 为负数)→ used > entitlement", () => { + expect( + parseCopilotQuota({ + quota_snapshots: { premium_interactions: { remaining: -51, entitlement: 300 } }, + }), + ).toEqual({ used: 351, entitlement: 300, accounts_active: 0, accounts_total: 0 }) + }) + + test("remaining 0 时 used = entitlement(全部用完)", () => { expect( parseCopilotQuota({ - quotaSnapshots: { premiumInteractions: { percentRemaining: 75 } }, + quota_snapshots: { premium_interactions: { remaining: 0, entitlement: 100 } }, }), - ).toEqual({ remaining: 25, entitlement: 100, accounts_active: 0, accounts_total: 0 }) + ).toEqual({ used: 100, entitlement: 100, accounts_active: 0, accounts_total: 0 }) }) - test("缺 quotaSnapshots → null", () => { + test("缺 quota_snapshots → null", () => { expect(parseCopilotQuota({})).toBeNull() }) - test("percentRemaining 非 number → null", () => { + test("缺 premium_interactions → null", () => { + expect(parseCopilotQuota({ quota_snapshots: {} })).toBeNull() + }) + + test("remaining 非 number → null", () => { expect( parseCopilotQuota({ - quotaSnapshots: { premiumInteractions: { percentRemaining: "75" } }, + quota_snapshots: { premium_interactions: { remaining: "30", entitlement: 100 } }, }), ).toBeNull() }) @@ -146,13 +213,11 @@ describe("fetchQuota", () => { quotaUrl: "http://internal:8000/copilot/quota", token: "sk-test", provider: "github-proxy", - authHeaderPrefix: "Bearer", } const copilotAuth: QuotaAuth = { quotaUrl: "https://api.github.com/copilot_internal/user", token: "gho_test", provider: "github-copilot", - authHeaderPrefix: "token", } test("github-proxy 200 → parseProxyQuota,header 注入 Bearer", async () => { @@ -162,38 +227,40 @@ describe("fetchQuota", () => { return new Response(JSON.stringify({ remaining: 20, entitlement: 100 }), { status: 200 }) }) as unknown as typeof fetch const q = await fetchQuota(proxyAuth) - expect(q).toEqual({ remaining: 20, entitlement: 100, accounts_active: 0, accounts_total: 0 }) + expect(q).toEqual({ used: 80, entitlement: 100, accounts_active: 0, accounts_total: 0 }) expect(captured!.url).toBe("http://internal:8000/copilot/quota") expect(captured!.headers.Authorization).toBe("Bearer sk-test") }) - test("github-copilot 200 → parseCopilotQuota,header 注入 token 前缀", async () => { + test("github-copilot 200 → parseCopilotQuota,header 注入 Bearer", async () => { let captured: Record = {} globalThis.fetch = mock(async (_url: string | URL, init?: RequestInit) => { captured = (init?.headers ?? {}) as Record return new Response( - JSON.stringify({ quotaSnapshots: { premiumInteractions: { percentRemaining: 60 } } }), + JSON.stringify({ + quota_snapshots: { premium_interactions: { remaining: 60, entitlement: 300 } }, + }), { status: 200 }, ) }) as unknown as typeof fetch const q = await fetchQuota(copilotAuth) - expect(q).toEqual({ remaining: 40, entitlement: 100, accounts_active: 0, accounts_total: 0 }) - expect(captured.Authorization).toBe("token gho_test") + expect(q).toEqual({ used: 240, entitlement: 300, accounts_active: 0, accounts_total: 0 }) + expect(captured.Authorization).toBe("Bearer gho_test") }) - test("非 200 → 返回 null(不抛)", async () => { + test("非 200 响应 → null", async () => { globalThis.fetch = mock(async () => new Response("nope", { status: 503 })) as unknown as typeof fetch expect(await fetchQuota(proxyAuth)).toBeNull() }) - test("fetch 抛错(如 timeout)→ 返回 null", async () => { + test("fetch 抛错 → null", async () => { globalThis.fetch = mock(async () => { - throw new Error("AbortError") + throw new Error("connection refused") }) as unknown as typeof fetch - expect(await fetchQuota(proxyAuth)).toBeNull() + expect(await fetchQuota(copilotAuth)).toBeNull() }) - test("响应 JSON 字段不完整 → 返回 null", async () => { + test("响应 JSON 字段不完整 → null", async () => { globalThis.fetch = mock(async () => new Response(JSON.stringify({}), { status: 200 })) as unknown as typeof fetch expect(await fetchQuota(proxyAuth)).toBeNull() })