Skip to content
Merged
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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<question>"`, `graphify path "<A>" "<B>"`, or `graphify explain "<concept>"` 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)
21 changes: 12 additions & 9 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,54 +7,61 @@ export interface QuotaAuth {
quotaUrl: string
token: string
provider: "github-proxy" | "github-copilot"
/** github-copilot 直连模式使用 "token <gho>" 认证头格式 */
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<QuotaAuth | null> {
/**
* 按 providerID 精确读取对应的 QuotaAuth。
* providerID 以 "github-proxy" 或 "github-copilot" 开头均可(支持子变体)。
*/
export async function readQuotaAuthForProvider(
stateDir: string,
providerID: string,
): Promise<QuotaAuth | null> {
try {
const text = await readFile(path.join(stateDir, "auth.json"), "utf-8")
const data = JSON.parse(text) as Record<string, unknown>

// 优先:github-proxy(带 proxyUrl 的内置 /copilot/quota 端点)
const proxyEntry = data["github-proxy"] as Record<string, unknown> | undefined
if (proxyEntry?.type === "api") {
const meta = proxyEntry.metadata as Record<string, string> | 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<string, unknown> | undefined
if (entry?.type === "api") {
const meta = entry.metadata as Record<string, string> | 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<string, unknown> | 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<string, unknown> | 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
Expand All @@ -63,31 +70,27 @@ export async function readQuotaAuth(stateDir: string): Promise<QuotaAuth | null>
}
}

/** 从 GitHub Copilot API 响应解析 quota(百分比模式) */
/** 从 GitHub Copilot API 响应解析 quota(snake_case 字段) */
export function parseCopilotQuota(data: Record<string, unknown>): QuotaInfo | null {
const snapshots = data.quotaSnapshots as Record<string, unknown> | undefined
const premium = snapshots?.premiumInteractions as Record<string, unknown> | undefined
const snapshots = data.quota_snapshots as Record<string, unknown> | undefined
const premium = snapshots?.premium_interactions as Record<string, unknown> | 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<string, unknown>): 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,
Expand All @@ -97,8 +100,8 @@ export function parseProxyQuota(data: Record<string, unknown>): QuotaInfo | null
export async function fetchQuota(auth: QuotaAuth): Promise<QuotaInfo | null> {
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<string, unknown>
Expand Down
75 changes: 44 additions & 31 deletions packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota.tsx
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -29,42 +35,49 @@ function QuotaView(props: { api: TuiPluginApi }) {
return t.textMuted
}
}
const [quotaAuth, setQuotaAuth] = createSignal<QuotaAuth | null>(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 永久跳过本插件
Expand All @@ -79,8 +92,8 @@ const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
session_prompt_right() {
return <QuotaView api={api} />
session_prompt_right(_ctx, props) {
return <QuotaView api={api} session_id={props.session_id} />
},
},
})
Expand Down
32 changes: 32 additions & 0 deletions packages/opencode/src/plugin/github-copilot/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,38 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
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<string, string>)?.["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,
Expand Down
Loading
Loading