pi-ccswitch-auto-switch 0.3.12 → 0.3.14
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 +16 -2
- package/README.zh-CN.md +10 -2
- package/index.ts +5 -1
- package/package.json +1 -1
- package/runner.mjs +92 -8
package/README.md
CHANGED
|
@@ -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
|
|
|
@@ -163,7 +163,7 @@ CCSwitch therefore prechecks **before the request is sent**, instead of waiting
|
|
|
163
163
|
- Image requirements remain active for the entire failover round, including images returned by tools during retry verification and later fallback attempts;
|
|
164
164
|
- Only models that **explicitly** support images (`input` includes `image`) are considered; models with missing metadata are never assumed to support images;
|
|
165
165
|
- The switch reason is recorded as `modality` and counted in both the session and lifetime switch counters;
|
|
166
|
-
- 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.
|
|
167
167
|
|
|
168
168
|
The modality precheck applies only in interactive TUI/RPC sessions (the same scope as failover); print/json modes only monitor and never switch.
|
|
169
169
|
|
|
@@ -183,6 +183,20 @@ npm test
|
|
|
183
183
|
|
|
184
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
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.
|
|
199
|
+
|
|
186
200
|
## License
|
|
187
201
|
|
|
188
202
|
[MIT](LICENSE)
|
package/README.zh-CN.md
CHANGED
|
@@ -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
|
|
|
@@ -151,7 +151,7 @@ Pi 在把请求发给 Provider 时,会根据当前模型的 `input` 能力静
|
|
|
151
151
|
- 候选只选**明确声明**支持图片的模型(`input` 含 `image`);元数据缺失的模型不冒险选择;
|
|
152
152
|
- 图片能力要求贯穿整个故障转移轮次,包括重试验证阶段工具返回图片以及此后的再次切换;
|
|
153
153
|
- 切换原因记为 `modality`,计入本 session 切换数与累计切换;
|
|
154
|
-
-
|
|
154
|
+
- 如果没有可用的多模态候选,RPC 图片输入会返回 exhausted 终态并在发出剥图请求前停止;交互式 TUI 保持原有通知行为。
|
|
155
155
|
|
|
156
156
|
模态预检只在 TUI/RPC 交互模式下生效(与故障转移一致);print/json 模式只监控不切换。
|
|
157
157
|
|
|
@@ -171,6 +171,14 @@ npm test
|
|
|
171
171
|
|
|
172
172
|
TypeScript 已显式列为开发依赖。`npm test` 开启 Node 原生 TypeScript 类型剥离,在 Node 22.19+ 上运行 `health`、`integration`、`logic`、`runner` 全部测试。测试使用 Node 内置测试运行器,覆盖失败分类矩阵、系列级审查避让、Provider 优先选择、输入兼容、冷却、状态持久化以及 Windows 路径。
|
|
173
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,保留原图;处理失败时明确报错。
|
|
181
|
+
|
|
174
182
|
## 许可证
|
|
175
183
|
|
|
176
184
|
[MIT](LICENSE)
|
package/index.ts
CHANGED
|
@@ -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.14'
|
|
21
21
|
// 同端点(BaseURL 相同)连续失败达到该次数即隔离该端点,避免同一个平台的多个模型逐个试错耗尽本轮切换
|
|
22
22
|
const ENDPOINT_FAIL_THRESHOLD = 3
|
|
23
23
|
|
|
@@ -394,6 +394,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
394
394
|
// 模态预检:输入带图片但当前模型不支持图片(非多模态)→ 主动切换到多模态模型,
|
|
395
395
|
// 避免 Pi 静默剥图后模型只回答“看不到图片”(此类情况不会触发 failover)。
|
|
396
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
|
+
}
|
|
397
401
|
return { action: 'continue' }
|
|
398
402
|
})
|
|
399
403
|
pi.on('before_provider_request', (_event, ctx) => {
|
package/package.json
CHANGED
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
|
|
@@ -293,5 +376,6 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
293
376
|
}
|
|
294
377
|
}
|
|
295
378
|
|
|
296
|
-
const
|
|
379
|
+
const invokedPath = process.argv[1] && await realpath(resolve(process.argv[1])).catch(() => undefined)
|
|
380
|
+
const invoked = invokedPath === fileURLToPath(import.meta.url)
|
|
297
381
|
if (invoked) main().then(code => { process.exitCode = code })
|