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 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 in the first release; images and stdin input are intentionally not.
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, CCSwitch notifies the user and keeps the current model (Pi will still strip the image and add its notice).
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`。首版只支持文本 `@文件`,不支持图片和 stdin。
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
- - 如果没有可用的多模态候选,则通知用户并保持原模型继续(Pi 会照常剥图并附带提示)。
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.12'
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ccswitch-auto-switch",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Provider-first automatic model failover extension for Pi and CC Switch",
5
5
  "license": "MIT",
6
6
  "keywords": [
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
- await request('prompt', { message: options.message })
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 fileText = await readTextFiles(options.files)
286
- const result = await run({ ...options, message: buildMessage(fileText, options.prompt) })
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 invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
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 })