@openplan/dsh-fuse 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/cordis.patch.yml +29 -0
- package/dist/budget-tool.d.ts +66 -0
- package/dist/budget-tool.js +108 -0
- package/dist/config.d.ts +155 -0
- package/dist/config.js +125 -0
- package/dist/fuse.d.ts +59 -0
- package/dist/fuse.js +83 -0
- package/dist/harness.d.ts +48 -0
- package/dist/harness.js +773 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +55 -0
- package/dist/meter.d.ts +71 -0
- package/dist/meter.js +73 -0
- package/dist/pricing.d.ts +193 -0
- package/dist/pricing.js +450 -0
- package/dist/router.d.ts +30 -0
- package/dist/router.js +34 -0
- package/dist/store.d.ts +168 -0
- package/dist/store.js +412 -0
- package/dist/sync.d.ts +70 -0
- package/dist/sync.js +153 -0
- package/dist/wire.d.ts +52 -0
- package/dist/wire.js +15 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpenPlan Code Company
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# /dsh-fuse
|
|
2
|
+
|
|
3
|
+
Política de custo multi-agente para o DeepSeek Harness: o plugin mede cada
|
|
4
|
+
chamada do agente, **trava antes** de estourar o budget (fuse local, <100ms,
|
|
5
|
+
offline) e sincroniza com o painel SaaS — cujo 429 é o portão secundário. Só
|
|
6
|
+
métricas sobem: tokens, custo, modelo — o conteúdo das sessões **nunca sai da
|
|
7
|
+
máquina**.
|
|
8
|
+
|
|
9
|
+
Painel central: **https://dsh.openplan.cc** — gastos por organização, projeto
|
|
10
|
+
e pessoa, budgets que o plugin aplica localmente, e o histórico de cortes.
|
|
11
|
+
|
|
12
|
+
## Instalar
|
|
13
|
+
|
|
14
|
+
O pacote declara `dsh.bundle`, que é o que faz `dsh plugin add` ativar a
|
|
15
|
+
camada. Sem essa declaração o pnpm instalaria uma biblioteca inerte.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# npm — a forma canônica de instalação (publicado, v0.1.0)
|
|
19
|
+
dsh plugin --profile <perfil> add @openplan/dsh-fuse
|
|
20
|
+
|
|
21
|
+
# tarball (sem depender de registry)
|
|
22
|
+
pnpm pack # gera openplan-dsh-fuse-<versão>.tgz
|
|
23
|
+
dsh plugin --profile <perfil> add ./openplan-dsh-fuse-0.1.0.tgz
|
|
24
|
+
|
|
25
|
+
# direto do git (exige allowlist de build do pnpm >= 10 — veja a doc do harness)
|
|
26
|
+
dsh plugin --profile <perfil> add github:<org>/<repo>#<sha>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Fonte: **https://github.com/openplancc/dsh-fuse** (espelho gerado a cada
|
|
30
|
+
release — cada tag `v<versão>` corresponde a um commit deste monorepo).
|
|
31
|
+
|
|
32
|
+
O plugin usa `@libsql/client` como store local — zero build nativa,
|
|
33
|
+
distribuível sem compilação (Node ≥ 20).
|
|
34
|
+
|
|
35
|
+
Verifique a camada sem bootar:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
dsh --profile <perfil> --dump-config # mostra '# == @openplan/dsh-fuse'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configurar
|
|
42
|
+
|
|
43
|
+
O bundle traz defaults seguros (modo local-only, store ancorado no harness
|
|
44
|
+
home, sync de 60s). Para sobrescrever, edite o `cordis.patch.yml` **do seu
|
|
45
|
+
profile** — a camada do usuário, aplicada depois de todas as camadas de bundle.
|
|
46
|
+
A sobrescrita é uma **linha direta com o mesmo `id`** (não um segundo `insert:`,
|
|
47
|
+
que duplicaria o id e faria o loader falhar com `duplicate loader entry id`):
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
# ~/.dsh/profiles/<perfil>/cordis.patch.yml
|
|
51
|
+
- id: fuse
|
|
52
|
+
name: '@openplan/dsh-fuse'
|
|
53
|
+
config:
|
|
54
|
+
project: meu-projeto
|
|
55
|
+
dev: eu@empresa.com
|
|
56
|
+
# Segredo nunca em texto puro no YAML — a tag !!js resolve no load.
|
|
57
|
+
orgKey: !!js process.env.DSH_ORG_KEY
|
|
58
|
+
baseUrl: https://dsh-api.openplan.cc
|
|
59
|
+
# Orçamento local (o fuse corta offline antes de gastar):
|
|
60
|
+
budgets:
|
|
61
|
+
- limitUsd: 50
|
|
62
|
+
window: month
|
|
63
|
+
- limitUsd: 5
|
|
64
|
+
window: day
|
|
65
|
+
# Políticas:
|
|
66
|
+
policies:
|
|
67
|
+
# maxReasoningEffort é um id da PRÓPRIA rota (adapter-owned):
|
|
68
|
+
maxReasoningEffort: medium
|
|
69
|
+
allowedModels:
|
|
70
|
+
- deepseek/deepseek-v4-flash
|
|
71
|
+
denylistedProjects:
|
|
72
|
+
- projeto-cliente-confidencial
|
|
73
|
+
# Preço (cents por 1M tokens) — OPCIONAL. Por padrão o plugin resolve o
|
|
74
|
+
# preço de cada modelo automaticamente (registry models.dev; veja abaixo),
|
|
75
|
+
# então esta tabela só existe para override explícito. As chaves podem ser
|
|
76
|
+
# o id do adapter (com prefixo de provider), o id nu, ou qualquer alias:
|
|
77
|
+
pricingTable:
|
|
78
|
+
deepseek/deepseek-v4.1-flash:
|
|
79
|
+
inputCentsPerM: 15
|
|
80
|
+
outputCentsPerM: 60
|
|
81
|
+
cacheReadCentsPerM: 3
|
|
82
|
+
gpt-4o:
|
|
83
|
+
inputCentsPerM: 250
|
|
84
|
+
outputCentsPerM: 1000
|
|
85
|
+
# Escape hatch quando o id do adapter não é adivinhável:
|
|
86
|
+
pricingAliases:
|
|
87
|
+
vendor/opaque-model-v9: gpt-4o
|
|
88
|
+
# Uma taxa ÚNICA opcional (cents por 1M) para modelos que nenhuma fonte
|
|
89
|
+
# precifica — um knob, não uma tabela. Sem ela, chamadas sem preço entram
|
|
90
|
+
# com custo zero mas marcadas como `unpriced` (nunca um zero silencioso).
|
|
91
|
+
unpricedFallback:
|
|
92
|
+
inputCentsPerM: 15
|
|
93
|
+
outputCentsPerM: 60
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Config inválida falha o load**, com erro acionável, em vez de ligar um plugin
|
|
97
|
+
que não enforça nada: metade de um alvo de sync (só `baseUrl` ou só `orgKey`)
|
|
98
|
+
ou uma `cascade` que não intersecta `policies.allowedModels` são recusados no
|
|
99
|
+
boot. Preço **não** é exigido — o plugin resolve genericamente (abaixo); um
|
|
100
|
+
modelo sem preço é um estado visível (`unpriced`), não um boot falho.
|
|
101
|
+
|
|
102
|
+
## Como o preço é resolvido
|
|
103
|
+
|
|
104
|
+
O harness reporta o modelo com o id do **adapter** (normalmente com prefixo de
|
|
105
|
+
provider, ex. `deepseek/deepseek-v4-flash`), enquanto os catálogos de preço vêm
|
|
106
|
+
keyed por rota (`provider/model`). O plugin junta **duas fontes**, ambas
|
|
107
|
+
baixadas e cacheadas no libsql para uso offline:
|
|
108
|
+
|
|
109
|
+
- **registry** (`pricingRegistryUrl`, default `https://models.dev/api.json`) —
|
|
110
|
+
catálogo de preços em USD por rota para 200+ providers. Cobre gateways que
|
|
111
|
+
não publicam preço (ex. command-code) resolvendo cada modelo por id.
|
|
112
|
+
- **gateway** (`pricingGatewayUrl` + `pricingGatewayProvider`) — quando o
|
|
113
|
+
gateway publica preço no próprio `/models` (OpenRouter, DeepInfra, …), esses
|
|
114
|
+
números são **autoritativos** para as rotas dele.
|
|
115
|
+
|
|
116
|
+
A resolução de um id de adapter procura, nesta ordem:
|
|
117
|
+
|
|
118
|
+
1. `pricingAliases[model]` (explícito, sempre ganha)
|
|
119
|
+
2. chave exata na tabela local
|
|
120
|
+
3. rota `provider/model` no registry/gateway
|
|
121
|
+
4. id nu no registry (o id do adapter sem prefixo)
|
|
122
|
+
5. sufixos progressivos do id (ex. `provider/anthropic/x` → `anthropic/x` → `x`)
|
|
123
|
+
|
|
124
|
+
Modelo que nenhuma fonte precifica é gravado com **`unpriced: true`** (contado
|
|
125
|
+
e mostrado no painel) e logado — nunca um zero silencioso, porque um preço
|
|
126
|
+
zerado faz o fuse nunca cortar e o budget do servidor nunca disparar. Se a org
|
|
127
|
+
setou `unpricedFallback`, esses modelos usam a taxa única e o budget continua
|
|
128
|
+
cortando.
|
|
129
|
+
|
|
130
|
+
## O agente enxerga a política (model-facing tool)
|
|
131
|
+
|
|
132
|
+
A doc do harness prescreve o par: o gate diz *não* (o fuse em `agent/pre-step`)
|
|
133
|
+
— e uma **tool model-facing separada** deixa o próprio agente ler o estado da
|
|
134
|
+
política. Quando a composição monta o serviço `tools` (`@deepseek-ai/dsh-tools`),
|
|
135
|
+
o plugin registra `dsh_budget_status`: o modelo pode consultar, no meio da
|
|
136
|
+
sessão, os budgets em vigor por escopo (org/projeto/dev), o total já gasto vs o
|
|
137
|
+
limite, e se há um 429 remoto ativo (e até quando). É o que torna uma trava dura
|
|
138
|
+
tolerável — o agente entende por que foi cortado e pode adaptar (perguntar,
|
|
139
|
+
parar, escolher rota mais barata) em vez de morrer às cegas.
|
|
140
|
+
|
|
141
|
+
A tool é **só leitura**, nunca uma decisão: lê o mesmo libsql que o fuse enforça
|
|
142
|
+
(`spentForWindow`, `remoteBlockFor`, a política publicada) e formata uma visão.
|
|
143
|
+
Ela não gateia nem reescreve nada. O schema viaja em toda request, então quem
|
|
144
|
+
quiser zero presença na wire desliga com `budgetStatusTool: false` (default
|
|
145
|
+
`true`); e se o serviço `tools` não estiver montado, o registro é simplesmente
|
|
146
|
+
pulado — o plugin declara nenhum `inject`, então um serviço opcional nunca
|
|
147
|
+
segura o load.
|
|
148
|
+
|
|
149
|
+
## Modo local-only
|
|
150
|
+
|
|
151
|
+
Sem `baseUrl`/`orgKey` o plugin funciona **sozinho**: nada sobe, o fuse enforça
|
|
152
|
+
os budgets offline. As sessões ficam no libsql local, por padrão em
|
|
153
|
+
`$DSH_HOME/dsh-fuse/local.db` (`~/.dsh/...` quando `DSH_HOME` não está
|
|
154
|
+
definido) — ancorado no harness home, então o ledger não muda conforme o
|
|
155
|
+
diretório de onde o harness foi iniciado. `storeUrl` troca o caminho; um
|
|
156
|
+
caminho relativo só vale quando você o define explicitamente.
|
|
157
|
+
|
|
158
|
+
## O que sobe pro painel (sync)
|
|
159
|
+
|
|
160
|
+
A cada 60s (`syncIntervalMs`), um único batch (`POST /v1/usage/batch`):
|
|
161
|
+
|
|
162
|
+
- **usage events** — projeto, dev, modelo, provider, reasoning effort, tokens
|
|
163
|
+
(entrada, saída, cache leitura, cache escrita), duração do step, custo e
|
|
164
|
+
timestamp. O session id sai **hasheado (SHA-256) no cliente**; conteúdo nunca.
|
|
165
|
+
- **fuse cuts** — toda vez que o fuse travou uma chamada (regra + projeto), pro
|
|
166
|
+
painel mostrar "quantas vezes a política cortou".
|
|
167
|
+
|
|
168
|
+
Um batch só marca as linhas como sincronizadas quando o SaaS **confirma** (2xx
|
|
169
|
+
com `ok`): um 500, um 502 do proxy ou uma página de erro nunca aposentam uma
|
|
170
|
+
linha sem ela ter sido gravada.
|
|
171
|
+
|
|
172
|
+
Um `429 BudgetExceeded { rule, reset_at }` do SaaS **engata o fuse local** até o
|
|
173
|
+
reset da janela — o time bloqueado centralmente fica bloqueado na máquina também.
|
|
174
|
+
|
|
175
|
+
## O painel como control plane
|
|
176
|
+
|
|
177
|
+
Além do 429, o plugin puxa `GET /v1/policy` (key-authed) no boot e a cada
|
|
178
|
+
`policyRefreshMs`: budgets e políticas `hard` da org, no vocabulário do fuse.
|
|
179
|
+
É isso que faz uma regra editada no painel chegar na máquina do dev — sem esse
|
|
180
|
+
pull, o fuse local só enforçaria o que o YAML do deployment diz. O resultado
|
|
181
|
+
fica em cache no libsql, então enforcement **não** depende da rede no boot.
|
|
182
|
+
|
|
183
|
+
## Alertas (50/80/95% + desvio)
|
|
184
|
+
|
|
185
|
+
Os thresholds graduados são disparados pelo backend (não pelo plugin) —
|
|
186
|
+
configure os canais no painel (**Alertas → Canais**): Telegram (bot token +
|
|
187
|
+
chat id) ou webhook (POST JSON `{kind, payload, at}`). O desvio de gasto acende
|
|
188
|
+
quando a última hora queima ≥ 5× a média das últimas 7 dias E ≥ US$ 1.
|
|
189
|
+
|
|
190
|
+
## Reasoning cap: por que não há lista fixa de valores
|
|
191
|
+
|
|
192
|
+
`ReasoningEffortId` é *adapter-owned*: o core do harness "brands identifiers but
|
|
193
|
+
does not enumerate their values; each adapter owns the ordered set". Então o cap
|
|
194
|
+
é comparado **por índice na lista de efforts da própria rota**
|
|
195
|
+
(`ctx.llm.resolveModelInfo`). Quando a rota não publica a lista, o cap é
|
|
196
|
+
reportado como **não enforçável** (`notEnforced`, com aviso no log) em vez de
|
|
197
|
+
adivinhado — um ranking local por nome, ou por código de caractere,
|
|
198
|
+
classificaria um id como `none` acima de `high` e travaria justamente a
|
|
199
|
+
requisição mais barata.
|
|
200
|
+
|
|
201
|
+
## GitHub Action
|
|
202
|
+
|
|
203
|
+
Veja `examples/agent-budget.yml`.
|
|
204
|
+
|
|
205
|
+
## Verificação
|
|
206
|
+
|
|
207
|
+
`pnpm dsh:smoke` (na raiz do monorepo) empacota o plugin, instala num profile
|
|
208
|
+
descartável com o `dsh plugin add` real, boota o harness headless e exige que o
|
|
209
|
+
fuse recuse um step que não cabe no budget e que um run permitido registre custo
|
|
210
|
+
**diferente de zero**.
|
|
211
|
+
|
|
212
|
+
## Licença
|
|
213
|
+
|
|
214
|
+
MIT.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# The bundle layer `dsh` applies when a profile lists this package.
|
|
2
|
+
#
|
|
3
|
+
# Every key this row needs is restated here because a patch REPLACES a row's
|
|
4
|
+
# config rather than deep-merging it. It deliberately carries no `config:` at
|
|
5
|
+
# all: the exported Config schema supplies the defaults a deployment is likely
|
|
6
|
+
# to keep (local-only mode, harness-home-anchored store, 60s sync), and users
|
|
7
|
+
# override what they need in their own profile's `cordis.patch.yml`, which is
|
|
8
|
+
# applied after every bundle layer.
|
|
9
|
+
#
|
|
10
|
+
# dsh plugin --profile <name> add @openplan/dsh-fuse
|
|
11
|
+
# dsh --profile <name> --dump-config # shows the "# == @openplan/dsh-fuse" layer
|
|
12
|
+
#
|
|
13
|
+
# Local-only mode (nothing leaves the machine) is the default. To report to a
|
|
14
|
+
# panel, add to the profile patch:
|
|
15
|
+
#
|
|
16
|
+
# - id: fuse
|
|
17
|
+
# name: '@openplan/dsh-fuse'
|
|
18
|
+
# config:
|
|
19
|
+
# project: meu-projeto
|
|
20
|
+
# dev: eu@empresa.com
|
|
21
|
+
# orgKey: !!js process.env.DSH_ORG_KEY
|
|
22
|
+
# baseUrl: https://dsh-api.openplan.cc
|
|
23
|
+
# budgets:
|
|
24
|
+
# - limitUsd: 50
|
|
25
|
+
# window: month
|
|
26
|
+
#
|
|
27
|
+
- insert:
|
|
28
|
+
- id: fuse
|
|
29
|
+
name: '@openplan/dsh-fuse'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing budget-status tool — the documented *"separate
|
|
3
|
+
* model-facing ask tool"* that pairs with an enforcement gate.
|
|
4
|
+
*
|
|
5
|
+
* The harness extension cookbook prescribes the pair: a gate says *no* (the
|
|
6
|
+
* plugin's `agent/pre-step` fuse), and a separate tool lets the AGENT itself
|
|
7
|
+
* read the policy state — remaining budget per enforced scope, active remote
|
|
8
|
+
* blocks and their reset, the project/dev labels in force. A hard cut is only
|
|
9
|
+
* tolerable when the thing being cut understands it: the model can query this
|
|
10
|
+
* before spending in a long task (or right after being cut) and adapt —
|
|
11
|
+
* asking the user, stopping, or picking a cheaper route — instead of dying
|
|
12
|
+
* blind. This is the mitigation the proposal's Fase-6 metric
|
|
13
|
+
* ("falso-positivo de trava") needs.
|
|
14
|
+
*
|
|
15
|
+
* ## Status-only, never a decision
|
|
16
|
+
*
|
|
17
|
+
* The tool is deliberately inert as policy: it reads the same libsql store the
|
|
18
|
+
* fuse enforces (`spentForWindow`, `remoteBlockFor`, the published policy) and
|
|
19
|
+
* formats a view. It never gates, never rewrites, and its schema rides in the
|
|
20
|
+
* model's toolset only when the deployment left it enabled
|
|
21
|
+
* (`config.budgetStatusTool`). Registration is optional at load: it is skipped
|
|
22
|
+
* when the composition mounts no `tools` service (`@deepseek-ai/dsh-tools`),
|
|
23
|
+
* because the plugin declares no `inject` — an optional service must never
|
|
24
|
+
* hold the fiber PENDING.
|
|
25
|
+
*
|
|
26
|
+
* Types are the REAL `@deepseek-ai/dsh-tools` declarations (the same rule as
|
|
27
|
+
* the rest of the plugin): the object below is built as a literal so no
|
|
28
|
+
* runtime import of `dsh-tools` is needed — the plugin stays distributable
|
|
29
|
+
* standalone, and only the type surface is referenced.
|
|
30
|
+
*/
|
|
31
|
+
import type { ToolDefinition } from "@deepseek-ai/dsh-tools";
|
|
32
|
+
/** One budget in force for this machine, with its governing scope. */
|
|
33
|
+
export interface ScopedBudgetStatus {
|
|
34
|
+
scope: "org" | "project" | "dev";
|
|
35
|
+
/** Project label / developer id for scoped entries. */
|
|
36
|
+
reference?: string;
|
|
37
|
+
window: "month" | "day";
|
|
38
|
+
limitUsd: number;
|
|
39
|
+
spentUsd: number;
|
|
40
|
+
}
|
|
41
|
+
/** The active remote 429 block governing this machine, if any. */
|
|
42
|
+
export interface RemoteBlockStatus {
|
|
43
|
+
rule: string;
|
|
44
|
+
resetAt: string;
|
|
45
|
+
}
|
|
46
|
+
/** The full model-facing view of the local policy state. */
|
|
47
|
+
export interface BudgetStatus {
|
|
48
|
+
project: string;
|
|
49
|
+
dev: string;
|
|
50
|
+
/** ISO timestamp of the snapshot. */
|
|
51
|
+
at: string;
|
|
52
|
+
/** Budgets in force (no ordering guaranteed). */
|
|
53
|
+
budgets: ScopedBudgetStatus[];
|
|
54
|
+
block: RemoteBlockStatus | null;
|
|
55
|
+
}
|
|
56
|
+
/** Human-readable markdown-ish summary handed straight to the model. */
|
|
57
|
+
export declare function budgetStatusText(status: BudgetStatus): string;
|
|
58
|
+
/**
|
|
59
|
+
* Build the registry-ready tool definition. The status read is injected so the
|
|
60
|
+
* module stays pure (unit-testable without the harness); the wiring in
|
|
61
|
+
* `harness.ts` supplies the live read over the same store the fuse uses.
|
|
62
|
+
*
|
|
63
|
+
* @param readStatus - resolve the current policy state (store-backed).
|
|
64
|
+
* @returns a `ToolDefinition` for `ctx.tools.register(...)`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function createBudgetStatusTool(readStatus: () => Promise<BudgetStatus>): ToolDefinition;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing budget-status tool — the documented *"separate
|
|
3
|
+
* model-facing ask tool"* that pairs with an enforcement gate.
|
|
4
|
+
*
|
|
5
|
+
* The harness extension cookbook prescribes the pair: a gate says *no* (the
|
|
6
|
+
* plugin's `agent/pre-step` fuse), and a separate tool lets the AGENT itself
|
|
7
|
+
* read the policy state — remaining budget per enforced scope, active remote
|
|
8
|
+
* blocks and their reset, the project/dev labels in force. A hard cut is only
|
|
9
|
+
* tolerable when the thing being cut understands it: the model can query this
|
|
10
|
+
* before spending in a long task (or right after being cut) and adapt —
|
|
11
|
+
* asking the user, stopping, or picking a cheaper route — instead of dying
|
|
12
|
+
* blind. This is the mitigation the proposal's Fase-6 metric
|
|
13
|
+
* ("falso-positivo de trava") needs.
|
|
14
|
+
*
|
|
15
|
+
* ## Status-only, never a decision
|
|
16
|
+
*
|
|
17
|
+
* The tool is deliberately inert as policy: it reads the same libsql store the
|
|
18
|
+
* fuse enforces (`spentForWindow`, `remoteBlockFor`, the published policy) and
|
|
19
|
+
* formats a view. It never gates, never rewrites, and its schema rides in the
|
|
20
|
+
* model's toolset only when the deployment left it enabled
|
|
21
|
+
* (`config.budgetStatusTool`). Registration is optional at load: it is skipped
|
|
22
|
+
* when the composition mounts no `tools` service (`@deepseek-ai/dsh-tools`),
|
|
23
|
+
* because the plugin declares no `inject` — an optional service must never
|
|
24
|
+
* hold the fiber PENDING.
|
|
25
|
+
*
|
|
26
|
+
* Types are the REAL `@deepseek-ai/dsh-tools` declarations (the same rule as
|
|
27
|
+
* the rest of the plugin): the object below is built as a literal so no
|
|
28
|
+
* runtime import of `dsh-tools` is needed — the plugin stays distributable
|
|
29
|
+
* standalone, and only the type surface is referenced.
|
|
30
|
+
*/
|
|
31
|
+
/** Human-readable markdown-ish summary handed straight to the model. */
|
|
32
|
+
export function budgetStatusText(status) {
|
|
33
|
+
const lines = [
|
|
34
|
+
`dsh budget status — project "${status.project}", dev "${status.dev}" (at ${status.at})`,
|
|
35
|
+
];
|
|
36
|
+
if (status.budgets.length === 0) {
|
|
37
|
+
lines.push(" no budgets in force");
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
lines.push(" budgets in force:");
|
|
41
|
+
for (const budget of status.budgets) {
|
|
42
|
+
const label = budget.scope === "project"
|
|
43
|
+
? `project "${budget.reference ?? status.project}"`
|
|
44
|
+
: budget.scope === "dev"
|
|
45
|
+
? `dev "${budget.reference ?? status.dev}"`
|
|
46
|
+
: "org (this machine)";
|
|
47
|
+
const used = budget.limitUsd > 0
|
|
48
|
+
? `${((budget.spentUsd / budget.limitUsd) * 100).toFixed(1)}% used`
|
|
49
|
+
: "no limit";
|
|
50
|
+
lines.push(` - ${label} · ${budget.window} · $${budget.spentUsd.toFixed(4)} / $${budget.limitUsd.toFixed(2)} (${used})`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (status.block) {
|
|
54
|
+
lines.push(` remote block: ${status.block.rule} until ${status.block.resetAt} (a SaaS 429 is engaging the local fuse)`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
lines.push(" remote block: none");
|
|
58
|
+
}
|
|
59
|
+
lines.push(" note: figures are what the local fuse enforces; unpriced calls count at $0.");
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build the registry-ready tool definition. The status read is injected so the
|
|
64
|
+
* module stays pure (unit-testable without the harness); the wiring in
|
|
65
|
+
* `harness.ts` supplies the live read over the same store the fuse uses.
|
|
66
|
+
*
|
|
67
|
+
* @param readStatus - resolve the current policy state (store-backed).
|
|
68
|
+
* @returns a `ToolDefinition` for `ctx.tools.register(...)`.
|
|
69
|
+
*/
|
|
70
|
+
export function createBudgetStatusTool(readStatus) {
|
|
71
|
+
return {
|
|
72
|
+
name: "dsh_budget_status",
|
|
73
|
+
description: "Read the local dsh cost-policy status: remaining budget per enforced scope (org/project/dev), any active remote block and its reset time. Useful before spending in a long task or right after a step was cut by policy.",
|
|
74
|
+
parameters: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: {},
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
},
|
|
79
|
+
output: {
|
|
80
|
+
schema: {
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
status: {
|
|
84
|
+
type: "string",
|
|
85
|
+
description: "Human-readable policy status summary.",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
required: ["status"],
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
},
|
|
91
|
+
render: (_args, value) => {
|
|
92
|
+
const status = value.status;
|
|
93
|
+
return [
|
|
94
|
+
{
|
|
95
|
+
type: "text",
|
|
96
|
+
text: typeof status === "string" ? status : String(value),
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
execute: async () => {
|
|
102
|
+
const status = await readStatus();
|
|
103
|
+
return { status: budgetStatusText(status) };
|
|
104
|
+
},
|
|
105
|
+
// Pure read of shared state; parallel sibling calls are safe.
|
|
106
|
+
isConcurrencySafe: () => true,
|
|
107
|
+
};
|
|
108
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration — schema + cross-field validation.
|
|
3
|
+
*
|
|
4
|
+
* The harness plugin-config contract (develop/basic/config) asks for an
|
|
5
|
+
* exported `Config` that is BOTH a TypeScript type and a runtime schema:
|
|
6
|
+
* "Do not export a plain object as Config; it does not implement the Standard
|
|
7
|
+
* Schema interface required by Cordis." Cordis validates it while the plugin
|
|
8
|
+
* loads, so bad config fails the load "with a precise error — the plugin never
|
|
9
|
+
* starts half-configured".
|
|
10
|
+
*
|
|
11
|
+
* ## Why the cross-field check matters more here than in a normal plugin
|
|
12
|
+
*
|
|
13
|
+
* A cost-enforcement plugin that loads with unusable configuration does not
|
|
14
|
+
* merely misbehave — it enforces NOTHING, silently, while the deployment
|
|
15
|
+
* believes it is protected. Configuration that cannot be acted on is refused
|
|
16
|
+
* at load time (the documented rule: "A plugin should also reject
|
|
17
|
+
* schema-valid config that names an unavailable resource or provider"), and
|
|
18
|
+
* the shape that produces a silent no-op is rejected explicitly:
|
|
19
|
+
*
|
|
20
|
+
* 1. exactly one of `baseUrl` / `orgKey`. Half a sync target is not a
|
|
21
|
+
* degraded sync, it is no sync (plus no `GET /v1/policy`, so the panel
|
|
22
|
+
* cannot reach the local fuse at all).
|
|
23
|
+
*
|
|
24
|
+
* Prices are NOT required here: the plugin resolves them generically by model
|
|
25
|
+
* id (see pricing.ts) and reports an unpriced call visibly rather than
|
|
26
|
+
* refusing to load. The old "budgets require a price source" gate was removed
|
|
27
|
+
* because it forced every deployment to hand-maintain a price table — exactly
|
|
28
|
+
* the maintenance burden the generic resolver exists to eliminate; a missing
|
|
29
|
+
* price is a visible state, not a boot failure and not a silent zero.
|
|
30
|
+
*/
|
|
31
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
32
|
+
import type { PricingAliases, PricingTable } from "./pricing.js";
|
|
33
|
+
export interface BudgetConfig {
|
|
34
|
+
limitUsd: number;
|
|
35
|
+
window: "month" | "day";
|
|
36
|
+
}
|
|
37
|
+
export interface PolicyConfig {
|
|
38
|
+
/**
|
|
39
|
+
* Highest allowed reasoning effort, as an id from the route's own set.
|
|
40
|
+
* Empty string means "no cap".
|
|
41
|
+
*/
|
|
42
|
+
maxReasoningEffort: string;
|
|
43
|
+
/** Allowed models; empty means every model the harness routes is allowed. */
|
|
44
|
+
allowedModels: string[];
|
|
45
|
+
/** Denied projects; empty means nothing is denied. */
|
|
46
|
+
denylistedProjects: string[];
|
|
47
|
+
}
|
|
48
|
+
export interface DshPluginConfig {
|
|
49
|
+
/**
|
|
50
|
+
* libsql URL — a file for the installed plugin, `:memory:` for tests.
|
|
51
|
+
* Empty means the harness-home-anchored default
|
|
52
|
+
* (`$DSH_HOME/dsh-fuse/local.db`, `~/.dsh` when `$DSH_HOME` is
|
|
53
|
+
* unset); a relative `file:` path is honored only when explicitly set.
|
|
54
|
+
*/
|
|
55
|
+
storeUrl: string;
|
|
56
|
+
/** Project label for windowing. */
|
|
57
|
+
project: string;
|
|
58
|
+
/** Developer identity carried on every usage row. */
|
|
59
|
+
dev: string;
|
|
60
|
+
/** Local caps — the fuse's budget inputs; spent comes from the store. */
|
|
61
|
+
budgets: BudgetConfig[];
|
|
62
|
+
/** Policy gates: reasoning cap, allowed models, project denylist. */
|
|
63
|
+
policies: PolicyConfig;
|
|
64
|
+
/** Routing cascade cheap → expensive (model ids). */
|
|
65
|
+
cascade: string[];
|
|
66
|
+
/** SaaS sync (optional): baseUrl + orgKey enable the 429 + policy pull. */
|
|
67
|
+
baseUrl?: string;
|
|
68
|
+
orgKey?: string;
|
|
69
|
+
/** Sync cadence in ms. */
|
|
70
|
+
syncIntervalMs: number;
|
|
71
|
+
/** Policy-refresh cadence in ms (`GET /v1/policy`). */
|
|
72
|
+
policyRefreshMs: number;
|
|
73
|
+
/**
|
|
74
|
+
* Expose the model-facing budget-status tool (`dsh_budget_status`) to the
|
|
75
|
+
* agent: the docs' "separate model-facing ask tool" paired with the fuse
|
|
76
|
+
* gate. It is status-only — reads the same store the fuse enforces and
|
|
77
|
+
* never decides. The schema rides in every request, so a deployment that
|
|
78
|
+
* wants zero tool presence on the wire can turn it off.
|
|
79
|
+
*/
|
|
80
|
+
budgetStatusTool: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Cost table (cents per 1M tokens) — YAML-serializable so the fuse has real
|
|
83
|
+
* numbers offline. Keys may be bare (`gpt-4o`), vendor-qualified, the
|
|
84
|
+
* adapter's own prefixed id, or the normalized `provider/model` route; the
|
|
85
|
+
* resolver tries each form (see pricing.ts). Optional: prices are resolved
|
|
86
|
+
* generically when this is empty.
|
|
87
|
+
*/
|
|
88
|
+
pricingTable: PricingTable;
|
|
89
|
+
/**
|
|
90
|
+
* Explicit `reported model id → price-table key` overrides. The escape
|
|
91
|
+
* hatch for an adapter whose id nothing else can guess.
|
|
92
|
+
*/
|
|
93
|
+
pricingAliases: PricingAliases;
|
|
94
|
+
/**
|
|
95
|
+
* The registry of model prices, keyed by route — auto-synced, cached by the
|
|
96
|
+
* plugin for offline use, never hand-maintained. Empty string disables it.
|
|
97
|
+
*/
|
|
98
|
+
pricingRegistryUrl: string;
|
|
99
|
+
/**
|
|
100
|
+
* The gateway's own OpenAI-compatible `/models` URL. When it publishes
|
|
101
|
+
* prices, those numbers are authoritative for its routes; when it does not
|
|
102
|
+
* (e.g. command-code), the registry resolves each model id generically.
|
|
103
|
+
*/
|
|
104
|
+
pricingGatewayUrl: string;
|
|
105
|
+
/**
|
|
106
|
+
* Provider id used to key the gateway's routes in the table.
|
|
107
|
+
*/
|
|
108
|
+
pricingGatewayProvider: string;
|
|
109
|
+
/**
|
|
110
|
+
* Env var holding the gateway bearer token, when `/models` needs auth.
|
|
111
|
+
*/
|
|
112
|
+
pricingGatewayApiKeyEnv: string;
|
|
113
|
+
/**
|
|
114
|
+
* ONE optional org-level rate for models nothing prices (cents per 1M) — a
|
|
115
|
+
* knob, not a table. Lets a USD budget still cut on an unpriced route.
|
|
116
|
+
* Absent = unpriced calls count $0 and are reported visibly as unpriced.
|
|
117
|
+
*/
|
|
118
|
+
unpricedFallback?: {
|
|
119
|
+
inputCentsPerM: number;
|
|
120
|
+
outputCentsPerM: number;
|
|
121
|
+
cacheReadCentsPerM?: number;
|
|
122
|
+
cacheWriteCentsPerM?: number;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The runtime schema Cordis validates before `apply` runs.
|
|
127
|
+
*
|
|
128
|
+
* Annotated as `Schema<Partial<DshPluginConfig>, DshPluginConfig>`: the first
|
|
129
|
+
* parameter is what a `cordis.yml` entry supplies (every field optional, filled
|
|
130
|
+
* by the schema defaults) and the second is what `apply` receives (complete).
|
|
131
|
+
* An explicit annotation is required because the inferred form cannot be named
|
|
132
|
+
* in the emitted declarations — it reaches into Schemastery's own type helpers
|
|
133
|
+
* and their vendored dependencies, which breaks the build of a package that
|
|
134
|
+
* ships `.d.ts`.
|
|
135
|
+
*/
|
|
136
|
+
export declare const Config: Schema<Partial<DshPluginConfig>, DshPluginConfig>;
|
|
137
|
+
/**
|
|
138
|
+
* Validate raw configuration and apply defaults, exactly as Cordis does when it
|
|
139
|
+
* loads the plugin. Exposed so tests and embedders can exercise the schema
|
|
140
|
+
* without a cast: the parameter is the partially-populated shape a
|
|
141
|
+
* `cordis.yml` entry supplies, and Schemastery fills every absent field.
|
|
142
|
+
*
|
|
143
|
+
* @throws when a value has the wrong type — Cordis surfaces that as a FAILED
|
|
144
|
+
* fibre ("invalid configuration fails the load with an actionable error").
|
|
145
|
+
*/
|
|
146
|
+
export declare function resolveConfig(raw: unknown): DshPluginConfig;
|
|
147
|
+
/**
|
|
148
|
+
* Cross-field constraints the schema cannot express, evaluated on the resolved
|
|
149
|
+
* config (defaults applied) so they see exactly what `apply` will receive.
|
|
150
|
+
*
|
|
151
|
+
* @throws Error with an actionable message — Cordis turns it into a FAILED
|
|
152
|
+
* fiber, which is the documented outcome for config naming what the plugin
|
|
153
|
+
* cannot serve.
|
|
154
|
+
*/
|
|
155
|
+
export declare function assertUsableConfig(config: DshPluginConfig): void;
|