pi-ccswitch-auto-switch 0.3.11 → 0.3.13
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 +22 -5
- package/README.zh-CN.md +17 -6
- package/candidates.ts +3 -1
- package/classify.ts +2 -1
- package/health.ts +169 -83
- package/index.ts +53 -20
- package/package.json +4 -3
- package/runner.mjs +90 -7
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The extension observes real Pi requests, records sanitized health signals, and
|
|
|
13
13
|
- Endpoint circuit breakers for DNS, connection, server, and streaming failures.
|
|
14
14
|
- Endpoint platform isolation: when several models on the same endpoint (same BaseURL/provider) fail within one round, the whole endpoint is isolated so sibling models from the same platform are not tried one by one.
|
|
15
15
|
- Model-only isolation for missing models and incompatible parameters.
|
|
16
|
-
- Learns model-family content-policy constraints from real failures (`glm-4.5` and `GLM-4.6`, for example, are both `glm`) and avoids the entire family during the current content-policy failover chain. Evidence is
|
|
16
|
+
- Learns model-family content-policy constraints from real failures (`glm-4.5` and `GLM-4.6`, for example, are both `glm`) and avoids the entire family during the current content-policy failover chain. Evidence lives only in the owning extension instance, is never written to shared health state, and is cleared at that instance’s `session_start`, because a new session may handle tasks that are not content-sensitive, so the family becomes eligible again until this session observes its own content-policy rejection.
|
|
17
17
|
- Applies hard health, failure-domain, policy-family, input-modality, and context filters before ranking candidates by provider diversity, model equivalence, capability compatibility, and historical reliability.
|
|
18
18
|
- Independent-provider semantics: a copied vendor (e.g. `my-provider-copy`) shares the BaseURL but is a distinct provider with its own endpoint key, so isolating one never blocks the other.
|
|
19
19
|
- 90-second first-response and 120-second streaming-idle watchdogs.
|
|
@@ -96,7 +96,7 @@ pi-ccswitch-run --no-tools --no-context-files @prompt.md "Summarize the attached
|
|
|
96
96
|
|
|
97
97
|
The runner starts one `pi --mode rpc --no-session --no-extensions --extension <this package>/index.ts` process, so only CCSwitch is loaded. `PI_BIN` may point to the real Pi executable. For migration safety, an old `PI_BIN=pi-ccswitch-run` or `PI_BIN=ccswitch-run` value emits a warning and falls back to `pi` on `PATH` instead of recursing or terminating every downstream job. Arbitrary commands are still rejected. The default deadline is ten minutes and can be changed with `--timeout-ms`.
|
|
98
98
|
|
|
99
|
-
Exit codes are `0` for success, `1` when candidates are exhausted, `2` for invocation/RPC configuration failures, `124` for timeout, and `130`/`143` for interruption. Text `@files` are supported
|
|
99
|
+
Exit codes are `0` for success, `1` when candidates are exhausted, `2` for invocation/RPC configuration failures, `124` for timeout, and `130`/`143` for interruption. Text and PNG/JPEG/GIF/WebP `@files` are supported. Images are detected by signature and sent as native RPC image parts; invalid image files and other binary formats are rejected. Each attachment is limited to 20 MiB and a request to 20 images. Stdin is not supported.
|
|
100
100
|
|
|
101
101
|
## Commands
|
|
102
102
|
|
|
@@ -160,15 +160,18 @@ CCSwitch therefore prechecks **before the request is sent**, instead of waiting
|
|
|
160
160
|
|
|
161
161
|
- When user input carries images (`input` event with `images`), CCSwitch immediately switches to a healthy multimodal candidate (`input` explicitly includes `image`) before the request is processed, so the images are preserved;
|
|
162
162
|
- When a tool execution returns images (e.g. the `read` tool loading an image file, whose result content includes `image` parts), CCSwitch also switches to a multimodal model before the next LLM call, so the tool-result images are not stripped;
|
|
163
|
+
- Image requirements remain active for the entire failover round, including images returned by tools during retry verification and later fallback attempts;
|
|
163
164
|
- Only models that **explicitly** support images (`input` includes `image`) are considered; models with missing metadata are never assumed to support images;
|
|
164
165
|
- The switch reason is recorded as `modality` and counted in both the session and lifetime switch counters;
|
|
165
|
-
- If no multimodal candidate is available,
|
|
166
|
+
- If no multimodal candidate is available, RPC image input is handled with an exhausted terminal result before any image-omitted request is sent. Interactive TUI retains its existing notification behavior.
|
|
166
167
|
|
|
167
168
|
The modality precheck applies only in interactive TUI/RPC sessions (the same scope as failover); print/json modes only monitor and never switch.
|
|
168
169
|
|
|
169
170
|
## Data and privacy
|
|
170
171
|
|
|
171
|
-
Health state is stored in Pi's agent directory as `ccswitch-auto-switch-state.json`. The extension stores counters, timestamps, cooldowns,
|
|
172
|
+
Health state is stored in Pi's agent directory as `ccswitch-auto-switch-state.json`. The extension stores counters, timestamps, cooldowns, and redacted/truncated error summaries. Model-family policy constraints stay in session memory. It does not access credentials, authorization headers, CC Switch's database, or Pi's `auth.json`.
|
|
173
|
+
|
|
174
|
+
State schema 3 loads schema 2 health records and preserves their existing totals as a shared baseline. Per-writer counters merge independent increments without counting repeated merges twice. Older releases cannot read schema 3 and cannot be made safe by code in a newer process. **Do not run schema-2 and schema-3 releases together against the same directory.** Before upgrading: (1) stop every old Pi process and scheduled runner using the same `PI_CODING_AGENT_DIR`; (2) back up `ccswitch-auto-switch-state.json` while they are stopped; (3) upgrade every installation that uses that directory; (4) restart only the upgraded processes. A running current-version store refuses writes when it encounters an unsupported schema or observes a downgrade from 3 to 2, preserves the file, and logs the reason in `ccswitch-auto-switch.log`. This cannot undo damage already caused by old code; the stop/upgrade/restart sequence is required. Manual enable/disable operations use monotonic local timestamps; merge picks the newer operation, with disable winning an exact timestamp tie (including conflicting legacy values without timestamps). Reset barriers prevent stale snapshots from restoring cleared records. Counter metadata grows with the number of writer instances that record failures or switches.
|
|
172
175
|
|
|
173
176
|
## Development
|
|
174
177
|
|
|
@@ -178,7 +181,21 @@ npm run typecheck
|
|
|
178
181
|
npm test
|
|
179
182
|
```
|
|
180
183
|
|
|
181
|
-
Tests use Node's built-in test runner and cover the failure matrix, family-level policy avoidance, provider-first selection, input compatibility, cooldowns, state persistence, and Windows-compatible paths.
|
|
184
|
+
TypeScript is an explicit development dependency. `npm test` enables Node’s native TypeScript stripping and runs all `health`, `integration`, `logic`, and `runner` suites on Node 22.19+. Tests use Node's built-in test runner and cover the failure matrix, family-level policy avoidance, provider-first selection, input compatibility, cooldowns, state persistence, and Windows-compatible paths.
|
|
185
|
+
|
|
186
|
+
## Optional task complexity
|
|
187
|
+
|
|
188
|
+
`--task-complexity auto` estimates reasoning effort from the task instruction, input text length,
|
|
189
|
+
and image count. Short text uses low; structured summaries or images use medium; at least
|
|
190
|
+
20,000 characters, 4 images, or cross-source synthesis uses high. This is a deterministic estimate,
|
|
191
|
+
not a model-quality ranking. `low|medium|high` may also be supplied explicitly.
|
|
192
|
+
The selected model stays on Pi defaults unless CCSwitch needs a healthy/vision fallback.
|
|
193
|
+
Pi clamps thinking to model capabilities. An explicit `--thinking` wins; omitting task-complexity
|
|
194
|
+
preserves existing thinking behavior. The runner does not write global settings.
|
|
195
|
+
RPC image requests with no selectable image-capable model terminate with an exhausted result,
|
|
196
|
+
rather than silently sending an image-omitted text request. Interactive TUI behavior is unchanged.
|
|
197
|
+
|
|
198
|
+
Large images exceeding 1.5 MiB of base64 use the installed Pi image normalizer before RPC dispatch; originals are never modified. If Pi cannot normalize the image, the runner fails explicitly.
|
|
182
199
|
|
|
183
200
|
## License
|
|
184
201
|
|
package/README.zh-CN.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
- 对认证、额度、账单和限流实施 Provider 优先熔断。
|
|
13
13
|
- 对 DNS、连接、服务端和流中断实施端点熔断。
|
|
14
14
|
- 模型不存在或参数不兼容时仅隔离该模型。
|
|
15
|
-
- 内容审查失败会学习模型系列约束(例如 `glm-4.5`、`GLM-4.6` 同属 `glm
|
|
15
|
+
- 内容审查失败会学习模型系列约束(例如 `glm-4.5`、`GLM-4.6` 同属 `glm`),在本轮内容审查故障转移中避开整个受限系列。该约束仅保存在当前扩展实例的内存中,不写入共享健康状态;当前实例的 `session_start` 只清除自身约束,因为新 session 处理的任务不一定涉及审查内容,模型系列恢复可用,直到本 session 再次观察到内容审查拒绝。
|
|
16
16
|
- 候选模型先经过健康、故障域、审查系列、输入模态和上下文窗口硬过滤,再按跨 Provider、模型等价性、能力兼容和历史稳定性排序。
|
|
17
17
|
- 首响应 90 秒、流式停滞 120 秒看门狗。
|
|
18
18
|
- 指数退避、`Retry-After` 支持和跨进程 half-open 单探针租约。
|
|
@@ -90,7 +90,7 @@ cd ~/.pi/agent/npm && npm install pi-ccswitch-auto-switch@latest
|
|
|
90
90
|
pi-ccswitch-run --no-tools --no-context-files @prompt.md "请总结附件"
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
runner 内部只显式加载本扩展并启动一次 Pi RPC;`PI_BIN` 应指定内部真实 `pi`。为兼容旧配置,`PI_BIN=pi-ccswitch-run` 或 `PI_BIN=ccswitch-run` 会输出警告并降级到 `PATH` 中的 `pi`,避免递归和长驻消费方连续失败;其他任意命令仍会被拒绝。默认总超时为 10 分钟,可用 `--timeout-ms` 调整。退出码:成功 `0`、候选耗尽 `1`、参数/RPC 配置错误 `2`、超时 `124`、中断 `130/143
|
|
93
|
+
runner 内部只显式加载本扩展并启动一次 Pi RPC;`PI_BIN` 应指定内部真实 `pi`。为兼容旧配置,`PI_BIN=pi-ccswitch-run` 或 `PI_BIN=ccswitch-run` 会输出警告并降级到 `PATH` 中的 `pi`,避免递归和长驻消费方连续失败;其他任意命令仍会被拒绝。默认总超时为 10 分钟,可用 `--timeout-ms` 调整。退出码:成功 `0`、候选耗尽 `1`、参数/RPC 配置错误 `2`、超时 `124`、中断 `130/143`。支持文本及 PNG/JPEG/GIF/WebP `@文件`,按签名识别图片并传入 RPC images;损坏/不支持的图片及其他二进制附件明确拒绝。单附件上限 20 MiB,最多 20 张图片;不支持 stdin。
|
|
94
94
|
|
|
95
95
|
正常使用 CC Switch 配置 Provider 即可;本插件不会修改 Pi 模型设置或 CC Switch 数据。
|
|
96
96
|
|
|
@@ -138,7 +138,7 @@ CCS v0.3.5 ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
|
|
|
138
138
|
| 内容审查/敏感拦截 | 当前模型 + 模型系列约束 | 模型冷却 2 分钟;系列约束按 session 隔离(`session_start` 时清除) |
|
|
139
139
|
| 上下文溢出 | 仅本轮 | 无 |
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
冷却时间会指数增长但有上限。发生内容审查时,插件只在审查故障转移链中避开已标记系列,普通限流、网络或模型配置故障仍可选择这些模型。上下文溢出时只会选择上下文窗口更大的模型;带图片的请求只选择显式声明支持 image 的模型。无法归类的异常按单模型故障处理并正常切换。用户主动取消是唯一不会触发故障转移的异常终止;看门狗取消会记录为超时。
|
|
142
142
|
|
|
143
143
|
### 模态预检(OCR / 图片请求自动切换多模态模型)
|
|
144
144
|
|
|
@@ -149,24 +149,35 @@ Pi 在把请求发给 Provider 时,会根据当前模型的 `input` 能力静
|
|
|
149
149
|
- **用户输入带图片时**(`input` 事件含 `images`),如果当前模型不支持图片,立即切换到健康的多模态候选(`input` 明确含 `image`)再处理,图片被保留;
|
|
150
150
|
- **工具执行返回图片时**(如 `read` 工具读取图片文件,工具结果含 `image` content),同样在下一轮 LLM 调用前切到多模态模型,确保图片不被剥除;
|
|
151
151
|
- 候选只选**明确声明**支持图片的模型(`input` 含 `image`);元数据缺失的模型不冒险选择;
|
|
152
|
+
- 图片能力要求贯穿整个故障转移轮次,包括重试验证阶段工具返回图片以及此后的再次切换;
|
|
152
153
|
- 切换原因记为 `modality`,计入本 session 切换数与累计切换;
|
|
153
|
-
-
|
|
154
|
+
- 如果没有可用的多模态候选,RPC 图片输入会返回 exhausted 终态并在发出剥图请求前停止;交互式 TUI 保持原有通知行为。
|
|
154
155
|
|
|
155
156
|
模态预检只在 TUI/RPC 交互模式下生效(与故障转移一致);print/json 模式只监控不切换。
|
|
156
157
|
|
|
157
158
|
## 数据与隐私
|
|
158
159
|
|
|
159
|
-
健康状态保存在 Pi agent 目录的 `ccswitch-auto-switch-state.json
|
|
160
|
+
健康状态保存在 Pi agent 目录的 `ccswitch-auto-switch-state.json`。其中只有计数、时间、冷却信息和脱敏/截断的错误摘要;模型系列审查约束只保存在 session 内存中;插件不会访问凭据、Authorization 请求头、CC Switch 数据库或 Pi 的 `auth.json`。
|
|
160
161
|
|
|
161
162
|
## 开发
|
|
162
163
|
|
|
164
|
+
状态 schema 3 兼容加载 schema 2 的健康记录,将原有累计数保留为共同基线;每个写入实例独立计数,合并时不丢增量、不重复累计。旧版本无法读取 schema 3,新进程中的代码无法修复仍在运行的旧代码。**禁止 schema-2 与 schema-3 版本共用同一目录混跑。**升级时必须依次:① 退出所有使用同一 `PI_CODING_AGENT_DIR` 的旧 Pi 进程并停止相关定时 runner;② 在进程停止后备份 `ccswitch-auto-switch-state.json`;③ 升级使用该目录的所有安装;④ 仅启动升级后的进程。当前版本遇到不支持的 schema,或已观察到 schema 3 后又读到 schema 2 时,会拒绝写入、保留原文件,并在 `ccswitch-auto-switch.log` 记录原因。这不能撤销旧代码已经造成的破坏,因此必须执行上述停机升级步骤。人工启用/禁用按本地单调时间戳选择较新操作;时间戳完全相同时保守保留禁用(旧记录缺字段时也如此)。reset 标记阻止旧快照恢复已清除的记录。计数元数据随实际记录失败或切换的实例数增长。
|
|
165
|
+
|
|
163
166
|
```bash
|
|
164
167
|
npm install
|
|
165
168
|
npm run typecheck
|
|
166
169
|
npm test
|
|
167
170
|
```
|
|
168
171
|
|
|
169
|
-
|
|
172
|
+
TypeScript 已显式列为开发依赖。`npm test` 开启 Node 原生 TypeScript 类型剥离,在 Node 22.19+ 上运行 `health`、`integration`、`logic`、`runner` 全部测试。测试使用 Node 内置测试运行器,覆盖失败分类矩阵、系列级审查避让、Provider 优先选择、输入兼容、冷却、状态持久化以及 Windows 路径。
|
|
173
|
+
|
|
174
|
+
## 可选任务复杂度
|
|
175
|
+
|
|
176
|
+
`--task-complexity auto` 根据任务指令、输入文字长度和图片数量估计推理强度:短文本使用 low;结构化摘要或图片使用 medium;至少 20,000 个字符、4 张图片或跨来源综合分析使用 high。也可显式指定 `low|medium|high`。这是确定性的任务估计,不是模型质量排名。
|
|
177
|
+
|
|
178
|
+
默认沿用 Pi 当前模型;需要健康或视觉候选时由 CCSwitch 切换。Pi 会按模型能力限制推理强度。显式 `--thinking` 优先;省略 `--task-complexity` 时保留现有推理行为。runner 不写入全局设置。
|
|
179
|
+
|
|
180
|
+
RPC 图片请求没有可选的视觉模型时返回 exhausted 终态,避免发送丢失图片的请求。交互式 TUI 行为保持不变。超过 1.5 MiB base64 的大图会通过已安装 Pi 的图片处理功能压缩后传入 RPC,保留原图;处理失败时明确报错。
|
|
170
181
|
|
|
171
182
|
## 许可证
|
|
172
183
|
|
package/candidates.ts
CHANGED
|
@@ -131,7 +131,9 @@ export function modelFamily(model: Pick<ModelRef, 'id' | 'name'>): string {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
function supportsRequiredInputs(candidate: ModelRef, required: readonly string[] | undefined): boolean {
|
|
134
|
-
if (!required?.length
|
|
134
|
+
if (!required?.length) return true
|
|
135
|
+
if (required.includes('image') && !candidate.input?.includes('image')) return false
|
|
136
|
+
if (!candidate.input?.length) return true
|
|
135
137
|
return required.every(input => candidate.input!.includes(input))
|
|
136
138
|
}
|
|
137
139
|
|
package/classify.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface Classification {
|
|
|
10
10
|
const content = /content[ _-]?(?:filter|policy|blocked|moderation)|sensitive|guardrail|policy[_ -]?violation|responsibleai|safety[_ -]?(?:policy|filter|violation)|security[_ -]?policy|moderation[_ -]?(?:blocked|failed)|blocked by.{0,20}(?:policy|safety|moderation)|data[_ -]?inspection[_ -]?failed|内容(?:审查|审核|安全|违规|被拦截)|安全审核|安全(?:策略|风控).{0,12}(?:阻断|拦截|拒绝)|审核(?:未通过|不通过|失败)|敏感(?:内容|词|信息)?|政治敏感|涉政|不合规|违反.{0,12}(?:安全|政策|规定)/i
|
|
11
11
|
// 配额/余额类失败(scope provider)。含中文供应商报错:用户积分不足、余额不足、欠费等,
|
|
12
12
|
// 这些通常不携带英文关键词,漏分类会落入 unknown(仅 2min 模型级冷却),导致死 provider 每轮重试。
|
|
13
|
-
const quota = /quota|billing|insufficient.?balance|out of budget|usage.?limit|credit.?balance|monthly.?limit|积分不足|余额不足|欠费|请充值|额度.{0,6}(?:用完|耗尽|不足|已用)|无可用额度/i
|
|
13
|
+
const quota = /quota|billing|insufficient.?balance|out of budget|usage.?limit|credit.?balance|balance.{0,12}(?:exhausted|insufficient|depleted)|credits|monthly.?limit|积分不足|余额不足|欠费|请充值|额度.{0,6}(?:用完|耗尽|不足|已用)|无可用额度/i
|
|
14
14
|
const context = /context.?window|context.?length|too many tokens|prompt is too long|input is too long|token limit/i
|
|
15
15
|
const transport = /dns|enotfound|eai_again|econn(?:reset|refused|aborted)|epipe|socket|connection|network|fetch failed|tls|certificate|timed? ?out|timeout|stream ended|terminated|websocket|und_err/i
|
|
16
16
|
const overloaded = /overload|server busy|service unavailable|temporarily unavailable|capacity|upstream|bad gateway/i
|
|
@@ -27,6 +27,7 @@ export function classifyFailure(observation: FailureObservation): Classification
|
|
|
27
27
|
if (status === 413) return { kind: 'context_overflow', roundOnly: true }
|
|
28
28
|
if (status === 429) return { kind: 'rate_limit', scope: 'provider', roundOnly: false, retryAfterMs: observation.retryAfterMs }
|
|
29
29
|
if (status === 402) return { kind: 'quota', scope: 'provider', roundOnly: false }
|
|
30
|
+
if (status === 403 && quota.test(text)) return { kind: 'quota', scope: 'provider', roundOnly: false }
|
|
30
31
|
if (status === 401 || status === 403) return { kind: 'auth', scope: 'provider', roundOnly: false }
|
|
31
32
|
if (quota.test(text)) return { kind: 'quota', scope: 'provider', roundOnly: false }
|
|
32
33
|
if (status === 408) return { kind: 'timeout', scope: 'endpoint', roundOnly: false }
|
package/health.ts
CHANGED
|
@@ -15,12 +15,15 @@ const MAX_LOG_BYTES = 512 * 1024
|
|
|
15
15
|
export interface HealthRecord {
|
|
16
16
|
consecutiveFailures: number
|
|
17
17
|
totalFailures: number
|
|
18
|
+
/** Per-writer grow-only counters; legacy totals form one shared baseline. */
|
|
19
|
+
failureCounts?: Record<string, number>
|
|
18
20
|
lastFailureAt?: number
|
|
19
21
|
lastSuccessAt?: number
|
|
20
22
|
cooldownUntil?: number
|
|
21
23
|
lastClass?: FailureClass
|
|
22
24
|
lastError?: string
|
|
23
25
|
disabled?: boolean
|
|
26
|
+
disabledUpdatedAt?: number
|
|
24
27
|
leaseUntil?: number
|
|
25
28
|
}
|
|
26
29
|
|
|
@@ -33,18 +36,22 @@ export interface ContentPolicyConstraint {
|
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export interface HealthState {
|
|
36
|
-
schemaVersion: 2
|
|
39
|
+
schemaVersion: 2 | 3
|
|
37
40
|
updatedAt: number
|
|
41
|
+
/** Reset barriers prevent older snapshots from resurrecting cleared history. */
|
|
42
|
+
resetAt?: number
|
|
43
|
+
modelResetAt?: Record<string, number>
|
|
38
44
|
models: Record<string, HealthRecord>
|
|
39
45
|
providers: Record<string, HealthRecord>
|
|
40
46
|
endpoints: Record<string, HealthRecord>
|
|
41
47
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
48
|
+
* Legacy schema-2 field: ignored on load and removed on the next write.
|
|
49
|
+
* Active policy constraints live only in the owning HealthStore instance.
|
|
44
50
|
*/
|
|
45
51
|
contentPolicyFamilies?: Record<string, ContentPolicyConstraint>
|
|
46
52
|
/** 累计成功切换次数(跨 session 持久化,用于衡量扩展有效程度) */
|
|
47
53
|
switches?: number
|
|
54
|
+
switchCounts?: Record<string, number>
|
|
48
55
|
/** 最近成功切换日志(有限条,环形保留) */
|
|
49
56
|
switchLog?: Array<{
|
|
50
57
|
at: number
|
|
@@ -87,7 +94,7 @@ function safeEndpoint(value: string): string {
|
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
function blank(): HealthState {
|
|
90
|
-
return { schemaVersion:
|
|
97
|
+
return { schemaVersion: 3, updatedAt: Date.now(), models: {}, providers: {}, endpoints: {}, switches: 0, switchLog: [] }
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
function redact(text: string | undefined): string | undefined {
|
|
@@ -113,30 +120,29 @@ export class HealthStore {
|
|
|
113
120
|
readonly dir: string
|
|
114
121
|
private state: HealthState = blank()
|
|
115
122
|
private dirty = false
|
|
116
|
-
private
|
|
117
|
-
private
|
|
118
|
-
|
|
119
|
-
private
|
|
123
|
+
private readonly writer = randomUUID()
|
|
124
|
+
private manualClock = 0
|
|
125
|
+
private writeBlocked = false
|
|
126
|
+
private observedSchema3 = false
|
|
127
|
+
private constraints: Record<string, ContentPolicyConstraint> = {}
|
|
120
128
|
|
|
121
129
|
constructor(dir = agentDir()) { this.dir = dir }
|
|
122
130
|
get file(): string { return join(this.dir, STATE_FILE) }
|
|
123
131
|
get snapshot(): HealthState { return structuredClone(this.state) }
|
|
132
|
+
get policyConstraints(): Record<string, ContentPolicyConstraint> { return structuredClone(this.constraints) }
|
|
124
133
|
|
|
125
134
|
async load(): Promise<void> {
|
|
126
135
|
await mkdir(this.dir, { recursive: true })
|
|
127
136
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
this.state = blank()
|
|
138
|
-
this.dirty = true
|
|
139
|
-
await this.flush()
|
|
137
|
+
this.state = await this.readDisk()
|
|
138
|
+
this.writeBlocked = false
|
|
139
|
+
delete this.state.contentPolicyFamilies
|
|
140
|
+
if (!existsSync(this.file)) await this.migrateLegacy()
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// Unknown/newer formats are not corruption. Keep the original in place
|
|
143
|
+
// and fail closed until an explicit successful reload repairs the state.
|
|
144
|
+
this.writeBlocked = true
|
|
145
|
+
await this.log(`health state load refused: ${String(error)}`)
|
|
140
146
|
}
|
|
141
147
|
}
|
|
142
148
|
|
|
@@ -163,6 +169,7 @@ export class HealthStore {
|
|
|
163
169
|
const failures = previous.consecutiveFailures + 1
|
|
164
170
|
bucket[key] = {
|
|
165
171
|
...previous, consecutiveFailures: failures, totalFailures: previous.totalFailures + 1,
|
|
172
|
+
failureCounts: incrementCounts(previous.failureCounts, previous.totalFailures, this.writer),
|
|
166
173
|
lastFailureAt: Date.now(), cooldownUntil: Date.now() + cooldownMs(kind, failures, retryAfterMs),
|
|
167
174
|
lastClass: kind, lastError: redact(message), leaseUntil: undefined,
|
|
168
175
|
}
|
|
@@ -175,7 +182,7 @@ export class HealthStore {
|
|
|
175
182
|
* 本 session 内再次观察到同系列审查会增加证据计数并刷新时间戳。
|
|
176
183
|
*/
|
|
177
184
|
recordContentPolicyConstraint(family: string, model: ModelRef, message?: string): void {
|
|
178
|
-
const constraints = this.
|
|
185
|
+
const constraints = this.constraints
|
|
179
186
|
const previous = constraints[family]
|
|
180
187
|
const now = Date.now()
|
|
181
188
|
constraints[family] = {
|
|
@@ -185,7 +192,6 @@ export class HealthStore {
|
|
|
185
192
|
lastModel: modelKey(model),
|
|
186
193
|
lastError: redact(message),
|
|
187
194
|
}
|
|
188
|
-
this.touch()
|
|
189
195
|
}
|
|
190
196
|
|
|
191
197
|
recordSuccess(model: ModelRef): void {
|
|
@@ -200,6 +206,7 @@ export class HealthStore {
|
|
|
200
206
|
* 用于衡量扩展有效程度,并在 /ccswitch status 面板展示。
|
|
201
207
|
*/
|
|
202
208
|
recordSwitch(from: string, to: string, reason?: string): void {
|
|
209
|
+
this.state.switchCounts = incrementCounts(this.state.switchCounts, this.state.switches ?? 0, this.writer)
|
|
203
210
|
this.state.switches = (this.state.switches ?? 0) + 1
|
|
204
211
|
const log = this.state.switchLog ?? []
|
|
205
212
|
log.push({ at: Date.now(), from, to, reason })
|
|
@@ -215,35 +222,53 @@ export class HealthStore {
|
|
|
215
222
|
|
|
216
223
|
disable(key: string, disabled: boolean): void {
|
|
217
224
|
const old = this.state.models[key] ?? { consecutiveFailures: 0, totalFailures: 0 }
|
|
218
|
-
this.state.models[key] = { ...old, disabled }
|
|
225
|
+
this.state.models[key] = { ...old, disabled, disabledUpdatedAt: this.manualVersion() }
|
|
219
226
|
this.touch()
|
|
220
227
|
}
|
|
221
228
|
|
|
222
|
-
reactivate(model: ModelRef):
|
|
223
|
-
|
|
224
|
-
for (const [scope, id] of [['model', key], ['provider', model.provider], ['endpoint', endpointKey(model)]] as const) {
|
|
225
|
-
const old = this.bucket(scope)[id]
|
|
226
|
-
if (old) this.bucket(scope)[id] = { ...old, disabled: false, cooldownUntil: undefined, leaseUntil: undefined, lastSuccessAt: Date.now(), consecutiveFailures: 0 }
|
|
227
|
-
}
|
|
228
|
-
this.touch()
|
|
229
|
+
async reactivate(model: ModelRef): Promise<boolean> {
|
|
230
|
+
return this.reactivateAtomically(model)
|
|
229
231
|
}
|
|
230
232
|
|
|
231
|
-
reactivateAll():
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
async reactivateAll(): Promise<boolean> {
|
|
234
|
+
return this.reactivateAtomically()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async reactivateAtomically(model?: ModelRef): Promise<boolean> {
|
|
238
|
+
try {
|
|
239
|
+
if (this.writeBlocked) throw new Error('health state writes are blocked; reload a supported state first')
|
|
240
|
+
return await this.withLock(async () => {
|
|
241
|
+
this.state = mergeState(await this.readDisk(), this.state)
|
|
242
|
+
const disabledUpdatedAt = this.manualVersion()
|
|
243
|
+
const targets: Array<[HealthScope, string]> = model
|
|
244
|
+
? [['model', modelKey(model)], ['provider', model.provider], ['endpoint', endpointKey(model)]]
|
|
245
|
+
: (['model', 'provider', 'endpoint'] as const).flatMap(scope => Object.keys(this.bucket(scope)).map(key => [scope, key] as [HealthScope, string]))
|
|
246
|
+
for (const [scope, key] of targets) {
|
|
247
|
+
const old = this.bucket(scope)[key]
|
|
248
|
+
if (!old) continue
|
|
249
|
+
this.bucket(scope)[key] = { ...old, disabled: false, disabledUpdatedAt,
|
|
250
|
+
cooldownUntil: undefined, leaseUntil: undefined, consecutiveFailures: 0,
|
|
251
|
+
lastSuccessAt: Math.max(Date.now(), (old.lastFailureAt ?? 0) + 1) }
|
|
252
|
+
}
|
|
253
|
+
this.touch()
|
|
254
|
+
await this.commit()
|
|
255
|
+
return true
|
|
256
|
+
})
|
|
257
|
+
} catch (error) {
|
|
258
|
+
await this.log(`reactivation refused: ${String(error)}`)
|
|
259
|
+
return false
|
|
235
260
|
}
|
|
236
|
-
this.touch()
|
|
237
261
|
}
|
|
238
262
|
|
|
239
263
|
reset(target: string | 'all'): void {
|
|
264
|
+
const resetAt = this.manualVersion()
|
|
240
265
|
if (target === 'all') {
|
|
241
|
-
this.
|
|
242
|
-
this.
|
|
243
|
-
this.resetModels.clear()
|
|
266
|
+
this.clearContentPolicyConstraints()
|
|
267
|
+
this.state = { ...blank(), resetAt }
|
|
244
268
|
} else {
|
|
245
269
|
delete this.state.models[target]
|
|
246
|
-
this.
|
|
270
|
+
const resets = this.state.modelResetAt ??= {}
|
|
271
|
+
resets[target] = resetAt
|
|
247
272
|
}
|
|
248
273
|
this.touch()
|
|
249
274
|
}
|
|
@@ -253,40 +278,44 @@ export class HealthStore {
|
|
|
253
278
|
* 对审查内容敏感,因此不在 session 间继承审查约束。
|
|
254
279
|
*/
|
|
255
280
|
clearContentPolicyConstraints(): void {
|
|
256
|
-
this.
|
|
257
|
-
this.clearContentPolicyOnFlush = true
|
|
258
|
-
this.touch()
|
|
281
|
+
this.constraints = {}
|
|
259
282
|
}
|
|
260
283
|
|
|
261
284
|
isBlocked(model: ModelRef, now = Date.now()): boolean {
|
|
285
|
+
if (this.writeBlocked) return true
|
|
262
286
|
return [this.state.models[modelKey(model)], this.state.providers[model.provider], this.state.endpoints[endpointKey(model)]]
|
|
263
287
|
.some(record => Boolean(record?.disabled || (record?.cooldownUntil && record.cooldownUntil > now) || (record?.leaseUntil && record.leaseUntil > now)))
|
|
264
288
|
}
|
|
265
289
|
|
|
266
|
-
async
|
|
267
|
-
|
|
268
|
-
let claimed =
|
|
290
|
+
async claimCandidate(model: ModelRef): Promise<boolean> {
|
|
291
|
+
if (this.writeBlocked) return false
|
|
292
|
+
let claimed = false
|
|
269
293
|
await this.withLock(async () => {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
this.
|
|
276
|
-
|
|
277
|
-
|
|
294
|
+
const disk = await this.readDisk()
|
|
295
|
+
this.state = mergeState(disk, this.state)
|
|
296
|
+
const records = (state: HealthState) => [state.models[modelKey(model)], state.providers[model.provider], state.endpoints[endpointKey(model)]]
|
|
297
|
+
const now = Date.now()
|
|
298
|
+
// Check disk as well: a stale local disabled flag must never override a
|
|
299
|
+
// disable written by another process before this atomic claim.
|
|
300
|
+
if ([...records(disk), ...records(this.state)].some(record => record?.disabled || (record?.cooldownUntil ?? 0) > now || (record?.leaseUntil ?? 0) > now)) return
|
|
301
|
+
const expired = records(this.state).filter(record => record?.cooldownUntil !== undefined && record.cooldownUntil <= now)
|
|
302
|
+
if (expired.length) {
|
|
303
|
+
for (const record of expired) record!.leaseUntil = now + 2 * 60_000
|
|
304
|
+
this.touch()
|
|
305
|
+
await this.commit()
|
|
306
|
+
}
|
|
307
|
+
claimed = true
|
|
308
|
+
}).catch(async error => { claimed = false; await this.log(`candidate claim refused: ${String(error)}`) })
|
|
278
309
|
return claimed
|
|
279
310
|
}
|
|
280
311
|
|
|
281
312
|
async flush(): Promise<void> {
|
|
282
|
-
if (!this.dirty) return
|
|
313
|
+
if (!this.dirty || this.writeBlocked) return
|
|
283
314
|
await this.withLock(async () => {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
// 必须在 merge 之后再清空:磁盘上残留的旧 session 约束不能因合并而重新出现
|
|
287
|
-
if (this.clearContentPolicyOnFlush) this.state.contentPolicyFamilies = {}
|
|
315
|
+
// Even reset-all must validate the on-disk schema before writing.
|
|
316
|
+
this.state = mergeState(await this.readDisk(), this.state)
|
|
288
317
|
await this.commit()
|
|
289
|
-
}).catch(
|
|
318
|
+
}).catch(async error => { await this.log(`health state flush refused: ${String(error)}`) })
|
|
290
319
|
}
|
|
291
320
|
|
|
292
321
|
async log(line: string): Promise<void> {
|
|
@@ -310,20 +339,39 @@ export class HealthStore {
|
|
|
310
339
|
private touch(): void { this.dirty = true }
|
|
311
340
|
private async readDisk(): Promise<HealthState> {
|
|
312
341
|
try {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
342
|
+
let raw: string
|
|
343
|
+
try { raw = await readFile(this.file, 'utf8') }
|
|
344
|
+
catch (error: any) { if (error?.code === 'ENOENT') return blank(); throw error }
|
|
345
|
+
const parsed = JSON.parse(raw) as HealthState
|
|
346
|
+
if (!parsed || (parsed.schemaVersion !== 2 && parsed.schemaVersion !== 3)) throw new Error(`unsupported state schema: ${parsed?.schemaVersion}`)
|
|
347
|
+
if (this.observedSchema3 && parsed.schemaVersion === 2) throw new Error('unsupported state schema downgrade: 3 -> 2; stop all old Pi processes')
|
|
348
|
+
if (!parsed.models || !parsed.providers || !parsed.endpoints) throw new Error('invalid health state buckets')
|
|
349
|
+
this.observedSchema3 ||= parsed.schemaVersion === 3
|
|
350
|
+
return parsed
|
|
351
|
+
} catch (error) {
|
|
352
|
+
this.writeBlocked = true
|
|
353
|
+
throw error
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private manualVersion(): number {
|
|
358
|
+
const records = [...Object.values(this.state.models), ...Object.values(this.state.providers), ...Object.values(this.state.endpoints)]
|
|
359
|
+
this.manualClock = Math.max(Date.now(), this.manualClock + 1, (this.state.resetAt ?? 0) + 1,
|
|
360
|
+
...Object.values(this.state.modelResetAt ?? {}).map(at => at + 1),
|
|
361
|
+
...records.map(record => (record.disabledUpdatedAt ?? 0) + 1))
|
|
362
|
+
return this.manualClock
|
|
317
363
|
}
|
|
364
|
+
|
|
318
365
|
private async commit(): Promise<void> {
|
|
366
|
+
if (this.writeBlocked) throw new Error("health state writes are blocked; reload a supported state first")
|
|
367
|
+
this.state.schemaVersion = 3
|
|
368
|
+
delete this.state.contentPolicyFamilies
|
|
319
369
|
this.state.updatedAt = Date.now()
|
|
320
370
|
const temp = `${this.file}.tmp-${process.pid}-${randomUUID()}`
|
|
321
371
|
await writeFile(temp, JSON.stringify(this.state, null, 2), 'utf8')
|
|
322
372
|
await rename(temp, this.file)
|
|
373
|
+
this.observedSchema3 = true
|
|
323
374
|
this.dirty = false
|
|
324
|
-
this.replaceOnFlush = false
|
|
325
|
-
this.resetModels.clear()
|
|
326
|
-
this.clearContentPolicyOnFlush = false
|
|
327
375
|
}
|
|
328
376
|
private bucket(scope: HealthScope): Record<string, HealthRecord> {
|
|
329
377
|
return scope === 'model' ? this.state.models : scope === 'provider' ? this.state.providers : this.state.endpoints
|
|
@@ -351,10 +399,15 @@ function mergeRecord(a: HealthRecord | undefined, b: HealthRecord | undefined):
|
|
|
351
399
|
const lastFailureAt = Math.max(a.lastFailureAt ?? 0, b.lastFailureAt ?? 0)
|
|
352
400
|
const lastSuccessAt = Math.max(a.lastSuccessAt ?? 0, b.lastSuccessAt ?? 0)
|
|
353
401
|
const successWins = lastSuccessAt > lastFailureAt
|
|
402
|
+
const failureCounts = mergeCounts(a.failureCounts, a.totalFailures, b.failureCounts, b.totalFailures)
|
|
354
403
|
return {
|
|
355
404
|
...newest,
|
|
356
|
-
totalFailures:
|
|
357
|
-
|
|
405
|
+
totalFailures: sumCounts(failureCounts),
|
|
406
|
+
failureCounts,
|
|
407
|
+
disabled: (a.disabledUpdatedAt ?? 0) === (b.disabledUpdatedAt ?? 0)
|
|
408
|
+
? (a.disabled === true || b.disabled === true ? true : b.disabled ?? a.disabled)
|
|
409
|
+
: (a.disabledUpdatedAt ?? 0) > (b.disabledUpdatedAt ?? 0) ? a.disabled : b.disabled,
|
|
410
|
+
disabledUpdatedAt: Math.max(a.disabledUpdatedAt ?? 0, b.disabledUpdatedAt ?? 0) || undefined,
|
|
358
411
|
lastFailureAt: lastFailureAt || undefined,
|
|
359
412
|
lastSuccessAt: lastSuccessAt || undefined,
|
|
360
413
|
consecutiveFailures: successWins ? 0 : Math.max(a.consecutiveFailures, b.consecutiveFailures),
|
|
@@ -373,26 +426,59 @@ function mergeBucket(a: Record<string, HealthRecord>, b: Record<string, HealthRe
|
|
|
373
426
|
}
|
|
374
427
|
|
|
375
428
|
function mergeState(a: HealthState, b: HealthState): HealthState {
|
|
376
|
-
const
|
|
429
|
+
const resetAt = Math.max(a.resetAt ?? 0, b.resetAt ?? 0)
|
|
430
|
+
a = afterGlobalReset(a, resetAt)
|
|
431
|
+
b = afterGlobalReset(b, resetAt)
|
|
432
|
+
const modelResetAt = { ...a.modelResetAt }
|
|
433
|
+
for (const [key, at] of Object.entries(b.modelResetAt ?? {})) modelResetAt[key] = Math.max(modelResetAt[key] ?? 0, at)
|
|
434
|
+
const models = mergeBucket(a.models, b.models)
|
|
435
|
+
for (const [key, at] of Object.entries(modelResetAt)) {
|
|
436
|
+
const record = mergeRecord(afterReset(a.models[key], a.modelResetAt?.[key] ?? 0, at), afterReset(b.models[key], b.modelResetAt?.[key] ?? 0, at))
|
|
437
|
+
if (record) models[key] = record
|
|
438
|
+
else delete models[key]
|
|
439
|
+
}
|
|
440
|
+
const switchCounts = mergeCounts(a.switchCounts, a.switches ?? 0, b.switchCounts, b.switches ?? 0)
|
|
441
|
+
const switches = sumCounts(switchCounts)
|
|
377
442
|
// 取最近更新的 switchLog(按 at 去倒序合并,保留最新 20 条)
|
|
378
443
|
const log = [...(a.switchLog ?? []), ...(b.switchLog ?? [])]
|
|
379
444
|
.sort((x, y) => y.at - x.at)
|
|
380
445
|
.filter((entry, index, all) => index === 0 || all[index - 1].at !== entry.at || all[index - 1].from !== entry.from || all[index - 1].to !== entry.to)
|
|
381
446
|
.slice(0, 20)
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
447
|
+
return { schemaVersion: 3, resetAt: resetAt || undefined, modelResetAt, updatedAt: Math.max(a.updatedAt ?? 0, b.updatedAt ?? 0), models, providers: mergeBucket(a.providers ?? {}, b.providers ?? {}), endpoints: mergeBucket(a.endpoints ?? {}, b.endpoints ?? {}), switchCounts, switches, switchLog: log }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Each store increments only its own component. Merging the same state twice is
|
|
451
|
+
// idempotent; independent increments add without inflating the backoff streak.
|
|
452
|
+
function mergeCounts(a: Record<string, number> | undefined, aTotal: number, b: Record<string, number> | undefined, bTotal: number): Record<string, number> {
|
|
453
|
+
const left = a ?? { legacy: aTotal }, right = b ?? { legacy: bTotal }
|
|
454
|
+
const result = { ...left }
|
|
455
|
+
for (const [writer, count] of Object.entries(right)) result[writer] = Math.max(result[writer] ?? 0, count)
|
|
456
|
+
return result
|
|
457
|
+
}
|
|
458
|
+
function incrementCounts(counts: Record<string, number> | undefined, total: number, writer: string): Record<string, number> {
|
|
459
|
+
const next = { ...(counts ?? { legacy: total }) }
|
|
460
|
+
next[writer] = (next[writer] ?? 0) + 1
|
|
461
|
+
return next
|
|
462
|
+
}
|
|
463
|
+
function sumCounts(counts: Record<string, number>): number {
|
|
464
|
+
return Object.values(counts).reduce((sum, count) => sum + count, 0)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function afterReset(record: HealthRecord | undefined, sourceResetAt: number, resetAt: number): HealthRecord | undefined {
|
|
468
|
+
if (sourceResetAt >= resetAt || !record) return record
|
|
469
|
+
// A manual operation newer than the reset survives, but old health history
|
|
470
|
+
// does not. Legacy records have timestamp zero and cannot beat a reset.
|
|
471
|
+
if ((record.disabledUpdatedAt ?? 0) > resetAt) return { consecutiveFailures: 0, totalFailures: 0, disabled: record.disabled, disabledUpdatedAt: record.disabledUpdatedAt }
|
|
472
|
+
return undefined
|
|
473
|
+
}
|
|
474
|
+
function afterGlobalReset(state: HealthState, resetAt: number): HealthState {
|
|
475
|
+
if ((state.resetAt ?? 0) >= resetAt) return state
|
|
476
|
+
const clean = { ...blank(), resetAt }
|
|
477
|
+
for (const scope of ['models', 'providers', 'endpoints'] as const) {
|
|
478
|
+
for (const [key, record] of Object.entries(state[scope])) {
|
|
479
|
+
const kept = afterReset(record, state.resetAt ?? 0, resetAt)
|
|
480
|
+
if (kept) clean[scope][key] = kept
|
|
395
481
|
}
|
|
396
482
|
}
|
|
397
|
-
return
|
|
483
|
+
return clean
|
|
398
484
|
}
|
package/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
6
6
|
import type { ExtensionAPI, ExtensionContext, FailureObservation, ModelRef } from './types.ts'
|
|
7
7
|
import { classifyFailure, parseRetryAfter } from './classify.ts'
|
|
8
8
|
import { candidateSnapshot, effectiveCandidates, chooseCandidate, multimodalCandidates, modelFamily, summarizeCandidateHealth } from './candidates.ts'
|
|
9
|
-
import { HealthStore, endpointKey, modelKey, platformKey, type
|
|
9
|
+
import { HealthStore, endpointKey, modelKey, platformKey, type ContentPolicyConstraint } from './health.ts'
|
|
10
10
|
|
|
11
11
|
const FIRST_RESPONSE_TIMEOUT = 90_000
|
|
12
12
|
const STREAM_IDLE_TIMEOUT = 120_000
|
|
@@ -17,7 +17,7 @@ const roundLimitMs = (): number => {
|
|
|
17
17
|
return Number.isFinite(override) && override > 0 ? override : ROUND_LIMIT_DEFAULT
|
|
18
18
|
}
|
|
19
19
|
const RPC_PROTOCOL_VERSION = 1
|
|
20
|
-
const EXTENSION_VERSION = '0.3.
|
|
20
|
+
const EXTENSION_VERSION = '0.3.13'
|
|
21
21
|
// 同端点(BaseURL 相同)连续失败达到该次数即隔离该端点,避免同一个平台的多个模型逐个试错耗尽本轮切换
|
|
22
22
|
const ENDPOINT_FAIL_THRESHOLD = 3
|
|
23
23
|
|
|
@@ -33,6 +33,8 @@ interface Round {
|
|
|
33
33
|
startedAt: number
|
|
34
34
|
text: string
|
|
35
35
|
images?: Array<Record<string, unknown>>
|
|
36
|
+
needsImage: boolean
|
|
37
|
+
modalitySwitch?: Promise<void>
|
|
36
38
|
tried: Set<string>
|
|
37
39
|
attempts: number
|
|
38
40
|
hadTool: boolean
|
|
@@ -43,7 +45,7 @@ interface Round {
|
|
|
43
45
|
observation?: FailureObservation
|
|
44
46
|
model?: ModelRef
|
|
45
47
|
endpointFails?: EndpointFailTracker
|
|
46
|
-
/**
|
|
48
|
+
/** 本轮内容审查故障转移中必须避开的模型系列(包含本 session 学到的约束)。 */
|
|
47
49
|
avoidFamilies?: Set<string>
|
|
48
50
|
/** 本轮内最后一次成功切换的时间;用于刷新 ROUND_LIMIT 窗口,避免供应商内部重试耗时导致误判“超过本轮时间限制” */
|
|
49
51
|
lastSwitchAt?: number
|
|
@@ -53,8 +55,8 @@ interface Round {
|
|
|
53
55
|
|
|
54
56
|
function key(model: ModelRef | undefined): string | undefined { return model && modelKey(model) }
|
|
55
57
|
function canRetry(ctx: ExtensionContext): boolean { return ctx.mode === 'tui' || ctx.mode === 'rpc' }
|
|
56
|
-
function activePolicyFamilies(
|
|
57
|
-
return new Set(Object.entries(
|
|
58
|
+
function activePolicyFamilies(constraints: Record<string, ContentPolicyConstraint>, now = Date.now()): Set<string> {
|
|
59
|
+
return new Set(Object.entries(constraints)
|
|
58
60
|
.filter(([, record]) => record.avoidUntil > now)
|
|
59
61
|
.map(([family]) => family))
|
|
60
62
|
}
|
|
@@ -154,7 +156,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
154
156
|
const candidates = snapshot.models
|
|
155
157
|
const counts = summarizeCandidateHealth(candidates, state)
|
|
156
158
|
const now = Date.now()
|
|
157
|
-
const policyFamilies = activePolicyFamilies(
|
|
159
|
+
const policyFamilies = activePolicyFamilies(health.policyConstraints, now)
|
|
158
160
|
const rows = candidates.slice(0, 10).map(model => {
|
|
159
161
|
const modelRecord = state.models[modelKey(model)]
|
|
160
162
|
const records = [modelRecord, state.providers[model.provider], state.endpoints[endpointKey(model)]]
|
|
@@ -179,7 +181,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
179
181
|
const policySummary = policyFamilies.size ? [...policyFamilies].join(', ') : '无'
|
|
180
182
|
const action = await ctx.ui.select(`CCSwitch 健康面板\n当前:${key(ctx.model) ?? '无'}\n${scopeLabel} · 唯一模型:${counts.total} · 健康:${counts.healthy} · 自动冷却:${counts.cooling} · 手动禁用:${counts.disabled}\n熔断记录:${counts.breakerRecords} · 审查约束系列:${policySummary}\n${switchSummary}${switchPanel}\n${rows.join('\n') || '没有可用模型'}`, ['刷新', '重新激活当前模型', '禁用当前模型', '重置当前模型历史', '关闭'])
|
|
181
183
|
if (action === '刷新') await refresh(ctx)
|
|
182
|
-
if (action === '重新激活当前模型' && ctx.model) {
|
|
184
|
+
if (action === '重新激活当前模型' && ctx.model) {
|
|
185
|
+
const restored = await health.reactivate(ctx.model)
|
|
186
|
+
status(ctx)
|
|
187
|
+
notify(ctx, restored ? '已重新激活当前模型' : '重新激活失败,健康状态未确认保存;请查看 ccswitch-auto-switch.log', restored ? 'info' : 'error')
|
|
188
|
+
}
|
|
183
189
|
if (action === '禁用当前模型' && ctx.model) { health.disable(modelKey(ctx.model), true); await health.flush(); status(ctx); notify(ctx, '已禁用当前模型', 'warning') }
|
|
184
190
|
if (action === '重置当前模型历史' && ctx.model) {
|
|
185
191
|
const ok = !ctx.ui.confirm || await ctx.ui.confirm('重置健康历史', `删除 ${modelKey(ctx.model)} 的记录?`)
|
|
@@ -223,12 +229,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
223
229
|
* 模态预检主动切换:当前模型不支持图片但本轮输入/工具结果带图时,
|
|
224
230
|
* 主动切到一个健康的多模态候选(不等失败)。无候选时通知并保持原模型。
|
|
225
231
|
*/
|
|
226
|
-
const
|
|
232
|
+
const performModalitySwitch = async (ctx: ExtensionContext, round: Round): Promise<void> => {
|
|
227
233
|
const current = round.model ?? ctx.model
|
|
228
|
-
if (!current || !canRetry(ctx) || round.phase
|
|
234
|
+
if (!current || !canRetry(ctx) || !['monitoring', 'verifying'].includes(round.phase)) return
|
|
229
235
|
if (current.input?.includes('image')) return
|
|
230
236
|
const candidates = effectiveCandidates(ctx.scopedModels, ctx.modelRegistry.getAvailable())
|
|
231
|
-
|
|
237
|
+
let next: ModelRef | undefined
|
|
238
|
+
for (const candidate of multimodalCandidates(candidates, current, health.snapshot)) {
|
|
239
|
+
if (round.tried.has(modelKey(candidate)) || round.avoidFamilies?.has(modelFamily(candidate)) || round.endpointFails?.isolated.has(platformKey(candidate))) continue
|
|
240
|
+
if (await health.claimCandidate(candidate)) { next = candidate; break }
|
|
241
|
+
}
|
|
232
242
|
if (!next) {
|
|
233
243
|
await health.log(`modality precheck: no multimodal candidate, staying on ${modelKey(current)}`)
|
|
234
244
|
notify(ctx, 'CCSwitch:当前模型不支持图片,且没有可用的多模态候选模型,已保持原模型', 'warning')
|
|
@@ -263,6 +273,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
263
273
|
status(ctx)
|
|
264
274
|
}
|
|
265
275
|
|
|
276
|
+
const proactiveModalitySwitch = (ctx: ExtensionContext, target: Round): Promise<void> => {
|
|
277
|
+
// Concurrent tool completions await the same switch before returning to Pi.
|
|
278
|
+
if (!target.modalitySwitch) {
|
|
279
|
+
target.modalitySwitch = performModalitySwitch(ctx, target).finally(() => { target.modalitySwitch = undefined })
|
|
280
|
+
}
|
|
281
|
+
return target.modalitySwitch
|
|
282
|
+
}
|
|
283
|
+
|
|
266
284
|
const failover = async (ctx: ExtensionContext) => {
|
|
267
285
|
if (!round || !round.observation || !round.model || !canRetry(ctx)) return
|
|
268
286
|
const classification = classifyFailure(round.observation)
|
|
@@ -285,7 +303,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
285
303
|
if (classification.kind === 'content_policy') {
|
|
286
304
|
health.recordContentPolicyConstraint(modelFamily(round.model), round.model, round.observation.message)
|
|
287
305
|
round.avoidFamilies ??= new Set()
|
|
288
|
-
for (const family of activePolicyFamilies(health.
|
|
306
|
+
for (const family of activePolicyFamilies(health.policyConstraints)) round.avoidFamilies.add(family)
|
|
289
307
|
}
|
|
290
308
|
// 失败必须先落台账再做时限判断:否则本轮最后一次失败不会进健康状态(无冷却),
|
|
291
309
|
// 下一轮还会重试同一个刚失败的模型/provider
|
|
@@ -303,11 +321,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
303
321
|
health: health.snapshot,
|
|
304
322
|
avoidEndpoints: failTracker.isolated,
|
|
305
323
|
avoidFamilies: round!.avoidFamilies,
|
|
306
|
-
requiredInputs: round!.
|
|
324
|
+
requiredInputs: round!.needsImage ? ['text', 'image'] : ['text'],
|
|
307
325
|
})
|
|
308
326
|
let next = chooseCandidate(candidates, candidateOptions())
|
|
309
327
|
while (next) {
|
|
310
|
-
if (!await health.
|
|
328
|
+
if (!await health.claimCandidate(next)) { round.tried.add(modelKey(next)); next = chooseCandidate(candidates, candidateOptions()); continue }
|
|
311
329
|
round.attempts++
|
|
312
330
|
ctx.ui.setWorkingMessage(`模型异常,正在切换到 ${modelKey(next)}…`)
|
|
313
331
|
const previousModel = round.model
|
|
@@ -371,14 +389,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
371
389
|
if (event.source === 'extension') return { action: 'continue' }
|
|
372
390
|
clearWatchdog()
|
|
373
391
|
lastStatus = {}
|
|
374
|
-
round = { id: (round?.id ?? 0) + 1, phase: 'monitoring', startedAt: Date.now(), text: event.text, images: event.images, tried: new Set(), attempts: 0, hadTool: false, inTool: false, hadOutput: false, watchdog: false, cleanRetry: false, model: ctx.model, endpointFails: undefined, avoidFamilies: undefined, errors: new Map() }
|
|
392
|
+
round = { id: (round?.id ?? 0) + 1, phase: 'monitoring', startedAt: Date.now(), text: event.text, images: event.images, needsImage: Boolean(event.images?.length), tried: new Set(), attempts: 0, hadTool: false, inTool: false, hadOutput: false, watchdog: false, cleanRetry: false, model: ctx.model, endpointFails: undefined, avoidFamilies: undefined, errors: new Map() }
|
|
375
393
|
status(ctx)
|
|
376
394
|
// 模态预检:输入带图片但当前模型不支持图片(非多模态)→ 主动切换到多模态模型,
|
|
377
395
|
// 避免 Pi 静默剥图后模型只回答“看不到图片”(此类情况不会触发 failover)。
|
|
378
396
|
if (event.images?.length && round.phase === 'monitoring') await proactiveModalitySwitch(ctx, round)
|
|
397
|
+
if (event.images?.length && ctx.mode === 'rpc' && !round.model?.input?.includes('image')) {
|
|
398
|
+
await exhaust(ctx, 'image input requires an available image-capable model')
|
|
399
|
+
return { action: 'handled' }
|
|
400
|
+
}
|
|
379
401
|
return { action: 'continue' }
|
|
380
402
|
})
|
|
381
|
-
pi.on('before_provider_request', (_event, ctx) => {
|
|
403
|
+
pi.on('before_provider_request', (_event, ctx) => {
|
|
404
|
+
lastStatus = {}
|
|
405
|
+
if (round) {
|
|
406
|
+
round.inTool = false
|
|
407
|
+
armWatchdog(ctx, FIRST_RESPONSE_TIMEOUT, round.id)
|
|
408
|
+
}
|
|
409
|
+
})
|
|
382
410
|
pi.on('after_provider_response', (event, ctx) => {
|
|
383
411
|
lastStatus = { status: event.status, retryAfterMs: parseRetryAfter(event.headers?.['retry-after']) }
|
|
384
412
|
if (round && event.status >= 200 && event.status < 300) armWatchdog(ctx, STREAM_IDLE_TIMEOUT, round.id)
|
|
@@ -401,7 +429,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
401
429
|
round.inTool = false
|
|
402
430
|
// 工具结果含图片(如 read 图片文件)且当前模型不支持 → 主动切换多模态模型,
|
|
403
431
|
// 确保下一轮 LLM 调用使用新模型,工具结果中的图片不被 Pi 静默剥除。
|
|
404
|
-
if (
|
|
432
|
+
if (resultContainsImage(_event.result)) {
|
|
433
|
+
round.needsImage = true
|
|
434
|
+
await proactiveModalitySwitch(ctx, round)
|
|
435
|
+
}
|
|
405
436
|
})
|
|
406
437
|
pi.on('turn_end', async (event, ctx) => {
|
|
407
438
|
const message = event.message
|
|
@@ -483,9 +514,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
483
514
|
if (!target && verb !== 'reset') { notify(ctx, '请指定 provider/model 或 all', 'warning'); return }
|
|
484
515
|
if (verb === 'disable' && target) { health.disable(target, true); await health.flush(); status(ctx); return }
|
|
485
516
|
if (verb === 'reactivate' && target) {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
517
|
+
const [provider, ...rest] = target.split('/')
|
|
518
|
+
const restored = await (target === 'all' ? health.reactivateAll() : health.reactivate({ provider, id: rest.join('/') }))
|
|
519
|
+
status(ctx)
|
|
520
|
+
notify(ctx, restored ? '已重新激活' : '重新激活失败,健康状态未确认保存;请查看 ccswitch-auto-switch.log', restored ? 'info' : 'error')
|
|
521
|
+
return
|
|
489
522
|
}
|
|
490
523
|
if (verb === 'reset' && target) {
|
|
491
524
|
const ok = !ctx.ui.confirm || await ctx.ui.confirm('重置健康历史', `删除 ${target} 的健康记录?`)
|
|
@@ -499,7 +532,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
499
532
|
const snapshot = candidateSnapshot(ctx.scopedModels, ctx.modelRegistry.getAvailable())
|
|
500
533
|
const counts = summarizeCandidateHealth(snapshot.models, health.snapshot)
|
|
501
534
|
const state = health.snapshot
|
|
502
|
-
const policyFamilies = activePolicyFamilies(
|
|
535
|
+
const policyFamilies = activePolicyFamilies(health.policyConstraints)
|
|
503
536
|
const source = snapshot.source === 'scoped' ? `Pi scope ${snapshot.sourceEntries} 条` : `Pi 注册表 ${snapshot.sourceEntries} 条`
|
|
504
537
|
notify(ctx, `CCSwitch 自检:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 熔断记录 ${counts.breakerRecords} · 审查约束系列 ${policyFamilies.size} · 本session切换 ${sessionSwitches} · 累计切换 ${state.switches ?? 0}`, counts.total ? 'info' : 'warning')
|
|
505
538
|
}})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ccswitch-auto-switch",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"description": "Provider-first automatic model failover extension for Pi and CC Switch",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
"node": ">=22.19.0"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
|
-
"test": "node --test test/*.test.ts test/*.test.mjs",
|
|
42
|
+
"test": "node --experimental-strip-types --test test/*.test.ts test/*.test.mjs",
|
|
43
43
|
"typecheck": "tsc --noEmit --allowImportingTsExtensions --module nodenext --moduleResolution nodenext --target es2022 --skipLibCheck *.ts"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@types/node": "^22.19.19"
|
|
46
|
+
"@types/node": "^22.19.19",
|
|
47
|
+
"typescript": "~5.9.3"
|
|
47
48
|
}
|
|
48
49
|
}
|
package/runner.mjs
CHANGED
|
@@ -4,18 +4,20 @@
|
|
|
4
4
|
* extension retries a failed turn on another model.
|
|
5
5
|
*/
|
|
6
6
|
import { spawn } from 'node:child_process'
|
|
7
|
-
import { access, readFile, stat } from 'node:fs/promises'
|
|
7
|
+
import { access, readFile, realpath, stat } from 'node:fs/promises'
|
|
8
8
|
import { existsSync } from 'node:fs'
|
|
9
|
-
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
10
10
|
import { delimiter, dirname, extname, isAbsolute, join, resolve } from 'node:path'
|
|
11
11
|
|
|
12
12
|
export const EXIT = Object.freeze({ SUCCESS: 0, EXHAUSTED: 1, CONFIG: 2, TIMEOUT: 124, INTERRUPTED: 130, TERMINATED: 143 })
|
|
13
13
|
export const DEFAULT_TIMEOUT_MS = 600_000
|
|
14
|
+
export const INLINE_IMAGE_MAX_BYTES = 1.5 * 1024 * 1024
|
|
14
15
|
|
|
15
|
-
const usage = `Usage: pi-ccswitch-run [options] [--] [@text-file ...] <prompt>
|
|
16
|
+
const usage = `Usage: pi-ccswitch-run [options] [--] [@text-or-image-file ...] <prompt>
|
|
16
17
|
|
|
17
18
|
Options:
|
|
18
19
|
--timeout-ms <ms> Total runner timeout (default: ${DEFAULT_TIMEOUT_MS})
|
|
20
|
+
--task-complexity <v> auto, low, medium, high (omitted: keep Pi thinking)
|
|
19
21
|
--no-tools Disable Pi tools
|
|
20
22
|
--no-context-files Disable AGENTS.md / CLAUDE.md discovery
|
|
21
23
|
--provider <name> Initial Pi provider
|
|
@@ -35,11 +37,17 @@ export function parseArgs(argv) {
|
|
|
35
37
|
const files = []
|
|
36
38
|
const prompt = []
|
|
37
39
|
let timeoutMs = DEFAULT_TIMEOUT_MS
|
|
40
|
+
let complexity
|
|
38
41
|
let positional = false
|
|
39
42
|
for (let index = 0; index < argv.length; index += 1) {
|
|
40
43
|
const arg = argv[index]
|
|
41
44
|
if (arg === '--') { positional = true; continue }
|
|
42
45
|
if (!positional && (arg === '-h' || arg === '--help')) return { help: true }
|
|
46
|
+
if (!positional && arg === '--task-complexity') {
|
|
47
|
+
complexity = argv[++index]
|
|
48
|
+
if (!['auto', 'low', 'medium', 'high'].includes(complexity)) throw new Error('invalid --task-complexity')
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
43
51
|
if (!positional && arg === '--timeout-ms') {
|
|
44
52
|
const raw = argv[++index]
|
|
45
53
|
const value = Number(raw)
|
|
@@ -64,7 +72,19 @@ export function parseArgs(argv) {
|
|
|
64
72
|
prompt.push(arg)
|
|
65
73
|
}
|
|
66
74
|
if (prompt.length === 0) throw new Error('a prompt is required')
|
|
67
|
-
return { timeoutMs, piArgs, files, prompt: prompt.join(' ') }
|
|
75
|
+
return { timeoutMs, piArgs, files, prompt: prompt.join(' '), ...(complexity ? { complexity } : {}) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function assessComplexity(prompt, fileText = '', imageCount = 0) {
|
|
79
|
+
const chars = prompt.length + fileText.length
|
|
80
|
+
// 只根据任务指令、输入规模和图片数量估计;不把附件正文当路由指令。
|
|
81
|
+
if (chars >= 20000 || imageCount >= 4 || /跨文章|跨聊天|主题脉络|交叉核验|因果分析|多来源|cross.source|synthesi[sz]/i.test(prompt)) {
|
|
82
|
+
return { level: 'high', reason: 'long-input-or-synthesis' }
|
|
83
|
+
}
|
|
84
|
+
if (imageCount || chars >= 2000 || /JSON|结构化|摘要|summari[sz]/i.test(prompt)) {
|
|
85
|
+
return { level: 'medium', reason: 'structured-or-visual' }
|
|
86
|
+
}
|
|
87
|
+
return { level: 'low', reason: 'short-text' }
|
|
68
88
|
}
|
|
69
89
|
|
|
70
90
|
export async function readTextFiles(fileArgs, cwd = process.cwd()) {
|
|
@@ -87,6 +107,60 @@ export function buildMessage(fileText, prompt) {
|
|
|
87
107
|
return fileText ? `${fileText}\n${prompt}` : prompt
|
|
88
108
|
}
|
|
89
109
|
|
|
110
|
+
export async function readAttachments(fileArgs, cwd = process.cwd()) {
|
|
111
|
+
let fileText = ''
|
|
112
|
+
const images = []
|
|
113
|
+
for (const raw of fileArgs) {
|
|
114
|
+
const path = resolve(cwd, raw)
|
|
115
|
+
const info = await stat(path)
|
|
116
|
+
if (!info.isFile()) throw new Error(`not a regular file: ${path}`)
|
|
117
|
+
if (info.size > 20 * 1024 * 1024) throw new Error(`attachment exceeds 20 MiB: ${path}`)
|
|
118
|
+
const data = await readFile(path)
|
|
119
|
+
let mimeType
|
|
120
|
+
if (data.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) mimeType = 'image/png'
|
|
121
|
+
else if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) mimeType = 'image/jpeg'
|
|
122
|
+
else if (/^GIF8[79]a$/.test(data.subarray(0, 6).toString('ascii'))) mimeType = 'image/gif'
|
|
123
|
+
else if (data.subarray(0, 4).toString() === 'RIFF' && data.subarray(8, 12).toString() === 'WEBP') mimeType = 'image/webp'
|
|
124
|
+
if (mimeType) {
|
|
125
|
+
if (images.length >= 20) throw new Error('at most 20 image attachments are supported')
|
|
126
|
+
images.push({ type: 'image', data: data.toString('base64'), mimeType })
|
|
127
|
+
fileText += `<file name="${path}">Image attachment ${images.length}</file>\n`
|
|
128
|
+
} else {
|
|
129
|
+
if (/\.(png|jpe?g|gif|webp|heic|heif|bmp|tiff?)$/i.test(path)) throw new Error(`invalid or unsupported image: ${path}`)
|
|
130
|
+
fileText += await readTextFiles([path])
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { fileText, images }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function normalizeLargeImages(images, resizeImage) {
|
|
137
|
+
const maxBytes = INLINE_IMAGE_MAX_BYTES
|
|
138
|
+
const result = []
|
|
139
|
+
for (const image of images) {
|
|
140
|
+
if (image.data.length <= maxBytes) { result.push(image); continue }
|
|
141
|
+
const resized = await resizeImage(Buffer.from(image.data, 'base64'), image.mimeType, { maxBytes })
|
|
142
|
+
if (!resized?.data || resized.data.length > maxBytes) throw new Error('image exceeds inline size limit after normalization')
|
|
143
|
+
result.push({ type: 'image', data: resized.data, mimeType: resized.mimeType })
|
|
144
|
+
}
|
|
145
|
+
return result
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function prepareImages(images, resolvePi = resolvePiCommand) {
|
|
149
|
+
if (!images.some(image => image.data.length > INLINE_IMAGE_MAX_BYTES)) return images
|
|
150
|
+
const resolved = await resolvePi()
|
|
151
|
+
const executable = resolved.prefix[0] || resolved.command
|
|
152
|
+
const candidates = isAbsolute(executable) ? [executable]
|
|
153
|
+
: (process.env.PATH || '').split(delimiter).filter(Boolean).map(dir => join(dir, executable))
|
|
154
|
+
const found = candidates.find(path => existsSync(path))
|
|
155
|
+
if (!found) throw new Error('cannot locate Pi image normalizer for large attachments')
|
|
156
|
+
const entry = await realpath(found)
|
|
157
|
+
const normalizer = [join(dirname(entry), 'utils', 'image-resize.js'), join(dirname(entry), '..', 'utils', 'image-resize.js')]
|
|
158
|
+
.find(path => existsSync(path))
|
|
159
|
+
if (!normalizer) throw new Error('installed Pi does not expose image normalization for large attachments')
|
|
160
|
+
const { resizeImage } = await import(pathToFileURL(normalizer).href)
|
|
161
|
+
return normalizeLargeImages(images, resizeImage)
|
|
162
|
+
}
|
|
163
|
+
|
|
90
164
|
const LEGACY_RUNNER_STEMS = new Set(['pi-ccswitch-run', 'ccswitch-run'])
|
|
91
165
|
|
|
92
166
|
function executableStem(value) {
|
|
@@ -242,7 +316,14 @@ export async function run(options, runtime = {}) {
|
|
|
242
316
|
if (!Array.isArray(commands?.commands) || !commands.commands.some((entry) => entry.name === 'ccswitch')) {
|
|
243
317
|
throw new Error('CCSwitch extension did not load')
|
|
244
318
|
}
|
|
245
|
-
|
|
319
|
+
if (options.complexity && !options.piArgs.includes('--thinking')) {
|
|
320
|
+
const assessment = options.complexity === 'auto'
|
|
321
|
+
? assessComplexity(options.prompt || '', options.fileText ?? options.message, options.images?.length || 0)
|
|
322
|
+
: { level: options.complexity, reason: 'explicit-complexity' }
|
|
323
|
+
await request('set_thinking_level', { level: assessment.level })
|
|
324
|
+
log(`complexity=${assessment.level} reason=${assessment.reason} images=${options.images?.length || 0}`)
|
|
325
|
+
}
|
|
326
|
+
await request('prompt', { message: options.message, ...(options.images?.length ? { images: options.images } : {}) })
|
|
246
327
|
while (!terminal && !protocolError && !closed) await new Promise(resolveWait => setTimeout(resolveWait, 10))
|
|
247
328
|
if (protocolError) {
|
|
248
329
|
closeStdin()
|
|
@@ -282,8 +363,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
282
363
|
try { options = parseArgs(argv) } catch (error) { process.stderr.write(`pi-ccswitch-run: ${error.message}\n${usage}\n`); return EXIT.CONFIG }
|
|
283
364
|
if (options.help) { process.stdout.write(`${usage}\n`); return EXIT.SUCCESS }
|
|
284
365
|
try {
|
|
285
|
-
const
|
|
286
|
-
const
|
|
366
|
+
const attached = await readAttachments(options.files)
|
|
367
|
+
const fileText = attached.fileText
|
|
368
|
+
const images = await prepareImages(attached.images)
|
|
369
|
+
const result = await run({ ...options, fileText, images, message: buildMessage(fileText, options.prompt) })
|
|
287
370
|
if (result.text) process.stdout.write(`${result.text}\n`)
|
|
288
371
|
if (result.error) process.stderr.write(`pi-ccswitch-run: ${result.error}\n`)
|
|
289
372
|
return result.code
|