🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

配额耗尽重试 windwhiterain/dsh-llm-quota-retry

DeepSeek Harness
windwhiterain15 天前有提交已提供中文文档扫描:未发现敏感能力

让 DeepSeek Harness 智能体在账号配额耗尽时继续跑:每小时重试同一请求,不限次数,直到成功或本轮取消。

30.7/100
综合分
1
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/18 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

该插件未发布到 npm,按作者文档从 GitHub 源安装即可(静态检查只覆盖 npm 路径,不代表插件不可用)。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add windwhiterain/dsh-llm-quota-retry
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:需留意
是什么
dsh 原生插件 · model
装得上吗
作者未把包发布到 npm,只能用源码安装
插件本身没问题,是分发方式不同
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 15 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查需源码安装

仓库 package.json 标记 private,未发布到 npm,需从源码安装

展开查看检查明细 ↓

以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。

✗npm 包dsh-llm-quota-retry(未发布到 npm,仅可源码安装)
✓Node 引擎未声明 engines.node
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明

源码安装时若 pnpm 拦截 prepare 脚本,按其提示在 pnpm-workspace.yaml 的 allowBuilds 中放行后重跑。

验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/16 04:11:53

安全扫描自动 · 静态分析源码
v2.7.0
🟢 扫描:未发现敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描未发现敏感能力:最多只发网络请求、读取自身元数据或只读文件,未发现执行命令、文件写入或敏感信息读取。

扫描于 2026/11/1(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • 配额耗尽时继续运行
  • 每小时重试同一请求
  • 不限重试次数

README

由 DeepSeek 最新模型翻译生成
dsh-llm-quota-retry 一个 DeepSeek Harness 宿主插件,用来应对账号配额或余额耗尽的情况:只要配置了还有额度的服务商/模型,它就把会话切过去;没配置的话,就在同一个未结束的轮次里重试,让这一步继续活着。 模型请求失败,原因是账号额度用光时,常规恢复策略会在重试预算耗尽后放弃,轮次就此结束。插件在这一刻接手。如果会话的路由池——一份有名字、有顺序的列表,里面是可以互换的服务商/模型对——里有还有额度的路由,重试立刻转过去,按各路由剩余额度比例抽取;一条都没有,就在同一个未结束的轮次里按配置的间隔重试——默认一小时,不设尝试上限——直到请求成功或轮次被取消。所有会话默认开启:想按会话关掉,用输入框旁的开关或 /quota-retry;想整个部署关掉,设 defaultEnabled: false。哪条路由还有额度,来自余额脚本,以及自己上报额度耗尽的那些服务商。 任何会话也能手动放进池子——用第二个输入框开关或 /quota-pool——模型恰好属于某个池的会话,不用交代也会跟着那个池走。池的 minimumRemaining 决定会话起步时能用哪些路由,起步那条按各路由剩余额度比例抽取;会话一旦跑起来,只有服务商上报额度用光才会让它换路由。
展开完整文档(README 剩余 1 节)
dsh-llm-quota-retry

一个 DeepSeek Harness 宿主插件,用来应对账号配额或余额耗尽的情况:只要配置了还有额度的服务商/模型,它就把会话切过去;没配置的话,就在同一个未结束的轮次里重试,让这一步继续活着。

模型请求失败,原因是账号额度用光时,常规恢复策略会在重试预算耗尽后放弃,轮次就此结束。插件在这一刻接手。如果会话的路由池——一份有名字、有顺序的列表,里面是可以互换的服务商/模型对——里有还有额度的路由,重试立刻转过去,按各路由剩余额度比例抽取;一条都没有,就在同一个未结束的轮次里按配置的间隔重试——默认一小时,不设尝试上限——直到请求成功或轮次被取消。所有会话默认开启:想按会话关掉,用输入框旁的开关或 /quota-retry;想整个部署关掉,设 defaultEnabled: false。哪条路由还有额度,来自余额脚本,以及自己上报额度耗尽的那些服务商。

任何会话也能手动放进池子——用第二个输入框开关或 /quota-pool——模型恰好属于某个池的会话,不用交代也会跟着那个池走。池的 minimumRemaining 决定会话起步时能用哪些路由,起步那条按各路由剩余额度比例抽取;会话一旦跑起来,只有服务商上报额度用光才会让它换路由。

这覆盖同一种状况的三种形态:标准的 QUOTA 错误码,以及文本里点名用量上限已耗尽的 AUTH 或 RATE_LIMIT 失败。后两种是计量型服务商的回答方式——按滚动窗口计量的服务商回 403 权限错误,按 Token Plan 计量的回 429 限流(已达到 Token Plan 用量上限:…)——只认 QUOTA 的插件,恰好会漏掉它存在的意义所在的那几种情况。

安装

这个包是 Cordis bundle,带一个 Web 输入框半边:

在包含本仓库检出的目录下:

dsh plugin --profile web add ./dsh-llm-quota-retry

安装会把包链接进配置档,并记进 dsh.profile.bundles,之后每次启动都会读取 bundle 层。宿主那一行不用重启就生效:配置档补丁热重载,行激活,bundle 报告 enabled,fiber 处于活动状态。

输入框开关第一次需要重启一次宿主。 dsh.client 清单由客户端模块扫描读取,扫描结果按包缓存,缓存活到宿主生命周期结束——某个包在声明 dsh.client 之前被扫过一次,就会一直缓存成「不是客户端包」,之后再怎么重扫也清不掉这条记录。安装后重启一次 dsh web,或者在给已安装的包加上 dsh.client,芯片就会出现。

组合顺序

把 llm-quota-retry 这一行组合到 @deepseek-ai/dsh-llm-retry 之后。

这个顺序是约定,不是偏好。dsh-llm-retry 在 agent/request-error 瀑布中更早运行,先花掉自己的重试预算,预算耗尽后再调用下一个监听器。等配额失败到达这个插件时,更早的恢复策略已经全部拒绝处理它——这正是「在基础快速重试之后」的含义。bundle 自带的 cordis.patch.yml 会把这一行追加到组合根,所以安装 bundle 会自动落到正确位置。

行顺序不决定监听器顺序。 Cordis 瀑布按注册顺序运行监听器,而插件在服务出现时注册,不是在读取行时注册;重新加载某一行会把该行的监听器移到末尾。所以在某个部署里,如果这个插件恰好先激活,它就会在配额失败上先发言,原生策略列为可重试的失败就完全得不到原生重试。在 nativeRetryFirstProviders 中列名的服务商,无论哪种情况都按同样方式处理。

为指定服务商做原生快速重试

有些服务商把普通的请求限流报告成额度耗尽——429 背后是 QUOTA 响应体,几秒后就恢复。为此等上一小时,正是这个插件要避免的失败;在这些服务商上,限流也不能算作账户额度耗尽的证据。

nativeRetryFirstProviders 用来列出它们。对列名的服务商,额度耗尽时先跑该服务商自己的原生重试计划——同样的有界指数退避、同样的尝试预算,与 @deepseek-ai/dsh-llm-retry 会跑的一致,取自失败请求携带的已解析 retryPolicy(initialDelayMs、maxDelayMs、jitterRatio、maxRetries,以及服务商要求且未超过自身上限的 providerRetryAfterMs)。只有这份预算花完,这个插件才把服务商标记为额度耗尽、把会话移到池中另一条路由,并开始按小时等待。

快速重试跑在耗尽标记之前,也跑在任何故障转移之前,所以只是被限流的服务商保住了额度,会话也留在它上面。快速重试会像长期等待那样占住 open 步骤几秒钟,而且不对外宣告任何东西:条目、它的事件和 ctx.llmQuotaRetry 状态描述的是长期等待,而长期等待还没开始。进度写在日志里:

llm-quota-retry: provider "qwen" returned QUOTA; native quick retry 2 in 4000ms

有三条规则决定是否发生快速重试,每一条都是原生策略而非这个插件掌管该步骤的情形:

- 服务商在该行中列名。 其他所有服务商保持不变。
- 原生策略持有有界的时间表。 完全没有 retryPolicy,以及 mode: always——它没有预算可花——都会把失败留给每小时等待。providerRetryAfterMs 超出策略的 maxDelayMs 时也一样,原生策略对此的回应是转交,而不是重试。
- 原生策略尚未花掉这一步的预算。 当这一行确实在 dsh-llm-retry 之后运行,且失败属于该策略会重试的类型时,它自己的预算先被花掉,本插件不再额外增加等待。插件从会话已记录的 llm/retry 事件中读取这一点——这是它依赖的唯一一处跨插件状态,也是它完全可能缺失的状态:没有 @deepseek-ai/dsh-llm-retry 时,快速时间表照常运行;原生策略从未花掉预算时,同样如此。

余额读数刻意不作为第四条规则。 无论套餐已用尽还是请求只是被限流,qwen 对同一个 insufficient_quota 失败都给出相同回答,所以显示余额不足的读数无法区分这两种情况——而快速重试是让被限流的请求在数秒内恢复的唯一手段。因读数偏低就跳过时间表,会把一个含义混淆的错误变成立即故障转移,这与本清单的目的正好相反。代价仍然有界且可见:真正没有余额的服务商花掉一步的时间表,随后进入长期等待。缩短该时间表靠服务商自己的 maxRetries 和 maxDelayMs,而不是这里的规则。

快速预算按步计数,与原生预算一样:一步用完就进入长期等待,下一步重新开始一份预算。

- id: llm-quota-retry
name: 'dsh-llm-quota-retry'
config:
qwen reports a 429 rate limit as insufficient_quota, so its own bounded
backoff runs first: 2s, 4s, 8s … up to maxRetries, then the hourly wait.
nativeRetryFirstProviders: [qwen]

本插件不触碰的内容

除额度耗尽外,插件对每种失败都严格只做加法;对额度耗尽也是如此,只要会话的开关是关的:

- 其他所有失败原样转交——限流、服务器错误、超时、传输错误、上下文溢出都保持原有处理,包括压缩自身的恢复。服务商将持续拒绝的凭证也一样,普通限流也一样:只有当 AUTH 或 RATE_LIMIT 失败的文本明确提到额度时,才把它读作额度耗尽,所以错误或已吊销的 API key 仍然快速失败,并发请求的突发仍然保留亚分钟级重试,而不是在不可能成功的尝试之间等上一小时。
- 开关关闭的会话原样转交额度耗尽。 关闭开关就是退出;每个会话默认开启,且开关同时覆盖路由迁移与等待。
- 池化会话的请求可以在发出前被迁移。 这就是池的用途,以及它只在请求解析到的路由不可用时才触发——额度标记已耗尽,或读数低于该服务商的最低值——并且会话池中另一条路由可用。没有池、也没有分配池的会话永远不会被触碰;用户在模型选择器里选定的模型,一旦落到会话池之外就不再干预。
- 原生快速重试永远不会被移除。 dsh-llm-retry 仍然先运行,其配置的 retryPolicy 仍然生效。注意 QUOTA 不在默认的 retryableCodes 里,所以默认情况下额度耗尽根本不会触发原生重试,只有本插件来应答;如果服务商列出了 QUOTA(或运行 mode: always),它会保留自己的重试,本插件只在那些重试耗尽后才醒来。在 nativeRetryFirstProviders 中列出的服务商,当原生策略没有运行时,会从这里运行同一套调度,这是叠加而非替换:见指定服务商。
- 有一种配置是被取代,而不只是被放过。 retryPolicy.mode: always 没有可用码列表,也没有预算,而且它先询问下游,所以对于额度耗尽,每小时调度会取代它自己的亚分钟级退避。其他所有配置都不受影响。

每会话开关

对额度耗尽的响应默认开启,直到开关被关掉,而且开关覆盖整个响应:切换到会话池的另一条路由,以及没有路由有额度时的等待重试。三条路径操作同一个开关:

- Composer 芯片 —— 位于模型、权限和压缩芯片旁边,菜单提供 On、Off 和 Default。
- /quota-retry <on|off|default|status> —— 芯片也使用这同一条写入路径,所以键入的命令行和点击永远不会不一致。
- 该设置按会话生效,并通过 llm_quota_retry 存储域持久化,因此能在宿主重启后保留。没有可用的存储域时,它降级为进程内值,而不是让开关失败。

没有自身记录的会话遵循 defaultEnabled,除非该行将其设为 false,否则为 true。分叉或委派的会话会一次性取最近祖先的显式设置,作为快照:该值从那时起适用于该会话,而 default 会将其清除回部署默认值,而不是重新继承。

在会话等待时把开关关掉会立即结束该条目——待处理的定时器被中止,而不是留到一小时后唤醒——并报告 left,原因为 disabled。

每会话路由池

哪个池管辖某个会话是它自己的设置,与开关在同一个持久记录中,继承规则也相同。它同样通过两种方式写入:

- Composer 芯片 —— 开关旁边的第二个芯片。其菜单按名称列出每个已配置的池,外加 Default 和 Off。
- /quota-pool <pool|off|default|status> — 唯一的写入路径,chip 也走这条。status 报告当前生效的池、它的来源,以及会话所在的路由。

三种状态,刻意区分:

| 设置 | 含义 |
| 池名 | 这个会话属于该池:额度耗尽时,它可以移到该池的其他路由上。 |
| default | 没有指定池,插件跟随会话所在的路由 —— 某条路由只被一个池持有时,就指向那个池。模型属于某个池的普通会话,因此无需额外指定就会故障转移;被两个池共享的路由不属于任何一个池。 |
| off | 这个会话永不更换服务商。额度耗尽就在原地等待,无论等多久。 |

选池就是选路由。 会话一旦被放进某个池 —— 无论是通过命令、chip,还是通过委派插件从池化模板创建 —— 插件就会从该池中抽一条路由(每条可用路由按剩余额度分配概率,所以阈值会生效),会话的下一个请求就用它,即使当前模型不属于任何池。这个选择记录在会话本身上,形式是模型选择器写入的 model/selection 事件,因此 Client 的选择器显示的是实际在用的路由。此后,只有服务商报告额度耗尽时,会话才会再次移动;用户之后选择的模型不会被改动。

配额故障转移从不改变新会话的部署默认值,尽管它移动会话的方式与选择器相同。选择器自己的写入路径还会把选择保存为默认值;本插件刻意不走那条路径。

配置

| 键 | 默认值 | 含义 |
| longTermDelayMs | 3600000 | 长期尝试之间的间隔,单位毫秒。必须是正有限数,且不大于 2147483647。 |
| defaultEnabled | true | 会话在拥有自己的设置之前所依据的开关。设为 false 时,整个额度响应变为需主动开启。 |
| nativeRetryFirstProviders | 无 | 这些服务商 id 的额度耗尽时,先跑服务商自己的原生重试计划。见指定服务商。 |
| pools | 无 | 命名的有序路由列表,会话从其中起步,并可在其间移动。每个池包含 routes,还可选 midSessionSwitch: false,用于拒绝本插件在会话运行后自行发起的移动。见下文。 |
| balances | 无 | 每个服务商一个余额脚本,报告剩余额度,外加它运行所用的 apiKeyEnv 凭据、为每个上报窗口加权的 coefficients、脚本未给出窗口时用于相除的 capacities,以及读数没有窗口时用于相除的 capacity。见下文。 |
| failOpen | true | 没有任何上报的路由是否算作可用。设为 false 时,读不到余额的服务商就不可用,因此只会选中脚本确认过的路由。 |
| exhaustedCooldownMs | 3600000 | 请求因额度耗尽失败后,服务商保持标记的时长。必须是正有限数,且不大于 2147483647。 |

未知键和非法值会在激活时抛错,所以配置写错的行会直接报错,而不是悄悄按错误的时间表重试。

某一行关掉默认值,整个部署的额度响应就变成需要显式开启;下面这行还缩短了间隔。

- id: llm-quota-retry
name: 'dsh-llm-quota-retry'
config:
longTermDelayMs: 1800000
defaultEnabled: false

路由池与余额来源

路由是一个服务商/模型对。池是一份有名字、有顺序的路由列表,池内路由可以互相顶替。余额来源是一个脚本,报告某个服务商还剩多少。这个部署运行的脚本放在 dsh-balance —— 每个服务商一个文件,无依赖,各自往 stdout 打印一条 JSON 读数。

- id: llm-quota-retry
name: 'dsh-llm-quota-retry'
config:
pools:
medium:
routes:
- { provider: gateway-a, model: flash-1, reasoningEffort: high }
- { provider: gateway-b, model: flash-1, reasoningEffort: high }
advice:
midSessionSwitch: false # interchangeable to start on, never swapped under a live session
routes:
- { provider: gateway-a, model: model-max, reasoningEffort: high }
- { provider: gateway-b, model: model-max, reasoningEffort: high }
balances:
- provider: gateway-a
script: ['node', 'C:/resource/dsh-balance/gateway-a.mjs']
apiKeyEnv: GATEWAY_A_API_KEY # resolved through the credential service, then forwarded
unit: percent # what remaining counts, for a person reading it
minimumRemaining: 5 # below this a route is not chosen, and never one already running
refreshMs: 60000 # how long a reading answers a route draw without a new run
timeoutMs: 5000 # and how long that draw waits for this script at most
coefficients: # how the windows this script reports are weighed
five-hour: 1 # a window the map does not name weighs 1
monthly: 0.25 # a month of credits matters a quarter as much
capacities: # what a window counts as full, for a script that
monthly: 40 # reports an amount without the plan's cap
- provider: gateway-b
script: ['node', 'C:/resource/dsh-balance/gateway-b.mjs']
apiKeyEnv: GATEWAY_B_API_KEY
unit: usd-left
minimumRemaining: 1
capacity: 20 # this script reports no window: 20 usd counts as full

路由的服务商是 harness 的服务商 id

provider 是路由键,harness 通过它解析模型请求——这个 id 本身会传给 ctx.llm,它不是服务商自己的营销名称,也不是底层某个 SDK 内部碰巧使用的 id。服务商插件注册的就是这样一个 id,而它的包名、它的行名或厂商名并不自动等于这个 id。

这是本文件中唯一无法从这里发现的问题,所以插件会检查它,而不是信任它:见服务商检查。

服务商检查

路由的服务商会原样写入本插件发出的 model/selection 事件,所以一个没有适配器服务的名称不会在写入处失败——它会在之后某个人的模型请求上失败,表现为 harness 的 NO_ADAPTER。因此插件会把 pools 和 balances 中的每个服务商名称与组合注册的适配器进行比对,并且只报告一次不匹配,而不是在每次公告时都报告:

llm-quota-retry: no LLM adapter is registered for provider "opencode-go" (named by
pools.medium.routes[0], balances[0]); a model request on one of them fails with
NO_ADAPTER. Registered providers: command-code-goat, dsh-opencode-go, kimi-coding

何时报告。 组合顺序决定每个适配器何时注册,而组合在本行之后的服务商插件会在 apply() 返回之后才注册它的适配器——所以激活时缺失的名称并不能说明它是否最终会出现。harness 中没有任何东西会宣告组合已完成,所以检查会在最早可行的时点给出答案:它跟随每一次 llm/adapters-updated 公告,但只有在连续两次公告对缺失集合达成一致时才从其中一次报告——每次公告至少注册一个适配器,所以集合不变意味着没有已配置的服务商还在路上。如果做不到这一点,第一次路由请求会给出结论,这也正是缺陷开始产生影响的时刻。

它对路由做什么。 如果某个池的路由指定的服务商没有适配器服务,该池会被拒绝:pickRoute 会为它抛出错误,并指出池和服务商,发起请求的会话不会被放入该池。指定了这种服务商的余额来源不会被拒绝——余额名称只是本插件自身读数的一个查找键,所以那里的不匹配只会损失一次读数,而不是一次请求,余额脚本命名错误的部署仍然可以委派。

/quota-routes 会在池的上方打印上面同一句话,所以人读到的状态与日志指出的缺陷一致。

余额脚本契约

脚本是 argv,绝不是 shell 命令行:script: ['node', 'x.mjs', '--json'] 会通过 harness 自己的子进程服务,以宿主启动时的工作目录,精确运行该可执行文件并传入这些参数。它应该在 stdout 上打印一个 JSON 对象:

{
"remaining": 2.94,
"windows": [
{ "name": "five-hour", "remaining": 14, "capacity": 14 },
{ "name": "weekly", "remaining": 2.94, "capacity": 35 },
{ "name": "monthly", "remaining": 37.94 }
],
"resetsAt": "2026-10-01T00:00:00Z"
}

remaining 表示服务商还剩多少额度,单位由该行的 unit 字段指定——剩余百分比、货币金额、credits 数量。如果某个服务商的脚本报告的是已用百分比,就在打印前换算(remaining = 100 - used)。resetsAt 是给人看的可选上下文;服务商自己拒绝请求时,可以在读数旁边用 error 报告。

windows 是可选的,它决定一条路由与另一条路由的排序:一个套餐会计量多个窗口(五小时上限、一周、一个月),而下一个请求会被哪个窗口拒绝,就按哪个窗口来排。每个条目用 name 标明自己的窗口,用 remaining 报告该窗口还剩多少,并在服务商公布时用 capacity 报告该窗口的总额度。窗口的 remaining 使用该窗口自己的单位;插件用它除以同一窗口的 capacity,这样按百分比的套餐和另一种货币的钱包就能互相比较。没有 capacity 的窗口——预付费钱包、端点只报告金额的月度 credits——保留给查看 /quota-routes 的人,排序时跳过。

其余情况都算读数缺失,不算错误:非零退出、超时(timeoutMs)、输出不是那个 JSON 对象,或者组合里没有子进程服务。读数缺失永远不会拦住请求——需要它的路由在 failOpen 下回退到池声明的顺序,而耗尽标记负责把池已经被告知的服务商挡在外面。格式错误的 windows 条目会被丢弃,而不是让整个读数失败;顶层 remaining 仍然由该行的 minimumRemaining 衡量。

脚本自己负责发请求,插件只转发该行指定的内容。apiKeyEnv 是它该用的凭据:插件每次运行都通过 harness 的凭据服务解析这个引用——所以无论密钥存在环境变量、.env 还是凭据存储里,效果都一样,轮换后的密钥也能到达下一次运行——并以同名传给脚本(OPENCODE_API_KEY=…)。没有配置对应引用的引用不会添加条目,只记一条警告;脚本照常运行,所以它可能从插件不知道的地方读取密钥,它自己的消息是更好的诊断。env 用来添加脚本需要的其他字面条目(base URL、区域、代理),永远不用于密钥。

插件从不把脚本的 stdout 写进日志。

如何选择路由

选择和中断是两条不同的规则。 阈值(minimumRemaining)决定会话从哪条路由开始,以及会话移到哪条路由;它从不中断已经在某条路由上运行的会话。这种拆分是有意为之:另一个账号可能意味着另一个模型、另一套提示词缓存,还有另一个价格,所以实时对话只有在它所在的路由完全无法应答时才会被迁移。

会话的首次请求由池来选择,而池绝不会在没有余额可依据的情况下做选择。 尚未通过本插件发起过请求的会话没有需要保护的对话,因此它的起始路由按下面的规则从池中抽取,无论 midSessionSwitch 怎么说。这次抽取依据池自己持有的读数:某个来源的读数缺失,或比它自己的 refreshMs 更旧,就先重新询问它,抽取会等它返回;读数在该间隔内则直接应答,无需往返。所以常见情况不花代价,间隔限定了脚本启动的频率,抽取也绝不会依据读数的缺失来做——那正是过去按声明顺序给会话分配路由的情形。等待受每个来源自己的 timeoutMs 限制,所以挂起的脚本只消耗它的超时,不影响请求。

选择路由,按以下顺序:

1. 耗尽标记优先决定。 服务商对请求应答了额度耗尽,就不可用,直到它的标记失效(exhaustedCooldownMs),或直到在标记之后取得的读数——至少晚一个 refreshMs,这样失败触发的那次探测本身无法撤销它——再次显示有额度。正是这一点让故障转移对完全不发布余额端点的服务商也能生效。
2. 然后由读数决定,对照该服务商自己的 minimumRemaining(未声明时为零)。
3. 没有任何报告的路由在 failOpen 开启时(默认)可用,关闭时不可用。

在可用的路由中,抽中的那条与它剩余的量成比例。 读数按约束它的额度的比例计分——取它各个 windows 中最差的那个,每个按该行自己的 coefficients 加权——抽取按该分数把路由放入池的范围。余量是另一条两倍的路由,被抽中的频率也两倍,于是池被分摊到各条路由上,而不是一条条被抽干,下一个请求仍然去抽取落到的路由,而不是去剩余最多的那条。

| 情况 | 决定因素 |
| 该行指定了一个或多个计分窗口 | 在带 capacity 的窗口,或为其名称提供 capacities 条目的窗口中,取最低的 remaining / capacity × coefficient |
| 读数没有可计分的窗口 | 该行声明了 capacity 时,用整个读数对照该行的 capacity |
| 两者都没有 | 该路由不计分,且池中任何计分路由可用时它不可抽取 |
| 池中没有任何计分 | 声明顺序:第一条可用路由,与之前完全一致 |

声明顺序也用于打破两条计分相同的路由之间的平局,而路由全部不可用的池仍然用它的第一条路由应答:请求总得在某处运行,长期等待覆盖了所有路由拒绝它。只有配置从未声明过的池才算错误,点名它的调用方会把它当作错误上报。

系数决定一次部署里哪些窗口重要。monthly: 0.25 表示一个月的额度只按五小时上限同比例的四分之一计入;0 则把这个窗口从评分里彻底去掉。映射里没提到的窗口名权重为 1,所以一行不声明 coefficients 时,每个窗口权重相同。

capacities 是另一半:某个服务商只报告窗口还剩多少,却不说满额是多少——比如某个端点只回答剩余额度的美元金额,不给月度上限——那这个窗口在行里写明满额之前就不参与评分。脚本报告的容量永远优先;行里的条目只补服务商没给的那个窗口。对余额脚本加 --raw,或者访问 /quota-routes,就能看到哪些窗口有返回、其中哪些正在参与评分。

离开一条路由只需要一件事:服务商自己报告额度已用尽。读数低于阈值不会让已经开始的会话移动,池内重新排序也不会。移动仍按同一规则进行——在没耗尽额度、且各自越过阈值的路由之间抽取,抽不到就取第一条仅仅没耗尽额度的路由,因为剩得少的路由也可能正是能应答的那条。

池可以拒绝这种移动。 池上设 midSessionSwitch: false,意味着它的路由对起始于其中一条的会话可以互换,但对已经跑在上面的会话永远不行:耗尽的那个服务商就地等过去,而不是在对话中途换成别的模型,也不会发出 llm-quota-retry/failover。它只管起始之后本插件自己发起的移动——失败时的故障转移和逐请求替换。用户要求的移动不属于这个决定,不受影响:模型选择器、/quota-pool <name>、输入框里的标签都仍能移动会话,pickRoute 也仍会为每个新会话和每个委派出的子代理抽一条起始路由。适用于路由是不同模型、而不是同一模型挂在多个账号下的池——那种情况下换路由要付出提示词缓存失效的代价,还会带来对话没要求的行为变化——这些会话仍然起始于剩余额度最多的那个模型。

| 池 | midSessionSwitch | 已经跑在其某条路由上的会话,在该服务商耗尽时会怎样 |
| medium(默认) | 开 | 移到池里另一条路由并立即重试。 |
| advice | 关 | 留在原路由等过这段时间;只有用户能移动它。 |

会话会怎样

- 一步结束时,在任何等待之前:因额度耗尽而失败的请求会标记它的服务商,并在会话池。找到就立刻把重试转到那里,并标记为 llm-quota-retry/failover;没找到就照旧走长期等待。会话池拒绝会话中途切换,也是「没找到」的情形之一。
- 每次请求,会话原本要用的路由都取自它的

pool in proportion to headroom while the session is still starting, and replaced
once it has started when its provider has reported the allowance spent and the
pool holds a route that has not. Drawing waits for whichever of the pool's
balance scripts are due and only marks once they have answered; replacing waits
for nothing at all, because that decision is made against the provider's own
exhaustion mark rather than a reading, and a provider that just refused a
request is the last thing worth waiting on. A reading below a route's threshold
does not move a session that has already started (see "How a route is chosen"), a
pool that refuses mid-session switching never moves it here either, and a route
the user picked in the model picker is left alone as soon as it falls outside the
session's pool.
- Which pool governs a session: the one the session states (/quota-pool,
the chip, or the delegation plugin that created it), or — when it states none — the single pool that holds the route the session is on. A route two pools share names neither, so a session is never moved between pools on a guess. A stated pool is what makes a pool name mean the same thing to a delegating session and to its child.
- A new session can ask for a route before it exists, through pickRoute,
which reads that pool's own providers first — asking again for whichever of them are due — so a pool never probes a provider it cannot use and the wait is bounded by the slowest script that actually ran. This is the delegation-time half: dsh-subagent-templates templates name a pool and call it for every child they create — one route per child, drawn in proportion to headroom, so two subagents of one template start on different routes without anyone choosing between them.

/quota-routes [pool] prints the same state a person needs: each pool, each
route, its last reading, its score when it has one, and its exhaustion mark. A
pool that refuses mid-session switching is named as such in its header, so the
report also answers why a session did not move.

Pool names

A pool is named by the deployment, with lowercase letters, digits, and hyphens.
Eight names are refused at activation because /quota-pool and the composer menu
answer them themselves: status, off, none, disable, disabled,
default, reset, auto, and error. A pool called off could never be
selected, so the row fails loudly instead of defining one nobody can reach.

Reading the state from another plugin

Two read paths are published.

The live retry state, as the service ctx.llmQuotaRetry:
js
export const inject = ['llmQuotaRetry']

export function apply(ctx) {
ctx.on('session/event', (session) => {
if (!ctx.llmQuotaRetry.isRetrying(session)) return
const state = ctx.llmQuotaRetry.stateOf(session)
// state.pending — an hourly wait is armed right now // state.attempts — long-term attempts scheduled for this entry // state.enteredAt — epoch ms the entry opened // state.nextAttemptAt — epoch ms of the armed attempt, while pending // state.provider, state.turn, state.step — the failing request }) }

isRetrying(session) answers whether the session is in long-term retry.
stateOf(session) returns a frozen snapshot, or undefined when the session
is not in long-term retry. Both accept the Harness Session. This state is
in-memory: it exists only while a wait is live.

The route surface, on the same service:
js
// Choose the route a session that does not exist yet should start on: one draw
// over the pool's usable routes, each in proportion to what it has left, so two
// calls can answer differently by design. Asks again for the pool's sources whose
// reading is missing or past their own refreshMs, so it may wait up to the
// slowest of THOSE scripts' timeoutMs.
// undefined means no pool by that name exists — a config error at the caller. // It rejects when a route of the pool names a provider no adapter serves, which // is also a config error at the caller; see // the provider check. const route = await ctx.llmQuotaRetry.pickRoute('medium') // route.provider, route.model, route.reasoningEffort?

// Put a session under one pool's care, so a route several pools share is not
// left to inference. The session's next request moves into the pool. Pass null
// for the opt-out. Returns false when the pool does not exist.
ctx.llmQuotaRetry.setPool(childSession, 'medium')

// The pool governing a session right now: the one it states, or the one its
// route belongs to; undefined when none does.
const pool = ctx.llmQuotaRetry.poolOf(session)

// Every pool, route, reading, score, and mark — what /quota-routes prints. const report = ctx.llmQuotaRetry.report()

dsh-subagent-templates is the reference caller: a template names a pool, its
child is created on the route pickRoute chose, and setPool records the pool on
the child so a failover later knows where it may move to.

The per-session settings, as the quotaRetry Session projection:
js
const facts = ctx.sessionProjections.stateOf(session, 'quotaRetry')
// facts.enabled — the effective switch // facts.defaultEnabled — the row's configured default // facts.source — 'default' | 'session' | 'inherited' // facts.pool — the pool governing the session, or null // facts.poolSource — 'default' | 'session' | 'inherited' | 'off' // facts.pools — every configured pool name, in configured order

The projection is the client-visible path, so it is also what both composer chips
read.

Subscribing to the transitions

Three Cordis events carry the timing of every transition.

llm-quota-retry/entered

Emitted once per entry, when the first quota failure takes the session over and
its first long-term wait is armed. A failure that finds a route with allowance is
never an entry, so it is announced as failover instead.

| Field | Meaning |
| session, agent | The session and agent whose request failed. |
| failure | The LlmFailure that opened the entry. |
| provider, turn, step | Location of the failing request. |
| enteredAt | Epoch ms the entry opened. |
| nextAttemptAt | Epoch ms of the first long-term attempt. |
| attempts | 1 on entry. |
| pending | true — the wait is armed. |
| retrying | true. |

llm-quota-retry/failover

Emitted when a failed request is retried at once on another route of the
session's pool, instead of waiting out an interval.

| Field | Meaning |
| session, agent | The session and agent whose request failed. |
| failure | The LlmFailure that triggered the move. |
| from | The route that failed: { provider, model }. |
| to | The route the retry goes to: { provider, model }. |
| pool | The pool that supplied it. |
| at | Epoch ms of the decision. |

llm-quota-retry/left

Emitted when an entry ends: a model request in the session succeeded, or the
switch was turned off.

| Field | Meaning |
| session | The session whose entry ended. |
| reason | recovered or disabled. |
| provider, turn, step | Location of the last failed request. |
| enteredAt | Epoch ms the entry opened. |
| leftAt | Epoch ms the entry ended. |
| durationMs | leftAt - enteredAt. |
| attempts | Long-term attempts scheduled during the entry. |
js
export function apply(ctx) {
ctx.on('llm-quota-retry/entered', ({ session, nextAttemptAt }) => {
// ...
})
ctx.on('llm-quota-retry/failover', ({ session, from, to, pool }) => {
// ...
})
ctx.on('llm-quota-retry/left', ({ session, reason, durationMs }) => {
// ...
})
}

Behavior and limits

- The trigger is an exhausted allowance. The canonical QUOTA code always
counts; an AUTH or RATE_LIMIT failure counts only when its text names a
spent usage limit, quota, balance, or credits, in English or Chinese.
Everything else reaches the next listener untouched.
- A quick retry of a named provider is a billed request too, and it is
bounded exactly like the native one: at most maxRetries waits per step,
none longer than the policy's maxDelayMs. It is what runs first for a
provider in nativeRetryFirstProviders, and it is the reason such a provider
is neither marked out of allowance nor moved off while its limit lasts.
- Quick retries publish nothing. They emit no plugin event and append no
session event: llm/retry belongs to @deepseek-ai/dsh-llm-retry and
counting these waits into its projection state would make it skip retries
it still owes. The entry, ctx.llmQuotaRetry, and the three events describe
the long-term wait, which starts after the quick budget is spent.
- A failover is a billed request on the other route, and it happens at once.
The failed request itself already cost what it cost; the retry on the pool's
next usable route is one more request, which is the point of configuring a
pool. llm-quota-retry/failover announces every such move.
- Two identical sessions can start on different routes. The route a pool
answers with is drawn in proportion to headroom rather than taken from the top
of a ranking, so a deployment with several routes spreads its work instead of
draining the richest one; only a pool with nothing scored falls back to the
declared order. The draw is what the score shown by /quota-routes weighs.
- The session stays busy. A long-term wait holds the open step, so the turn
does not reach quiescence and the session reports as running until a request
succeeds or the turn is cancelled.
- Each long-term attempt is a billed provider request. The schedule is
unbounded by design: nothing but a successful request, cancellation, the switch
going off, or unloading the plugin stops it.
- A route choice waits only for the balance scripts that are due. Drawing a
session's starting route asks again for the pool's sources whose reading is
missing or past their own refreshMs, and waits for exactly those; a reading
still inside its interval answers the draw with no round trip at all. The wait
is bounded by each source's own timeoutMs and by nothing else: losing a
script's output costs a reading and the timeout, never the request, and a route
with no reading at all falls back to failOpen.
- A provider's own failure outranks its script. A reading taken right after
an exhaustion mark cannot withdraw it (it must be at least one refreshMs
later), so a script with its own cache cannot bounce a session straight back
into the route that just refused it.
- Route state is in-memory, the settings are not. Readings, marks, and the
route each session is on are lost when the host restarts or the row reloads, so
the first request after one reads as that session's start and is drawn from its
pool again — a pool that refuses mid-session switching included. The switch and the route pool are durable, in the same llm_quota_retry record with the same inheritance.
- Cancelling a turn keeps the entry. The pending wait aborts, pending
becomes false, and no left is announced; the entry and its enteredAt
survive, and the next quota failure in that session re-arms the schedule.
Only success or turning the switch off ends an entry.
- The composer chips show the settings, not live progress. Session
projections recompute on session events, and the retry state changes between
them, so a live "retrying now" badge would be up to an hour stale. Use the
service, /quota-routes, or the three events for live progress.
- The wording checks compensate for the harness. A Harness release that
classifies a spent allowance as QUOTA — which @deepseek-ai/dsh-llm does as of the fix(llm): classify a spent usage allowance as quota, not auth change, for a rolling window a provider reports as a 403 — needs no wording check at all. A Token Plan provider still arrives as RATE_LIMIT: that harness classifier reads English wording only, so a Chinese body keeps its 429 code and would take its turn down here. The checks keep this plugin correct against both hosts.
- It consumes no model-visible context. Nothing here reaches the model, and
the one event the plugin appends is model/selection — the picker's own, so the Client shows the route a pool chose. It introduces no new session vocabulary a build would have to know, and it never touches the deployment's default model. A route it proposes is recorded the ordinary way, as the config of the request that used it.

Hot reload

Listing index.js, client.js, lib/policy.js, lib/routes.js,
lib/balance.js, and lib/providers.js in the profile's hmr roots makes an
edit to any of them replace the module generation in a running host: the plugin is
disposed and re-imported, and apply() re-runs. A module missing from that list
silently never reloads, so keep the list in step with the files.
The in-memory retry state does not survive a reload — it drops a pending wait and its entry, and with them every reading and exhaustion mark, exactly as a restart does; the next requests re-derive them. The durable per-session switch and route pool do survive, because they live in the storage domain. Editing client.js of an already-registered client row is picked up as a new artifact generation; only adding or changing the dsh.client manifest itself needs a restart, because the client-module scan caches that verdict per package. The page is already open, so refresh it to load the new chip.

Client bundle scope

The shell concatenates every dynamic client bundle of a batch into one
classic script, so a top-level declaration in client.js joins the same
lexical scope as every other bundle's. A name two bundles both declare is a
SyntaxError that fails the whole batch, which takes down every composer
contribution in it — not just this one.

client.js therefore declares nothing at the top level: a single IIFE owns
every name and only the window.__ModuleLoader__.load(...) call inside it
reaches the shell. It registers two composer chips — the retry switch and the route pool — into the same slot, each with its own registration and disposer.

Probe

probe/probe.mjs drives apply() against a fake Cordis context and checks
every decision, event, state transition, command outcome, inherited setting, and
projection value without a Harness — including the route surface: the request that is moved off an exhausted provider, the immediate retry a pool with allowance produces, the long-term wait a pool without one still uses, the switch that stops both, the pool setting (stated, inferred, opted out, inherited, and restored from storage), the model/selection event a chosen route is recorded as, the balance scripts seen through a fake subprocess service, the draw that sends a session to a route in proportion to its headroom (driven through an injected random, so the choice is exact), and the native-first provider list: which providers qualify, the waits it schedules, and the long-term entry it hands over to once that budget is spent. sh
node probe/probe.mjs

probe/routes.probe.mjs drives the route policy alone — config validation, the balance-output contract (including the windows a reading carries and the coefficients and capacity a row declares), the score a reading earns, fail-open vs fail-closed, exhaustion marks and their cooldown, pool inference, and the proportional draw itself — with no context, no subprocess, and no clock: sh
node probe/routes.probe.mjs

probe/client-probe.mjs loads the composer half through a stub module loader
and checks both chips: their slot registration, order, dictionary, menu contents,
selected option, and the exact /quota-retry or /quota-pool line each submits:
sh
node probe/client-probe.mjs

No probe can produce a provider response that reports an exhausted quota, and
none runs a real balance script. Confirming the composed behavior against a real
provider requires either an account that actually runs out of balance or an
OpenAI-compatible endpoint that answers 402 with insufficient-balance wording;
the balance contract is confirmed by running a script by hand and comparing its
stdout with {"remaining": …} and its windows. What a real pool then does with those readings — which route a draw lands on — is what /quota-routes shows.

License

MIT
预览分享卡片长什么样 ↓windwhiterain/dsh-llm-quota-retry
分享 · DeepSeek Harness Hub
windwhiterain/dsh-llm-quota-retry

让 DeepSeek Harness 智能体在账号配额耗尽时继续跑:每小时重试同一请求,不限次数,直到成功或本轮取消。

回到顶部