From 84ad33278408a6a8d6c9876badcb19976373ec08 Mon Sep 17 00:00:00 2001 From: lex Date: Fri, 1 May 2026 16:45:35 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A(opencode)=20AGE?= =?UTF-8?q?NTS.md=20=E8=A1=A5=E5=85=85=20graphify=20=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=9B=BE=E8=B0=B1=E4=BD=BF=E7=94=A8=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) 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) From 3ef650ce679deda1061efa01e56aaa67d4bd0e1f Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 2 May 2026 00:36:02 +0800 Subject: [PATCH 2/6] fix(tui): expand pasted-text extmarks by string match, not stale offsets When submitting a prompt, the previous code expanded `[Pasted ~N lines]` placeholders into their original content using the extmark's `start`/ `end` offsets: inputText.slice(0, extmark.start) + part.text + inputText.slice(extmark.end) If the user pasted text and then moved the cursor to type characters *before* the placeholder, the extmark's offsets in the underlying input buffer drifted relative to `inputText`. The slice then took the wrong range, leaving fragments of the placeholder string (for example "d ~1 lines]") inside the submitted message. Replace the offset-based slice with a `lastIndexOf` lookup of the stored virtual text (`part.source.text.value`), iterating extmarks right-to-left so duplicate placeholders are still paired correctly. This eliminates dependence on opentui's offset tracking after edits. --- .../cli/cmd/tui/component/prompt/index.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) 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 From 7c2e2790da28d84bcc0d6842c11c1f8a5a26aa55 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 2 May 2026 00:51:55 +0800 Subject: [PATCH 3/6] fix(tui): use Bearer auth header for Copilot quota endpoint The github-copilot direct-login path in quota-fetch.ts was sending `token ` as the Authorization header when calling /copilot_internal/user. However, the copilot plugin itself consistently uses `Bearer ` for all GitHub API calls (copilot.ts:72,154). The /copilot_internal/user endpoint rejects the `token` prefix format, returning 401, which caused fetchQuota to silently return null and the quota indicator to never display for direct OAuth users. Change authHeaderPrefix from "token" to "Bearer" so it matches the copilot plugin's auth format. This fixes the issue where github-proxy users could see their quota but github-copilot direct-login users could not. --- .../src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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..979e329fb4 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,8 +7,8 @@ export interface QuotaAuth { quotaUrl: string token: string provider: "github-proxy" | "github-copilot" - /** github-copilot 直连模式使用 "token " 认证头格式 */ - authHeaderPrefix: "Bearer" | "token" + /** github-copilot 直连模式也使用 "Bearer " 认证头格式(与 copilot plugin 一致) */ + authHeaderPrefix: "Bearer" } export interface QuotaInfo { @@ -52,7 +52,7 @@ export async function readQuotaAuth(stateDir: string): Promise quotaUrl: `${apiBase}/copilot_internal/user`, token: refresh, provider: "github-copilot", - authHeaderPrefix: "token", + authHeaderPrefix: "Bearer", } } } From 20a35c1efca15f409d962a282865cc1066238ec9 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 2 May 2026 01:49:21 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A(tui)=20quota=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=AD=97=E6=AE=B5=E5=90=8D=20camelCase=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=20+=20proxy=20=E5=A4=B1=E8=B4=A5=E6=97=A0?= =?UTF-8?q?=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseCopilotQuota 改用 snake_case 字段(quota_snapshots / premium_interactions / remaining / entitlement),修复 GitHub API 响应解析全部返回 null 的问题 - readQuotaAuth → readQuotaAuths 返回 QuotaAuth[],同时收集 github-proxy 与 github-copilot 两条来源 - fetchQuota 接收数组,依次尝试,proxy 不可达时自动降级到 copilot - 测试同步更新,新增 fallback 降级用例,共 22 个全部通过 --- .../feature-plugins/session/quota-fetch.ts | 61 ++++---- .../cmd/tui/feature-plugins/session/quota.tsx | 22 +-- .../session/quota-fetch.test.ts | 136 +++++++++++++----- 3 files changed, 142 insertions(+), 77 deletions(-) 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 979e329fb4..7303a13776 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 @@ -18,10 +18,11 @@ export interface QuotaInfo { accounts_total: number } -export async function readQuotaAuth(stateDir: string): Promise { +export async function readQuotaAuths(stateDir: string): Promise { try { const text = await readFile(path.join(stateDir, "auth.json"), "utf-8") const data = JSON.parse(text) as Record + const result: QuotaAuth[] = [] // 优先:github-proxy(带 proxyUrl 的内置 /copilot/quota 端点) const proxyEntry = data["github-proxy"] as Record | undefined @@ -30,16 +31,16 @@ export async function readQuotaAuth(stateDir: string): Promise const proxyUrl = meta?.proxyUrl const apiKey = proxyEntry.key as string | undefined if (proxyUrl && apiKey) { - return { + result.push({ quotaUrl: `${proxyUrl.replace(/\/+$/, "")}/copilot/quota`, token: apiKey, provider: "github-proxy", authHeaderPrefix: "Bearer", - } + }) } } - // 回退:github-copilot 直连模式(GitHub API 取 quota) + // 备选:github-copilot 直连模式(GitHub API 取 quota) const copilotEntry = data["github-copilot"] as Record | undefined if (copilotEntry?.type === "oauth") { const refresh = copilotEntry.refresh as string | undefined @@ -48,34 +49,36 @@ export async function readQuotaAuth(stateDir: string): Promise const apiBase = enterpriseUrl ? `https://api.${enterpriseUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}` : "https://api.github.com" - return { + result.push({ quotaUrl: `${apiBase}/copilot_internal/user`, token: refresh, provider: "github-copilot", authHeaderPrefix: "Bearer", - } + }) } } - return null + return result } catch { - return null + return [] } } -/** 从 GitHub Copilot API 响应解析 quota(百分比模式) */ +/** 从 GitHub Copilot API 响应解析 quota(snake_case 字段,直连 GitHub API) */ 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 格式统一 + // 归一化:QuotaInfo.remaining 存的是「已用量」,与 proxy 格式保持一致 + // GitHub API 返回的 remaining 是「剩余量」,需翻转 return { - remaining: 100 - percentRemaining, - entitlement: 100, + remaining: entitlement - actualRemaining, + entitlement, accounts_active: 0, accounts_total: 0, } @@ -94,16 +97,20 @@ 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), - }) - if (!resp.ok) return null - const data = (await resp.json()) as Record - return auth.provider === "github-copilot" ? parseCopilotQuota(data) : parseProxyQuota(data) - } catch { - return null +export async function fetchQuota(auths: QuotaAuth[]): Promise { + for (const auth of auths) { + try { + const resp = await fetch(auth.quotaUrl, { + headers: { Authorization: `${auth.authHeaderPrefix} ${auth.token}` }, + signal: AbortSignal.timeout(5_000), + }) + if (!resp.ok) continue + const data = (await resp.json()) as Record + const info = auth.provider === "github-copilot" ? parseCopilotQuota(data) : parseProxyQuota(data) + if (info) return info + } catch { + // 继续尝试下一个 + } } + return null } 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..e89780f917 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,5 +1,5 @@ // quota.tsx — 在 session prompt 右侧显示 Copilot premium request 配额。 -// 支持两种 auth 来源: +// 支持两种 auth 来源(按优先级依次尝试): // 1. github-proxy(type:"api",metadata.proxyUrl + key 走 /copilot/quota) // 2. github-copilot(type:"oauth",refresh token 走 GitHub /copilot_internal/user) // @@ -8,7 +8,7 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import { createSignal, onCleanup } from "solid-js" import { Global } from "@opencode-ai/core/global" -import { fetchQuota, readQuotaAuth, type QuotaAuth, type QuotaInfo } from "./quota-fetch" +import { fetchQuota, readQuotaAuths, type QuotaAuth, type QuotaInfo } from "./quota-fetch" const id = "internal:session-quota" @@ -29,10 +29,10 @@ function QuotaView(props: { api: TuiPluginApi }) { return t.textMuted } } - const [quotaAuth, setQuotaAuth] = createSignal(null) + const [quotaAuths, setQuotaAuths] = createSignal([]) function applyQuota(q: QuotaInfo) { - // 后端 remaining 字段实际是 used(已用量),需翻转为真正的剩余量 + // QuotaInfo.remaining 存的是「已用量」,翻转得到真实剩余量 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") @@ -46,13 +46,13 @@ function QuotaView(props: { api: TuiPluginApi }) { // 启动:读 auth → 首次拉取 // 注意:auth.json 位于 Global.Path.data(XDG_DATA_HOME), // 不能用 props.api.state.path.state(XDG_STATE_HOME),二者是不同目录。 - readQuotaAuth(Global.Path.data).then((auth) => { - if (!auth) { + readQuotaAuths(Global.Path.data).then((auths) => { + if (auths.length === 0) { setLabel("") return } - setQuotaAuth(auth) - fetchQuota(auth).then((q) => { + setQuotaAuths(auths) + fetchQuota(auths).then((q) => { if (q) applyQuota(q) else setLabel("") }) @@ -60,9 +60,9 @@ function QuotaView(props: { api: TuiPluginApi }) { // 每 60 秒刷新 const timer = setInterval(async () => { - const auth = quotaAuth() - if (!auth) return - const q = await fetchQuota(auth) + const auths = quotaAuths() + if (auths.length === 0) return + const q = await fetchQuota(auths) if (q) applyQuota(q) }, 60_000) onCleanup(() => clearInterval(timer)) 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..cb3ffe7e17 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,7 +1,7 @@ // quota-fetch.test.ts — TUI Quota 自动化测试,覆盖 quota.tsx 抽离的纯逻辑 -// - readQuotaAuth:2 种 auth 来源(github-proxy / github-copilot)+ 异常分支 +// - readQuotaAuths:2 种 auth 来源(github-proxy / github-copilot)+ 异常分支 // - parseProxyQuota / parseCopilotQuota:正常解析 + 字段缺失返回 null -// - fetchQuota:正常 200 + 非 200 + fetch 抛错(含 timeout) +// - fetchQuota:正常 200 + 非 200 + fetch 抛错(含 timeout)+ fallback 降级 // 不覆盖:Solid 组件渲染、setInterval 调度、opentui Slot 逻辑(需手测,见 08-test-plan §5.2) import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import { mkdtemp, rm, writeFile } from "node:fs/promises" @@ -11,7 +11,7 @@ import { fetchQuota, parseCopilotQuota, parseProxyQuota, - readQuotaAuth, + readQuotaAuths, type QuotaAuth, } from "@/cli/cmd/tui/feature-plugins/session/quota-fetch" @@ -21,7 +21,7 @@ afterEach(() => { globalThis.fetch = originalFetch }) -describe("readQuotaAuth", () => { +describe("readQuotaAuths", () => { 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,8 +41,8 @@ describe("readQuotaAuth", () => { }, }), ) - const auth = await readQuotaAuth(dir) - expect(auth).toEqual({ + const auths = await readQuotaAuths(dir) + expect(auths[0]).toEqual({ quotaUrl: "http://internal:8000/copilot/quota", token: "sk-test", provider: "github-proxy", @@ -50,7 +50,21 @@ describe("readQuotaAuth", () => { }) }) - test("缺 proxyUrl 时降级到 github-copilot oauth", async () => { + test("同时有 proxy 和 copilot → 两条都返回,proxy 排第一", async () => { + await writeFile( + path.join(dir, "auth.json"), + JSON.stringify({ + "github-proxy": { type: "api", key: "sk-test", metadata: { proxyUrl: "http://p:8000" } }, + "github-copilot": { type: "oauth", refresh: "gho_test" }, + }), + ) + const auths = await readQuotaAuths(dir) + expect(auths.length).toBe(2) + expect(auths[0].provider).toBe("github-proxy") + expect(auths[1].provider).toBe("github-copilot") + }) + + test("缺 proxyUrl 时只返回 github-copilot", async () => { await writeFile( path.join(dir, "auth.json"), JSON.stringify({ @@ -58,12 +72,13 @@ describe("readQuotaAuth", () => { "github-copilot": { type: "oauth", refresh: "gho_test" }, }), ) - const auth = await readQuotaAuth(dir) - expect(auth).toEqual({ + const auths = await readQuotaAuths(dir) + expect(auths.length).toBe(1) + expect(auths[0]).toEqual({ quotaUrl: "https://api.github.com/copilot_internal/user", token: "gho_test", provider: "github-copilot", - authHeaderPrefix: "token", + authHeaderPrefix: "Bearer", }) }) @@ -78,22 +93,22 @@ describe("readQuotaAuth", () => { }, }), ) - const auth = await readQuotaAuth(dir) - expect(auth?.quotaUrl).toBe("https://api.ghes.corp.io/copilot_internal/user") + const auths = await readQuotaAuths(dir) + expect(auths[0]?.quotaUrl).toBe("https://api.ghes.corp.io/copilot_internal/user") }) - test("auth.json 不存在 → 返回 null", async () => { - expect(await readQuotaAuth(dir)).toBeNull() + test("auth.json 不存在 → 返回 []", async () => { + expect(await readQuotaAuths(dir)).toEqual([]) }) - test("auth.json 非法 JSON → 返回 null", async () => { + test("auth.json 非法 JSON → 返回 []", async () => { await writeFile(path.join(dir, "auth.json"), "{not json") - expect(await readQuotaAuth(dir)).toBeNull() + expect(await readQuotaAuths(dir)).toEqual([]) }) - test("无任何 provider 配置 → 返回 null", async () => { + test("无任何 provider 配置 → 返回 []", async () => { await writeFile(path.join(dir, "auth.json"), JSON.stringify({ other: { type: "api" } })) - expect(await readQuotaAuth(dir)).toBeNull() + expect(await readQuotaAuths(dir)).toEqual([]) }) }) @@ -120,22 +135,34 @@ describe("parseProxyQuota", () => { }) describe("parseCopilotQuota", () => { - test("percentRemaining 翻转为 used 数值,entitlement 固定 100", () => { + test("snake_case 字段解析,remaining 翻转为 used 数值", () => { expect( parseCopilotQuota({ - quotaSnapshots: { premiumInteractions: { percentRemaining: 75 } }, + quota_snapshots: { premium_interactions: { remaining: 30, entitlement: 300 } }, }), - ).toEqual({ remaining: 25, entitlement: 100, accounts_active: 0, accounts_total: 0 }) + ).toEqual({ remaining: 270, entitlement: 300, accounts_active: 0, accounts_total: 0 }) }) - test("缺 quotaSnapshots → null", () => { + test("remaining 0 时 used = entitlement(全部用完)", () => { + expect( + parseCopilotQuota({ + quota_snapshots: { premium_interactions: { remaining: 0, entitlement: 100 } }, + }), + ).toEqual({ remaining: 100, entitlement: 100, accounts_active: 0, accounts_total: 0 }) + }) + + 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() }) @@ -152,7 +179,7 @@ describe("fetchQuota", () => { quotaUrl: "https://api.github.com/copilot_internal/user", token: "gho_test", provider: "github-copilot", - authHeaderPrefix: "token", + authHeaderPrefix: "Bearer", } test("github-proxy 200 → parseProxyQuota,header 注入 Bearer", async () => { @@ -161,7 +188,7 @@ describe("fetchQuota", () => { captured = { url: String(url), headers: (init?.headers ?? {}) as Record } return new Response(JSON.stringify({ remaining: 20, entitlement: 100 }), { status: 200 }) }) as unknown as typeof fetch - const q = await fetchQuota(proxyAuth) + const q = await fetchQuota([proxyAuth]) expect(q).toEqual({ remaining: 20, 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") @@ -172,29 +199,60 @@ describe("fetchQuota", () => { 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") + const q = await fetchQuota([copilotAuth]) + // remaining=60 → used=300-60=240 + expect(q).toEqual({ remaining: 240, entitlement: 300, accounts_active: 0, accounts_total: 0 }) + expect(captured.Authorization).toBe("Bearer gho_test") }) - test("非 200 → 返回 null(不抛)", async () => { - globalThis.fetch = mock(async () => new Response("nope", { status: 503 })) as unknown as typeof fetch - expect(await fetchQuota(proxyAuth)).toBeNull() + test("proxy 失败(非 200)→ 降级到 copilot", async () => { + let callCount = 0 + globalThis.fetch = mock(async (url: string | URL) => { + callCount++ + if (String(url).includes("internal:8000")) return new Response("nope", { status: 503 }) + return new Response( + JSON.stringify({ + quota_snapshots: { premium_interactions: { remaining: 30, entitlement: 300 } }, + }), + { status: 200 }, + ) + }) as unknown as typeof fetch + const q = await fetchQuota([proxyAuth, copilotAuth]) + expect(callCount).toBe(2) + expect(q).toEqual({ remaining: 270, entitlement: 300, accounts_active: 0, accounts_total: 0 }) }) - test("fetch 抛错(如 timeout)→ 返回 null", async () => { - globalThis.fetch = mock(async () => { - throw new Error("AbortError") + test("proxy 抛错 → 降级到 copilot", async () => { + globalThis.fetch = mock(async (url: string | URL) => { + if (String(url).includes("internal:8000")) throw new Error("connection refused") + return new Response( + JSON.stringify({ + quota_snapshots: { premium_interactions: { remaining: 30, entitlement: 300 } }, + }), + { status: 200 }, + ) }) as unknown as typeof fetch - expect(await fetchQuota(proxyAuth)).toBeNull() + const q = await fetchQuota([proxyAuth, copilotAuth]) + expect(q).toEqual({ remaining: 270, entitlement: 300, accounts_active: 0, accounts_total: 0 }) + }) + + test("所有 auth 均失败 → 返回 null", async () => { + globalThis.fetch = mock(async () => new Response("nope", { status: 503 })) as unknown as typeof fetch + expect(await fetchQuota([proxyAuth, copilotAuth])).toBeNull() + }) + + test("空 auth 列表 → 返回 null", async () => { + expect(await fetchQuota([])).toBeNull() }) test("响应 JSON 字段不完整 → 返回 null", async () => { globalThis.fetch = mock(async () => new Response(JSON.stringify({}), { status: 200 })) as unknown as typeof fetch - expect(await fetchQuota(proxyAuth)).toBeNull() + expect(await fetchQuota([proxyAuth])).toBeNull() }) }) From 6f31a38d1b6ea5d039c9debf508b93832553b7de Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 2 May 2026 03:37:02 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E8=AF=8A=E6=96=AD=EF=BC=9A(copilot)=20?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E6=97=A5=E5=BF=97=E8=BF=BD=E8=B8=AA=20x-init?= =?UTF-8?q?iator=20=E5=8F=91=E5=87=BA=E5=80=BC=E5=AE=9A=E4=BD=8D=20premium?= =?UTF-8?q?=20=E8=AF=AF=E6=89=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已知 opencode issue #8030:合成 user 消息(compaction / subtask / 图片附件) 被 copilot.ts loader.fetch 错判为 user 请求,导致每轮 agentic 循环均扣 premium quota。本提交在 fetch 出站前记录 xInitiator、hookHeader、 isAgent、lastRole、lastContentTypes、lastTextSample 供分析。 用完即删,正式修复见后续提交。 --- .../src/plugin/github-copilot/copilot.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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, From d4fbbffc263572e6eb7b084f61c827d75b0dde71 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 2 May 2026 10:19:00 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A(tui)=20quota=20?= =?UTF-8?q?=E6=8C=89=20providerID=20=E7=B2=BE=E7=A1=AE=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=20+=20used=20=E5=AD=97=E6=AE=B5=E6=8D=A2?= =?UTF-8?q?=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature-plugins/session/quota-fetch.ts | 118 ++++++----- .../cmd/tui/feature-plugins/session/quota.tsx | 77 +++++--- .../session/quota-fetch.test.ts | 187 +++++++++--------- 3 files changed, 200 insertions(+), 182 deletions(-) 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 7303a13776..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,64 +7,70 @@ export interface QuotaAuth { quotaUrl: string token: string provider: "github-proxy" | "github-copilot" - /** github-copilot 直连模式也使用 "Bearer " 认证头格式(与 copilot plugin 一致) */ - authHeaderPrefix: "Bearer" } export interface QuotaInfo { - remaining: number + /** 已用量(consumed count)。proxy 直接读响应字段;copilot 由 entitlement-remaining 换算 */ + used: number entitlement: number accounts_active: number accounts_total: number } -export async function readQuotaAuths(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 - const result: QuotaAuth[] = [] - // 优先: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) { - result.push({ - 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" - result.push({ - quotaUrl: `${apiBase}/copilot_internal/user`, - token: refresh, - provider: "github-copilot", - authHeaderPrefix: "Bearer", - }) + 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 result + return null } catch { - return [] + return null } } -/** 从 GitHub Copilot API 响应解析 quota(snake_case 字段,直连 GitHub API) */ +/** 从 GitHub Copilot API 响应解析 quota(snake_case 字段) */ export function parseCopilotQuota(data: Record): QuotaInfo | null { const snapshots = data.quota_snapshots as Record | undefined const premium = snapshots?.premium_interactions as Record | undefined @@ -74,43 +80,33 @@ export function parseCopilotQuota(data: Record): QuotaInfo | nu const entitlement = typeof premium.entitlement === "number" ? premium.entitlement : null if (actualRemaining === null || entitlement === null) return null - // 归一化:QuotaInfo.remaining 存的是「已用量」,与 proxy 格式保持一致 - // GitHub API 返回的 remaining 是「剩余量」,需翻转 - return { - remaining: entitlement - actualRemaining, - entitlement, - 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, } } -export async function fetchQuota(auths: QuotaAuth[]): Promise { - for (const auth of auths) { - try { - const resp = await fetch(auth.quotaUrl, { - headers: { Authorization: `${auth.authHeaderPrefix} ${auth.token}` }, - signal: AbortSignal.timeout(5_000), - }) - if (!resp.ok) continue - const data = (await resp.json()) as Record - const info = auth.provider === "github-copilot" ? parseCopilotQuota(data) : parseProxyQuota(data) - if (info) return info - } catch { - // 继续尝试下一个 - } +export async function fetchQuota(auth: QuotaAuth): Promise { + try { + const resp = await fetch(auth.quotaUrl, { + headers: { Authorization: `Bearer ${auth.token}` }, + signal: AbortSignal.timeout(2_000), + }) + if (!resp.ok) return null + const data = (await resp.json()) as Record + return auth.provider === "github-copilot" ? parseCopilotQuota(data) : parseProxyQuota(data) + } catch { + return null } - return null } 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 e89780f917..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, readQuotaAuths, 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 [quotaAuths, setQuotaAuths] = createSignal([]) + + // 从最后一条 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) { - // QuotaInfo.remaining 存的是「已用量」,翻转得到真实剩余量 - 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),二者是不同目录。 - readQuotaAuths(Global.Path.data).then((auths) => { - if (auths.length === 0) { + 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 } - setQuotaAuths(auths) - fetchQuota(auths).then((q) => { - if (q) applyQuota(q) - else setLabel("") - }) + const q = await fetchQuota(auth) + if (q) applyQuota(q) + else setLabel("") + } + + // providerID 变化时立即重新拉取(含首次挂载) + createEffect(() => { + void refresh() }) - // 每 60 秒刷新 - const timer = setInterval(async () => { - const auths = quotaAuths() - if (auths.length === 0) return - const q = await fetchQuota(auths) - if (q) applyQuota(q) - }, 60_000) + // 每 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/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 cb3ffe7e17..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 抽离的纯逻辑 -// - readQuotaAuths: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)+ fallback 降级 -// 不覆盖:Solid 组件渲染、setInterval 调度、opentui Slot 逻辑(需手测,见 08-test-plan §5.2) +// - fetchQuota:正常 200 + 非 200 + fetch 抛错(含 timeout) +// 不覆盖: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, - readQuotaAuths, + readQuotaAuthForProvider, type QuotaAuth, } from "@/cli/cmd/tui/feature-plugins/session/quota-fetch" @@ -21,7 +21,7 @@ afterEach(() => { globalThis.fetch = originalFetch }) -describe("readQuotaAuths", () => { +describe("readQuotaAuthForProvider", () => { let dir: string beforeEach(async () => { dir = await mkdtemp(path.join(os.tmpdir(), "quota-auth-")) @@ -30,7 +30,7 @@ describe("readQuotaAuths", () => { 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,48 +41,52 @@ describe("readQuotaAuths", () => { }, }), ) - const auths = await readQuotaAuths(dir) - expect(auths[0]).toEqual({ + expect(await readQuotaAuthForProvider(dir, "github-proxy")).toEqual({ quotaUrl: "http://internal:8000/copilot/quota", token: "sk-test", provider: "github-proxy", - authHeaderPrefix: "Bearer", }) }) - test("同时有 proxy 和 copilot → 两条都返回,proxy 排第一", async () => { + test("github-copilot → 返回 copilot auth", async () => { await writeFile( path.join(dir, "auth.json"), JSON.stringify({ - "github-proxy": { type: "api", key: "sk-test", metadata: { proxyUrl: "http://p:8000" } }, "github-copilot": { type: "oauth", refresh: "gho_test" }, }), ) - const auths = await readQuotaAuths(dir) - expect(auths.length).toBe(2) - expect(auths[0].provider).toBe("github-proxy") - expect(auths[1].provider).toBe("github-copilot") + expect(await readQuotaAuthForProvider(dir, "github-copilot")).toEqual({ + quotaUrl: "https://api.github.com/copilot_internal/user", + token: "gho_test", + provider: "github-copilot", + }) }) - test("缺 proxyUrl 时只返回 github-copilot", async () => { + test("github-copilot 子变体(如 github-copilot-custom)→ 匹配 copilot 条目", 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" }, + "github-copilot": { type: "oauth", refresh: "gho_sub" }, }), ) - const auths = await readQuotaAuths(dir) - expect(auths.length).toBe(1) - expect(auths[0]).toEqual({ - quotaUrl: "https://api.github.com/copilot_internal/user", - token: "gho_test", - provider: "github-copilot", - authHeaderPrefix: "Bearer", - }) + 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 () => { + test("github-copilot enterpriseUrl → 注入 api. 子域", async () => { await writeFile( path.join(dir, "auth.json"), JSON.stringify({ @@ -93,35 +97,63 @@ describe("readQuotaAuths", () => { }, }), ) - const auths = await readQuotaAuths(dir) - expect(auths[0]?.quotaUrl).toBe("https://api.ghes.corp.io/copilot_internal/user") + const auth = await readQuotaAuthForProvider(dir, "github-copilot") + expect(auth?.quotaUrl).toBe("https://api.ghes.corp.io/copilot_internal/user") }) - test("auth.json 不存在 → 返回 []", async () => { - expect(await readQuotaAuths(dir)).toEqual([]) + 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 → 返回 []", async () => { - await writeFile(path.join(dir, "auth.json"), "{not json") - expect(await readQuotaAuths(dir)).toEqual([]) + 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 配置 → 返回 []", async () => { - await writeFile(path.join(dir, "auth.json"), JSON.stringify({ other: { type: "api" } })) - expect(await readQuotaAuths(dir)).toEqual([]) + 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, @@ -135,12 +167,20 @@ describe("parseProxyQuota", () => { }) describe("parseCopilotQuota", () => { - test("snake_case 字段解析,remaining 翻转为 used 数值", () => { + test("snake_case 字段解析 → used = entitlement - remaining", () => { expect( parseCopilotQuota({ quota_snapshots: { premium_interactions: { remaining: 30, entitlement: 300 } }, }), - ).toEqual({ remaining: 270, entitlement: 300, accounts_active: 0, accounts_total: 0 }) + ).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(全部用完)", () => { @@ -148,7 +188,7 @@ describe("parseCopilotQuota", () => { parseCopilotQuota({ quota_snapshots: { premium_interactions: { remaining: 0, entitlement: 100 } }, }), - ).toEqual({ remaining: 100, entitlement: 100, accounts_active: 0, accounts_total: 0 }) + ).toEqual({ used: 100, entitlement: 100, accounts_active: 0, accounts_total: 0 }) }) test("缺 quota_snapshots → null", () => { @@ -173,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: "Bearer", } test("github-proxy 200 → parseProxyQuota,header 注入 Bearer", async () => { @@ -188,13 +226,13 @@ describe("fetchQuota", () => { captured = { url: String(url), headers: (init?.headers ?? {}) as Record } 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 }) + const q = await fetchQuota(proxyAuth) + 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 @@ -205,54 +243,25 @@ describe("fetchQuota", () => { { status: 200 }, ) }) as unknown as typeof fetch - const q = await fetchQuota([copilotAuth]) - // remaining=60 → used=300-60=240 - expect(q).toEqual({ remaining: 240, entitlement: 300, accounts_active: 0, accounts_total: 0 }) + const q = await fetchQuota(copilotAuth) + expect(q).toEqual({ used: 240, entitlement: 300, accounts_active: 0, accounts_total: 0 }) expect(captured.Authorization).toBe("Bearer gho_test") }) - test("proxy 失败(非 200)→ 降级到 copilot", async () => { - let callCount = 0 - globalThis.fetch = mock(async (url: string | URL) => { - callCount++ - if (String(url).includes("internal:8000")) return new Response("nope", { status: 503 }) - return new Response( - JSON.stringify({ - quota_snapshots: { premium_interactions: { remaining: 30, entitlement: 300 } }, - }), - { status: 200 }, - ) - }) as unknown as typeof fetch - const q = await fetchQuota([proxyAuth, copilotAuth]) - expect(callCount).toBe(2) - expect(q).toEqual({ remaining: 270, entitlement: 300, accounts_active: 0, accounts_total: 0 }) - }) - - test("proxy 抛错 → 降级到 copilot", async () => { - globalThis.fetch = mock(async (url: string | URL) => { - if (String(url).includes("internal:8000")) throw new Error("connection refused") - return new Response( - JSON.stringify({ - quota_snapshots: { premium_interactions: { remaining: 30, entitlement: 300 } }, - }), - { status: 200 }, - ) - }) as unknown as typeof fetch - const q = await fetchQuota([proxyAuth, copilotAuth]) - expect(q).toEqual({ remaining: 270, entitlement: 300, accounts_active: 0, accounts_total: 0 }) - }) - - test("所有 auth 均失败 → 返回 null", async () => { + test("非 200 响应 → null", async () => { globalThis.fetch = mock(async () => new Response("nope", { status: 503 })) as unknown as typeof fetch - expect(await fetchQuota([proxyAuth, copilotAuth])).toBeNull() + expect(await fetchQuota(proxyAuth)).toBeNull() }) - test("空 auth 列表 → 返回 null", async () => { - expect(await fetchQuota([])).toBeNull() + test("fetch 抛错 → null", async () => { + globalThis.fetch = mock(async () => { + throw new Error("connection refused") + }) as unknown as typeof fetch + 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() + expect(await fetchQuota(proxyAuth)).toBeNull() }) })