cordis.patch.yml,不用禁用任何东西。
export OPENROUTER_API_KEY=... 进环境变量。npx @deepseek-ai/dsh@0.1.1-rc.2 web → Settings → Models → Add provider → openrouter → Customized settings → Add model,填 stealth/ox-alpha。input: [text, image],刷新页面即可读图。export OPENROUTER_API_KEY=sk-or-v1-你的key
pi-ai 内置 openrouter → OPENROUTER_API_KEY 映射,dsh 会自动认。界面上的 API key 框会变成只读的 "Provided by the launch environment"——一个字不用敲,密钥也不落盘。
要持久化就放一个工作区之外的文件:
mkdir -p ~/.config/dsh-oxalpha
echo 'OPENROUTER_API_KEY=sk-or-v1-你的key' > ~/.config/dsh-oxalpha/env
chmod 600 ~/.config/dsh-oxalpha/env
dsh 的 workspace-write 沙箱只限制写和执行,读不受限——agent 能直接读到,会话日志有带出去的风险。
npx @deepseek-ai/dsh@0.1.1-rc.2 web
首次进来点 Continue,再点 Configure later 跳过 DeepSeek 官方 key。然后:
Settings → Models → Add provider → 选 openrouter → Customized settings → Add model,填 Model ID stealth/ox-alpha,Apply。
openrouter 是 pi-ai 内置目录里的 provider,Base URL 和协议自动继承,不用填。
对内置目录的 provider 它本地作答、不发网络请求,返回的是 pi-ai 打包的那份 276 个模型清单。ox-alpha 是 2026-08-20 才上架的,不在里面。手输就对了。
到这里文本对话已经能用,但还看不了图。
点界面右上角 Open configuration file,给模型加 input:
llm-pi-ai:
providers:
openrouter:
models:
- id: stealth/ox-alpha
name: Ox Alpha
contextWindow: 1048576 # 建议加:不写的话按兜底值 262144 算
input: [ text, image ] # ← 必须手加,界面上没有这个字段
不用重启,dsh 每次请求都重读配置,刷新页面即可。
这就是全部。整个 DSH_HOME 里只有这一个文件,其余全是 dsh 默认值。
界面上没有模态开关,是官方明确的设计:
"A model you enter by hand is treated as text-only until it says otherwise, because nothing can ask an endpoint which modalities it accepts."
没办法去问一个端点"你收不收图片",所以只能由你声明。harness 会在图片发出去之前就拦截——这是个声明,不是检测,写错了只会在真正发请求时才炸。
漏了会看到两种报错,根因相同:
| 场景 | 报错 |
|---|---|
| 让模型读图 | ... does not declare image input |
| 在已有图片的会话里切到它 | ... does not accept image input, but this session already contains images |
补完刷新页面即可,会话不用重建。
三个容易误会的点:
models 是替换不是追加。 写了它,该路由内置的 276 个模型全部从选择器消失。想保留就把它们也列进去,每个只写 - id: 一行,其余属性(含模态)自动从目录继承。models 不行。 界面提示 "Unlisted IDs can still be sent directly" 是错的,实测 UNKNOWN_MODEL。路由级 defaultInput 也救不了——它只管模态,不管模型存不存在。OpenRouter 给 ox-alpha 登记的模态是 text+image+video。两侧都实测了,harness 侧完全走不通,直连 API 可以用但有几个坑。
pi-ai 的模态枚举只有两个值(types.d.ts:649):
input: ("text" | "image")[];
不是"video 被忽略",是整条 provider 注册失败。同一个 DSH_HOME,只改这一个词:
input: [ text, image, video ] # → dsh: NO_ADAPTER: no adapter registered for provider "openrouter"
input: [ text, image ] # → 正常
报错是 NO_ADAPTER,跟模态一个字都不沾,很容易查错方向。别往 input 里写 video。 要用视频只能绕开 harness 直接调 API。
| content 写法 | 结果 |
|---|---|
{"type":"video_url","video_url":{"url":"data:video/mp4;base64,…"}} | ✓ 正确 |
{"type":"file","file":{…}} | 被识别为文件,走文件通道(门槛 $0.50) |
{"type":"input_video",…} | 静默丢弃:请求 200,模型回"我没有收到任何视频内容" |
{"type":"image_url"…} 里塞 mp4 | provider 400 拒绝 |
input_video 请求成功、零报错、模型照样答题,但它压根没收到视频。拿这个格式做测试,会得到一个看起来能用其实全是幻觉的结果。
实测方法:做一段每隔固定时间换一个颜色词的片子,问模型按顺序列出来。只答对一两个 = 它只抽了几帧;全中 = 真读了视频。
| 视频长度 | 模型收到 | 采样间隔 | 5 个颜色命中 |
|---|---|---|---|
| 5 秒 | 2 帧 | ~2.5s | 2/5 |
| 15 秒 | 8 帧 | ~2.1s | 5/5 |
| 30 秒 | 15 帧 | 每 2 秒一帧 | 5/5 |
采样率约 0.5 fps(每 2 秒一帧),和视频本身的帧率无关。所以:变化快于 2 秒的内容会被漏掉——5 秒的片子它只拿到 2 帧,直接答不全;想让它看清某个瞬间,那个画面至少要停留 2 秒以上。
有个细节值得说:5 秒那次它没有硬编,而是主动说明"我只收到了视频中的 2 帧(第 0 秒和第 2.5 秒)……其余 3 个我没有看到对应画面,因此无法按顺序列出全部 5 个"。没有幻觉出完整序列,这点比答对更让人放心。
这是最需要注意的一条。视频请求有 $1.00 最低余额门槛(文件是 $0.50),余额不够返回 402。但门槛之外,它是真收费的:
调用前余额: $67.435416
响应里的 cost: 0 ← 不可信
调用后余额: $67.314669
实际扣费: $0.120747 ← 一次 30 秒视频(15 帧,prompt 2113 token)
对照组:同一账户静置 25 秒不发任何请求,余额漂移 $0.000000——所以这 $0.12 确实是这次调用产生的。
ox-alpha 的 prompt / completion 定价是 0,但那只对文本和图片成立。 视频按抽出来的帧计费,而且响应体里的 cost 字段和 usage.prompt_tokens_details.video_tokens 都报 0,不能拿来估成本——只能查 /api/v1/credits 的余额差。
仓库里的 test-video.py 是完整脚本:查余额 → 现造测试片 → 用 video_url 发出去 → 自动判定命中几个颜色、顺序对不对,并对 429 做退避重试(stealth 端点共享池经常限流)。
export OPENROUTER_API_KEY=sk-or-v1-...
python3 test-video.py
默认那段是 5 秒的,会得到 2/5——想看全中就把每色停留时间拉到 3 秒以上。
上面那 276 个内置模型,很多是带 input: [text, image] 的——凭什么它们不用手写,ox-alpha 就要?搞清楚这个,就知道该怎么批量导入了。DSH 这条链路上,模态信息有三个可能的来源:
node_modules/@earendil-works/pi-ai/dist/models.generated.js 开头写着:
// This file is auto-generated by scripts/generate-models.ts
// Do not edit manually - run 'npm run generate-models' to update
再往下是 import values from "./data/openrouter.json"。也就是说,这是一份打包时冻结的 JSON 快照,37 个 provider、1109 个模型,每条带着 input、contextWindow、compat 等等。问题就在"冻结"两个字:
| 数据源 | 数量 |
|---|---|
| pi-ai 内置的 openrouter 模型 | 276 |
| OpenRouter 线上实际有的 | 421 |
差的 145 个里就包括 ox-alpha(2026-08-20 上架,快照做的时候还不存在)。快照不会自己更新,只有 pi-ai 发新版本才会带上新的一批。
这个按钮看着像是去线上问,实际分两种情况,两种都拿不到模态:
openrouter):pi-ai 直接用内置快照作答,根本不发网络请求。所以返回的就是那 276 个。GET /models,但官方文档写明只读这些字段——"Most listings disclose an id and nothing else; context_window/context_length and max_output_tokens/max_tokens are read when a gateway supplies them, entries without a usable id are skipped, and everything else the adopting surface still owes." "everything else" 就包括模态。所以不管点哪种,input 都得你自己填。
OpenRouter 的 /api/v1/models 是有完整模态信息的:
curl -s https://openrouter.ai/api/v1/models \
| python3 -c "import json,sys; d=json.load(sys.stdin)['data']; \
m=[x for x in d if x['id']=='stealth/ox-alpha'][0]; print(m['architecture'])"
{"modality": "text+image+video->text",
"input_modalities": ["text", "image", "video"],
"output_modalities": ["text"], "tokenizer": "Other"}
input_modalities 就是我们要的东西。所以正确做法是从这里拉,自己生成配置。仓库里的 gen-models.py 干的就是这件事:
python3 gen-models.py stealth/ox-alpha
llm-pi-ai:
providers:
openrouter:
models:
- id: stealth/ox-alpha
name: Ox Alpha
contextWindow: 1048576
maxTokens: 131072
input: [ text, image ]
# 注意:OpenRouter 标称还支持 video,但 pi-ai 模态枚举只有 text/image,传不进去
可以一次生成多个,或者按能力批量筛:
python3 gen-models.py stealth/ox-alpha anthropic/claude-haiku-4.5
python3 gen-models.py --vision-free # 所有免费且支持图像的(当前 12 个)
脚本做了三件手写容易漏的事:过滤掉 pi-ai 不支持的模态(video / file 会被丢弃并留注释)、给含冒号的模型名加引号(Anthropic: Claude Haiku 4.5 不加引号会破坏 YAML)、带上线上的 contextWindow 和 maxTokens(不写的话按兜底值 262144 / 32768 算)。
因为模态在这套设计里是一个声明,不是一次探测。官方文档的原话:"Both fields state a claim about your endpoint rather than checking it."
自动拉看着方便,但它会把"OpenRouter 说这模型支持 video"这种信息也带进来,而 pi-ai 根本传不了 video——于是变成一个多声明:请求发出去、用户消息已落库、服务端拒绝,会话卡在反复重发上。少声明只是一句报错,多声明是会话卡死。
所以这条线上,生成脚本的输出仍然是给你审一眼再贴进去的草稿,不是自动同步。
用一张内容明确、能对答案的图。这里用《牛来》的一帧剧照:
放进工作区,界面里选中 Ox Alpha,然后问:
用 read_image 看 niulai-test.jpg,回答四件事,一行一个:
1) 画面里一共有几只动物 2) 体型大的那只头上的角是什么颜色
3) 右边小的那只是什么颜色 4) 天空是什么颜色
正确答案:2 只 / 紫色 / 黄色 / 黄橙色夕阳。实测四项全中,16 秒。
是它有没有真的调用 read_image。第 3 步没生效时模型不会放弃,它会改去调 OCR——那样图上的文字还能读出来,但数量、颜色这类纯视觉信息答不出来。所以问题里一定要有颜色和数量。
上面的配置里,模型选择器没有 Low / High / Max 档位。因为手输的模型没声明思考能力,pi-ai 就当它不会思考。模型实际还在思考(ox-alpha 关不掉),只是按自己的默认档 max 跑,你控制不了。
多数情况这样就挺好。想要选择器,加两处:
llm-pi-ai:
providers:
openrouter:
reasoning: max # ← 和下面成对出现,不能只加一个
models:
- id: stealth/ox-alpha
name: Ox Alpha
contextWindow: 1048576
maxTokens: 65536 # 思考 token 也算在这个预算里
input: [ text, image ]
reasoningEfforts:
low: low
high: high
max: max # 故意不写 off
三个约束,违反任何一条都会报错:
reasoning: max 不能省。 声明了 reasoningEfforts,pi-ai 就认为该模型会思考,每次请求都要带档位;某次没带时它会发 reasoning: {effort: "none"},而 ox-alpha 的思考强制开启,直接 400 Reasoning is mandatory...。dsh 内部有生成会话标题这类后台调用不走界面,所以这个错会冷不丁蹦出来。reasoningEfforts 里不能有 off(写成 off: 留空也不行),同样的 400。maxTokens 要给足。 实测 max_tokens: 60 时 high 档 60 个 token 全被思考吃光,正文一个字没输出,接口还返回 200。端点上限 131072。这套只适合"路由下只有 ox-alpha"。 reasoning: 是路由级的,会强加给该路由每个模型。实测加两个目录模型后两个都起不来:UNSUPPORTED_REASONING_EFFORT。要混用多个模型,就把 reasoning: 和 reasoningEfforts 一起去掉,回到上面那份最简配置。
默认档填什么(一道编码题,单次测量):
| 档位 | 耗时 | 思考字数 | 输出 token |
|---|---|---|---|
| low | 5.6s | 0 | 145 |
| high | 4.8s | 0 | 146 |
| max | 11.0s | 812 | 368 |
low / high 在这道题上压根没触发思考。模型自己会判断该不该想,默认拉到 max 即可。
档位是按会话记的。改了 reasoning: 要开新会话才看得到新默认值;老会话保留它自己的档位,这不是配置没生效。
dsh 的几个默认值对 ox-alpha 偏紧,长任务容易撞上。这些和 Claude Code 的 CLAUDE_* / BASH_* 环境变量是两套东西,dsh 一个都不认,要写进 dsh 自己的配置。
settings.yaml 的 provider 上:
llm-pi-ai:
providers:
openrouter:
streamIdleTimeoutMs: 900000 # 默认 300000(5分钟)
retryPolicy:
mode: normal
maxRetries: 8 # 默认 5
backoff: { initialDelayMs: 500, maxDelayMs: 30000, jitterRatio: 0.1 }
models:
- id: stealth/ox-alpha
maxTokens: 131072 # 端点上限,避免长输出被截
# ...其余同上
cordis.patch.yml 里:
- id: bash-sandbox
config:
timeoutMs: 600000 # 默认 60000(60秒)
最该改的是 bash-sandbox.timeoutMs。 基线值是 60 秒,实测跑 sleep 90 会在 60 秒被 SIGTERM 杀掉:
超时被终止,没有输出 —— 超时时长 60000ms,终止方式 SIGTERM
提到 600000 之后同一条命令正常跑完。streamIdleTimeoutMs 管的是"模型安静多久算超时"。ox-alpha 强制思考,max 档 + 长上下文确实可能长时间不吐字——用 curl 测视觉时,默认档就撞过 2 分钟不返回。
thinkingBudgets 别配。 pi-ai 有这个字段,但只有 anthropic-messages / google-* / bedrock 这几条线会读它;openrouter 线走的 openai-completions.js 根本没引用,配了是死配置。ox-alpha 的思考预算只能通过 reasoningEfforts 档位间接控制。
输出长度不用调。 dsh 的 spill-policy.maxInlineBytes 默认 50000,但超出后是把全文存盘、只给模型看头尾预览加取回路径,不是丢弃。跟"日志被截断"不是一回事。
| 报错 | 原因 | 修法 |
|---|---|---|
does not declare image input | 该模型条目缺 input | 第 3 步。注意模态不跨模型继承 |
does not accept image input, but this session already contains images | 同上,切模型时出现 | 同上,补完刷新页面 |
模型改去调 OCR 而不是 read_image | input 没生效 | 检查缩进,确认加在 ox-alpha 那条下面 |
UNKNOWN_MODEL | 模型不在 models 列表里 | models 是替换不是追加,要用的都得列 |
Reasoning is mandatory...(400) | 请求发了 effort: "none" | 加 reasoning: max,或把 reasoningEfforts 一起删掉 |
UNSUPPORTED_REASONING_EFFORT | 路由级 reasoning: 强加给了不支持的模型 | 路由下不止 ox 时,去掉 reasoning: 和 reasoningEfforts |
NO_ADAPTER: no adapter registered for provider "openrouter" | input 里写了 video,整条路由注册失败 | 删掉 video,pi-ai 只认 text/image;要用视频得绕开 harness 直连 API(见 § 03) |
MISSING_CREDENTIAL | OPENROUTER_API_KEY 没解析到 | 确认启动服务那个 shell 里有这个变量 |
402 requires more credits | OpenRouter 余额不足 | ox-alpha 免费不受影响,别的模型要充值 |
404 No endpoints found | 该模型 OpenRouter 已下架 | 换一个,内置目录可能比线上滞后 |
| 端口被占 | 3080 已占用 | dsh web --port 3090 |
装依赖像卡死,node_modules 一直空的 | npm 在解析依赖树,正常 | 等(约 9 分钟),或用 pnpm |
workspace-write 沙箱。有些 demo 写 permission.defaultPreset: danger-full-access,那会让 agent 读写工作区之外的任何文件,别在真项目里这么干。