cordis.patch.yml,不用禁用任何东西。
export OPENROUTER_API_KEY=... 进环境变量。npx @deepseek-ai/[email protected] 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/[email protected] 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 标称支持,但 pi-ai 的模态枚举只有 text | image。
上面那 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 |
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 读写工作区之外的任何文件,别在真项目里这么干。