pi-ccswitch-auto-switch 0.1.7 → 0.3.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/README.md +23 -8
- package/README.zh-CN.md +21 -8
- package/candidates.ts +52 -6
- package/classify.ts +7 -3
- package/health.ts +60 -6
- package/index.ts +92 -18
- package/package.json +6 -2
- package/runner.mjs +283 -0
package/README.md
CHANGED
|
@@ -13,13 +13,15 @@ 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
|
-
-
|
|
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 current and future content-policy failover chains. Evidence expires after 30 days.
|
|
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.
|
|
17
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.
|
|
18
19
|
- 90-second first-response and 120-second streaming-idle watchdogs.
|
|
19
20
|
- Exponential cooldowns, `Retry-After` support, and one persisted half-open probe lease per provider.
|
|
20
21
|
- Persistent, atomic, cross-process health state with error redaction and bounded logs.
|
|
21
22
|
- Compact Pi status bar and interactive health panel.
|
|
22
23
|
- Non-interactive `--print` / JSON runs monitor and record failures but do not inject a competing retry.
|
|
24
|
+
- `pi-ccswitch-run` keeps one headless Pi RPC session alive and returns only the final successful answer after automatic model failover.
|
|
23
25
|
|
|
24
26
|
## Requirements
|
|
25
27
|
|
|
@@ -45,6 +47,18 @@ Restart Pi or run `/reload` after installing or updating. To update the Git inst
|
|
|
45
47
|
|
|
46
48
|
CC Switch should be configured normally. This extension deliberately does not write Pi model settings or CC Switch data.
|
|
47
49
|
|
|
50
|
+
### Headless / automation use
|
|
51
|
+
|
|
52
|
+
Install the package as an npm dependency, then run its binary (or invoke the packaged `runner.mjs` with Node):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pi-ccswitch-run --no-tools --no-context-files @prompt.md "Summarize the attached text"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
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; it must not point to `pi-ccswitch-run`. The default deadline is ten minutes and can be changed with `--timeout-ms`.
|
|
59
|
+
|
|
60
|
+
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 in the first release; images and stdin input are intentionally not.
|
|
61
|
+
|
|
48
62
|
## Commands
|
|
49
63
|
|
|
50
64
|
| Command | What it does |
|
|
@@ -54,7 +68,7 @@ CC Switch should be configured normally. This extension deliberately does not wr
|
|
|
54
68
|
| `/ccswitch refresh` | Refresh Pi's model registry and the status display. |
|
|
55
69
|
| `/ccswitch reactivate <provider/model\|all>` | Clear a breaker and let the next real request verify recovery, while preserving history. |
|
|
56
70
|
| `/ccswitch disable <provider/model>` | Manually exclude a model from failover. |
|
|
57
|
-
| `/ccswitch reset <provider/model\|all>` | Delete selected health history after confirmation. |
|
|
71
|
+
| `/ccswitch reset <provider/model\|all>` | Delete selected health history after confirmation; `all` also clears learned content-policy constraints. |
|
|
58
72
|
| `/ccswitch-test` | Inspect candidate discovery without changing models. |
|
|
59
73
|
|
|
60
74
|
Examples:
|
|
@@ -80,7 +94,7 @@ CCS ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
|
|
|
80
94
|
|
|
81
95
|
四个状态(健康/冷却/禁用/切换)即使为 0 也始终显示,便于确认扩展处于监控中。All-healthy still shows zero counters, e.g. `CCS ✓131/131 · ⏳0 · ⛔0 · 🔄0 · provider/model-id`. Use `/ccswitch` or `/ccswitch-test` to see Pi's raw scope entry count, the deduplicated model count, affected model counts, and the underlying breaker-record count.
|
|
82
96
|
|
|
83
|
-
切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。
|
|
97
|
+
切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。RPC 模式还会发出协议版本为 `1` 的 `ccswitch-complete` 或 `ccswitch-exhausted` 终态 entry;runner 用它们区分中间失败轮和最终结果。
|
|
84
98
|
|
|
85
99
|
## Failover behavior
|
|
86
100
|
|
|
@@ -90,15 +104,16 @@ The state machine waits for Pi's native retry cycle to settle before switching.
|
|
|
90
104
|
| --- | --- | --- |
|
|
91
105
|
| `401`, `403`, quota, billing | Provider | 30 minutes |
|
|
92
106
|
| `429` | Provider | `Retry-After` when present; otherwise 5 minutes |
|
|
93
|
-
| DNS, network, `5xx`, interrupted stream | Endpoint | 2 minutes |
|
|
107
|
+
| DNS, network, `408`, `5xx`, interrupted stream | Endpoint | 2 minutes |
|
|
94
108
|
| `404`, invalid model, incompatible parameters | Model | 15 minutes |
|
|
95
|
-
| Content
|
|
109
|
+
| Content-policy or sensitive-content rejection | Current model + learned model-family constraint | 2-minute model cooldown; 30-day family evidence |
|
|
110
|
+
| Context overflow | Current round only | None |
|
|
96
111
|
|
|
97
|
-
Cooldowns grow exponentially within bounded limits. Context-overflow retries only consider models with a larger context window.
|
|
112
|
+
Cooldowns grow exponentially within bounded limits. Learned policy-constrained families are excluded only after a content-policy rejection; they remain eligible during ordinary rate-limit, network, and model-configuration failovers. Context-overflow retries only consider models with a larger context window, and image requests do not move to a model that explicitly supports text only. Unknown errors are isolated to the current model and still fail over normally. A user cancellation is the only aborted turn that does not trigger failover; watchdog cancellations are recorded as timeouts.
|
|
98
113
|
|
|
99
114
|
## Data and privacy
|
|
100
115
|
|
|
101
|
-
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. It does not access credentials, authorization headers, CC Switch's database, or Pi's `auth.json`.
|
|
116
|
+
Health state is stored in Pi's agent directory as `ccswitch-auto-switch-state.json`. The extension stores counters, timestamps, cooldowns, learned model-family policy constraints, and redacted/truncated error summaries. It does not access credentials, authorization headers, CC Switch's database, or Pi's `auth.json`.
|
|
102
117
|
|
|
103
118
|
## Development
|
|
104
119
|
|
|
@@ -108,7 +123,7 @@ npm run typecheck
|
|
|
108
123
|
npm test
|
|
109
124
|
```
|
|
110
125
|
|
|
111
|
-
Tests use Node's built-in test runner and cover failure
|
|
126
|
+
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.
|
|
112
127
|
|
|
113
128
|
## License
|
|
114
129
|
|
package/README.zh-CN.md
CHANGED
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
- 对认证、额度、账单和限流实施 Provider 优先熔断。
|
|
13
13
|
- 对 DNS、连接、服务端和流中断实施端点熔断。
|
|
14
14
|
- 模型不存在或参数不兼容时仅隔离该模型。
|
|
15
|
-
-
|
|
15
|
+
- 内容审查失败会学习模型系列约束(例如 `glm-4.5`、`GLM-4.6` 同属 `glm`),本轮及后续内容审查故障转移会避开整个受限系列;证据 30 天后自动过期。
|
|
16
|
+
- 候选模型先经过健康、故障域、审查系列、输入模态和上下文窗口硬过滤,再按跨 Provider、模型等价性、能力兼容和历史稳定性排序。
|
|
16
17
|
- 首响应 90 秒、流式停滞 120 秒看门狗。
|
|
17
18
|
- 指数退避、`Retry-After` 支持和跨进程 half-open 单探针租约。
|
|
18
19
|
- 原子持久化状态、错误脱敏和有上限的日志。
|
|
19
20
|
- Pi 紧凑状态栏与健康管理面板。
|
|
20
21
|
- `--print` / JSON 非交互运行只监控和记录,不会注入可能与进程退出竞争的新请求。
|
|
22
|
+
- `pi-ccswitch-run` 使用单个 Pi RPC 会话完成无头自动故障转移,只输出最终成功模型的回答。
|
|
21
23
|
|
|
22
24
|
## 要求
|
|
23
25
|
|
|
@@ -41,6 +43,16 @@ pi install npm:pi-ccswitch-auto-switch
|
|
|
41
43
|
|
|
42
44
|
安装或更新后重启 Pi,或执行 `/reload`。后续更新 Git 版本可执行 `pi update --extensions`。
|
|
43
45
|
|
|
46
|
+
### 无头自动化调用
|
|
47
|
+
|
|
48
|
+
将本包作为 npm 依赖安装后,可调用:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pi-ccswitch-run --no-tools --no-context-files @prompt.md "请总结附件"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
runner 内部只显式加载本扩展并启动一次 Pi RPC;`PI_BIN` 只能指定内部真实 `pi`,不能指向 runner。默认总超时为 10 分钟,可用 `--timeout-ms` 调整。退出码:成功 `0`、候选耗尽 `1`、参数/RPC 配置错误 `2`、超时 `124`、中断 `130/143`。首版只支持文本 `@文件`,不支持图片和 stdin。
|
|
55
|
+
|
|
44
56
|
正常使用 CC Switch 配置 Provider 即可;本插件不会修改 Pi 模型设置或 CC Switch 数据。
|
|
45
57
|
|
|
46
58
|
## 命令
|
|
@@ -52,7 +64,7 @@ pi install npm:pi-ccswitch-auto-switch
|
|
|
52
64
|
| `/ccswitch refresh` | 刷新 Pi 模型注册表和状态栏。 |
|
|
53
65
|
| `/ccswitch reactivate <provider/model\|all>` | 解除熔断,下一次真实请求验证恢复情况;保留历史。 |
|
|
54
66
|
| `/ccswitch disable <provider/model>` | 手动排除模型。 |
|
|
55
|
-
| `/ccswitch reset <provider/model\|all>` |
|
|
67
|
+
| `/ccswitch reset <provider/model\|all>` | 确认后删除相应健康历史;`all` 同时清除已学习的审查约束。 |
|
|
56
68
|
| `/ccswitch-test` | 仅检查候选发现,不切换模型。 |
|
|
57
69
|
|
|
58
70
|
## 状态栏
|
|
@@ -70,7 +82,7 @@ CCS ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
|
|
|
70
82
|
|
|
71
83
|
四个状态(健康/冷却/禁用/切换)即使为 0 也始终显示,便于确认扩展处于监控中;全部健康时仍显示零计数,例如 `CCS ✓131/131 · ⏳0 · ⛔0 · 🔄0 · provider/model-id`。通过 `/ccswitch` 或 `/ccswitch-test` 可同时查看 Pi 原始 scope 条目数、去重模型数、受影响模型数、底层熔断记录数、本 session 切换数与累计切换数。
|
|
72
84
|
|
|
73
|
-
切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。
|
|
85
|
+
切换成功后扩展还会通过 `appendEntry` 向会话注入一条 `ccswitch-switch` custom entry(不参与 LLM 上下文),触发 TUI 底栏重绘——这样 Pi 右下角的模型名显示也会同步为切换后的模型。RPC 模式还会发出协议版本为 `1` 的 `ccswitch-complete` 或 `ccswitch-exhausted` 终态 entry,供 runner 判定最终结果。
|
|
74
86
|
|
|
75
87
|
## 故障转移逻辑
|
|
76
88
|
|
|
@@ -80,15 +92,16 @@ CCS ✓70/131 · ⏳61 · ⛔0 · 🔄3 · provider/model-id
|
|
|
80
92
|
| --- | --- | --- |
|
|
81
93
|
| `401`、`403`、额度或账单问题 | Provider | 30 分钟 |
|
|
82
94
|
| `429` | Provider | 优先使用 `Retry-After`,否则 5 分钟 |
|
|
83
|
-
| DNS、网络、`5xx`、流中断 | 端点 | 2 分钟 |
|
|
95
|
+
| DNS、网络、`408`、`5xx`、流中断 | 端点 | 2 分钟 |
|
|
84
96
|
| `404`、模型不存在、参数不兼容 | 模型 | 15 分钟 |
|
|
85
|
-
|
|
|
97
|
+
| 内容审查/敏感拦截 | 当前模型 + 模型系列约束 | 模型冷却 2 分钟;系列避让证据 30 天 |
|
|
98
|
+
| 上下文溢出 | 仅本轮 | 无 |
|
|
86
99
|
|
|
87
|
-
|
|
100
|
+
冷却时间会指数增长但有上限。发生内容审查时,插件只在审查故障转移链中避开已标记系列,普通限流、网络或模型配置故障仍可选择这些模型。上下文溢出时只会选择上下文窗口更大的模型;带图片的请求不会切到明确仅支持文本的模型。无法归类的异常按单模型故障处理并正常切换。用户主动取消是唯一不会触发故障转移的异常终止;看门狗取消会记录为超时。
|
|
88
101
|
|
|
89
102
|
## 数据与隐私
|
|
90
103
|
|
|
91
|
-
健康状态保存在 Pi agent 目录的 `ccswitch-auto-switch-state.json
|
|
104
|
+
健康状态保存在 Pi agent 目录的 `ccswitch-auto-switch-state.json`。其中只有计数、时间、冷却信息、模型系列审查约束和脱敏/截断的错误摘要;插件不会访问凭据、Authorization 请求头、CC Switch 数据库或 Pi 的 `auth.json`。
|
|
92
105
|
|
|
93
106
|
## 开发
|
|
94
107
|
|
|
@@ -98,7 +111,7 @@ npm run typecheck
|
|
|
98
111
|
npm test
|
|
99
112
|
```
|
|
100
113
|
|
|
101
|
-
测试使用 Node
|
|
114
|
+
测试使用 Node 内置测试运行器,覆盖失败分类矩阵、系列级审查避让、Provider 优先选择、输入兼容、冷却、状态持久化以及 Windows 路径。
|
|
102
115
|
|
|
103
116
|
## 许可证
|
|
104
117
|
|
package/candidates.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import type { ModelRef, ScopedModel } from './types.ts'
|
|
1
|
+
import type { FailureClass, ModelRef, ScopedModel } from './types.ts'
|
|
2
2
|
import { endpointKey, modelKey, type HealthState } from './health.ts'
|
|
3
3
|
|
|
4
4
|
export interface CandidateOptions {
|
|
5
5
|
current?: ModelRef
|
|
6
6
|
tried: Set<string>
|
|
7
|
-
failureKind?:
|
|
7
|
+
failureKind?: FailureClass
|
|
8
8
|
health: HealthState
|
|
9
9
|
avoidEndpoints?: Set<string>
|
|
10
|
+
/** 模型系列已被证实存在内容审查约束时,本轮不再选择该系列。 */
|
|
11
|
+
avoidFamilies?: ReadonlySet<string>
|
|
12
|
+
/** 本轮输入真正需要的模态;显式声明不支持的候选会被排除。 */
|
|
13
|
+
requiredInputs?: readonly string[]
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
export interface CandidateSnapshot {
|
|
@@ -76,22 +80,64 @@ export function chooseCandidate(models: ModelRef[], options: CandidateOptions):
|
|
|
76
80
|
const candidates = models.filter(model => {
|
|
77
81
|
if (options.tried.has(modelKey(model)) || blocked(model, options.health)) return false
|
|
78
82
|
if (options.avoidEndpoints?.has(endpointKey(model))) return false
|
|
83
|
+
if (options.avoidFamilies?.has(modelFamily(model))) return false
|
|
84
|
+
if (!supportsRequiredInputs(model, options.requiredInputs)) return false
|
|
79
85
|
if (options.failureKind === 'context_overflow' && current && (model.contextWindow ?? 0) <= (current.contextWindow ?? 0)) return false
|
|
80
86
|
return true
|
|
81
87
|
})
|
|
82
|
-
return candidates.sort((a, b) => score(a, current) - score(b, current))[0]
|
|
88
|
+
return candidates.sort((a, b) => score(a, current, options.health) - score(b, current, options.health))[0]
|
|
83
89
|
}
|
|
84
90
|
|
|
85
|
-
|
|
91
|
+
/**
|
|
92
|
+
* 返回跨 Provider 稳定的模型系列标识。内容审查通常是模型系列的能力约束,而不是某个 API Key
|
|
93
|
+
* 或单一部署的瞬时故障,因此已知系列使用显式规则;未知模型则保守地按版本号前的 id 归组。
|
|
94
|
+
*/
|
|
95
|
+
export function modelFamily(model: Pick<ModelRef, 'id' | 'name'>): string {
|
|
96
|
+
const identity = `${model.id} ${model.name ?? ''}`.toLowerCase()
|
|
97
|
+
const knownFamilies: Array<[string, RegExp]> = [
|
|
98
|
+
['glm', /(?:^|[^a-z0-9])(?:chat)?glm(?:$|[^a-z])/],
|
|
99
|
+
['qwen', /(?:^|[^a-z0-9])qwen(?:$|[^a-z])/],
|
|
100
|
+
['deepseek', /(?:^|[^a-z0-9])deepseek(?:$|[^a-z])/],
|
|
101
|
+
['kimi', /(?:^|[^a-z0-9])(?:kimi|moonshot)(?:$|[^a-z])/],
|
|
102
|
+
['gpt', /(?:^|[^a-z0-9])gpt(?:$|[^a-z])/],
|
|
103
|
+
['claude', /(?:^|[^a-z0-9])claude(?:$|[^a-z])/],
|
|
104
|
+
['gemini', /(?:^|[^a-z0-9])gemini(?:$|[^a-z])/],
|
|
105
|
+
['llama', /(?:^|[^a-z0-9])llama(?:$|[^a-z])/],
|
|
106
|
+
['mistral', /(?:^|[^a-z0-9])mistral(?:$|[^a-z])/],
|
|
107
|
+
['minimax', /(?:^|[^a-z0-9])minimax(?:$|[^a-z])/],
|
|
108
|
+
['ernie', /(?:^|[^a-z0-9])ernie(?:$|[^a-z])/],
|
|
109
|
+
['doubao', /(?:^|[^a-z0-9])doubao(?:$|[^a-z])/],
|
|
110
|
+
['hunyuan', /(?:^|[^a-z0-9])hunyuan(?:$|[^a-z])/],
|
|
111
|
+
]
|
|
112
|
+
for (const [family, pattern] of knownFamilies) if (pattern.test(identity)) return family
|
|
113
|
+
|
|
114
|
+
const leaf = model.id.toLowerCase().split('/').pop() ?? model.id.toLowerCase()
|
|
115
|
+
return leaf
|
|
116
|
+
.replace(/(?:[-_.]v)?\d+(?:[.-]\d+)*(?:[-_.].*)?$/, '')
|
|
117
|
+
.replace(/[-_.:]+$/, '') || leaf
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function supportsRequiredInputs(candidate: ModelRef, required: readonly string[] | undefined): boolean {
|
|
121
|
+
if (!required?.length || !candidate.input?.length) return true
|
|
122
|
+
return required.every(input => candidate.input!.includes(input))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function score(candidate: ModelRef, current: ModelRef | undefined, health: HealthState): number {
|
|
86
126
|
if (!current) return 0
|
|
127
|
+
// 排序优先级:故障域多样性 > 原模型等价性 > 输入/推理/上下文兼容性 > 历史稳定性。
|
|
128
|
+
// 硬约束(冷却、审查系列、输入模态、上下文溢出)已在上方 filter 中处理。
|
|
87
129
|
const sameProvider = candidate.provider === current.provider ? 1000 : 0
|
|
88
130
|
const differentId = candidate.id === current.id ? 0 : 100
|
|
89
131
|
const inputMismatch = sameInputs(candidate, current) ? 0 : 20
|
|
90
132
|
const reasoningMismatch = candidate.reasoning === current.reasoning ? 0 : 10
|
|
91
133
|
const contextPenalty = (candidate.contextWindow ?? 0) < (current.contextWindow ?? 0) ? 5 : 0
|
|
92
|
-
|
|
134
|
+
const records = [health.models[modelKey(candidate)], health.providers[candidate.provider], health.endpoints[endpointKey(candidate)]]
|
|
135
|
+
const historicalPenalty = Math.min(80, records.reduce((sum, record) => sum + Math.log2((record?.totalFailures ?? 0) + 1) * 4, 0))
|
|
136
|
+
return sameProvider + differentId + inputMismatch + reasoningMismatch + contextPenalty + historicalPenalty
|
|
93
137
|
}
|
|
94
138
|
|
|
95
139
|
function sameInputs(a: ModelRef, b: ModelRef): boolean {
|
|
96
|
-
|
|
140
|
+
const left = a.input ?? []
|
|
141
|
+
const right = b.input ?? []
|
|
142
|
+
return left.length === right.length && left.every(input => right.includes(input))
|
|
97
143
|
}
|
package/classify.ts
CHANGED
|
@@ -7,10 +7,11 @@ export interface Classification {
|
|
|
7
7
|
retryAfterMs?: number
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
const content = /content[ _-]?filter|sensitive|guardrail|policy[_ ]?violation|
|
|
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
|
const quota = /quota|billing|insufficient.?balance|out of budget|usage.?limit|credit.?balance|monthly.?limit/i
|
|
12
12
|
const context = /context.?window|context.?length|too many tokens|prompt is too long|input is too long|token limit/i
|
|
13
|
-
const transport = /dns|enotfound|eai_again|socket|connection|network|fetch failed|timed? ?out|timeout|stream ended|terminated|websocket/i
|
|
13
|
+
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
|
|
14
|
+
const overloaded = /overload|server busy|service unavailable|temporarily unavailable|capacity|upstream|bad gateway/i
|
|
14
15
|
|
|
15
16
|
export function classifyFailure(observation: FailureObservation): Classification {
|
|
16
17
|
const text = `${observation.rawStopReason ?? ''} ${observation.message ?? ''}`
|
|
@@ -21,12 +22,15 @@ export function classifyFailure(observation: FailureObservation): Classification
|
|
|
21
22
|
// chooseCandidate 会排除后自动切换到其他模型,而不是同一内容原样重试到本轮上限。
|
|
22
23
|
if (content.test(text)) return { kind: 'content_policy', scope: 'model', roundOnly: false }
|
|
23
24
|
if (context.test(text)) return { kind: 'context_overflow', roundOnly: true }
|
|
25
|
+
if (status === 413) return { kind: 'context_overflow', roundOnly: true }
|
|
24
26
|
if (status === 429) return { kind: 'rate_limit', scope: 'provider', roundOnly: false, retryAfterMs: observation.retryAfterMs }
|
|
27
|
+
if (status === 402) return { kind: 'quota', scope: 'provider', roundOnly: false }
|
|
25
28
|
if (status === 401 || status === 403) return { kind: 'auth', scope: 'provider', roundOnly: false }
|
|
26
29
|
if (quota.test(text)) return { kind: 'quota', scope: 'provider', roundOnly: false }
|
|
30
|
+
if (status === 408) return { kind: 'timeout', scope: 'endpoint', roundOnly: false }
|
|
27
31
|
if (status !== undefined && status >= 500) return { kind: 'endpoint', scope: 'endpoint', roundOnly: false }
|
|
32
|
+
if (transport.test(text) || overloaded.test(text)) return { kind: 'endpoint', scope: 'endpoint', roundOnly: false }
|
|
28
33
|
if (status === 400 || status === 404 || status === 422) return { kind: 'model_config', scope: 'model', roundOnly: false }
|
|
29
|
-
if (transport.test(text)) return { kind: 'endpoint', scope: 'endpoint', roundOnly: false }
|
|
30
34
|
return { kind: 'unknown', scope: 'model', roundOnly: false }
|
|
31
35
|
}
|
|
32
36
|
|
package/health.ts
CHANGED
|
@@ -24,12 +24,25 @@ export interface HealthRecord {
|
|
|
24
24
|
leaseUntil?: number
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export interface ContentPolicyConstraint {
|
|
28
|
+
observations: number
|
|
29
|
+
lastObservedAt: number
|
|
30
|
+
avoidUntil: number
|
|
31
|
+
lastModel: string
|
|
32
|
+
lastError?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
export interface HealthState {
|
|
28
36
|
schemaVersion: 2
|
|
29
37
|
updatedAt: number
|
|
30
38
|
models: Record<string, HealthRecord>
|
|
31
39
|
providers: Record<string, HealthRecord>
|
|
32
40
|
endpoints: Record<string, HealthRecord>
|
|
41
|
+
/**
|
|
42
|
+
* 已从真实失败中学到的模型系列内容审查约束。它不会影响普通故障选模,只在本轮已经
|
|
43
|
+
* 发生 content_policy 失败时用于避开同类系列。
|
|
44
|
+
*/
|
|
45
|
+
contentPolicyFamilies?: Record<string, ContentPolicyConstraint>
|
|
33
46
|
/** 累计成功切换次数(跨 session 持久化,用于衡量扩展有效程度) */
|
|
34
47
|
switches?: number
|
|
35
48
|
/** 最近成功切换日志(有限条,环形保留) */
|
|
@@ -64,7 +77,7 @@ function safeEndpoint(value: string): string {
|
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
function blank(): HealthState {
|
|
67
|
-
return { schemaVersion: 2, updatedAt: Date.now(), models: {}, providers: {}, endpoints: {}, switches: 0, switchLog: [] }
|
|
80
|
+
return { schemaVersion: 2, updatedAt: Date.now(), models: {}, providers: {}, endpoints: {}, contentPolicyFamilies: {}, switches: 0, switchLog: [] }
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
function redact(text: string | undefined): string | undefined {
|
|
@@ -91,6 +104,7 @@ export class HealthStore {
|
|
|
91
104
|
private state: HealthState = blank()
|
|
92
105
|
private dirty = false
|
|
93
106
|
private replaceOnFlush = false
|
|
107
|
+
private resetModels = new Set<string>()
|
|
94
108
|
|
|
95
109
|
constructor(dir = agentDir()) { this.dir = dir }
|
|
96
110
|
get file(): string { return join(this.dir, STATE_FILE) }
|
|
@@ -143,6 +157,24 @@ export class HealthStore {
|
|
|
143
157
|
this.touch()
|
|
144
158
|
}
|
|
145
159
|
|
|
160
|
+
/**
|
|
161
|
+
* 内容审查约束比限流/网络故障稳定得多,但仍设置 30 天证据有效期,避免模型策略升级后
|
|
162
|
+
* 永久污染路由。再次观察到同系列审查会增加证据计数并刷新有效期。
|
|
163
|
+
*/
|
|
164
|
+
recordContentPolicyConstraint(family: string, model: ModelRef, message?: string): void {
|
|
165
|
+
const constraints = this.state.contentPolicyFamilies ??= {}
|
|
166
|
+
const previous = constraints[family]
|
|
167
|
+
const now = Date.now()
|
|
168
|
+
constraints[family] = {
|
|
169
|
+
observations: (previous?.observations ?? 0) + 1,
|
|
170
|
+
lastObservedAt: now,
|
|
171
|
+
avoidUntil: now + 30 * 24 * 60 * 60_000,
|
|
172
|
+
lastModel: modelKey(model),
|
|
173
|
+
lastError: redact(message),
|
|
174
|
+
}
|
|
175
|
+
this.touch()
|
|
176
|
+
}
|
|
177
|
+
|
|
146
178
|
recordSuccess(model: ModelRef): void {
|
|
147
179
|
this.close('model', modelKey(model))
|
|
148
180
|
this.close('provider', model.provider)
|
|
@@ -192,9 +224,14 @@ export class HealthStore {
|
|
|
192
224
|
}
|
|
193
225
|
|
|
194
226
|
reset(target: string | 'all'): void {
|
|
195
|
-
if (target === 'all')
|
|
196
|
-
|
|
197
|
-
|
|
227
|
+
if (target === 'all') {
|
|
228
|
+
this.state = blank()
|
|
229
|
+
this.replaceOnFlush = true
|
|
230
|
+
this.resetModels.clear()
|
|
231
|
+
} else {
|
|
232
|
+
delete this.state.models[target]
|
|
233
|
+
this.resetModels.add(target)
|
|
234
|
+
}
|
|
198
235
|
this.touch()
|
|
199
236
|
}
|
|
200
237
|
|
|
@@ -221,7 +258,8 @@ export class HealthStore {
|
|
|
221
258
|
async flush(): Promise<void> {
|
|
222
259
|
if (!this.dirty) return
|
|
223
260
|
await this.withLock(async () => {
|
|
224
|
-
this.state = mergeState(await this.readDisk(), this.state)
|
|
261
|
+
if (!this.replaceOnFlush) this.state = mergeState(await this.readDisk(), this.state)
|
|
262
|
+
for (const key of this.resetModels) delete this.state.models[key]
|
|
225
263
|
await this.commit()
|
|
226
264
|
}).catch(() => {})
|
|
227
265
|
}
|
|
@@ -259,6 +297,7 @@ export class HealthStore {
|
|
|
259
297
|
await rename(temp, this.file)
|
|
260
298
|
this.dirty = false
|
|
261
299
|
this.replaceOnFlush = false
|
|
300
|
+
this.resetModels.clear()
|
|
262
301
|
}
|
|
263
302
|
private bucket(scope: HealthScope): Record<string, HealthRecord> {
|
|
264
303
|
return scope === 'model' ? this.state.models : scope === 'provider' ? this.state.providers : this.state.endpoints
|
|
@@ -314,5 +353,20 @@ function mergeState(a: HealthState, b: HealthState): HealthState {
|
|
|
314
353
|
.sort((x, y) => y.at - x.at)
|
|
315
354
|
.filter((entry, index, all) => index === 0 || all[index - 1].at !== entry.at || all[index - 1].from !== entry.from || all[index - 1].to !== entry.to)
|
|
316
355
|
.slice(0, 20)
|
|
317
|
-
|
|
356
|
+
const contentPolicyFamilies: Record<string, ContentPolicyConstraint> = {}
|
|
357
|
+
for (const family of new Set([...Object.keys(a.contentPolicyFamilies ?? {}), ...Object.keys(b.contentPolicyFamilies ?? {})])) {
|
|
358
|
+
const left = a.contentPolicyFamilies?.[family]
|
|
359
|
+
const right = b.contentPolicyFamilies?.[family]
|
|
360
|
+
if (!left) contentPolicyFamilies[family] = right!
|
|
361
|
+
else if (!right) contentPolicyFamilies[family] = left
|
|
362
|
+
else {
|
|
363
|
+
const newest = right.lastObservedAt >= left.lastObservedAt ? right : left
|
|
364
|
+
contentPolicyFamilies[family] = {
|
|
365
|
+
...newest,
|
|
366
|
+
observations: Math.max(left.observations, right.observations),
|
|
367
|
+
avoidUntil: Math.max(left.avoidUntil, right.avoidUntil),
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return { schemaVersion: 2, updatedAt: Math.max(a.updatedAt ?? 0, b.updatedAt ?? 0), models: mergeBucket(a.models ?? {}, b.models ?? {}), providers: mergeBucket(a.providers ?? {}, b.providers ?? {}), endpoints: mergeBucket(a.endpoints ?? {}, b.endpoints ?? {}), contentPolicyFamilies, switches, switchLog: log }
|
|
318
372
|
}
|
package/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext, FailureObservation, ModelRef } from './types.ts'
|
|
2
2
|
import { classifyFailure, parseRetryAfter } from './classify.ts'
|
|
3
|
-
import { candidateSnapshot, effectiveCandidates, chooseCandidate, summarizeCandidateHealth } from './candidates.ts'
|
|
4
|
-
import { HealthStore, endpointKey, modelKey } from './health.ts'
|
|
3
|
+
import { candidateSnapshot, effectiveCandidates, chooseCandidate, modelFamily, summarizeCandidateHealth } from './candidates.ts'
|
|
4
|
+
import { HealthStore, endpointKey, modelKey, type HealthState } from './health.ts'
|
|
5
5
|
|
|
6
6
|
const FIRST_RESPONSE_TIMEOUT = 90_000
|
|
7
7
|
const STREAM_IDLE_TIMEOUT = 120_000
|
|
8
8
|
const MAX_ATTEMPTS = 5
|
|
9
9
|
const ROUND_LIMIT = 8 * 60_000
|
|
10
|
+
const RPC_PROTOCOL_VERSION = 1
|
|
10
11
|
// 同端点(BaseURL 相同)连续失败达到该次数即隔离该端点,避免同一个平台的多个模型逐个试错耗尽本轮切换
|
|
11
12
|
const ENDPOINT_FAIL_THRESHOLD = 3
|
|
12
13
|
|
|
@@ -32,12 +33,26 @@ interface Round {
|
|
|
32
33
|
observation?: FailureObservation
|
|
33
34
|
model?: ModelRef
|
|
34
35
|
endpointFails?: EndpointFailTracker
|
|
36
|
+
/** 本轮内容审查故障转移中必须避开的模型系列(包含持久化学到的约束)。 */
|
|
37
|
+
avoidFamilies?: Set<string>
|
|
35
38
|
/** 本轮内最后一次成功切换的时间;用于刷新 ROUND_LIMIT 窗口,避免供应商内部重试耗时导致误判“达到上限” */
|
|
36
39
|
lastSwitchAt?: number
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
function key(model: ModelRef | undefined): string | undefined { return model && modelKey(model) }
|
|
40
43
|
function canRetry(ctx: ExtensionContext): boolean { return ctx.mode === 'tui' || ctx.mode === 'rpc' }
|
|
44
|
+
function activePolicyFamilies(state: HealthState, now = Date.now()): Set<string> {
|
|
45
|
+
return new Set(Object.entries(state.contentPolicyFamilies ?? {})
|
|
46
|
+
.filter(([, record]) => record.avoidUntil > now)
|
|
47
|
+
.map(([family]) => family))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveModel(ctx: ExtensionContext, provider: string, id: string, previous?: ModelRef): ModelRef {
|
|
51
|
+
// 优先使用注册表/scope 中的完整元数据,避免 turn_end 消息只有 provider/id 时丢失 BaseURL、
|
|
52
|
+
// 上下文窗口和输入模态;previous/ctx.model 作为注册表未命中时的后备。
|
|
53
|
+
const refs = [...ctx.scopedModels.map(item => item.model), ...ctx.modelRegistry.getAvailable(), previous, ctx.model]
|
|
54
|
+
return refs.find(model => model?.provider === provider && model.id === id) ?? { provider, id }
|
|
55
|
+
}
|
|
41
56
|
|
|
42
57
|
export default function (pi: ExtensionAPI) {
|
|
43
58
|
const health = new HealthStore()
|
|
@@ -51,10 +66,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
51
66
|
const armWatchdog = (ctx: ExtensionContext, ms: number, roundId: number) => {
|
|
52
67
|
clearWatchdog()
|
|
53
68
|
timer = setTimeout(() => {
|
|
54
|
-
if (!round || round.id !== roundId ||
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
69
|
+
if (!round || round.id !== roundId || ctx.isIdle() || round.inTool) return
|
|
70
|
+
round.watchdog = true
|
|
71
|
+
ctx.abort()
|
|
58
72
|
}, ms)
|
|
59
73
|
timer.unref?.()
|
|
60
74
|
}
|
|
@@ -90,7 +104,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
90
104
|
'/ccswitch refresh — 刷新 Pi 模型注册表和状态',
|
|
91
105
|
'/ccswitch reactivate <provider/model|all> — 解除熔断,保留历史',
|
|
92
106
|
'/ccswitch disable <provider/model> — 手动禁用模型',
|
|
93
|
-
'/ccswitch reset <provider/model|all> —
|
|
107
|
+
'/ccswitch reset <provider/model|all> — 清除健康历史;all 也会清除已学习的审查约束(需确认)',
|
|
94
108
|
'/ccswitch-test — 自检候选模型,不实际切换',
|
|
95
109
|
].join('\n'), 'info')
|
|
96
110
|
}
|
|
@@ -104,16 +118,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
104
118
|
const candidates = snapshot.models
|
|
105
119
|
const counts = summarizeCandidateHealth(candidates, state)
|
|
106
120
|
const now = Date.now()
|
|
121
|
+
const policyFamilies = activePolicyFamilies(state, now)
|
|
107
122
|
const rows = candidates.slice(0, 10).map(model => {
|
|
108
123
|
const modelRecord = state.models[modelKey(model)]
|
|
109
124
|
const records = [modelRecord, state.providers[model.provider], state.endpoints[endpointKey(model)]]
|
|
110
125
|
const blockedUntil = Math.max(0, ...records.flatMap(record => [record?.cooldownUntil ?? 0, record?.leaseUntil ?? 0]))
|
|
111
|
-
const
|
|
126
|
+
const policySuffix = policyFamilies.has(modelFamily(model)) ? ' · 内容审查约束' : ''
|
|
127
|
+
const baseSuffix = modelRecord?.disabled ? '手动禁用' : blockedUntil > now ? `自动冷却 ${Math.max(1, Math.ceil((blockedUntil - now) / 60_000))}m` : '健康'
|
|
128
|
+
const suffix = `${baseSuffix}${policySuffix}`
|
|
112
129
|
return `${modelKey(model)} ${suffix}`
|
|
113
130
|
})
|
|
114
131
|
if (!ctx.ui.select) {
|
|
115
132
|
const source = snapshot.source === 'scoped' ? `Pi scope ${snapshot.sourceEntries} 条` : `Pi 注册表 ${snapshot.sourceEntries} 条`
|
|
116
|
-
notify(ctx, `CCSwitch:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 本session切换 ${sessionSwitches} · 累计切换 ${state.switches ?? 0}`, 'info')
|
|
133
|
+
notify(ctx, `CCSwitch:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 审查约束系列 ${policyFamilies.size} · 本session切换 ${sessionSwitches} · 累计切换 ${state.switches ?? 0}`, 'info')
|
|
117
134
|
return
|
|
118
135
|
}
|
|
119
136
|
const scopeLabel = snapshot.source === 'scoped' ? `Pi scope:${snapshot.sourceEntries} 条` : `Pi 可用注册表:${snapshot.sourceEntries} 条`
|
|
@@ -123,7 +140,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
123
140
|
return ` ${time} ${entry.from} 🔄 ${entry.to}`
|
|
124
141
|
})
|
|
125
142
|
const switchPanel = recentSwitches.length ? `\n最近切换:\n${recentSwitches.join('\n')}` : ''
|
|
126
|
-
const
|
|
143
|
+
const policySummary = policyFamilies.size ? [...policyFamilies].join(', ') : '无'
|
|
144
|
+
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') || '没有可用模型'}`, ['刷新', '重新激活当前模型', '禁用当前模型', '重置当前模型历史', '关闭'])
|
|
127
145
|
if (action === '刷新') await refresh(ctx)
|
|
128
146
|
if (action === '重新激活当前模型' && ctx.model) { health.reactivate(ctx.model); await health.flush(); status(ctx); notify(ctx, '已重新激活当前模型') }
|
|
129
147
|
if (action === '禁用当前模型' && ctx.model) { health.disable(modelKey(ctx.model), true); await health.flush(); status(ctx); notify(ctx, '已禁用当前模型', 'warning') }
|
|
@@ -139,6 +157,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
139
157
|
ctx.ui.setWorkingMessage()
|
|
140
158
|
await health.report(`# CCSwitch 自动故障转移失败\n\n时间:${new Date().toISOString()}\n原因:${reason}\n\n已尝试:\n${[...round.tried].map(item => `- ${item}`).join('\n')}\n\n可使用 /ccswitch status 查看状态,/ccswitch reactivate <provider/model> 重新激活。`)
|
|
141
159
|
await health.log(`round exhausted: ${reason}; tried=${[...round.tried].join(',')}`)
|
|
160
|
+
// RPC runner relies on an explicit terminal signal instead of guessing from
|
|
161
|
+
// agent_settled. Keep this RPC-only so ordinary TUI transcripts stay quiet.
|
|
162
|
+
if (ctx.mode === 'rpc') {
|
|
163
|
+
const lastFailure = round.observation ? classifyFailure(round.observation).kind : undefined
|
|
164
|
+
try {
|
|
165
|
+
pi.appendEntry?.('ccswitch-exhausted', {
|
|
166
|
+
protocolVersion: RPC_PROTOCOL_VERSION,
|
|
167
|
+
roundId: round.id,
|
|
168
|
+
reason,
|
|
169
|
+
lastFailure,
|
|
170
|
+
model: key(round.model),
|
|
171
|
+
attempts: round.attempts,
|
|
172
|
+
tried: [...round.tried],
|
|
173
|
+
sessionSwitches,
|
|
174
|
+
})
|
|
175
|
+
} catch { /* reporting must not prevent the regular exhausted path */ }
|
|
176
|
+
}
|
|
142
177
|
notify(ctx, `CCSwitch:自动切换停止(${reason}),请用 /ccswitch 查看详情`, 'error')
|
|
143
178
|
status(ctx)
|
|
144
179
|
}
|
|
@@ -161,20 +196,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
161
196
|
failTracker.isolated.add(ep)
|
|
162
197
|
await health.log(`endpoint ${ep} failed ${epFails} times this round, isolating endpoint`)
|
|
163
198
|
}
|
|
199
|
+
if (classification.kind === 'content_policy') {
|
|
200
|
+
health.recordContentPolicyConstraint(modelFamily(round.model), round.model, round.observation.message)
|
|
201
|
+
round.avoidFamilies ??= new Set()
|
|
202
|
+
for (const family of activePolicyFamilies(health.snapshot)) round.avoidFamilies.add(family)
|
|
203
|
+
}
|
|
164
204
|
if (!classification.roundOnly && classification.scope) health.recordFailure(classification.scope, classification.scope === 'model' ? modelKey(round.model) : classification.scope === 'provider' ? round.model.provider : endpointKey(round.model), classification.kind, round.observation.message, classification.retryAfterMs)
|
|
165
205
|
await health.flush()
|
|
166
206
|
await refresh(ctx)
|
|
167
207
|
const candidates = effectiveCandidates(ctx.scopedModels, ctx.modelRegistry.getAvailable())
|
|
168
|
-
|
|
208
|
+
const candidateOptions = () => ({
|
|
209
|
+
current: round!.model,
|
|
210
|
+
tried: round!.tried,
|
|
211
|
+
failureKind: classification.kind,
|
|
212
|
+
health: health.snapshot,
|
|
213
|
+
avoidEndpoints: failTracker.isolated,
|
|
214
|
+
avoidFamilies: round!.avoidFamilies,
|
|
215
|
+
requiredInputs: round!.images?.length ? ['text', 'image'] : ['text'],
|
|
216
|
+
})
|
|
217
|
+
let next = chooseCandidate(candidates, candidateOptions())
|
|
169
218
|
while (next) {
|
|
170
|
-
if (!await health.claimProvider(next)) { round.tried.add(modelKey(next)); next = chooseCandidate(candidates,
|
|
219
|
+
if (!await health.claimProvider(next)) { round.tried.add(modelKey(next)); next = chooseCandidate(candidates, candidateOptions()); continue }
|
|
171
220
|
round.attempts++
|
|
172
221
|
ctx.ui.setWorkingMessage(`模型异常,正在切换到 ${modelKey(next)}…`)
|
|
222
|
+
const previousModel = round.model
|
|
173
223
|
const set = await pi.setModel(next).catch(() => false)
|
|
174
224
|
if (!set) {
|
|
175
225
|
health.recordFailure('model', modelKey(next), 'model_config', 'Pi refused model selection')
|
|
176
226
|
round.tried.add(modelKey(next)); await health.flush()
|
|
177
|
-
next = chooseCandidate(candidates,
|
|
227
|
+
next = chooseCandidate(candidates, candidateOptions())
|
|
178
228
|
continue
|
|
179
229
|
}
|
|
180
230
|
round.model = next
|
|
@@ -183,7 +233,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
183
233
|
round.lastSwitchAt = Date.now()
|
|
184
234
|
// 本次 session 成功切换计数 + 持久化累计/日志(衡量扩展有效程度)
|
|
185
235
|
sessionSwitches += 1
|
|
186
|
-
const fromKey = key(
|
|
236
|
+
const fromKey = key(previousModel ?? ctx.model)
|
|
187
237
|
health.recordSwitch(fromKey ?? '', modelKey(next), classification.kind)
|
|
188
238
|
await health.flush()
|
|
189
239
|
notify(ctx, `CCSwitch:已切换至 ${modelKey(next)}(${round.attempts}/${MAX_ATTEMPTS})`, 'info')
|
|
@@ -191,7 +241,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
191
241
|
// interactive-mode 收到后执行 footer.invalidate() + requestRender(),footer 从 session.state.model 重新读取,
|
|
192
242
|
// 从而让右下角模型名同步显示新模型(setModel 只改 state,不直接触发 footer 刷新)。
|
|
193
243
|
try {
|
|
194
|
-
pi.appendEntry?.('ccswitch-switch', {
|
|
244
|
+
pi.appendEntry?.('ccswitch-switch', {
|
|
245
|
+
protocolVersion: RPC_PROTOCOL_VERSION,
|
|
246
|
+
roundId: round.id,
|
|
247
|
+
from: fromKey ?? '',
|
|
248
|
+
to: modelKey(next),
|
|
249
|
+
reason: classification.kind,
|
|
250
|
+
attempts: round.attempts,
|
|
251
|
+
sessionSwitches,
|
|
252
|
+
})
|
|
195
253
|
} catch { /* appendEntry 失败不影响切换 */ }
|
|
196
254
|
const continuation = round.hadTool || round.hadOutput
|
|
197
255
|
? '请从当前会话状态继续完成上一条请求;不要重复已经完成的工具操作。'
|
|
@@ -213,7 +271,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
213
271
|
if (event.source === 'extension') return { action: 'continue' }
|
|
214
272
|
clearWatchdog()
|
|
215
273
|
lastStatus = {}
|
|
216
|
-
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 }
|
|
274
|
+
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 }
|
|
217
275
|
status(ctx)
|
|
218
276
|
return { action: 'continue' }
|
|
219
277
|
})
|
|
@@ -240,7 +298,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
240
298
|
const message = event.message
|
|
241
299
|
if (message?.role !== 'assistant' || !round) return
|
|
242
300
|
clearWatchdog()
|
|
243
|
-
round.model =
|
|
301
|
+
round.model = resolveModel(ctx, message.provider, message.model, round.model)
|
|
244
302
|
round.hadOutput ||= Boolean(message.content?.length)
|
|
245
303
|
if (message.stopReason === 'error' || message.stopReason === 'aborted') {
|
|
246
304
|
round.phase = 'settled-error'
|
|
@@ -249,6 +307,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
249
307
|
} else {
|
|
250
308
|
health.recordSuccess(round.model); await health.flush()
|
|
251
309
|
round.phase = 'idle'; round.observation = undefined; ctx.ui.setWorkingMessage(); status(ctx)
|
|
310
|
+
// agent_settled is also emitted after intermediate failed turns. An
|
|
311
|
+
// explicit terminal entry lets the headless RPC runner return only after
|
|
312
|
+
// this final successful assistant turn has settled.
|
|
313
|
+
if (ctx.mode === 'rpc') {
|
|
314
|
+
try {
|
|
315
|
+
pi.appendEntry?.('ccswitch-complete', {
|
|
316
|
+
protocolVersion: RPC_PROTOCOL_VERSION,
|
|
317
|
+
roundId: round.id,
|
|
318
|
+
model: key(round.model),
|
|
319
|
+
attempts: round.attempts,
|
|
320
|
+
sessionSwitches,
|
|
321
|
+
})
|
|
322
|
+
} catch { /* completion reporting is best-effort */ }
|
|
323
|
+
}
|
|
252
324
|
}
|
|
253
325
|
})
|
|
254
326
|
pi.on('agent_settled', async (_event, ctx) => {
|
|
@@ -256,6 +328,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
256
328
|
if (!ctx.isIdle()) return
|
|
257
329
|
if (!canRetry(ctx) && round.observation && round.model) {
|
|
258
330
|
const classification = classifyFailure(round.observation)
|
|
331
|
+
if (classification.kind === 'content_policy') health.recordContentPolicyConstraint(modelFamily(round.model), round.model, round.observation.message)
|
|
259
332
|
if (!classification.roundOnly && classification.scope) {
|
|
260
333
|
const domain = classification.scope === 'model' ? modelKey(round.model) : classification.scope === 'provider' ? round.model.provider : endpointKey(round.model)
|
|
261
334
|
health.recordFailure(classification.scope, domain, classification.kind, round.observation.message, classification.retryAfterMs)
|
|
@@ -293,7 +366,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
293
366
|
const snapshot = candidateSnapshot(ctx.scopedModels, ctx.modelRegistry.getAvailable())
|
|
294
367
|
const counts = summarizeCandidateHealth(snapshot.models, health.snapshot)
|
|
295
368
|
const state = health.snapshot
|
|
369
|
+
const policyFamilies = activePolicyFamilies(state)
|
|
296
370
|
const source = snapshot.source === 'scoped' ? `Pi scope ${snapshot.sourceEntries} 条` : `Pi 注册表 ${snapshot.sourceEntries} 条`
|
|
297
|
-
notify(ctx, `CCSwitch 自检:${source} · 唯一模型 ${counts.total} · 健康 ${counts.healthy} · 自动冷却 ${counts.cooling} · 手动禁用 ${counts.disabled} · 熔断记录 ${counts.breakerRecords} · 本session切换 ${sessionSwitches} · 累计切换 ${state.switches ?? 0}`, counts.total ? 'info' : 'warning')
|
|
371
|
+
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')
|
|
298
372
|
}})
|
|
299
373
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ccswitch-auto-switch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Provider-first automatic model failover extension for Pi and CC Switch",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -20,11 +20,15 @@
|
|
|
20
20
|
"homepage": "https://github.com/JunyWuuuu91/pi-ccswitch-auto-switch#readme",
|
|
21
21
|
"files": [
|
|
22
22
|
"index.ts",
|
|
23
|
+
"runner.mjs",
|
|
23
24
|
"types.ts",
|
|
24
25
|
"classify.ts",
|
|
25
26
|
"candidates.ts",
|
|
26
27
|
"health.ts"
|
|
27
28
|
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"pi-ccswitch-run": "./runner.mjs"
|
|
31
|
+
},
|
|
28
32
|
"pi": {
|
|
29
33
|
"extensions": [
|
|
30
34
|
"./index.ts"
|
|
@@ -35,7 +39,7 @@
|
|
|
35
39
|
"node": ">=22.19.0"
|
|
36
40
|
},
|
|
37
41
|
"scripts": {
|
|
38
|
-
"test": "node --test test/*.test.ts",
|
|
42
|
+
"test": "node --test test/*.test.ts test/*.test.mjs",
|
|
39
43
|
"typecheck": "tsc --noEmit --allowImportingTsExtensions --module nodenext --moduleResolution nodenext --target es2022 --skipLibCheck *.ts"
|
|
40
44
|
},
|
|
41
45
|
"devDependencies": {
|
package/runner.mjs
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Headless Pi runner that keeps one RPC session alive while the CCSwitch
|
|
4
|
+
* extension retries a failed turn on another model.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { access, readFile, stat } from 'node:fs/promises'
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { basename, delimiter, dirname, extname, isAbsolute, join, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
export const EXIT = Object.freeze({ SUCCESS: 0, EXHAUSTED: 1, CONFIG: 2, TIMEOUT: 124, INTERRUPTED: 130, TERMINATED: 143 })
|
|
13
|
+
export const DEFAULT_TIMEOUT_MS = 600_000
|
|
14
|
+
|
|
15
|
+
const usage = `Usage: pi-ccswitch-run [options] [--] [@text-file ...] <prompt>
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--timeout-ms <ms> Total runner timeout (default: ${DEFAULT_TIMEOUT_MS})
|
|
19
|
+
--no-tools Disable Pi tools
|
|
20
|
+
--no-context-files Disable AGENTS.md / CLAUDE.md discovery
|
|
21
|
+
--provider <name> Initial Pi provider
|
|
22
|
+
--model <name> Initial Pi model
|
|
23
|
+
--thinking <level> Initial Pi thinking level
|
|
24
|
+
--models <patterns> Limit the failover candidate scope
|
|
25
|
+
-p, --print Accepted for migration compatibility
|
|
26
|
+
--no-session Accepted; sessions are always ephemeral
|
|
27
|
+
--no-extensions Accepted; external discovery is always disabled
|
|
28
|
+
-h, --help Show this help`
|
|
29
|
+
|
|
30
|
+
const passthroughWithValue = new Set(['--provider', '--model', '--thinking', '--models'])
|
|
31
|
+
const acceptedNoops = new Set(['-p', '--print', '--no-session', '--no-extensions'])
|
|
32
|
+
|
|
33
|
+
export function parseArgs(argv) {
|
|
34
|
+
const piArgs = []
|
|
35
|
+
const files = []
|
|
36
|
+
const prompt = []
|
|
37
|
+
let timeoutMs = DEFAULT_TIMEOUT_MS
|
|
38
|
+
let positional = false
|
|
39
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
40
|
+
const arg = argv[index]
|
|
41
|
+
if (arg === '--') { positional = true; continue }
|
|
42
|
+
if (!positional && (arg === '-h' || arg === '--help')) return { help: true }
|
|
43
|
+
if (!positional && arg === '--timeout-ms') {
|
|
44
|
+
const raw = argv[++index]
|
|
45
|
+
const value = Number(raw)
|
|
46
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new Error('--timeout-ms must be a positive integer')
|
|
47
|
+
timeoutMs = value
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (!positional && (arg === '--no-tools' || arg === '--no-context-files')) { piArgs.push(arg); continue }
|
|
51
|
+
if (!positional && passthroughWithValue.has(arg)) {
|
|
52
|
+
const value = argv[++index]
|
|
53
|
+
if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`)
|
|
54
|
+
piArgs.push(arg, value)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (!positional && acceptedNoops.has(arg)) continue
|
|
58
|
+
if (!positional && arg.startsWith('-')) throw new Error(`unsupported option: ${arg}`)
|
|
59
|
+
if (!positional && arg.startsWith('@')) {
|
|
60
|
+
if (arg.length === 1) throw new Error('@file requires a path')
|
|
61
|
+
files.push(arg.slice(1))
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
prompt.push(arg)
|
|
65
|
+
}
|
|
66
|
+
if (prompt.length === 0) throw new Error('a prompt is required')
|
|
67
|
+
return { timeoutMs, piArgs, files, prompt: prompt.join(' ') }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function readTextFiles(fileArgs, cwd = process.cwd()) {
|
|
71
|
+
let text = ''
|
|
72
|
+
for (const raw of fileArgs) {
|
|
73
|
+
const path = resolve(cwd, raw)
|
|
74
|
+
try { await access(path) } catch { throw new Error(`file not found: ${path}`) }
|
|
75
|
+
const info = await stat(path)
|
|
76
|
+
if (!info.isFile()) throw new Error(`not a regular text file: ${path}`)
|
|
77
|
+
if (info.size === 0) continue
|
|
78
|
+
let content
|
|
79
|
+
try { content = await readFile(path, 'utf8') } catch { throw new Error(`not a UTF-8 text file: ${path}`) }
|
|
80
|
+
if (content.includes('\0')) throw new Error(`binary files are not supported: ${path}`)
|
|
81
|
+
text += `<file name="${path}">\n${content.replace(/^\uFEFF/, '')}\n</file>\n`
|
|
82
|
+
}
|
|
83
|
+
return text
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildMessage(fileText, prompt) {
|
|
87
|
+
return fileText ? `${fileText}\n${prompt}` : prompt
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validPiBinary(value) {
|
|
91
|
+
const normalized = basename(value).toLowerCase().replace(/\.(cmd|exe|bat)$/, '')
|
|
92
|
+
return normalized === 'pi'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function resolveWindowsShim(command) {
|
|
96
|
+
const candidates = []
|
|
97
|
+
if (isAbsolute(command) || command.includes('\\') || command.includes('/')) candidates.push(command)
|
|
98
|
+
else {
|
|
99
|
+
for (const dir of (process.env.PATH || '').split(delimiter).filter(Boolean)) {
|
|
100
|
+
candidates.push(join(dir, command))
|
|
101
|
+
if (!/\.(cmd|bat)$/i.test(command)) candidates.push(join(dir, `${command}.cmd`))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const candidate of candidates) {
|
|
105
|
+
if (!existsSync(candidate) || !/\.(cmd|bat)$/i.test(candidate)) continue
|
|
106
|
+
const source = await readFile(candidate, 'utf8').catch(() => '')
|
|
107
|
+
const match = source.match(/["']([^"']+\.js)["']/i)
|
|
108
|
+
if (!match) continue
|
|
109
|
+
const target = match[1].replace(/%~dp0/ig, `${dirname(candidate)}\\`)
|
|
110
|
+
const entry = resolve(target.replace(/\\/g, '/'))
|
|
111
|
+
if (existsSync(entry)) return { command: process.execPath, prefix: [entry] }
|
|
112
|
+
}
|
|
113
|
+
return { command, prefix: [] }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function resolvePiCommand() {
|
|
117
|
+
const configured = String(process.env.PI_BIN || '').trim()
|
|
118
|
+
if (configured && !validPiBinary(configured)) throw new Error('PI_BIN must name the real pi executable, not an arbitrary command')
|
|
119
|
+
const command = configured || 'pi'
|
|
120
|
+
if (process.platform === 'win32') return resolveWindowsShim(command)
|
|
121
|
+
return { command, prefix: [] }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function terminateTree(child, signal = 'SIGTERM') {
|
|
125
|
+
if (!child || child.exitCode !== null) return
|
|
126
|
+
if (process.platform === 'win32' && child.pid) {
|
|
127
|
+
const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
|
|
128
|
+
killer.on('error', () => { try { child.kill('SIGKILL') } catch {} })
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
try { child.kill(signal) } catch {}
|
|
132
|
+
setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, 1_000).unref()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function customEntry(event) {
|
|
136
|
+
if (event?.type !== 'entry_appended' || event.entry?.type !== 'custom') return undefined
|
|
137
|
+
return { type: event.entry.customType, data: event.entry.data || {} }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function run(options, runtime = {}) {
|
|
141
|
+
const spawnImpl = runtime.spawn || spawn
|
|
142
|
+
const extensionPath = runtime.extensionPath || fileURLToPath(new URL('./index.ts', import.meta.url))
|
|
143
|
+
const resolvePi = runtime.resolvePiCommand || resolvePiCommand
|
|
144
|
+
const log = runtime.log || ((message) => process.stderr.write(`[ccswitch] ${message}\n`))
|
|
145
|
+
const resolved = await resolvePi()
|
|
146
|
+
const args = [...resolved.prefix, '--mode', 'rpc', '--no-session', '--no-extensions', '--extension', extensionPath, ...options.piArgs]
|
|
147
|
+
const child = spawnImpl(resolved.command, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, env: process.env })
|
|
148
|
+
let buffer = ''
|
|
149
|
+
let closed = false
|
|
150
|
+
let terminal
|
|
151
|
+
let retryPending = false
|
|
152
|
+
let protocolError
|
|
153
|
+
let nextId = 1
|
|
154
|
+
const pending = new Map()
|
|
155
|
+
const closeStdin = () => { try { child.stdin?.end() } catch {} }
|
|
156
|
+
const request = (type, payload = {}) => new Promise((resolveRequest, rejectRequest) => {
|
|
157
|
+
const id = String(nextId++)
|
|
158
|
+
pending.set(id, { resolve: resolveRequest, reject: rejectRequest })
|
|
159
|
+
try { child.stdin.write(`${JSON.stringify({ id, type, ...payload })}\n`) } catch (error) { pending.delete(id); rejectRequest(error) }
|
|
160
|
+
})
|
|
161
|
+
const failPending = (error) => {
|
|
162
|
+
for (const item of pending.values()) item.reject(error)
|
|
163
|
+
pending.clear()
|
|
164
|
+
}
|
|
165
|
+
const handleEvent = (event) => {
|
|
166
|
+
if (event?.type === 'extension_error') { protocolError = `extension error: ${event.error}`; return }
|
|
167
|
+
if (event?.type === 'turn_start') retryPending = false
|
|
168
|
+
const entry = customEntry(event)
|
|
169
|
+
if (!entry) {
|
|
170
|
+
if (event?.type === 'agent_settled' && !terminal && !retryPending) {
|
|
171
|
+
protocolError = 'Pi settled without a CCSwitch terminal event'
|
|
172
|
+
}
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
if (entry.data?.protocolVersion !== 1) return
|
|
176
|
+
if (entry.type === 'ccswitch-switch') {
|
|
177
|
+
retryPending = true
|
|
178
|
+
log(`switch ${entry.data.from || '?'} → ${entry.data.to || '?'} (${entry.data.attempts ?? '?'}/5)`)
|
|
179
|
+
} else if (entry.type === 'ccswitch-complete') {
|
|
180
|
+
terminal = { kind: 'complete', data: entry.data }
|
|
181
|
+
} else if (entry.type === 'ccswitch-exhausted') {
|
|
182
|
+
terminal = { kind: 'exhausted', data: entry.data }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const processLine = (line) => {
|
|
186
|
+
if (!line.trim()) return
|
|
187
|
+
let event
|
|
188
|
+
try { event = JSON.parse(line) } catch { protocolError = 'Pi RPC emitted malformed JSONL'; return }
|
|
189
|
+
if (event?.type === 'response' && event.id !== undefined) {
|
|
190
|
+
const item = pending.get(String(event.id))
|
|
191
|
+
if (item) {
|
|
192
|
+
pending.delete(String(event.id))
|
|
193
|
+
if (event.success) item.resolve(event.data)
|
|
194
|
+
else item.reject(new Error(event.error || `${event.command || 'RPC'} failed`))
|
|
195
|
+
}
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
handleEvent(event)
|
|
199
|
+
}
|
|
200
|
+
child.stdout?.setEncoding('utf8')
|
|
201
|
+
child.stdout?.on('data', (chunk) => {
|
|
202
|
+
buffer += chunk
|
|
203
|
+
let newline
|
|
204
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
205
|
+
const line = buffer.slice(0, newline)
|
|
206
|
+
buffer = buffer.slice(newline + 1)
|
|
207
|
+
processLine(line)
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
child.stderr?.on('data', () => {})
|
|
211
|
+
const exit = new Promise((resolveExit, rejectExit) => {
|
|
212
|
+
child.once('error', rejectExit)
|
|
213
|
+
child.once('close', (code, signal) => { closed = true; resolveExit({ code, signal }) })
|
|
214
|
+
})
|
|
215
|
+
const timeout = setTimeout(() => { protocolError = 'runner timed out'; terminateTree(child) }, options.timeoutMs)
|
|
216
|
+
timeout.unref?.()
|
|
217
|
+
const onSignal = (signal, code) => {
|
|
218
|
+
protocolError = signal
|
|
219
|
+
terminateTree(child, signal)
|
|
220
|
+
process.exitCode = code
|
|
221
|
+
}
|
|
222
|
+
const onSigint = () => onSignal('SIGINT', EXIT.INTERRUPTED)
|
|
223
|
+
const onSigterm = () => onSignal('SIGTERM', EXIT.TERMINATED)
|
|
224
|
+
process.once('SIGINT', onSigint)
|
|
225
|
+
process.once('SIGTERM', onSigterm)
|
|
226
|
+
try {
|
|
227
|
+
const commands = await request('get_commands')
|
|
228
|
+
if (!Array.isArray(commands?.commands) || !commands.commands.some((entry) => entry.name === 'ccswitch')) {
|
|
229
|
+
throw new Error('CCSwitch extension did not load')
|
|
230
|
+
}
|
|
231
|
+
await request('prompt', { message: options.message })
|
|
232
|
+
while (!terminal && !protocolError && !closed) await new Promise(resolveWait => setTimeout(resolveWait, 10))
|
|
233
|
+
if (protocolError) {
|
|
234
|
+
closeStdin()
|
|
235
|
+
await exit.catch(() => {})
|
|
236
|
+
if (protocolError === 'runner timed out') return { code: EXIT.TIMEOUT, error: protocolError }
|
|
237
|
+
if (protocolError === 'SIGINT') return { code: EXIT.INTERRUPTED, error: protocolError }
|
|
238
|
+
if (protocolError === 'SIGTERM') return { code: EXIT.TERMINATED, error: protocolError }
|
|
239
|
+
return { code: EXIT.CONFIG, error: protocolError }
|
|
240
|
+
}
|
|
241
|
+
if (!terminal) return { code: EXIT.CONFIG, error: 'Pi exited before CCSwitch reached a terminal state' }
|
|
242
|
+
if (terminal.kind === 'exhausted') {
|
|
243
|
+
closeStdin()
|
|
244
|
+
await exit.catch(() => {})
|
|
245
|
+
return { code: EXIT.EXHAUSTED, error: terminal.data.reason || 'all candidate models were exhausted' }
|
|
246
|
+
}
|
|
247
|
+
const last = await request('get_last_assistant_text')
|
|
248
|
+
const text = String(last?.text || '').trim()
|
|
249
|
+
closeStdin()
|
|
250
|
+
await exit.catch(() => {})
|
|
251
|
+
if (!text) return { code: EXIT.CONFIG, error: 'CCSwitch completed but Pi returned no assistant text' }
|
|
252
|
+
return { code: EXIT.SUCCESS, text }
|
|
253
|
+
} catch (error) {
|
|
254
|
+
terminateTree(child)
|
|
255
|
+
closeStdin()
|
|
256
|
+
await exit.catch(() => {})
|
|
257
|
+
return { code: EXIT.CONFIG, error: error instanceof Error ? error.message : String(error) }
|
|
258
|
+
} finally {
|
|
259
|
+
clearTimeout(timeout)
|
|
260
|
+
process.off('SIGINT', onSigint)
|
|
261
|
+
process.off('SIGTERM', onSigterm)
|
|
262
|
+
if (!closed) { failPending(new Error('runner stopped')); terminateTree(child) }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
267
|
+
let options
|
|
268
|
+
try { options = parseArgs(argv) } catch (error) { process.stderr.write(`pi-ccswitch-run: ${error.message}\n${usage}\n`); return EXIT.CONFIG }
|
|
269
|
+
if (options.help) { process.stdout.write(`${usage}\n`); return EXIT.SUCCESS }
|
|
270
|
+
try {
|
|
271
|
+
const fileText = await readTextFiles(options.files)
|
|
272
|
+
const result = await run({ ...options, message: buildMessage(fileText, options.prompt) })
|
|
273
|
+
if (result.text) process.stdout.write(`${result.text}\n`)
|
|
274
|
+
if (result.error) process.stderr.write(`pi-ccswitch-run: ${result.error}\n`)
|
|
275
|
+
return result.code
|
|
276
|
+
} catch (error) {
|
|
277
|
+
process.stderr.write(`pi-ccswitch-run: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
278
|
+
return EXIT.CONFIG
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
283
|
+
if (invoked) main().then(code => { process.exitCode = code })
|