@huui/cdx-switcher-auto-service
v1.0.1
Published
基于 codex-switcher 的账号用量监控与自动切换服务
Readme
codex-switcher-auto-service
1. 背景
codex-switcher-auto-service 是一个本地账号用量监控与自动切换服务。它面向已经具备账号读取与切换能力的来源,在同一进程内持续评估当前账号风险,并在满足固定安全规则时切换到经过验证的候选账号。
服务的默认账号来源为 @huui/cdx-switcher。每轮评估只读取一次全部账号的批量快照;账号池筛选、用量风险计算、候选排序、策略决策和下一轮调度都在内存中完成。业务代码只依赖本项目的公开接口,不依赖 cdx 或其他来源的返回类型和错误码。
服务提供以下能力:
- 根据所有用量窗口中的最大
usedPercent评估账号风险:<90%为正常、90%–<95%为警戒、>=95%为需要切换、>=98%为紧急。 - 只允许唯一非空标签、用量有效且低于 95% 的非当前账号成为切换候选;候选按显式优先级、最早刷新时间、账号池顺序排序。
- 成功切换到不同账号后执行固定 5 分钟冷却;冷却期间仍可读取用量和发送事件,但不会再次切换。
- 提供账号池、切换策略、轮询策略、切换锁和账号来源五个扩展点;固定阈值、候选验证、冷却期和切换前后校验不能被扩展点绕过。
- 通过状态快照和结构化事件提供可观察性;接口、状态和事件均不包含令牌、账号 ID、邮箱、原始响应或堆栈。
2. 项目链路
一轮评估按以下路径执行:
- 接入方调用
enable()、定时器触发或调用evaluateNow();服务为本轮创建唯一的evaluationId。 AccountSource.readSnapshot()只执行一次,返回当前账号、所有账号安全摘要和逐项用量或失败状态。- 服务调用账号池扩展点,将其返回的标签与本轮快照交叉验证,生成不可变的评估快照。
- 服务以所有用量窗口的最大值计算风险,以最早刷新时间参与候选排序;认证、网络或上游用量失败的账号仅在本轮不可作为候选,下轮会重新读取。
- 服务调用策略扩展点,得到
keep、switch或stop。策略只负责决策,不能直接发起切换。 keep时调用轮询策略并安排下一轮;switch时依次通过冷却期、目标资格、切换锁、切换前校验和切换后确认;stop或全部已验证候选达到阈值时安全停止服务。- 服务更新
ServiceStatus,并以lifecycle、evaluation、switch、schedule、hook五类事件通知接入方。
所有扩展点只接收当前轮冻结的脱敏数据。自定义来源位于防腐层外侧:更换服务商或接入本地模拟器时,只替换 AccountSource 实现,业务编排、策略与事件消费代码无需感知下游协议。
3. 项目接入方式
安装与创建
npm install @huui/cdx-switcher-auto-service本地开发本项目时使用 npm 安装依赖并执行脚本:
npm install
npm run typecheck
npm run buildimport {
createCodexSwitcherAutoService,
type ServiceEvent,
} from "@huui/cdx-switcher-auto-service";
const service = createCodexSwitcherAutoService();
const unsubscribe = service.subscribe((event: ServiceEvent) => {
const reason = event.reason ? ` reason=${event.reason}` : "";
console.log(`[auto-service] ${event.type}/${event.stage} ${event.outcome}${reason}`);
if (event.data.error) {
console.error(event.data.error.code, event.data.error.message);
}
});
const enabled = await service.enable();
if (!enabled.ok) {
console.error("首轮评估未完成:", enabled.error.code, enabled.error.message);
}
// 业务需要时可立即发起一轮评估;不会绕过冷却期、候选校验或锁。
const evaluated = await service.evaluateNow();
if (!evaluated.ok) {
console.error("评估结果:", evaluated.error.code);
}
console.log(service.getStatus());
// 业务进程退出前停用服务,取消下一轮定时器并阻止后续切换。
await service.disable();
unsubscribe();创建服务本身不会启动轮询;只有 enable() 成功进入生命周期后才会开始首轮评估。默认来源要求本机 cdx 已具备可读取的账号配置;需要自动切换的账号必须拥有唯一且非空的标签。
公开控制接口
| 方法 | 用途 |
| --- | --- |
| configure(options) | 在 disabled 或 stopped 状态下原子替换完整配置。 |
| enable() | 启用服务,并立即执行首轮评估。 |
| disable() | 取消定时器、取消等待中的锁,并停止后续动作;可重复调用。 |
| evaluateNow() | 立即执行一轮评估;仍受互斥、冷却期和全部安全规则约束。 |
| getStatus() | 获取只读的 ServiceStatus,不触发任何网络请求。 |
| subscribe(listener) | 订阅结构化事件,返回取消订阅函数。 |
所有异步方法都返回 AutoServiceResult<T>:成功时为 { ok: true, data },可预期失败时为 { ok: false, error }。接入方应使用 error.code 与 error.retryable 处理分支,而不是依赖 message 文本。
4. 自定义钩子
创建服务时可传入下列扩展点:
createCodexSwitcherAutoService({
accountPool,
policy,
polling,
lock,
lockAcquireTimeoutMs: 30_000,
});钩子可异步执行。服务会隔离其异常,并验证返回值;账号池和策略钩子失败或返回非法值会结束当前服务生命周期,轮询钩子失败或返回非法延迟会回退到本轮默认延迟。钩子不得修改传入对象、读取凭据或自行调用账号来源。
4.1 账号池:AccountPoolProvider
账号池决定哪些标签参与自动管理,并可指定优先级。它只收到当前轮的账号身份摘要,不会收到用量、失败详情或来源实现。
import type { AccountIdentity } from "@huui/cdx-switcher-auto-service";
interface AccountPoolContext {
readonly now: string;
readonly currentAccount: AccountIdentity | null;
readonly accounts: readonly AccountIdentity[];
}
interface AccountPoolItem {
label: string;
priority?: number;
}
interface AccountPoolProvider {
load(context: AccountPoolContext): Promise<readonly AccountPoolItem[]>;
}
const accountPool: AccountPoolProvider = {
async load(context) {
console.log("本轮当前账号:", context.currentAccount?.label ?? "未知");
return [
{ label: "主账号", priority: 1 },
{ label: "备用账号", priority: 2 },
];
},
};label 会被去除首尾空格,且必须非空、唯一并存在于本轮快照。数值越小优先级越高;同优先级时服务继续按最早刷新时间和账号池返回顺序排序。省略此钩子时,默认账号池按来源快照顺序使用所有带标签账号。
4.2 切换策略:SwitchPolicy
策略决定本轮保持、切换或停止。服务已经完成用量验证和候选排序,eligibleTargets 只包含可安全切换的账号;策略指定的目标仍会在切换前再次验证。
import type { EvaluatedAccount } from "@huui/cdx-switcher-auto-service";
interface SwitchPolicyContext {
readonly now: string;
readonly current: EvaluatedAccount | null;
readonly accounts: readonly EvaluatedAccount[];
readonly eligibleTargets: readonly EvaluatedAccount[];
readonly cooldownUntil: string | null;
}
type SwitchDecision =
| { action: "keep"; reason?: string }
| { action: "switch"; targetLabel?: string; reason?: string }
| { action: "stop"; reason: string };
const policy: SwitchPolicy = {
async decide(context) {
if (context.current?.risk === "emergency") {
return {
action: "switch",
targetLabel: context.eligibleTargets[0]?.label ?? undefined,
reason: "当前账号处于紧急风险",
};
}
return { action: "keep", reason: "当前用量可继续使用" };
},
};EvaluatedAccount 提供 label、displayName、isCurrent、priority、availability、usage、effectiveUsedPercent、nextResetAtUnixMs、risk 与 unavailableReason。策略不能把未验证账号变成候选,也不能绕过 95% 候选上限、冷却期和锁。
4.3 轮询策略:PollingStrategy
轮询策略只决定成功评估后到下一轮的延迟,不负责创建定时器。服务会把最终延迟限制在 10 秒至 24 小时之间。
import type {
EvaluatedAccount,
EvaluationResult,
} from "@huui/cdx-switcher-auto-service";
interface PollingContext {
readonly defaultDelayMs: number;
readonly current: EvaluatedAccount | null;
readonly accounts: readonly EvaluatedAccount[];
readonly lastEvaluation: EvaluationResult | null;
}
const polling: PollingStrategy = {
async nextDelayMs(context) {
if (context.current?.risk === "warning") return 2 * 60_000;
return context.defaultDelayMs;
},
};defaultDelayMs 已按服务固定风险规则计算,适合在自定义逻辑没有特殊要求时直接返回。切换成功后的固定 5 分钟冷却期、来源读取失败和无可验证目标的重试间隔不由该钩子覆盖。
4.4 切换锁:SwitchLock
锁保护真实的 AccountSource.switchTo() 调用。默认使用单进程内存锁;跨进程、文件、Redis 或数据库协调可通过此接口接入。也可以继承导出的 AbstractSwitchLock。
import type { AutoServiceResult } from "@huui/cdx-switcher-auto-service";
interface LockAcquireRequest {
key: string;
timeoutMs: number;
signal: AbortSignal;
}
interface SwitchLockLease {
release(): Promise<void>;
}
interface SwitchLock {
acquire(request: LockAcquireRequest): Promise<AutoServiceResult<SwitchLockLease>>;
}
const lock: SwitchLock = {
async acquire({ key, timeoutMs, signal }) {
if (signal.aborted) {
return {
ok: false,
error: {
code: "LOCK_OPERATION_FAILED",
message: "锁请求已取消。",
retryable: true,
cause: { source: "lock" },
},
};
}
const token = await acquireFromYourLockService(key, timeoutMs, signal);
return {
ok: true,
data: {
async release() {
await releaseFromYourLockService(token);
},
},
};
},
};key 是固定服务锁名,timeoutMs 是本次允许等待的毫秒数,signal 会在服务停用时中止等待。获取失败应返回 AutoServiceResult;获得的租约必须实现幂等的 release(),服务会在切换成功、失败或取消后释放它。
5. 自定义账号来源
账号来源是服务与外部账号系统之间的防腐层。默认的 CdxSwitcherAccountSource 使用 getAccountUsages() 和 switchToAccount();接入其他服务商、本地 HTTP 模拟器或测试数据时,实现相同的 AccountSource 即可。
必须实现的接口
interface AccountSource {
/** 每轮只会被服务调用一次。 */
readSnapshot(): Promise<AccountSourceResult<AccountSnapshot>>;
switchTo(label: string): Promise<AccountSourceResult<SwitchOutcome>>;
}
type AccountSourceResult<T> =
| { ok: true; data: T }
| { ok: false; error: AccountSourceFailure };
interface AccountSourceFailure {
code:
| "configuration"
| "authentication"
| "network"
| "usage_unavailable"
| "switch_failed"
| "unavailable";
message: string;
retryable: boolean;
}
interface AccountSnapshot {
checkedAt: string;
currentAccount: AccountIdentity | null;
accounts: readonly AccountObservation[];
}
interface AccountObservation extends AccountIdentity {
usage: UsageSnapshot | null;
failure: AccountSourceFailure | null;
}
interface AccountIdentity {
label: string | null;
displayName: string;
isCurrent: boolean;
}
interface UsageSnapshot {
checkedAt: string;
windows: readonly UsageWindowSnapshot[];
}
interface UsageWindowSnapshot {
kind: "primary" | "secondary";
usedPercent: number;
resetsAtUnixMs: number;
}
interface SwitchOutcome {
targetLabel: string;
currentAccount: AccountIdentity | null;
changed: boolean;
switchedAt: string;
}AccountSnapshot.currentAccount 只有在 accounts 中恰好一个账号的 isCurrent 为 true,且身份摘要一致时才可以赋值;无法唯一确认时必须为 null。每个账号的 usage 与 failure 二选一:单项读取失败应保留账号身份,将 usage 设为 null 并填写 failure,不能中断同批其他账号。
来源错误只能使用以下稳定分类:configuration、authentication、network、usage_unavailable、switch_failed、unavailable。错误消息必须是脱敏的固定业务描述,不能透传访问令牌、账号 ID、邮箱、原始 HTTP 响应或堆栈。
自定义来源示例
import {
createCodexSwitcherAutoService,
type AccountSource,
} from "@huui/cdx-switcher-auto-service";
const accountSource: AccountSource = {
async readSnapshot() {
const response = await fetch("http://127.0.0.1:4317/v1/account-usages");
if (!response.ok) {
return {
ok: false,
error: {
code: "network",
message: "账号来源网络请求失败。",
retryable: true,
},
};
}
const payload = await response.json();
return {
ok: true,
data: {
checkedAt: payload.checkedAt,
currentAccount: payload.currentAccount,
accounts: payload.accounts,
},
};
},
async switchTo(label) {
const response = await fetch("http://127.0.0.1:4317/v1/switch-to-account", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ label }),
});
if (!response.ok) {
return {
ok: false,
error: {
code: "switch_failed",
message: "账号来源未能完成账号切换。",
retryable: true,
},
};
}
return { ok: true, data: await response.json() };
},
};
const service = createCodexSwitcherAutoService({ accountSource });示例中的 HTTP 结构仅用于展示映射位置;真实实现必须在来源适配器内校验下游返回数据,并转换为本节的公开契约。服务层、账号池、策略、轮询、锁和事件监听代码不应导入或依赖该下游协议。
