agentus 0.15.3 → 0.15.5
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/installation.md +1 -1
- package/docs/installation.zh-CN.md +1 -1
- package/package.json +2 -2
- package/packages/server/mock/agent.mjs +12 -6
- package/packages/server/src/acp/session-manager.ts +53 -14
- package/packages/server/src/model-default.ts +222 -12
- package/packages/server/src/store/store.ts +34 -0
- package/packages/shared/src/index.ts +13 -8
- package/packages/web/dist/assets/{index-Bn2UlcXK.js → index-B7y5yEpo.js} +42 -42
- package/packages/web/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ background process comes back as a turn of its own), an **ACP session that write
|
|
|
57
57
|
cockpit-side failure is diagnosable afterwards), and a **DeepSeek web backend** (search +
|
|
58
58
|
extract on the model key) — come from the fork's integration build instead. That is a five-line install:
|
|
59
59
|
[§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
|
|
60
|
-
`agentus/v0.22.0/merge-
|
|
60
|
+
`agentus/v0.22.0/merge-20261028-1949`, on Hermes `v0.22.0`.
|
|
61
61
|
|
|
62
62
|
The first slot you start will use the Hermes home **your own agent already uses**
|
|
63
63
|
(`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
|
package/README.zh-CN.md
CHANGED
|
@@ -51,7 +51,7 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
|
|
|
51
51
|
以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
|
|
52
52
|
装法就五行:
|
|
53
53
|
[§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
|
|
54
|
-
`agentus/v0.22.0/merge-
|
|
54
|
+
`agentus/v0.22.0/merge-20261028-1949`,基座 Hermes `v0.22.0`。
|
|
55
55
|
|
|
56
56
|
你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
|
|
57
57
|
指定了另一个 —— 见下面的「HERMES_HOME」。
|
package/docs/installation.md
CHANGED
|
@@ -60,7 +60,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
|
|
|
60
60
|
ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
|
|
61
61
|
last segment sorts, so "newest" is a `sort -V`.
|
|
62
62
|
|
|
63
|
-
**Current integration build: `agentus/v0.22.0/merge-
|
|
63
|
+
**Current integration build: `agentus/v0.22.0/merge-20261028-1949`** — the newest tag on the fork,
|
|
64
64
|
built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
|
|
65
65
|
`TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
|
|
66
66
|
ever looks stale.
|
|
@@ -52,7 +52,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
|
|
|
52
52
|
tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
|
|
53
53
|
可排序,所以「最新」就是一次 `sort -V`。
|
|
54
54
|
|
|
55
|
-
**当前集成构建:`agentus/v0.22.0/merge-
|
|
55
|
+
**当前集成构建:`agentus/v0.22.0/merge-20261028-1949`** —— fork 上最新的那个 tag,基座 Hermes
|
|
56
56
|
`v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
|
|
57
57
|
以它为准。
|
|
58
58
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.15.
|
|
4
|
-
"description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence
|
|
3
|
+
"version": "0.15.5",
|
|
4
|
+
"description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -949,17 +949,23 @@ const agent = () => ({
|
|
|
949
949
|
used: s.used,
|
|
950
950
|
size: picked > 0 ? picked : Number(process.env.MOCK_USAGE_SIZE || 200_000),
|
|
951
951
|
});
|
|
952
|
-
// The prompt response's `usage`, shaped like the hermes adapter's (issue #70). ACP's
|
|
953
|
-
// are "Total … across all turns", so the counters ACCUMULATE on the session state —
|
|
954
|
-
// that forgot to diff would show the whole conversation on every turn, and a
|
|
955
|
-
// is what proves it does not.
|
|
956
|
-
//
|
|
952
|
+
// The prompt response's `usage`, shaped like the hermes adapter's (issue #70, #71). ACP's
|
|
953
|
+
// semantics are "Total … across all turns", so the counters ACCUMULATE on the session state —
|
|
954
|
+
// a cockpit that forgot to diff would show the whole conversation on every turn, and a
|
|
955
|
+
// two-turn QA sweep is what proves it does not.
|
|
956
|
+
//
|
|
957
|
+
// Issue #71: `inputTokens` is the FULL prompt — hermes fills it from `prompt_tokens`, which
|
|
958
|
+
// its own accounting defines as fresh input + cache read + cache write. The mock therefore
|
|
959
|
+
// adds the cache buckets INTO the input counter, exactly as the real adapter does: a mock that
|
|
960
|
+
// kept them separate would let the old 「input + read + write」 denominator stay green.
|
|
961
|
+
// `cachedWriteTokens` is filled too, because the fork's adapter still omits it and the cockpit
|
|
962
|
+
// must not depend on that omission.
|
|
957
963
|
const outChars = words.join(" ").length + MOCK_MARKDOWN.length;
|
|
958
964
|
s.tokens = s.tokens || {
|
|
959
965
|
totalTokens: 0, inputTokens: 0, outputTokens: 0, thoughtTokens: 0,
|
|
960
966
|
cachedReadTokens: 0, cachedWriteTokens: 0,
|
|
961
967
|
};
|
|
962
|
-
s.tokens.inputTokens += text.length + 200;
|
|
968
|
+
s.tokens.inputTokens += text.length + 200 + 4096 + 128;
|
|
963
969
|
s.tokens.cachedReadTokens += 4096;
|
|
964
970
|
s.tokens.cachedWriteTokens += 128;
|
|
965
971
|
s.tokens.outputTokens += Math.ceil(outChars / 4);
|
|
@@ -1404,7 +1404,7 @@ export class SessionManager {
|
|
|
1404
1404
|
});
|
|
1405
1405
|
if (!r.ok) console.log(`[agentus] model default not written (${info.backend}): ${r.reason}`);
|
|
1406
1406
|
else if (r.changed) {
|
|
1407
|
-
console.log(`[agentus] model default → ${modelId}${r.provider ? ` (provider ${r.provider})` : ""} for ${info.backend}`);
|
|
1407
|
+
console.log(`[agentus] model default → ${r.model ?? modelId}${r.provider ? ` (provider ${r.provider})` : ""} for ${info.backend} (picked ${modelId})`);
|
|
1408
1408
|
}
|
|
1409
1409
|
} catch (e) {
|
|
1410
1410
|
console.log(`[agentus] model default write-back failed: ${errMessage(e)}`);
|
|
@@ -3105,8 +3105,30 @@ export class SessionManager {
|
|
|
3105
3105
|
if (known.length && !known.some((m) => m.modelId === modelId)) {
|
|
3106
3106
|
throw new Error(`unknown model: ${modelId}`);
|
|
3107
3107
|
}
|
|
3108
|
-
await conn.request("session/set_model", { sessionId: s.info.acpSessionId, modelId });
|
|
3109
|
-
|
|
3108
|
+
const reply = await conn.request("session/set_model", { sessionId: s.info.acpSessionId, modelId });
|
|
3109
|
+
// The agent answers with its model state; a few of them (hermes does) re-render the list and mark
|
|
3110
|
+
// the new current there. Ignoring it leaves the slot and the cached handshake on the list from
|
|
3111
|
+
// *before* the switch, which the next start dialog then reads (#72 review). Ids we already know
|
|
3112
|
+
// keep their names/descriptions — the richer shape only exists on the live session.
|
|
3113
|
+
const replied = (reply ?? {}) as {
|
|
3114
|
+
models?: { currentModelId?: string; availableModels?: { modelId?: string }[] } | null;
|
|
3115
|
+
};
|
|
3116
|
+
const returnedIds = (replied.models?.availableModels ?? [])
|
|
3117
|
+
.map((m) => String(m.modelId ?? "")).filter(Boolean);
|
|
3118
|
+
if (s.info.models) {
|
|
3119
|
+
const known = s.info.models.availableModels ?? [];
|
|
3120
|
+
const merged = [...known];
|
|
3121
|
+
for (const id of returnedIds) {
|
|
3122
|
+
if (!merged.some((m) => m.modelId === id)) merged.push({ modelId: id, name: id, description: null });
|
|
3123
|
+
}
|
|
3124
|
+
s.info.models = { ...s.info.models, currentModelId: replied.models?.currentModelId ?? modelId, availableModels: merged };
|
|
3125
|
+
}
|
|
3126
|
+
// #72:缓存握手是"上一次真启动"的快照,新建会话对话框只读它 —— 换了模型却不改它,对话框会一直
|
|
3127
|
+
// 显示(并且把)切换前的那个 id 当中文默认值。活会话手里有完整清单,顺手把两份都刷新。
|
|
3128
|
+
this.#store.recordHandshakeModels(s.info.backend, {
|
|
3129
|
+
currentModelId: modelId,
|
|
3130
|
+
available: (s.info.models?.availableModels ?? []).map((m) => m.modelId).filter(Boolean),
|
|
3131
|
+
});
|
|
3110
3132
|
if (s.info.pendingModelId) s.info.pendingModelId = null;
|
|
3111
3133
|
this.#store.setPendingModel(id, null);
|
|
3112
3134
|
// #62-1: 只有 agent **接受**过的切换才计数 —— 被拒的条目永不加分,于是"广告了但实际不可用"
|
|
@@ -3574,27 +3596,44 @@ export function turnUsageOf(
|
|
|
3574
3596
|
input: input ?? 0,
|
|
3575
3597
|
output: output ?? 0,
|
|
3576
3598
|
thought: thought ?? 0,
|
|
3577
|
-
|
|
3578
|
-
|
|
3599
|
+
// issue #71: never `?? 0` here — the baseline is also what a persisted session reloads, and a
|
|
3600
|
+
// washed-in 0 is indistinguishable from a measured zero on the next diff.
|
|
3601
|
+
cacheRead,
|
|
3602
|
+
cacheWrite,
|
|
3579
3603
|
};
|
|
3580
3604
|
const delta = (now: number, before: number | undefined): number =>
|
|
3581
3605
|
(before === undefined ? now : now >= before ? now - before : now);
|
|
3606
|
+
// Issue #71: a bucket the agent never reports stays NULL through the bill. 「写 0」 reads as
|
|
3607
|
+
// "this turn's cache writes were measured at zero", and cache writes are the billed kind
|
|
3608
|
+
// (Anthropic 1.25×). Omission is the honest shape; see tokens.ts. A null BASELINE means the
|
|
3609
|
+
// first reported value is the turn's own — undefined and null never had a prior count.
|
|
3610
|
+
const deltaOpt = (now: number | null, before: number | null | undefined): number | null =>
|
|
3611
|
+
(now === null ? null : delta(now, before ?? undefined));
|
|
3612
|
+
// Computed up front: the hit rate below divides by THIS turn's prompt delta, not the raw one.
|
|
3613
|
+
const billInput = delta(cur.input, base?.input);
|
|
3614
|
+
const billCacheRead = deltaOpt(cur.cacheRead, base?.cacheRead);
|
|
3582
3615
|
const bill: TurnUsage = {
|
|
3583
3616
|
total: delta(cur.total, base?.total),
|
|
3584
|
-
input:
|
|
3617
|
+
input: billInput,
|
|
3585
3618
|
output: delta(cur.output, base?.output),
|
|
3586
3619
|
thought: delta(cur.thought, base?.thought),
|
|
3587
|
-
cacheRead:
|
|
3588
|
-
cacheWrite:
|
|
3620
|
+
cacheRead: billCacheRead,
|
|
3621
|
+
cacheWrite: deltaOpt(cur.cacheWrite, base?.cacheWrite),
|
|
3589
3622
|
durationMs,
|
|
3590
|
-
//
|
|
3591
|
-
//
|
|
3623
|
+
// Issue #71 — the denominator is this turn's prompt total, i.e. the billed `input` DELTA.
|
|
3624
|
+
// ACP's Usage.inputTokens is the FULL prompt (hermes' adapter fills it with `prompt_tokens`,
|
|
3625
|
+
// which by definition = fresh input + cache read + cache write; see agent/usage_pricing.py in
|
|
3626
|
+
// the fork), so the cache buckets are a SUBSET of input, not peers beside it. Adding them
|
|
3627
|
+
// back counted every cached token twice and pinned the reading at cacheRead/(input+cacheRead)
|
|
3628
|
+
// ≈ 50% for any cache-heavy session — the exact defect #71 measured (real hits 91–100%, page
|
|
3629
|
+
// 50%). webui keeps one invariant across both of its sources: the denominator is a number
|
|
3630
|
+
// that already IS the full prompt (api/models.py passes input_tokens alone); its additive
|
|
3631
|
+
// form exists only for a rollup that stored uncached input separately. We read the first
|
|
3632
|
+
// source, so no addition. `min(100, …)` stays as a clamp against a skewed diff, never as the
|
|
3633
|
+
// thing that makes the ratio believable: with correct semantics normal data cannot exceed 100.
|
|
3592
3634
|
cacheHitPercent: cacheRead === null && cacheWrite === null
|
|
3593
3635
|
? null
|
|
3594
|
-
: (()
|
|
3595
|
-
const prompt = (input ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0);
|
|
3596
|
-
return prompt > 0 ? Math.min(100, Math.round(((cacheRead ?? 0) / prompt) * 100)) : null;
|
|
3597
|
-
})(),
|
|
3636
|
+
: (billInput > 0 ? Math.min(100, Math.round(((billCacheRead ?? 0) / billInput) * 100)) : null),
|
|
3598
3637
|
};
|
|
3599
3638
|
// Average speed: this turn's OWN output over wall time (tool calls included, so a long tool
|
|
3600
3639
|
// turn honestly reads slow rather than being dressed up as model speed).
|
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
// · 只在 server 的 setModel **成功之后**、且是操作者动作时调用(重放不算,见 session-manager)。
|
|
9
9
|
// · 写完必须能读回来:写盘前用真 YAML 解析器验一遍 `model.default` 是不是那个值,验不过就**拒写**。
|
|
10
10
|
// 这是别人的全局配置文件,写坏了下次 spawn 直接起不来 —— 宁可不动,也不能"改成功但改坏"。
|
|
11
|
-
// · 原子写(临时文件 + rename
|
|
11
|
+
// · 原子写(临时文件 + rename)并保持原权限。
|
|
12
|
+
// · 写进去的必须是 hermes 的**配置形状**,不是 ACP 的线格式:`default: <裸模型名>` +
|
|
13
|
+
// `provider: custom:<条目名>`。把选择器的三段 id(`custom:<条目>:<模型>`)直接塞进
|
|
14
|
+
// `model.default`,新建会话会把它整体当模型名发出去(`404 model_not_found`),或者被按第一个
|
|
15
|
+
// 冒号切成 `provider=custom` + 剩下的整串 —— 静默跑到别的端点上(#72)。
|
|
16
|
+
// · 端点跟着条目走:命名条目自己声明 api_url/api_mode,块里残留的是**上一个** provider 的口子
|
|
17
|
+
// (`base_url` 指向旧云端点、`api_mode: codex_responses`),换 provider 时必须清掉,
|
|
18
|
+
// 否则它会盖掉条目的声明。
|
|
12
19
|
import { chmodSync, copyFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
13
20
|
import path from "node:path";
|
|
14
21
|
import { parse as parseYaml } from "yaml";
|
|
@@ -18,6 +25,8 @@ export interface WriteBackResult {
|
|
|
18
25
|
changed: boolean;
|
|
19
26
|
/** 写进去的 provider;null = 没写(推不出或不安全) */
|
|
20
27
|
provider: string | null;
|
|
28
|
+
/** 写进 `model.default` 的裸模型名(从 ACP choice id 拆出来的那个) */
|
|
29
|
+
model?: string;
|
|
21
30
|
reason?: string;
|
|
22
31
|
}
|
|
23
32
|
|
|
@@ -109,6 +118,166 @@ function setKey(lines: string[], key: string, value: string, indent: string): st
|
|
|
109
118
|
return copy;
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
|
|
122
|
+
/** 命名端点(`providers:` 的键 / `custom_providers:` 的 name)在配置里声明的端点与协议。
|
|
123
|
+
*
|
|
124
|
+
* 写回时这两样是**端点归属**的唯一来源:provider 认条目,端口跟着条目走。块级的
|
|
125
|
+
* `base_url` / `api_mode` 是上一个 provider 留下的,换 provider 时要清掉(#72)。 */
|
|
126
|
+
export interface EndpointInfo {
|
|
127
|
+
/** 持久身份:`custom:<config 键>`(legacy 条目用规范化后的 name) */
|
|
128
|
+
id: string;
|
|
129
|
+
apiUrl: string | null;
|
|
130
|
+
apiMode: string | null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** hermes 的 `custom_provider_slug`:小写、空格换连字符、补 `custom:` 前缀。 */
|
|
134
|
+
const customSlug = (display: string): string => {
|
|
135
|
+
const normalized = display.trim().toLowerCase().replace(/ /g, "-");
|
|
136
|
+
return normalized.startsWith("custom:") ? normalized : `custom:${normalized}`;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const bareName = (id: string): string => id.trim().toLowerCase().replace(/^custom:/, "");
|
|
140
|
+
|
|
141
|
+
/** hermes 的 `is_provider_enabled`:`enabled:` 的假值一律视为停用。 */
|
|
142
|
+
const isDisabled = (entry: Record<string, unknown>): boolean => {
|
|
143
|
+
const raw = entry.enabled;
|
|
144
|
+
if (raw === undefined || raw === null) return false;
|
|
145
|
+
if (typeof raw === "boolean") return !raw;
|
|
146
|
+
return ["false", "no", "off", "0", "disabled"].includes(String(raw).trim().toLowerCase());
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** 配置里的命名端点,**键是持久身份** `custom:<条目>`;显示名另作别名指向同一条目。
|
|
150
|
+
*
|
|
151
|
+
* 为什么别名也进这张表:choice id 是身份的权威来源(`custom:<键>:<模型>`),但旧形状
|
|
152
|
+
* (`custom:<模型>`)与裸模型名只剩描述里的**显示名**可用。用显示名当 `model.provider` 写下去,
|
|
153
|
+
* 一旦操作者改了 `name:`,那份配置就再也解析不回来 —— 所以别名必须翻译成 `custom:<键>`,
|
|
154
|
+
* 而不是跟着名字走。
|
|
155
|
+
*
|
|
156
|
+
* 端点字段的顺序照 hermes 的 `_pick_provider_base_url`:`base_url` → `url` → `api`。
|
|
157
|
+
* `api_url` 是 inventory 行(运行时)的字段,不是 config 键;它只作为最后的容错。 */
|
|
158
|
+
export function namedEndpoints(doc: unknown): Map<string, EndpointInfo> {
|
|
159
|
+
const out = new Map<string, EndpointInfo>();
|
|
160
|
+
const endpointOf = (entry: Record<string, unknown>): string | null => {
|
|
161
|
+
for (const key of ["base_url", "url", "api", "api_url"]) {
|
|
162
|
+
const value = entry[key];
|
|
163
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
};
|
|
167
|
+
const add = (id: string, entry: Record<string, unknown>): void => {
|
|
168
|
+
const info: EndpointInfo = { id, apiUrl: endpointOf(entry), apiMode: typeof entry.api_mode === "string" ? entry.api_mode : null };
|
|
169
|
+
out.set(id, info);
|
|
170
|
+
const display = typeof entry.name === "string" ? entry.name.trim() : "";
|
|
171
|
+
if (display) out.set(customSlug(display), info);
|
|
172
|
+
};
|
|
173
|
+
const d = (doc ?? {}) as { providers?: unknown; custom_providers?: unknown };
|
|
174
|
+
const providers = d.providers;
|
|
175
|
+
if (providers && typeof providers === "object" && !Array.isArray(providers)) {
|
|
176
|
+
for (const [key, entry] of Object.entries(providers as Record<string, unknown>)) {
|
|
177
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
178
|
+
const raw = entry as Record<string, unknown>;
|
|
179
|
+
if (isDisabled(raw)) continue; // hermes 不认停用条目:写进 provider 等于让下一次 spawn 起不来
|
|
180
|
+
// 键是身份(显示名可以改),和 hermes 的 custom_provider_slug(display, key) 同一个取向。
|
|
181
|
+
add(customSlug(key), raw);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const legacy = d.custom_providers;
|
|
185
|
+
if (Array.isArray(legacy)) {
|
|
186
|
+
for (const entry of legacy) {
|
|
187
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
188
|
+
const raw = entry as Record<string, unknown>;
|
|
189
|
+
if (isDisabled(raw)) continue;
|
|
190
|
+
const name = typeof raw.name === "string" ? raw.name.trim() : "";
|
|
191
|
+
if (!name) continue;
|
|
192
|
+
add(customSlug(name), raw);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface ChoiceSplit {
|
|
199
|
+
/** hermes `model.default` 该写的值:裸模型名 */
|
|
200
|
+
model: string;
|
|
201
|
+
/** hermes `model.provider` 该写的值;null = 推不出来,保持原样 */
|
|
202
|
+
provider: string | null;
|
|
203
|
+
/** true = 这个 id 描述了一个本配置里不存在的条目:**不许写**,也算不出可信的模型名 */
|
|
204
|
+
unresolved?: boolean;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 把选择器的 choice id 拆回 `(裸模型名, 持久 provider 身份)`。
|
|
208
|
+
*
|
|
209
|
+
* 规则就是 hermes 自己的那条:provider 只在左半段是**已知 provider/条目**时才算分隔符,
|
|
210
|
+
* 否则整串都是模型名(`anthropic/claude-3.5-sonnet:beta` 不能被切)。条目名到身份的映射由
|
|
211
|
+
* `namedEndpoints` 提供,所以这里不需要再猜"哪些名字是 provider"。 */
|
|
212
|
+
export function splitChoiceId(
|
|
213
|
+
modelId: string,
|
|
214
|
+
providerHint: string | null,
|
|
215
|
+
endpoints: Map<string, EndpointInfo>,
|
|
216
|
+
): ChoiceSplit {
|
|
217
|
+
const raw = (modelId ?? "").trim();
|
|
218
|
+
if (!raw) return { model: raw, provider: null };
|
|
219
|
+
const lowered = raw.toLowerCase();
|
|
220
|
+
|
|
221
|
+
// 描述里的名字是**显示名**:先翻成持久身份(改名也不会断),翻不到才当 canonical slug 用。
|
|
222
|
+
const hintSlug = providerHint ? (providerHint.includes(":") ? providerHint.trim().toLowerCase() : customSlug(providerHint)) : null;
|
|
223
|
+
const hintInfo = hintSlug ? endpoints.get(hintSlug) : undefined;
|
|
224
|
+
const hintId = hintInfo?.id ?? null;
|
|
225
|
+
const hintBares = new Set<string>();
|
|
226
|
+
if (hintSlug) hintBares.add(bareName(hintSlug));
|
|
227
|
+
if (hintId) hintBares.add(bareName(hintId));
|
|
228
|
+
hintBares.delete("custom");
|
|
229
|
+
hintBares.delete("");
|
|
230
|
+
|
|
231
|
+
// 1) 描述里声明的条目自己认领前缀:`custom:<条目>:<模型>` / `<条目>:<模型>`。
|
|
232
|
+
for (const bare of hintBares) {
|
|
233
|
+
const customPrefix = `custom:${bare}:`;
|
|
234
|
+
if (lowered.startsWith(customPrefix)) {
|
|
235
|
+
// 只有这个条目真的在配置里,才允许把它的名字当成身份写下去。
|
|
236
|
+
const entry = endpoints.get(`custom:${bare}`);
|
|
237
|
+
return { model: raw.slice(customPrefix.length).trim(), provider: entry?.id ?? hintId ?? null,
|
|
238
|
+
unresolved: entry ? undefined : true };
|
|
239
|
+
}
|
|
240
|
+
if (!lowered.startsWith("custom:") && lowered.startsWith(`${bare}:`)) {
|
|
241
|
+
return { model: raw.slice(bare.length + 1).trim(), provider: hintId ?? bare };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 2) `custom:` 开头的三段 id:第二段是已配置条目才切。
|
|
246
|
+
if (lowered.startsWith("custom:")) {
|
|
247
|
+
const rest = raw.slice("custom:".length);
|
|
248
|
+
const cut = rest.indexOf(":");
|
|
249
|
+
if (cut > 0) {
|
|
250
|
+
const named = endpoints.get(customSlug(rest.slice(0, cut)));
|
|
251
|
+
if (named) return { model: rest.slice(cut + 1).trim(), provider: named.id };
|
|
252
|
+
// 这个条目本配置里没有(被改名/删掉,而对话框拿的是旧快照)。把整串写进 model.default
|
|
253
|
+
// 正是 #72 的病灶:模型名变成 `条目:模型`,或者被按第一个冒号切成 provider=custom。
|
|
254
|
+
return { model: raw, provider: hintId, unresolved: true };
|
|
255
|
+
}
|
|
256
|
+
// `custom:<模型>`(旧形状):模型名是确定的那半,provider 只能靠描述推。
|
|
257
|
+
return { model: rest.trim(), provider: hintId };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 3) `<provider>:<模型>`:左半段是已配置条目(含显示名)、或就是描述里声明的那个名字。
|
|
261
|
+
const colon = raw.indexOf(":");
|
|
262
|
+
if (colon > 0) {
|
|
263
|
+
const left = raw.slice(0, colon).trim().toLowerCase();
|
|
264
|
+
const named = endpoints.get(customSlug(left));
|
|
265
|
+
if (named) return { model: raw.slice(colon + 1).trim(), provider: named.id };
|
|
266
|
+
if (hintBares.has(left)) return { model: raw.slice(colon + 1).trim(), provider: hintId ?? left };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 4) 裸模型名 + 描述里的条目:描述只用来定 provider,绝不改模型名。
|
|
270
|
+
return { model: raw, provider: hintId ?? (hintSlug && hintBares.size ? [...hintBares][0] : null) };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function deleteKey(lines: string[], key: string): string[] {
|
|
274
|
+
const at = findInModelBlock(lines, key);
|
|
275
|
+
if (at < 0) return lines;
|
|
276
|
+
const copy = [...lines];
|
|
277
|
+
copy.splice(at, 1);
|
|
278
|
+
return copy;
|
|
279
|
+
}
|
|
280
|
+
|
|
112
281
|
/** 把 modelId(以及可安全推断时的 provider)写进 agent 的配置文件。 */
|
|
113
282
|
export function writeModelDefault(
|
|
114
283
|
backend: string,
|
|
@@ -139,28 +308,69 @@ export function writeModelDefault(
|
|
|
139
308
|
return { ok: false, changed: false, provider: null, reason: `model: is not a plain block (${header.trim().slice(0, 40)})` };
|
|
140
309
|
}
|
|
141
310
|
|
|
142
|
-
|
|
311
|
+
// 配置里有哪些命名端点,决定了 choice id 怎么拆、以及块内残留的端点要不要清。
|
|
312
|
+
let before: unknown = null;
|
|
313
|
+
try { before = parseYaml(text); } catch { before = null; }
|
|
314
|
+
const endpoints = namedEndpoints(before);
|
|
315
|
+
const split = splitChoiceId(modelId, options.provider ?? null, endpoints);
|
|
316
|
+
const provider = split.provider;
|
|
317
|
+
const desiredModel = split.model;
|
|
318
|
+
if (split.unresolved) {
|
|
319
|
+
// 对话框拿的是旧快照时会出现:条目已被改名/删除,pick 却还带着 `custom:<旧名>:<模型>`。
|
|
320
|
+
// 把它当 model.default 写下去,等于把 #72 的病灶重新种进配置(模型名带条目前缀、或按第一个冒号
|
|
321
|
+
// 切成 provider=custom),所以整次拒写,让调用方看到原因。
|
|
322
|
+
return {
|
|
323
|
+
ok: false, changed: false, provider, model: desiredModel,
|
|
324
|
+
reason: `refusing to write: ${modelId} names an endpoint this config does not declare`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const previousProvider = (before as { model?: { provider?: unknown } } | null)?.model?.provider;
|
|
328
|
+
const previous = typeof previousProvider === "string" ? previousProvider.trim() : null;
|
|
329
|
+
const providerChanged = provider != null && provider !== previous;
|
|
330
|
+
const entry = provider ? endpoints.get(provider) : undefined;
|
|
331
|
+
// 块级的 base_url/api_mode 是**上一个 provider 的口子**,两种情况下都必然过期或多余:
|
|
332
|
+
// · 换了 provider —— 它描述的是被换掉的那一个;
|
|
333
|
+
// · 新 provider 是命名条目 —— 端点归条目所有(条目改了端口/协议,块里那份就成了矛盾状态)。
|
|
334
|
+
// 只按「换 provider 且端点归某个条目」判断,会漏掉 `custom → openrouter`(新 provider 不声明端点),
|
|
335
|
+
// 于是写出「provider 是云厂商、base_url 还指着本地边车」的块,那正是 #72 B 段的错路由。
|
|
336
|
+
const dropEndpointKeys = providerChanged || Boolean(entry);
|
|
337
|
+
|
|
143
338
|
const indent = blockIndent(lines);
|
|
144
339
|
let work = lines;
|
|
145
340
|
if (provider) work = setKey(work, "provider", provider, indent);
|
|
146
|
-
work = setKey(work, "default",
|
|
341
|
+
work = setKey(work, "default", desiredModel, indent);
|
|
342
|
+
if (dropEndpointKeys) {
|
|
343
|
+
work = deleteKey(work, "base_url");
|
|
344
|
+
work = deleteKey(work, "api_mode");
|
|
345
|
+
}
|
|
147
346
|
|
|
148
347
|
const next = work.join("\n");
|
|
149
|
-
if (next === text) return { ok: true, changed: false, provider };
|
|
348
|
+
if (next === text) return { ok: true, changed: false, provider, model: desiredModel };
|
|
150
349
|
|
|
151
|
-
//
|
|
350
|
+
// 写盘前的最后一道闸:真解析一遍。不只看"两行读得回",要看**这个块自洽**——
|
|
351
|
+
// provider 指向的条目和块里的端点必须是同一个端点,否则整次拒写(旧闸门会稳定放过矛盾状态)。
|
|
152
352
|
let doc: unknown;
|
|
153
353
|
try {
|
|
154
354
|
doc = parseYaml(next);
|
|
155
355
|
} catch (e) {
|
|
156
|
-
return { ok: false, changed: false, provider, reason: `refusing to write: result would not parse (${(e as Error).message.split("\n")[0]})` };
|
|
356
|
+
return { ok: false, changed: false, provider, model: desiredModel, reason: `refusing to write: result would not parse (${(e as Error).message.split("\n")[0]})` };
|
|
157
357
|
}
|
|
158
|
-
const model = (doc as { model?: { default?: unknown; provider?: unknown } } | null)?.model;
|
|
159
|
-
if (!model || model.default !==
|
|
160
|
-
return { ok: false, changed: false, provider, reason: "refusing to write: model.default would not read back" };
|
|
358
|
+
const model = (doc as { model?: { default?: unknown; provider?: unknown; base_url?: unknown; api_mode?: unknown } } | null)?.model;
|
|
359
|
+
if (!model || model.default !== desiredModel) {
|
|
360
|
+
return { ok: false, changed: false, provider, model: desiredModel, reason: "refusing to write: model.default would not read back" };
|
|
161
361
|
}
|
|
162
362
|
if (provider && model.provider !== provider) {
|
|
163
|
-
return { ok: false, changed: false, provider, reason: "refusing to write: model.provider would not read back" };
|
|
363
|
+
return { ok: false, changed: false, provider, model: desiredModel, reason: "refusing to write: model.provider would not read back" };
|
|
364
|
+
}
|
|
365
|
+
if (entry?.apiUrl) {
|
|
366
|
+
const norm = (v: unknown) => String(v ?? "").trim().replace(/\/+$/, "").toLowerCase();
|
|
367
|
+
const blockUrl = norm(model.base_url);
|
|
368
|
+
if (blockUrl && blockUrl !== norm(entry.apiUrl)) {
|
|
369
|
+
return { ok: false, changed: false, provider, model: desiredModel, reason: `refusing to write: model.base_url (${String(model.base_url)}) is not where ${provider} lives (${entry.apiUrl})` };
|
|
370
|
+
}
|
|
371
|
+
if (model.api_mode && entry.apiMode && String(model.api_mode) !== entry.apiMode) {
|
|
372
|
+
return { ok: false, changed: false, provider, model: desiredModel, reason: `refusing to write: model.api_mode (${String(model.api_mode)}) contradicts ${provider} (${entry.apiMode})` };
|
|
373
|
+
}
|
|
164
374
|
}
|
|
165
375
|
|
|
166
376
|
// 配置里可能有 api_key:比 0600 宽的一律收紧。
|
|
@@ -173,7 +383,7 @@ export function writeModelDefault(
|
|
|
173
383
|
renameSync(tmp, file);
|
|
174
384
|
} catch (e) {
|
|
175
385
|
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
176
|
-
return { ok: false, changed: false, provider, reason: `write failed: ${(e as Error).message}` };
|
|
386
|
+
return { ok: false, changed: false, provider, model: desiredModel, reason: `write failed: ${(e as Error).message}` };
|
|
177
387
|
}
|
|
178
|
-
return { ok: true, changed: true, provider };
|
|
388
|
+
return { ok: true, changed: true, provider, model: desiredModel };
|
|
179
389
|
}
|
|
@@ -1786,6 +1786,40 @@ export class Store {
|
|
|
1786
1786
|
return Number(info.changes ?? 0) > 0;
|
|
1787
1787
|
}
|
|
1788
1788
|
|
|
1789
|
+
/**
|
|
1790
|
+
* Refresh the MODEL half of a row's cached handshake.
|
|
1791
|
+
*
|
|
1792
|
+
* The handshake is a snapshot of the last real start, and it is the only thing a start dialog can
|
|
1793
|
+
* read for a backend that is not running: a `currentModelId` captured before the operator switched
|
|
1794
|
+
* models is stale forever (nothing else rewrites it), and the dialog would present — and replay —
|
|
1795
|
+
* the model the agent no longer uses (#72). ``available`` is optional: only the live session knows
|
|
1796
|
+
* the full list, so pass it when the caller has one.
|
|
1797
|
+
*/
|
|
1798
|
+
recordHandshakeModels(
|
|
1799
|
+
id: string,
|
|
1800
|
+
models: { currentModelId?: string | null; available?: string[] },
|
|
1801
|
+
): void {
|
|
1802
|
+
const row = this.#db.prepare("select handshake from backends where id = ?").get(id) as
|
|
1803
|
+
| { handshake?: string | null }
|
|
1804
|
+
| undefined;
|
|
1805
|
+
if (!row?.handshake) return; // a row that never ran has no snapshot to correct
|
|
1806
|
+
let hs: BackendHandshake;
|
|
1807
|
+
try {
|
|
1808
|
+
hs = JSON.parse(row.handshake) as BackendHandshake;
|
|
1809
|
+
} catch {
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
if (!hs?.models) return;
|
|
1813
|
+
const current = models.currentModelId ?? null;
|
|
1814
|
+
const supplied = models.available?.length ? models.available : hs.models.available;
|
|
1815
|
+
// `current` must stay inside `available`: the dialog only shows an id that is in the list
|
|
1816
|
+
// (`startModelValue`), so caching a current the list cannot contain would silently downgrade the
|
|
1817
|
+
// next new-session default to "agent default" right after the operator switched (#72 review).
|
|
1818
|
+
const available = current && !supplied.includes(current) ? [current, ...supplied] : supplied;
|
|
1819
|
+
hs.models = { ...hs.models, currentModelId: current, available };
|
|
1820
|
+
this.#db.prepare("update backends set handshake = ? where id = ?").run(JSON.stringify(hs), id);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1789
1823
|
/**
|
|
1790
1824
|
* Write back a health snapshot (and optionally a handshake) for one row. Deliberately a
|
|
1791
1825
|
* column-scoped UPDATE rather than upsertBackend: a check must never resurrect a stale copy of
|
|
@@ -140,29 +140,34 @@ export interface UsageView {
|
|
|
140
140
|
totals?: UsageTotals | null;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
/** Cumulative token buckets a turn's usage is measured against (ACP `Usage`).
|
|
143
|
+
/** Cumulative token buckets a turn's usage is measured against (ACP `Usage`).
|
|
144
|
+
* `cacheRead`/`cacheWrite` are null = the agent never reports that bucket (issue #71); a
|
|
145
|
+
* baseline of null diffs the first REPORTED value as the turn's own, same as undefined. */
|
|
144
146
|
export interface UsageTotals {
|
|
145
147
|
total: number;
|
|
146
148
|
input: number;
|
|
147
149
|
output: number;
|
|
148
150
|
thought: number;
|
|
149
|
-
cacheRead: number;
|
|
150
|
-
cacheWrite: number;
|
|
151
|
+
cacheRead: number | null;
|
|
152
|
+
cacheWrite: number | null;
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
/** One turn's token bill (issue #70) — the DELTA of the agent's cumulative counters.
|
|
154
156
|
*
|
|
155
|
-
* `cacheHitPercent`
|
|
156
|
-
*
|
|
157
|
-
*
|
|
157
|
+
* `cacheHitPercent` is cacheRead / input, where ACP's `inputTokens` is ALREADY the full prompt
|
|
158
|
+
* (fresh + cache read + cache write — issue #71; do not add the buckets back, it counts cached
|
|
159
|
+
* tokens twice and pins the reading near 50%). Null when the agent reports no cache buckets at
|
|
160
|
+
* all — a Qoder session must read as unavailable, not as a 0% hit rate. `tps` is the turn AVERAGE
|
|
158
161
|
* (output tokens over wall time, tool calls included); it is deliberately labelled as an average
|
|
159
162
|
* rather than sold as instantaneous speed. */
|
|
160
163
|
export interface TurnUsage {
|
|
161
164
|
input: number;
|
|
162
165
|
output: number;
|
|
163
166
|
thought: number;
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
/** NULL = the agent never reports this bucket (issue #71). A reported 0 is a real 0 — the
|
|
168
|
+
* two must not collapse into one number, or 「写 0」 lies about cache writes (1.25× billed). */
|
|
169
|
+
cacheRead: number | null;
|
|
170
|
+
cacheWrite: number | null;
|
|
166
171
|
total: number;
|
|
167
172
|
durationMs?: number;
|
|
168
173
|
cacheHitPercent?: number | null;
|