|
|
@@ -6,9 +6,10 @@ import {
|
|
|
type HookCallback,
|
|
|
type PermissionResult,
|
|
|
type SDKMessage,
|
|
|
+ type SDKRateLimitInfo,
|
|
|
type SDKUserMessage,
|
|
|
} from '@anthropic-ai/claude-agent-sdk'
|
|
|
-import type { AppSettings, InteractionQuestion, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
|
|
|
+import type { AppSettings, InteractionQuestion, ProviderQuotaDto, ProviderStatusDto, QuotaWindowDto, UsagePayload } from '@agent-server/shared'
|
|
|
import { toClaudeSandbox } from '../../services/access-policy'
|
|
|
import { newId } from '../../lib/ids'
|
|
|
import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
|
|
|
@@ -23,6 +24,9 @@ export class ClaudeProvider implements AgentProvider {
|
|
|
readonly id = 'claude' as const
|
|
|
/** 会话级"本会话允许"规则(进程内保存,重启后失效) */
|
|
|
private sessionAllowed = new Map<string, Set<string>>()
|
|
|
+ /** 对话中引擎报告的额度(rate_limit_event),查询额度失败时作为后备 */
|
|
|
+ private lastRateLimits = new Map<string, QuotaWindowDto>()
|
|
|
+ private quotaCache: { at: number; value: ProviderQuotaDto } | null = null
|
|
|
|
|
|
async run(ctx: RunContext): Promise<RunResult> {
|
|
|
try {
|
|
|
@@ -127,6 +131,7 @@ export class ClaudeProvider implements AgentProvider {
|
|
|
const mapper = new MessageMapper(ctx, deniedToolIds)
|
|
|
try {
|
|
|
for await (const m of q) {
|
|
|
+ if (m.type === 'rate_limit_event') this.recordRateLimit(m.rate_limit_info)
|
|
|
const done = mapper.handle(m)
|
|
|
if (done) return done
|
|
|
}
|
|
|
@@ -166,6 +171,51 @@ export class ClaudeProvider implements AgentProvider {
|
|
|
return { behavior: 'allow', updatedInput: { ...input, answers } }
|
|
|
}
|
|
|
|
|
|
+ private recordRateLimit(info: SDKRateLimitInfo) {
|
|
|
+ if (!info.rateLimitType || info.utilization === undefined) return
|
|
|
+ const key = info.rateLimitType
|
|
|
+ this.lastRateLimits.set(key, {
|
|
|
+ key, label: QUOTA_LABELS[key] ?? key, usedPercent: Math.round(info.utilization * (info.utilization <= 1 ? 100 : 1)),
|
|
|
+ resetsAt: info.resetsAt ? new Date(info.resetsAt * 1000).toISOString() : null,
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 订阅额度:用 SDK 的 usage 接口查询(与 /usage 命令相同的数据,不发送消息、不调用模型),缓存 1 分钟。
|
|
|
+ * 该接口标注为实验性,失败时显示最近一次对话中引擎报告的数值
|
|
|
+ */
|
|
|
+ async quota(settings: AppSettings): Promise<ProviderQuotaDto> {
|
|
|
+ if (this.quotaCache && Date.now() - this.quotaCache.at < 60_000) return this.quotaCache.value
|
|
|
+ const ac = new AbortController()
|
|
|
+ const timer = setTimeout(() => ac.abort(), 30_000)
|
|
|
+ try {
|
|
|
+ const q = query({
|
|
|
+ prompt: idleInput(ac.signal),
|
|
|
+ options: { abortController: ac, settingSources: [], cwd: settings.paths.workspaceRoot, pathToClaudeCodeExecutable: settings.providers.claude.cliPath },
|
|
|
+ })
|
|
|
+ const u = await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true })
|
|
|
+ const limits = (u.rate_limits ?? {}) as Record<string, { utilization: number | null; resets_at: string | null } | null | undefined>
|
|
|
+ const windows = Object.keys(QUOTA_LABELS).flatMap(key => {
|
|
|
+ const w = limits[key]
|
|
|
+ return w && (w.utilization !== null || w.resets_at) ? [{ key, label: QUOTA_LABELS[key]!, usedPercent: w.utilization, resetsAt: w.resets_at }] : []
|
|
|
+ })
|
|
|
+ const value: ProviderQuotaDto = {
|
|
|
+ provider: 'claude', plan: u.subscription_type, windows, source: 'live', fetchedAt: new Date().toISOString(),
|
|
|
+ message: u.rate_limits_available ? null : '当前登录方式没有订阅额度信息',
|
|
|
+ }
|
|
|
+ this.quotaCache = { at: Date.now(), value }
|
|
|
+ return value
|
|
|
+ } catch (err) {
|
|
|
+ return {
|
|
|
+ provider: 'claude', plan: null, windows: [...this.lastRateLimits.values()], source: 'last-run', fetchedAt: new Date().toISOString(),
|
|
|
+ message: `查询额度失败:${(err as Error).message.slice(0, 200)}${this.lastRateLimits.size ? ';显示最近一次对话中的数值' : ''}`,
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ clearTimeout(timer)
|
|
|
+ ac.abort()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
async status(settings: AppSettings): Promise<ProviderStatusDto> {
|
|
|
const s = settings.providers.claude
|
|
|
const base: ProviderStatusDto = {
|
|
|
@@ -189,6 +239,15 @@ export class ClaudeProvider implements AgentProvider {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+const QUOTA_LABELS: Record<string, string> = {
|
|
|
+ five_hour: '5 小时', seven_day: '7 天', seven_day_opus: '7 天 · Opus', seven_day_sonnet: '7 天 · Sonnet',
|
|
|
+}
|
|
|
+
|
|
|
+/** 不发送任何消息的输入流(只为查询账户信息启动 CLI),中止时结束 */
|
|
|
+async function* idleInput(signal: AbortSignal): AsyncGenerator<SDKUserMessage> {
|
|
|
+ await new Promise(resolve => signal.addEventListener('abort', resolve, { once: true }))
|
|
|
+}
|
|
|
+
|
|
|
/** 流式输入:只发送一条用户消息(支持图片),发完即结束输入 */
|
|
|
async function* singleMessage(msg: SDKUserMessage): AsyncGenerator<SDKUserMessage> {
|
|
|
yield msg
|