Files

1 line
1.1 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "7as0nch/mimo2codex", "files": {"mimoskill/SKILL.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595134.5515378, "n": 15480, "h": "50c513f009fe8792", "c": "---\nname: mimoskill\ndescription: Use Xiaomi MiMo V2.5 (the LLM behind mimo2codex) for chat, vision, web search, TTS and ASR — and route around capabilities MiMo doesn't natively support, especially OCR / image recognition / 识图 / 提取图片文字 / extract text from image when the current model can't see images, and image generation / 图像生成 / 生成图片 / draw a picture / 画一张 including Codex Pets `/hatch`. Trigger when the user mentions MiMo, calls into mimo2codex, asks to read text from an image, asks to describe or 识别 an image while using a non-vision model (mimo-v2.5-pro, …), asks to generate / hatch a Codex pet, asks for image generation while using MiMo as the chat backend, or hits a \"no image generation available\" / \"image_gen tool unavailable\" / \"this model does not support image input\" message inside Codex.\n---\n\n# mimoskill — Xiaomi MiMo V2.5 + gap fillers\n\nThis skill bundles two things:\n\n1. **Direct MiMo V2.5 access** — recipes for hitting `https://api.xiaomimimo.com/v1` for chat, vision, web search, TTS, and ASR (works whether or not the [mimo2codex](../README.md) proxy is running).\n2. **Workarounds for MiMo's gaps** — concrete scripts for the few things MiMo doesn't do, particularly **image generation** (which is what Codex's `/hatch` pet creation needs).\n\n## Hard rules (for Codex agents)\n\nThese are non-negotiable when operating inside Codex pointed at this proxy:\n\n- **Never `pip install openai` and never `import openai`.** All scripts use only\n the Python standard library (`urllib.request`, `json`, etc.). The OpenAI SDK\n would fail auth or hit non-existent endpoints.\n- **Never assume image generation is available natively.** MiMo has no image-gen\n endpoint. Use `scripts/generate_image.py` or `scripts/generate_pet.py`.\n- **Don't fight the sandbox.** If you need a Python dependency, check\n `mimoskill/scripts/` first — most things are already there in stdlib.\n- **Non-vision model + image input → OCR it.** When the chat model can't see\n images, run `scripts/ocr.py` — never ask the user to switch models.\n\n## When to use\n\nTrigger this skill when:\n\n- User asks to hit MiMo's API directly (chat / vision / web search / TTS / ASR)\n- User asks \"how do I generate a Codex pet\" / \"/hatch isn't working\" / \"image_gen tool not available\"\n- User wants image generation as part of a MiMo-backed workflow\n- User pastes the Codex error: `the image generation tool (image_gen) is not available in this environment` or `the CLI fallback requires the openai Python package`\n- User wants to **OCR / read text from / describe / 识别 / 提取文字 from an image** while the active chat model is non-vision (e.g. mimo-v2.5-pro, deepseek-*, or any third-party text-only model) — use `scripts/ocr.py`. Works with or without a MiMo key (free pollinations fallback when `MIMO_API_KEY` is unset).\n- User sees the proxy's `[N image attachment(s) omitted: this model does not support image input …]` placeholder in their transcript\n- Anything in the `mimo2codex` repo that touches a feature MiMo doesn't support\n\n## What MiMo V2.5 does and doesn't do\n\nQuick answer:\n\n| Capability | MiMo native | Best model | Notes |\n|---|---|---|---|\n| Text chat | ✅ | `mimo-v2.5-pro` | reasoning + tools |\n| Tool / function calling | ✅ | any | parallel calls supported |\n| Vision (image input) | ✅ | `mimo-v2.5` | NOT mimo-v2.5-pro |\n| Web search | ✅ | any | requires Web Search Plugin activated in MiMo console |\n| TTS (speech synth) | ✅ | `mimo-v2.5-tts` | separate endpoint |\n| ASR (speech recog) | ✅ | `mimo-v2.5-asr` | separate endpoint |\n| **Image generation** | ❌ | — | `scripts/generate_image.py` (general) or `scripts/generate_pet.py` (Codex pets) — see below |\n| OCR / 识图 (when chat model is non-vision) | ⚠️ via `mimo-v2.5` or free pollinations | `scripts/ocr.py` | `--engine auto`: mimo if `MIMO_API_KEY` set, else pollinations (no key) |\n| Code interpreter / sandbox | ❌ | — | not provided |\n\nFor the full capability matrix and examples, read [references/models.md](references/models.md).\n\n## Decision tree: what does the user actually want?\n\n```\nIs it OCR / read text from image / describe / 识别 an image\nwhen the active chat model is non-vision?\n├── Yes → use scripts/ocr.py (mimo-v2.5 if MIMO_API_KEY set, else free pollinations)\n└── No\n │\n Is it chat / vision / search / TTS / ASR with a vision-capable model?\n ├── Yes → use MiMo directly (see \"Calling MiMo directly\" below) or via mimo2codex if Codex is the client\n └── No, they want image generation\n │\n Is it for a Codex pet (`/hatch`)?\n ├── Yes → see \"Generating a Codex pet\" below (scripts/generate_pet.py + install_pet.sh)\n └── No → see \"General (non-pet) image generation\" below (scripts/generate_image.py)\n```\n\n## Calling chat directly (works without any key)\n\nUse `scripts/mimo_chat.py` for one-shot or streaming chat. Two engines, `--engine auto` (default) picks `mimo` if `MIMO_API_KEY` is set, else `pollinations` (free, no key) — so **the script works without any key** for text and vision.\n\n```bash\n# Zero-setup — uses pollinations fallback when MIMO_API_KEY is unset\npython3 mimoskill/scripts/mimo_chat.py \"your prompt here\"\npython3 mimoskill/scripts/mimo_chat.py --image https://example.com/x.png \"describe this\"\n\n# Best quality + MiMo-specific features (web search, TTS, ASR)\nexport MIMO_API_KEY=sk-xxxxxxxxxxxxxxxx\npython3 mimoskill/scripts/mimo_chat.py \"your prompt here\"\npython3 mimoskill/scripts/mimo_chat.py \"今天上海天气?\" # web search auto-enabled on sk-* keys\npython3 mimoskill/scripts/mimo_chat.py --stream \"tell me a story\"\n```\n\nWhen the mimo engine is active the script handles all MiMo-specific quirks — `max_completion_tokens` instead of `max_tokens`, the required `text` part next to `image_url`, `reasoning_content` round-tripping, etc. **Web search is auto-enabled on pay-as-you-go (`sk-*`) keys** — the `web_search` builtin is always included in the tools array and the model decides when to invoke it (`tool_choice: \"auto\"`). Token-plan (`tp-*`) keys skip web search (the endpoint doesn't support it). The pollinations engine doesn't support web search, TTS, or ASR (those are MiMo native features); it auto-switches to OpenAI-compat field names (`max_tokens`).\n\nFor non-trivial integrations, [references/models.md](references/models.md) and [the official MiMo OpenAI-compat doc](https://platform.xiaomimimo.com/docs/api/chat/openai-api) are the authoritative references.\n\n## OCR / image recognition (when the chat model can't see images)\n\nIf the user wants to **read text from an image** or **describe / 识别 an image** but the current chat model is non-vision (`mimo-v2.5-pro`, `deepseek-*`, or any third-party text-only model), invoke `scripts/ocr.py`. Three engines, `--engine auto` (default) picks in this order — mimo if `MIMO_API_KEY` set, else tesseract if installed and mode=text, else pollinations:\n\n- **`mimo`** — needs `MIMO_API_KEY`, uses `mimo-v2.5` regardless of the chat model. Best quality. All modes.\n- **`tesseract`** — **no key, no network**. Fully local OCR. Auto-used if installed and `--mode text`. Recommended for users behind GFW or offline. One-time install: `brew install tesseract tesseract-lang` / `sudo apt install tesseract-ocr tesseract-ocr-chi-sim` / Windows installer at github.com/UB-Mannheim/tesseract/wiki.\n- **`pollinations`** — free public vision endpoint at `text.pollinations.ai`, **no key required**. All modes. But may be unreachable from mainland China — if you see \"connection failed (pollinations)\", suggest tesseract as the offline alternative.\n\nThe proxy silently drops image attachments on non-vision models (`src/translate/reqToChat.ts:48-72`) and leaves a `[N image attachment(s) omitted: …]` placeholder. **When you see that placeholder in the transcript, the right move is to run ocr.py and feed the text back into the conversation.** Don't ask the user to switch models.\n\n```bash\n# Zero-setup — uses pollinations fallback when MIMO_API_KEY is unset\npython3 mimoskill/scripts/ocr.py path/to/image.png\npython3 mimoskill/scripts/ocr.py --mode describe https://example.com/x.png\npython3 mimoskill/scripts/ocr.py --mode structured a.png b.jpg\ncat scan.png | python3 mimoskill/scripts/ocr.py --mode markdown\n\n# Best quality — set MiMo key, auto picks mimo\nexport MIMO_API_KEY=sk-xxxxxxxxxxxxxxxx\npython3 mimoskill/scripts/ocr.py path/to/image.png\n\n# Force the free engine even when you have a MiMo key (e.g. to save quota)\npython3 mimoskill/scripts/ocr.py --engine pollinations form.png\n```\n\n`ocr.py` accepts local paths, http(s) URLs, `data:` URLs, or stdin bytes. Magic-byte sniffs the MIME (PNG / JPEG / GIF / WebP / BMP). Multiple positional args are batched into one upstream call. Non-vision `--model` values are auto-coerced to `mimo-v2.5` with one stderr note (mimo engine only; on pollinations use `--pollinations-model`).\n\nSee [references/ocr_workflow.md](references/ocr_workflow.md) for full mode reference, exit codes, JSON shape for `--mode structured`, and the `--lang` / `--prompt` knobs.\n\n## General (non-pet) image generation\n\nFor arbitrary image generation, use `scripts/generate_image.py` — a thin wrapper over `generate_pet.py` with the chibi-pet prompt boilerplate removed and an optional `--style` for common looks. Same providers (`auto` / `pollinations` / `gpt-image-1` / `replicate` / `local-sd`), same env vars, same `auto` fallback to free Pollinations when you only have a MiMo key.\n\n```bash\n# free, no key\npython3 mimoskill/scripts/generate_image.py \\\n --prompt \"isometric cyberpunk city at dusk\" --out /tmp/out.png\n\n# with a style preset\npython3 mimoskill/scripts/generate_image.py --style pixel-art \\\n --prompt \"a brave knight\" --out /tmp/knight.png\n\n# multiple variants -> /tmp/img-1.png /tmp/img-2.png /tmp/img-3.png /tmp/img-4.png\npython3 mimoskill/scripts/generate_image.py --n 4 \\\n --prompt \"watercolor desert sunrise\" --out /tmp/img.png\n\n# best quality (needs PET_OPENAI_API_KEY — same env var as the pet flow)\nexport PET_OPENAI_API_KEY=sk-real-openai-key\npython3 mimoskill/scripts/generate_image.py --provider gpt-image-1 \\\n --prompt \"...\" --out /tmp/out.png\n```\n\n`--style` choices: `plain` (default, no prefix), `pixel-art`, `photo`, `3d-render`, `line-art`, `watercolor`, `sticker`. `plain` sends your prompt verbatim — pick that when the user gave a fully-specified prompt.\n\nFor **Codex `/hatch` pets** keep using `generate_pet.py` + `install_pet.sh` — that flow is unchanged and tuned for the chibi sprite + 3-state bundle Codex wants.\n\n## Generating a Codex pet (the `/hatch` alternative)\n\n**Why this needs special handling**: Codex's built-in `/hatch` pet generation requires OpenAI's image generation API (`gpt-image-1`). MiMo doesn't have an image generation endpoint, and mimo2codex can't fake one. So `/hatch` from inside Codex won't work when Codex is pointed at MiMo.\n\n**The workaround**: generate the pet image *outside* of Codex, then drop the result into Codex's pet directory and restart Codex. The script supports several image-gen backends:\n\n- **`auto` (default)** — picks `gpt-image-1` if you have an OpenAI key set, otherwise falls back to **pollinations.ai** (free, no key, no signup). **Works with only a MiMo key.**\n- **`pollinations`** — free, no key required\n- **`gpt-image-1`** — best quality, needs a real OpenAI key (separate from `MIMO_API_KEY`)\n- **`replicate`** — FLUX/SDXL, ~$0.003/img, needs `REPLICATE_API_TOKEN`\n- **`local-sd`** — Automatic1111/ComfyUI on `127.0.0.1:7860`, free, needs local setup\n\n### Quickstart (only MiMo key required)\n\n```bash\n# 1. No OpenAI key, no pip install — just run with the free fallback\npython3 mimoskill/scripts/generate_pet.py \\\n --description \"a chubby cyberpunk axolotl coding hero\" \\\n --out ~/Downloads/my-pet.png\n\n# 2. Install into Codex's pet folder\nbash mimoskill/scripts/install_pet.sh ~/Downloads/my-pet.png \"axolotl-coder\"\n\n# 3. Restart Codex completely and select the new pet from the pet menu\n```\n\n### If the sandbox blocks the network call\n\nCodex's sandbox may prevent the scripts from reaching external APIs\n(Pollinations, OpenAI, Replicate, MiMo, etc.). When that happens, do **not**\nrespond with \"please install openai\" or try to work around the sandbox.\nTell the user to run the command in a regular terminal:\n\n> I can't reach the network from inside the sandbox. Please run the\n> following in a regular terminal (outside Codex), then tell me when it's\n> done and I'll continue:\n>\n> python3 mimoskill/scripts/generate_pet.py --description \"...\" --out /tmp/pet.png\n> bash mimoskill/scripts/install_pet.sh /tmp/pet.png \"<pet-name>\"\n>\n> No `pip install` is needed — the script uses only the Python standard\n> library.\n\nThe same pattern applies for `generate_image.py`, `ocr.py`, and `mimo_chat.py`.\n\n`generate_pet.py` will print `[provider] auto → pollinations` so you know the free path is in use.\n\n### Optional: better quality with an OpenAI key\n\nIf you do want gpt-image-1 quality (and image-to-image edit via `--reference`):\n\n```bash\nexport PET_OPENAI_API_KEY=sk-real-openai-key # NOT mimo2codex-local\npython3 mimoskill/scripts/generate_pet.py \\\n --reference path/to/source-image.jpg \\\n --description \"a chubby cyberpunk axolotl coding hero\" \\\n --out ~/Downloads/my-pet.png\n```\n\n`auto` will pick gpt-image-1 automatically when this env var is set. This OpenAI key is **only** used for the image generation call — your chat conversations still go through MiMo via mimo2codex.\n\n### Step-by-step walkthrough + prompt design\n\nRead [references/pet_workflow.md](references/pet_workflow.md) for:\n\n- The exact Codex pet folder location on macOS / Linux / Windows\n- How to make a static image work (most pets are animated GIFs, but a static PNG fallback works)\n- How to generate animated states (idle / working / done) — typically requires multiple gpt-image-1 calls with edit / remix prompting\n- How to mix MiMo + image gen: have MiMo write the prompt, then feed that prompt to gpt-image-1\n\nUse the proven pet prompt formula in [assets/pet_prompt_template.md](assets/pet_prompt_template.md) — it's tuned for the chibi / sticker style Codex uses.\n\n## Image generation in general\n\nIf the user wants image generation for some other reason (not a pet), the same workaround applies: `gpt-image-1` is the highest-quality option but requires a real OpenAI key. Free alternatives:\n\n- **Stable Diffusion** locally via [Automatic1111](https://github.com/AUTOMATIC1111/stable-diffusion-webui) or [ComfyUI](https://github.com/comfyanonymous/ComfyUI) — heavy setup but no per-call cost\n- **Together AI** / **Replicate** — pay-as-you-go for SDXL / FLUX\n- **Pollinations.ai** — free, no key required, lower quality\n\n`scripts/generate_pet.py` defaults to gpt-image-1 but accepts `--provider pollinations` for the free path (with reduced quality).\n\n## Cost notes\n\n- Direct MiMo: pay-as-you-go (`sk-xxx`) or token plan (`tp-xxx`). See [pricing](https://platform.xiaomimimo.com/docs/pricing).\n- Web Search plugin: separately metered per keyword search. Cap with `max_keyword`.\n- gpt-image-1: ~$0.04 per 1024×1024 image (low quality), up to ~$0.17 (HD). One pet usually costs <$0.50 even with retries.\n- Pollinations.ai: free.\n\n## Don't use this skill for\n\n- Just running mimo2codex (that's an HTTP proxy; this skill is direct API + workarounds). For mimo2codex itself, see the project [README.md](../README.md) / [README.zh.md](../README.zh.md).\n- Configuring Codex (use `mimo2codex print-config` or `mimo2codex print-cc-switch`).\n- Anything Anthropic / Claude — this is MiMo-specific.\n"}}, "doc/mimoskill.zh.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595135.3490252, "n": 10883, "h": "ab212192a6ae3398", "c": "# mimoskill · 详细介绍\n\n> [English](./mimoskill.md) · 中文\n>\n> 回到:[README English](../README.md) · [README 中文](../README.zh.md)\n\n`mimoskill/` 是仓库根目录下一捆**辅助脚本 + 参考文档**。它存在的原因是有些事 MiMo / DeepSeek / 大多数纯文本 LLM 原生做不了(图像生成、纯文本模型看图、…),而 Codex 在客户端硬编码了一些能力假设,代理层压根改不动。\n\n代理(mimo2codex)和 mimoskill **完全独立**:不跑 mimo2codex 也能用 mimoskill,反之亦然。两者通过**约定**协作:代理检测到能力缺口时,会在消息里塞占位文本,指向对应的 `mimoskill/scripts/*.py`。\n\n## 什么时候会触发?\n\n> 一句话:**\"模型能做的事 proxy 透传,模型做不了的事 mimoskill 兜底。\"**\n\n| 能力 | 当前 chat 模型能做 | 当前 chat 模型做不了 |\n|---|---|---|\n| 看图 / OCR / 识图 | proxy 透传图片给模型;**mimoskill 不触发** | proxy 剥掉图片、塞 `[N image attachment(s) omitted: … python3 mimoskill/scripts/ocr.py <path> …]` 占位文本;LLM 读到占位 + AGENTS.md 后 **去跑 `ocr.py`** |\n| 图像生成 | 没有任何主流 chat 模型自带 image-gen | **mimoskill 永远触发** —— `scripts/generate_image.py` 或 `scripts/generate_pet.py` |\n| 联网搜索 | proxy 在 MiMo `sk-*`(按量)key 下把 Codex 的 `web_search` 翻译成 MiMo 内置的;`tp-*`(套餐)key 与 DeepSeek 自动跳过 | `scripts/mimo_chat.py` 遵循同样规则 —— MiMo `sk-*` 自动启用,`tp-*` / pollinations 跳过。**无需参数** |\n| TTS / ASR | Codex 没接 | `scripts/mimo_chat.py` 直接调 MiMo 的独立端点 |\n\n触发**发生在 LLM 这一层**,不在 proxy 层。proxy 只做协议翻译 + 最小兼容性修整(剥图、塞占位文本)。Codex 读 [AGENTS.md](../AGENTS.md) 和 [mimoskill/SKILL.md](../mimoskill/SKILL.md),看到占位文本或者用户意图后,自己决定调哪个脚本。脚本是独立子进程,**完全绕开 proxy** —— OCR 直接打 MiMo 或 pollinations,出图直接打 pollinations 或 OpenAI,等等。\n\n## 目录结构\n\n```\nmimoskill/\n├── SKILL.md # 给 LLM 看的 skill 清单 —— 触发规则 + 决策树\n├── scripts/\n│ ├── mimo_chat.py # 直接调 MiMo 聊天 / 视觉 / 联网搜索(纯标准库)\n│ ├── ocr.py # OCR / 识图。MiMo 或免费 pollinations\n│ ├── generate_image.py # 通用图像生成(任意风格 / 主题)\n│ ├── generate_pet.py # Codex 宠物生成(chibi 贴纸风)\n│ └── install_pet.sh # 把生成的 PNG 装到 Codex 的宠物目录\n├── references/\n│ ├── models.md # MiMo 能力矩阵 + 字段坑\n│ ├── ocr_workflow.md # 完整 OCR 模式参考、退出码、JSON 结构\n│ └── pet_workflow.md # 单图 vs 多状态动画 bundle\n└── assets/\n └── pet_prompt_template.md # 调好的 chibi 贴纸提示词模板\n```\n\n## 脚本详解\n\n### `scripts/mimo_chat.py` —— 聊天 / 视觉(无 key 也能用)\n\n纯标准库 Python 脚本,单轮或流式聊天。两个引擎,跟 `ocr.py` 是同一套 `--engine auto|mimo|pollinations`:\n\n| 引擎 | 需要 key | 备注 |\n|---|---|---|\n| `mimo` | 需要 `MIMO_API_KEY` | 最佳质量。`sk-*` key 自动启用 web_search(无需参数),TTS / ASR 也只能用这个 |\n| `pollinations` | **不需要** | 免费公共端点 `text.pollinations.ai`。文本 + 视觉可用,联网搜索 / TTS / ASR 不可用 |\n\nauto 选择:有 `MIMO_API_KEY` 用 mimo,否则 pollinations。**这个脚本现在不依赖任何 key**(纯文本 + 视觉场景)。\n\n```bash\n# 零配置 —— 自动走 pollinations 兜底\npython3 mimoskill/scripts/mimo_chat.py \"讲个笑话\"\npython3 mimoskill/scripts/mimo_chat.py --image https://x/y.png \"描述这张图\"\n\n# 最佳质量 + MiMo 原生能力(sk-* key 自动开 web_search,TTS、ASR)\nexport MIMO_API_KEY=sk-xxxxxxxxxxxxxxxx\npython3 mimoskill/scripts/mimo_chat.py \"今天上海天气\" # 自动带 web_search\npython3 mimoskill/scripts/mimo_chat.py --model mimo-v2.5-pro --max-tokens 8000 --stream \"写长一点\"\n```\n\nmimo 引擎自动踩好 MiMo 的坑:`max_completion_tokens`(不是 `max_tokens`)、图片必须配 `text` part、多轮 `reasoning_content` 回填、联网搜索插件调用。\n\n| 参数 | 说明 |\n|---|---|\n| `--engine` | `auto` / `mimo` / `pollinations`(默认 auto) |\n| `--model` | 默认 `mimo-v2.5-pro`(mimo 引擎)。视觉用 `mimo-v2.5` |\n| `--pollinations-model` | 默认 `openai`(视觉能力)。可选 `openai-large` / `openai-fast` |\n| `--image URL` | 附图。自动 bump 到视觉能力模型 |\n| `--stream` | SSE 流式 |\n| `--max-tokens N` | mimo 引擎映射到 `max_completion_tokens`,pollinations 映射到 `max_tokens` |\n| `--temperature F` | 默认 0.7 |\n\n### `scripts/ocr.py` —— OCR / 识图\n\n非视觉 chat 模型场景下的兜底。**三个引擎**(`--engine auto` 自动选):\n\n| 引擎 | 需要 key | 需要网络 | 支持 mode | 备注 |\n|---|---|---|---|---|\n| `mimo` | 需要 `MIMO_API_KEY` | 需要 | 全部 | 内部调 `mimo-v2.5`(视觉模型),与外层 chat 模型无关。质量最好 |\n| `tesseract` | **不需要** | **不需要** | 仅 text | 纯本地 OCR。**国内 / GFW 环境推荐**——pollinations 不通时就靠它兜底。装一次永久可用 |\n| `pollinations` | **不需要** | 需要 | 全部 | 免费公共端点 `text.pollinations.ai`,无需注册。但国内访问可能慢 / 不通 |\n\nauto 选择顺序:`mimo`(有 key)→ `tesseract`(已装 + `--mode text`)→ `pollinations`。所以**只配了 DeepSeek key**(或啥都没配)的用户也能零配置用 OCR;国内用户装一次 tesseract 之后 OCR 就完全离线了。\n\n```bash\n# 零配置 —— auto 自动挑可用的引擎\npython3 mimoskill/scripts/ocr.py path/to/image.png\n\n# 国内 / 离线 —— 装一次 tesseract,永久离线可用\n# macOS: brew install tesseract tesseract-lang\n# Ubuntu: sudo apt install tesseract-ocr tesseract-ocr-chi-sim\n# Windows: https://github.com/UB-Mannheim/tesseract/wiki\npython3 mimoskill/scripts/ocr.py --engine tesseract --lang Chinese scan.png\n\n# 最佳质量 —— 设 MiMo key\nexport MIMO_API_KEY=sk-xxxx\npython3 mimoskill/scripts/ocr.py path/to/image.png # auto -> mimo\n\n# 强制走 pollinations(即便你有 MiMo key,比如想省额度)\npython3 mimoskill/scripts/ocr.py --engine pollinations form.png\n\n# 强制 MiMo —— 没设 key 直接报错(不静默降级)\npython3 mimoskill/scripts/ocr.py --engine mimo form.png\n```\n\n四个输出模式:\n\n| `--mode` | 输出 |\n|---|---|\n| `text`(默认) | 逐字 OCR —— 保留换行 + 阅读顺序 |\n| `describe` | 2-4 句描述 |\n| `structured` | 单个 JSON:`text` / `language` / `regions[]` / `summary` |\n| `markdown` | 整张图重新渲染成 GitHub-flavored Markdown |\n\n输入形态(位置参数,0+ 个):\n- 本地路径:`./scan.png`、`C:\\foo.jpg`\n- HTTP(S) URL:原样转发\n- `data:image/...;base64,…`:原样转发\n- `-` 或管道 stdin:从 stdin 读一张图的字节\n\nmagic-byte 嗅探 MIME(不信任扩展名):PNG / JPEG / GIF / WebP / BMP。多个位置参数会**一次 upstream 调用**批处理。\n\n> 完整参考:[mimoskill/references/ocr_workflow.md](../mimoskill/references/ocr_workflow.md)(模式、退出码、JSON 结构、lang/prompt 参数、pollinations 细节)。\n\n### `scripts/generate_image.py` —— 通用图像生成\n\n`generate_pet.py` 的薄包装,去掉 chibi 宠物提示词模板、加了可选的 `--style` 常见风格。同样的 providers、同样的环境变量、同样的 auto 兜底策略。\n\n```bash\n# 免费 —— 没设 OpenAI key 时 auto 走 pollinations\npython3 mimoskill/scripts/generate_image.py --prompt \"日式庭园,水彩,黎明\" --out garden.png\n\n# 高质量 —— 设 OpenAI key\nexport PET_OPENAI_API_KEY=sk-real-openai-key\npython3 mimoskill/scripts/generate_image.py --prompt \"...\" --out art.png # auto -> gpt-image-1\n\n# 风格预设\npython3 mimoskill/scripts/generate_image.py --style anime --prompt \"黄昏的神社\" --out shrine.png\n```\n\n| `--provider` | 后端 |\n|---|---|\n| `auto`(默认) | 有 `PET_OPENAI_API_KEY` 走 `gpt-image-1`,否则 `pollinations` |\n| `pollinations` | 免费、无 key |\n| `gpt-image-1` | OpenAI 官方图像生成 —— 最佳质量 |\n| `replicate` | Replicate API(任意模型) |\n| `local-sd` | 本地 Stable Diffusion |\n\n> `PET_OPENAI_API_KEY` 故意**和 `MIMO_API_KEY`、`OPENAI_API_KEY` 分开** —— 只用于图像生成,泄露或不存在都不影响别的事。\n\n### `scripts/generate_pet.py` —— Codex 宠物生成\n\n同样的后端,但内置了一套调好的 chibi 贴纸提示词,围绕 `--description` 组装。输出尺寸 + 留白都按 Codex 宠物选择器适配。\n\n```bash\n# 单张静态宠物(免费)\npython3 mimoskill/scripts/generate_pet.py --description \"chibi shiba 程序员\" --out pet.png\n\n# 多状态动画 bundle(idle / thinking / typing / sleeping)\npython3 mimoskill/scripts/generate_pet.py --description \"chibi 猫\" --bundle ./shiba/\n```\n\n提示词模板在 [mimoskill/assets/pet_prompt_template.md](../mimoskill/assets/pet_prompt_template.md)。完整流程见 [mimoskill/references/pet_workflow.md](../mimoskill/references/pet_workflow.md)。\n\n### `scripts/install_pet.sh` —— 装宠物到 Codex\n\n自动探测 macOS / Linux / Windows 的宠物目录,把 PNG(或 bundle)拷过去。绕开 Codex 硬编码的宠物路径问题。\n\n```bash\nbash mimoskill/scripts/install_pet.sh pet.png shiba\n# 然后完全退出 + 重启 Codex(桌面端走系统托盘退出,不只是关窗口)\n```\n\n## 三种用法\n\n### 1. 直接调用(普通用户,零配置)\n\n```bash\npython3 mimoskill/scripts/mimo_chat.py \"...\"\npython3 mimoskill/scripts/ocr.py invoice.png # 无 key 也能跑,走免费 pollinations\npython3 mimoskill/scripts/generate_image.py --prompt \"...\"\n```\n\n不需要注册 skill —— 就是普通 Python 脚本(纯标准库,不用 `pip install`)。\n\n### 2. 当 Claude Code 的 Skill 用\n\n软链到 `~/.claude/skills/`:\n\n```bash\nln -s \"$(pwd)/mimoskill\" ~/.claude/skills/mimoskill\n```\n\n之后 Claude 会读 [SKILL.md](../mimoskill/SKILL.md),遇到相关请求(\"帮我从这张图生成宠物\"、\"读一下这张截图的文字\"、\"让 MiMo 把这段话朗读了\")自动路由到对应脚本。\n\n### 3. 当 Codex agent 指南\n\n仓库根的 [AGENTS.md](../AGENTS.md) 已经接好。Codex 每次启会话都会读,遇到生图 / 宠物 / OCR 任务会路由到 mimoskill 脚本 —— **不会**再去 `pip install openai`,也不会在用 MiMo / DeepSeek / Qwen / 任何非 OpenAI 上游时尝试调 OpenAI 的 `image_gen` 工具。\n\n## 环境变量\n\n| 变量 | 谁用 | 说明 |\n|---|---|---|\n| `MIMO_API_KEY` | `mimo_chat.py`、`ocr.py`(engine=mimo / auto 时) | MiMo Chat / 视觉 key。两个脚本都**可选** —— 没设会自动走 pollinations |\n| `MIMO_CHAT_ENGINE` | `mimo_chat.py` | `auto` / `mimo` / `pollinations` —— 等价于 `--engine` |\n| `MIMO_BASE_URL` | `mimo_chat.py`、`ocr.py` | 默认 `https://api.xiaomimimo.com/v1` |\n| `MIMO_MODEL` / `MIMO_OCR_MODEL` | `ocr.py` 模型 auto-pick | 没传 `--model` 时使用(必须视觉能力) |\n| `MIMO_OCR_ENGINE` | `ocr.py` | `auto` / `mimo` / `pollinations` —— 等价于 `--engine` 参数 |\n| `POLLINATIONS_MODEL` | `ocr.py` | 默认 `openai`(视觉能力)。可选 `openai-large`、`openai-fast` |\n| `PET_OPENAI_API_KEY` | `generate_pet.py`、`generate_image.py` | 跟 `MIMO_API_KEY` / `OPENAI_API_KEY` 独立;只用于图像生成 |\n| `REPLICATE_API_TOKEN` | `generate_*.py --provider replicate` | 仅 Replicate 后端时需要 |\n\n## 常用组合\n\n### 先 OCR 一张图,再用当前 chat 模型总结\n\n```bash\nTEXT=$(python3 mimoskill/scripts/ocr.py invoice.png)\npython3 mimoskill/scripts/mimo_chat.py \"总结这张发票:\\n$TEXT\"\n```\n\n或者直接在 Codex 里:把图贴进去就行。proxy 剥图后留指向 `ocr.py` 的占位文本,Codex 自己跑脚本把文字喂回对话 —— **完全自动**。\n\n### 生成 `/hatch` 替代宠物(无 OpenAI key 也能用)\n\n```bash\npython3 mimoskill/scripts/generate_pet.py --description \"chibi shiba 程序员\" --out pet.png\nbash mimoskill/scripts/install_pet.sh pet.png shiba\n# 完全退出 + 重启 Codex,宠物菜单里挑新的\n```\n\n想要更好质量,设 `PET_OPENAI_API_KEY=sk-真OpenAI-key`,auto 会切到 `gpt-image-1`。\n\n### 结构化 OCR + JSON 解析\n\n```bash\nJSON=$(python3 mimoskill/scripts/ocr.py --mode structured invoice.png)\necho \"$JSON\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d['summary'])\"\n```\n\n### 多图批量 OCR(一次计费)\n\n```bash\npython3 mimoskill/scripts/ocr.py page1.png page2.png page3.png\n```\n\n所有图**单次** upstream 调用,模型可跨图引用(如身份证正反面)。输出是按阅读顺序串联的一段文本。\n\n## 故障排查\n\n<details>\n<summary><b><code>MIMO_API_KEY</code> 未设置</b> —— ocr.py 退出码 3</summary>\n\n你显式传了 `--engine mimo`。要么去掉这个参数(`auto` 会自动降级到 pollinations),要么设 key:\n\n```bash\nexport MIMO_API_KEY=sk-xxxx\npython3 mimoskill/scripts/ocr.py form.png\n```\n\n</details>\n\n<details>\n<summary><b>Pollinations 返回 429 / 限流</b></summary>\n\n撞 IP 限流。等会儿再试,或者切到 `--engine mimo`(如果你有 MiMo key)。\n\n</details>\n\n<details>\n<summary><b>Codex 跑 /hatch 时报 <code>image_gen tool not available</code></b></summary>\n\nCodex 的 `/hatch` 在客户端硬编码调 OpenAI 的 `image_gen` 工具,代理拦不住。改用 `generate_pet.py`,见上文「生成 /hatch 替代宠物」。\n\n</details>\n\n<details>\n<summary><b>报 <code>pip install openai</code> 错 / Codex 想装 openai</b></summary>\n\n是 Codex 想用 openai Python SDK 兜底图像生成。[AGENTS.md](../AGENTS.md) 已经预防这条路 —— 确认它在仓库根,且当前 Codex 会话已经读过(编辑完 AGENTS.md 后要开新会话)。\n\n</details>\n\n<details>\n<summary><b>工具返回了图,但模型在工具结果里看不到图</b></summary>\n\n设计如此。Chat Completions 的 `tool` role 历史上只接受字符串 content —— `function_call_output` 里的图片 content part 会被 flatten 成 `[N image attachment(s) omitted from tool output: ...]` 占位文本(详见 [src/translate/reqToChat.ts](../src/translate/reqToChat.ts) 的 `toolOutputToString`)。要把图喂给 LLM,让工具把图存到本地、返回路径,下一轮用户消息再 `@path/to/screenshot.png` 让 ocr.py 类工具读出来 —— 这时如果 chat 模型不支持视觉,OCR 兜底机制就会接管。\n\n</details>\n\n## 设计取舍\n\n- **不需要 `pip install`。** 所有脚本纯标准库。避免依赖漂移,任何裸 Python ≥ 3.8 都能跑。\n- **网络操作明确。** 不偷偷重试备用端点。要 MiMo 又没 key 就直接报错 —— 而不是静默降级掩盖配错。\n- **proxy 和 mimoskill 互不调用。** 两个独立进程,靠 `AGENTS.md` / `SKILL.md` 约定连接。这样两边都能独立测试 / 替换。\n- **Pollinations 是无 key 逃生通道。** 在 `ocr.py`(视觉)、`generate_pet.py`(出图)、`generate_image.py`(出图)里都用作免费兜底。有 IP 限流但永远在线。项目把它当成一等公民,不是\"降级模式\"。\n"}}, "doc/mimoskill.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595135.631837, "n": 16064, "h": "2567b4a9475210c1", "c": "# mimoskill · Detailed Guide\n\n> English · [中文](./mimoskill.zh.md)\n>\n> Back to: [README English](../README.md) · [README 中文](../README.zh.md)\n\n`mimoskill/` is a bundle of helper scripts + reference docs at the project root. It exists because some things MiMo / DeepSeek / most chat-only LLMs can't natively do (image generation, image understanding when the chat model is text-only, …), and Codex hardcodes a few capability assumptions on the client side that the proxy layer can't override.\n\nThe proxy and mimoskill are **completely independent**: mimoskill works without `mimo2codex` running, and mimo2codex works without mimoskill. They compose by **convention**: when the proxy detects a capability gap it leaves a placeholder text in the message that points the LLM at the right `mimoskill/scripts/*.py` script.\n\n## When does it trigger?\n\n> Short answer: **\"the chat model does what it can; mimoskill fills the gap.\"**\n\n| Capability | Chat model can do it | Chat model CAN'T do it |\n|---|---|---|\n| Read / OCR / 识图 an image | proxy forwards the image to the model directly; **mimoskill not triggered** | proxy strips the image and inserts `[N image attachment(s) omitted: … python3 mimoskill/scripts/ocr.py <path> …]`; the LLM reads that placeholder + AGENTS.md and **runs `ocr.py`** |\n| Generate an image | no mainstream chat model has native image-gen | **mimoskill always triggers** — `scripts/generate_image.py` or `scripts/generate_pet.py` |\n| Web search | proxy forwards Codex's `web_search` to MiMo's builtin on `sk-*` (pay-as-you-go) keys; auto-skipped on `tp-*` (token-plan) and DeepSeek | `scripts/mimo_chat.py` follows the same rule — auto-enables web search on MiMo `sk-*`, skips on `tp-*` / pollinations. No flag needed. |\n| TTS / ASR | not exposed in Codex | `scripts/mimo_chat.py` direct call to MiMo's separate endpoints |\n\nThe triggering **happens in the LLM**, not in the proxy. The proxy only does protocol translation + minimal compatibility fixups (image stripping, placeholder injection). Codex reads [AGENTS.md](../AGENTS.md) and [mimoskill/SKILL.md](../mimoskill/SKILL.md), notices the placeholder or the user's intent, and decides which script to invoke. The script is an independent subprocess that **bypasses the proxy entirely** — OCR talks to MiMo or pollinations directly, image-gen talks to pollinations or OpenAI directly, etc.\n\n## Layout\n\n```\nmimoskill/\n├── SKILL.md # skill manifest the LLM reads — trigger rules, decision tree\n├── scripts/\n│ ├── mimo_chat.py # direct chat / vision / web-search call to MiMo (stdlib-only)\n│ ├── ocr.py # OCR / image recognition. Mimo or free pollinations\n│ ├── generate_image.py # general image generation (any style / subject)\n│ ├── generate_pet.py # Codex pet generation (chibi-sticker style)\n│ └── install_pet.sh # install generated PNG into Codex's pet directory\n├── references/\n│ ├── models.md # MiMo capability matrix + field quirks\n│ ├── ocr_workflow.md # full OCR mode reference, exit codes, JSON shape\n│ └── pet_workflow.md # single-image vs animated bundle generation\n└── assets/\n └── pet_prompt_template.md # tuned chibi-sticker prompt templates\n```\n\n## Scripts in depth\n\n### `scripts/mimo_chat.py` — chat / vision (no key required)\n\nStdlib-only Python script for one-shot or streaming chat. Two engines, same `--engine auto|mimo|pollinations` pattern as `ocr.py`:\n\n| Engine | Needs key | Notes |\n|---|---|---|\n| `mimo` | `MIMO_API_KEY` | Best quality. Web search auto-enabled on `sk-*` keys (no flag needed). TTS/ASR also MiMo-only. |\n| `pollinations` | **NO** | Free public endpoint at `text.pollinations.ai`. Text + vision work. No web search / TTS / ASR. |\n\nAuto resolution: `mimo` if `MIMO_API_KEY` set, else `pollinations`. So this script now works **without any key** for text and vision chat.\n\n```bash\n# Zero-setup — uses pollinations fallback\npython3 mimoskill/scripts/mimo_chat.py \"tell me a joke\"\npython3 mimoskill/scripts/mimo_chat.py --image https://x/y.png \"describe this\"\n\n# Best quality + MiMo native features (web search auto-on with sk-*, TTS, ASR)\nexport MIMO_API_KEY=sk-xxxxxxxxxxxxxxxx\npython3 mimoskill/scripts/mimo_chat.py \"今天上海天气\" # web_search auto-included\npython3 mimoskill/scripts/mimo_chat.py --model mimo-v2.5-pro --max-tokens 8000 --stream \"long answer please\"\n```\n\nFor the mimo engine, the script handles MiMo's quirks transparently: `max_completion_tokens` (not `max_tokens`), the required `text` part next to `image_url`, `reasoning_content` round-tripping for multi-turn, web search plugin invocation.\n\n| Flag | Notes |\n|---|---|\n| `--engine` | `auto` / `mimo` / `pollinations` (default auto) |\n| `--model` | default `mimo-v2.5-pro` (mimo engine). For vision use `mimo-v2.5` |\n| `--pollinations-model` | default `openai` (vision-capable). Alternatives: `openai-large`, `openai-fast` |\n| `--image URL` | attach image. Auto-bumps to vision-capable model |\n| `--stream` | SSE streaming |\n| `--max-tokens N` | maps to `max_completion_tokens` on mimo, `max_tokens` on pollinations |\n| `--temperature F` | default 0.7 |\n\n### `scripts/ocr.py` — OCR / image recognition\n\nOCR fallback for when the chat model can't see images. **Three engines** (`--engine auto` picks):\n\n| Engine | Needs key | Needs network | Modes | Notes |\n|---|---|---|---|---|\n| `mimo` | `MIMO_API_KEY` | yes | all | Calls `mimo-v2.5` (the vision model) regardless of the chat model in use. Best quality. |\n| `tesseract` | **NO** | **NO** | text only | Fully local OCR. Recommended when pollinations is unreachable (e.g. mainland China). One-time install. |\n| `pollinations` | **NO** | yes | all | Free public endpoint at `text.pollinations.ai`. No signup, but may be slow / blocked in some regions. |\n\nAuto resolution order: `mimo` (if `MIMO_API_KEY`) → `tesseract` (if installed **and** `--mode text`) → `pollinations`. So users with **only a DeepSeek key** (or no key at all) still get OCR with zero setup; users behind GFW who install tesseract once never need to touch the network.\n\n```bash\n# Zero-setup — auto picks the best available engine\npython3 mimoskill/scripts/ocr.py path/to/image.png\n\n# Offline / behind GFW — install tesseract once, then it works without network\n# macOS: brew install tesseract tesseract-lang\n# Ubuntu: sudo apt install tesseract-ocr tesseract-ocr-chi-sim\n# Windows: https://github.com/UB-Mannheim/tesseract/wiki\npython3 mimoskill/scripts/ocr.py --engine tesseract --lang Chinese scan.png\n\n# Best quality — set MiMo key\nexport MIMO_API_KEY=sk-xxxx\npython3 mimoskill/scripts/ocr.py path/to/image.png # auto -> mimo\n\n# Force pollinations even when you have a MiMo key (save quota)\npython3 mimoskill/scripts/ocr.py --engine pollinations form.png\n\n# Force MiMo — errors out if MIMO_API_KEY is not set (no silent fallback)\npython3 mimoskill/scripts/ocr.py --engine mimo form.png\n```\n\nFour output modes:\n\n| `--mode` | Output |\n|---|---|\n| `text` (default) | verbatim OCR — line breaks + reading order preserved |\n| `describe` | 2-4 sentence description |\n| `structured` | single JSON object: `text` / `language` / `regions[]` / `summary` |\n| `markdown` | re-render the image as GitHub-flavored Markdown |\n\nInput forms (positional, 0+ args):\n- Local path: `./scan.png`, `C:\\foo.jpg`\n- HTTP(S) URL: forwarded as-is\n- `data:image/...;base64,…`: forwarded as-is\n- `-` or piped stdin: read one image's bytes from stdin\n\nMagic-byte MIME sniffing (not file extension): PNG / JPEG / GIF / WebP / BMP. Multi-image positional args batch into one upstream call.\n\n> Full reference: [mimoskill/references/ocr_workflow.md](../mimoskill/references/ocr_workflow.md) (modes, exit codes, JSON shape, lang/prompt knobs, pollinations specifics).\n\n### `scripts/generate_image.py` — general image generation\n\nThin wrapper over `generate_pet.py` minus the chibi-pet prompt boilerplate, with an optional `--style` for common looks. Same providers, same env vars, same auto-fallback strategy.\n\n```bash\n# Free — auto picks pollinations when no OpenAI key\npython3 mimoskill/scripts/generate_image.py --prompt \"japanese garden, watercolor, dawn\" --out garden.png\n\n# Best quality — set OpenAI key\nexport PET_OPENAI_API_KEY=sk-real-openai-key\npython3 mimoskill/scripts/generate_image.py --prompt \"...\" --out art.png # auto -> gpt-image-1\n\n# Common style presets\npython3 mimoskill/scripts/generate_image.py --style anime --prompt \"shrine at dusk\" --out shrine.png\n```\n\n| `--provider` | Backend |\n|---|---|\n| `auto` (default) | `gpt-image-1` if `PET_OPENAI_API_KEY` set, else `pollinations` |\n| `pollinations` | Free, no key |\n| `gpt-image-1` | OpenAI's official image gen — best quality |\n| `replicate` | Replicate API (any model) |\n| `local-sd` | Local Stable Diffusion |\n\n> `PET_OPENAI_API_KEY` is intentionally **separate from `MIMO_API_KEY` and `OPENAI_API_KEY`** — it's used only for image generation, so leaking it (or just not having one) doesn't affect anything else.\n\n### `scripts/generate_pet.py` — Codex pet generation\n\nSame backends as `generate_image.py`, but with a tuned chibi-sticker prompt built around `--description`. Outputs a PNG sized + framed for Codex's pet picker.\n\n```bash\n# Single static pet (free)\npython3 mimoskill/scripts/generate_pet.py --description \"chibi shiba coder\" --out pet.png\n\n# Animated multi-state bundle (idle / thinking / typing / sleeping)\npython3 mimoskill/scripts/generate_pet.py --description \"chibi cat\" --bundle ./shiba/\n```\n\nPrompt templates live in [mimoskill/assets/pet_prompt_template.md](../mimoskill/assets/pet_prompt_template.md). Full workflow in [mimoskill/references/pet_workflow.md](../mimoskill/references/pet_workflow.md).\n\n### `scripts/install_pet.sh` — install pet into Codex\n\nProbes macOS / Linux / Windows for the right pet directory and copies the PNG (or bundle) there. Works around Codex's hardcoded pet paths.\n\n```bash\nbash mimoskill/scripts/install_pet.sh pet.png shiba\n# Then fully quit + relaunch Codex (system tray → Quit, not just close window)\n```\n\n## Three ways to use it\n\n### 1. Direct invocation (any user, no setup)\n\n```bash\npython3 mimoskill/scripts/mimo_chat.py \"...\"\npython3 mimoskill/scripts/ocr.py invoice.png # works with no key, free pollinations\npython3 mimoskill/scripts/generate_image.py --prompt \"...\"\n```\n\nNo skill registration required — these are standard Python scripts (stdlib-only, no `pip install` step).\n\n### 2. As a Claude Code skill\n\nSymlink the directory into `~/.claude/skills/`:\n\n```bash\nln -s \"$(pwd)/mimoskill\" ~/.claude/skills/mimoskill\n```\n\nClaude reads [SKILL.md](../mimoskill/SKILL.md) and automatically routes relevant requests (\"generate a pet from this image\", \"read the text from this screenshot\", \"MiMo TTS this paragraph\") to the right scripts.\n\n### 3. As a Codex agent guide\n\nAlready wired via [AGENTS.md](../AGENTS.md) at the repo root. Codex reads it on each session and routes image-gen / pet / OCR tasks to mimoskill scripts — it **won't** try to `pip install openai` or call OpenAI's image_gen tool when the active backend is MiMo / DeepSeek / Qwen / any non-OpenAI provider.\n\n## Environment variables\n\n| Var | Used by | Notes |\n|---|---|---|\n| `MIMO_API_KEY` | `mimo_chat.py`, `ocr.py` (engine=mimo / auto when set) | MiMo Chat / vision key. **Optional** for both scripts — they fall back to free pollinations when unset |\n| `MIMO_CHAT_ENGINE` | `mimo_chat.py` | `auto` / `mimo` / `pollinations` — same as `--engine` |\n| `MIMO_BASE_URL` | `mimo_chat.py`, `ocr.py` | default `https://api.xiaomimimo.com/v1` |\n| `MIMO_MODEL` / `MIMO_OCR_MODEL` | `ocr.py` model auto-pick | used when `--model` not passed and vision-capable |\n| `MIMO_OCR_ENGINE` | `ocr.py` | `auto` / `mimo` / `pollinations` — same as `--engine` flag |\n| `POLLINATIONS_MODEL` | `ocr.py` | default `openai` (vision-capable). Alternatives: `openai-large`, `openai-fast` |\n| `PET_OPENAI_API_KEY` | `generate_pet.py`, `generate_image.py` | separate from `MIMO_API_KEY` / `OPENAI_API_KEY`; used only for image gen |\n| `REPLICATE_API_TOKEN` | `generate_*.py --provider replicate` | required only when using Replicate backend |\n\n## Common recipes\n\n### Read text from an image, then summarize via the active chat model\n\n```bash\nTEXT=$(python3 mimoskill/scripts/ocr.py invoice.png)\npython3 mimoskill/scripts/mimo_chat.py \"Summarize this invoice:\\n$TEXT\"\n```\n\nOr inside Codex: just paste the image. The proxy strips it, leaves a placeholder pointing at `ocr.py`, Codex runs the script and feeds the text back into the conversation — no manual step.\n\n### Generate a `/hatch` replacement pet (works without OpenAI key)\n\n```bash\npython3 mimoskill/scripts/generate_pet.py --description \"chibi shiba coder\" --out pet.png\nbash mimoskill/scripts/install_pet.sh pet.png shiba\n# Fully quit + relaunch Codex, pick the new pet from the picker\n```\n\nFor higher quality, set `PET_OPENAI_API_KEY=sk-real-openai-key` and `auto` switches to `gpt-image-1`.\n\n### Structured OCR + JSON parse\n\n```bash\nJSON=$(python3 mimoskill/scripts/ocr.py --mode structured invoice.png)\necho \"$JSON\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d['summary'])\"\n```\n\n### Multi-image batch OCR (one billable call)\n\n```bash\npython3 mimoskill/scripts/ocr.py page1.png page2.png page3.png\n```\n\nAll images go in a **single** upstream call; the model can cross-reference (e.g. ID front + back). Output is a single text body in reading order across the images.\n\n## Troubleshooting\n\n<details>\n<summary><b><code>MIMO_API_KEY</code> is not set</b> — ocr.py exits 3</summary>\n\nYou explicitly passed `--engine mimo`. Either drop the flag (`auto` will fall back to pollinations) or set the key:\n\n```bash\nexport MIMO_API_KEY=sk-xxxx\npython3 mimoskill/scripts/ocr.py form.png\n```\n\n</details>\n\n<details>\n<summary><b>Pollinations returns 429 / rate limit</b></summary>\n\nYou hit per-IP rate limits. Either wait + retry, or switch to `--engine mimo` if you have a MiMo key.\n\n</details>\n\n<details>\n<summary><b>Codex shows <code>image_gen tool not available</code> when running /hatch</b></summary>\n\nCodex's `/hatch` is hardcoded to call OpenAI's `image_gen` tool client-side. The proxy can't intercept that. Use `generate_pet.py` instead — see \"Generate a /hatch replacement pet\" above.\n\n</details>\n\n<details>\n<summary><b><code>pip install openai</code> errors / Codex tries to install openai</b></summary>\n\nThat's Codex trying to fall back to the openai Python SDK for image generation. [AGENTS.md](../AGENTS.md) is wired to prevent this — make sure it's at the repo root and the Codex session has read it (start fresh session if you edited AGENTS.md mid-conversation).\n\n</details>\n\n<details>\n<summary><b>Tool returned an image but my model can't see images in tool output</b></summary>\n\nThis is by design. Chat Completions `tool` role only accepts string content — image content parts in `function_call_output` are flattened to `[N image attachment(s) omitted from tool output: ...]` placeholders (see `toolOutputToString` in [src/translate/reqToChat.ts](../src/translate/reqToChat.ts)). To feed an image back to the LLM, have the tool save it to disk and return a file path, then re-attach as a user message — at which point the OCR fallback kicks in if the chat model is non-vision.\n\n</details>\n\n## Design notes\n\n- **No `pip install` step.** Every script is stdlib-only. This avoids dependency drift and lets the scripts run on bare Python ≥ 3.8 anywhere.\n- **Network operations are explicit.** No silent retries to alternate endpoints. If you ask for MiMo and there's no key, you get a clear error — not a silent fallback that masks the misconfiguration.\n- **The proxy and mimoskill never call each other.** They're separate processes connected only by `AGENTS.md` / `SKILL.md` conventions. This makes both halves independently testable and replaceable.\n- **Pollinations is the no-key escape hatch.** Used as the free fallback in `ocr.py` (vision), `generate_pet.py` (image gen), `generate_image.py` (image gen). Rate-limited but always available. The project treats it as a first-class option, not a degraded mode.\n"}}, "src/cli.ts": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595136.6927829, "n": 34584, "h": "93a1a36335f63b5e", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\nimport { printBoxedBanner } from \"./util/cliBanner.js\";\nimport { detectColorLevel, fg, BOLD, RESET } from \"./util/cliColor.js\";\nimport { logFirstRunBannerIfNeeded } from \"./auth/bootstrap.js\";\nimport {\n installProxyDispatcherFromEnv,\n redactProxyUrl,\n type ProxyStatus,\n} from \"./upstream/proxyDispatcher.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --disable-thinking globally skip upstream \"thinking\" mode (mimo/deepseek: thinking:disabled;\n sensenova/generic: reasoning_effort:\"none\"). Admin UI 也可配置;\n CLI flag 优先于 admin UI 设置. env: MIMO2CODEX_DISABLE_THINKING=1\n --web-search forward web_search to the upstream (default OFF; MiMo's Web Search\n Plugin is separately billed and off by default — forwarding without\n it 400s). Admin UI 也可配置;CLI flag 优先. env: MIMO2CODEX_WEB_SEARCH=1\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n --auth <on|off> enforce login + per-user API keys (default: off for the\n native CLI; on by default inside the Docker image).\n env: MIMO2CODEX_AUTH=on. When on, the first start\n prints a one-time bootstrap URL.\n --log-body-mode <mode>\n persist request/response bodies in chat_logs:\n full | errors-only | off\n env: MIMO2CODEX_LOG_BODY_MODE\n --log-retention-days <n>\n auto-delete logs older than <n> days on startup and\n periodically while running; use 0 to disable.\n env: MIMO2CODEX_LOG_RETENTION_DAYS\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\n// Render the banner's `proxy:` row with state-aware coloring. Uses the same\n// fg() helper as the box border + snippet body so we get the truecolor /\n// 256-color / no-color tiered fallback for free. Returns a pre-colored string\n// (or plain ASCII at level 0); cliBanner.visibleWidth strips ANSI before\n// computing padding so alignment is unaffected.\nfunction colorProxyLine(proxyStatus: ProxyStatus): string {\n let body: string;\n let r: number;\n let g: number;\n let b: number;\n if (proxyStatus.enabled) {\n const parts: string[] = [];\n if (proxyStatus.httpsProxy) parts.push(`HTTPS_PROXY=${redactProxyUrl(proxyStatus.httpsProxy)}`);\n if (proxyStatus.httpProxy && proxyStatus.httpProxy !== proxyStatus.httpsProxy) {\n parts.push(`HTTP_PROXY=${redactProxyUrl(proxyStatus.httpProxy)}`);\n }\n if (proxyStatus.noProxy) parts.push(`NO_PROXY=${proxyStatus.noProxy}`);\n body = parts.join(\" \");\n // #00D75F — bright green: proxy is active, your config took effect.\n [r, g, b] = [0x00, 0xd7, 0x5f];\n } else if (proxyStatus.reason === \"opted-out\") {\n body = \"disabled (MIMO2CODEX_NO_PROXY_FROM_ENV=1)\";\n // #FFAF00 — amber: you explicitly opted out, worth double-checking.\n [r, g, b] = [0xff, 0xaf, 0x00];\n } else {\n body = \"direct (no HTTPS_PROXY / HTTP_PROXY in env)\";\n // #00D7FF — bright cyan: informational baseline, no action needed.\n [r, g, b] = [0x00, 0xd7, 0xff];\n }\n const level = detectColorLevel();\n const tint = fg(r, g, b, level);\n // Bold tightens the eye-catch without changing color; both wrappers degrade\n // to \"\" at level 0, so the plain-text path stays identical to before.\n const open = level > 0 ? `${BOLD}${tint}` : \"\";\n const close = level > 0 ? RESET : \"\";\n return `${open}proxy: ${body}${close}`;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null,\n proxyStatus: ProxyStatus\n): void {\n // Collect every runtime status line first, then frame the whole block in\n // a rounded box. Width is content-driven so the right border always aligns.\n const lines: string[] = [];\n lines.push(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n lines.push(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n lines.push(`provider: ${cfg.defaultProviderId}`);\n lines.push(`upstream: ${cfg.baseUrl}`);\n lines.push(`api key: ${redactKey(cfg.apiKey)}`);\n // Proxy line: always print so users can confirm at a glance whether\n // outbound calls are going direct vs through a proxy. Color-coded per\n // state — enabled→green (active), direct→cyan (info), opted-out→amber\n // (caution). Picks are pure-ish 6×6×6 cube cells so the 256-color fallback\n // on Apple Terminal stays distinguishable. fg() returns \"\" at level 0 so\n // pipes / CI / NO_COLOR strip cleanly.\n lines.push(colorProxyLine(proxyStatus));\n // Upstream read timeouts (undici headersTimeout/bodyTimeout). Surfaced so a\n // user hitting \"stream disconnected before completion\" on large contexts /\n // images can confirm the window and tune it via the env vars.\n const fmtTimeout = (ms: number): string => (ms === 0 ? \"off\" : `${Math.round(ms / 1000)}s`);\n lines.push(\n `timeouts: upstream headers ${fmtTimeout(proxyStatus.headersTimeoutMs)} / body ${fmtTimeout(proxyStatus.bodyTimeoutMs)} ` +\n `(MIMO2CODEX_UPSTREAM_HEADERS_TIMEOUT_MS / _BODY_TIMEOUT_MS, 0=off)`\n );\n // Auto-compaction status (env view; admin DB can also toggle per-request).\n // The trigger scales with each model's context window, so show the rule, not\n // a single number — unless an absolute override is set.\n const acEnv = process.env.MIMO2CODEX_AUTO_COMPACT;\n const acOn = acEnv === undefined ? true : !/^(0|false|off|no)$/i.test(acEnv);\n const acAtRaw = Number(process.env.MIMO2CODEX_AUTO_COMPACT_AT_TOKENS);\n const acThrRaw = Number(process.env.MIMO2CODEX_AUTO_COMPACT_THRESHOLD);\n const acThrPct = Number.isFinite(acThrRaw) && acThrRaw > 0 && acThrRaw < 1 ? Math.round(acThrRaw * 100) : 80;\n const acTrigger =\n Number.isFinite(acAtRaw) && acAtRaw > 0\n ? `at ~${Math.round(acAtRaw / 1000)}k tokens`\n : `at ~${acThrPct}% of model window`;\n lines.push(\n `auto-compact:${acOn ? ` on ${acTrigger}` : \" off\"} ` +\n `(MIMO2CODEX_AUTO_COMPACT / _AT_TOKENS / _THRESHOLD)`\n );\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n lines.push(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n lines.push(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n lines.push(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n if (cfg.disableThinkingFromCli === true) {\n lines.push(`thinking: disabled (--disable-thinking)`);\n } else if (cfg.disableThinkingFromCli === false) {\n lines.push(`thinking: forced on (CLI overrode admin setting)`);\n }\n if (cfg.webSearchFromCli === true) {\n lines.push(`web search: ON (--web-search / env — needs MiMo Web Search Plugin)`);\n } else if (cfg.webSearchFromCli === false) {\n lines.push(`web search: OFF (explicit)`);\n }\n // 未显式设 CLI flag 时不打印此行 —— 实际值由 admin UI 控制,每请求动态读 settings。\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n lines.push(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n lines.push(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n lines.push(`data dir: ${cfg.dataDir}`);\n } else {\n lines.push(`admin UI: disabled (--no-admin)`);\n }\n\n printBoxedBanner(lines);\n // 不再在启动时把 ~/.codex/auth.json + config.toml 完整 snippet 打印到终端 ——\n // 用户反馈太啰嗦。需要查具体配置请到 admin 控制台(Setup / 对接指引 页),\n // 或运行 `mimo2codex print-config` / `print-cc-switch` 显式生成。\n // eslint-disable-next-line no-console\n console.log(\"\");\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请到 admin 控制台查看: http://${cfg.host}:${cfg.port}/admin/`\n );\n } else {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请运行 \\`mimo2codex print-config\\`(或 \\`print-cc-switch\\`)查看。`\n );\n }\n // target 仅在 print-config / print-cc-switch subcommand 路径下用到,本函数已不再消费。\n void target;\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n // Install undici proxy dispatcher from env BEFORE any fetch happens (update\n // check, upstream calls, etc.). With no HTTP_PROXY/HTTPS_PROXY in env this\n // is a no-op so the default-no-proxy user is unaffected. Opt-out via\n // MIMO2CODEX_NO_PROXY_FROM_ENV=1 for users who keep proxy env vars set for\n // curl/git but don't want mimo2codex to follow.\n const proxyStatus = installProxyDispatcherFromEnv(process.env);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // 启动期 sqlite 打开失败 → 优雅降级为 admin 关闭模式,而不是硬退(issue #30)。\n // 触发场景:better-sqlite3 native binding 没法在当前 Node + 操作系统 + 安装\n // 方式下加载(典型:pnpm 全局安装 + Windows + Node 22 没拿到对应 ABI\n // 的 prebuilt 二进制,报 \"Could not locate the bindings file\")。\n // 代理核心(Codex ↔ Chat-Completions 翻译)本来就不依赖 DB —— 所有 DB\n // 读取在 cfg.adminEnabled=false 时都已经短路(src/server.ts),所以这里\n // 直接把开关关掉、继续启动比 process.exit(2) 让用户每次都得手敲\n // --no-admin 体验好得多。\n const msg = (err as Error).message;\n // Widened beyond the original Windows/pnpm case: macOS Apple Silicon adds\n // arch (\"incompatible architecture\"), code-signing (\"code signature\"),\n // and dlopen/Mach-O failure modes for the bundled-desktop sidecar where a\n // wrong-arch / wrong-ABI / unsigned better_sqlite3.node won't load.\n const looksLikeBinding =\n /Could not locate the bindings file|NODE_MODULE_VERSION|incompatible architecture|code signature|dlopen|mach-o/i.test(\n msg\n );\n // eslint-disable-next-line no-console\n console.warn(\n `warning: admin database unavailable at ${cfg.dataDir}: ${msg}\\n` +\n (looksLikeBinding\n ? \" → better-sqlite3 的 native binding 没能在当前 Node + 操作系统 + 安装方式下加载。\\n\" +\n \" 常见原因:pnpm 全局安装 + Windows + Node 22 没拿到对应 ABI 的 prebuilt 二进制。\\n\" +\n \" 可选方案: (a) 改用 npm 重装:npm i -g mimo2codex(postinstall 构建更可靠),\\n\" +\n \" (b) 继续以 admin 关闭模式运行(下方将自动继续),或\\n\" +\n \" (c) 显式 MIMO2CODEX_NO_ADMIN=1 永久消除该告警。\\n\"\n : \" → 用 --no-admin / MIMO2CODEX_NO_ADMIN=1 消除该告警,或 --data-dir <path> 换可写位置。\\n\") +\n \"代理仍会启动,admin UI + 持久化已禁用 —— 核心 Codex ↔ Chat-Completions 翻译不受影响。\"\n );\n // Stash WHY admin is off so server.ts can serve a clear diagnostic on\n // /admin/ (a 503 with this message) instead of the misleading `no route`\n // 404. Only set on this crash path — an intentional --no-admin leaves it\n // undefined, so that case keeps the historic 404.\n cfg.adminDisabledReason = { message: msg, likelyBinding: looksLikeBinding, dataDir: cfg.dataDir };\n cfg.adminEnabled = false;\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv, proxyStatus);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. CLI restart is rare and explicit — the user is sitting at the\n // terminal asking \"anything new?\". So we ALWAYS hit npm at startup,\n // ignoring the 6h cache TTL. Without this, a cache written when\n // v0.2.10 was the latest known stays \"fresh\" for 6h after v0.2.11\n // ships, suppressing prompts across every restart in that window.\n // 2. Race the refresh against a 2s timeout so a slow / blackholed npm\n // mirror never delays startup beyond 2s. Network failures bubble up\n // as a null resolution (refreshCacheInBackground swallows fetch\n // errors internally) and we fall through to cached state.\n // 3. The refresh promise keeps running past the race timeout; even if\n // we couldn't use its result for this run's prompt, it still writes\n // the cache for the next launch and the webui's status endpoint.\n //\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const refreshPromise = refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => null);\n const TIMEOUT = Symbol(\"timeout\");\n type RaceResult = UpdateStatus | null | typeof TIMEOUT;\n const raced: RaceResult = await Promise.race([\n refreshPromise,\n new Promise<typeof TIMEOUT>((resolve) =>\n setTimeout(() => resolve(TIMEOUT), 2000).unref()\n ),\n ]);\n const status: UpdateStatus =\n raced && raced !== TIMEOUT\n ? (raced as UpdateStatus)\n : getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n if (status.hasUpdate) {\n try {\n await maybePromptForUpdate(status);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n // Surface the authentication posture in the boot banner — running into\n // \"I set MIMO2CODEX_AUTH but it had no effect\" is otherwise hard to\n // debug from outside the process.\n log.info(`auth: ${cfg.authMode}${cfg.authMode === \"on\" ? \" — admin login required\" : \" (local zero-auth mode)\"}`);\n // Log a \"go to /admin/ to claim the admin account\" banner when authMode=on\n // and the users table is still empty. No-op otherwise.\n if (cfg.adminEnabled) logFirstRunBannerIfNeeded(cfg);\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "84c8c87860501597ba7f9650b9cd8c8a26685068": {"t": 1785748989.9664729, "n": 34584, "h": "93a1a36335f63b5e", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\nimport { printBoxedBanner } from \"./util/cliBanner.js\";\nimport { detectColorLevel, fg, BOLD, RESET } from \"./util/cliColor.js\";\nimport { logFirstRunBannerIfNeeded } from \"./auth/bootstrap.js\";\nimport {\n installProxyDispatcherFromEnv,\n redactProxyUrl,\n type ProxyStatus,\n} from \"./upstream/proxyDispatcher.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --disable-thinking globally skip upstream \"thinking\" mode (mimo/deepseek: thinking:disabled;\n sensenova/generic: reasoning_effort:\"none\"). Admin UI 也可配置;\n CLI flag 优先于 admin UI 设置. env: MIMO2CODEX_DISABLE_THINKING=1\n --web-search forward web_search to the upstream (default OFF; MiMo's Web Search\n Plugin is separately billed and off by default — forwarding without\n it 400s). Admin UI 也可配置;CLI flag 优先. env: MIMO2CODEX_WEB_SEARCH=1\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n --auth <on|off> enforce login + per-user API keys (default: off for the\n native CLI; on by default inside the Docker image).\n env: MIMO2CODEX_AUTH=on. When on, the first start\n prints a one-time bootstrap URL.\n --log-body-mode <mode>\n persist request/response bodies in chat_logs:\n full | errors-only | off\n env: MIMO2CODEX_LOG_BODY_MODE\n --log-retention-days <n>\n auto-delete logs older than <n> days on startup and\n periodically while running; use 0 to disable.\n env: MIMO2CODEX_LOG_RETENTION_DAYS\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\n// Render the banner's `proxy:` row with state-aware coloring. Uses the same\n// fg() helper as the box border + snippet body so we get the truecolor /\n// 256-color / no-color tiered fallback for free. Returns a pre-colored string\n// (or plain ASCII at level 0); cliBanner.visibleWidth strips ANSI before\n// computing padding so alignment is unaffected.\nfunction colorProxyLine(proxyStatus: ProxyStatus): string {\n let body: string;\n let r: number;\n let g: number;\n let b: number;\n if (proxyStatus.enabled) {\n const parts: string[] = [];\n if (proxyStatus.httpsProxy) parts.push(`HTTPS_PROXY=${redactProxyUrl(proxyStatus.httpsProxy)}`);\n if (proxyStatus.httpProxy && proxyStatus.httpProxy !== proxyStatus.httpsProxy) {\n parts.push(`HTTP_PROXY=${redactProxyUrl(proxyStatus.httpProxy)}`);\n }\n if (proxyStatus.noProxy) parts.push(`NO_PROXY=${proxyStatus.noProxy}`);\n body = parts.join(\" \");\n // #00D75F — bright green: proxy is active, your config took effect.\n [r, g, b] = [0x00, 0xd7, 0x5f];\n } else if (proxyStatus.reason === \"opted-out\") {\n body = \"disabled (MIMO2CODEX_NO_PROXY_FROM_ENV=1)\";\n // #FFAF00 — amber: you explicitly opted out, worth double-checking.\n [r, g, b] = [0xff, 0xaf, 0x00];\n } else {\n body = \"direct (no HTTPS_PROXY / HTTP_PROXY in env)\";\n // #00D7FF — bright cyan: informational baseline, no action needed.\n [r, g, b] = [0x00, 0xd7, 0xff];\n }\n const level = detectColorLevel();\n const tint = fg(r, g, b, level);\n // Bold tightens the eye-catch without changing color; both wrappers degrade\n // to \"\" at level 0, so the plain-text path stays identical to before.\n const open = level > 0 ? `${BOLD}${tint}` : \"\";\n const close = level > 0 ? RESET : \"\";\n return `${open}proxy: ${body}${close}`;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null,\n proxyStatus: ProxyStatus\n): void {\n // Collect every runtime status line first, then frame the whole block in\n // a rounded box. Width is content-driven so the right border always aligns.\n const lines: string[] = [];\n lines.push(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n lines.push(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n lines.push(`provider: ${cfg.defaultProviderId}`);\n lines.push(`upstream: ${cfg.baseUrl}`);\n lines.push(`api key: ${redactKey(cfg.apiKey)}`);\n // Proxy line: always print so users can confirm at a glance whether\n // outbound calls are going direct vs through a proxy. Color-coded per\n // state — enabled→green (active), direct→cyan (info), opted-out→amber\n // (caution). Picks are pure-ish 6×6×6 cube cells so the 256-color fallback\n // on Apple Terminal stays distinguishable. fg() returns \"\" at level 0 so\n // pipes / CI / NO_COLOR strip cleanly.\n lines.push(colorProxyLine(proxyStatus));\n // Upstream read timeouts (undici headersTimeout/bodyTimeout). Surfaced so a\n // user hitting \"stream disconnected before completion\" on large contexts /\n // images can confirm the window and tune it via the env vars.\n const fmtTimeout = (ms: number): string => (ms === 0 ? \"off\" : `${Math.round(ms / 1000)}s`);\n lines.push(\n `timeouts: upstream headers ${fmtTimeout(proxyStatus.headersTimeoutMs)} / body ${fmtTimeout(proxyStatus.bodyTimeoutMs)} ` +\n `(MIMO2CODEX_UPSTREAM_HEADERS_TIMEOUT_MS / _BODY_TIMEOUT_MS, 0=off)`\n );\n // Auto-compaction status (env view; admin DB can also toggle per-request).\n // The trigger scales with each model's context window, so show the rule, not\n // a single number — unless an absolute override is set.\n const acEnv = process.env.MIMO2CODEX_AUTO_COMPACT;\n const acOn = acEnv === undefined ? true : !/^(0|false|off|no)$/i.test(acEnv);\n const acAtRaw = Number(process.env.MIMO2CODEX_AUTO_COMPACT_AT_TOKENS);\n const acThrRaw = Number(process.env.MIMO2CODEX_AUTO_COMPACT_THRESHOLD);\n const acThrPct = Number.isFinite(acThrRaw) && acThrRaw > 0 && acThrRaw < 1 ? Math.round(acThrRaw * 100) : 80;\n const acTrigger =\n Number.isFinite(acAtRaw) && acAtRaw > 0\n ? `at ~${Math.round(acAtRaw / 1000)}k tokens`\n : `at ~${acThrPct}% of model window`;\n lines.push(\n `auto-compact:${acOn ? ` on ${acTrigger}` : \" off\"} ` +\n `(MIMO2CODEX_AUTO_COMPACT / _AT_TOKENS / _THRESHOLD)`\n );\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n lines.push(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n lines.push(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n lines.push(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n if (cfg.disableThinkingFromCli === true) {\n lines.push(`thinking: disabled (--disable-thinking)`);\n } else if (cfg.disableThinkingFromCli === false) {\n lines.push(`thinking: forced on (CLI overrode admin setting)`);\n }\n if (cfg.webSearchFromCli === true) {\n lines.push(`web search: ON (--web-search / env — needs MiMo Web Search Plugin)`);\n } else if (cfg.webSearchFromCli === false) {\n lines.push(`web search: OFF (explicit)`);\n }\n // 未显式设 CLI flag 时不打印此行 —— 实际值由 admin UI 控制,每请求动态读 settings。\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n lines.push(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n lines.push(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n lines.push(`data dir: ${cfg.dataDir}`);\n } else {\n lines.push(`admin UI: disabled (--no-admin)`);\n }\n\n printBoxedBanner(lines);\n // 不再在启动时把 ~/.codex/auth.json + config.toml 完整 snippet 打印到终端 ——\n // 用户反馈太啰嗦。需要查具体配置请到 admin 控制台(Setup / 对接指引 页),\n // 或运行 `mimo2codex print-config` / `print-cc-switch` 显式生成。\n // eslint-disable-next-line no-console\n console.log(\"\");\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请到 admin 控制台查看: http://${cfg.host}:${cfg.port}/admin/`\n );\n } else {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请运行 \\`mimo2codex print-config\\`(或 \\`print-cc-switch\\`)查看。`\n );\n }\n // target 仅在 print-config / print-cc-switch subcommand 路径下用到,本函数已不再消费。\n void target;\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n // Install undici proxy dispatcher from env BEFORE any fetch happens (update\n // check, upstream calls, etc.). With no HTTP_PROXY/HTTPS_PROXY in env this\n // is a no-op so the default-no-proxy user is unaffected. Opt-out via\n // MIMO2CODEX_NO_PROXY_FROM_ENV=1 for users who keep proxy env vars set for\n // curl/git but don't want mimo2codex to follow.\n const proxyStatus = installProxyDispatcherFromEnv(process.env);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // 启动期 sqlite 打开失败 → 优雅降级为 admin 关闭模式,而不是硬退(issue #30)。\n // 触发场景:better-sqlite3 native binding 没法在当前 Node + 操作系统 + 安装\n // 方式下加载(典型:pnpm 全局安装 + Windows + Node 22 没拿到对应 ABI\n // 的 prebuilt 二进制,报 \"Could not locate the bindings file\")。\n // 代理核心(Codex ↔ Chat-Completions 翻译)本来就不依赖 DB —— 所有 DB\n // 读取在 cfg.adminEnabled=false 时都已经短路(src/server.ts),所以这里\n // 直接把开关关掉、继续启动比 process.exit(2) 让用户每次都得手敲\n // --no-admin 体验好得多。\n const msg = (err as Error).message;\n // Widened beyond the original Windows/pnpm case: macOS Apple Silicon adds\n // arch (\"incompatible architecture\"), code-signing (\"code signature\"),\n // and dlopen/Mach-O failure modes for the bundled-desktop sidecar where a\n // wrong-arch / wrong-ABI / unsigned better_sqlite3.node won't load.\n const looksLikeBinding =\n /Could not locate the bindings file|NODE_MODULE_VERSION|incompatible architecture|code signature|dlopen|mach-o/i.test(\n msg\n );\n // eslint-disable-next-line no-console\n console.warn(\n `warning: admin database unavailable at ${cfg.dataDir}: ${msg}\\n` +\n (looksLikeBinding\n ? \" → better-sqlite3 的 native binding 没能在当前 Node + 操作系统 + 安装方式下加载。\\n\" +\n \" 常见原因:pnpm 全局安装 + Windows + Node 22 没拿到对应 ABI 的 prebuilt 二进制。\\n\" +\n \" 可选方案: (a) 改用 npm 重装:npm i -g mimo2codex(postinstall 构建更可靠),\\n\" +\n \" (b) 继续以 admin 关闭模式运行(下方将自动继续),或\\n\" +\n \" (c) 显式 MIMO2CODEX_NO_ADMIN=1 永久消除该告警。\\n\"\n : \" → 用 --no-admin / MIMO2CODEX_NO_ADMIN=1 消除该告警,或 --data-dir <path> 换可写位置。\\n\") +\n \"代理仍会启动,admin UI + 持久化已禁用 —— 核心 Codex ↔ Chat-Completions 翻译不受影响。\"\n );\n // Stash WHY admin is off so server.ts can serve a clear diagnostic on\n // /admin/ (a 503 with this message) instead of the misleading `no route`\n // 404. Only set on this crash path — an intentional --no-admin leaves it\n // undefined, so that case keeps the historic 404.\n cfg.adminDisabledReason = { message: msg, likelyBinding: looksLikeBinding, dataDir: cfg.dataDir };\n cfg.adminEnabled = false;\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv, proxyStatus);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. CLI restart is rare and explicit — the user is sitting at the\n // terminal asking \"anything new?\". So we ALWAYS hit npm at startup,\n // ignoring the 6h cache TTL. Without this, a cache written when\n // v0.2.10 was the latest known stays \"fresh\" for 6h after v0.2.11\n // ships, suppressing prompts across every restart in that window.\n // 2. Race the refresh against a 2s timeout so a slow / blackholed npm\n // mirror never delays startup beyond 2s. Network failures bubble up\n // as a null resolution (refreshCacheInBackground swallows fetch\n // errors internally) and we fall through to cached state.\n // 3. The refresh promise keeps running past the race timeout; even if\n // we couldn't use its result for this run's prompt, it still writes\n // the cache for the next launch and the webui's status endpoint.\n //\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const refreshPromise = refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => null);\n const TIMEOUT = Symbol(\"timeout\");\n type RaceResult = UpdateStatus | null | typeof TIMEOUT;\n const raced: RaceResult = await Promise.race([\n refreshPromise,\n new Promise<typeof TIMEOUT>((resolve) =>\n setTimeout(() => resolve(TIMEOUT), 2000).unref()\n ),\n ]);\n const status: UpdateStatus =\n raced && raced !== TIMEOUT\n ? (raced as UpdateStatus)\n : getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n if (status.hasUpdate) {\n try {\n await maybePromptForUpdate(status);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n // Surface the authentication posture in the boot banner — running into\n // \"I set MIMO2CODEX_AUTH but it had no effect\" is otherwise hard to\n // debug from outside the process.\n log.info(`auth: ${cfg.authMode}${cfg.authMode === \"on\" ? \" — admin login required\" : \" (local zero-auth mode)\"}`);\n // Log a \"go to /admin/ to claim the admin account\" banner when authMode=on\n // and the users table is still empty. No-op otherwise.\n if (cfg.adminEnabled) logFirstRunBannerIfNeeded(cfg);\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "63e25075e27a9d0f11dc7417d7a96ae7eb21b08b": {"t": 1785748991.4309485, "n": 34081, "h": "b013ca5a12bf44fe", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\nimport { printBoxedBanner } from \"./util/cliBanner.js\";\nimport { detectColorLevel, fg, BOLD, RESET } from \"./util/cliColor.js\";\nimport { logFirstRunBannerIfNeeded } from \"./auth/bootstrap.js\";\nimport {\n installProxyDispatcherFromEnv,\n redactProxyUrl,\n type ProxyStatus,\n} from \"./upstream/proxyDispatcher.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --disable-thinking globally skip upstream \"thinking\" mode (mimo/deepseek: thinking:disabled;\n sensenova/generic: reasoning_effort:\"none\"). Admin UI 也可配置;\n CLI flag 优先于 admin UI 设置. env: MIMO2CODEX_DISABLE_THINKING=1\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n --auth <on|off> enforce login + per-user API keys (default: off for the\n native CLI; on by default inside the Docker image).\n env: MIMO2CODEX_AUTH=on. When on, the first start\n prints a one-time bootstrap URL.\n --log-body-mode <mode>\n persist request/response bodies in chat_logs:\n full | errors-only | off\n env: MIMO2CODEX_LOG_BODY_MODE\n --log-retention-days <n>\n auto-delete logs older than <n> days on startup and\n periodically while running; use 0 to disable.\n env: MIMO2CODEX_LOG_RETENTION_DAYS\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\n// Render the banner's `proxy:` row with state-aware coloring. Uses the same\n// fg() helper as the box border + snippet body so we get the truecolor /\n// 256-color / no-color tiered fallback for free. Returns a pre-colored string\n// (or plain ASCII at level 0); cliBanner.visibleWidth strips ANSI before\n// computing padding so alignment is unaffected.\nfunction colorProxyLine(proxyStatus: ProxyStatus): string {\n let body: string;\n let r: number;\n let g: number;\n let b: number;\n if (proxyStatus.enabled) {\n const parts: string[] = [];\n if (proxyStatus.httpsProxy) parts.push(`HTTPS_PROXY=${redactProxyUrl(proxyStatus.httpsProxy)}`);\n if (proxyStatus.httpProxy && proxyStatus.httpProxy !== proxyStatus.httpsProxy) {\n parts.push(`HTTP_PROXY=${redactProxyUrl(proxyStatus.httpProxy)}`);\n }\n if (proxyStatus.noProxy) parts.push(`NO_PROXY=${proxyStatus.noProxy}`);\n body = parts.join(\" \");\n // #00D75F — bright green: proxy is active, your config took effect.\n [r, g, b] = [0x00, 0xd7, 0x5f];\n } else if (proxyStatus.reason === \"opted-out\") {\n body = \"disabled (MIMO2CODEX_NO_PROXY_FROM_ENV=1)\";\n // #FFAF00 — amber: you explicitly opted out, worth double-checking.\n [r, g, b] = [0xff, 0xaf, 0x00];\n } else {\n body = \"direct (no HTTPS_PROXY / HTTP_PROXY in env)\";\n // #00D7FF — bright cyan: informational baseline, no action needed.\n [r, g, b] = [0x00, 0xd7, 0xff];\n }\n const level = detectColorLevel();\n const tint = fg(r, g, b, level);\n // Bold tightens the eye-catch without changing color; both wrappers degrade\n // to \"\" at level 0, so the plain-text path stays identical to before.\n const open = level > 0 ? `${BOLD}${tint}` : \"\";\n const close = level > 0 ? RESET : \"\";\n return `${open}proxy: ${body}${close}`;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null,\n proxyStatus: ProxyStatus\n): void {\n // Collect every runtime status line first, then frame the whole block in\n // a rounded box. Width is content-driven so the right border always aligns.\n const lines: string[] = [];\n lines.push(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n lines.push(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n lines.push(`provider: ${cfg.defaultProviderId}`);\n lines.push(`upstream: ${cfg.baseUrl}`);\n lines.push(`api key: ${redactKey(cfg.apiKey)}`);\n // Proxy line: always print so users can confirm at a glance whether\n // outbound calls are going direct vs through a proxy. Color-coded per\n // state — enabled→green (active), direct→cyan (info), opted-out→amber\n // (caution). Picks are pure-ish 6×6×6 cube cells so the 256-color fallback\n // on Apple Terminal stays distinguishable. fg() returns \"\" at level 0 so\n // pipes / CI / NO_COLOR strip cleanly.\n lines.push(colorProxyLine(proxyStatus));\n // Upstream read timeouts (undici headersTimeout/bodyTimeout). Surfaced so a\n // user hitting \"stream disconnected before completion\" on large contexts /\n // images can confirm the window and tune it via the env vars.\n const fmtTimeout = (ms: number): string => (ms === 0 ? \"off\" : `${Math.round(ms / 1000)}s`);\n lines.push(\n `timeouts: upstream headers ${fmtTimeout(proxyStatus.headersTimeoutMs)} / body ${fmtTimeout(proxyStatus.bodyTimeoutMs)} ` +\n `(MIMO2CODEX_UPSTREAM_HEADERS_TIMEOUT_MS / _BODY_TIMEOUT_MS, 0=off)`\n );\n // Auto-compaction status (env view; admin DB can also toggle per-request).\n // The trigger scales with each model's context window, so show the rule, not\n // a single number — unless an absolute override is set.\n const acEnv = process.env.MIMO2CODEX_AUTO_COMPACT;\n const acOn = acEnv === undefined ? true : !/^(0|false|off|no)$/i.test(acEnv);\n const acAtRaw = Number(process.env.MIMO2CODEX_AUTO_COMPACT_AT_TOKENS);\n const acThrRaw = Number(process.env.MIMO2CODEX_AUTO_COMPACT_THRESHOLD);\n const acThrPct = Number.isFinite(acThrRaw) && acThrRaw > 0 && acThrRaw < 1 ? Math.round(acThrRaw * 100) : 80;\n const acTrigger =\n Number.isFinite(acAtRaw) && acAtRaw > 0\n ? `at ~${Math.round(acAtRaw / 1000)}k tokens`\n : `at ~${acThrPct}% of model window`;\n lines.push(\n `auto-compact:${acOn ? ` on ${acTrigger}` : \" off\"} ` +\n `(MIMO2CODEX_AUTO_COMPACT / _AT_TOKENS / _THRESHOLD)`\n );\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n lines.push(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n lines.push(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n lines.push(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n if (cfg.disableThinkingFromCli === true) {\n lines.push(`thinking: disabled (--disable-thinking)`);\n } else if (cfg.disableThinkingFromCli === false) {\n lines.push(`thinking: forced on (CLI overrode admin setting)`);\n }\n // 未显式设 CLI flag 时不打印此行 —— 实际值由 admin UI 控制,每请求动态读 settings。\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n lines.push(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n lines.push(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n lines.push(`data dir: ${cfg.dataDir}`);\n } else {\n lines.push(`admin UI: disabled (--no-admin)`);\n }\n\n printBoxedBanner(lines);\n // 不再在启动时把 ~/.codex/auth.json + config.toml 完整 snippet 打印到终端 ——\n // 用户反馈太啰嗦。需要查具体配置请到 admin 控制台(Setup / 对接指引 页),\n // 或运行 `mimo2codex print-config` / `print-cc-switch` 显式生成。\n // eslint-disable-next-line no-console\n console.log(\"\");\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请到 admin 控制台查看: http://${cfg.host}:${cfg.port}/admin/`\n );\n } else {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请运行 \\`mimo2codex print-config\\`(或 \\`print-cc-switch\\`)查看。`\n );\n }\n // target 仅在 print-config / print-cc-switch subcommand 路径下用到,本函数已不再消费。\n void target;\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n // Install undici proxy dispatcher from env BEFORE any fetch happens (update\n // check, upstream calls, etc.). With no HTTP_PROXY/HTTPS_PROXY in env this\n // is a no-op so the default-no-proxy user is unaffected. Opt-out via\n // MIMO2CODEX_NO_PROXY_FROM_ENV=1 for users who keep proxy env vars set for\n // curl/git but don't want mimo2codex to follow.\n const proxyStatus = installProxyDispatcherFromEnv(process.env);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // 启动期 sqlite 打开失败 → 优雅降级为 admin 关闭模式,而不是硬退(issue #30)。\n // 触发场景:better-sqlite3 native binding 没法在当前 Node + 操作系统 + 安装\n // 方式下加载(典型:pnpm 全局安装 + Windows + Node 22 没拿到对应 ABI\n // 的 prebuilt 二进制,报 \"Could not locate the bindings file\")。\n // 代理核心(Codex ↔ Chat-Completions 翻译)本来就不依赖 DB —— 所有 DB\n // 读取在 cfg.adminEnabled=false 时都已经短路(src/server.ts),所以这里\n // 直接把开关关掉、继续启动比 process.exit(2) 让用户每次都得手敲\n // --no-admin 体验好得多。\n const msg = (err as Error).message;\n // Widened beyond the original Windows/pnpm case: macOS Apple Silicon adds\n // arch (\"incompatible architecture\"), code-signing (\"code signature\"),\n // and dlopen/Mach-O failure modes for the bundled-desktop sidecar where a\n // wrong-arch / wrong-ABI / unsigned better_sqlite3.node won't load.\n const looksLikeBinding =\n /Could not locate the bindings file|NODE_MODULE_VERSION|incompatible architecture|code signature|dlopen|mach-o/i.test(\n msg\n );\n // eslint-disable-next-line no-console\n console.warn(\n `warning: admin database unavailable at ${cfg.dataDir}: ${msg}\\n` +\n (looksLikeBinding\n ? \" → better-sqlite3 的 native binding 没能在当前 Node + 操作系统 + 安装方式下加载。\\n\" +\n \" 常见原因:pnpm 全局安装 + Windows + Node 22 没拿到对应 ABI 的 prebuilt 二进制。\\n\" +\n \" 可选方案: (a) 改用 npm 重装:npm i -g mimo2codex(postinstall 构建更可靠),\\n\" +\n \" (b) 继续以 admin 关闭模式运行(下方将自动继续),或\\n\" +\n \" (c) 显式 MIMO2CODEX_NO_ADMIN=1 永久消除该告警。\\n\"\n : \" → 用 --no-admin / MIMO2CODEX_NO_ADMIN=1 消除该告警,或 --data-dir <path> 换可写位置。\\n\") +\n \"代理仍会启动,admin UI + 持久化已禁用 —— 核心 Codex ↔ Chat-Completions 翻译不受影响。\"\n );\n // Stash WHY admin is off so server.ts can serve a clear diagnostic on\n // /admin/ (a 503 with this message) instead of the misleading `no route`\n // 404. Only set on this crash path — an intentional --no-admin leaves it\n // undefined, so that case keeps the historic 404.\n cfg.adminDisabledReason = { message: msg, likelyBinding: looksLikeBinding, dataDir: cfg.dataDir };\n cfg.adminEnabled = false;\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv, proxyStatus);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. CLI restart is rare and explicit — the user is sitting at the\n // terminal asking \"anything new?\". So we ALWAYS hit npm at startup,\n // ignoring the 6h cache TTL. Without this, a cache written when\n // v0.2.10 was the latest known stays \"fresh\" for 6h after v0.2.11\n // ships, suppressing prompts across every restart in that window.\n // 2. Race the refresh against a 2s timeout so a slow / blackholed npm\n // mirror never delays startup beyond 2s. Network failures bubble up\n // as a null resolution (refreshCacheInBackground swallows fetch\n // errors internally) and we fall through to cached state.\n // 3. The refresh promise keeps running past the race timeout; even if\n // we couldn't use its result for this run's prompt, it still writes\n // the cache for the next launch and the webui's status endpoint.\n //\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const refreshPromise = refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => null);\n const TIMEOUT = Symbol(\"timeout\");\n type RaceResult = UpdateStatus | null | typeof TIMEOUT;\n const raced: RaceResult = await Promise.race([\n refreshPromise,\n new Promise<typeof TIMEOUT>((resolve) =>\n setTimeout(() => resolve(TIMEOUT), 2000).unref()\n ),\n ]);\n const status: UpdateStatus =\n raced && raced !== TIMEOUT\n ? (raced as UpdateStatus)\n : getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n if (status.hasUpdate) {\n try {\n await maybePromptForUpdate(status);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n // Surface the authentication posture in the boot banner — running into\n // \"I set MIMO2CODEX_AUTH but it had no effect\" is otherwise hard to\n // debug from outside the process.\n log.info(`auth: ${cfg.authMode}${cfg.authMode === \"on\" ? \" — admin login required\" : \" (local zero-auth mode)\"}`);\n // Log a \"go to /admin/ to claim the admin account\" banner when authMode=on\n // and the users table is still empty. No-op otherwise.\n if (cfg.adminEnabled) logFirstRunBannerIfNeeded(cfg);\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "180feef6922f640661886944e5eaea8b9d88118b": {"t": 1785748992.6959445, "n": 33300, "h": "6924bc189f487530", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\nimport { printBoxedBanner } from \"./util/cliBanner.js\";\nimport { detectColorLevel, fg, BOLD, RESET } from \"./util/cliColor.js\";\nimport { logFirstRunBannerIfNeeded } from \"./auth/bootstrap.js\";\nimport {\n installProxyDispatcherFromEnv,\n redactProxyUrl,\n type ProxyStatus,\n} from \"./upstream/proxyDispatcher.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --disable-thinking globally skip upstream \"thinking\" mode (mimo/deepseek: thinking:disabled;\n sensenova/generic: reasoning_effort:\"none\"). Admin UI 也可配置;\n CLI flag 优先于 admin UI 设置. env: MIMO2CODEX_DISABLE_THINKING=1\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n --auth <on|off> enforce login + per-user API keys (default: off for the\n native CLI; on by default inside the Docker image).\n env: MIMO2CODEX_AUTH=on. When on, the first start\n prints a one-time bootstrap URL.\n --log-body-mode <mode>\n persist request/response bodies in chat_logs:\n full | errors-only | off\n env: MIMO2CODEX_LOG_BODY_MODE\n --log-retention-days <n>\n auto-delete logs older than <n> days on startup and\n periodically while running; use 0 to disable.\n env: MIMO2CODEX_LOG_RETENTION_DAYS\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\n// Render the banner's `proxy:` row with state-aware coloring. Uses the same\n// fg() helper as the box border + snippet body so we get the truecolor /\n// 256-color / no-color tiered fallback for free. Returns a pre-colored string\n// (or plain ASCII at level 0); cliBanner.visibleWidth strips ANSI before\n// computing padding so alignment is unaffected.\nfunction colorProxyLine(proxyStatus: ProxyStatus): string {\n let body: string;\n let r: number;\n let g: number;\n let b: number;\n if (proxyStatus.enabled) {\n const parts: string[] = [];\n if (proxyStatus.httpsProxy) parts.push(`HTTPS_PROXY=${redactProxyUrl(proxyStatus.httpsProxy)}`);\n if (proxyStatus.httpProxy && proxyStatus.httpProxy !== proxyStatus.httpsProxy) {\n parts.push(`HTTP_PROXY=${redactProxyUrl(proxyStatus.httpProxy)}`);\n }\n if (proxyStatus.noProxy) parts.push(`NO_PROXY=${proxyStatus.noProxy}`);\n body = parts.join(\" \");\n // #00D75F — bright green: proxy is active, your config took effect.\n [r, g, b] = [0x00, 0xd7, 0x5f];\n } else if (proxyStatus.reason === \"opted-out\") {\n body = \"disabled (MIMO2CODEX_NO_PROXY_FROM_ENV=1)\";\n // #FFAF00 — amber: you explicitly opted out, worth double-checking.\n [r, g, b] = [0xff, 0xaf, 0x00];\n } else {\n body = \"direct (no HTTPS_PROXY / HTTP_PROXY in env)\";\n // #00D7FF — bright cyan: informational baseline, no action needed.\n [r, g, b] = [0x00, 0xd7, 0xff];\n }\n const level = detectColorLevel();\n const tint = fg(r, g, b, level);\n // Bold tightens the eye-catch without changing color; both wrappers degrade\n // to \"\" at level 0, so the plain-text path stays identical to before.\n const open = level > 0 ? `${BOLD}${tint}` : \"\";\n const close = level > 0 ? RESET : \"\";\n return `${open}proxy: ${body}${close}`;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null,\n proxyStatus: ProxyStatus\n): void {\n // Collect every runtime status line first, then frame the whole block in\n // a rounded box. Width is content-driven so the right border always aligns.\n const lines: string[] = [];\n lines.push(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n lines.push(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n lines.push(`provider: ${cfg.defaultProviderId}`);\n lines.push(`upstream: ${cfg.baseUrl}`);\n lines.push(`api key: ${redactKey(cfg.apiKey)}`);\n // Proxy line: always print so users can confirm at a glance whether\n // outbound calls are going direct vs through a proxy. Color-coded per\n // state — enabled→green (active), direct→cyan (info), opted-out→amber\n // (caution). Picks are pure-ish 6×6×6 cube cells so the 256-color fallback\n // on Apple Terminal stays distinguishable. fg() returns \"\" at level 0 so\n // pipes / CI / NO_COLOR strip cleanly.\n lines.push(colorProxyLine(proxyStatus));\n // Upstream read timeouts (undici headersTimeout/bodyTimeout). Surfaced so a\n // user hitting \"stream disconnected before completion\" on large contexts /\n // images can confirm the window and tune it via the env vars.\n const fmtTimeout = (ms: number): string => (ms === 0 ? \"off\" : `${Math.round(ms / 1000)}s`);\n lines.push(\n `timeouts: upstream headers ${fmtTimeout(proxyStatus.headersTimeoutMs)} / body ${fmtTimeout(proxyStatus.bodyTimeoutMs)} ` +\n `(MIMO2CODEX_UPSTREAM_HEADERS_TIMEOUT_MS / _BODY_TIMEOUT_MS, 0=off)`\n );\n // Auto-compaction status (env view; admin DB can also toggle per-request).\n // The trigger scales with each model's context window, so show the rule, not\n // a single number — unless an absolute override is set.\n const acEnv = process.env.MIMO2CODEX_AUTO_COMPACT;\n const acOn = acEnv === undefined ? true : !/^(0|false|off|no)$/i.test(acEnv);\n const acAtRaw = Number(process.env.MIMO2CODEX_AUTO_COMPACT_AT_TOKENS);\n const acThrRaw = Number(process.env.MIMO2CODEX_AUTO_COMPACT_THRESHOLD);\n const acThrPct = Number.isFinite(acThrRaw) && acThrRaw > 0 && acThrRaw < 1 ? Math.round(acThrRaw * 100) : 80;\n const acTrigger =\n Number.isFinite(acAtRaw) && acAtRaw > 0\n ? `at ~${Math.round(acAtRaw / 1000)}k tokens`\n : `at ~${acThrPct}% of model window`;\n lines.push(\n `auto-compact:${acOn ? ` on ${acTrigger}` : \" off\"} ` +\n `(MIMO2CODEX_AUTO_COMPACT / _AT_TOKENS / _THRESHOLD)`\n );\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n lines.push(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n lines.push(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n lines.push(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n if (cfg.disableThinkingFromCli === true) {\n lines.push(`thinking: disabled (--disable-thinking)`);\n } else if (cfg.disableThinkingFromCli === false) {\n lines.push(`thinking: forced on (CLI overrode admin setting)`);\n }\n // 未显式设 CLI flag 时不打印此行 —— 实际值由 admin UI 控制,每请求动态读 settings。\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n lines.push(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n lines.push(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n lines.push(`data dir: ${cfg.dataDir}`);\n } else {\n lines.push(`admin UI: disabled (--no-admin)`);\n }\n\n printBoxedBanner(lines);\n // 不再在启动时把 ~/.codex/auth.json + config.toml 完整 snippet 打印到终端 ——\n // 用户反馈太啰嗦。需要查具体配置请到 admin 控制台(Setup / 对接指引 页),\n // 或运行 `mimo2codex print-config` / `print-cc-switch` 显式生成。\n // eslint-disable-next-line no-console\n console.log(\"\");\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请到 admin 控制台查看: http://${cfg.host}:${cfg.port}/admin/`\n );\n } else {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请运行 \\`mimo2codex print-config\\`(或 \\`print-cc-switch\\`)查看。`\n );\n }\n // target 仅在 print-config / print-cc-switch subcommand 路径下用到,本函数已不再消费。\n void target;\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n // Install undici proxy dispatcher from env BEFORE any fetch happens (update\n // check, upstream calls, etc.). With no HTTP_PROXY/HTTPS_PROXY in env this\n // is a no-op so the default-no-proxy user is unaffected. Opt-out via\n // MIMO2CODEX_NO_PROXY_FROM_ENV=1 for users who keep proxy env vars set for\n // curl/git but don't want mimo2codex to follow.\n const proxyStatus = installProxyDispatcherFromEnv(process.env);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // 启动期 sqlite 打开失败 → 优雅降级为 admin 关闭模式,而不是硬退(issue #30)。\n // 触发场景:better-sqlite3 native binding 没法在当前 Node + 操作系统 + 安装\n // 方式下加载(典型:pnpm 全局安装 + Windows + Node 22 没拿到对应 ABI\n // 的 prebuilt 二进制,报 \"Could not locate the bindings file\")。\n // 代理核心(Codex ↔ Chat-Completions 翻译)本来就不依赖 DB —— 所有 DB\n // 读取在 cfg.adminEnabled=false 时都已经短路(src/server.ts),所以这里\n // 直接把开关关掉、继续启动比 process.exit(2) 让用户每次都得手敲\n // --no-admin 体验好得多。\n const msg = (err as Error).message;\n const looksLikeBinding = /Could not locate the bindings file|NODE_MODULE_VERSION/i.test(\n msg\n );\n // eslint-disable-next-line no-console\n console.warn(\n `warning: admin database unavailable at ${cfg.dataDir}: ${msg}\\n` +\n (looksLikeBinding\n ? \" → better-sqlite3 的 native binding 没能在当前 Node + 操作系统 + 安装方式下加载。\\n\" +\n \" 常见原因:pnpm 全局安装 + Windows + Node 22 没拿到对应 ABI 的 prebuilt 二进制。\\n\" +\n \" 可选方案: (a) 改用 npm 重装:npm i -g mimo2codex(postinstall 构建更可靠),\\n\" +\n \" (b) 继续以 admin 关闭模式运行(下方将自动继续),或\\n\" +\n \" (c) 显式 MIMO2CODEX_NO_ADMIN=1 永久消除该告警。\\n\"\n : \" → 用 --no-admin / MIMO2CODEX_NO_ADMIN=1 消除该告警,或 --data-dir <path> 换可写位置。\\n\") +\n \"代理仍会启动,admin UI + 持久化已禁用 —— 核心 Codex ↔ Chat-Completions 翻译不受影响。\"\n );\n cfg.adminEnabled = false;\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv, proxyStatus);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. CLI restart is rare and explicit — the user is sitting at the\n // terminal asking \"anything new?\". So we ALWAYS hit npm at startup,\n // ignoring the 6h cache TTL. Without this, a cache written when\n // v0.2.10 was the latest known stays \"fresh\" for 6h after v0.2.11\n // ships, suppressing prompts across every restart in that window.\n // 2. Race the refresh against a 2s timeout so a slow / blackholed npm\n // mirror never delays startup beyond 2s. Network failures bubble up\n // as a null resolution (refreshCacheInBackground swallows fetch\n // errors internally) and we fall through to cached state.\n // 3. The refresh promise keeps running past the race timeout; even if\n // we couldn't use its result for this run's prompt, it still writes\n // the cache for the next launch and the webui's status endpoint.\n //\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const refreshPromise = refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => null);\n const TIMEOUT = Symbol(\"timeout\");\n type RaceResult = UpdateStatus | null | typeof TIMEOUT;\n const raced: RaceResult = await Promise.race([\n refreshPromise,\n new Promise<typeof TIMEOUT>((resolve) =>\n setTimeout(() => resolve(TIMEOUT), 2000).unref()\n ),\n ]);\n const status: UpdateStatus =\n raced && raced !== TIMEOUT\n ? (raced as UpdateStatus)\n : getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n if (status.hasUpdate) {\n try {\n await maybePromptForUpdate(status);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n // Surface the authentication posture in the boot banner — running into\n // \"I set MIMO2CODEX_AUTH but it had no effect\" is otherwise hard to\n // debug from outside the process.\n log.info(`auth: ${cfg.authMode}${cfg.authMode === \"on\" ? \" — admin login required\" : \" (local zero-auth mode)\"}`);\n // Log a \"go to /admin/ to claim the admin account\" banner when authMode=on\n // and the users table is still empty. No-op otherwise.\n if (cfg.adminEnabled) logFirstRunBannerIfNeeded(cfg);\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "76d0624ff9d497b323c2a37eaa50ca0ba903ca76": {"t": 1785748994.1175904, "n": 31850, "h": "470c577cf651fa6e", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\nimport { printBoxedBanner } from \"./util/cliBanner.js\";\nimport { detectColorLevel, fg, BOLD, RESET } from \"./util/cliColor.js\";\nimport { logFirstRunBannerIfNeeded } from \"./auth/bootstrap.js\";\nimport {\n installProxyDispatcherFromEnv,\n redactProxyUrl,\n type ProxyStatus,\n} from \"./upstream/proxyDispatcher.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --disable-thinking globally skip upstream \"thinking\" mode (mimo/deepseek: thinking:disabled;\n sensenova/generic: reasoning_effort:\"none\"). Admin UI 也可配置;\n CLI flag 优先于 admin UI 设置. env: MIMO2CODEX_DISABLE_THINKING=1\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n --auth <on|off> enforce login + per-user API keys (default: off for the\n native CLI; on by default inside the Docker image).\n env: MIMO2CODEX_AUTH=on. When on, the first start\n prints a one-time bootstrap URL.\n --log-body-mode <mode>\n persist request/response bodies in chat_logs:\n full | errors-only | off\n env: MIMO2CODEX_LOG_BODY_MODE\n --log-retention-days <n>\n auto-delete logs older than <n> days on startup and\n periodically while running; use 0 to disable.\n env: MIMO2CODEX_LOG_RETENTION_DAYS\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\n// Render the banner's `proxy:` row with state-aware coloring. Uses the same\n// fg() helper as the box border + snippet body so we get the truecolor /\n// 256-color / no-color tiered fallback for free. Returns a pre-colored string\n// (or plain ASCII at level 0); cliBanner.visibleWidth strips ANSI before\n// computing padding so alignment is unaffected.\nfunction colorProxyLine(proxyStatus: ProxyStatus): string {\n let body: string;\n let r: number;\n let g: number;\n let b: number;\n if (proxyStatus.enabled) {\n const parts: string[] = [];\n if (proxyStatus.httpsProxy) parts.push(`HTTPS_PROXY=${redactProxyUrl(proxyStatus.httpsProxy)}`);\n if (proxyStatus.httpProxy && proxyStatus.httpProxy !== proxyStatus.httpsProxy) {\n parts.push(`HTTP_PROXY=${redactProxyUrl(proxyStatus.httpProxy)}`);\n }\n if (proxyStatus.noProxy) parts.push(`NO_PROXY=${proxyStatus.noProxy}`);\n body = parts.join(\" \");\n // #00D75F — bright green: proxy is active, your config took effect.\n [r, g, b] = [0x00, 0xd7, 0x5f];\n } else if (proxyStatus.reason === \"opted-out\") {\n body = \"disabled (MIMO2CODEX_NO_PROXY_FROM_ENV=1)\";\n // #FFAF00 — amber: you explicitly opted out, worth double-checking.\n [r, g, b] = [0xff, 0xaf, 0x00];\n } else {\n body = \"direct (no HTTPS_PROXY / HTTP_PROXY in env)\";\n // #00D7FF — bright cyan: informational baseline, no action needed.\n [r, g, b] = [0x00, 0xd7, 0xff];\n }\n const level = detectColorLevel();\n const tint = fg(r, g, b, level);\n // Bold tightens the eye-catch without changing color; both wrappers degrade\n // to \"\" at level 0, so the plain-text path stays identical to before.\n const open = level > 0 ? `${BOLD}${tint}` : \"\";\n const close = level > 0 ? RESET : \"\";\n return `${open}proxy: ${body}${close}`;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null,\n proxyStatus: ProxyStatus\n): void {\n // Collect every runtime status line first, then frame the whole block in\n // a rounded box. Width is content-driven so the right border always aligns.\n const lines: string[] = [];\n lines.push(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n lines.push(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n lines.push(`provider: ${cfg.defaultProviderId}`);\n lines.push(`upstream: ${cfg.baseUrl}`);\n lines.push(`api key: ${redactKey(cfg.apiKey)}`);\n // Proxy line: always print so users can confirm at a glance whether\n // outbound calls are going direct vs through a proxy. Color-coded per\n // state — enabled→green (active), direct→cyan (info), opted-out→amber\n // (caution). Picks are pure-ish 6×6×6 cube cells so the 256-color fallback\n // on Apple Terminal stays distinguishable. fg() returns \"\" at level 0 so\n // pipes / CI / NO_COLOR strip cleanly.\n lines.push(colorProxyLine(proxyStatus));\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n lines.push(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n lines.push(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n lines.push(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n if (cfg.disableThinkingFromCli === true) {\n lines.push(`thinking: disabled (--disable-thinking)`);\n } else if (cfg.disableThinkingFromCli === false) {\n lines.push(`thinking: forced on (CLI overrode admin setting)`);\n }\n // 未显式设 CLI flag 时不打印此行 —— 实际值由 admin UI 控制,每请求动态读 settings。\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n lines.push(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n lines.push(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n lines.push(`data dir: ${cfg.dataDir}`);\n } else {\n lines.push(`admin UI: disabled (--no-admin)`);\n }\n\n printBoxedBanner(lines);\n // 不再在启动时把 ~/.codex/auth.json + config.toml 完整 snippet 打印到终端 ——\n // 用户反馈太啰嗦。需要查具体配置请到 admin 控制台(Setup / 对接指引 页),\n // 或运行 `mimo2codex print-config` / `print-cc-switch` 显式生成。\n // eslint-disable-next-line no-console\n console.log(\"\");\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请到 admin 控制台查看: http://${cfg.host}:${cfg.port}/admin/`\n );\n } else {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请运行 \\`mimo2codex print-config\\`(或 \\`print-cc-switch\\`)查看。`\n );\n }\n // target 仅在 print-config / print-cc-switch subcommand 路径下用到,本函数已不再消费。\n void target;\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n // Install undici proxy dispatcher from env BEFORE any fetch happens (update\n // check, upstream calls, etc.). With no HTTP_PROXY/HTTPS_PROXY in env this\n // is a no-op so the default-no-proxy user is unaffected. Opt-out via\n // MIMO2CODEX_NO_PROXY_FROM_ENV=1 for users who keep proxy env vars set for\n // curl/git but don't want mimo2codex to follow.\n const proxyStatus = installProxyDispatcherFromEnv(process.env);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // 启动期 sqlite 打开失败 → 优雅降级为 admin 关闭模式,而不是硬退(issue #30)。\n // 触发场景:better-sqlite3 native binding 没法在当前 Node + 操作系统 + 安装\n // 方式下加载(典型:pnpm 全局安装 + Windows + Node 22 没拿到对应 ABI\n // 的 prebuilt 二进制,报 \"Could not locate the bindings file\")。\n // 代理核心(Codex ↔ Chat-Completions 翻译)本来就不依赖 DB —— 所有 DB\n // 读取在 cfg.adminEnabled=false 时都已经短路(src/server.ts),所以这里\n // 直接把开关关掉、继续启动比 process.exit(2) 让用户每次都得手敲\n // --no-admin 体验好得多。\n const msg = (err as Error).message;\n const looksLikeBinding = /Could not locate the bindings file|NODE_MODULE_VERSION/i.test(\n msg\n );\n // eslint-disable-next-line no-console\n console.warn(\n `warning: admin database unavailable at ${cfg.dataDir}: ${msg}\\n` +\n (looksLikeBinding\n ? \" → better-sqlite3 的 native binding 没能在当前 Node + 操作系统 + 安装方式下加载。\\n\" +\n \" 常见原因:pnpm 全局安装 + Windows + Node 22 没拿到对应 ABI 的 prebuilt 二进制。\\n\" +\n \" 可选方案: (a) 改用 npm 重装:npm i -g mimo2codex(postinstall 构建更可靠),\\n\" +\n \" (b) 继续以 admin 关闭模式运行(下方将自动继续),或\\n\" +\n \" (c) 显式 MIMO2CODEX_NO_ADMIN=1 永久消除该告警。\\n\"\n : \" → 用 --no-admin / MIMO2CODEX_NO_ADMIN=1 消除该告警,或 --data-dir <path> 换可写位置。\\n\") +\n \"代理仍会启动,admin UI + 持久化已禁用 —— 核心 Codex ↔ Chat-Completions 翻译不受影响。\"\n );\n cfg.adminEnabled = false;\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv, proxyStatus);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. CLI restart is rare and explicit — the user is sitting at the\n // terminal asking \"anything new?\". So we ALWAYS hit npm at startup,\n // ignoring the 6h cache TTL. Without this, a cache written when\n // v0.2.10 was the latest known stays \"fresh\" for 6h after v0.2.11\n // ships, suppressing prompts across every restart in that window.\n // 2. Race the refresh against a 2s timeout so a slow / blackholed npm\n // mirror never delays startup beyond 2s. Network failures bubble up\n // as a null resolution (refreshCacheInBackground swallows fetch\n // errors internally) and we fall through to cached state.\n // 3. The refresh promise keeps running past the race timeout; even if\n // we couldn't use its result for this run's prompt, it still writes\n // the cache for the next launch and the webui's status endpoint.\n //\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const refreshPromise = refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => null);\n const TIMEOUT = Symbol(\"timeout\");\n type RaceResult = UpdateStatus | null | typeof TIMEOUT;\n const raced: RaceResult = await Promise.race([\n refreshPromise,\n new Promise<typeof TIMEOUT>((resolve) =>\n setTimeout(() => resolve(TIMEOUT), 2000).unref()\n ),\n ]);\n const status: UpdateStatus =\n raced && raced !== TIMEOUT\n ? (raced as UpdateStatus)\n : getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n if (status.hasUpdate) {\n try {\n await maybePromptForUpdate(status);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n // Surface the authentication posture in the boot banner — running into\n // \"I set MIMO2CODEX_AUTH but it had no effect\" is otherwise hard to\n // debug from outside the process.\n log.info(`auth: ${cfg.authMode}${cfg.authMode === \"on\" ? \" — admin login required\" : \" (local zero-auth mode)\"}`);\n // Log a \"go to /admin/ to claim the admin account\" banner when authMode=on\n // and the users table is still empty. No-op otherwise.\n if (cfg.adminEnabled) logFirstRunBannerIfNeeded(cfg);\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "ef8542e70b7e28e038d226190dad0cc886b33414": {"t": 1785748995.365495, "n": 31400, "h": "c0c1e70336ad66f5", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\nimport { printBoxedBanner } from \"./util/cliBanner.js\";\nimport { detectColorLevel, fg, BOLD, RESET } from \"./util/cliColor.js\";\nimport { logFirstRunBannerIfNeeded } from \"./auth/bootstrap.js\";\nimport {\n installProxyDispatcherFromEnv,\n redactProxyUrl,\n type ProxyStatus,\n} from \"./upstream/proxyDispatcher.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --disable-thinking globally skip upstream \"thinking\" mode (mimo/deepseek: thinking:disabled;\n sensenova/generic: reasoning_effort:\"none\"). Admin UI 也可配置;\n CLI flag 优先于 admin UI 设置. env: MIMO2CODEX_DISABLE_THINKING=1\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n --auth <on|off> enforce login + per-user API keys (default: off for the\n native CLI; on by default inside the Docker image).\n env: MIMO2CODEX_AUTH=on. When on, the first start\n prints a one-time bootstrap URL.\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\n// Render the banner's `proxy:` row with state-aware coloring. Uses the same\n// fg() helper as the box border + snippet body so we get the truecolor /\n// 256-color / no-color tiered fallback for free. Returns a pre-colored string\n// (or plain ASCII at level 0); cliBanner.visibleWidth strips ANSI before\n// computing padding so alignment is unaffected.\nfunction colorProxyLine(proxyStatus: ProxyStatus): string {\n let body: string;\n let r: number;\n let g: number;\n let b: number;\n if (proxyStatus.enabled) {\n const parts: string[] = [];\n if (proxyStatus.httpsProxy) parts.push(`HTTPS_PROXY=${redactProxyUrl(proxyStatus.httpsProxy)}`);\n if (proxyStatus.httpProxy && proxyStatus.httpProxy !== proxyStatus.httpsProxy) {\n parts.push(`HTTP_PROXY=${redactProxyUrl(proxyStatus.httpProxy)}`);\n }\n if (proxyStatus.noProxy) parts.push(`NO_PROXY=${proxyStatus.noProxy}`);\n body = parts.join(\" \");\n // #00D75F — bright green: proxy is active, your config took effect.\n [r, g, b] = [0x00, 0xd7, 0x5f];\n } else if (proxyStatus.reason === \"opted-out\") {\n body = \"disabled (MIMO2CODEX_NO_PROXY_FROM_ENV=1)\";\n // #FFAF00 — amber: you explicitly opted out, worth double-checking.\n [r, g, b] = [0xff, 0xaf, 0x00];\n } else {\n body = \"direct (no HTTPS_PROXY / HTTP_PROXY in env)\";\n // #00D7FF — bright cyan: informational baseline, no action needed.\n [r, g, b] = [0x00, 0xd7, 0xff];\n }\n const level = detectColorLevel();\n const tint = fg(r, g, b, level);\n // Bold tightens the eye-catch without changing color; both wrappers degrade\n // to \"\" at level 0, so the plain-text path stays identical to before.\n const open = level > 0 ? `${BOLD}${tint}` : \"\";\n const close = level > 0 ? RESET : \"\";\n return `${open}proxy: ${body}${close}`;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null,\n proxyStatus: ProxyStatus\n): void {\n // Collect every runtime status line first, then frame the whole block in\n // a rounded box. Width is content-driven so the right border always aligns.\n const lines: string[] = [];\n lines.push(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n lines.push(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n lines.push(`provider: ${cfg.defaultProviderId}`);\n lines.push(`upstream: ${cfg.baseUrl}`);\n lines.push(`api key: ${redactKey(cfg.apiKey)}`);\n // Proxy line: always print so users can confirm at a glance whether\n // outbound calls are going direct vs through a proxy. Color-coded per\n // state — enabled→green (active), direct→cyan (info), opted-out→amber\n // (caution). Picks are pure-ish 6×6×6 cube cells so the 256-color fallback\n // on Apple Terminal stays distinguishable. fg() returns \"\" at level 0 so\n // pipes / CI / NO_COLOR strip cleanly.\n lines.push(colorProxyLine(proxyStatus));\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n lines.push(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n lines.push(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n lines.push(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n if (cfg.disableThinkingFromCli === true) {\n lines.push(`thinking: disabled (--disable-thinking)`);\n } else if (cfg.disableThinkingFromCli === false) {\n lines.push(`thinking: forced on (CLI overrode admin setting)`);\n }\n // 未显式设 CLI flag 时不打印此行 —— 实际值由 admin UI 控制,每请求动态读 settings。\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n lines.push(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n lines.push(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n lines.push(`data dir: ${cfg.dataDir}`);\n } else {\n lines.push(`admin UI: disabled (--no-admin)`);\n }\n\n printBoxedBanner(lines);\n // 不再在启动时把 ~/.codex/auth.json + config.toml 完整 snippet 打印到终端 ——\n // 用户反馈太啰嗦。需要查具体配置请到 admin 控制台(Setup / 对接指引 页),\n // 或运行 `mimo2codex print-config` / `print-cc-switch` 显式生成。\n // eslint-disable-next-line no-console\n console.log(\"\");\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请到 admin 控制台查看: http://${cfg.host}:${cfg.port}/admin/`\n );\n } else {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请运行 \\`mimo2codex print-config\\`(或 \\`print-cc-switch\\`)查看。`\n );\n }\n // target 仅在 print-config / print-cc-switch subcommand 路径下用到,本函数已不再消费。\n void target;\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n // Install undici proxy dispatcher from env BEFORE any fetch happens (update\n // check, upstream calls, etc.). With no HTTP_PROXY/HTTPS_PROXY in env this\n // is a no-op so the default-no-proxy user is unaffected. Opt-out via\n // MIMO2CODEX_NO_PROXY_FROM_ENV=1 for users who keep proxy env vars set for\n // curl/git but don't want mimo2codex to follow.\n const proxyStatus = installProxyDispatcherFromEnv(process.env);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // 启动期 sqlite 打开失败 → 优雅降级为 admin 关闭模式,而不是硬退(issue #30)。\n // 触发场景:better-sqlite3 native binding 没法在当前 Node + 操作系统 + 安装\n // 方式下加载(典型:pnpm 全局安装 + Windows + Node 22 没拿到对应 ABI\n // 的 prebuilt 二进制,报 \"Could not locate the bindings file\")。\n // 代理核心(Codex ↔ Chat-Completions 翻译)本来就不依赖 DB —— 所有 DB\n // 读取在 cfg.adminEnabled=false 时都已经短路(src/server.ts),所以这里\n // 直接把开关关掉、继续启动比 process.exit(2) 让用户每次都得手敲\n // --no-admin 体验好得多。\n const msg = (err as Error).message;\n const looksLikeBinding = /Could not locate the bindings file|NODE_MODULE_VERSION/i.test(\n msg\n );\n // eslint-disable-next-line no-console\n console.warn(\n `warning: admin database unavailable at ${cfg.dataDir}: ${msg}\\n` +\n (looksLikeBinding\n ? \" → better-sqlite3 的 native binding 没能在当前 Node + 操作系统 + 安装方式下加载。\\n\" +\n \" 常见原因:pnpm 全局安装 + Windows + Node 22 没拿到对应 ABI 的 prebuilt 二进制。\\n\" +\n \" 可选方案: (a) 改用 npm 重装:npm i -g mimo2codex(postinstall 构建更可靠),\\n\" +\n \" (b) 继续以 admin 关闭模式运行(下方将自动继续),或\\n\" +\n \" (c) 显式 MIMO2CODEX_NO_ADMIN=1 永久消除该告警。\\n\"\n : \" → 用 --no-admin / MIMO2CODEX_NO_ADMIN=1 消除该告警,或 --data-dir <path> 换可写位置。\\n\") +\n \"代理仍会启动,admin UI + 持久化已禁用 —— 核心 Codex ↔ Chat-Completions 翻译不受影响。\"\n );\n cfg.adminEnabled = false;\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv, proxyStatus);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. CLI restart is rare and explicit — the user is sitting at the\n // terminal asking \"anything new?\". So we ALWAYS hit npm at startup,\n // ignoring the 6h cache TTL. Without this, a cache written when\n // v0.2.10 was the latest known stays \"fresh\" for 6h after v0.2.11\n // ships, suppressing prompts across every restart in that window.\n // 2. Race the refresh against a 2s timeout so a slow / blackholed npm\n // mirror never delays startup beyond 2s. Network failures bubble up\n // as a null resolution (refreshCacheInBackground swallows fetch\n // errors internally) and we fall through to cached state.\n // 3. The refresh promise keeps running past the race timeout; even if\n // we couldn't use its result for this run's prompt, it still writes\n // the cache for the next launch and the webui's status endpoint.\n //\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const refreshPromise = refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => null);\n const TIMEOUT = Symbol(\"timeout\");\n type RaceResult = UpdateStatus | null | typeof TIMEOUT;\n const raced: RaceResult = await Promise.race([\n refreshPromise,\n new Promise<typeof TIMEOUT>((resolve) =>\n setTimeout(() => resolve(TIMEOUT), 2000).unref()\n ),\n ]);\n const status: UpdateStatus =\n raced && raced !== TIMEOUT\n ? (raced as UpdateStatus)\n : getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n if (status.hasUpdate) {\n try {\n await maybePromptForUpdate(status);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n // Surface the authentication posture in the boot banner — running into\n // \"I set MIMO2CODEX_AUTH but it had no effect\" is otherwise hard to\n // debug from outside the process.\n log.info(`auth: ${cfg.authMode}${cfg.authMode === \"on\" ? \" — admin login required\" : \" (local zero-auth mode)\"}`);\n // Log a \"go to /admin/ to claim the admin account\" banner when authMode=on\n // and the users table is still empty. No-op otherwise.\n if (cfg.adminEnabled) logFirstRunBannerIfNeeded(cfg);\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "832bac58ce3aca67d0b6fd52257fde3536d80bb5": {"t": 1785748996.6137476, "n": 30311, "h": "cf915e382f0d1906", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\nimport { printBoxedBanner } from \"./util/cliBanner.js\";\nimport { detectColorLevel, fg, BOLD, RESET } from \"./util/cliColor.js\";\nimport { logFirstRunBannerIfNeeded } from \"./auth/bootstrap.js\";\nimport {\n installProxyDispatcherFromEnv,\n redactProxyUrl,\n type ProxyStatus,\n} from \"./upstream/proxyDispatcher.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --disable-thinking globally skip upstream \"thinking\" mode (mimo/deepseek: thinking:disabled;\n sensenova/generic: reasoning_effort:\"none\"). Admin UI 也可配置;\n CLI flag 优先于 admin UI 设置. env: MIMO2CODEX_DISABLE_THINKING=1\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n --auth <on|off> enforce login + per-user API keys (default: off for the\n native CLI; on by default inside the Docker image).\n env: MIMO2CODEX_AUTH=on. When on, the first start\n prints a one-time bootstrap URL.\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\n// Render the banner's `proxy:` row with state-aware coloring. Uses the same\n// fg() helper as the box border + snippet body so we get the truecolor /\n// 256-color / no-color tiered fallback for free. Returns a pre-colored string\n// (or plain ASCII at level 0); cliBanner.visibleWidth strips ANSI before\n// computing padding so alignment is unaffected.\nfunction colorProxyLine(proxyStatus: ProxyStatus): string {\n let body: string;\n let r: number;\n let g: number;\n let b: number;\n if (proxyStatus.enabled) {\n const parts: string[] = [];\n if (proxyStatus.httpsProxy) parts.push(`HTTPS_PROXY=${redactProxyUrl(proxyStatus.httpsProxy)}`);\n if (proxyStatus.httpProxy && proxyStatus.httpProxy !== proxyStatus.httpsProxy) {\n parts.push(`HTTP_PROXY=${redactProxyUrl(proxyStatus.httpProxy)}`);\n }\n if (proxyStatus.noProxy) parts.push(`NO_PROXY=${proxyStatus.noProxy}`);\n body = parts.join(\" \");\n // #00D75F — bright green: proxy is active, your config took effect.\n [r, g, b] = [0x00, 0xd7, 0x5f];\n } else if (proxyStatus.reason === \"opted-out\") {\n body = \"disabled (MIMO2CODEX_NO_PROXY_FROM_ENV=1)\";\n // #FFAF00 — amber: you explicitly opted out, worth double-checking.\n [r, g, b] = [0xff, 0xaf, 0x00];\n } else {\n body = \"direct (no HTTPS_PROXY / HTTP_PROXY in env)\";\n // #00D7FF — bright cyan: informational baseline, no action needed.\n [r, g, b] = [0x00, 0xd7, 0xff];\n }\n const level = detectColorLevel();\n const tint = fg(r, g, b, level);\n // Bold tightens the eye-catch without changing color; both wrappers degrade\n // to \"\" at level 0, so the plain-text path stays identical to before.\n const open = level > 0 ? `${BOLD}${tint}` : \"\";\n const close = level > 0 ? RESET : \"\";\n return `${open}proxy: ${body}${close}`;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null,\n proxyStatus: ProxyStatus\n): void {\n // Collect every runtime status line first, then frame the whole block in\n // a rounded box. Width is content-driven so the right border always aligns.\n const lines: string[] = [];\n lines.push(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n lines.push(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n lines.push(`provider: ${cfg.defaultProviderId}`);\n lines.push(`upstream: ${cfg.baseUrl}`);\n lines.push(`api key: ${redactKey(cfg.apiKey)}`);\n // Proxy line: always print so users can confirm at a glance whether\n // outbound calls are going direct vs through a proxy. Color-coded per\n // state — enabled→green (active), direct→cyan (info), opted-out→amber\n // (caution). Picks are pure-ish 6×6×6 cube cells so the 256-color fallback\n // on Apple Terminal stays distinguishable. fg() returns \"\" at level 0 so\n // pipes / CI / NO_COLOR strip cleanly.\n lines.push(colorProxyLine(proxyStatus));\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n lines.push(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n lines.push(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n lines.push(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n if (cfg.disableThinkingFromCli === true) {\n lines.push(`thinking: disabled (--disable-thinking)`);\n } else if (cfg.disableThinkingFromCli === false) {\n lines.push(`thinking: forced on (CLI overrode admin setting)`);\n }\n // 未显式设 CLI flag 时不打印此行 —— 实际值由 admin UI 控制,每请求动态读 settings。\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n lines.push(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n lines.push(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n lines.push(`data dir: ${cfg.dataDir}`);\n } else {\n lines.push(`admin UI: disabled (--no-admin)`);\n }\n\n printBoxedBanner(lines);\n // 不再在启动时把 ~/.codex/auth.json + config.toml 完整 snippet 打印到终端 ——\n // 用户反馈太啰嗦。需要查具体配置请到 admin 控制台(Setup / 对接指引 页),\n // 或运行 `mimo2codex print-config` / `print-cc-switch` 显式生成。\n // eslint-disable-next-line no-console\n console.log(\"\");\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请到 admin 控制台查看: http://${cfg.host}:${cfg.port}/admin/`\n );\n } else {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请运行 \\`mimo2codex print-config\\`(或 \\`print-cc-switch\\`)查看。`\n );\n }\n // target 仅在 print-config / print-cc-switch subcommand 路径下用到,本函数已不再消费。\n void target;\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n // Install undici proxy dispatcher from env BEFORE any fetch happens (update\n // check, upstream calls, etc.). With no HTTP_PROXY/HTTPS_PROXY in env this\n // is a no-op so the default-no-proxy user is unaffected. Opt-out via\n // MIMO2CODEX_NO_PROXY_FROM_ENV=1 for users who keep proxy env vars set for\n // curl/git but don't want mimo2codex to follow.\n const proxyStatus = installProxyDispatcherFromEnv(process.env);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n `error: failed to open admin database at ${cfg.dataDir}: ${(err as Error).message}\\n` +\n `Pass --no-admin to disable persistence, or --data-dir <path> to choose a writable location.`\n );\n process.exit(2);\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv, proxyStatus);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. CLI restart is rare and explicit — the user is sitting at the\n // terminal asking \"anything new?\". So we ALWAYS hit npm at startup,\n // ignoring the 6h cache TTL. Without this, a cache written when\n // v0.2.10 was the latest known stays \"fresh\" for 6h after v0.2.11\n // ships, suppressing prompts across every restart in that window.\n // 2. Race the refresh against a 2s timeout so a slow / blackholed npm\n // mirror never delays startup beyond 2s. Network failures bubble up\n // as a null resolution (refreshCacheInBackground swallows fetch\n // errors internally) and we fall through to cached state.\n // 3. The refresh promise keeps running past the race timeout; even if\n // we couldn't use its result for this run's prompt, it still writes\n // the cache for the next launch and the webui's status endpoint.\n //\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const refreshPromise = refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => null);\n const TIMEOUT = Symbol(\"timeout\");\n type RaceResult = UpdateStatus | null | typeof TIMEOUT;\n const raced: RaceResult = await Promise.race([\n refreshPromise,\n new Promise<typeof TIMEOUT>((resolve) =>\n setTimeout(() => resolve(TIMEOUT), 2000).unref()\n ),\n ]);\n const status: UpdateStatus =\n raced && raced !== TIMEOUT\n ? (raced as UpdateStatus)\n : getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n if (status.hasUpdate) {\n try {\n await maybePromptForUpdate(status);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n // Surface the authentication posture in the boot banner — running into\n // \"I set MIMO2CODEX_AUTH but it had no effect\" is otherwise hard to\n // debug from outside the process.\n log.info(`auth: ${cfg.authMode}${cfg.authMode === \"on\" ? \" — admin login required\" : \" (local zero-auth mode)\"}`);\n // Log a \"go to /admin/ to claim the admin account\" banner when authMode=on\n // and the users table is still empty. No-op otherwise.\n if (cfg.adminEnabled) logFirstRunBannerIfNeeded(cfg);\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "edfc011ddd628397598ba130af6bdc4c9606b271": {"t": 1785748998.0278673, "n": 27412, "h": "69d02ff53b4226a8", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\nimport { printBoxedBanner } from \"./util/cliBanner.js\";\nimport { logFirstRunBannerIfNeeded } from \"./auth/bootstrap.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --disable-thinking globally skip upstream \"thinking\" mode (mimo/deepseek: thinking:disabled;\n sensenova/generic: reasoning_effort:\"none\"). Admin UI 也可配置;\n CLI flag 优先于 admin UI 设置. env: MIMO2CODEX_DISABLE_THINKING=1\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n --auth <on|off> enforce login + per-user API keys (default: off for the\n native CLI; on by default inside the Docker image).\n env: MIMO2CODEX_AUTH=on. When on, the first start\n prints a one-time bootstrap URL.\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null\n): void {\n // Collect every runtime status line first, then frame the whole block in\n // a rounded box. Width is content-driven so the right border always aligns.\n const lines: string[] = [];\n lines.push(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n lines.push(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n lines.push(`provider: ${cfg.defaultProviderId}`);\n lines.push(`upstream: ${cfg.baseUrl}`);\n lines.push(`api key: ${redactKey(cfg.apiKey)}`);\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n lines.push(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n lines.push(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n lines.push(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n if (cfg.disableThinkingFromCli === true) {\n lines.push(`thinking: disabled (--disable-thinking)`);\n } else if (cfg.disableThinkingFromCli === false) {\n lines.push(`thinking: forced on (CLI overrode admin setting)`);\n }\n // 未显式设 CLI flag 时不打印此行 —— 实际值由 admin UI 控制,每请求动态读 settings。\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n lines.push(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n lines.push(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n lines.push(`data dir: ${cfg.dataDir}`);\n } else {\n lines.push(`admin UI: disabled (--no-admin)`);\n }\n\n printBoxedBanner(lines);\n // 不再在启动时把 ~/.codex/auth.json + config.toml 完整 snippet 打印到终端 ——\n // 用户反馈太啰嗦。需要查具体配置请到 admin 控制台(Setup / 对接指引 页),\n // 或运行 `mimo2codex print-config` / `print-cc-switch` 显式生成。\n // eslint-disable-next-line no-console\n console.log(\"\");\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请到 admin 控制台查看: http://${cfg.host}:${cfg.port}/admin/`\n );\n } else {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请运行 \\`mimo2codex print-config\\`(或 \\`print-cc-switch\\`)查看。`\n );\n }\n // target 仅在 print-config / print-cc-switch subcommand 路径下用到,本函数已不再消费。\n void target;\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n `error: failed to open admin database at ${cfg.dataDir}: ${(err as Error).message}\\n` +\n `Pass --no-admin to disable persistence, or --data-dir <path> to choose a writable location.`\n );\n process.exit(2);\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. CLI restart is rare and explicit — the user is sitting at the\n // terminal asking \"anything new?\". So we ALWAYS hit npm at startup,\n // ignoring the 6h cache TTL. Without this, a cache written when\n // v0.2.10 was the latest known stays \"fresh\" for 6h after v0.2.11\n // ships, suppressing prompts across every restart in that window.\n // 2. Race the refresh against a 2s timeout so a slow / blackholed npm\n // mirror never delays startup beyond 2s. Network failures bubble up\n // as a null resolution (refreshCacheInBackground swallows fetch\n // errors internally) and we fall through to cached state.\n // 3. The refresh promise keeps running past the race timeout; even if\n // we couldn't use its result for this run's prompt, it still writes\n // the cache for the next launch and the webui's status endpoint.\n //\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const refreshPromise = refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => null);\n const TIMEOUT = Symbol(\"timeout\");\n type RaceResult = UpdateStatus | null | typeof TIMEOUT;\n const raced: RaceResult = await Promise.race([\n refreshPromise,\n new Promise<typeof TIMEOUT>((resolve) =>\n setTimeout(() => resolve(TIMEOUT), 2000).unref()\n ),\n ]);\n const status: UpdateStatus =\n raced && raced !== TIMEOUT\n ? (raced as UpdateStatus)\n : getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n if (status.hasUpdate) {\n try {\n await maybePromptForUpdate(status);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n // Surface the authentication posture in the boot banner — running into\n // \"I set MIMO2CODEX_AUTH but it had no effect\" is otherwise hard to\n // debug from outside the process.\n log.info(`auth: ${cfg.authMode}${cfg.authMode === \"on\" ? \" — admin login required\" : \" (local zero-auth mode)\"}`);\n // Log a \"go to /admin/ to claim the admin account\" banner when authMode=on\n // and the users table is still empty. No-op otherwise.\n if (cfg.adminEnabled) logFirstRunBannerIfNeeded(cfg);\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "bbc0c33fd65cf5727ea4daada31fedeeedcb4bad": {"t": 1785748999.2836077, "n": 26547, "h": "06f569a0155faeed", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\nimport { printBoxedBanner } from \"./util/cliBanner.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --disable-thinking globally skip upstream \"thinking\" mode (mimo/deepseek: thinking:disabled;\n sensenova/generic: reasoning_effort:\"none\"). Admin UI 也可配置;\n CLI flag 优先于 admin UI 设置. env: MIMO2CODEX_DISABLE_THINKING=1\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null\n): void {\n // Collect every runtime status line first, then frame the whole block in\n // a rounded box. Width is content-driven so the right border always aligns.\n const lines: string[] = [];\n lines.push(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n lines.push(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n lines.push(`provider: ${cfg.defaultProviderId}`);\n lines.push(`upstream: ${cfg.baseUrl}`);\n lines.push(`api key: ${redactKey(cfg.apiKey)}`);\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n lines.push(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n lines.push(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n lines.push(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n if (cfg.disableThinkingFromCli === true) {\n lines.push(`thinking: disabled (--disable-thinking)`);\n } else if (cfg.disableThinkingFromCli === false) {\n lines.push(`thinking: forced on (CLI overrode admin setting)`);\n }\n // 未显式设 CLI flag 时不打印此行 —— 实际值由 admin UI 控制,每请求动态读 settings。\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n lines.push(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n lines.push(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n lines.push(`data dir: ${cfg.dataDir}`);\n } else {\n lines.push(`admin UI: disabled (--no-admin)`);\n }\n\n printBoxedBanner(lines);\n // 不再在启动时把 ~/.codex/auth.json + config.toml 完整 snippet 打印到终端 ——\n // 用户反馈太啰嗦。需要查具体配置请到 admin 控制台(Setup / 对接指引 页),\n // 或运行 `mimo2codex print-config` / `print-cc-switch` 显式生成。\n // eslint-disable-next-line no-console\n console.log(\"\");\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请到 admin 控制台查看: http://${cfg.host}:${cfg.port}/admin/`\n );\n } else {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请运行 \\`mimo2codex print-config\\`(或 \\`print-cc-switch\\`)查看。`\n );\n }\n // target 仅在 print-config / print-cc-switch subcommand 路径下用到,本函数已不再消费。\n void target;\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n `error: failed to open admin database at ${cfg.dataDir}: ${(err as Error).message}\\n` +\n `Pass --no-admin to disable persistence, or --data-dir <path> to choose a writable location.`\n );\n process.exit(2);\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. CLI restart is rare and explicit — the user is sitting at the\n // terminal asking \"anything new?\". So we ALWAYS hit npm at startup,\n // ignoring the 6h cache TTL. Without this, a cache written when\n // v0.2.10 was the latest known stays \"fresh\" for 6h after v0.2.11\n // ships, suppressing prompts across every restart in that window.\n // 2. Race the refresh against a 2s timeout so a slow / blackholed npm\n // mirror never delays startup beyond 2s. Network failures bubble up\n // as a null resolution (refreshCacheInBackground swallows fetch\n // errors internally) and we fall through to cached state.\n // 3. The refresh promise keeps running past the race timeout; even if\n // we couldn't use its result for this run's prompt, it still writes\n // the cache for the next launch and the webui's status endpoint.\n //\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const refreshPromise = refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => null);\n const TIMEOUT = Symbol(\"timeout\");\n type RaceResult = UpdateStatus | null | typeof TIMEOUT;\n const raced: RaceResult = await Promise.race([\n refreshPromise,\n new Promise<typeof TIMEOUT>((resolve) =>\n setTimeout(() => resolve(TIMEOUT), 2000).unref()\n ),\n ]);\n const status: UpdateStatus =\n raced && raced !== TIMEOUT\n ? (raced as UpdateStatus)\n : getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n if (status.hasUpdate) {\n try {\n await maybePromptForUpdate(status);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "7a65875dce57f42430ad35dcfe55a0c876d5fc44": {"t": 1785749000.5296302, "n": 25976, "h": "66ecdd81b1872d39", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\nimport { printBoxedBanner } from \"./util/cliBanner.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null\n): void {\n // Collect every runtime status line first, then frame the whole block in\n // a rounded box. Width is content-driven so the right border always aligns.\n const lines: string[] = [];\n lines.push(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n lines.push(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n lines.push(`provider: ${cfg.defaultProviderId}`);\n lines.push(`upstream: ${cfg.baseUrl}`);\n lines.push(`api key: ${redactKey(cfg.apiKey)}`);\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n lines.push(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n lines.push(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n lines.push(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n lines.push(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n lines.push(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n lines.push(`data dir: ${cfg.dataDir}`);\n } else {\n lines.push(`admin UI: disabled (--no-admin)`);\n }\n\n printBoxedBanner(lines);\n // 不再在启动时把 ~/.codex/auth.json + config.toml 完整 snippet 打印到终端 ——\n // 用户反馈太啰嗦。需要查具体配置请到 admin 控制台(Setup / 对接指引 页),\n // 或运行 `mimo2codex print-config` / `print-cc-switch` 显式生成。\n // eslint-disable-next-line no-console\n console.log(\"\");\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请到 admin 控制台查看: http://${cfg.host}:${cfg.port}/admin/`\n );\n } else {\n // eslint-disable-next-line no-console\n console.log(\n `具体配置请运行 \\`mimo2codex print-config\\`(或 \\`print-cc-switch\\`)查看。`\n );\n }\n // target 仅在 print-config / print-cc-switch subcommand 路径下用到,本函数已不再消费。\n void target;\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n `error: failed to open admin database at ${cfg.dataDir}: ${(err as Error).message}\\n` +\n `Pass --no-admin to disable persistence, or --data-dir <path> to choose a writable location.`\n );\n process.exit(2);\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. CLI restart is rare and explicit — the user is sitting at the\n // terminal asking \"anything new?\". So we ALWAYS hit npm at startup,\n // ignoring the 6h cache TTL. Without this, a cache written when\n // v0.2.10 was the latest known stays \"fresh\" for 6h after v0.2.11\n // ships, suppressing prompts across every restart in that window.\n // 2. Race the refresh against a 2s timeout so a slow / blackholed npm\n // mirror never delays startup beyond 2s. Network failures bubble up\n // as a null resolution (refreshCacheInBackground swallows fetch\n // errors internally) and we fall through to cached state.\n // 3. The refresh promise keeps running past the race timeout; even if\n // we couldn't use its result for this run's prompt, it still writes\n // the cache for the next launch and the webui's status endpoint.\n //\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const refreshPromise = refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => null);\n const TIMEOUT = Symbol(\"timeout\");\n type RaceResult = UpdateStatus | null | typeof TIMEOUT;\n const raced: RaceResult = await Promise.race([\n refreshPromise,\n new Promise<typeof TIMEOUT>((resolve) =>\n setTimeout(() => resolve(TIMEOUT), 2000).unref()\n ),\n ]);\n const status: UpdateStatus =\n raced && raced !== TIMEOUT\n ? (raced as UpdateStatus)\n : getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n if (status.hasUpdate) {\n try {\n await maybePromptForUpdate(status);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "96e1681f02fa22f7884b6e69a7cc00b93ddd969d": {"t": 1785749001.815116, "n": 25549, "h": "2b30de1eb3de5ca6", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\nimport { printBoxedBanner, colorizeSnippet } from \"./util/cliBanner.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null\n): void {\n // Collect every runtime status line first, then frame the whole block in\n // a rounded box. Width is content-driven so the right border always aligns.\n const lines: string[] = [];\n lines.push(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n lines.push(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n lines.push(`provider: ${cfg.defaultProviderId}`);\n lines.push(`upstream: ${cfg.baseUrl}`);\n lines.push(`api key: ${redactKey(cfg.apiKey)}`);\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n lines.push(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n lines.push(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n lines.push(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n lines.push(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n lines.push(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n lines.push(`data dir: ${cfg.dataDir}`);\n } else {\n lines.push(`admin UI: disabled (--no-admin)`);\n }\n\n printBoxedBanner(lines);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(colorizeSnippet(configSnippet({ host: cfg.host, port: cfg.port }, target)));\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n `error: failed to open admin database at ${cfg.dataDir}: ${(err as Error).message}\\n` +\n `Pass --no-admin to disable persistence, or --data-dir <path> to choose a writable location.`\n );\n process.exit(2);\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. CLI restart is rare and explicit — the user is sitting at the\n // terminal asking \"anything new?\". So we ALWAYS hit npm at startup,\n // ignoring the 6h cache TTL. Without this, a cache written when\n // v0.2.10 was the latest known stays \"fresh\" for 6h after v0.2.11\n // ships, suppressing prompts across every restart in that window.\n // 2. Race the refresh against a 2s timeout so a slow / blackholed npm\n // mirror never delays startup beyond 2s. Network failures bubble up\n // as a null resolution (refreshCacheInBackground swallows fetch\n // errors internally) and we fall through to cached state.\n // 3. The refresh promise keeps running past the race timeout; even if\n // we couldn't use its result for this run's prompt, it still writes\n // the cache for the next launch and the webui's status endpoint.\n //\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const refreshPromise = refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => null);\n const TIMEOUT = Symbol(\"timeout\");\n type RaceResult = UpdateStatus | null | typeof TIMEOUT;\n const raced: RaceResult = await Promise.race([\n refreshPromise,\n new Promise<typeof TIMEOUT>((resolve) =>\n setTimeout(() => resolve(TIMEOUT), 2000).unref()\n ),\n ]);\n const status: UpdateStatus =\n raced && raced !== TIMEOUT\n ? (raced as UpdateStatus)\n : getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n if (status.hasUpdate) {\n try {\n await maybePromptForUpdate(status);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "dcdb614b0688f98374aa8b7590390a31f21aae85": {"t": 1785749003.0319076, "n": 24938, "h": "68880f2e25a394be", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n DEFAULT_TTL_MS,\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\nimport { printBoxedBanner, colorizeSnippet } from \"./util/cliBanner.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null\n): void {\n // Collect every runtime status line first, then frame the whole block in\n // a rounded box. Width is content-driven so the right border always aligns.\n const lines: string[] = [];\n lines.push(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n lines.push(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n lines.push(`provider: ${cfg.defaultProviderId}`);\n lines.push(`upstream: ${cfg.baseUrl}`);\n lines.push(`api key: ${redactKey(cfg.apiKey)}`);\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n lines.push(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n lines.push(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n lines.push(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n lines.push(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n lines.push(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n lines.push(`data dir: ${cfg.dataDir}`);\n } else {\n lines.push(`admin UI: disabled (--no-admin)`);\n }\n\n printBoxedBanner(lines);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(colorizeSnippet(configSnippet({ host: cfg.host, port: cfg.port }, target)));\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n `error: failed to open admin database at ${cfg.dataDir}: ${(err as Error).message}\\n` +\n `Pass --no-admin to disable persistence, or --data-dir <path> to choose a writable location.`\n );\n process.exit(2);\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. Read cached status synchronously (offline, instant). If it shows a\n // newer version, prompt the user (TTY only) before starting the server.\n // 2. Fire-and-forget a background refresh so the next launch sees a fresh\n // cache. We never block startup waiting for the network.\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const cached = getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n // Cache age check: if it's stale or absent, kick a refresh in the\n // background. The current process won't use the result; next launch will.\n const isStale =\n cached.checkedAt === null || Date.now() - cached.checkedAt >= DEFAULT_TTL_MS;\n if (isStale) {\n refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => {\n /* swallow — version check must never affect server startup */\n });\n }\n if (cached.hasUpdate) {\n try {\n await maybePromptForUpdate(cached);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "447c5808c9d24825bda00a73b5d189e8e7e5d279": {"t": 1785749004.341796, "n": 25153, "h": "235d8e1dbac2a7c4", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\nimport {\n DEFAULT_TTL_MS,\n getCachedStatus,\n refreshCacheInBackground,\n type UpdateStatus,\n} from \"./util/checkUpdate.js\";\nimport { runUpdate } from \"./setup/runUpdate.js\";\nimport { printLogo } from \"./util/logo.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex update\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n --no-update-check skip the startup npm registry version check\n (env: MIMO2CODEX_NO_UPDATE_CHECK=1)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n update detect the install method (npm-global vs git checkout) and\n run the matching update command, streaming output to your\n terminal. Exit code mirrors the underlying spawn.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\n// ─── update-check helpers ──────────────────────────────────────────────────\n// Async prompt that resolves on either a key press or a timeout. Used to ask\n// the user y/n at startup when a newer version is in cache. Restores raw-mode\n// state on every exit path so the rest of the process keeps a sane stdin.\nfunction timedYesNoPrompt(question: string, timeoutMs: number): Promise<\"y\" | \"n\" | \"timeout\"> {\n return new Promise((resolveOnce) => {\n const stdin = process.stdin;\n if (!stdin.isTTY) {\n resolveOnce(\"timeout\");\n return;\n }\n // eslint-disable-next-line no-console\n process.stdout.write(question);\n const wasRaw = stdin.isRaw;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding(\"utf8\");\n let settled = false;\n const finish = (result: \"y\" | \"n\" | \"timeout\"): void => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n stdin.removeListener(\"data\", onData);\n stdin.setRawMode(wasRaw);\n stdin.pause();\n // print a newline so the next banner line starts on a fresh row\n process.stdout.write(\"\\n\");\n resolveOnce(result);\n };\n const onData = (chunk: string): void => {\n const c = chunk.toLowerCase();\n if (c === \"\u0003\") {\n // Ctrl-C while we hold the prompt — let the user kill the process\n finish(\"timeout\");\n process.exit(130);\n }\n if (c === \"y\") return finish(\"y\");\n if (c === \"n\" || c === \"\\r\" || c === \"\\n\") return finish(\"n\");\n // any other key: keep waiting\n };\n stdin.on(\"data\", onData);\n const timer = setTimeout(() => finish(\"timeout\"), timeoutMs);\n });\n}\n\nfunction shouldSkipUpdateCheck(\n parsed: ReturnType<typeof parseArgv>,\n env: NodeJS.ProcessEnv\n): boolean {\n if (parsed.noUpdateCheck) return true;\n if (env.MIMO2CODEX_NO_UPDATE_CHECK) return true;\n return false;\n}\n\nasync function maybePromptForUpdate(status: UpdateStatus): Promise<void> {\n if (!status.hasUpdate || !status.latest) return;\n const banner = `New version v${status.latest} available (current ${status.current}).`;\n const stdinTTY = process.stdin.isTTY;\n const stdoutTTY = process.stdout.isTTY;\n if (!stdinTTY || !stdoutTTY) {\n // eslint-disable-next-line no-console\n console.log(\n `[update] ${banner} Run \\`mimo2codex update\\` to install, or \\`mimo2codex --no-update-check\\` to silence.`\n );\n return;\n }\n const answer = await timedYesNoPrompt(\n `${banner} Update now? [y/N] (auto-skip in 5s) `,\n 5000\n );\n if (answer === \"y\") {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n process.exit(result.exitCode);\n }\n // n / timeout: continue startup, leaving banner visible\n}\n\nasync function runUpdateSubcommand(): Promise<never> {\n const result = await runUpdate({\n onLine: (line, stream) => {\n // eslint-disable-next-line no-console\n (stream === \"stderr\" ? console.error : console.log)(line);\n },\n });\n if (result.skipped) {\n // eslint-disable-next-line no-console\n console.log(\n `\\nNo automated update path detected for this install. Run the suggested command manually:`\n );\n // eslint-disable-next-line no-console\n console.log(` ${result.command}`);\n }\n process.exit(result.exitCode);\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null\n): void {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n // eslint-disable-next-line no-console\n console.log(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n // eslint-disable-next-line no-console\n console.log(`provider: ${cfg.defaultProviderId}`);\n // eslint-disable-next-line no-console\n console.log(`upstream: ${cfg.baseUrl}`);\n // eslint-disable-next-line no-console\n console.log(`api key: ${redactKey(cfg.apiKey)}`);\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n // eslint-disable-next-line no-console\n console.log(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n // eslint-disable-next-line no-console\n console.log(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n // eslint-disable-next-line no-console\n console.log(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n // eslint-disable-next-line no-console\n console.log(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n // eslint-disable-next-line no-console\n console.log(`data dir: ${cfg.dataDir}`);\n } else {\n // eslint-disable-next-line no-console\n console.log(`admin UI: disabled (--no-admin)`);\n }\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(configSnippet({ host: cfg.host, port: cfg.port }, target));\n}\n\nasync function main(): Promise<void> {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n printLogo(VERSION);\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Logo prints for normal startup + init + update. Subcommands whose output\n // is intended to be piped to a file (print-config / print-cc-switch) skip\n // it so the resulting snippets stay clean.\n const subcmd = parsed.positional[0];\n if (subcmd !== \"print-config\" && subcmd !== \"print-cc-switch\") {\n printLogo(VERSION);\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // `update` subcommand: detect install method, spawn the matching update\n // command, stream output to terminal, exit with the spawn's exit code.\n if (parsed.positional[0] === \"update\") {\n await runUpdateSubcommand();\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n `error: failed to open admin database at ${cfg.dataDir}: ${(err as Error).message}\\n` +\n `Pass --no-admin to disable persistence, or --data-dir <path> to choose a writable location.`\n );\n process.exit(2);\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv);\n\n // Update-check: gated by --no-update-check / env / settings. Strategy:\n // 1. Read cached status synchronously (offline, instant). If it shows a\n // newer version, prompt the user (TTY only) before starting the server.\n // 2. Fire-and-forget a background refresh so the next launch sees a fresh\n // cache. We never block startup waiting for the network.\n // Use dataDirForLoader (not cfg.dataDir): this honors --data-dir even\n // when --no-admin is passed, so opt-in-only users still get update prompts\n // from their cache file. Falls back to empty when nothing's resolvable.\n const updateCheckDataDir = dataDirForLoader || null;\n if (!shouldSkipUpdateCheck(parsed, process.env) && updateCheckDataDir) {\n const cached = getCachedStatus({ currentVersion: VERSION, dataDir: updateCheckDataDir });\n // Cache age check: if it's stale or absent, kick a refresh in the\n // background. The current process won't use the result; next launch will.\n const isStale =\n cached.checkedAt === null || Date.now() - cached.checkedAt >= DEFAULT_TTL_MS;\n if (isStale) {\n refreshCacheInBackground({\n currentVersion: VERSION,\n dataDir: updateCheckDataDir,\n }).catch(() => {\n /* swallow — version check must never affect server startup */\n });\n }\n if (cached.hasUpdate) {\n try {\n await maybePromptForUpdate(cached);\n } catch (err) {\n // Prompt failures (e.g. raw-mode unsupported) should never block start\n log.warn(\"update prompt failed\", { error: (err as Error).message });\n }\n }\n }\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain().catch((err) => {\n // eslint-disable-next-line no-console\n console.error(`fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"}, "d3f0ada0303b66a3af8e06bbf18104aa5a57703f": {"t": 1785749005.6310256, "n": 18806, "h": "2245220686fd6529", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { loadDotenvFile } from \"./util/dotenv.js\";\nimport {\n bundledExamplePath,\n dataDirEnvPath,\n dataDirExamplePath,\n ensureDataDirEnv,\n refreshDataDirExample,\n} from \"./setup/initEnv.js\";\nimport { PROVIDER_LIST } from \"./providers/registry.js\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex init\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n --no-load-env skip auto-loading <data-dir>/.env on startup\n (default: auto-loaded if the file exists)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n init bootstrap <data-dir>/.env + .env.example from the bundled\n template. Idempotent: refreshes .env.example, only creates\n .env if absent. Run this once after install, edit .env, then\n launch mimo2codex normally — keys auto-load on every start.\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\n// Collect the env-var names every registered provider could pull a key from,\n// so we can detect \"user already has a key in shell env, don't surprise them\n// with bootstrap\" reliably across built-ins + generic providers.\nfunction knownProviderKeyEnvNames(): string[] {\n const names = new Set<string>();\n for (const p of PROVIDER_LIST) {\n for (const k of p.envKeys) names.add(k);\n }\n // Generic single-instance env var (synthesized when GENERIC_BASE_URL is set).\n names.add(\"GENERIC_API_KEY\");\n return [...names];\n}\n\nfunction hasAnyProviderKey(env: NodeJS.ProcessEnv): boolean {\n return knownProviderKeyEnvNames().some((n) => !!env[n]);\n}\n\n// Auto-load <dataDir>/.env into process.env on startup. Returns the load\n// result so the banner can mention which keys came from the file. Silent\n// no-op when the file doesn't exist — bootstrap is handled separately.\nfunction tryAutoLoadEnv(dataDir: string): { path: string; loaded: string[] } | null {\n const envPath = dataDirEnvPath(dataDir);\n if (!existsSync(envPath)) return null;\n try {\n const result = loadDotenvFile(envPath, process.env);\n return { path: envPath, loaded: result.loaded };\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`load-env: failed to read ${envPath}: ${(err as Error).message}`);\n return null;\n }\n}\n\nfunction runInitSubcommand(parsed: ReturnType<typeof parseArgv>): void {\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (!existsSync(bundled)) {\n // eslint-disable-next-line no-console\n console.error(\n `error: bundled .env.example not found at ${bundled}. This usually means a broken install — try \\`npm i -g mimo2codex\\` again.`\n );\n process.exit(2);\n }\n const refreshed = refreshDataDirExample(dataDir);\n const ensured = ensureDataDirEnv(dataDir);\n // eslint-disable-next-line no-console\n console.log(`mimo2codex init`);\n // eslint-disable-next-line no-console\n console.log(` data dir: ${dataDir}`);\n // eslint-disable-next-line no-console\n console.log(` template: ${refreshed.dest} (refreshed)`);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (created from template)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(` 2. Run \\`mimo2codex\\` — the file is auto-loaded on every start.`);\n } else {\n // eslint-disable-next-line no-console\n console.log(` env file: ${ensured.envPath} (already exists — left untouched)`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(`The template at ${refreshed.dest} was refreshed; copy any new keys`);\n // eslint-disable-next-line no-console\n console.log(`into your .env manually if you want them.`);\n }\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\nfunction printStartupBanner(\n cfg: Config,\n target: SnippetTarget,\n autoLoadedEnv: { path: string; loaded: string[] } | null\n): void {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n if (autoLoadedEnv) {\n // eslint-disable-next-line no-console\n console.log(\n `env file: ${autoLoadedEnv.path} (${autoLoadedEnv.loaded.length} key${autoLoadedEnv.loaded.length === 1 ? \"\" : \"s\"}: ${autoLoadedEnv.loaded.join(\", \") || \"—\"})`\n );\n }\n // eslint-disable-next-line no-console\n console.log(`provider: ${cfg.defaultProviderId}`);\n // eslint-disable-next-line no-console\n console.log(`upstream: ${cfg.baseUrl}`);\n // eslint-disable-next-line no-console\n console.log(`api key: ${redactKey(cfg.apiKey)}`);\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n // eslint-disable-next-line no-console\n console.log(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n // eslint-disable-next-line no-console\n console.log(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n // eslint-disable-next-line no-console\n console.log(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n // eslint-disable-next-line no-console\n console.log(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n // eslint-disable-next-line no-console\n console.log(`data dir: ${cfg.dataDir}`);\n } else {\n // eslint-disable-next-line no-console\n console.log(`admin UI: disabled (--no-admin)`);\n }\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(configSnippet({ host: cfg.host, port: cfg.port }, target));\n}\n\nfunction main(): void {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // `init` subcommand: bootstrap <data-dir>/.env + .env.example and exit.\n // Always idempotent; safe to re-run.\n if (parsed.positional[0] === \"init\") {\n runInitSubcommand(parsed);\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n\n // Auto-load <dataDir>/.env into process.env before generic-provider\n // resolution (envKey lookups) and buildConfig (key + base-url reads).\n // Skipped for print-* subcommands (don't side-effect previews) and when\n // --no-load-env is set. First-run bootstrap: when no .env exists AND no\n // provider key is anywhere in env, copy the bundled template into place\n // and exit with friendly instructions — that's the npm-install user's\n // first interaction with mimo2codex.\n let autoLoadedEnv: { path: string; loaded: string[] } | null = null;\n if (!isSubcommand && !parsed.noLoadEnv && dataDirForLoader) {\n autoLoadedEnv = tryAutoLoadEnv(dataDirForLoader);\n if (!autoLoadedEnv && !hasAnyProviderKey(process.env)) {\n // First-run UX: no .env file, no keys in shell env, user clearly hasn't\n // set things up yet. Bootstrap and exit cleanly so they can edit.\n const dataDir = resolveDataDir(parsed.dataDir, process.env);\n const bundled = bundledExamplePath();\n if (existsSync(bundled)) {\n const ensured = ensureDataDirEnv(dataDir);\n if (ensured.created) {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex: first-run setup`);\n // eslint-disable-next-line no-console\n console.log(\n ` Created ${ensured.envPath} from the bundled template (${dataDirExamplePath(dataDir)}).`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\"Next:\");\n // eslint-disable-next-line no-console\n console.log(` 1. Open ${ensured.envPath} and fill in your API key(s).`);\n // eslint-disable-next-line no-console\n console.log(\n ` 2. Re-run \\`mimo2codex\\` — the file is auto-loaded on every start.`\n );\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(\n `Tip: \\`mimo2codex --no-load-env\\` skips the auto-load if you prefer shell-exported keys.`\n );\n return;\n }\n }\n // Bundled template missing or bootstrap failed — fall through to the\n // existing \"missing API key\" error from buildConfig, which already\n // points users at the right docs.\n }\n }\n\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n `error: failed to open admin database at ${cfg.dataDir}: ${(err as Error).message}\\n` +\n `Pass --no-admin to disable persistence, or --data-dir <path> to choose a writable location.`\n );\n process.exit(2);\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model), autoLoadedEnv);\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain();\n"}, "a0d10e3c03b8c88c83afff62fad91e30b1c7361e": {"t": 1785749007.2298443, "n": 12043, "h": "127f528746de0588", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { initRegistry } from \"./providers/registry.js\";\nimport { loadGenericProviders, GenericLoaderError } from \"./providers/genericLoader.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// Discover the data-dir path WITHOUT creating it. Used for print-config /\n// print-cc-switch subcommands so a one-shot snippet print doesn't have\n// filesystem side effects.\nfunction nonCreatingDataDirCandidate(\n cliOverride: string | undefined,\n env: NodeJS.ProcessEnv\n): string {\n const dir = cliOverride ?? env.MIMO2CODEX_DATA_DIR ?? join(homedir(), \".mimo2codex\");\n return existsSync(dir) ? dir : \"\";\n}\nimport {\n ccSwitchSnippet,\n configSnippet,\n configSnippetEnvKey,\n resolveSnippetTarget,\n type SnippetTarget,\n} from \"./setup/snippets.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nGENERIC OPENAI-COMPAT PROVIDERS\n Declare any OpenAI Chat-Completions-compatible upstream (Qwen, GLM, Kimi,\n vLLM, Ollama, etc.) in providers.json — by default at:\n ~/.mimo2codex/providers.json\n Or set MIMO2CODEX_PROVIDERS_FILE to point elsewhere.\n\n For a one-shot single instance, set GENERIC_BASE_URL + GENERIC_API_KEY +\n GENERIC_DEFAULT_MODEL; mimo2codex synthesizes a provider with id \"generic\".\n\n Each provider entry supports wireApi: \"chat\" (default — translate to\n Chat Completions) or \"responses\" (pipe Codex's Responses payload through\n to the upstream's /v1/responses unchanged — use when the upstream natively\n speaks the Responses API).\n\n To make a generic provider the default, pass --model <id-or-shortcut>\n (e.g. \\`--model qwen\\`). With no flag, mimo2codex defaults to mimo.\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n\n # Generic OpenAI-compat upstream — single instance via env vars\n GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 \\\\\n GENERIC_API_KEY=sk-... GENERIC_DEFAULT_MODEL=qwen3-max \\\\\n mimo2codex --model generic\n\n # Multi-instance — declare in ~/.mimo2codex/providers.json, then:\n QWEN_API_KEY=sk-... mimo2codex --model qwen\n`;\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\nfunction printStartupBanner(cfg: Config, target: SnippetTarget): void {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n // eslint-disable-next-line no-console\n console.log(`provider: ${cfg.defaultProviderId}`);\n // eslint-disable-next-line no-console\n console.log(`upstream: ${cfg.baseUrl}`);\n // eslint-disable-next-line no-console\n console.log(`api key: ${redactKey(cfg.apiKey)}`);\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n // eslint-disable-next-line no-console\n console.log(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n // eslint-disable-next-line no-console\n console.log(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n // eslint-disable-next-line no-console\n console.log(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n // eslint-disable-next-line no-console\n console.log(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n // eslint-disable-next-line no-console\n console.log(`data dir: ${cfg.dataDir}`);\n } else {\n // eslint-disable-next-line no-console\n console.log(`admin UI: disabled (--no-admin)`);\n }\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(configSnippet({ host: cfg.host, port: cfg.port }, target));\n}\n\nfunction main(): void {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n // Register generic providers from providers.json (or GENERIC_* env vars)\n // BEFORE we look at print-config / print-cc-switch subcommands, so those\n // can resolve `--model qwen` against a user-declared generic. We do NOT\n // call resolveDataDir() here (which would auto-create ~/.mimo2codex/) — we\n // only inspect the default path if it already exists, so a one-shot\n // `mimo2codex print-config` doesn't have filesystem side effects.\n const isSubcommand =\n parsed.positional[0] === \"print-config\" || parsed.positional[0] === \"print-cc-switch\";\n const adminEnabledForLoader = parsed.noAdmin\n ? false\n : process.env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDirForLoader =\n !isSubcommand && adminEnabledForLoader\n ? resolveDataDir(parsed.dataDir, process.env)\n : nonCreatingDataDirCandidate(parsed.dataDir, process.env);\n try {\n const generics = loadGenericProviders(process.env, dataDirForLoader);\n initRegistry(generics);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n // eslint-disable-next-line no-console\n console.error(`error: ${err.message}`);\n process.exit(2);\n }\n throw err;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed.model);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n `error: failed to open admin database at ${cfg.dataDir}: ${(err as Error).message}\\n` +\n `Pass --no-admin to disable persistence, or --data-dir <path> to choose a writable location.`\n );\n process.exit(2);\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed.model));\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain();\n"}, "ae10a9a0c8a940478ce1cf61af034975ff5d77ed": {"t": 1785749008.431083, "n": 16140, "h": "401f3c583e3da3c6", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config, type ParsedArgs } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { byShortcut, PROVIDERS } from \"./providers/registry.js\";\nimport type { ProviderId } from \"./providers/types.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n`;\n\ninterface SnippetTarget {\n providerId: ProviderId;\n providerKey: string; // toml [model_providers.<key>] name\n providerLabel: string; // human-readable name field\n modelId: string; // Codex's model = \"<id>\"\n contextWindow?: number; // for model_context_window\n maxOutputTokens?: number; // for model_max_output_tokens\n}\n\nfunction deepseekMaxOutput(): number {\n // DeepSeek V4 family: 1M input / 384K max output across all current models.\n return 393_216;\n}\n\n// Pick the provider's default model + look up its context window. Alternative\n// model ids (including the long-context [1m] variants) are surfaced via the\n// `alternativesComment` block embedded in the printed toml — users edit two\n// lines (model + model_context_window) to switch.\nfunction resolveSnippetTarget(parsed: ParsedArgs): SnippetTarget {\n const providerId: ProviderId = parsed.model\n ? (byShortcut(parsed.model)?.id ?? \"mimo\")\n : \"mimo\";\n const provider = PROVIDERS[providerId];\n const modelMeta = provider.builtinModels.find((m) => m.id === provider.defaultModel);\n return {\n providerId,\n providerKey: providerId === \"mimo\" ? \"mimo\" : \"mimo2codex\",\n providerLabel: provider.displayName,\n modelId: provider.defaultModel,\n contextWindow: modelMeta?.contextWindow,\n maxOutputTokens: providerId === \"deepseek\" ? deepseekMaxOutput() : undefined,\n };\n}\n\nfunction modelTuningLines(t: SnippetTarget): string {\n const lines: string[] = [];\n if (t.contextWindow) lines.push(`model_context_window = ${t.contextWindow}`);\n if (t.maxOutputTokens) lines.push(`model_max_output_tokens = ${t.maxOutputTokens}`);\n return lines.length ? \"\\n\" + lines.join(\"\\n\") : \"\";\n}\n\n// Comment block listing every builtin model for the chosen provider, with the\n// matching context_window and (for DS) max_output_tokens. Lets the user pick\n// a different variant just by editing two lines in their config.toml — no\n// extra CLI flag needed.\nfunction alternativesComment(t: SnippetTarget): string {\n const provider = PROVIDERS[t.providerId];\n const lines: string[] = [];\n lines.push(\n `# Switch model — replace the two lines above (model = ... and`,\n `# model_context_window = ...) with one of the entries below. The 1M variants`,\n `# require (1) your account to actually offer them upstream, and (2) Codex`,\n `# client to honor model_context_window (older versions cap at ~256K).`,\n `# Available ${provider.displayName} models:`\n );\n for (const m of provider.builtinModels) {\n if (m.deprecatedAfter) continue; // hide legacy aliases from the suggestion list\n const ctx = m.contextWindow\n ? ` model_context_window = ${m.contextWindow}`\n : \"\";\n const maxOut = t.providerId === \"deepseek\" && m.contextWindow\n ? ` model_max_output_tokens = ${deepseekMaxOutput()}`\n : \"\";\n const marker = m.id === t.modelId ? \" (current)\" : \"\";\n lines.push(`# model = \"${m.id}\"${ctx}${maxOut}${marker}`);\n }\n return lines.join(\"\\n\");\n}\n\n// Default snippet — uses ~/.codex/auth.json + requires_openai_auth = true.\n// This avoids the common \"Missing environment variable: MIMO2CODEX_KEY\" error\n// on the Codex desktop app, which doesn't inherit shell env vars set via\n// `export` or `setx`. Works for both CLI and desktop with no env setup.\nfunction configSnippet(cfg: { host: string; port: number }, target: SnippetTarget): string {\n return `# Step 1 — write ~/.codex/auth.json (Windows: %USERPROFILE%\\\\.codex\\\\auth.json)\n# Any non-empty value works; mimo2codex does not validate inbound credentials.\n{\n \"OPENAI_API_KEY\": \"mimo2codex-local\"\n}\n\n# Step 2 — append to ~/.codex/config.toml (Windows: %USERPROFILE%\\\\.codex\\\\config.toml)\nmodel = \"${target.modelId}\"\nmodel_provider = \"${target.providerKey}\"${modelTuningLines(target)}\n\n${alternativesComment(target)}\n\n[model_providers.${target.providerKey}]\nname = \"${target.providerLabel}\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n\n# Step 3 — completely quit and restart Codex (the desktop app must be relaunched\n# for the new auth.json to be picked up). Then run \\`codex\\` and pick this provider.\n\n# ⚠️ If you also use Codex with your real OpenAI account, this auth.json overwrites\n# your OpenAI login. Use cc-switch (\\`mimo2codex print-cc-switch\\`) instead to switch\n# between providers cleanly, or use \\`mimo2codex print-config --env-key\\` for the\n# env-var-based variant (works for Codex CLI but not the desktop app).\n`;\n}\n\n// Legacy env_key variant — keeps ~/.codex/auth.json untouched (preserving any\n// existing OpenAI login). Requires MIMO2CODEX_KEY to be set in the environment\n// of the process running \\`codex\\`. Codex DESKTOP APP does not inherit shell env\n// vars on macOS/Windows, so this variant only works reliably for the CLI.\nfunction configSnippetEnvKey(cfg: { host: string; port: number }, target: SnippetTarget): string {\n return `# ~/.codex/config.toml — env-var variant (Codex CLI only; desktop app won't see shell env vars)\nmodel = \"${target.modelId}\"\nmodel_provider = \"${target.providerKey}\"${modelTuningLines(target)}\n\n${alternativesComment(target)}\n\n[model_providers.${target.providerKey}]\nname = \"${target.providerLabel}\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nenv_key = \"MIMO2CODEX_KEY\"\nrequest_max_retries = 1\n\n# Then in your shell (the same shell you launch \\`codex\\` from):\n# export MIMO2CODEX_KEY=anything # macOS/Linux/Git Bash\n# $env:MIMO2CODEX_KEY=\"anything\" # Windows PowerShell\n# set MIMO2CODEX_KEY=anything # Windows CMD (current session only)\n#\n# For Codex DESKTOP APP, this variant does NOT work — desktop apps launched from\n# Finder/Start Menu don't inherit shell env vars. Use the default print-config\n# (auth.json variant) or \\`mimo2codex print-cc-switch\\` instead.\n`;\n}\n\n// cc-switch (https://github.com/farion1231/cc-switch) is a desktop app that\n// manages multiple Codex providers via a \"+\" → \"Custom\" panel. It writes\n// ~/.codex/auth.json + ~/.codex/config.toml when you switch providers.\n// This subcommand prints both snippets in a copy-pasteable form so users can\n// add mimo2codex as a custom Codex provider in cc-switch.\nfunction ccSwitchSnippet(cfg: { host: string; port: number }, target: SnippetTarget): string {\n const authJson = JSON.stringify({ OPENAI_API_KEY: \"mimo2codex-local\" }, null, 2);\n const configToml = `model_provider = \"${target.providerKey}\"\nmodel = \"${target.modelId}\"${modelTuningLines(target)}\n\n${alternativesComment(target)}\n\n[model_providers.${target.providerKey}]\nname = \"${target.providerLabel}\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n`;\n return `# cc-switch — Add Provider → Codex tab → Custom\n\n# ───────── auth.json (paste into the auth.json textarea) ─────────\n${authJson}\n\n# ───────── config.toml (paste into the config.toml textarea) ─────────\n${configToml}\n# Note: OPENAI_API_KEY can be any non-empty string — mimo2codex does not\n# validate inbound credentials. Your real upstream key (MIMO_API_KEY /\n# DS_API_KEY) stays in the env of the machine running mimo2codex.\n`;\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\nfunction printStartupBanner(cfg: Config, target: SnippetTarget): void {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n // eslint-disable-next-line no-console\n console.log(`provider: ${cfg.defaultProviderId}`);\n // eslint-disable-next-line no-console\n console.log(`upstream: ${cfg.baseUrl}`);\n // eslint-disable-next-line no-console\n console.log(`api key: ${redactKey(cfg.apiKey)}`);\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n // eslint-disable-next-line no-console\n console.log(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n // eslint-disable-next-line no-console\n console.log(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n // eslint-disable-next-line no-console\n console.log(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n // eslint-disable-next-line no-console\n console.log(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n // eslint-disable-next-line no-console\n console.log(`data dir: ${cfg.dataDir}`);\n } else {\n // eslint-disable-next-line no-console\n console.log(`admin UI: disabled (--no-admin)`);\n }\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(configSnippet({ host: cfg.host, port: cfg.port }, target));\n}\n\nfunction main(): void {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n `error: failed to open admin database at ${cfg.dataDir}: ${(err as Error).message}\\n` +\n `Pass --no-admin to disable persistence, or --data-dir <path> to choose a writable location.`\n );\n process.exit(2);\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed));\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain();\n"}, "80b5cb63afdb24d3dfa96af654b39d13241e965d": {"t": 1785749011.1286833, "n": 16573, "h": "a20281f8177839b4", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config, type ParsedArgs } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\nimport { byShortcut, PROVIDERS } from \"./providers/registry.js\";\nimport type { ProviderId } from \"./providers/types.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --long-context for print-config / print-cc-switch — pick the largest-window\n model variant of the chosen provider (MiMo → mimo-v2.5-pro[1m]\n / 1M; DeepSeek → deepseek-v4-pro / 1M, already default).\n Emits the matching model_context_window automatically.\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n`;\n\ninterface SnippetTarget {\n providerId: ProviderId;\n providerKey: string; // toml [model_providers.<key>] name\n providerLabel: string; // human-readable name field\n modelId: string; // Codex's model = \"<id>\"\n contextWindow?: number; // for model_context_window\n maxOutputTokens?: number; // for model_max_output_tokens\n}\n\nfunction deepseekMaxOutput(): number {\n // DeepSeek V4 family: 1M input / 384K max output across all current models.\n return 393_216;\n}\n\n// Pick the provider's default model. `--long-context` upgrades to the largest-\n// context builtin variant for that provider (MiMo → mimo-v2.5-pro[1m]; DeepSeek\n// is already 1M by default). Alternative model ids are also surfaced as a\n// comment block so users can swap by editing one line in cc-switch's textarea.\nfunction resolveSnippetTarget(parsed: ParsedArgs): SnippetTarget {\n const providerId: ProviderId = parsed.model\n ? (byShortcut(parsed.model)?.id ?? \"mimo\")\n : \"mimo\";\n const provider = PROVIDERS[providerId];\n let modelMeta = provider.builtinModels.find((m) => m.id === provider.defaultModel);\n if (parsed.longContext) {\n const longest = provider.builtinModels\n .filter((m) => !m.deprecatedAfter && m.contextWindow)\n .reduce(\n (best, m) =>\n !best || (m.contextWindow ?? 0) > (best.contextWindow ?? 0) ? m : best,\n modelMeta\n );\n if (longest) modelMeta = longest;\n }\n const modelId = modelMeta?.id ?? provider.defaultModel;\n return {\n providerId,\n providerKey: providerId === \"mimo\" ? \"mimo\" : \"mimo2codex\",\n providerLabel: provider.displayName,\n modelId,\n contextWindow: modelMeta?.contextWindow,\n maxOutputTokens: providerId === \"deepseek\" ? deepseekMaxOutput() : undefined,\n };\n}\n\nfunction modelTuningLines(t: SnippetTarget): string {\n const lines: string[] = [];\n if (t.contextWindow) lines.push(`model_context_window = ${t.contextWindow}`);\n if (t.maxOutputTokens) lines.push(`model_max_output_tokens = ${t.maxOutputTokens}`);\n return lines.length ? \"\\n\" + lines.join(\"\\n\") : \"\";\n}\n\n// Comment block listing every builtin model for the chosen provider, with the\n// matching context_window and (for DS) max_output_tokens. Lets the user pick\n// a different variant just by editing two lines in their config.toml — no\n// extra CLI flag needed.\nfunction alternativesComment(t: SnippetTarget): string {\n const provider = PROVIDERS[t.providerId];\n const lines: string[] = [];\n lines.push(`# Other ${provider.displayName} models (edit the lines above to switch):`);\n for (const m of provider.builtinModels) {\n if (m.deprecatedAfter) continue; // hide legacy aliases from the suggestion list\n const ctx = m.contextWindow\n ? ` model_context_window = ${m.contextWindow}`\n : \"\";\n const maxOut = t.providerId === \"deepseek\" && m.contextWindow\n ? ` model_max_output_tokens = ${deepseekMaxOutput()}`\n : \"\";\n const marker = m.id === t.modelId ? \" (current)\" : \"\";\n lines.push(`# model = \"${m.id}\"${ctx}${maxOut}${marker}`);\n }\n return lines.join(\"\\n\");\n}\n\n// Default snippet — uses ~/.codex/auth.json + requires_openai_auth = true.\n// This avoids the common \"Missing environment variable: MIMO2CODEX_KEY\" error\n// on the Codex desktop app, which doesn't inherit shell env vars set via\n// `export` or `setx`. Works for both CLI and desktop with no env setup.\nfunction configSnippet(cfg: { host: string; port: number }, target: SnippetTarget): string {\n return `# Step 1 — write ~/.codex/auth.json (Windows: %USERPROFILE%\\\\.codex\\\\auth.json)\n# Any non-empty value works; mimo2codex does not validate inbound credentials.\n{\n \"OPENAI_API_KEY\": \"mimo2codex-local\"\n}\n\n# Step 2 — append to ~/.codex/config.toml (Windows: %USERPROFILE%\\\\.codex\\\\config.toml)\nmodel = \"${target.modelId}\"\nmodel_provider = \"${target.providerKey}\"${modelTuningLines(target)}\n\n${alternativesComment(target)}\n\n[model_providers.${target.providerKey}]\nname = \"${target.providerLabel}\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n\n# Step 3 — completely quit and restart Codex (the desktop app must be relaunched\n# for the new auth.json to be picked up). Then run \\`codex\\` and pick this provider.\n\n# ⚠️ If you also use Codex with your real OpenAI account, this auth.json overwrites\n# your OpenAI login. Use cc-switch (\\`mimo2codex print-cc-switch\\`) instead to switch\n# between providers cleanly, or use \\`mimo2codex print-config --env-key\\` for the\n# env-var-based variant (works for Codex CLI but not the desktop app).\n`;\n}\n\n// Legacy env_key variant — keeps ~/.codex/auth.json untouched (preserving any\n// existing OpenAI login). Requires MIMO2CODEX_KEY to be set in the environment\n// of the process running \\`codex\\`. Codex DESKTOP APP does not inherit shell env\n// vars on macOS/Windows, so this variant only works reliably for the CLI.\nfunction configSnippetEnvKey(cfg: { host: string; port: number }, target: SnippetTarget): string {\n return `# ~/.codex/config.toml — env-var variant (Codex CLI only; desktop app won't see shell env vars)\nmodel = \"${target.modelId}\"\nmodel_provider = \"${target.providerKey}\"${modelTuningLines(target)}\n\n${alternativesComment(target)}\n\n[model_providers.${target.providerKey}]\nname = \"${target.providerLabel}\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nenv_key = \"MIMO2CODEX_KEY\"\nrequest_max_retries = 1\n\n# Then in your shell (the same shell you launch \\`codex\\` from):\n# export MIMO2CODEX_KEY=anything # macOS/Linux/Git Bash\n# $env:MIMO2CODEX_KEY=\"anything\" # Windows PowerShell\n# set MIMO2CODEX_KEY=anything # Windows CMD (current session only)\n#\n# For Codex DESKTOP APP, this variant does NOT work — desktop apps launched from\n# Finder/Start Menu don't inherit shell env vars. Use the default print-config\n# (auth.json variant) or \\`mimo2codex print-cc-switch\\` instead.\n`;\n}\n\n// cc-switch (https://github.com/farion1231/cc-switch) is a desktop app that\n// manages multiple Codex providers via a \"+\" → \"Custom\" panel. It writes\n// ~/.codex/auth.json + ~/.codex/config.toml when you switch providers.\n// This subcommand prints both snippets in a copy-pasteable form so users can\n// add mimo2codex as a custom Codex provider in cc-switch.\nfunction ccSwitchSnippet(cfg: { host: string; port: number }, target: SnippetTarget): string {\n const authJson = JSON.stringify({ OPENAI_API_KEY: \"mimo2codex-local\" }, null, 2);\n const configToml = `model_provider = \"${target.providerKey}\"\nmodel = \"${target.modelId}\"${modelTuningLines(target)}\n\n${alternativesComment(target)}\n\n[model_providers.${target.providerKey}]\nname = \"${target.providerLabel}\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n`;\n return `# cc-switch — Add Provider → Codex tab → Custom\n\n# ───────── auth.json (paste into the auth.json textarea) ─────────\n${authJson}\n\n# ───────── config.toml (paste into the config.toml textarea) ─────────\n${configToml}\n# Note: OPENAI_API_KEY can be any non-empty string — mimo2codex does not\n# validate inbound credentials. Your real upstream key (MIMO_API_KEY /\n# DS_API_KEY) stays in the env of the machine running mimo2codex.\n`;\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\nfunction printStartupBanner(cfg: Config, target: SnippetTarget): void {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n // eslint-disable-next-line no-console\n console.log(`provider: ${cfg.defaultProviderId}`);\n // eslint-disable-next-line no-console\n console.log(`upstream: ${cfg.baseUrl}`);\n // eslint-disable-next-line no-console\n console.log(`api key: ${redactKey(cfg.apiKey)}`);\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n // eslint-disable-next-line no-console\n console.log(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n // eslint-disable-next-line no-console\n console.log(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n // eslint-disable-next-line no-console\n console.log(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n // eslint-disable-next-line no-console\n console.log(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n // eslint-disable-next-line no-console\n console.log(`data dir: ${cfg.dataDir}`);\n } else {\n // eslint-disable-next-line no-console\n console.log(`admin UI: disabled (--no-admin)`);\n }\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(configSnippet({ host: cfg.host, port: cfg.port }, target));\n}\n\nfunction main(): void {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n const target = resolveSnippetTarget(parsed);\n // eslint-disable-next-line no-console\n console.log(\n useEnvKey\n ? configSnippetEnvKey({ host, port }, target)\n : configSnippet({ host, port }, target)\n );\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const target = resolveSnippetTarget(parsed);\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }, target));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n `error: failed to open admin database at ${cfg.dataDir}: ${(err as Error).message}\\n` +\n `Pass --no-admin to disable persistence, or --data-dir <path> to choose a writable location.`\n );\n process.exit(2);\n }\n }\n\n printStartupBanner(cfg, resolveSnippetTarget(parsed));\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain();\n"}, "738d731aa3f7ea75ae161514264471c933a4fa2d": {"t": 1785749012.2251022, "n": 12396, "h": "d49771b047a372b9", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\nimport { closeDb, openDb } from \"./db/index.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Chat Completions (MiMo / DeepSeek)\n\nUSAGE\n mimo2codex [options]\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --model <shortcut> default upstream provider: \"mimo\" (default) or \"ds\" (DeepSeek)\n --base-url <url> base url for the default provider (env: MIMO_BASE_URL / DEEPSEEK_BASE_URL)\n --api-key <key> api key for the default provider (env varies — see below) — required\n --no-reasoning hide reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n --data-dir <path> admin sqlite + UI data directory (default: ~/.mimo2codex,\n env: MIMO2CODEX_DATA_DIR)\n --no-admin disable the local admin UI + sqlite logging\n (env: MIMO2CODEX_NO_ADMIN=1)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nPROVIDER KEYS\n MiMo: MIMO_API_KEY (default base: https://api.xiaomimimo.com/v1)\n DeepSeek: DS_API_KEY or DEEPSEEK_API_KEY (default base: https://api.deepseek.com/v1)\n Set the key for whichever provider --model selects. Other providers are\n registered automatically when their key is present (per-request routing\n lands in a follow-up release).\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n`;\n\n// Default snippet — uses ~/.codex/auth.json + requires_openai_auth = true.\n// This avoids the common \"Missing environment variable: MIMO2CODEX_KEY\" error\n// on the Codex desktop app, which doesn't inherit shell env vars set via\n// `export` or `setx`. Works for both CLI and desktop with no env setup.\nfunction configSnippet(cfg: { host: string; port: number }): string {\n return `# Step 1 — write ~/.codex/auth.json (Windows: %USERPROFILE%\\\\.codex\\\\auth.json)\n# Any non-empty value works; mimo2codex does not validate inbound credentials.\n{\n \"OPENAI_API_KEY\": \"mimo2codex-local\"\n}\n\n# Step 2 — append to ~/.codex/config.toml (Windows: %USERPROFILE%\\\\.codex\\\\config.toml)\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n\n# Step 3 — completely quit and restart Codex (the desktop app must be relaunched\n# for the new auth.json to be picked up). Then run \\`codex\\` and pick this provider.\n\n# ⚠️ If you also use Codex with your real OpenAI account, this auth.json overwrites\n# your OpenAI login. Use cc-switch (\\`mimo2codex print-cc-switch\\`) instead to switch\n# between providers cleanly, or use \\`mimo2codex print-config --env-key\\` for the\n# env-var-based variant (works for Codex CLI but not the desktop app).\n`;\n}\n\n// Legacy env_key variant — keeps ~/.codex/auth.json untouched (preserving any\n// existing OpenAI login). Requires MIMO2CODEX_KEY to be set in the environment\n// of the process running \\`codex\\`. Codex DESKTOP APP does not inherit shell env\n// vars on macOS/Windows, so this variant only works reliably for the CLI.\nfunction configSnippetEnvKey(cfg: { host: string; port: number }): string {\n return `# ~/.codex/config.toml — env-var variant (Codex CLI only; desktop app won't see shell env vars)\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nenv_key = \"MIMO2CODEX_KEY\"\nrequest_max_retries = 1\n\n# Then in your shell (the same shell you launch \\`codex\\` from):\n# export MIMO2CODEX_KEY=anything # macOS/Linux/Git Bash\n# $env:MIMO2CODEX_KEY=\"anything\" # Windows PowerShell\n# set MIMO2CODEX_KEY=anything # Windows CMD (current session only)\n#\n# For Codex DESKTOP APP, this variant does NOT work — desktop apps launched from\n# Finder/Start Menu don't inherit shell env vars. Use the default print-config\n# (auth.json variant) or \\`mimo2codex print-cc-switch\\` instead.\n`;\n}\n\n// cc-switch (https://github.com/farion1231/cc-switch) is a desktop app that\n// manages multiple Codex providers via a \"+\" → \"Custom\" panel. It writes\n// ~/.codex/auth.json + ~/.codex/config.toml when you switch providers.\n// This subcommand prints both snippets in a copy-pasteable form so users can\n// add mimo2codex as a custom Codex provider in cc-switch.\nfunction ccSwitchSnippet(cfg: { host: string; port: number }): string {\n const authJson = JSON.stringify({ OPENAI_API_KEY: \"mimo2codex-local\" }, null, 2);\n const configToml = `model_provider = \"mimo2codex\"\nmodel = \"mimo-v2.5-pro\"\n\n[model_providers.mimo2codex]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n`;\n return `# cc-switch — Add Provider → Codex tab → Custom\n\n# ───────── auth.json (paste into the auth.json textarea) ─────────\n${authJson}\n\n# ───────── config.toml (paste into the config.toml textarea) ─────────\n${configToml}\n# Note: OPENAI_API_KEY can be any non-empty string — mimo2codex does not\n# validate inbound credentials. Your real MiMo key stays in MIMO_API_KEY\n# on the machine running mimo2codex.\n`;\n}\n\nfunction checkMimoHostMismatch(cfg: Config): string | null {\n // Catch the most common foot-gun: tp-* key sent at the pay-as-you-go host\n // (or sk-* key sent at the token-plan host) — usually because MIMO_BASE_URL\n // is left over in the shell from a previous session. Yields 401 or\n // confusing 400s upstream; cheaper to warn at startup.\n if (cfg.defaultProviderId !== \"mimo\") return null;\n const isTpKey = cfg.apiKey.startsWith(\"tp-\");\n const isSkKey = cfg.apiKey.startsWith(\"sk-\");\n const hostIsTokenPlan = /token-plan/i.test(cfg.baseUrl);\n if (isTpKey && !hostIsTokenPlan) {\n return `tp-* key 通常需要 token-plan 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否覆盖了自动推断。`;\n }\n if (isSkKey && hostIsTokenPlan) {\n return `sk-* key 通常需要 pay-as-you-go 主机,但当前 baseUrl 是 ${cfg.baseUrl}。检查 MIMO_BASE_URL / --base-url 是否泄漏(PowerShell: Remove-Item Env:MIMO_BASE_URL)。`;\n }\n return null;\n}\n\nfunction printStartupBanner(cfg: Config): void {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n // eslint-disable-next-line no-console\n console.log(`provider: ${cfg.defaultProviderId}`);\n // eslint-disable-next-line no-console\n console.log(`upstream: ${cfg.baseUrl}`);\n // eslint-disable-next-line no-console\n console.log(`api key: ${redactKey(cfg.apiKey)}`);\n const mismatch = checkMimoHostMismatch(cfg);\n if (mismatch) {\n // eslint-disable-next-line no-console\n console.log(`⚠ 警告: ${mismatch}`);\n }\n if (cfg.defaultProviderId === \"mimo\") {\n // eslint-disable-next-line no-console\n console.log(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n }\n // eslint-disable-next-line no-console\n console.log(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n const others = (Object.keys(cfg.providers) as Array<keyof typeof cfg.providers>)\n .filter((id) => id !== cfg.defaultProviderId && cfg.providers[id])\n .join(\", \");\n if (others) {\n // eslint-disable-next-line no-console\n console.log(`registered: ${others} (model-routed when client picks one of those ids)`);\n }\n if (cfg.adminEnabled) {\n // eslint-disable-next-line no-console\n console.log(`admin UI: http://${cfg.host}:${cfg.port}/admin/`);\n // eslint-disable-next-line no-console\n console.log(`data dir: ${cfg.dataDir}`);\n } else {\n // eslint-disable-next-line no-console\n console.log(`admin UI: disabled (--no-admin)`);\n }\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(configSnippet({ host: cfg.host, port: cfg.port }));\n}\n\nfunction main(): void {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n // eslint-disable-next-line no-console\n console.log(useEnvKey ? configSnippetEnvKey({ host, port }) : configSnippet({ host, port }));\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n\n if (cfg.adminEnabled) {\n try {\n openDb(cfg.dataDir);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\n `error: failed to open admin database at ${cfg.dataDir}: ${(err as Error).message}\\n` +\n `Pass --no-admin to disable persistence, or --data-dir <path> to choose a writable location.`\n );\n process.exit(2);\n }\n }\n\n printStartupBanner(cfg);\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => {\n try {\n closeDb();\n } catch {\n // ignore\n }\n process.exit(0);\n });\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain();\n"}, "7181e0f65cbae7e2be0d7cefc794e998170e7813": {"t": 1785749013.3575046, "n": 9190, "h": "9629d1674a6e66ec", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Xiaomi MiMo Chat Completions\n\nUSAGE\n mimo2codex [options]\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --base-url <url> MiMo base url (default: https://api.xiaomimimo.com/v1, env: MIMO_BASE_URL)\n --api-key <key> MiMo api key (env: MIMO_API_KEY) — required\n --no-reasoning hide MiMo reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n`;\n\n// Default snippet — uses ~/.codex/auth.json + requires_openai_auth = true.\n// This avoids the common \"Missing environment variable: MIMO2CODEX_KEY\" error\n// on the Codex desktop app, which doesn't inherit shell env vars set via\n// `export` or `setx`. Works for both CLI and desktop with no env setup.\nfunction configSnippet(cfg: { host: string; port: number }): string {\n return `# Step 1 — write ~/.codex/auth.json (Windows: %USERPROFILE%\\\\.codex\\\\auth.json)\n# Any non-empty value works; mimo2codex does not validate inbound credentials.\n{\n \"OPENAI_API_KEY\": \"mimo2codex-local\"\n}\n\n# Step 2 — append to ~/.codex/config.toml (Windows: %USERPROFILE%\\\\.codex\\\\config.toml)\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n\n# Step 3 — completely quit and restart Codex (the desktop app must be relaunched\n# for the new auth.json to be picked up). Then run \\`codex\\` and pick this provider.\n\n# ⚠️ If you also use Codex with your real OpenAI account, this auth.json overwrites\n# your OpenAI login. Use cc-switch (\\`mimo2codex print-cc-switch\\`) instead to switch\n# between providers cleanly, or use \\`mimo2codex print-config --env-key\\` for the\n# env-var-based variant (works for Codex CLI but not the desktop app).\n`;\n}\n\n// Legacy env_key variant — keeps ~/.codex/auth.json untouched (preserving any\n// existing OpenAI login). Requires MIMO2CODEX_KEY to be set in the environment\n// of the process running \\`codex\\`. Codex DESKTOP APP does not inherit shell env\n// vars on macOS/Windows, so this variant only works reliably for the CLI.\nfunction configSnippetEnvKey(cfg: { host: string; port: number }): string {\n return `# ~/.codex/config.toml — env-var variant (Codex CLI only; desktop app won't see shell env vars)\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nenv_key = \"MIMO2CODEX_KEY\"\nrequest_max_retries = 1\n\n# Then in your shell (the same shell you launch \\`codex\\` from):\n# export MIMO2CODEX_KEY=anything # macOS/Linux/Git Bash\n# $env:MIMO2CODEX_KEY=\"anything\" # Windows PowerShell\n# set MIMO2CODEX_KEY=anything # Windows CMD (current session only)\n#\n# For Codex DESKTOP APP, this variant does NOT work — desktop apps launched from\n# Finder/Start Menu don't inherit shell env vars. Use the default print-config\n# (auth.json variant) or \\`mimo2codex print-cc-switch\\` instead.\n`;\n}\n\n// cc-switch (https://github.com/farion1231/cc-switch) is a desktop app that\n// manages multiple Codex providers via a \"+\" → \"Custom\" panel. It writes\n// ~/.codex/auth.json + ~/.codex/config.toml when you switch providers.\n// This subcommand prints both snippets in a copy-pasteable form so users can\n// add mimo2codex as a custom Codex provider in cc-switch.\nfunction ccSwitchSnippet(cfg: { host: string; port: number }): string {\n const authJson = JSON.stringify({ OPENAI_API_KEY: \"mimo2codex-local\" }, null, 2);\n const configToml = `model_provider = \"mimo2codex\"\nmodel = \"mimo-v2.5-pro\"\n\n[model_providers.mimo2codex]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n`;\n return `# cc-switch — Add Provider → Codex tab → Custom\n\n# ───────── auth.json (paste into the auth.json textarea) ─────────\n${authJson}\n\n# ───────── config.toml (paste into the config.toml textarea) ─────────\n${configToml}\n# Note: OPENAI_API_KEY can be any non-empty string — mimo2codex does not\n# validate inbound credentials. Your real MiMo key stays in MIMO_API_KEY\n# on the machine running mimo2codex.\n`;\n}\n\nfunction printStartupBanner(cfg: Config): void {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n // eslint-disable-next-line no-console\n console.log(`upstream: ${cfg.baseUrl}`);\n // eslint-disable-next-line no-console\n console.log(`api key: ${redactKey(cfg.apiKey)}`);\n // eslint-disable-next-line no-console\n console.log(\n `plan: ${cfg.isTokenPlan ? \"token-plan (web_search auto-disabled — plugin not available)\" : \"pay-as-you-go\"}`\n );\n // eslint-disable-next-line no-console\n console.log(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(configSnippet({ host: cfg.host, port: cfg.port }));\n}\n\nfunction main(): void {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n // eslint-disable-next-line no-console\n console.log(useEnvKey ? configSnippetEnvKey({ host, port }) : configSnippet({ host, port }));\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n printStartupBanner(cfg);\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => process.exit(0));\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain();\n"}, "083b03a5ea671240a664c8e8f9226a795335c32e": {"t": 1785749014.6095877, "n": 9008, "h": "7cf3408aa3c58345", "c": "#!/usr/bin/env node\nimport { createRequire } from \"node:module\";\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\n\nconst VERSION = (createRequire(import.meta.url)(\"../package.json\") as { version: string }).version;\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Xiaomi MiMo Chat Completions\n\nUSAGE\n mimo2codex [options]\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --base-url <url> MiMo base url (default: https://api.xiaomimimo.com/v1, env: MIMO_BASE_URL)\n --api-key <key> MiMo api key (env: MIMO_API_KEY) — required\n --no-reasoning hide MiMo reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n`;\n\n// Default snippet — uses ~/.codex/auth.json + requires_openai_auth = true.\n// This avoids the common \"Missing environment variable: MIMO2CODEX_KEY\" error\n// on the Codex desktop app, which doesn't inherit shell env vars set via\n// `export` or `setx`. Works for both CLI and desktop with no env setup.\nfunction configSnippet(cfg: { host: string; port: number }): string {\n return `# Step 1 — write ~/.codex/auth.json (Windows: %USERPROFILE%\\\\.codex\\\\auth.json)\n# Any non-empty value works; mimo2codex does not validate inbound credentials.\n{\n \"OPENAI_API_KEY\": \"mimo2codex-local\"\n}\n\n# Step 2 — append to ~/.codex/config.toml (Windows: %USERPROFILE%\\\\.codex\\\\config.toml)\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n\n# Step 3 — completely quit and restart Codex (the desktop app must be relaunched\n# for the new auth.json to be picked up). Then run \\`codex\\` and pick this provider.\n\n# ⚠️ If you also use Codex with your real OpenAI account, this auth.json overwrites\n# your OpenAI login. Use cc-switch (\\`mimo2codex print-cc-switch\\`) instead to switch\n# between providers cleanly, or use \\`mimo2codex print-config --env-key\\` for the\n# env-var-based variant (works for Codex CLI but not the desktop app).\n`;\n}\n\n// Legacy env_key variant — keeps ~/.codex/auth.json untouched (preserving any\n// existing OpenAI login). Requires MIMO2CODEX_KEY to be set in the environment\n// of the process running \\`codex\\`. Codex DESKTOP APP does not inherit shell env\n// vars on macOS/Windows, so this variant only works reliably for the CLI.\nfunction configSnippetEnvKey(cfg: { host: string; port: number }): string {\n return `# ~/.codex/config.toml — env-var variant (Codex CLI only; desktop app won't see shell env vars)\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nenv_key = \"MIMO2CODEX_KEY\"\nrequest_max_retries = 1\n\n# Then in your shell (the same shell you launch \\`codex\\` from):\n# export MIMO2CODEX_KEY=anything # macOS/Linux/Git Bash\n# $env:MIMO2CODEX_KEY=\"anything\" # Windows PowerShell\n# set MIMO2CODEX_KEY=anything # Windows CMD (current session only)\n#\n# For Codex DESKTOP APP, this variant does NOT work — desktop apps launched from\n# Finder/Start Menu don't inherit shell env vars. Use the default print-config\n# (auth.json variant) or \\`mimo2codex print-cc-switch\\` instead.\n`;\n}\n\n// cc-switch (https://github.com/farion1231/cc-switch) is a desktop app that\n// manages multiple Codex providers via a \"+\" → \"Custom\" panel. It writes\n// ~/.codex/auth.json + ~/.codex/config.toml when you switch providers.\n// This subcommand prints both snippets in a copy-pasteable form so users can\n// add mimo2codex as a custom Codex provider in cc-switch.\nfunction ccSwitchSnippet(cfg: { host: string; port: number }): string {\n const authJson = JSON.stringify({ OPENAI_API_KEY: \"mimo2codex-local\" }, null, 2);\n const configToml = `model_provider = \"mimo2codex\"\nmodel = \"mimo-v2.5-pro\"\n\n[model_providers.mimo2codex]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n`;\n return `# cc-switch — Add Provider → Codex tab → Custom\n\n# ───────── auth.json (paste into the auth.json textarea) ─────────\n${authJson}\n\n# ───────── config.toml (paste into the config.toml textarea) ─────────\n${configToml}\n# Note: OPENAI_API_KEY can be any non-empty string — mimo2codex does not\n# validate inbound credentials. Your real MiMo key stays in MIMO_API_KEY\n# on the machine running mimo2codex.\n`;\n}\n\nfunction printStartupBanner(cfg: Config): void {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n // eslint-disable-next-line no-console\n console.log(`upstream: ${cfg.baseUrl}`);\n // eslint-disable-next-line no-console\n console.log(`api key: ${redactKey(cfg.apiKey)}`);\n // eslint-disable-next-line no-console\n console.log(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(configSnippet({ host: cfg.host, port: cfg.port }));\n}\n\nfunction main(): void {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n // eslint-disable-next-line no-console\n console.log(useEnvKey ? configSnippetEnvKey({ host, port }) : configSnippet({ host, port }));\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n printStartupBanner(cfg);\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => process.exit(0));\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain();\n"}, "5efa1d21960033b585927864a7c3d3c9242ca960": {"t": 1785749015.7147274, "n": 8888, "h": "98060bfec52c4e74", "c": "#!/usr/bin/env node\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\n\nconst VERSION = \"0.1.0\";\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Xiaomi MiMo Chat Completions\n\nUSAGE\n mimo2codex [options]\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --base-url <url> MiMo base url (default: https://api.xiaomimimo.com/v1, env: MIMO_BASE_URL)\n --api-key <key> MiMo api key (env: MIMO_API_KEY) — required\n --no-reasoning hide MiMo reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nDEFAULTS BAKED IN (no flag needed)\n ✓ MiMo thinking mode ON — model generates reasoning_content; use\n --no-reasoning to hide it from the Codex terminal (still preserved\n between turns for multi-turn tool quality)\n ✓ parallel_tool_calls forced on — model can batch tool calls per turn,\n helps avoid \"model says 'I'll do X' then ends\" pattern\n ✓ Codex web_search forwarded to MiMo's web_search builtin. If your account\n doesn't have the Web Search Plugin activated, MiMo returns 400\n \"webSearchEnabled is false\" — mimo2codex surfaces that error so you can\n activate the plugin (https://platform.xiaomimimo.com/#/console/plugin,\n separately billed) and restart, or accept that web search isn't available\n\nSUBCOMMANDS\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n`;\n\n// Default snippet — uses ~/.codex/auth.json + requires_openai_auth = true.\n// This avoids the common \"Missing environment variable: MIMO2CODEX_KEY\" error\n// on the Codex desktop app, which doesn't inherit shell env vars set via\n// `export` or `setx`. Works for both CLI and desktop with no env setup.\nfunction configSnippet(cfg: { host: string; port: number }): string {\n return `# Step 1 — write ~/.codex/auth.json (Windows: %USERPROFILE%\\\\.codex\\\\auth.json)\n# Any non-empty value works; mimo2codex does not validate inbound credentials.\n{\n \"OPENAI_API_KEY\": \"mimo2codex-local\"\n}\n\n# Step 2 — append to ~/.codex/config.toml (Windows: %USERPROFILE%\\\\.codex\\\\config.toml)\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n\n# Step 3 — completely quit and restart Codex (the desktop app must be relaunched\n# for the new auth.json to be picked up). Then run \\`codex\\` and pick this provider.\n\n# ⚠️ If you also use Codex with your real OpenAI account, this auth.json overwrites\n# your OpenAI login. Use cc-switch (\\`mimo2codex print-cc-switch\\`) instead to switch\n# between providers cleanly, or use \\`mimo2codex print-config --env-key\\` for the\n# env-var-based variant (works for Codex CLI but not the desktop app).\n`;\n}\n\n// Legacy env_key variant — keeps ~/.codex/auth.json untouched (preserving any\n// existing OpenAI login). Requires MIMO2CODEX_KEY to be set in the environment\n// of the process running \\`codex\\`. Codex DESKTOP APP does not inherit shell env\n// vars on macOS/Windows, so this variant only works reliably for the CLI.\nfunction configSnippetEnvKey(cfg: { host: string; port: number }): string {\n return `# ~/.codex/config.toml — env-var variant (Codex CLI only; desktop app won't see shell env vars)\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nenv_key = \"MIMO2CODEX_KEY\"\nrequest_max_retries = 1\n\n# Then in your shell (the same shell you launch \\`codex\\` from):\n# export MIMO2CODEX_KEY=anything # macOS/Linux/Git Bash\n# $env:MIMO2CODEX_KEY=\"anything\" # Windows PowerShell\n# set MIMO2CODEX_KEY=anything # Windows CMD (current session only)\n#\n# For Codex DESKTOP APP, this variant does NOT work — desktop apps launched from\n# Finder/Start Menu don't inherit shell env vars. Use the default print-config\n# (auth.json variant) or \\`mimo2codex print-cc-switch\\` instead.\n`;\n}\n\n// cc-switch (https://github.com/farion1231/cc-switch) is a desktop app that\n// manages multiple Codex providers via a \"+\" → \"Custom\" panel. It writes\n// ~/.codex/auth.json + ~/.codex/config.toml when you switch providers.\n// This subcommand prints both snippets in a copy-pasteable form so users can\n// add mimo2codex as a custom Codex provider in cc-switch.\nfunction ccSwitchSnippet(cfg: { host: string; port: number }): string {\n const authJson = JSON.stringify({ OPENAI_API_KEY: \"mimo2codex-local\" }, null, 2);\n const configToml = `model_provider = \"mimo2codex\"\nmodel = \"mimo-v2.5-pro\"\n\n[model_providers.mimo2codex]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n`;\n return `# cc-switch — Add Provider → Codex tab → Custom\n\n# ───────── auth.json (paste into the auth.json textarea) ─────────\n${authJson}\n\n# ───────── config.toml (paste into the config.toml textarea) ─────────\n${configToml}\n# Note: OPENAI_API_KEY can be any non-empty string — mimo2codex does not\n# validate inbound credentials. Your real MiMo key stays in MIMO_API_KEY\n# on the machine running mimo2codex.\n`;\n}\n\nfunction printStartupBanner(cfg: Config): void {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n // eslint-disable-next-line no-console\n console.log(`upstream: ${cfg.baseUrl}`);\n // eslint-disable-next-line no-console\n console.log(`api key: ${redactKey(cfg.apiKey)}`);\n // eslint-disable-next-line no-console\n console.log(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(configSnippet({ host: cfg.host, port: cfg.port }));\n}\n\nfunction main(): void {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n // eslint-disable-next-line no-console\n console.log(useEnvKey ? configSnippetEnvKey({ host, port }) : configSnippet({ host, port }));\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n printStartupBanner(cfg);\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => process.exit(0));\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain();\n"}, "1eb2bb4f3afac54f98c4b7574a650f1dcaf79ee0": {"t": 1785749017.195508, "n": 8122, "h": "7b248fca1ca92e4d", "c": "#!/usr/bin/env node\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\n\nconst VERSION = \"0.1.0\";\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Xiaomi MiMo Chat Completions\n\nUSAGE\n mimo2codex [options]\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --base-url <url> MiMo base url (default: https://api.xiaomimimo.com/v1, env: MIMO_BASE_URL)\n --api-key <key> MiMo api key (env: MIMO_API_KEY) — required\n --no-reasoning hide MiMo reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nSUBCOMMANDS\n print-config print ~/.codex/auth.json + config.toml snippets (default;\n works for Codex CLI and desktop app)\n print-config --env-key print env-var-based variant (Codex CLI only — desktop app\n will NOT see shell env vars set via export/setx)\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-config --env-key # legacy env-var variant\n mimo2codex print-cc-switch\n`;\n\n// Default snippet — uses ~/.codex/auth.json + requires_openai_auth = true.\n// This avoids the common \"Missing environment variable: MIMO2CODEX_KEY\" error\n// on the Codex desktop app, which doesn't inherit shell env vars set via\n// `export` or `setx`. Works for both CLI and desktop with no env setup.\nfunction configSnippet(cfg: { host: string; port: number }): string {\n return `# Step 1 — write ~/.codex/auth.json (Windows: %USERPROFILE%\\\\.codex\\\\auth.json)\n# Any non-empty value works; mimo2codex does not validate inbound credentials.\n{\n \"OPENAI_API_KEY\": \"mimo2codex-local\"\n}\n\n# Step 2 — append to ~/.codex/config.toml (Windows: %USERPROFILE%\\\\.codex\\\\config.toml)\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n\n# Step 3 — completely quit and restart Codex (the desktop app must be relaunched\n# for the new auth.json to be picked up). Then run \\`codex\\` and pick this provider.\n\n# ⚠️ If you also use Codex with your real OpenAI account, this auth.json overwrites\n# your OpenAI login. Use cc-switch (\\`mimo2codex print-cc-switch\\`) instead to switch\n# between providers cleanly, or use \\`mimo2codex print-config --env-key\\` for the\n# env-var-based variant (works for Codex CLI but not the desktop app).\n`;\n}\n\n// Legacy env_key variant — keeps ~/.codex/auth.json untouched (preserving any\n// existing OpenAI login). Requires MIMO2CODEX_KEY to be set in the environment\n// of the process running \\`codex\\`. Codex DESKTOP APP does not inherit shell env\n// vars on macOS/Windows, so this variant only works reliably for the CLI.\nfunction configSnippetEnvKey(cfg: { host: string; port: number }): string {\n return `# ~/.codex/config.toml — env-var variant (Codex CLI only; desktop app won't see shell env vars)\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nenv_key = \"MIMO2CODEX_KEY\"\nrequest_max_retries = 1\n\n# Then in your shell (the same shell you launch \\`codex\\` from):\n# export MIMO2CODEX_KEY=anything # macOS/Linux/Git Bash\n# $env:MIMO2CODEX_KEY=\"anything\" # Windows PowerShell\n# set MIMO2CODEX_KEY=anything # Windows CMD (current session only)\n#\n# For Codex DESKTOP APP, this variant does NOT work — desktop apps launched from\n# Finder/Start Menu don't inherit shell env vars. Use the default print-config\n# (auth.json variant) or \\`mimo2codex print-cc-switch\\` instead.\n`;\n}\n\n// cc-switch (https://github.com/farion1231/cc-switch) is a desktop app that\n// manages multiple Codex providers via a \"+\" → \"Custom\" panel. It writes\n// ~/.codex/auth.json + ~/.codex/config.toml when you switch providers.\n// This subcommand prints both snippets in a copy-pasteable form so users can\n// add mimo2codex as a custom Codex provider in cc-switch.\nfunction ccSwitchSnippet(cfg: { host: string; port: number }): string {\n const authJson = JSON.stringify({ OPENAI_API_KEY: \"mimo2codex-local\" }, null, 2);\n const configToml = `model_provider = \"mimo2codex\"\nmodel = \"mimo-v2.5-pro\"\n\n[model_providers.mimo2codex]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n`;\n return `# cc-switch — Add Provider → Codex tab → Custom\n\n# ───────── auth.json (paste into the auth.json textarea) ─────────\n${authJson}\n\n# ───────── config.toml (paste into the config.toml textarea) ─────────\n${configToml}\n# Note: OPENAI_API_KEY can be any non-empty string — mimo2codex does not\n# validate inbound credentials. Your real MiMo key stays in MIMO_API_KEY\n# on the machine running mimo2codex.\n`;\n}\n\nfunction printStartupBanner(cfg: Config): void {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n // eslint-disable-next-line no-console\n console.log(`upstream: ${cfg.baseUrl}`);\n // eslint-disable-next-line no-console\n console.log(`api key: ${redactKey(cfg.apiKey)}`);\n // eslint-disable-next-line no-console\n console.log(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(configSnippet({ host: cfg.host, port: cfg.port }));\n}\n\nfunction main(): void {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n const useEnvKey = parsed.envKey === true;\n // eslint-disable-next-line no-console\n console.log(useEnvKey ? configSnippetEnvKey({ host, port }) : configSnippet({ host, port }));\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n printStartupBanner(cfg);\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => process.exit(0));\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain();\n"}, "53a45fc3273ed8eabb2011c00c1b74f5ed94c4de": {"t": 1785749018.686374, "n": 5645, "h": "b411e1df9cc628c2", "c": "#!/usr/bin/env node\nimport { buildConfig, parseArgv, type Config } from \"./config.js\";\nimport { startServer } from \"./server.js\";\nimport { setVerbose, log, redactKey } from \"./util/log.js\";\n\nconst VERSION = \"0.1.0\";\n\nconst HELP = `mimo2codex v${VERSION} — local proxy: Codex Responses API → Xiaomi MiMo Chat Completions\n\nUSAGE\n mimo2codex [options]\n mimo2codex print-config\n mimo2codex print-cc-switch\n\nOPTIONS\n -p, --port <n> listen port (default: 8788, env: MIMO2CODEX_PORT)\n --host <h> bind host (default: 127.0.0.1, env: MIMO2CODEX_HOST)\n --base-url <url> MiMo base url (default: https://api.xiaomimimo.com/v1, env: MIMO_BASE_URL)\n --api-key <key> MiMo api key (env: MIMO_API_KEY) — required\n --no-reasoning hide MiMo reasoning_content from Codex (still re-injected for multi-turn quality)\n --reasoning force reasoning passthrough (default)\n -v, --verbose log every request (env: MIMO2CODEX_VERBOSE=1)\n -V, --version print version\n -h, --help show this help\n\nSUBCOMMANDS\n print-config print the ~/.codex/config.toml snippet to stdout\n print-cc-switch print auth.json + config.toml snippets for the cc-switch\n desktop app (https://github.com/farion1231/cc-switch)\n\nEXAMPLES\n MIMO_API_KEY=sk-... mimo2codex\n mimo2codex --port 9000 --base-url https://token-plan-cn.xiaomimimo.com/v1\n mimo2codex print-config > codex-mimo.toml\n mimo2codex print-cc-switch\n`;\n\nfunction configSnippet(cfg: { host: string; port: number }): string {\n return `# ~/.codex/config.toml — drop these lines in (or merge with existing config)\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nenv_key = \"MIMO2CODEX_KEY\"\nrequest_max_retries = 1\n\n# In your shell, export any non-empty value (the proxy doesn't validate it):\n# export MIMO2CODEX_KEY=anything\n# On Windows (CMD): setx MIMO2CODEX_KEY anything\n`;\n}\n\n// cc-switch (https://github.com/farion1231/cc-switch) is a desktop app that\n// manages multiple Codex providers via a \"+\" → \"Custom\" panel. It writes\n// ~/.codex/auth.json + ~/.codex/config.toml when you switch providers.\n// This subcommand prints both snippets in a copy-pasteable form so users can\n// add mimo2codex as a custom Codex provider in cc-switch.\nfunction ccSwitchSnippet(cfg: { host: string; port: number }): string {\n const authJson = JSON.stringify({ OPENAI_API_KEY: \"mimo2codex-local\" }, null, 2);\n const configToml = `model_provider = \"mimo2codex\"\nmodel = \"mimo-v2.5-pro\"\n\n[model_providers.mimo2codex]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://${cfg.host}:${cfg.port}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n`;\n return `# cc-switch — Add Provider → Codex tab → Custom\n\n# ───────── auth.json (paste into the auth.json textarea) ─────────\n${authJson}\n\n# ───────── config.toml (paste into the config.toml textarea) ─────────\n${configToml}\n# Note: OPENAI_API_KEY can be any non-empty string — mimo2codex does not\n# validate inbound credentials. Your real MiMo key stays in MIMO_API_KEY\n# on the machine running mimo2codex.\n`;\n}\n\nfunction printStartupBanner(cfg: Config): void {\n // eslint-disable-next-line no-console\n console.log(`mimo2codex v${VERSION} listening on http://${cfg.host}:${cfg.port}`);\n // eslint-disable-next-line no-console\n console.log(`upstream: ${cfg.baseUrl}`);\n // eslint-disable-next-line no-console\n console.log(`api key: ${redactKey(cfg.apiKey)}`);\n // eslint-disable-next-line no-console\n console.log(`reasoning: ${cfg.exposeReasoning ? \"passthrough\" : \"hidden\"}`);\n // eslint-disable-next-line no-console\n console.log(\"\");\n // eslint-disable-next-line no-console\n console.log(configSnippet({ host: cfg.host, port: cfg.port }));\n}\n\nfunction main(): void {\n let parsed;\n try {\n parsed = parseArgv(process.argv.slice(2));\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n if (parsed.showHelp) {\n // eslint-disable-next-line no-console\n console.log(HELP);\n return;\n }\n if (parsed.showVersion) {\n // eslint-disable-next-line no-console\n console.log(VERSION);\n return;\n }\n\n if (parsed.positional[0] === \"print-config\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n // eslint-disable-next-line no-console\n console.log(configSnippet({ host, port }));\n return;\n }\n\n if (parsed.positional[0] === \"print-cc-switch\") {\n const host = parsed.host ?? \"127.0.0.1\";\n const port = parsed.port ?? 8788;\n // eslint-disable-next-line no-console\n console.log(ccSwitchSnippet({ host, port }));\n return;\n }\n\n let cfg: Config;\n try {\n cfg = buildConfig(parsed, process.env, VERSION);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(`error: ${(err as Error).message}`);\n process.exit(2);\n }\n\n setVerbose(cfg.verbose);\n printStartupBanner(cfg);\n\n const server = startServer(cfg);\n server.on(\"listening\", () => {\n log.debug(\"server listening\");\n });\n server.on(\"error\", (err) => {\n log.error(\"server error\", { error: err.message });\n process.exit(1);\n });\n\n const shutdown = (sig: string) => {\n log.info(`received ${sig}, shutting down`);\n server.close(() => process.exit(0));\n setTimeout(() => process.exit(1), 5000).unref();\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n}\n\nmain();\n"}}, "doc/tag-log.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595136.9389882, "n": 57641, "h": "9699bc5ed9509856", "c": "# Tag Log\n\n<p>\n <a href=\"./tag-log.md\"><strong>English</strong></a> ·\n <a href=\"./tag-log.zh.md\">简体中文</a>\n</p>\n\nRelease history of mimo2codex, newest first.\n\n**Category tags**\n\n- **[new]** / **[feat]**: new features\n- **[fix]**: bug fixes\n- **[opt]** / **[refactor]**: optimization / refactor\n- **[doc]**: documentation\n- **[test]**: tests\n\n---\n\n## v0.5.29 (upcoming)\n\n- **[opt]** **MiMo v2 models retired → transparently aliased to v2.5 (no config edits needed)** — MiMo took its v2 generation offline on 2026-06-30 (`mimo-v2-pro`, `mimo-v2-omni`, `mimo-v2-flash`); requests using the old names now 400 upstream. mimo2codex now **aliases the retired names to their official replacements** so an existing `config.toml` keeps working and still hits the right model: `mimo-v2-pro` → `mimo-v2.5-pro`, `mimo-v2-omni` / `mimo-v2-flash` → `mimo-v2.5`. Implemented via `ProviderModel.aliases` + an alias-aware `resolveModel` (mirrors DeepSeek); the alias is a *clean resolve* — the upstream gets the live id and there's no fallback rewrite/notice. The three retired entries are removed from the builtin catalog, so the model list, generated config.toml snippets, and the admin **Models** page now advertise **only** the live v2.5 models (`mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`); their stale DB rows are auto-pruned by `seedBuiltins` on next boot. **Behavior change for old `mimo-v2-flash` users:** its replacement `mimo-v2.5` defaults thinking **ON** (flash defaulted OFF) and **supports vision**. The mimoskill helpers (`ocr.py` / `mimo_chat.py`, which call MiMo directly with no alias layer) now coerce retired names to their v2.5 replacement too.\n\n- **[fix]** **Thinking-mode sampling: `top_p` is now stripped as well (was `temperature` only)** — per MiMo's docs the v2.5 family ignores custom `temperature` **and** `top_p` in thinking mode (upstream forces `temperature:1.0` / `top_p:0.95`). `normalizeMimoBody` previously dropped only `temperature`; it now drops both for the v2.5 reasoning models (`mimo-v2.5-pro`, `mimo-v2.5`, `mimo-v2.5-pro-ultraspeed`), so the request matches the eventual upstream behavior. Normalization is also now keyed off the **upstream** model id (post-alias) at both the Responses and Chat call sites — previously the Chat path keyed off the client literal (`out.model`), which would mis-apply the rules to an aliased legacy name.\n\n---\n\n## v0.5.28 (upcoming)\n\n- **[opt]** **Admin dashboard no longer crawls (or stalls the proxy) on a large `data.db`** (issue #76): a heavy user's `data.db` grew to 22 GB and the **Overview / Logs** pages took 10+ minutes to load. Root cause: every dashboard load ran 5-6 **live aggregations** (`aggregateStats` / `aggregateTokensTimeseries` with `strftime` + 3-way `GROUP BY` / `aggregateLatency` pulling every `duration_ms` into JS / `aggregateProviderHealth` / `aggregateMappings` with no time filter) directly over the multi-GB `chat_logs` table — and since better-sqlite3 is **synchronous**, one slow scan blocked the entire HTTP loop, stalling even `/v1/responses`. Fix: a new **hourly rollup table `chat_stats_hourly`** keyed by `(hour, provider, client_model, upstream_model)` is incremented inside each `insertLog`'s transaction (\"write the log → bump the stat\"); all dashboard aggregates now read that tiny table (rows = hours × model-tuples, independent of how big `chat_logs` grows). Latency percentiles come from an 8-bucket histogram (exact average from sum/count); the error-code breakdown still reads `chat_logs` but via a new partial index `WHERE status_code >= 400`. Pre-existing rows are folded in by a **background, newest-first backfill** (chunked + non-blocking — recent ranges become accurate within minutes). Plus anti-avalanche tweaks: the Overview's auto-refresh drops from 5s → 20s/60s with an in-flight guard, and the Logs page no longer re-queries the DB size on every page turn. New file `src/db/stats.ts`; schema migration v6. **Disk note:** this makes the dashboard fast but doesn't shrink an already-huge file — set a **retention (days)** and/or switch body capture to **errors-only** in the Logs page's 存储设置, then VACUUM (needs free space ≈ db size).\n\n- **[fix]** **MiMo web search is now OFF by default — no more \"webSearchEnabled is false\" 400 loops on accounts without the plugin** — when a request reached MiMo carrying a `web_search` tool but the account hadn't activated the (separately-billed) Web Search Plugin, the upstream 400'd `web search tool found in the request body, but webSearchEnabled is false`; since 400 isn't retryable the error bubbled to Codex, which kept resending the same request per its `request_max_retries` → an endless fail loop. Root cause: mimo2codex forwarded `web_search` to any non-token-plan (sk-) account ([mimo.ts](src/providers/mimo.ts) `enableWebSearch: !isTokenPlan`), wrongly assuming pay-as-you-go accounts have the plugin — they don't, it's opt-in/billed for everyone. **web_search forwarding is now opt-in and OFF by default** for all accounts: mimo2codex strips `web_search` before forwarding unless you explicitly enable it. Enable it (only if your account has the plugin activated) via the admin **Codex Enable → Thinking & Override → Web search** toggle, or `--web-search` / `MIMO2CODEX_WEB_SEARCH=1`. Token-plan (tp-) accounts never get web search regardless. New setting `mimo.webSearchEnabled`, new `GET/PUT /admin/api/web-search-state`, threaded through `PreprocessCtx.webSearchEnabled` (mirrors the `thinking.disabled` toggle end-to-end).\n\n---\n\n## v0.5.27 (upcoming)\n\n- **[new]** **\"Keep ChatGPT login\" apply mode + a Codex-mobile guide** — stay signed into your real ChatGPT account *and* run the model through mimo2codex at the same time (the prerequisite for OpenAI's official \"Codex mobile / remote\", where your phone drives this computer's Codex). Until now, enabling a mimo2codex provider **overwrote** `~/.codex/auth.json` with a placeholder key — logging you out of \"Sign in with ChatGPT\" — so the official login and the proxy were mutually exclusive. The new path (default when a real login is detected) **leaves auth.json byte-for-byte untouched** and only rewrites `config.toml` (`model_provider` → proxy, `requires_openai_auth = true`); Codex then attaches your real ChatGPT OAuth token as the bearer to the local proxy, which ignores it and forwards upstream. The Codex-Enable confirm dialog explains this and a checkbox still lets you opt into the old overwrite; a config-only **\"Keep ChatGPT login\"** snippet tab and a collapsible **\"Control this Codex from your phone\"** guide card were added, plus `doc/codex-mobile-remote.{md,zh.md}`. Built as isolated new files (`src/codex/preserveLogin.ts`, `src/setup/preserveLoginSnippet.ts`, `web/src/pages/codex/MobileRemoteCard.tsx`) so the existing apply path is untouched. **Caveat:** whether OpenAI's *remote* mode actually runs the model through your local proxy (vs. forcing its own models) is undocumented and needs real-world testing — the guide says so.\n\n- **[opt]** **The client's requested model now takes precedence over a runtime override** — previously a webui runtime override (Pass 0 in `selectProvider`) won *unconditionally*, hijacking even a model Codex explicitly configured (e.g. Codex sends `mimo-v2.5-pro` but the override forced `deepseek`). Routing priority is now: **(1)** the client model, when a registered+keyed provider recognizes it; **(2)** the runtime override, when the model id isn't in any provider catalog; **(3)** the default provider as a bare fallback. So an explicitly-configured Codex model is honored, while the override remains the smart fallback for *unrecognized* model ids (instead of silently rewriting to the default). Implementation: `selectProvider` in `src/server.ts` moves the override block from Pass 0 to after the catalog passes; the admin \"Runtime override only\" copy was updated to match. No change when no override is set.\n\n- **[fix]** **Desktop Mac Apple-Silicon (arm64) `/admin/` silent 404** (0.5.26 regression; mac-intel / win audited too): visiting `/admin/` returned `{\"error\":{\"type\":\"invalid_request_error\",\"code\":\"not_found\",\"message\":\"no route for GET /admin/\",\"status\":404}}`, while 0.5.24 worked. Root-cause chain confirmed line-by-line: at startup `openDb()` fails to load the better-sqlite3 native module (wrong arch / wrong ABI / unsigned-and-AMFI-rejected on Apple Silicon), `cli.ts` gracefully degrades to `adminEnabled=false` (issue #30), and `/admin/` then falls through to the generic 404 — **the failure was swallowed silently and the message was misleading**. Three-layer fix: (1) **runtime: silent 404 → clear diagnostic** — when admin is force-disabled by a DB load failure, `/admin/` now returns **503 `admin_db_unavailable`** carrying the real better-sqlite3 error + remediation (reinstall / arch match / macOS `xattr -cr`), and keeps `/admin/api/health` answering `200 {adminEnabled:false, reason:\"db_unavailable\", message}` for CI / the desktop shell to probe; an intentional `--no-admin` / `MIMO2CODEX_NO_ADMIN` still gets the historic 404. (2) **macOS ad-hoc re-sign**: a new `afterPack` hook (`scripts/after-pack-sign.cjs`, mac-only, non-fatal) ad-hoc-signs the `.node` files under `Contents/Resources/sidecar` and the app bundle — the extraResources `better_sqlite3.node` can stay unsigned when there's no signing identity, and Apple Silicon's AMFI rejects it at dlopen (Intel / Windows don't enforce this, matching \"M-chip broken, intel/win fine\"). (3) **post-package gate**: a new `scripts/postpack-healthcheck.mjs` launches the packaged sidecar via the bundled Electron and asserts `/admin/api/health → adminEnabled:true`, catching the wrong-arch / wrong-ABI / unsigned-.node class before release (wired into `pack-desktop-local` step 7 and CI `build-desktop.yml`; runs on native targets, self-skips cross targets).\n\n- **[fix]** **Desktop: a blank Base URL left the upstream host empty** (startup banner `upstream:` blank, requests hostless): the desktop Settings writes `MIMO_BASE_URL=` (empty) when the Base URL field is left blank — this is **intentional** (clearing a field clears its line, which is how you disable a provider by clearing its API key). But config resolution used `??`, and an empty string isn't nullish, so it **\"won\" the fallback chain** — the key-prefix host inference (`tp-` → `token-plan-cn.xiaomimimo.com` / `sk-` → `api.xiaomimimo.com`) never ran and `upstream` became `\"\"`. Empty / whitespace-only base URLs (from env or CLI) are now treated as \"unset\", restoring the fallback (key-based inference → default host). Existing `.env` files with a stray empty `MIMO_BASE_URL` are **auto-fixed on upgrade — no re-save needed**.\n\n- **[opt]** **Stricter build-time verification, no more silent gaps**: `build-sidecar.mjs`'s cross-build (arm64 runner → x64 package) used to **silently skip** the ABI smoke test; it now emits a **loud warning** noting ABI/signing must be validated by the post-package gate on a native machine. The native smoke test no longer just `require`s — it opens `new Database(':memory:')` and prints `process.versions.modules` (ABI) + `process.arch` to aid future electron-drift debugging. New `scripts/verify-release-native.mjs` inspects any desktop artifact (.app / win-unpacked / sidecar dir / `.node`): parses the Mach-O/PE/ELF header to report CPU arch, compares it against the `SIDECAR_INFO.json` build target, and prints the next-step commands to verify ABI + signing on the target machine.\n\n- **[opt]** **Desktop build arch cleanup: Windows ships x64 only, macOS ships Intel + Apple Silicon, and no arch is built twice** (answering \"check the Windows platforms too\"): (1) **win-arm64 is dropped entirely** — better-sqlite3 v12.x has no reliable win32-arm64 prebuild, so a win-arm64 package bundles a **wrong-arch** native module and 404s on `/admin/` anyway (a stale local win-arm64 artifact was found to contain an *x64* `better_sqlite3.node`). It's now blocked at the config level in both `electron-builder.yml` (`win.target` has no arm64) and `pack-desktop-local.mjs` (`VALID_TARGETS` drops `win-arm64`); Windows-on-ARM uses the x64 build under emulation. (2) **The mac/win targets no longer pin `arch: [x64, arm64]`** — arch is driven by the `--x64`/`--arm64` CLI flag (passed by both CI and the local packer). The pinned list made every mac job build BOTH arches (the CLI flag didn't override an in-target arch list), producing a redundant cross-built x64 in the arm64 job and duplicate release zips. macOS still ships **two separate installers (Intel x64 + Apple Silicon arm64)**, but each job now builds only its own arch.\n\n---\n\n## v0.5.26 (upcoming)\n\n- **[new]** **Built-in support for `MiMo-V2.5-Pro-UltraSpeed`** (issue #70): Xiaomi's new 1T-param flagship (500-1000 tok/s \"experience\" mode) is now a recognized built-in model. Before this, a request with `mimo-v2.5-pro-ultraspeed` was **silently rewritten to `mimo-v2.5-pro`** (so you actually ran Pro, not UltraSpeed) and the model couldn't be picked from the admin UI. Now it routes verbatim, appears in the model catalog / Codex Enable page, and `print-config` lists it. Declared text-only with reasoning + tool calling, 1M context, 131072 max output. Web search is **off** per the official spec — if you've enabled Codex web search on a pay-as-you-go account, UltraSpeed may 400 on the forwarded `web_search` tool; disable it for this model. **Access:** UltraSpeed is application-only (limited daily approval — apply at https://platform.xiaomimimo.com/ultraspeed) and is served **only on the pay-as-you-go API host (`sk-` keys)**; token-plan / subscription (`tp-`) accounts can't use it. The admin Codex-Enable page tags it **Restricted** (hover shows the note), and selecting UltraSpeed with a `tp-` key is rejected up front with a clear message (`model_requires_payg`) instead of a confusing upstream model-not-found.\n\n---\n\n## v0.5.25\n\n- **[new]** **Database housekeeping — one-click cleanup + VACUUM, size visibility, auto-maintenance** (issue #67): `data.db` could balloon (a user hit 6 GB) because the default keeps **full** request/response bodies forever and deletes never reclaim disk (SQLite leaves freed pages in the file until `VACUUM`). This adds: (1) **\"Clear old logs\" / \"Clear all logs\"** on the Logs page that **auto-run VACUUM right after deleting** (so the file actually shrinks, not just frees internal pages), plus a **live database-size readout** on the Logs page; (2) **size-cap auto-maintenance** — set `logging.maxDbSizeMb` and the 6-hour maintenance pass trims the oldest logs and runs a throttled VACUUM (≤ once/day); (3) **db-friendlier defaults for fresh installs only** — 30-day retention + `errors-only` body capture, while existing installs are migrated to explicit `off`/`full` so an upgrade never deletes anyone's logs or changes capture without consent. New endpoints: `GET /admin/api/db/size`, `POST /admin/api/db/vacuum` (with a free-disk pre-check), and `DELETE /admin/api/logs?all=1|keepDays=<n>`.\n\n- **[fix]** **Desktop app crashed on Intel (x86_64) macOS — admin UI 404** (issue #69): the macOS **x64** package shipped an **arm64** `better-sqlite3` native module, so on Intel Macs the sidecar failed to load it (`incompatible architecture`), the admin DB was unavailable, the admin routes were never registered, and every `/admin/` request 404'd. Root cause: the sidecar build passed `npm_config_target_arch`/`_platform`, but **prebuild-install** (better-sqlite3's binary fetcher) only honors `npm_config_arch`/`npm_config_platform` — so the cross-arch build (arm64 CI runner → x64 package) silently fell back to the runner's arch and fetched the wrong prebuild. Only macOS x64 was affected (Windows x64 / macOS arm64 are same-arch builds where the fallback happened to be correct). Two fixes: (1) `build-sidecar.mjs` now sets `npm_config_arch`/`npm_config_platform` (keeping `target_*` as a node-gyp source-build fallback); (2) a new **static arch check** (`scripts/detectNativeArch.mjs`, parses the Mach-O/PE/ELF header) runs on **every** build — including cross-arch, where the executable smoke test was skipped — so a wrong-arch module now fails CI instead of shipping to users.\n\n- **[fix]** **`install.sh` / `install.ps1` cloned a non-existent repo** (issue #66 follow-up): the bootstrap scripts' default `MIMO2CODEX_REPO` was still the template placeholder `your-org/mimo2codex`, so `curl … | bash` / `irm … | iex` failed right at the clone step. Now points at the real repo. These git-clone bootstrap scripts are *not* the documented install path (that's `npm install -g mimo2codex` or Docker), so this is unlikely to be the root of #66 — but it's a real bug regardless.\n\n---\n\n## v0.5.24\n\n- **[new]** **Automatic context compaction** (issue #65 follow-up): long Codex sessions resend their whole history every turn, and once it nears the model's context cap the upstream either 400s or prefills so slowly the stream drops. mimo2codex now estimates the input size and, when it crosses a token trigger that **scales with the model's context window** (`contextWindow × threshold`, **threshold default 0.8** — e.g. ~800k for a 1M-window model, ~205k for a 256k one), **summarizes the older middle** of the conversation into one compact note via the same model, keeping the leading system messages and the most recent turns verbatim. The split always lands on a clean `user` boundary so tool_call/tool_result pairs are never orphaned, image base64 is never fed to the summarizer, and a stable prefix is cached so it isn't re-summarized every turn. Best-effort: if the summary call fails the original history is left intact. Default **on**; toggle/tune via `MIMO2CODEX_AUTO_COMPACT` (0=off), `MIMO2CODEX_AUTO_COMPACT_THRESHOLD`, or an absolute `MIMO2CODEX_AUTO_COMPACT_AT_TOKENS` (for upstreams whose advertised window overstates their real cap) — also exposed as admin settings `codex.autoCompactEnabled` / `codex.autoCompactThreshold` / `codex.autoCompactAtTokens`. Runs while the keepalive is active so the summary round-trip doesn't reintroduce a silent socket.\n\n- **[fix]** **Request-body cap is configurable and no longer disconnects on oversized image uploads** (issue #65): the body limit was a hard-coded 16MB and overflow `destroy()`d the socket mid-upload — which Codex saw as \"error sending request for url\" rather than a clean error. The cap is now **64MB default and configurable** (`MIMO2CODEX_MAX_REQUEST_BODY_MB`), and overflow drains the rest of the body before returning a proper **413** the client actually receives.\n\n- **[fix]** **\"stream disconnected before completion\" on large contexts / image uploads** (issue #65): the proxy used to `await` the upstream's first byte before sending Codex anything, and Node's `fetch` (undici) caps the wait at a 300s default. A long prefill (big conversation, or a base64 image inflating the request) could blow past that window while Codex stared at a silent socket and tripped *its own* idle timeout. Three coordinated fixes: (1) a **global undici dispatcher** now applies a configurable upstream timeout (default **10 min**, `0` = off) whether or not a proxy is set — `MIMO2CODEX_UPSTREAM_HEADERS_TIMEOUT_MS` / `MIMO2CODEX_UPSTREAM_BODY_TIMEOUT_MS`; (2) header/body **timeouts no longer trigger the retry storm** — they fail fast with a clear 504 instead of re-sending a multi-MB body up to 6×; (3) both streaming paths now **flush SSE headers + start the keepalive *before* awaiting the upstream**, so Codex keeps receiving `: keepalive` comments during a long prefill. Trade-off: once the 200 SSE stream is committed, a terminal upstream error (e.g. context-overflow 400) is delivered as an SSE `error` event rather than a JSON 4xx. The startup banner now shows the active timeouts, and image-bearing streaming requests are logged with their approximate size.\n\n---\n\n## v0.5.23\n\n- **[new]** **Windows: isolated Codex CLI launcher** (PR #64, thanks @Kaiyuan GONG): a new `scripts/codex-mimo-isolated.ps1` lets you run **Codex CLI** against MiMo via mimo2codex **without touching the `~/.codex` used by Codex Desktop**. It uses a separate `CODEX_HOME=%USERPROFILE%\\.codex-mimo`, writes a minimal `auth.json` + `config.toml` there on first run, auto-starts the proxy if `:8788` isn't already listening, prints the local API/admin URLs, then forwards all remaining args to `codex`. API keys are not hardcoded — configure them via `mimo2codex init`. See `doc/codex-cli-isolated-windows.zh.md` for the walkthrough.\n\n- **[fix]** **Saving a generic provider with a duplicate shortcut no longer bricks the admin UI (`/admin/` 404)** (issue #63): `providers.shortcut` is `UNIQUE`, but the save path only de-duped provider `id`, not `shortcut`. A generic whose shortcut collided with a built-in (`mimo` / `ds`) or with another generic would save fine, then crash the **next** startup's DB seed (`UNIQUE constraint failed: providers.shortcut`), which the cli.ts fallback then turned into a disabled admin — so every `/admin/` request 404'd. Two-layer fix: (1) `writeSpecsToFile` now rejects a colliding shortcut **at save time** with a clear message (seeded with the built-in shortcuts); (2) DB seeding de-dupes by shortcut (`dedupeProvidersByShortcut`) — a duplicate is **skipped with a warning** instead of crashing the whole seed, so anyone who already saved a dirty `providers.json` gets their admin back on the next start.\n\n- **[fix]** **`enhanceErrorPreset: \"kimi\"` is no longer silently dropped from generic providers**: `kimi` is a valid `ProviderPresetId` (`src/providers/presets.ts`) but the providers.json parser only accepted `sensenova` / `minimax`, so a Kimi error-diagnostic preset never persisted. It's now accepted alongside the others.\n\n---\n\n## v0.5.22\n\n- **[new]** **Multimodal fallback — auto-switch to a vision model when a request carries images** (PR #58, thanks @Grub): when a request contains images but the active model can't see them (e.g. `mimo-v2.5-pro`), the proxy rewrites the upstream model to a vision-capable one (default `mimo-v2.5`) so the image is processed instead of silently dropped — applied on both the Responses and Chat paths. **Scoped to MiMo — other providers are never affected**: vision capability is a MiMo provider feature (`provider.supportsVision`), so only MiMo triggers the fallback; DeepSeek / generic requests are left untouched. Even on MiMo, the switch is skipped when the fallback model can't be resolved. Toggle + target model live in the admin UI → Codex Integration → \"Multimodal fallback\" card; **disabled by default** — enable it when your workflow mixes vision and non-vision models.\n\n---\n\n## v0.5.21\n\n- **[fix]** **Sustained 429 rate limits no longer break the session (follow-up to v0.5.20's retry)**: v0.5.20 added proxy-side 429/5xx retry, but the default budget (3 retries, ~3.5s) only outlasted sub-second blips. Real per-minute quota limits (`429 Too many requests / limitation`, often *without* a `Retry-After` header) still exhausted it, so the raw 429 was forwarded to Codex, which then burned its own retries and surfaced \"exceeded retry limit, last status: 429\" again. The default retry budget is now larger: **6 retries with exponential backoff capped at 12s (~28s total)**, so a multi-second quota limit clears before we give up. Still abortable, still honors `Retry-After` when present, and still tunable via `MIMO2CODEX_UPSTREAM_MAX_RETRIES` (now up to 12) / `MIMO2CODEX_UPSTREAM_RETRY_BASE_MS`. Trade-off: while rate-limited, a single request now waits up to ~28s before failing instead of ~3.5s.\n\n- **[new]** **Log storage controls for long-running deployments**: **what problem this solves** — every request/response used to be logged in full and kept forever, so on always-on installs (Docker, shared/team setups) `data.db` grows without bound: it eats disk, slows backups and the Logs page, and keeps full conversation text around far longer than you may want for privacy. Two knobs now cap that. `MIMO2CODEX_LOG_BODY_MODE=full|errors-only|off` (also in the Logs page → \"Storage settings\") keeps full debugging detail, stores bodies for failed requests only (enough to triage, far smaller), or disables body capture entirely. `MIMO2CODEX_LOG_RETENTION_DAYS=<n>` (same place) auto-deletes rows older than `n` days — on startup and every 6h while running; `0` disables pruning. Typical use: a small VPS / team proxy sets `errors-only` + `30` so the DB stays bounded instead of ballooning over months. Settings live in the DB (no restart) and the env/CLI value wins when set.\n\n---\n\n## v0.5.20\n\n- **[fix]** **Transient upstream 429 / 5xx no longer break the session (\"exceeded retry limit, last status: 429\")**: the proxy used to forward a rate-limit straight back to Codex, which then burned its own `request_max_retries` and gave up — leaving the user to manually hit \"continue\". mimo2codex now absorbs transient failures itself: `postUpstream` retries `429` and `500/502/503/504` (and network connect failures) with exponential backoff + jitter, honoring the upstream's `Retry-After` header (capped at 10s so Codex doesn't time out). Retries are abortable — a Codex cancel during backoff stops immediately. Non-retryable errors (400/401/403 …) still fail fast. Tunable via `MIMO2CODEX_UPSTREAM_MAX_RETRIES` (default 3) and `MIMO2CODEX_UPSTREAM_RETRY_BASE_MS` (default 500).\n\n- **[fix]** **\"写入文件并启用\" no longer wipes your other config.toml settings**: applying a model used to overwrite the whole `~/.codex/config.toml` with just `model` + `model_provider` + `[model_providers.<key>]`, silently dropping everything else the user had — `[projects]` trust levels, `[mcp_servers]`, `[windows] sandbox`, `model_reasoning_effort`, `[notice.model_migrations]`, comments. Switching models now does a **surgical merge** (`src/codex/tomlMerge.ts`): only the four keys we manage (`model`, `model_provider`, `model_context_window`, `model_max_output_tokens`) and our own `[model_providers.<key>]` table are rewritten; every other byte is preserved. A fresh install (no existing config.toml) still gets the rich first-run snippet. Backups are still taken before every write, so any prior config remains fully restorable.\n- **[new]** **Session Manager — browse all Codex sessions across providers and migrate them** (new left-nav tab). Codex Desktop stores each session in `~/.codex/state_<N>.sqlite` (`threads` table) tagged with one `model_provider`, and filters its session list by it — which is why switching providers in mimo2codex made \"ds and mimo sessions invisible to each other\". The new tab reads that DB (read-only) and shows every session grouped by **provider → project (cwd) → session**, regardless of which provider is currently active. Display niceties: the Windows extended-length `\\\\?\\` prefix is stripped from project paths (so the same project doesn't split into two groups), session timestamps render correctly (Codex stores seconds), long titles are middle-ellipsized to one line, and only the first provider group is expanded by default. Since a session can't be *shared* across providers (the row holds exactly one), it can be **migrated**: \"Migrate to…\" rewrites the session's `model_provider` (in the DB and the rollout file's `session_meta`) so Codex lists it under the chosen provider after a restart. **Batch migrate**: tick the checkboxes (selection spans every project/provider table) and \"Migrate selected\" moves them all to one provider in one go. Safety: mimo2codex snapshots the whole state DB (+ `-wal`/`-shm`) and the rollout into `~/.codex/.m2c-backups/sessions/<ts>/` before touching anything, and **refuses to migrate while Codex Desktop holds the DB lock** (a `409 codex_running`) so an open app can't corrupt its sessions. Local mode only. ⚠️ This edits Codex's private, version-stamped state — if a future Codex changes the schema, the tab degrades to \"unavailable\" rather than breaking.\n- **[new]** **Preview a session's chat transcript + export to Markdown**: each session row has a \"Preview\" button that opens a drawer rendering the conversation Codex-style — user/assistant messages, reasoning, and tool calls (shell commands, `apply_patch` …) with their output in code blocks. Tool/shell calls are **collapsed by default** (header shows the tool + first command line) so the text conversation stands out — click to expand. The rollout JSONL is parsed server-side (`src/codex/transcript.ts`); injected developer/permission blocks are dropped and environment/instruction context is collapsed so the real conversation stands out. An \"Export Markdown\" button downloads the whole transcript as a `.md` file. Read-only, local mode only.\n- **[new]** **Live \"当前状态\" Codex indicator in the header**: the current state is something users check constantly, so it now sits in the top bar alongside the other status items, instead of taking up a card on the Codex page. It's a compact ticker labeled \"当前状态\" that cycles every 3s through each state row — codex dir, auth.json owner, config.toml provider/model, runtime override — and turns blue when a runtime override is active. Clicking it opens the full state (codex dir + editor, auth.json, config.toml, override, export/import) — a popover on wide screens, a modal when narrow. Auto-refreshes every 30s.\n- **[opt]** **Codex page slimmed to direct model switching**: the \"current state\" card moved to the header (above) and the redundant quick-switch bar was dropped, so the Codex Integration page is now just the title + the model-switch table (\"可启用模型\" / \"写入文件并启用\"). The two \"工作原理\" mode explanations and the intro were already folded into the collapsible \"先决条件\" panel (default-collapsed).\n- **[new]** **Restart Codex right after applying a config**: a config switch only takes effect once Codex reloads, so applying one (\"写入文件并启用\") now pops a \"Restart Codex to apply the change?\" dialog (Restart now / Not now). It force-closes the running Codex Desktop app and relaunches it (and just launches it if it wasn't running), so you don't have to hunt for the app to close it yourself. Windows: targets only the Desktop app's own `Codex.exe` processes (matched by executable path, so the VS Code extension's `codex` engine is left alone) and relaunches via the Store AppUserModelID. macOS: best-effort `pkill` + `open -a Codex`. Local mode only; unsupported platforms tell you to restart manually.\n- **[new]** **Desktop: offer to open Codex on launch**: when you start the mimo2codex desktop app and Codex Desktop isn't already running, a dialog asks whether to open it (\"打开 Codex\" / \"暂不\") and launches it for you if you confirm. Detection targets only the real Codex Desktop processes (by executable path); launch goes through the Store AppUserModelID on Windows / `open -a Codex` on macOS. Skipped when Codex is already running, on first-run setup, and on autostart-at-boot launches (so it never nags during boot). Detection + launch reuse the same primitives as the in-app restart.\n- **[opt]** **Desktop: double-click the tray icon to open the admin console**: previously double-clicking the system-tray icon did nothing (only right-click opened the menu). It now opens the admin console in-app directly — one gesture instead of right-click → menu. Menu access stays on right-click. (The quit confirmation already lists \"Quit\" left of \"Cancel\" with Cancel as the safe default, so that was already as requested.)\n- **[opt]** **Config backups moved into a dedicated `~/.codex/.m2c-backups/` folder**: the per-switch `auth.json.bak.*` / `config.toml.bak.*` snapshots used to pile up directly in `~/.codex/`, cluttering the directory listing the Codex app and CLI also read. They now live in a hidden `.m2c-backups/` subfolder (still inside the codex dir, so restore works unchanged). Existing legacy sibling backups are migrated automatically on first read.\n- **[opt]** **Model-rewrite log: now silent by default + a quick toggle in the header** (builds on [PR #49](https://github.com/7as0nch/mimo2codex/pull/49), thanks @oxsean). When Codex sends a model id that differs from the provider's catalog (e.g. `gpt-5.4` → `mimo-v2.5-pro`), the proxy logged a \"model fallback applied\" INFO line on every request. That log is now **suppressed by default**, with a quick switch under the admin header's \"更多\" menu (\"静默模型改写日志\") to flip it at runtime — no restart. Resolution order is env > admin setting > silent: `MIMO2CODEX_SILENT_REWRITE=1`/`true` (or `0`/`false`) still wins and, when set, disables the UI toggle.\n\n---\n\n## v0.5.6\n\n- **[fix]** **Long-conversation 400 \"unexpected end of data: line 1 column 46 (char 45)\"**: once an upstream stream finished mid tool-call (length limit, network cut, client cancel, thinking-budget exhaustion …), Codex persisted the **truncated `tool_call.arguments`** as part of the session history. From that point on, every subsequent request in the same session carried the malformed JSON-as-string field, and strict upstreams (MiMo / DeepSeek / SenseNova …) rejected the request body with a JSON parser error pointing at the truncation point — the session looked permanently broken until the user started a new chat. mimo2codex now sanitizes tool-call arguments at **three layers**: (1) on the way *back* to Codex during streaming (`streamToSse.finalizeToolCalls`) — invalid JSON is salvaged to `\"{}\"` with a clear WARN that names the cause (length truncation vs. other), so the bad value never reaches Codex's history in the first place; (2) the same defense on the non-streaming path (`respToResponses`); (3) on the way *out* to the upstream (`reqToChat`'s `function_call` branch) — historical `arguments` that failed validation are likewise rewritten to `\"{}\"`, which immediately revives sessions poisoned by older proxy versions. The matching `tool` message stays paired with the assistant turn (the `removeOrphanToolMessages` / `ensureToolCallsHaveOutputs` invariant is preserved). For the rare case the upstream still returns this shape, `contextOverflow.detectMalformedJsonField` rewrites the raw 400 into a bilingual recovery hint (\"upgrade or start a new codex session\") instead of dumping the cryptic upstream error at the user.\n- **[opt]** **Desktop Settings supports multiple provider keys at once + custom base URLs** ([PR #43](https://github.com/7as0nch/mimo2codex/pull/43) — A1, thanks @starlsd93-sudo). The original Settings window only let you configure one provider's API key at a time, so users running multiple providers (MiMo + DeepSeek) had to switch the provider dropdown and re-save once per key. The new layout shows API key + optional base URL fields for every provider (MiMo, DeepSeek, Generic) on a single page, with `GENERIC_DEFAULT_MODEL` exposed as well. Setup completes if at least one provider has a key; the base URL placeholders show the built-in defaults so users only fill what they want to override (MiMo TP subscription host, DeepSeek tenant, etc.). The MiMo Base URL field includes an inline hint that explains the proxy auto-routes based on the key's `sk-*` / `tp-*` prefix, so new users don't paste the wrong host and get 401. How to open: tray icon → Settings…, or top menu bar 「文件 → 设置… (Ctrl+, / Cmd+,)」. What it looks like:\n\n ![desktop settings screenshot](../images/desktop-setttings.png)\n- **[new]** **Desktop first-run can import config from an existing CLI install** ([PR #43](https://github.com/7as0nch/mimo2codex/pull/43) — A3, thanks @starlsd93-sudo). If `~/.mimo2codex/.env` exists from a prior `npm install -g mimo2codex` install, the desktop Settings window shows a one-click \"Import all into desktop\" button that copies the API keys / base URLs / proxy vars over to the desktop's per-platform AppData. Existing desktop values are never overwritten — any key already set in the desktop is reported as \"skipped\". After import, fields are pre-filled in the multi-provider form for review before Save & Restart. Detection honors `~/.mimo2codex-pointer.json`, so users who previously migrated their CLI data directory via the admin UI get the **active** .env imported, not a stale default-location leftover.\n- **[new]** **Localized desktop application menu + top-bar \"设置\" entry** ([PR #43](https://github.com/7as0nch/mimo2codex/pull/43) — A2, thanks @starlsd93-sudo). The Electron default Windows / Linux menu bar was English-only (\"File / Edit / View / Window / Help\") with no Settings entry — users had to right-click the tray icon, which is unintuitive. v0.5.6 ships a unified Chinese menu on all three platforms (文件 / 编辑 / 视图 / 窗口 / 帮助). \"文件 → 设置… (Ctrl+, / Cmd+,)\" opens the Electron Settings window directly. As a redundant in-context entry, the admin web UI's header also has a \"桌面端设置\" button (only rendered inside the desktop shell, gated on `/admin/api/desktop/sentinel`); it travels through a file-based signal channel (`<dataDir>/.desktop-signal.json` written by the sidecar, watched by Electron main) since the admin BrowserWindow has no preload bridge.\n- **[new]** **Windows app icon refresh** ([PR #43](https://github.com/7as0nch/mimo2codex/pull/43) — B, thanks @starlsd93-sudo). The contributor supplied a higher-resolution AI-rendered icon set; the orange variant is now wired up as `package/win/icon.ico`. The full set (orange + purple, four sizes each) is preserved under `package/brand/contributed-by-starlsd93/` with provenance notes. macOS `.icns` is untouched in this release — the contribution didn't include `.icns` / `tray-Template*.png`, so a future maintainer pass will refresh those.\n\n---\n\n## v0.5.5\n\n- **[new]** **Windows / macOS desktop app goes GA (no longer beta)**: after the beta-testing window that started with v0.4.8, the desktop app is now stable. Runs mimo2codex in the background; tray / menu-bar icon management; one-click admin UI; auto-update wired up. The CLI install (`npm install -g mimo2codex`) is unchanged and can coexist. Downloads: <https://mimodoc.chengj.online/download>.\n- **[fix]** **`tool_search` builtin now supported ([issue #41](https://github.com/7as0nch/mimo2codex/issues/41))**: Codex Desktop's deferred-tool-discovery tool was previously dropped as an unknown type, blocking deferred tool discovery and triggering cascading orphan warnings. It's now translated to a regular function tool — works normally.\n- **[fix]** **Connector plugins no longer fail with \"unsupported call\" ([issue #39](https://github.com/7as0nch/mimo2codex/issues/39))**: GitHub / Canva / HeyGen / Dropbox / Gmail / Google Drive connectors require OpenAI's backend-hosted MCP runtime, which a third-party proxy can't substitute for. mimo2codex now tells the upstream model — the model suggests `shell` + a CLI alternative (e.g. `gh` for GitHub) instead of failing.\n- **[fix]** **Capability checks (vision, etc.) now follow the upstream model**: when a runtime override / alias maps the client's `mimo-v2.5-pro` to upstream `mimo-v2.5` (which supports vision), images were still being stripped at the proxy because the check used the client literal. Fixed — capability decisions now follow the real upstream model id, so switching models at runtime takes effect immediately without restart.\n\n---\n\n## (v0.4.10 — 2026-05-24)\n\n- **[fix]** **Codex Desktop namespace tools reporting `unsupported call` ([PR #34](https://github.com/7as0nch/mimo2codex/pull/34), [issue #33](https://github.com/7as0nch/mimo2codex/issues/33), thanks @meesii)**: Codex Desktop's namespace-wrapped tools (e.g. `spawn_agent` under `multi_agent_v1`) failed with `unsupported call` when routed through mimo2codex — the client uses the `namespace` field on each `function_call` output item to dispatch to the correct local handler, and the proxy was dropping it during translation. The fix builds a `toolName → namespaceName` map from the request's `tools` array and re-attaches `namespace` on both non-streaming (`respToResponses`) and streaming (`streamToSse`) outputs. Requests without namespace tools (MiMo / DeepSeek / plain Codex CLI) stay byte-identical.\n\n---\n\n## (v0.4.8 — 2026-05-23)\n\n- **[new]** **Desktop preview (beta) — Windows tray / macOS menu-bar app**: optional Electron companion that runs mimo2codex in the background — no terminal window required. First launch shows a small settings window to pick a provider + paste an API key; after that the tray / menu-bar icon opens the embedded admin UI (either in a window or in your default browser). The sidecar lifecycle (start / stop / restart on settings change) is fully managed; menu **Quit** stops it cleanly. Includes an opt-in \"Start on system boot\" toggle. The CLI install (`npm install -g mimo2codex`) is unchanged and can coexist on the same machine — the desktop build ships as a separate `v*-desktop` artifact. This is a **beta** — installer, launch, sidecar, and auto-update flows still need real-world miles, so please report friction. Downloads + install guide: <https://mimodoc.chengj.online/download>.\n- **[fix]** **CodeX Desktop string-input misidentified as probe ([PR #31](https://github.com/7as0nch/mimo2codex/pull/31), thanks @85339098-afk)**: the OpenAI Responses API allows `input` to be either a string or an array of items; the probe-shape detector in `handleResponses` only matched the array form, so requests like `{model, input: \"write hello world\"}` (CodeX Desktop's natural shape) were short-circuited to a synthetic 200 with empty `output: []` — looked like the model said nothing, with **no error signal**. The check now also recognizes non-empty string `input`. Logic extracted into an exported `isResponsesProbe()` helper with a focused unit-test suite (`test/server.probe.test.ts`) so this rule can't silently regress.\n\n---\n\n## (v0.4.6 — 2026-05-23)\n\n- **[fix]** **DeepSeek V4 400 `Invalid assistant message: content or tool_calls must be set` ([issue #29](https://github.com/7as0nch/mimo2codex/issues/29))**: when an assistant turn was assembled from a reasoning item + function_call without any visible text part (Codex Chrome plugin pattern), the wire shape became `{role:\"assistant\", content: null, tool_calls:[…], reasoning_content:\"…\"}`. DeepSeek's strict validator treats explicit `null` as \"neither field present\" and rejects. The OpenAI Chat Completions spec says `content` is optional when `tool_calls` is set, so we now OMIT the field instead of setting it to null. Reasoning-only fallback turns (rare: no text, no tools) get `content: \"\"` to satisfy the spec.\n- **[fix]** **Windows / pnpm-global / Node 22 startup crash ([issue #30](https://github.com/7as0nch/mimo2codex/issues/30))**: `mimo2codex` no longer exits when the admin sqlite database can't be opened at startup. Typical cause: pnpm's global install layout didn't fetch a prebuilt `better-sqlite3` binary for the user's Node ABI (`node-v127-win32-x64`), so `new Database()` throws `Could not locate the bindings file`. The proxy now logs a clear, multi-line warning (with the underlying error and a Windows/pnpm-specific hint) and starts with admin DISABLED. Core Codex ↔ Chat-Completions translation never needed the DB and now works out-of-the-box on the install setups that hit this binding gap.\n\n---\n\n## v0.4.5 — 2026-05-22\n\n- **[new]** **Desktop shell (Windows tray / macOS menu bar)**: optional companion app that runs mimo2codex in the background — no terminal window required. First-launch settings window for picking a provider + API key, embedded admin UI from the tray, sidecar lifecycle is fully managed (start / stop / restart on settings change), graceful quit, and an opt-in \"Start on system boot\" toggle. The CLI flow (`npm install -g mimo2codex`) is completely unaffected; the desktop build is shipped as a separate `v*-desktop` release on GitHub. Downloads + install guide: <https://mimodoc.chengj.online/download>.\n- **[opt]** **Desktop Mac builds ship as `.zip` (was `.dmg`)**: multiple GitHub-runner hdiutil versions (macos-14 + macos-15) and dmg formats (UDZO + ULFO) consistently produced technically-valid-but-unmountable `.dmg` images on consumer Macs (\"此电脑不能读取你连接的磁盘\" / \"error 3840\"). `.zip` is boring and works everywhere — Finder unzips on double-click, drag `mimo2codex.app` to `/Applications`. The download page detects the format automatically; if a future signed `.dmg` is added we can re-enable that target. SHA256 verification + `xattr -cr` for quarantine clearing are unchanged.\n- **[new]** **Proxy support**: mimo2codex's outbound calls honor `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` env vars — same behavior as `curl` / `git`. Declare them in `docker-compose.yml`'s `environment:` for Docker, or `export` from your shell / `.env` for local runs. The startup banner gains a `proxy:` line that echoes the active proxy so env-detection is verifiable at a glance. `MIMO2CODEX_NO_PROXY_FROM_ENV=1` opts out (for users whose shell keeps `HTTPS_PROXY` set for `curl`/`git` but don't want mimo2codex to follow).\n- **[opt]** Upstream connect-failure logs carry the underlying cause's `code` and `message` (e.g. `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT`); the same detail flows into the 502 `UpstreamError.message`, making proxy-port typos, DNS failures, and timeouts distinguishable at a glance.\n- **[doc]** Proxy FAQ §1 rewritten to spell out \"system proxy ≠ process proxy\" — Clash / Surge's \"system proxy\" toggle doesn't auto-export env vars. New 🩺 self-check callout turns the banner's `proxy:` line into a one-glance diagnostic. §5 gains an `ECONNREFUSED <proxy-host>:<proxy-port>` row (including the Docker `127.0.0.1` gotcha).\n\n---\n\n## v0.4.4 — 2026-05-21\n\n- **[new]** **AI documentation assistant on the official docs site ([mimodoc.chengj.online](https://mimodoc.chengj.online/))**: click the bottom-right robot float — drop any common configuration question (first-time setup, why-502, generic-provider wiring, etc.) and the assistant runs a tool-calling agent loop over the project's `doc/*.md` corpus, returning a streamed markdown answer. The reasoning trace is shown in a collapsible \"thinking\" panel above the answer (auto-collapses once the answer starts). MiMo V2.5 multimodal is wired in — paste / drag / click the paperclip to upload a config screenshot and the AI looks at it before answering. Chat history lives in localStorage per anonymous browser id; clear-conversation button in the drawer header. \n\n---\n\n## v0.4.2 — 2026-05-21\n\n- **[new]** **Migrate the data directory from the admin UI**: top-right ⚙️ Settings → Local data directory → Migrate. Pick a target path, preview file count + total size, then a live progress bar copies SQLite + `.env` + `providers.json`. The server enters maintenance mode (503) while copying; the original directory is preserved so users can verify the new location before deleting. Auto-rollback on failure (partially-written destination is wiped + the old location is reopened). A persistent banner reminds the user to restart so the new directory takes effect; the resolver priority becomes CLI > env > pointer file (`~/.mimo2codex-pointer.json`) > default `~/.mimo2codex/`.\n- **[doc]** **Official docs site goes live at [mimodoc.chengj.online](https://mimodoc.chengj.online/)**: single home for docs and tutorials. The admin footer now points at it directly with a tooltip nudge for stuck users.\n- **[fix]** **Hide server-only Codex entries in local mode**: the \"Export to local\" / \"Import from local\" buttons and the `History` tab on the Codex 接入 page only make sense in Docker auth deployments (`authMode=on`), where operators ship rendered `auth.json` + `config.toml` bundles between machines. Local single-user runs already write those files directly to `~/.codex/`, so the buttons were noise. Now gated on `authMode === \"on\"`.\n\n---\n\n## (v0.3.0 — coming)\n\n- **[new]** **Docker auth deployment goes GA**: after v0.2.17 served as the preview, the **Docker auth mode** is now a stable feature — user registration / login, per-user m2c proxy API keys, BYOK (bring-your-own upstream key), Gitee / GitHub OAuth, downloadable Codex client config bundles. Put mimo2codex behind Docker / an internal network / a small private circle without leaking the upstream key. Local single-user runs (`authMode` defaults to `off`) are unaffected. Full guide: [doc/auth-deployment.md](./auth-deployment.md) — covers Docker compose, first-run bootstrap, OAuth setup, and troubleshooting.\n- **[fix]** **Tool list dedup defense ([issue #20](https://github.com/7as0nch/mimo2codex/issues/20))**: newer Codex CLI / Desktop / DeX builds emit duplicate tool names (typical shape: a top-level `_fetch` function plus a `namespace`-wrapped `_fetch` that flattens to a second copy), causing MiMo to 400 with `\"tools contains duplicate names: _fetch\"`. reqToChat now dedupes by `function.name` / builtin `type` keep-first after the merge step; duplicates are logged at `WARN` so users can spot the client-side bug.\n- **[new]** **Mixed-mode thinking history defense**: when conversation history contains assistant messages without `reasoning_content` (typical scenario: user toggled the thinking switch mid-session), automatically backfill those messages with the placeholder `\"(this turn ran without thinking mode)\"`. **Thinking stays ON** — avoids upstream MiMo / DeepSeek 400 `\"reasoning_content must be passed back\"`. Logs a paired INFO line.\n- **[opt]** Quieter console log: `WARN client model rewritten on the way upstream` → `INFO model fallback applied — client sent unknown model id, request continues with provider default`. Demoted to INFO + rephrased; it was always a graceful fallback (request succeeds), not an error.\n- **[doc]** New bilingual [Proxy / Network FAQ](./proxy-faq.md): mac & win proxy setup, error-code lookup (502 / ECONNREFUSED / DNS / TLS-MITM, etc.), origin of the `gpt-5.4` placeholder, mixed-mode thinking history explainer.\n- **[doc]** New bilingual [Tag Log](./tag-log.md): migrated out of the README's `<details>` changelog block; sorted newest-first with `[new]/[fix]/[opt]/[doc]` categorization across all 44 historical tags.\n\n---\n\n## v0.2.17 — 2026-05-19\n\n- **[new]** **Docker auth mode (preview)**: users can register, log in, and generate their own m2c (mimo2codex proxy) API key. For Docker / intranet / small private deployments, replace `OPENAI_API_KEY`'s `mimo2codex-local` placeholder with the generated m2c key — protects the upstream key from being abused. Single-user local runs (`authMode` defaults to `off`) are unaffected.\n\n> ⚠️ **v0.2.17 is a preview release** — the first cut of the Docker auth deployment. **v0.3.0 is the GA**. For production use, please run v0.3.0+. See [Auth & deployment](./auth-deployment.md).\n\n## v0.2.16 — 2026-05-19\n\n- **[opt]** Admin UI tightening: denser layout, dropped redundant displays, reduced visual noise.\n\n## v0.2.15 — 2026-05-18\n\n> Includes betas `v0.2.15-beta.0/1/2` (SenseNova model adaptation + thinking fine-tuning + Kimi adaptation).\n\n- **[new]** **Thinking mode admin UI**: the \"Codex Enable\" page gains a global **Thinking** card.\n - **Thinking ON/OFF**: persists into the settings DB; no more `--disable-thinking` restart. Takes effect immediately on the next request. OFF makes every provider skip thinking (`thinking:{type:\"disabled\"}` for mimo / deepseek, `reasoning_effort:\"none\"` for sensenova / other generic).\n - **Force high reasoning effort**: when Codex didn't pass `reasoning.effort`, mimo2codex fills in `reasoning_effort:\"high\"`. Disabled by default with a visible side-effect warning (billing can spike). CLI `--disable-thinking` still wins.\n- **[new]** **Kimi (Moonshot) preset**: typing `https://api.moonshot.cn/v1` (or `moonshot.ai`) as baseUrl is auto-recognized and applies `dropReasoningEffort: true`, so Kimi (which uses `thinking:{enabled/disabled}` instead of `reasoning_effort`) doesn't 400 on the unknown field. Models: `kimi-k2.6` / `kimi-k2.5` / `kimi-k2-thinking` / `kimi-k2-thinking-turbo` / `moonshot-v1-{8k,32k,128k}`. See [doc/kimi.md](./kimi.md).\n- **[new]** **Docker deployment**: new `Dockerfile` (multi-stage alpine, ~70MB), `.dockerignore`, GitHub Actions workflow that auto-builds **multi-arch `linux/amd64 / linux/arm64` images and pushes to ghcr.io/7as0nch/mimo2codex**; bundled `docker-compose.yml` for one-command launch with **the data dir bind-mounted to local `./.mimo2codex/`** (sqlite + providers.json + admin UI config persist across container rebuilds); env supports both `.env` mount and `-e` / `environment:` injection. macOS / Windows / Linux. Based on [#15](https://github.com/7as0nch/mimo2codex/pull/15) (thanks @hufang360).\n- **[new]** SenseNova model adaptation (from betas).\n\n## v0.2.14 — 2026-05-15\n\n- **[fix]** Added inline comments to `.env.example` so first-time users don't miss what each field means.\n\n## v0.2.13 / v0.2.12 / v0.2.11 / v0.2.10 — 2026-05-15\n\n- **[new]** Version-update check (queries the upstream npm registry for newer releases). Iterated through four patches to refine network tolerance, caching, and message phrasing.\n\n## v0.2.9 — 2026-05-15\n\n- **[new]** Universal `.env` config: `mimo2codex init` then fill in keys — same config across platforms.\n\n## v0.2.8 — 2026-05-15\n\n> MiniMax / strict OpenAI-compatible upstream support patchset (PR #12).\n\n- **[fix]** `reqToChat`: no longer sends `strict: null` upstream (MiMo's Pydantic schema rejects null and 400s with `\"Input should be a valid boolean\"`). Fixes [issue #11](https://github.com/7as0nch/mimo2codex/issues/11).\n- **[fix]** `minimax-compat`: one-click preset no longer strips `stream_options` / `parallel_tool_calls` by default.\n- **[feat]** `minimax-compat`: inline `<think>...</think>` on the response side is split into `reasoning_content`.\n- **[feat]** Admin webui providers form: new \"Strict OpenAI compat\" switch group (covers minimaxCompat etc.).\n- **[feat]** Generic provider gains the MiniMax-compat patch ([issue #7](https://github.com/7as0nch/mimo2codex/issues/7)).\n\n## v0.2.7 — 2026-05-15\n\n- **[new]** Full admin webui rewrite on **Ant Design 5**: dark/light themes, EN/中文 i18n, viewport-locked sider + footer, smoothed Token-usage curves.\n- **[new]** `.env.example` + **Bash / PowerShell one-liner key-loader scripts** (`.env` is gitignored).\n- **[new]** Per-model **⚡Probe** button on \"Codex Enable\": fires a minimal ping to validate key / baseUrl / model id end-to-end.\n- **[new]** Token-usage chart folds in **cache-hit bars** (green = hits, gray ghost = prompt totals) plus a window-wide hit-rate summary.\n- **[new]** Customizable Codex dir via settings or the `CODEX_HOME` env var.\n\n> Includes betas `v0.2.6-beta.1/2/3`: MiMo models' `contextWindow` 128K → 1M (matching DeepSeek; fixes Codex 256K-config 400); webui refactor PR #1~#6 (antd 5 base, Setup/Models/CodexEnable theming, Logs table, Dashboard cache-hit overlay, viewport lockdown, etc.).\n\n## v0.2.6 — 2026-05-14\n\n- **[new]** **\"Codex Enable\" page** (**replaces cc-switch**): admin webui writes `~/.codex/auth.json` + `config.toml` in one click.\n- **[new]** **Runtime override**: swap upstream models without restarting Codex.\n- **[new]** Permanent backup retention + half-broken pair recovery + manual deletion: originals are auto-backed-up, and **the first backup capturing your real external auth.json is permanently preserved** — switch models 100 times and you can still restore the original Codex config.\n- **[fix]** `removeOrphanToolMessages`: drops orphan tool messages on DeepSeek V4 session desync, preventing 400 `\"Messages with role 'tool' must be a response to...\"` ([PR #10](https://github.com/7as0nch/mimo2codex/pull/10) / [issue #8](https://github.com/7as0nch/mimo2codex/issues/8)).\n- See [doc/codex-enable.md](./codex-enable.md).\n\n## v0.2.5 — 2026-05-14\n\n> Includes beta `v0.2.5-beta.1`.\n\n- **[feat]** MiMo / DeepSeek docs aligned.\n- **[fix]** DeepSeek `tool_calls` 400 fix.\n- **[feat]** Friendly context-overflow error: surfaces a readable `/compact` hint instead of a raw 400.\n- **[feat]** Beta release workflow (`npm run release:beta`).\n\n## v0.2.4 — 2026-05-13\n\n- **[test]** Added two-stage priority regression tests for `selectProvider`.\n- **[doc]** Generic-provider routing-priority docs updated to match.\n\n## v0.2.3 — 2026-05-13\n\n- **[fix]** Fixed MiMo `reasoning_content` round-trip per Xiaomi's [official guidance](https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/passing-back-reasoning_content).\n\n## v0.2.2 — 2026-05-13\n\n- **[fix]** GitHub Actions workflow fix.\n\n## v0.2.1 — 2026-05-12\n\n- **[new]** Added `mimoskill` — Python helpers for image generation, OCR, etc. (stdlib only, no pip).\n\n## v0.1.16 ~ v0.1.19 — 2026-05-12\n\n- **[new]** Early `mimoskill` iteration (v0.1.17 ~ v0.1.19): image gen / OCR / pet generation, polished step by step.\n- **[new]** v0.1.16: support for additional models with `wireApi=\"responses\"` direct passthrough (in addition to the default mimo / deepseek chat-translation path).\n\n## v0.1.15 — 2026-05-12\n\n- **[fix]** Registered `mimo-v2.5` vision model in the builtin catalog so it no longer silently falls back to `mimo-v2.5-pro` (which would drop images).\n\n## v0.1.1 ~ v0.1.14 — 2026-05-09 ~ 2026-05-10\n\nEarly-project iteration (v0.1.1 was the first public release on 2026-05-09). No detailed changelog kept for this phase; main work:\n\n- mimo / deepseek dual-provider scaffolding.\n- Responses API ↔ Chat Completions bidirectional translation core (`reqToChat` / `respToResponses` / `streamToSse`).\n- First-cut admin webui (Tokens / Logs / Settings pages).\n- SQLite persistence (chat logs, model catalog, runtime settings).\n- CLI: `mimo2codex init` / `update` / `print-config` / `print-cc-switch`.\n\nBrowse the full commit stream with `git log v0.1.1..v0.1.14 --oneline`.\n\n---\n\n## Release commands\n\nDefined in [package.json](../package.json):\n\n```bash\nnpm run release:patch # x.y.Z+1\nnpm run release:minor # x.Y+1.0\nnpm run release:major # X+1.0.0\nnpm run release:beta # pre-release\n```\n\nFull runbook: [PUBLISHING.md](../PUBLISHING.md) (repo root).\n"}}, "doc/tag-log.zh.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595137.7269166, "n": 33538, "h": "ebbde0565e63531a", "c": "# 版本日志(Tag Log)\n\n<p>\n <a href=\"./tag-log.md\">English</a> ·\n <a href=\"./tag-log.zh.md\"><strong>简体中文</strong></a>\n</p>\n\nmimo2codex 的版本发布历史,按 tag 倒序排列。\n\n**类别标签说明**\n\n- **[new]** / **[feat]**:新增功能\n- **[fix]**:bug 修复\n- **[opt]** / **[refactor]**:优化 / 重构\n- **[doc]**:文档相关\n- **[test]**:测试用例\n\n---\n\n## v0.5.29 (upcoming)\n\n- **[opt]** **MiMo v2 模型下线 → 透明别名映射到 v2.5(无需改配置)** —— MiMo 已于 2026-06-30 下线 v2 代模型(`mimo-v2-pro`、`mimo-v2-omni`、`mimo-v2-flash`),继续用旧名请求上游会 400。mimo2codex 现在把**退役旧名别名映射到官方替代模型**,老的 `config.toml` 不改也能用、且仍命中正确模型:`mimo-v2-pro` → `mimo-v2.5-pro`,`mimo-v2-omni` / `mimo-v2-flash` → `mimo-v2.5`。实现方式是 `ProviderModel.aliases` + 支持别名的 `resolveModel`(照搬 DeepSeek);别名属于「干净解析」——上游拿到的是在线模型 id,不产生兜底改写/告警。三个退役条目从内置目录移除,因此模型列表、生成的 config.toml 片段、管理台「模型」页只保留在线的 v2.5 模型(`mimo-v2.5-pro`、`mimo-v2.5-pro-ultraspeed`、`mimo-v2.5`);旧的 DB 行会在下次启动时由 `seedBuiltins` 自动清理。**旧 `mimo-v2-flash` 用户注意行为变化:** 其替代模型 `mimo-v2.5` 思考模式**默认开启**(flash 原本默认关闭),且**支持视觉**。mimoskill 辅助脚本(`ocr.py` / `mimo_chat.py`,它们直连 MiMo、没有别名层)也会把退役旧名强制切到 v2.5 替代模型。\n\n- **[fix]** **思考模式采样参数:现在也剥离 `top_p`(此前只剥 `temperature`)** —— 按 MiMo 文档,v2.5 系列在思考模式下会忽略自定义 `temperature` **和** `top_p`(上游强制 `temperature:1.0` / `top_p:0.95`)。`normalizeMimoBody` 此前只删 `temperature`,现在对 v2.5 推理模型(`mimo-v2.5-pro`、`mimo-v2.5`、`mimo-v2.5-pro-ultraspeed`)两者都删,让请求与上游最终行为一致。归一化现在在 Responses 和 Chat 两个调用点都以**上游**模型 id(别名解析后)为准——此前 Chat 路径用的是客户端原始名(`out.model`),会把规则错误地套到被别名映射的旧名上。\n\n---\n\n## v0.5.28 (upcoming)\n\n- **[opt]** **大 `data.db` 下管理后台不再卡死(也不再拖垮代理)**(issue #76):有重度用户的 `data.db` 涨到 22GB,**概览 / 日志** 页要十几分钟才打开。根因:每次加载概览都在多 GB 的 `chat_logs` 大表上跑 5~6 个**实时聚合**(`aggregateStats` / `aggregateTokensTimeseries`(strftime+三维 GROUP BY) / `aggregateLatency`(把窗口内全部 duration_ms 读进 JS) / `aggregateProviderHealth` / 无时间过滤的 `aggregateMappings`)——而 better-sqlite3 是**同步**的,一个慢扫描会阻塞整个 HTTP 主循环,连 `/v1/responses` 都被卡。修复:新增**按小时统计表 `chat_stats_hourly`**,主键 `(小时, provider, client_model, upstream_model)`,在每次 `insertLog` 的事务里同步累加(即「写日志→统计 +1」);概览所有聚合改读这张小表(行数 = 小时数 × 模型组合,与 `chat_logs` 多大无关)。延迟分位用 8 桶直方图近似(精确均值来自 sum/count);按 error_code 的错误分布仍读 `chat_logs`,但新增部分索引 `WHERE status_code >= 400`。已有历史行由**后台、从最近往前的分批回填**灌入(分批 + 不阻塞——最近区间几分钟内就准)。另加防雪崩:概览自动刷新从 5s 放缓到 20s/60s 并加在飞守卫,日志页不再每翻页都查 db 大小。新文件 `src/db/stats.ts`;schema 迁移 v6。**磁盘提示:**这能让后台变快,但不会缩小已经很大的文件——请在日志页「存储设置」里设**保留天数**和/或把 body 捕获改成**仅错误**,再 VACUUM(需约等于 db 大小的可用空间)。\n\n- **[fix]** **MiMo 联网搜索现在默认关闭 —— 没开通插件的账户不再陷入 \"webSearchEnabled is false\" 400 重试死循环** —— 当请求带着 `web_search` 工具到 MiMo、但账户没开通(单独计费的)联网插件时,上游会 400 `web search tool found in the request body, but webSearchEnabled is false`;而 400 不可重试,错误透传回 Codex,Codex 又按自己的 `request_max_retries` 不停重发同样请求 → 无限失败循环。根因:mimo2codex 把 `web_search` 转发给所有非 token-plan(sk-)账户([mimo.ts](src/providers/mimo.ts) 的 `enableWebSearch: !isTokenPlan`),错误地假定按量付费账户有插件——其实没有,插件对所有人都是 opt-in/计费的。**现在 web_search 转发改为 opt-in、对所有账户默认关闭**:除非显式开启,否则 mimo2codex 转发前会剥掉 `web_search`。需要时(且账户已开通插件)在管理台 **Codex 启用 → 思考与运行时覆盖 → 联网搜索** 开关打开,或用 `--web-search` / `MIMO2CODEX_WEB_SEARCH=1`。token-plan(tp-)账户无论如何都不带联网。新增设置 `mimo.webSearchEnabled`、新增 `GET/PUT /admin/api/web-search-state`,通过 `PreprocessCtx.webSearchEnabled` 全链路贯穿(照搬 `thinking.disabled` 那套开关)。\n\n---\n\n## v0.5.27 (upcoming)\n\n- **[new]** **新增「保留 ChatGPT 登录」启用方式 + Codex 手机端引导** —— 让你在保持真实 ChatGPT 账号登录的同时,把模型走 mimo2codex 代理(这正是用 OpenAI 官方「Codex 手机端 / 远程」、用手机操作这台电脑上 Codex 的前提)。此前启用 mimo2codex provider 会把 `~/.codex/auth.json` **覆盖**成占位 key,把你从「Sign in with ChatGPT」登出,于是官方登录和代理互斥。新路径(检测到真实登录时默认走它)**对 auth.json 一字节都不改**,只改写 `config.toml`(`model_provider` → 代理、`requires_openai_auth = true`);Codex 随后把你真实的 ChatGPT OAuth token 作为 bearer 附到本地代理,代理忽略它并转发到上游。「Codex 启用」页的确认弹窗会说明这一点,仍可勾选退回旧的覆盖行为;同时新增了仅 config.toml 的**「保留 ChatGPT 登录」**片段页签、可折叠的**「用手机操作这台 Codex」**引导卡片,以及 `doc/codex-mobile-remote.{md,zh.md}`。实现上隔离为独立新文件(`src/codex/preserveLogin.ts`、`src/setup/preserveLoginSnippet.ts`、`web/src/pages/codex/MobileRemoteCard.tsx`),现有 apply 路径完全不动。**注意:**官方**远程**模式到底会不会把模型走你本地代理(还是被强制用 OpenAI 自家模型)没有官方文档、需实测 —— 引导里已注明。\n\n- **[opt]** **客户端请求的模型现在优先于运行时覆盖** —— 此前 webui 的运行时覆盖(`selectProvider` 的 Pass 0)**无条件**优先,会劫持掉 Codex 明确配置的模型(例如 Codex 发 `mimo-v2.5-pro`,覆盖却强制成 `deepseek`)。现在路由优先级为:**(1)** 客户端模型——只要有「已注册且有 key」的 provider 能识别它;**(2)** 运行时覆盖——当模型 id 不在任何 provider 目录里时;**(3)** 默认 provider 兜底。于是 Codex 明确配置的模型会被尊重,而覆盖退化为「未识别模型 id」时的智能兜底(不再静默改写成默认模型)。实现:`src/server.ts` 的 `selectProvider` 把覆盖块从 Pass 0 移到识别 pass 之后;管理台「仅运行时覆盖」说明同步更新。未设置覆盖时行为不变。\n\n- **[fix]** **桌面端 Mac M 芯片(arm64)`/admin/` 静默 404**(0.5.26 回归,mac-intel / win 一并排查):现象是访问 `/admin/` 返回 `{\"error\":{\"type\":\"invalid_request_error\",\"code\":\"not_found\",\"message\":\"no route for GET /admin/\",\"status\":404}}`,退回 0.5.24 正常。根因链已逐行确认:sidecar 启动时 `openDb()` 加载 better-sqlite3 原生模块失败(错架构 / 错 ABI / Apple Silicon 未签名被 AMFI 拒),`cli.ts` 优雅降级把 `adminEnabled=false`(issue #30),于是 `/admin/` 落到通用 404 —— **失败被静默吞掉,报文极具误导性**。本次三层修复:(1) **运行时把静默 404 变成明确诊断**:因 DB 加载失败而被迫禁用 admin 时,`/admin/` 改回 **503 `admin_db_unavailable`**,带上 better-sqlite3 的原始错误与修复指引(重装 / 架构匹配 / macOS `xattr -cr`),并保留 `/admin/api/health` 回 `200 {adminEnabled:false, reason:\"db_unavailable\", message}` 供 CI / 桌面壳探测;用户显式 `--no-admin` / `MIMO2CODEX_NO_ADMIN` 仍保持原 404、不打扰。(2) **macOS ad-hoc 重签**:新增 `afterPack` 钩子(`scripts/after-pack-sign.cjs`,仅 macOS、非致命)对 `Contents/Resources/sidecar` 下的 `.node` 及整个 app 包做 ad-hoc 签名 —— extraResources 里的 `better_sqlite3.node` 在无证书签名时可能未签,Apple Silicon 的 AMFI 会在 dlopen 时拒绝它(Intel / Windows 不强制,正好对应\"M 芯片坏、intel/win 好\")。(3) **打包后端到端门禁**:新增 `scripts/postpack-healthcheck.mjs`,用打进去的 Electron 实跑打包后的 sidecar 并断言 `/admin/api/health → adminEnabled:true`,把\"错架构 / 错 ABI / 未签名 .node\"这一类问题在发布前拦住(接入 `pack-desktop-local` 第 7 步与 CI `build-desktop.yml`,原生目标跑、交叉目标自动跳过)。\n\n- **[fix]** **桌面端 Base URL 留空时上游主机变空**(启动 banner `upstream:` 空白、请求没有主机):桌面端 Settings 的 Base URL 字段留空时,会把 `MIMO_BASE_URL=`(空串)写进 `.env`——这是**有意**的(清空字段=清掉该行,用于\"清空 API key 停用某 provider\")。但配置解析用 `??` 取 base url,**空串不是 nullish、会\"赢\"过后面的回落链**,导致不再按 key 前缀自动选主机(`tp-` → `token-plan-cn.xiaomimimo.com` / `sk-` → `api.xiaomimimo.com`),`upstream` 变成空串、请求发不出去。现在把 env / CLI 传入的**空白或纯空格** base url 一律当作\"未设置\",回落链恢复(key 前缀推断 → 默认主机)。已有 `.env` 里残留空 `MIMO_BASE_URL` 的用户**升级后无需重存即自动修复**。\n\n- **[opt]** **构建期校验更严、不再有静默缺口**:`build-sidecar.mjs` 的交叉构建(arm64 runner → x64 包)此前会**静默跳过** ABI smoke test,现改为**醒目告警**并说明 ABI/签名须由打包后门禁在原生机校验;原生 smoke test 现在不只 `require`,还会实跑 `new Database(':memory:')` 并打印 `process.versions.modules`(ABI)+ `process.arch`,方便日后排查 electron 版本漂移。新增 `scripts/verify-release-native.mjs`:对任意桌面产物(.app / win-unpacked / sidecar 目录 / `.node`)解析 Mach-O/PE/ELF 头报告 CPU 架构、比对 `SIDECAR_INFO.json` 记录的构建目标,并打印目标机上验证 ABI + 签名的下一步命令。\n\n- **[opt]** **桌面端构建架构收敛:win 只出 x64、mac 出 Intel + Apple Silicon 两个包,且不再重复构建**(回答\"win 几个平台都查一下\"):(1) **彻底去掉 win-arm64** —— better-sqlite3 v12.x 没有可靠的 win32-arm64 预编译,硬打出来的 win-arm64 包会塞进**错架构**的原生模块、照样 `/admin/` 404(本机实测一个旧的本地 win-arm64 产物,里面的 `better_sqlite3.node` 竟是 x64)。现在在 `electron-builder.yml`(`win.target` 去掉 arm64)和 `pack-desktop-local.mjs`(`VALID_TARGETS` 移除 `win-arm64`)两处都堵死,Windows on ARM 用户用 x64 包(Win11 ARM 的 x64 仿真下可运行)。(2) **mac/win 的 target 不再写死 `arch: [x64, arm64]`**,改由 `--x64`/`--arm64` CLI flag 驱动(CI 矩阵与本地打包都会传)—— 之前写死 arch 会让每个 mac job **同时构建两种架构**(CLI flag 压不过 target 内的 arch 列表),在 arm64 job 里产出多余的交叉 x64、还撞出重复的发布 zip。mac 依旧发布 **Intel(x64)与 Apple Silicon(arm64)两个独立安装包**,但现在每个 job 只构建自己那一种架构。\n\n---\n\n## v0.5.26 (upcoming)\n\n- **[new]** **内置支持 `MiMo-V2.5-Pro-UltraSpeed`**(issue #70):小米新出的万亿参数(1T)旗舰、500-1000 tok/s「体验模式」现已作为内置模型被识别。此前发送 `mimo-v2.5-pro-ultraspeed` 会**被静默改写成 `mimo-v2.5-pro`**(实际跑的是 Pro 而非 UltraSpeed),且 admin UI 里选不到它。现在它会原样路由、出现在模型目录 /「Codex 启用」页,`print-config` 也会列出。按官方规格声明为纯文本 + 深度思考 + 工具调用、1M 上下文、131072 最大输出。联网搜索**关闭**(官方能力未列)——若你在 PAYG 账户上开了 Codex 联网搜索,UltraSpeed 转发 `web_search` 时可能 400,对该模型请关掉。**使用限制:**UltraSpeed 需申请开通(每日限量审批,在 https://platform.xiaomimimo.com/ultraspeed 申请),且**仅在按量付费 API host(`sk-` key)上提供**;套餐订阅(`tp-`)账户用不了。admin 的「Codex 启用」页会把它标为**受限**(悬停看说明),用 `tp-` key 选 UltraSpeed 会被前置拦截、给出清晰提示(`model_requires_payg`),而不是上游那种让人困惑的 model-not-found。\n\n---\n\n## v0.5.25\n\n- **[new]** **数据库瘦身——一键清理 + VACUUM、大小可见、自动维护**(issue #67):`data.db` 会无限膨胀(有用户涨到 6 GB),因为默认**完整**保存请求/响应体且永不清理,而删日志也不还磁盘(SQLite 删行只把空闲页留在文件里,要 `VACUUM` 才缩)。本次新增:(1) Logs 页**「清理旧日志」/「清空全部」**会在删除后**自动 VACUUM**(文件才真正变小,而不只是释放内部页),Logs 页顶部显示**实时数据库大小**;(2) **按大小阈值自动维护**——设置 `logging.maxDbSizeMb` 后,每 6 小时维护会清理最旧日志并做节流 VACUUM(≤ 每天一次);(3) **仅对全新安装启用更省空间的默认值**——保留 30 天 + 只存出错请求体,已有安装迁移为显式 `off`/`full`,升级绝不删任何人的历史日志或改变捕获方式。新增端点:`GET /admin/api/db/size`、`POST /admin/api/db/vacuum`(带可用磁盘预检)、`DELETE /admin/api/logs?all=1|keepDays=<n>`。\n\n- **[fix]** **桌面端在 Intel(x86_64)macOS 上崩溃——admin UI 404**(issue #69):macOS 的 **x64** 安装包里打进了 **arm64** 的 `better-sqlite3` 原生模块,于是 Intel Mac 上 sidecar 加载失败(`incompatible architecture`),admin 数据库不可用,admin 路由从未注册,所有 `/admin/` 请求都 404。根因:sidecar 构建传的是 `npm_config_target_arch`/`_platform`,但 **prebuild-install**(better-sqlite3 下载预编译二进制的工具)只认 `npm_config_arch`/`npm_config_platform`——于是交叉构建(arm64 CI runner → x64 包)静默 fallback 到 runner 的架构,下载了错误架构的 prebuild。只有 macOS x64 受影响(Windows x64 / macOS arm64 都是同架构构建,fallback 碰巧正确)。两处修复:(1) `build-sidecar.mjs` 现在设置 `npm_config_arch`/`npm_config_platform`(保留 `target_*` 作为 node-gyp 源码编译兜底);(2) 新增**静态架构校验**(`scripts/detectNativeArch.mjs`,解析 Mach-O/PE/ELF 头),**每次**构建都跑——包括过去会跳过可执行 smoke test 的交叉构建——错误架构的模块现在会让 CI 失败,而不是发布给用户。\n\n- **[fix]** **`install.sh` / `install.ps1` 克隆了不存在的仓库**(issue #66 跟进):引导脚本的默认 `MIMO2CODEX_REPO` 一直是模板占位符 `your-org/mimo2codex`,导致 `curl … | bash` / `irm … | iex` 在 clone 步骤就直接失败。现已指向真实仓库。这两个 git-clone 引导脚本**并非**文档推荐的安装路径(推荐 `npm install -g mimo2codex` 或 Docker),所以多半不是 #66 的根因——但仍是个实打实的 bug,一并修掉。\n\n---\n\n## v0.5.24\n\n- **[new]** **上下文自动压缩**(issue #65 后续):长会话每轮都把完整历史重发,一旦接近模型上下文上限,上游要么 400、要么 prefill 太慢导致断流。mimo2codex 现在会估算输入大小,超过**随模型窗口缩放**的 token 阈值(`contextWindow × 阈值`,**阈值默认 0.8**——例如 1M 窗口约 800k、256k 窗口约 205k)时,**把较旧的中段对话**经同一个模型总结成一条紧凑摘要,保留开头的 system 消息和最近若干轮原文。切分点一定落在干净的 `user` 边界,绝不拆散 tool_call/tool_result 配对;图片 base64 绝不喂给摘要调用;稳定的前缀会被缓存,避免每轮重复总结。尽力而为:摘要调用失败则原样保留历史。默认**开**;可用 `MIMO2CODEX_AUTO_COMPACT`(0=关)、`MIMO2CODEX_AUTO_COMPACT_THRESHOLD`,或绝对值 `MIMO2CODEX_AUTO_COMPACT_AT_TOKENS`(用于「对外报的窗口大于真实可用上限」的上游)调整——同样暴露为 admin 设置 `codex.autoCompactEnabled` / `codex.autoCompactThreshold` / `codex.autoCompactAtTokens`。压缩在 keepalive 已激活时进行,摘要往返不会重新引入静默连接。\n\n- **[fix]** **请求体上限可配置,上传超大图片不再断连**(issue #65):请求体上限原本硬编码 16MB,超限会在上传途中 `destroy()` 套接字——Codex 看到的是「error sending request for url」而非干净错误。现在上限**默认 64MB 且可配置**(`MIMO2CODEX_MAX_REQUEST_BODY_MB`),超限会先把剩余 body 读完再返回客户端真正能收到的 **413**。\n\n- **[fix]** **大上下文 / 上传图片时的「stream disconnected before completion」**(issue #65):代理过去要先 `await` 到上游的第一个字节、才会给 Codex 发数据,而 Node 的 `fetch`(undici)默认把这段等待上限定在 300s。一旦 prefill 很慢(会话很大,或一张 base64 图片把请求体撑大),就可能超过这个窗口——而此时 Codex 盯着一条没有任何字节的连接,触发了**它自己**的空闲超时。三处协同修复:(1) 现在无论是否配置代理,都安装一个**全局 undici dispatcher**,应用可配置的上游超时(默认 **10 分钟**,`0` = 关闭)——`MIMO2CODEX_UPSTREAM_HEADERS_TIMEOUT_MS` / `MIMO2CODEX_UPSTREAM_BODY_TIMEOUT_MS`;(2) header/body **超时不再触发重试风暴**——直接以清晰的 504 快速失败,而不是把几 MB 的请求体重发最多 6 次;(3) 两条流式路径现在都在 `await` 上游**之前**就 **flush SSE 头并起 keepalive**,让 Codex 在长 prefill 期间持续收到 `: keepalive` 注释。代价:一旦 200 SSE 流已经发出,上游的终态错误(如上下文超限的 400)会以 SSE `error` 事件下发,而不是 JSON 4xx。启动 banner 现在会显示当前生效的超时,带图片的流式请求也会打日志(含大致体积)。\n\n---\n\n## v0.5.23\n\n- **[new]** **Windows:隔离的 Codex CLI 启动器**(PR #64,感谢 @Kaiyuan GONG):新增脚本 `scripts/codex-mimo-isolated.ps1`,让你用 **Codex CLI** 经 mimo2codex 接 MiMo,又**不动 Codex 桌面端常用的 `~/.codex`**。它用独立的 `CODEX_HOME=%USERPROFILE%\\.codex-mimo`,首次运行时在那里写入最小的 `auth.json` + `config.toml`,若 `:8788` 没在监听就自动拉起代理,打印本地 API/admin 地址,然后把其余参数原样转发给 `codex`。脚本不硬编码 API key —— 用 `mimo2codex init` 配置。完整说明见 `doc/codex-cli-isolated-windows.zh.md`。\n\n- **[fix]** **保存带重复 shortcut 的 generic provider 不再把后台搞挂(`/admin/` 404)**(issue #63):`providers.shortcut` 是 `UNIQUE` 列,但保存路径只对 provider `id` 去重、不查 `shortcut`。一个 generic 的 shortcut 撞上内置(`mimo` / `ds`)或撞上另一个 generic 时,保存能成功,却会在**下次启动**的 DB seeding 阶段抛 `UNIQUE constraint failed: providers.shortcut`,再被 cli.ts 的兜底逻辑变成「admin 禁用」—— 于是所有 `/admin/` 请求都 404。两层修复:(1) `writeSpecsToFile` 现在**保存时**就拦下冲突的 shortcut 并给出清晰提示(预置了内置的 shortcut);(2) DB seeding 按 shortcut 去重(`dedupeProvidersByShortcut`)——重复项**跳过并打 warn**,而不是让整个 seeding 崩掉,这样已经存了脏 `providers.json` 的用户下次启动也能恢复后台。\n\n- **[fix]** **generic provider 的 `enhanceErrorPreset: \"kimi\"` 不再被静默丢弃**:`kimi` 本就是合法的 `ProviderPresetId`(`src/providers/presets.ts`),但 providers.json 解析器此前只认 `sensenova` / `minimax`,导致 Kimi 的错误诊断预设永远存不下来。现在和其它预设一并承认。\n\n---\n\n## v0.5.22\n\n- **[new]** **多模态 Fallback —— 请求带图片时自动切换到 vision 模型**(PR #58,感谢 @Grub):当请求包含图片但当前模型看不了图(如 `mimo-v2.5-pro`)时,代理会把 upstream model 改写成支持 vision 的模型(默认 `mimo-v2.5`),避免图片被静默丢弃——Responses 和 Chat 两条路径都生效。**已收敛为 MiMo 专属、不影响其他模型**:vision 能力是 MiMo provider 的特性(`provider.supportsVision`),只有 MiMo 会触发 fallback,DeepSeek / generic 等请求完全不受影响。即便在 MiMo 下,若目标 vision 模型解析不出来也会跳过、保持原模型。开关和目标模型在 admin UI → Codex 接入页的「多模态 fallback」卡片;**默认关闭**——混用 vision / 非 vision 模型时再开。\n\n---\n\n## v0.5.21\n\n- **[fix]** **持续型 429 限流不再中断会话(v0.5.20 重试的补强)**:v0.5.20 加了代理侧的 429/5xx 重试,但默认预算(重试 3 次、约 3.5 秒)只能扛住亚秒级抖动。真正按分钟计的配额限流(`429 Too many requests / limitation`,且经常**不带 `Retry-After` 头**)仍会把预算耗尽,于是原始 429 被透传给 Codex,Codex 再耗尽自己的重试,又报出「exceeded retry limit, last status: 429」。现在默认重试预算放大为:**重试 6 次、指数退避封顶 12 秒(总计约 28 秒)**,让几秒到几十秒的配额限流在放弃前自行解除。仍可被取消、仍尊重上游的 `Retry-After`、仍可通过 `MIMO2CODEX_UPSTREAM_MAX_RETRIES`(上限提到 12)/ `MIMO2CODEX_UPSTREAM_RETRY_BASE_MS` 调整。代价:限流期间单个请求最长会等约 28 秒才失败,而不是原来的约 3.5 秒。\n\n- **[new]** **面向长期运行部署的日志存储控制**:**解决什么问题** —— 以前每次请求/响应都会被完整记录并永久保存,所以在常驻部署(Docker、团队/多人共享)里 `data.db` 会无上限地膨胀:占磁盘、拖慢备份和日志页,还会把完整对话内容留存得比你出于隐私考虑想要的更久。现在有两个旋钮来封顶。`MIMO2CODEX_LOG_BODY_MODE=full|errors-only|off`(日志页 →「存储设置」也能设)可保留全部调试细节、只保留失败请求的 body(够排障、体积小很多)、或完全关闭 body 记录。`MIMO2CODEX_LOG_RETENTION_DAYS=<n>`(同一处)会自动删除超过 `n` 天的旧记录——启动时及运行期间每 6 小时各跑一次;设为 `0` 关闭清理。典型用法:小 VPS / 团队代理设 `errors-only` + `30`,数据库就稳定在可控大小,而不会几个月下来越滚越大。设置存在 DB 里(改完免重启),且 env/CLI 显式设置时优先。\n\n---\n\n## v0.5.20\n\n- **[fix]** **上游 429 / 5xx 瞬时错误不再中断会话(「exceeded retry limit, last status: 429」)**:以前代理会把限流直接透传回 Codex,Codex 用完自己的 `request_max_retries` 就放弃,用户只能手动点「继续」。现在 mimo2codex 自己兜底:`postUpstream` 对 `429` 和 `500/502/503/504`(以及网络连接失败)做指数退避 + 抖动重试,并遵循上游的 `Retry-After` 头(上限 10 秒,避免 Codex 等到超时)。重试可被中断——退避期间 Codex 取消会立即停止。非可重试错误(400/401/403 等)仍快速失败。可通过 `MIMO2CODEX_UPSTREAM_MAX_RETRIES`(默认 3)和 `MIMO2CODEX_UPSTREAM_RETRY_BASE_MS`(默认 500)调整。\n\n- **[fix]** **「写入文件并启用」不再抹掉你 config.toml 里的其它设置**:以前应用一个模型会用 `model` + `model_provider` + `[model_providers.<key>]` 整体覆盖 `~/.codex/config.toml`,悄悄丢掉用户其余配置——`[projects]` 信任级别、`[mcp_servers]`、`[windows] sandbox`、`model_reasoning_effort`、`[notice.model_migrations]`、注释统统消失。现在切换模型走**外科式合并**(`src/codex/tomlMerge.ts`):只重写我们管理的四个键(`model`、`model_provider`、`model_context_window`、`model_max_output_tokens`)和我们自己的 `[model_providers.<key>]` 表块,其它字节原样保留。全新安装(没有 config.toml)仍然写入带备选模型注释的首次引导片段。每次写入前依旧先备份,历史配置完全可追溯复原。\n- **[new]** **会话管理——跨 provider 浏览所有 Codex 会话并可迁移**(左侧新增 Tab)。Codex 桌面端把每个会话存在 `~/.codex/state_<N>.sqlite`(`threads` 表)里,并打上一个 `model_provider` 标记、按它筛选会话列表——这正是「在 mimo2codex 里切 provider 后 ds 和 mimo 的会话相互看不见」的原因。新 Tab 只读读取该数据库,把所有会话按 **provider → 项目(cwd)→ 会话** 分组展示,与当前激活的 provider 无关。展示细节:去掉项目路径里的 Windows 扩展前缀 `\\\\?\\`(避免同一项目被拆成两组)、修正会话时间显示(Codex 以秒存储)、过长标题中间省略只占一行、默认只展开第一个 provider 分组。由于一个会话无法在多个 provider 间「共享」(一行只有一个 provider),因此提供**迁移**:「迁移到…」会改写该会话的 `model_provider`(数据库 + rollout 文件首行 `session_meta`),重启 Codex 后即归到所选 provider 下。**批量迁移**:勾选复选框(选择可跨所有项目/provider 表格),点「批量迁移选中」即可把它们一次性迁到同一个 provider。安全措施:改动前先把整个 state 数据库(含 `-wal`/`-shm`)和 rollout 快照到 `~/.codex/.m2c-backups/sessions/<ts>/`,并在 **Codex 桌面端仍持有数据库锁时拒绝迁移**(返回 `409 codex_running`),避免打开状态下损坏会话。仅本地模式可用(服务端部署无法访问操作者的 `~/.codex`)。⚠️ 这会改写 Codex 私有、带版本号的状态——若未来 Codex 改了表结构,该 Tab 会降级为「不可用」而非报错崩溃。\n- **[new]** **预览会话聊天记录 + 导出 Markdown**:每个会话行都有「预览」按钮,点开抽屉以 Codex 风格渲染对话——用户/助手消息、推理、工具调用(shell 命令、`apply_patch` 等)及其输出(代码块)。工具/shell 调用**默认折叠**(标题显示工具名 + 命令首行),突出文本对话,点击展开。rollout JSONL 在服务端解析(`src/codex/transcript.ts`);丢弃注入的 developer/权限指令块、把环境/指令上下文折叠收起,让真实对话更突出。抽屉里的「导出 Markdown」按钮可把整段记录下载为 `.md`。只读,仅本地模式。\n- **[opt]** **模型改写日志:现在默认静默 + 顶栏快速开关**(基于 [PR #49](https://github.com/7as0nch/mimo2codex/pull/49),感谢 @oxsean)。当 Codex 发送与 provider 目录不一致的 model id(例如 `gpt-5.4` → `mimo-v2.5-pro`),代理每个请求都会打一条「model fallback applied」INFO 日志。现在**默认静默**,并在管理后台顶栏「更多」菜单加了快速开关「静默模型改写日志」可运行时切换、无需重启。解析顺序为 env > 管理设置 > 静默(`resolveSilentRewrite`):环境变量 `MIMO2CODEX_SILENT_REWRITE=1`/`true`(或 `0`/`false`)仍优先,且设置后 UI 开关禁用。\n- **[new]** **顶栏「当前状态」实时指示器**:当前状态是用户经常要看的,所以挪到顶栏、和其它状态项放在一起,不再占用 Codex 接入页的卡片。它是一个名为「当前状态」的紧凑跑马灯,每 3 秒轮播一项——codex 目录、auth.json 归属、config.toml provider/model、运行时覆盖;运行时覆盖生效时变蓝。点击弹出完整状态(codex 目录及编辑、auth.json、config.toml、运行时覆盖、导出/导入)——宽屏用气泡、窄屏用弹窗。每 30 秒自动刷新。\n- **[opt]** **Codex 接入页精简为直接切模型**:「当前状态」卡片挪到顶栏(见上),多余的快速切换栏也去掉了,所以 Codex 接入页现在就剩标题 + 切模型表格(「可启用模型」/「写入文件并启用」)。两段「工作原理」说明和导语此前已折进可折叠的「先决条件」面板(默认折叠)。\n- **[new]** **应用配置后顺手重启 Codex**:切换配置要重启 Codex 才生效,所以应用配置(「写入文件并启用」)后会弹出「重启 Codex 让配置生效?」对话框(立即重启 / 暂不)。它会强制关闭正在运行的 Codex 桌面端并重新启动(没在运行就直接启动),不用再自己去找 Codex 窗口关掉重开。Windows:只针对桌面端自身的 `Codex.exe` 进程(按可执行文件路径匹配,因此不会误杀 VS Code 扩展的 `codex` 引擎),并通过 Store 的 AppUserModelID 重新拉起。macOS:尽力而为,`pkill` + `open -a Codex`。仅本地模式可用;不支持的平台会提示你手动重启。\n- **[new]** **桌面端:启动时询问是否打开 Codex**:启动 mimo2codex 桌面端时,如果检测到 Codex 桌面端没在运行,会弹窗询问是否现在打开(「打开 Codex」/「暂不」),确认后帮你启动。检测只针对真正的 Codex 桌面端进程(按可执行文件路径);启动走 Windows 的 Store AppUserModelID / macOS 的 `open -a Codex`。Codex 已在运行、首次安装设置、以及开机自启动这几种情况会跳过(避免开机时打扰)。检测与启动复用应用内「重启 Codex」的同一套底层逻辑。\n- **[opt]** **桌面端:双击托盘图标直接打开控制台**:以前双击系统托盘图标没有反应(只有右键能出菜单)。现在双击直接在应用内打开控制台——一步到位,无需右键再点菜单。菜单仍走右键。(退出确认弹窗本就把「Quit」放在「Cancel」左侧、并以 Cancel 为安全默认,所以这一项原本已符合要求。)\n- **[opt]** **配置备份迁入独立的 `~/.codex/.m2c-backups/` 目录**:每次切换产生的 `auth.json.bak.*` / `config.toml.bak.*` 快照过去直接堆在 `~/.codex/` 顶层,污染 Codex 应用和 CLI 也要读的目录列表。现在统一放进隐藏子目录 `.m2c-backups/`(仍在 codex 目录内,恢复逻辑不变)。已有的旧版同级备份会在首次读取时自动迁移过去。\n---\n\n## v0.5.6\n\n- **[fix]** **长对话 400 \"unexpected end of data: line 1 column 46 (char 45)\"**:上游 SSE 流在某次工具调用中途结束(输出 token 用尽、网络中断、客户端取消、思考预算用光……)时,Codex 会把那条**被截断的 `tool_call.arguments`** 当成完整 JSON 持久化进会话历史。从此该会话每次新请求都把这条坏 tool call 原样回放给严格上游(MiMo / DeepSeek / SenseNova……),上游在校验阶段重新 `json.loads` 这个字符串字段时在截断点炸出 400 —— 会话看起来\"用着用着就坏了\",必须新建对话才能恢复。本版本加了**三层防御**:(1) 流式回传到 Codex 之前(`streamToSse.finalizeToolCalls`),先校验累加好的 `argsBuffer` 能否 `JSON.parse`,不能就替换为 `\"{}\"` 并 WARN 一条带因果的日志(长度截断 vs. 其它),让坏值根本不会写进 Codex 历史;(2) 非流式 `respToResponses` 同样设防;(3) 出站到上游之前(`reqToChat` 的 `function_call` 分支),对历史 `arguments` 再校验一次失败就改写 `\"{}\"` —— 这让被旧版代理污染的存量会话立刻恢复正常。配对的 `tool` 消息保持原样、`removeOrphanToolMessages` / `ensureToolCallsHaveOutputs` 不变。万一上游真返回了这种 400,`contextOverflow.detectMalformedJsonField` 会把原始报错改写成双语恢复提示(\"升级到新版 mimo2codex / 或新建 codex 会话\")而不是把那段晦涩报文丢给用户。\n- **[opt]** **桌面端 Settings 支持多 provider key + 自定义 base URL**([PR #43](https://github.com/7as0nch/mimo2codex/pull/43) — A1,感谢 @starlsd93-sudo)。原先的 Settings 窗只能选一个 provider 填一个 key,同时养 MiMo + DeepSeek 的用户必须切换 provider 多次保存。新版改成把三个 provider(MiMo / DeepSeek / Generic)的 API Key + 可选 Base URL 同框显示,Generic 还多了 `GENERIC_DEFAULT_MODEL` 字段。只要填了任意一个 provider 的 key 就能完成首次安装;Base URL 留空走默认(MiMo / DeepSeek 内置 host),需要走 TP 订阅或企业 tenant 才填。MiMo Base URL 下面专门加了一行提示,说明代理会按 `sk-*` / `tp-*` 前缀自动选择主机,避免新手粘错地址被 401。打开路径:系统托盘图标 → Settings…,或顶部菜单「文件 → 设置… (Ctrl+, / Cmd+,)」。界面长这样:\n\n ![桌面端配置截图](../images/desktop-setttings.png)\n- **[new]** **桌面端首次启动可一键导入老 CLI 配置**([PR #43](https://github.com/7as0nch/mimo2codex/pull/43) — A3,感谢 @starlsd93-sudo)。如果你机器上同时有 `~/.mimo2codex/.env`(来自 `npm install -g mimo2codex` 安装),桌面端 Settings 会自动检测并在欢迎页弹一个「Import all into desktop」按钮,把 API key / base URL / proxy 等变量一次性复制到桌面端的 AppData 配置里。已经在桌面端填过的字段**不会**被覆盖(会作为\"skipped\"提示给你);导入后这些值会自动填到上面的多 provider 表格里,你 review 一下点 Save & Restart 即可。**对迁移过数据目录的用户友好**:如果你之前通过 admin UI 把 CLI 的 dataDir 迁移到了别处(比如 `D:\\workspace\\.mimo2codex`),检测会读 `~/.mimo2codex-pointer.json` 指针文件、直接定位到真正的活动 .env,不会去导入已经废弃的 `~/.mimo2codex/.env` 残留。\n- **[new]** **桌面端应用菜单中文化 + 左上角直接调起设置窗**([PR #43](https://github.com/7as0nch/mimo2codex/pull/43) — A2,感谢 @starlsd93-sudo)。之前 Windows / Linux 桌面端顶部菜单条全是 Electron 默认的英文「File / Edit / View / Window / Help」,没有「设置」入口,用户只能去系统托盘点 Settings 太隐蔽。本版本三平台统一了一套中文菜单(文件 / 编辑 / 视图 / 窗口 / 帮助),文件菜单第一项就是「设置… (Ctrl+, / Cmd+,)」直接调起 Electron 设置窗。同时 admin web UI 顶栏也有冗余按钮「桌面端设置」(仅在桌面壳里显示,通过 `/admin/api/desktop/sentinel` 探测),点了走 file-based 信号通路(`<dataDir>/.desktop-signal.json` → Electron main `fs.watch`)打开同一个设置窗,喜欢哪个用哪个。\n- **[new]** **Windows 应用图标升级**([PR #43](https://github.com/7as0nch/mimo2codex/pull/43) — B,感谢 @starlsd93-sudo)。社区贡献了 新版图标,本版本接入了橙色版作为 `package/win/icon.ico`;完整 8 张(橙紫两色 × 64/128/256/512 四个尺寸)全部归档到 `package/brand/contributed-by-starlsd93/`,并附 provenance 说明。macOS `.icns` 这次未动 —— 投稿没附 `.icns` / `tray-Template*.png`,等后续单独 PR 补齐。\n\n---\n\n## v0.5.5\n\n- **[new]** **Windows / macOS 桌面端正式发布(不再 beta)**:经过 v0.4.8 起的 beta 验证,桌面端转 GA。后台跑 mimo2codex、系统托盘 / 顶栏图标管理、admin UI 一键打开、自更新链路全部就绪。命令行版(`npm install -g mimo2codex`)依然不变,两者可共存。下载入口:<https://mimodoc.chengj.online/download>。\n- **[fix]** **`tool_search` 工具支持([issue #41](https://github.com/7as0nch/mimo2codex/issues/41))**:Codex Desktop 的延迟工具发现工具之前被当未知类型丢,模型发现不了延迟加载的工具,还会刷一串 orphan 警告。现在翻成普通 function 工具,恢复正常。\n- **[fix]** **Connector 插件不再报 \"unsupported call\"([issue #39](https://github.com/7as0nch/mimo2codex/issues/39))**:GitHub / Canva / HeyGen / Dropbox / Gmail / Google Drive 这些 connector 依赖 OpenAI 后端的 MCP 运行时,第三方代理实现不了。现在 mimo2codex 把这个情况告诉上游模型,模型会主动建议用 shell + 命令行替代(比如 GitHub 用 `gh`)。\n- **[fix]** **运行时覆盖后的多模态判断走真实上游模型**:之前客户端发 `mimo-v2.5-pro`、admin 运行时把它映射到 `mimo-v2.5`(支持识图)时,mimo2codex 还是按客户端那个不支持识图的 id 判断、把图片提前剥掉了。修复后视觉 / 联网能力检查跟着**实际发往上游的模型 id** 走 —— 运行时改模型不用重启,识图、联网立刻按新模型的能力生效。\n\n---\n\n## (v0.4.10 — 2026-05-24)\n\n- **[fix]** **Codex Desktop namespace 工具报 `unsupported call`([PR #34](https://github.com/7as0nch/mimo2codex/pull/34),[issue #33](https://github.com/7as0nch/mimo2codex/issues/33),感谢 @meesii)**:Codex Desktop 调用 namespace 包装的工具(如 `multi_agent_v1` 下的 `spawn_agent`)走 mimo2codex 代理时报 `unsupported call` —— 客户端依赖每个 `function_call` output item 上的 `namespace` 字段来路由到对应的本地 handler,而代理之前在翻译响应时把这个字段丢了。修复:从请求的 `tools` 数组抽出 `toolName → namespaceName` 映射,在非流式(`respToResponses`)和流式(`streamToSse`)两条响应路径上按需附加 `namespace` 字段。不带 namespace 工具的请求(MiMo / DeepSeek / 普通 Codex CLI 等)行为字节级保持一致。\n\n---\n\n## (v0.4.8 — 2026-05-23)\n\n- **[new]** **桌面预览(beta)—— Windows 系统托盘 / macOS 顶栏桌面端**:可选的 Electron 壳子,后台跑 mimo2codex,不用一直挂着终端窗口。首次启动会有个小设置窗让你选 provider 并粘贴 API Key;之后从系统托盘 / 顶栏图标一键打开内嵌的 admin UI(窗内或默认浏览器都行)。sidecar 生命周期(启动 / 停止 / 改设置时重启)完全托管,菜单 **Quit** 干净退出。提供可选的\"开机自启\"开关。命令行版(`npm install -g mimo2codex`)完全不变,两者可在同一台机器共存 —— 桌面版作为独立的 `v*-desktop` 制品发布。这是 **beta** —— 安装、启动、sidecar、自更新链路还需要真实环境的里程验证,遇到任何卡点请反馈。下载和安装指引:<https://mimodoc.chengj.online/download>。\n- **[fix]** **CodeX Desktop string-input 被误判为 probe([PR #31](https://github.com/7as0nch/mimo2codex/pull/31),感谢 @85339098-afk)**:OpenAI Responses API 规范允许 `input` 是 string 或 items 数组两种形式;`handleResponses` 里的 probe 形状检测之前只认数组形式,导致 `{model, input: \"write hello world\"}` 这种 CodeX Desktop 的自然请求被短路成 synthetic 200 + 空 `output: []` —— 看起来像\"模型啥也没说\",**完全没有错误信号**。现在 string `input` 非空也会正确通过。把判定逻辑抽成导出的 `isResponsesProbe()` 函数,配套单元测试套件(`test/server.probe.test.ts`),后续不会因为重构再次被回归。\n\n---\n\n## (v0.4.6 — 2026-05-23)\n\n- **[fix]** **DeepSeek V4 400 `Invalid assistant message: content or tool_calls must be set` ([issue #29](https://github.com/7as0nch/mimo2codex/issues/29))**:当某个 assistant 回合由 reasoning + function_call 拼成、且没有可见 text 时(典型场景:Codex Chrome 插件),翻译产物形状是 `{role:\"assistant\", content: null, tool_calls:[…], reasoning_content:\"…\"}`。DeepSeek 的严格校验把显式 `null` 当成\"两个字段都没有\"于是 400。OpenAI Chat Completions 规范规定 `tool_calls` 存在时 `content` 是可选的,现在直接省略该字段而不是发 `null`。reasoning-only 兜底回合(少见:无 text 无 tools)回落到 `content: \"\"` 以满足\"content 或 tool_calls 必须存在\"。\n- **[fix]** **Windows / pnpm 全局安装 / Node 22 启动崩溃 ([issue #30](https://github.com/7as0nch/mimo2codex/issues/30))**:admin sqlite 启动打开失败时 `mimo2codex` 不再退出。典型原因:pnpm 全局安装布局没拿到对应 Node ABI(`node-v127-win32-x64`)的 `better-sqlite3` prebuilt 二进制,于是 `new Database()` 报 `Could not locate the bindings file`。现在改成打印一段多行告警(包含原始错误信息和针对 Windows / pnpm 的修复建议)然后以 admin 关闭模式继续启动。代理核心(Codex ↔ Chat-Completions 翻译)本来就不依赖 DB —— 这次让命中 binding 缺失的安装方式也能开箱可用。\n\n---\n\n## v0.4.5 — 2026-05-22\n\n- **[new]** **桌面端(Windows 系统托盘 / macOS 顶栏菜单)**:可选的桌面壳子,后台跑 mimo2codex,不再依赖终端窗口常开。首次启动有设置窗让你填 provider + API Key;托盘菜单一键打开 admin UI、查看 sidecar 日志、重启;可选「开机自启」复选框。命令行版安装(`npm install -g mimo2codex`)完全不变,桌面端走单独的 `v*-desktop` GitHub release 分发。下载与安装指南:<https://mimodoc.chengj.online/download>。\n- **[opt]** **桌面端 Mac 包改成 `.zip`(原来是 `.dmg`)**:GitHub Actions runner 上的 hdiutil(不论 macos-14 / macos-15)+ dmg 格式(UDZO / ULFO)多次试下来,生成的 `.dmg` SHA 校验过得了,但到真机上 Finder 挂不上(「此电脑不能读取你连接的磁盘」 / 「错误代码 3840」)。`.zip` 简单粗暴、哪里都能用——Finder 双击解压出 `mimo2codex.app`,拖到 `/Applications` 即可。下载页会自动识别格式,以后真要做签名 `.dmg` 再把那个 target 加回来。SHA256 校验、首次启动 `xattr -cr` 清 quarantine 这些都不变。\n- **[new]** **代理的支持**:mimo2codex 出站请求支持 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` 环境变量,行为与 `curl` / `git` 一致。Docker 部署在 `docker-compose.yml` 的 `environment:` 段声明,本地在 shell / `.env` 里 `export` 都可以。启动 banner 多一行 `proxy:` 回显当前生效的代理,env 是否被识别一眼能看到。`MIMO2CODEX_NO_PROXY_FROM_ENV=1` 可让 mimo2codex 无视代理 env(适合 shell 里为 `curl` / `git` 常驻了代理、但不想让 mimo2codex 跟着走的场景)。\n- **[opt]** 上游连接失败的日志补上 underlying cause 的 `code` 和 `message`(如 `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT`),同样的细节注入到 502 的 `UpstreamError.message`,代理端口写错、DNS 解析失败、超时这些情况一眼能分辨。\n- **[doc]** proxy-faq §1 改写:明确\"系统代理 ≠ 进程代理\"——Clash / Surge 等 UI 里点的\"系统代理\"开关不会自动导出 env;新增 🩺 自检 callout 让用户从启动 banner 一眼看出当前代理状态。§5 新增 `ECONNREFUSED <代理-host>:<代理-port>` 一行(含 Docker 里 `127.0.0.1` 的坑)。\n\n---\n\n## v0.4.4 — 2026-05-21\n\n- **[new]** **官网新增 AI 文档助手 ([mimodoc.chengj.online](https://mimodoc.chengj.online/))**:右下角机器人浮球 —— 常见配置问题(第一次怎么配、为什么 502、通用 provider 怎么接)点开就能问。助手在项目 `doc/*.md` 上跑 tool calling agent 循环检索文档,流式渲染 markdown 回答。思考过程展示在答案上方的可折叠面板里(开始出答案时自动收拢)。接通了 MiMo V2.5 多模态 —— 粘贴 / 拖拽 / 点回形针上传配置截图,AI 直接看图诊断。聊天历史按匿名 client_id 存 localStorage,drawer 头部有「清空对话」按钮。\n\n---\n\n## v0.4.2 — 2026-05-21\n\n- **[new]** **Admin UI 一键迁移数据目录**:右上 ⚙️ 设置 → 本地数据目录 → 迁移到新目录。选目标路径 → 预览将复制的文件数和大小 → 进度条流式复制 SQLite + `.env` + `providers.json`。迁移期间服务进入维护模式(503),原目录保留待用户验证后手动清理;失败自动回滚(清空目标已写入的部分 + 重新打开原目录)。完成后顶部出现常驻提示 banner 提醒用户重启生效。解析优先级变为 CLI > env > 指针文件(`~/.mimo2codex-pointer.json`)> 默认 `~/.mimo2codex/`。\n- **[doc]** **官方文档站上线 [mimodoc.chengj.online](https://mimodoc.chengj.online/)**:完整文档与教程的统一入口。admin 后台 footer 已加 📖 直达链接和悬浮提示,方便用户随时查阅。\n- **[fix]** **本地代理模式隐藏 server-only Codex 入口**:「Codex 接入」页的「导出到本地」/「从本地导入」按钮和 `History` tab 只在 Docker 鉴权部署模式(`authMode=on`)下有意义 —— 多运维之间互传渲染好的 `auth.json` + `config.toml` 配置 bundle。本地单机模式下 mimo2codex 直接把这些文件写到 `~/.codex/`,按钮属于噪音。现已按 `authMode === \"on\"` 条件渲染。\n\n---\n\n## (v0.3.0)\n\n- **[new]** **Docker 鉴权部署正式发布(GA)**:v0.2.17 作为预览验证后,**Docker 鉴权模式**作为稳定特性发布 —— 用户注册 / 登录系统、每用户独立的 m2c 代理 API key、BYOK(自带上游 key)、Gitee / GitHub OAuth、Codex 客户端配置 bundle 下载。把 mimo2codex 部署到 Docker / 内网 / 小圈子时不再泄漏上游 key。本地单机运行(`authMode` 默认 `off`)完全不受影响。完整教程:[doc/auth-deployment.zh.md](./auth-deployment.zh.md) —— 含 Docker compose、首次启动 bootstrap、OAuth 配置、故障排查。\n- **[fix]** **工具列表去重防御([issue #20](https://github.com/7as0nch/mimo2codex/issues/20))**:新版 Codex CLI / Desktop / DeX 会发出重名工具(典型样例:顶层 `_fetch` 函数 + namespace 展平后再来一个 `_fetch`),导致 MiMo 上游 400 `\"tools contains duplicate names: _fetch\"`。reqToChat 在工具合并后按 `function.name` / 内置 `type` 二维 keep-first 去重;重复时记 `WARN` 日志告知用户这是客户端 bug。\n- **[new]** **思考模式混合历史防御**:检测到会话历史里有 assistant 消息缺 `reasoning_content`(典型场景:用户在同会话切换\"默认开启思考\"开关),自动给这些历史消息回填占位符 `\"(this turn ran without thinking mode)\"`,**思考保留为开**,避免上游 MiMo / DeepSeek 400 `\"reasoning_content must be passed back\"`。配套 INFO 日志。\n- **[opt]** 控制台日志降噪:`WARN client model rewritten on the way upstream` → `INFO model fallback applied — client sent unknown model id, request continues with provider default`。降级到 INFO + 改文案,不再让人误以为是错误(实际是 graceful fallback,请求正常完成)。\n- **[doc]** 新增双语 [代理 / 网络 FAQ](./proxy-faq.zh.md):mac & win 各自代理设置、502 / ECONNREFUSED / DNS / TLS-MITM 等错误码自查表、`gpt-5.4` placeholder 来源解释、思考模式混合历史现象说明。\n- **[doc]** 新增双语 [版本日志](./tag-log.zh.md):把 README 顶部 `<details>` changelog 块迁出,按 tag 倒序、`[new]/[fix]/[opt]/[doc]` 分类,含全部 44 个历史 tag。\n\n---\n\n## v0.2.17 — 2026-05-19\n\n- **[new]** **Docker 鉴权模式预览版**:用户可注册登录,生成 mimo2codex 专属代理 API 的 key(m2c key)。在 Docker / 内网 / 小圈子部署时,把 `OPENAI_API_KEY` 字段的 `mimo2codex-local` 替换成生成的 m2c key,避免上游 key 被滥用。本地单机运行(`authMode` 默认 `off`)不受影响。\n\n> ⚠️ **v0.2.17 是预览版本**,作为 Docker 鉴权部署的首发试用。**v0.3.0 是正式 GA 版本**,请生产环境使用 v0.3.0+。详见 [鉴权与部署](./auth-deployment.zh.md)。\n\n## v0.2.16 — 2026-05-19\n\n- **[opt]** Admin UI 紧凑化:内容更紧凑、删掉无用展示,减少视觉噪声。\n\n## v0.2.15 — 2026-05-18\n\n> 含 beta 系列 `v0.2.15-beta.0/1/2`(SenseNova 模型适配 + 思考微调 + Kimi 适配)。\n\n- **[new]** **思考模式 admin UI 化**:「Codex 启用」页新增「思考模式」全局卡片。\n - **思考 开/关**:写入 settings 持久化,不用每次重启加 `--disable-thinking`;改完立即对新请求生效(无需重启)。关闭后所有 provider 都不思考(mimo / deepseek 发 `thinking:{type:\"disabled\"}`,sensenova / 其他 generic 发 `reasoning_effort:\"none\"`)。\n - **强制高强度思考**:Codex 没在请求里传 `reasoning.effort` 时,兜底注 `reasoning_effort:\"high\"`。默认关,开启时显示明显副作用警告(账单可能上涨)。CLI `--disable-thinking` 仍优先。\n- **[new]** **Kimi (Moonshot) preset**:admin UI 输入 `https://api.moonshot.cn/v1`(或 `moonshot.ai`)自动识别为 Kimi,套上 `dropReasoningEffort: true`,避免 Kimi 不识别 `reasoning_effort` 时 400。覆盖 `kimi-k2.6` / `kimi-k2.5` / `kimi-k2-thinking` / `kimi-k2-thinking-turbo` / `moonshot-v1-{8k,32k,128k}`。详见 [doc/kimi.zh.md](./kimi.zh.md)。\n- **[new]** **Docker 部署**:新增 `Dockerfile`(多阶段 alpine 构建,~70MB)+ `.dockerignore` + GitHub Actions workflow(**自动构建 `linux/amd64 / linux/arm64` 双架构镜像,推送到 ghcr.io/7as0nch/mimo2codex**);`docker-compose.yml` 一键起,数据目录挂在本地 `./.mimo2codex/`(sqlite + providers.json + admin UI 配置跨容器重建持久化);env 支持 `.env` 文件挂载或 `-e` / `environment:` 直传 key。mac / Windows / Linux 全平台通吃。基于 [#15](https://github.com/7as0nch/mimo2codex/pull/15)(感谢 @hufang360)。\n- **[new]** **SenseNova (商汤) 模型适配**(来自 beta.0/1)。\n\n## v0.2.14 — 2026-05-15\n\n- **[fix]** `.env` 配置 example 文件新增注释,避免初次配置时漏看字段含义。\n\n## v0.2.13 / v0.2.12 / v0.2.11 / v0.2.10 — 2026-05-15\n\n- **[new]** 版本更新检查(check 上游 npm registry 是否有新版)。多次迭代修补(连发 4 个 patch)以打磨网络容错、缓存策略与提示文案。\n\n## v0.2.9 — 2026-05-15\n\n- **[new]** 新增 `.env` 通用配置方案:`mimo2codex init` 后填入 key 即可,跨平台一份配置。\n\n## v0.2.8 — 2026-05-15\n\n> MiniMax / 严格 OpenAI 兼容上游接入修补合集(PR #12)。\n\n- **[fix]** `reqToChat`:不再发 `strict: null` 到上游(MiMo Pydantic schema 拒绝 null,会 400 `\"Input should be a valid boolean\"`)。修 [issue #11](https://github.com/7as0nch/mimo2codex/issues/11)。\n- **[fix]** `minimax-compat`:一键预设不再默认删 `stream_options` / `parallel_tool_calls`。\n- **[feat]** `minimax-compat`:响应侧 inline `<think>...</think>` 切到 `reasoning_content`。\n- **[feat]** webui providers 表单加「严格 OpenAI 兼容」开关组(minimaxCompat 等)。\n- **[feat]** generic provider 接 MiniMax 兼容补丁([issue #7](https://github.com/7as0nch/mimo2codex/issues/7))。\n\n## v0.2.7 — 2026-05-15\n\n- **[new]** 全新 webui(**Ant Design 5** 重写):深浅主题、中英双语 i18n、视口锁定 sider + footer 固定布局、Token 趋势平滑曲线。\n- **[new]** 新增 `.env.example` + **Bash / PowerShell 一行命令注入 key** 脚本(`.env` 已 gitignore)。\n- **[new]** 「Codex 启用」每行加 **⚡探测** 按钮:发最小 ping 验证 key / baseUrl / 模型 id 是否通。\n- **[new]** Token 趋势图融合**缓存命中柱**(绿柱 = 命中、灰柱 = 提示总量)+ 窗口聚合命中率。\n- **[new]** 支持**修改 Codex 目录**:settings 配置或 `CODEX_HOME` 环境变量。\n\n> 含 beta 系列 `v0.2.6-beta.1/2/3`:MiMo 全部模型 `contextWindow` 128K → 1M(对齐 DeepSeek,解 Codex 256K 配置 400);webui 重构 PR #1~#6(antd 5 引入、Setup/Models/CodexEnable 主题化、Logs 表格化、Dashboard 缓存命中、视口高度锁定等)。\n\n## v0.2.6 — 2026-05-14\n\n- **[new]** **「Codex 启用」页面**(**替代 cc-switch**):admin webui 一键写入 `~/.codex/auth.json` + `config.toml`。\n- **[new]** **运行时覆盖**:无需重启 Codex 即可换上游 model。\n- **[new]** Codex 备份永久保留 + 半残 pair 恢复 + 手动删除:原文件自动备份,**首次覆盖外部 auth.json 时的备份永久保留**——切换 100 次模型也找得回原始配置。\n- **[fix]** `removeOrphanToolMessages`:DeepSeek V4 session 中断时丢弃孤儿 tool 消息,避免 400 `\"Messages with role 'tool' must be a response to...\"`(修 [PR #10](https://github.com/7as0nch/mimo2codex/pull/10) / [issue #8](https://github.com/7as0nch/mimo2codex/issues/8))。\n- 详见 [doc/codex-enable.zh.md](./codex-enable.zh.md)。\n\n## v0.2.5 — 2026-05-14\n\n> 含 beta `v0.2.5-beta.1`。\n\n- **[feat]** MiMo / DeepSeek 文档对齐。\n- **[fix]** DeepSeek `tool_calls` 400 修复。\n- **[feat]** Friendly context overflow 错误提示:上游 context 超限时给可读的 `/compact` 引导,而不是裸 400。\n- **[feat]** Beta 发版流程(`npm run release:beta`)。\n\n## v0.2.4 — 2026-05-13\n\n- **[test]** 补 `selectProvider` 两段优先级回归测试。\n- **[doc]** 同步通用 provider 路由优先级文档。\n\n## v0.2.3 — 2026-05-13\n\n- **[fix]** 根据[小米官方公告](https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/passing-back-reasoning_content)修复 MiMo `reasoning_content` 回传问题。\n\n## v0.2.2 — 2026-05-13\n\n- **[fix]** 工作流(GitHub Actions)修补。\n\n## v0.2.1 — 2026-05-12\n\n- **[new]** 添加 `mimoskill` 支持:图像生成、OCR 等能力(用 Python stdlib,无 pip 依赖)。\n\n## v0.1.16 ~ v0.1.19 — 2026-05-12\n\n- **[new]** `mimoskill` 早期迭代(v0.1.17 ~ v0.1.19):图像生成、OCR、宠物生成功能逐步打磨。\n- **[new]** v0.1.16:新增其他模型支持,默认 mimo / deepseek,同时支持 Responses API 原生对接(`wireApi=\"responses\"`)。\n\n## v0.1.15 — 2026-05-12\n\n- **[fix]** 注册 `mimo-v2.5` 视觉模型为内置目录,避免静默降级到 `mimo-v2.5-pro`(导致用户传图时被丢弃)。\n\n## v0.1.1 ~ v0.1.14 — 2026-05-09 ~ 2026-05-10\n\n项目早期迭代版本(v0.1.1 = 2026-05-09 首次公开发布)。这一阶段没有详细 changelog,主要工作:\n\n- 搭建 mimo / deepseek 双 provider 基础。\n- Responses API ↔ Chat Completions 双向翻译核心(`reqToChat` / `respToResponses` / `streamToSse`)。\n- 第一版 webui(Token / Logs / Settings 基础页)。\n- SQLite 持久化(聊天日志、模型目录、runtime settings)。\n- CLI 工具:`mimo2codex init` / `update` / `print-config` / `print-cc-switch`。\n\n完整 commit 流水可通过 `git log v0.1.1..v0.1.14 --oneline` 查看。\n\n---\n\n## 发版流程\n\n发版命令在 [package.json](../package.json) 里:\n\n```bash\nnpm run release:patch # x.y.Z+1\nnpm run release:minor # x.Y+1.0\nnpm run release:major # X+1.0.0\nnpm run release:beta # 预发布\n```\n\n完整 runbook 见 [PUBLISHING.md](../PUBLISHING.md)(仓库根目录)。\n"}}, "doc/docker.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595187.1088479, "n": 10369, "h": "7e33273340533add", "c": "# Docker deployment\n\nmimo2codex ships an official docker image, **multi-arch (amd64 + arm64)**, **works on macOS / Windows / Linux**, hosted on GitHub Container Registry:\n\n```\nghcr.io/7as0nch/mimo2codex\n```\n\n~70MB final image (alpine base + multi-stage build, no npm / build toolchain).\n\n## Contents\n\n- [Quickstart (docker compose)](#quickstart-docker-compose)\n- [Plain `docker run` one-liner](#plain-docker-run-one-liner)\n- [Build locally](#build-locally)\n- [Env vars & key injection](#env-vars--key-injection)\n- [Data persistence](#data-persistence)\n- [Image tags](#image-tags)\n- [Common ops commands](#common-ops-commands)\n- [FAQ](#faq)\n- [Multi-arch / multi-platform support](#multi-arch--multi-platform-support)\n\n## Quickstart (docker compose)\n\nRepo root ships a `docker-compose.yml`. Three steps:\n\n```bash\n# 1. Prepare .env (already gitignored)\ncp .env.example .env\n# Edit .env, set at least one provider key:\n# MIMO_API_KEY=sk-xxxxxxxxxxxx\n# or DEEPSEEK_API_KEY=sk-xxxxxxxxxxxx\n\n# 2. Start\ndocker compose up -d\n\n# 3. Open admin UI\nopen http://localhost:8788 # mac/Linux\nstart http://localhost:8788 # Windows\n```\n\nDone. Point Codex at `http://localhost:8788/v1` as the base URL.\n\n## Plain `docker run` one-liner\n\nIf you don't want compose:\n\n```bash\n# Minimal (key inline, no persistence)\ndocker run -d --name mimo2codex \\\n -p 8788:8788 \\\n -e MIMO_API_KEY=sk-xxxxxxxxxxxx \\\n ghcr.io/7as0nch/mimo2codex:latest\n\n# With persistence + .env file\ndocker run -d --name mimo2codex \\\n -p 8788:8788 \\\n --env-file ./.env \\\n -e MIMO2CODEX_DATA_DIR=/data \\\n -v ~/.mimo2codex:/data \\\n ghcr.io/7as0nch/mimo2codex:latest\n```\n\n## Build locally\n\nSkip the registry, build yourself:\n\n```bash\n# Build for your current arch\ndocker build -t mimo2codex:local .\n\n# Or via compose: in docker-compose.yml swap\n# image: ghcr.io/7as0nch/mimo2codex:latest ← comment out\n# build: . ← enable\ndocker compose build\ndocker compose up -d\n```\n\nBuild details:\n- Stage 1: `node:20-alpine` + `python3 + make + g++` to compile `better-sqlite3` native module\n- Stage 2: `alpine:3.19` + `nodejs + libstdc++` only, no npm\n- Trims devDeps, `.md`, `.ts`, `.map`, test dirs → ~70MB final\n- Local single-arch build: ~2-3 min first time. CI multi-arch (amd64 + arm64): ~10 min.\n\n## Env vars & key injection\n\n### Loading chain\n\n```\nhost .env\n │\n │ docker compose loads env_file → injects KEY=VALUE into container process.env\n ▼\ncontainer process starts\n │\n │ mimo2codex/cli.ts then reads <dataDir>/.env (default /data/.env)\n ▼\nfinal process.env:\n /data/.env exists → overrides compose-injected values for matching keys\n /data/.env absent → compose-injected values stand\n```\n\n**Tip**: keep **only one** `.env` (host repo root, the one compose injects). Don't also drop one into `.mimo2codex/.env` — the two will fight in confusing ways.\n\n### Injection methods\n\n| Method | How | When | Safety |\n|---|---|---|---|\n| `env_file: - .env` (compose default) | Point compose at `.env` file | Local deploy ✅ | `.env` is gitignored |\n| `environment: - KEY=value` (hardcoded) | Literal in compose file | ❌ Don't | Key lands in git history |\n| `environment: - KEY=${KEY}` (from shell) | Read from invoking shell | CI / CD | OK |\n| `-e KEY=value` (docker run inline) | Command line | Debug | OK, but leaks to shell history |\n\n### Required / optional env\n\nNeed at least one provider key. Full reference: [.env.example](../.env.example) or [doc/env-setup.md](./env-setup.md). Common ones:\n\n- `MIMO_API_KEY` — Xiaomi MiMo (`sk-*` / `tp-*` auto-routes to right host)\n- `DEEPSEEK_API_KEY` — DeepSeek\n- `GENERIC_API_KEY` + `GENERIC_BASE_URL` — any OpenAI-compatible upstream (Qwen / Kimi / GLM / etc)\n- `MIMO2CODEX_DATA_DIR=/data` — **required** (compose sets it), tells app where the mounted volume is\n- `MIMO2CODEX_HOST=0.0.0.0` — Dockerfile default, listens on all interfaces inside container\n- `MIMO2CODEX_DEFAULT_PROVIDER=mimo` — default upstream provider\n\n## Data persistence\n\nInside `<dataDir>` (container `/data`, host `./.mimo2codex/` or custom):\n\n| File | Content | Loss impact |\n|---|---|---|\n| `sqlite.db` | Token usage stats, request logs, settings | Admin UI history wiped |\n| `providers.json` | Generic providers added via admin UI (Qwen/Kimi/etc) | Config gone, re-enter |\n| `.env.example` | Auto-copied on startup, reference only | Regenerates |\n\n**Container stop / restart / image upgrade (`docker compose pull && up -d`) all keep data** — it lives in the mounted volume.\n\n### Mount elsewhere\n\nShare data with an npm-installed version (use home dir):\n\n```yaml\nvolumes:\n - ~/.mimo2codex:/data\n```\n\nNotes:\n- macOS / Windows: Docker Desktop handles path translation, just works\n- Linux: host files end up root-owned (container runs as root), edit/rm needs `sudo`\n\n### Backup\n\nJust tar the mount dir:\n\n```bash\ntar czf mimo2codex-backup-$(date +%F).tar.gz .mimo2codex/\n```\n\n## Image tags\n\nGitHub Actions workflow (`.github/workflows/docker.yml`) auto-builds & pushes:\n\n| Tag | When updated | Use for |\n|---|---|---|\n| `:latest` | Every main push + every release | \"track main\" / quick start |\n| `:0.2.15` | When `v0.2.15` git tag is pushed | Production, pinned version |\n| `:0.2` | Latest patch within same minor | Production, allow patch upgrades |\n| `:main` | Every main push | Test bleeding-edge |\n| `:pr-N` | PR build verify | Internal CI only, not pushed |\n\nRelease flow:\n```bash\nnpm run release:patch # 0.2.14 → 0.2.15, auto git tag + push --tags\n# Workflow then auto-produces:\n# ghcr.io/7as0nch/mimo2codex:0.2.15\n# ghcr.io/7as0nch/mimo2codex:0.2\n# updates :latest\n```\n\n## Common ops commands\n\n```bash\n# ─── Start/stop ─────────────────────────────────\ndocker compose up -d # Start detached\ndocker compose stop # Stop only (keep container + volumes)\ndocker compose down # Stop + remove container (data stays in mount)\ndocker compose down -v # ⚠️ Also remove named volumes (bind mounts unaffected)\n\n# ─── Logs ───────────────────────────────────────\ndocker compose logs -f mimo2codex\ndocker compose logs -f --tail=200 mimo2codex\ndocker compose logs --since 10m mimo2codex\n\n# ─── Debug shell ────────────────────────────────\ndocker compose exec mimo2codex sh\ndocker compose exec mimo2codex env | grep MIMO\ndocker compose exec mimo2codex ls /data\n\n# ─── Upgrade ────────────────────────────────────\ndocker compose pull\ndocker compose up -d # Recreate container, data persists\n\n# ─── Restart (required after .env edits) ────────\ndocker compose restart\n# Or stronger (re-injects env vars cleanly):\ndocker compose down && docker compose up -d\n\n# ─── Status ─────────────────────────────────────\ndocker compose ps\ndocker stats mimo2codex\n```\n\n## FAQ\n\n### Q: `docker compose up` says `ghcr.io/7as0nch/mimo2codex:latest not found`\n\n`metadata-action` doesn't emit `:latest` by default. If your fork's workflow predates [66ca82d](https://github.com/7as0nch/mimo2codex/commit/66ca82d), it's missing. Two options:\n\n**A. Temporarily use `:main`:**\n```yaml\nimage: ghcr.io/7as0nch/mimo2codex:main\n```\n\n**B. Sync the latest workflow + wait for build:**\n```bash\ngit pull && git push # triggers new build\n# Wait ~5-10 min (multi-arch is slow), then:\ndocker compose pull && docker compose up -d\n```\n\n### Q: Admin UI config lost on container rebuild\n\nVolume not mounted. Check:\n\n```bash\ndocker compose config | grep -A3 volumes\ndocker compose exec mimo2codex ls /data # Should show sqlite.db, providers.json\n```\n\nMake sure compose has:\n```yaml\nenvironment:\n - MIMO2CODEX_DATA_DIR=/data\nvolumes:\n - ./.mimo2codex:/data\n```\n\n### Q: Port 8788 already in use\n\nChange host-side port in compose `ports:`:\n```yaml\nports:\n - \"9999:8788\"\n```\nThen browser → http://localhost:9999.\n\n### Q: Slow on Apple Silicon\n\nShouldn't be — image has arm64. Docker Desktop auto-pulls right arch. Verify:\n\n```bash\ndocker image inspect ghcr.io/7as0nch/mimo2codex:latest --format '{{.Architecture}}'\n# Expect: arm64\n```\n\nIf amd64, you're going through Rosetta. Force re-pull:\n```bash\ndocker pull --platform linux/arm64 ghcr.io/7as0nch/mimo2codex:latest\n```\n\n### Q: Edited .env, `docker compose up -d` didn't pick up changes\n\n`up -d` skips touching containers if image/config unchanged. **Env changes need a restart:**\n\n```bash\ndocker compose restart\n# Or stronger:\ndocker compose down && docker compose up -d\n```\n\n### Q: Build is slow\n\nMulti-arch build (QEMU emulating arm64 for native module compile) is inherently slow. Single-arch local build is much faster:\n\n```bash\ndocker build -t mimo2codex:local .\n```\n\nCI first build ~10 min, subsequent runs hit gha cache so much faster.\n\n### Q: How to check the running container's version\n\n```bash\ndocker compose exec mimo2codex node -e \"console.log(require('./package.json').version)\"\n```\n\nOr:\n```bash\ndocker image inspect ghcr.io/7as0nch/mimo2codex:latest \\\n --format '{{index .Config.Labels \"org.opencontainers.image.version\"}}'\n```\n\n### Q: Mirror to DockerHub / private registry\n\nFile an issue with your use case, or fork the workflow and add a second `docker/login-action` + push target.\n\n## Multi-arch / multi-platform support\n\nWorkflow auto-builds:\n- `linux/amd64` — Intel / AMD Linux servers, Intel Mac, Windows + WSL2 (default)\n- `linux/arm64` — Apple Silicon Mac (M1/M2/M3/M4), ARM Linux servers, Raspberry Pi 4/5 (64-bit OS)\n\n`docker pull` picks the right manifest based on host CPU automatically — **same commands across platforms**.\n\n**Not built**: 32-bit x86, 32-bit ARM (armv7, older Raspberry Pi), RISC-V. DIY:\n```bash\ndocker build --platform linux/arm/v7 -t mimo2codex:armv7 .\n```\n(Note: alpine + node not tested on armv7, may need a different base image.)\n\nTested platforms:\n\n| Platform | Status | Notes |\n|---|---|---|\n| macOS Intel | ✅ | Docker Desktop |\n| macOS Apple Silicon | ✅ | Docker Desktop / OrbStack, native arm64 |\n| Windows 10/11 + WSL2 | ✅ | Docker Desktop |\n| Windows + Hyper-V | ⚠️ | Prefer WSL2 backend |\n| Ubuntu / Debian / Arch | ✅ | Native docker engine |\n| Raspberry Pi 4/5 (64-bit) | ✅ | arm64 image pulls directly |\n| Raspberry Pi 3 and older | ❌ | 32-bit ARM, not built |\n\n---\n\nMore docs:\n- [.env + loader scripts](./env-setup.md)\n- [Codex Enable](./codex-enable.md)\n- [Generic providers](./generic-providers.md)\n- [mimoskill](./mimoskill.md)\n"}}, "doc/env-setup.zh.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595187.1415327, "n": 6788, "h": "6c3b7b51e3946528", "c": "# .env 与一键加载脚本 · 分系统快速配置\n\n> [English](./env-setup.md) · 中文\n>\n> 返回:[README 中文](../README.zh.md) · [README English](../README.md)\n\nmimo2codex 把每个 provider 的 API key 都做成了**环境变量**——好处是不入库、不在配置文件里裸奔;坏处是每开一个新终端窗口都得重新 `export` 一遍。\n\nmimo2codex 提供两种持久化方式,**普通用户用第一种就够了**:\n\n| 方式 | 适合谁 | 谁负责加载 |\n|---|---|---|\n| **A. 内置 `.env` 自动加载**(推荐) | 装了 npm 包 / curl 一键安装的普通用户 | `mimo2codex` 自己——启动时自动读 `~/.mimo2codex/.env`,不需要任何额外步骤 |\n| **B. 仓库脚本注入到 shell** | 拉了源码 + 同时还想给 codex / IDE / 其他工具用同一份 key 的高级用户 | 你自己 `source scripts/load-env.sh` / `. .\\scripts\\load-env.ps1`,env 进入当前 shell |\n\n下面先讲 **A**,**B** 放在最后做对照。\n\n## 它解决什么\n\n- 不想每次开 shell 都重新 `export MIMO_API_KEY=... && export DS_API_KEY=...`\n- 想把 MiMo / DeepSeek / Qwen / Kimi / OpenAI 等多家 key 放一个文件里集中管\n- 不想 key 写进 `~/.zshrc` / PowerShell `$PROFILE` 里全局污染(也不想被备份工具同步到云端)\n- 不想 key 被 `git commit` 误带上去(`.env` 已经在 `.gitignore` 里)\n\n## 方式 A · 内置加载器(推荐,跨系统通用)\n\n> 不需要拉仓库、不需要 source 任何东西。只要装好 `mimo2codex`,三行命令搞定。\n\n```bash\nmimo2codex init # 1. 在 ~/.mimo2codex/ 下生成 .env + .env.example\n# 用任意编辑器编辑 ~/.mimo2codex/.env # 2. 填入真 key\nmimo2codex # 3. 启动;启动横幅会显示 \"env file: ... (1 key: MIMO_API_KEY)\"\n```\n\n**关键点:**\n\n- `~/.mimo2codex/.env` 是 mimo2codex **每次启动**都会自动加载的文件——不依赖 shell、不依赖操作系统、桌面端启动也读得到(因为是 mimo2codex 进程内自己读,不靠 shell env 继承)\n- Windows 上 `~/` 解析为 `%USERPROFILE%`,所以路径是 `C:\\Users\\<你>\\.mimo2codex\\.env`\n- 第一次裸跑 `mimo2codex`(没 .env、shell 里也没 key)会自动给你建好 .env 并打印「编辑这个文件再 re-run」——所以 step 1 的 `init` 是可选的,**直接跑 `mimo2codex` 也行**\n- 不想自动加载?加 `--no-load-env`,行为退回到老逻辑(只看 shell env)\n- 自定义存放位置:`mimo2codex --data-dir /some/other/dir`,`.env` 会在那个目录\n\n**`mimo2codex init` 详解:**\n\n| 场景 | 行为 |\n|---|---|\n| 全新机器 | 创建 `~/.mimo2codex/`,写入 `.env.example`,把 `.env.example` 复制成 `.env` |\n| 已有 `.env` | 只刷新 `.env.example`(追新 key 列表用),**不动**你的 `.env` |\n| `--data-dir <path>` | 改写 `<path>/.env` 而不是默认目录 |\n\n**老用户从 `export` 平滑迁移:** 如果你已经在 shell 里 `export MIMO_API_KEY=...`,且没建 `.env`,mimo2codex 探测到「shell 里有 key」就**不会**触发首次设置 bootstrap,沿用老的行为不打扰你。想迁移到 .env 文件,跑一次 `mimo2codex init`,然后把 `export` 行从 `.zshrc` 里删掉就行。\n\n## 方式 B · 用仓库脚本注入到 shell(高级,可选)\n\n适用场景:\n\n- 你拉了源码(不是 npm 装的)\n- 你**还想把 key 给其他工具用**(codex 命令本身已经不需要、用 auth.json 了;但比如你的脚本 / IDE / 第三方 CLI 想读同一份 key)\n\n仓库根目录有一对脚本:`scripts/load-env.sh`(bash/zsh/Git Bash/WSL)+ `scripts/load-env.ps1`(Windows PowerShell)。它们做的事和方式 A 的内置加载器一样,但**注入到你的当前 shell**——所以你 source 完之后,从这个 shell 启动的任何工具都能读到 key。\n\n### macOS / Linux(bash / zsh)\n\n```bash\ncp .env.example .env # 仓库根目录\nsource scripts/load-env.sh\necho $MIMO_API_KEY # 验证\nmimo2codex\n```\n\n> ⚠️ 必须用 `source`(或等价的 `.`),**不能**直接 `bash scripts/load-env.sh` —— 直接执行会开子 shell,env 设完就丢。脚本里已经做了检测,错误执行会立刻报错退出。\n\n### Windows PowerShell\n\n```powershell\nCopy-Item .env.example .env\n. .\\scripts\\load-env.ps1 # 注意前面的点 `.` ——dot-source 语法\necho $env:MIMO_API_KEY\nmimo2codex\n```\n\n**如果遇到「无法加载脚本,因为在此系统上禁止运行脚本」**:\n\n```powershell\n# 临时绕过(只对当前 PowerShell 窗口有效)\nSet-ExecutionPolicy -Scope Process Bypass\n. .\\scripts\\load-env.ps1\n\n# 或者:单独解锁这个脚本\nUnblock-File .\\scripts\\load-env.ps1\n```\n\n永久允许(仅对当前用户):`Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`。\n\n### Windows Git Bash / WSL / Cygwin\n\n和 macOS / Linux 一样走 `.sh` 脚本:`cp .env.example .env && source scripts/load-env.sh`。\n\n### Windows cmd.exe(不推荐)\n\n加载脚本不支持 cmd。要么切 PowerShell(按 `Win+X` → Windows PowerShell),要么**直接用方式 A**——`mimo2codex` 在 cmd 里也能跑,`.env` 自动加载和 shell 没关系。手动 `set` 兜底:\n\n```cmd\nset MIMO_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx\nmimo2codex\n```\n\n## `.env` 语法\n\n脚本和 dotenv 系列工具基本一致,规则简单:\n\n| 写法 | 行为 |\n|---|---|\n| `KEY=value` | 标准赋值 |\n| `KEY=\"value\"` / `KEY='value'` | 两端的引号会被剥掉,内部按字面量处理(**不**做 `$var` 展开、**不**做 `\\n` 转义) |\n| `# comment` 整行 | 跳过 |\n| 空行 | 跳过 |\n| `export KEY=value` | 兼容写法,等同于 `KEY=value` |\n| 已经存在的同名 env | **覆盖**(`.env` 是 source of truth) |\n| Windows CRLF 行尾 | bash 版会自动剥 `\\r`,PowerShell 版由 `Get-Content` 处理 |\n| 非法 key 名(含数字开头、特殊字符) | 跳过 + 打 warning |\n\n完整带注释的可用 key 列表见 [.env.example](../.env.example),主要包含:\n\n- 内置 provider:`MIMO_API_KEY`、`DS_API_KEY` / `DEEPSEEK_API_KEY`\n- 通用 provider 单实例:`GENERIC_BASE_URL` / `GENERIC_API_KEY` / `GENERIC_DEFAULT_MODEL`\n- 通用 provider 多实例(看你的 `providers.json` 里 `envKey` 字段写了啥):`QWEN_API_KEY` / `KIMI_API_KEY` / `GLM_API_KEY` / `OPENAI_API_KEY` …\n- 运行时配置:`MIMO2CODEX_HOST` / `MIMO2CODEX_PORT` / `MIMO2CODEX_DATA_DIR` / `MIMO2CODEX_DEFAULT_PROVIDER` / `MIMO2CODEX_NO_REASONING` / `MIMO2CODEX_VERBOSE` / `MIMO2CODEX_NO_ADMIN` / `MIMO2CODEX_CONTEXT_OVERFLOW_MODE`\n\n## 常见问题\n\n<details>\n<summary><b>方式 A 和方式 B 同时用会冲突吗?</b></summary>\n\n不会。两边都是写 `process.env`,方式 A 由 mimo2codex 自己做(只影响 mimo2codex 进程),方式 B 由 shell 做(影响整个 shell + 后续启动的所有进程)。如果方式 B 已经 source 过、方式 A 又从 `~/.mimo2codex/.env` 读到同名 key——后读的(A)覆盖先读的(B),文件即真理。\n\n通常没必要同时用。多数 npm 用户只用方式 A 就够。\n</details>\n\n<details>\n<summary><b>用方式 A 还需要每次开新终端再做什么吗?</b></summary>\n\n不用。**任何**启动 mimo2codex 的方式(shell 命令、桌面端 GUI、scheduler、systemd unit、Docker 入口)都会触发自动加载——因为是 mimo2codex 进程内自己读 `~/.mimo2codex/.env`,跟 shell 完全无关。\n\n而方式 B(shell 脚本)才需要每次开新 shell 重新 source——因为它注入的是 shell 的 env。\n</details>\n\n<details>\n<summary><b>方式 B:每次开新终端窗口都得重新 source?</b></summary>\n\n是的——shell env 是 per-shell 的,关掉窗口就没了。如果你确实想全局可用,挑一种持久化方式:\n\n- **macOS / Linux**:把 `source /path/to/mimo2codex/scripts/load-env.sh` 加到 `~/.zshrc` 或 `~/.bashrc` 末尾\n- **PowerShell**:编辑 `$PROFILE`(`code $PROFILE` 或 `notepad $PROFILE`),加一行 `. C:\\path\\to\\mimo2codex\\scripts\\load-env.ps1`\n\n但这样做的代价是**所有**子进程都会读到你的 key —— 谨慎一点的做法是切到方式 A,那样只有 mimo2codex 自己读得到。\n</details>\n\n<details>\n<summary><b>Codex 桌面端读不到我 source 的 env 怎么办?</b></summary>\n\n桌面应用从 GUI(Dock、开始菜单)启动时**不继承 shell 环境变量**——这是方式 B 的根本限制,也是方式 A 存在的主要原因之一。\n\n如果你受这个困扰,切到方式 A 就好:mimo2codex 自己读文件,跟桌面端怎么启动无关。\n\n如果你坚持用方式 B,让桌面端从命令行启动(macOS:`open -a Codex`,Windows:`Start-Process codex`),那样会继承当前 shell 的 env。另外 mimo2codex 的默认输出本来就走 `auth.json` 方式(Codex 通过 `requires_openai_auth = true` 读 `~/.codex/auth.json`),不依赖 shell env——详见 [Configure Codex](../README.zh.md#3-配置-codex)。\n</details>\n\n<details>\n<summary><b>能不能不暴露 key?只看脚本加载了哪些?</b></summary>\n\n脚本设计就是**只打印 key 名,从来不打印 key 值**。`source scripts/load-env.sh` 输出形如:\n\n```\nload-env: 3 variable(s) loaded from .env\n - MIMO_API_KEY\n - DS_API_KEY\n - QWEN_API_KEY\n```\n\n值想看自己 `echo $MIMO_API_KEY`,但这意味着 key 会进 shell history —— 想避开就用 `printenv MIMO_API_KEY | head -c 10`(只看前 10 个字符)。\n</details>\n\n<details>\n<summary><b>加载多个 .env 文件(dev / prod)?</b></summary>\n\n脚本第一个参数就是文件路径:\n\n```bash\nsource scripts/load-env.sh .env.dev # bash\n. .\\scripts\\load-env.ps1 .\\.env.prod # PowerShell\n```\n\n后加载的会**覆盖**先加载的同名 key——`.env` 自身就是这么处理的。\n</details>\n\n<details>\n<summary><b>不小心把 .env 提交了怎么办?</b></summary>\n\n`.gitignore` 已经挡了,但万一改名或被 `git add -f` 强制加进去:\n\n```bash\ngit rm --cached .env # 从索引里去掉,但保留本地文件\ngit commit -m \"untrack .env\"\n```\n\n如果已经 push 到远端,**立刻把所有泄露的 key 在各家控制台 revoke 重发**——历史里的内容用 `git filter-repo` / `bfg` 也能清,但已经 push 出去的就当泄露了。\n</details>\n"}}, "AGENTS.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595188.6317396, "n": 9618, "h": "047b037ac6df12d6", "c": "# Agent instructions for the mimo2codex repo\n\nThis repo is a local proxy that lets the latest OpenAI Codex CLI / desktop talk\nto **Xiaomi MiMo V2.5** and **DeepSeek V4 Pro** by translating the Responses\nAPI to upstream Chat Completions. Routing is per-request: the client-supplied\n`model` field decides which provider serves the request (see `src/providers/`\nand `src/server.ts:selectProvider`). When you (the agent) run inside Codex\npointed at this proxy, the chat backend is **MiMo or DeepSeek, not OpenAI** —\nadjust your assumptions accordingly.\n\n## Hard rules\n\n1. **Never `pip install openai` and never `import openai`.** This project\n intentionally avoids the OpenAI Python SDK. The user's API key is for\n Xiaomi MiMo, not OpenAI — `openai` SDK calls would either fail\n authentication or hit endpoints that don't exist. The sandbox also blocks\n network installs.\n\n2. **Never assume image generation is available natively.** MiMo V2.5 does not\n have an image generation endpoint. Codex's `/hatch` (which calls OpenAI's\n `gpt-image-1`) does not work when Codex is pointed at MiMo. There is a\n ready-made workaround in `mimoskill/`; use that instead of writing fresh\n code to call `gpt-image-1`.\n\n3. **Don't fight the sandbox by asking the user to install packages.** If you\n would normally write code that needs a Python dependency, first check\n `mimoskill/scripts/` — most things you need are already there using only\n stdlib (`urllib.request`, `json`, etc.). If you genuinely need a new\n dependency, say so explicitly with the user before adding code that\n depends on it.\n\n4. **Non-vision model + image input → don't give up, OCR it.** When the\n active chat model is `mimo-v2.5-pro`, `deepseek-*`,\n or any third-party model without vision, the proxy strips image\n attachments in `src/translate/reqToChat.ts:48-72` and leaves a\n `[N image attachment(s) omitted: this model does not support image input …]`\n placeholder. When you see that placeholder, **or** when the user asks to\n \"read / describe / OCR / 识别 / 提取\" an image, run\n `python3 mimoskill/scripts/ocr.py <image>` and feed the result back into\n the conversation. The script always uses `mimo-v2.5` internally — do\n **not** ask the user to switch chat models, the OCR fallback is\n transparent.\n\n## Where things are\n\n- `src/` — TypeScript source for the proxy (Node 18+). Compiled to `dist/`\n via `npm run build`. Tests in `test/` (vitest, 45 files).\n - `providers/` — Provider abstraction (`types.ts`, `mimo.ts`, `deepseek.ts`,\n `registry.ts`). MiMo-specific behavior (web_search builtin, thinking\n injection, token-plan host inference) is confined to `mimo.ts` hooks; do\n NOT leak it to the generic translate/upstream layers.\n - `db/` — better-sqlite3 persistence (chat logs, model catalog, aliases,\n settings). Default at `~/.mimo2codex/data.db`. Disabled with `--no-admin`.\n - `admin/router.ts` — `/admin/api/*` REST + SPA hosting at `/admin/`.\n - `translate/`, `upstream/`, `util/` — generic, provider-agnostic.\n- `web/` — Vite + React 18 admin console (separate workspace). Builds to\n `dist/web/`. Run `npm run web:install` once, then `npm run web:build` (or\n `npm run build:all` to do both backend + frontend). Dev: `npm run web:dev`\n on port 5173 with `/admin/api` proxied to 8788.\n- `scripts/install.sh` and `scripts/install.ps1` — bootstrap scripts (clone or\n in-repo run, install deps, build, test).\n- `mimoskill/` — a self-contained directory with helpers for MiMo + workarounds\n for what MiMo doesn't do. **This is where you should look first when the\n user asks for anything beyond plain chat.**\n\n## Specifically: generating a Codex pet from an image\n\nThe user almost certainly hits this when they say \"generate a pet\" /\n\"`/hatch` doesn't work\" / \"the image generation tool isn't available\". The\nflow you should follow is **already implemented** — just invoke it, do not\nrewrite it from scratch:\n\n```bash\n# 1. Generate the pet image. --provider auto picks the best available backend\n# given the env vars: gpt-image-1 if PET_OPENAI_API_KEY is set, otherwise\n# pollinations.ai (free, no key, no signup). With only MIMO_API_KEY,\n# auto = pollinations.\npython3 mimoskill/scripts/generate_pet.py \\\n --description \"<short chibi pet description>\" \\\n --out /tmp/pet.png\n\n# 2. Optional: bundle of three states (idle / working / done) from one call\npython3 mimoskill/scripts/generate_pet.py \\\n --description \"<...>\" \\\n --bundle /tmp/my-pet/\n\n# 3. Install into Codex's pet directory (probes macOS/Linux/Windows paths)\nbash mimoskill/scripts/install_pet.sh /tmp/pet.png \"<pet-name>\"\n# or for the bundle:\nbash mimoskill/scripts/install_pet.sh --bundle /tmp/my-pet/ \"<pet-name>\"\n```\n\nIf the sandbox blocks the network call (Pollinations / OpenAI / Replicate),\ndo NOT respond with \"please install openai\". The correct response is:\n\n> \"I can't reach the network from inside the sandbox. Please run the\n> following in a regular terminal (outside Codex), then tell me when it's\n> done and I'll continue:\n>\n> python3 mimoskill/scripts/generate_pet.py --description \"...\" --out /tmp/pet.png\n> bash mimoskill/scripts/install_pet.sh /tmp/pet.png \"<pet-name>\"\n>\n> No `pip install` is needed — the script uses only the Python standard\n> library.\"\n\n## Other MiMo capability gaps\n\nWhen the user asks for something MiMo doesn't natively support, the answer\nis in `mimoskill/references/models.md`. Quick rules:\n\n- **Image input (vision)** — only `mimo-v2.5` accepts it.\n `mimo-v2.5-pro` does NOT. mimo2codex auto-strips images on non-vision\n models. **If you need the image content anyway, run\n `mimoskill/scripts/ocr.py` to extract text / description via `mimo-v2.5`\n without changing the chat model.** Modes: `text` (default, verbatim OCR),\n `describe` (prose), `structured` (JSON regions), `markdown` (re-render).\n- **Image generation** — none of MiMo's models do this. Use\n `mimoskill/scripts/generate_image.py` for general image gen (free\n Pollinations by default, or higher-quality `gpt-image-1` when\n `PET_OPENAI_API_KEY` is set), or `mimoskill/scripts/generate_pet.py` +\n `install_pet.sh` for Codex `/hatch` pets specifically.\n- **TTS / ASR** — separate MiMo endpoints (`mimo-v2.5-tts`, `mimo-v2.5-asr`).\n Out of scope for the chat completions proxy; call them directly.\n- **Code interpreter / sandboxed Python** — not provided by MiMo. Run code\n locally if needed.\n- **`computer_use_preview` / `file_search`** — server-side OpenAI tools with\n no MiMo equivalent. mimo2codex silently drops them.\n\n## Field quirks when calling MiMo directly\n\nThese bite people; use them when writing chat-completions calls:\n\n- Use `max_completion_tokens`, NOT `max_tokens`.\n- When you send `image_url` content, you MUST also send a `text` content part\n in the same array — image-only messages return 400 \"Param Incorrect:\n `text` is not set\". An empty space `\" \"` is enough.\n- Reasoning is `reasoning_content` on the assistant message (DeepSeek-style),\n not `reasoning_summary`.\n- Web search is a builtin tool of `type: \"web_search\"` — **not** a function\n tool. Requires the user to have activated the Web Search Plugin in their\n MiMo console (separately metered).\n- `tool_choice` other than `\"auto\"` is silently ignored upstream right now.\n\nFor the canonical reference, hit\n`https://platform.xiaomimimo.com/docs/api/chat/openai-api`.\n\n## Change workflow rules\n\nThese apply to every change made in this repo, on top of the technical hard rules above:\n\n1. **Never commit on the user's behalf.** Do not run `git commit`, `git add` with intent to commit, `git push`, `git tag`, or any release script (`npm run release:patch / release:minor / release:major / release:beta`). The user reviews the working tree and commits / publishes manually. Read-only inspection (`git status`, `git diff`, `git log`) and branch creation are fine; surface \"what changed and where\" at the end of the task and leave staging untouched.\n\n2. **Log every non-trivial change in BOTH `tag-log` and `release-notes`.** A change worth a `[new]` / `[opt]` / `[fix]` / `[doc]` tag must land in both files — they're complementary, never update only one:\n - **`doc/tag-log.md` + `doc/tag-log.zh.md`** — developer-facing verbose changelog. Append under the current upcoming-version block, or create a new `## vX.Y.Z — YYYY-MM-DD` section at the top. Keep the bilingual files in lockstep.\n - **`web/src/release-notes.tsx`** — user-facing in-app \"What's new\" modal. Add a `ReleaseHighlight` to the matching `ReleaseNote`, or prepend a new entry when bumping the version. Each highlight needs bilingual `title` + `description` + a `kind` (`new`/`improved`/`fixed`/`doc`); add an optional `location` so users can find the new thing in the UI (\"教学\" part), and an optional `ctaLabel` + `ctaPath` (in-app route) / `ctaHref` (external link).\n\n The user bumps the version with `npm run release:*` themselves (see rule 1); the new modal pops on first admin load after the proxy restarts.\n\n## When in doubt\n\n- Read `README.md` (English) or `README.zh.md` (Chinese) for the proxy itself.\n- Read `mimoskill/SKILL.md` for the MiMo helper library and pet workflow.\n- Both `node dist/cli.js print-config` and `node dist/cli.js print-cc-switch`\n emit ready-to-paste config snippets — prefer those over hand-crafting\n TOML / JSON.\n- If you find yourself writing a new `import openai` or `pip install\n openai` line, stop and use `mimoskill/scripts/mimo_chat.py` (chat),\n `mimoskill/scripts/ocr.py` (OCR / image recognition), or\n `mimoskill/scripts/generate_image.py` / `generate_pet.py` (image gen)\n instead.\n"}}, "doc/docker.zh.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595188.9468706, "n": 8416, "h": "320df2e1a7dba9c5", "c": "# Docker 部署\n\nmimo2codex 提供官方 docker 镜像,**多架构(amd64 + arm64)**、**支持 macOS / Windows / Linux**,发布在 GitHub Container Registry:\n\n```\nghcr.io/7as0nch/mimo2codex\n```\n\n镜像 ~70MB(alpine 基础 + 多阶段构建裁剪),不含 npm / build 工具链。\n\n## 目录\n\n- [快速开始(docker compose)](#快速开始docker-compose)\n- [纯 docker run 单行命令](#纯-docker-run-单行命令)\n- [本地构建](#本地构建)\n- [环境变量与 key 注入](#环境变量与-key-注入)\n- [数据持久化](#数据持久化)\n- [镜像 tag 说明](#镜像-tag-说明)\n- [常用运维命令](#常用运维命令)\n- [常见问题](#常见问题)\n- [多架构 / 多平台支持](#多架构--多平台支持)\n\n## 快速开始(docker compose)\n\n仓库根目录已自带 `docker-compose.yml`。三步起:\n\n```bash\n# 1. 准备 .env(已在 .gitignore,不会被提交)\ncp .env.example .env\n# 编辑 .env,至少填一个 provider key:\n# MIMO_API_KEY=sk-xxxxxxxxxxxx\n# 或 DEEPSEEK_API_KEY=sk-xxxxxxxxxxxx\n\n# 2. 启动\ndocker compose up -d\n\n# 3. 打开 admin UI\n# Mac/Linux:\nopen http://localhost:8788\n# Windows:\nstart http://localhost:8788\n```\n\n完成。后续配 Codex 用 `http://localhost:8788/v1` 当 base URL 即可。\n\n## 纯 docker run 单行命令\n\n不想用 compose 的话:\n\n```bash\n# 简版(key 直接传,无持久化)\ndocker run -d --name mimo2codex \\\n -p 8788:8788 \\\n -e MIMO_API_KEY=sk-xxxxxxxxxxxx \\\n ghcr.io/7as0nch/mimo2codex:latest\n\n# 带数据持久化 + .env 文件\ndocker run -d --name mimo2codex \\\n -p 8788:8788 \\\n --env-file ./.env \\\n -e MIMO2CODEX_DATA_DIR=/data \\\n -v ~/.mimo2codex:/data \\\n ghcr.io/7as0nch/mimo2codex:latest\n```\n\n## 本地构建\n\n不拉镜像,自己 build:\n\n```bash\n# 直接 build 当前架构的镜像\ndocker build -t mimo2codex:local .\n\n# 或者改 docker-compose.yml 用 build:\n# image: ghcr.io/7as0nch/mimo2codex:latest ← 注释掉\n# build: . ← 启用\ndocker compose build\ndocker compose up -d\n```\n\n构建特点:\n- 阶段 1:`node:20-alpine` 装 `python3 + make + g++` 编译 `better-sqlite3` 原生模块\n- 阶段 2:`alpine:3.19` 只装 `nodejs + libstdc++`,不含 npm\n- 自动裁剪:删 devDeps、`.md`、`.ts`、`.map`、test 目录\n- 最终 ~70MB\n\n本地单架构 build 通常 2~3 分钟(首次)。多架构(amd64 + arm64)build 走 GitHub Actions,~10 分钟。\n\n## 环境变量与 key 注入\n\n### 加载链\n\n```\n宿主机 .env\n │\n │ docker compose 启动时,env_file 把 KEY=VALUE 注入容器 process.env\n ▼\n容器进程启动\n │\n │ mimo2codex/cli.ts 再读 <dataDir>/.env(默认 /data/.env)\n ▼\n最终 process.env:\n /data/.env 存在 → 覆盖 compose 注入的同名变量\n /data/.env 不存在 → 用 compose 注入的值\n```\n\n**实践建议**:**只**在宿主机仓库根目录维护一份 `.env`,**不要**往 `.mimo2codex/.env`(挂载卷里)再放一份,避免两份覆盖关系混乱。\n\n### 三种注入方式对比\n\n| 方式 | 用法 | 适用场景 | 安全性 |\n|---|---|---|---|\n| `env_file: - .env`(compose 默认) | compose 文件指向 `.env` 文件 | 本地部署 ✅ | `.env` 在 `.gitignore` |\n| `environment: - KEY=value`(硬编码) | compose 文件里写死值 | ❌ 别用 | key 会进 git 历史 |\n| `environment: - KEY=${KEY}`(从 shell 取) | 从执行 `docker compose` 的 shell 读 | CI / CD | OK |\n| `-e KEY=value`(docker run 临时) | 命令行传 | 调试 | OK,但易泄到 shell history |\n\n### 必填 / 可选 env\n\n最少填一个 provider key 即可启动。完整说明见 [.env.example](../.env.example) 或 [doc/env-setup.zh.md](./env-setup.zh.md)。常用:\n\n- `MIMO_API_KEY` — 小米米莫(`sk-*` / `tp-*` 自动识别 host)\n- `DEEPSEEK_API_KEY` — DeepSeek\n- `GENERIC_API_KEY` + `GENERIC_BASE_URL` — 任意 OpenAI 兼容上游(Qwen / Kimi / GLM 等)\n- `MIMO2CODEX_DATA_DIR=/data` — **必须**(compose 已带),告诉 app 数据写到挂载卷里\n- `MIMO2CODEX_HOST=0.0.0.0` — Dockerfile 默认已设,容器内监听全网卡\n- `MIMO2CODEX_DEFAULT_PROVIDER=mimo` — 默认上游 provider\n\n## 数据持久化\n\n`<dataDir>`(容器内 `/data`,宿主机 `./.mimo2codex/` 或自定义)下维护:\n\n| 文件 | 内容 | 丢了影响 |\n|---|---|---|\n| `sqlite.db` | token 用量统计、请求日志、设置 | admin UI 数据归零 |\n| `providers.json` | admin UI 加的 generic provider(Qwen / Kimi 等) | 配置丢失,要重新填 |\n| `.env.example` | 启动时自动拷贝,参考用 | 可重生 |\n\n**容器 stop / restart / 镜像升级(`docker compose pull && up -d`)都不丢数据**——因为数据在挂载卷里。\n\n### 改挂别处\n\n想和 npm 安装版共用同一份数据(家目录的 `~/.mimo2codex/`):\n\n```yaml\nvolumes:\n - ~/.mimo2codex:/data\n```\n\n注意:\n- macOS / Windows:Docker Desktop 自动处理路径转换,OK\n- Linux:宿主机文件 owner 会变成 root(容器跑 root),编辑/删除要 `sudo`\n\n### 备份\n\n直接打包挂载目录:\n\n```bash\ntar czf mimo2codex-backup-$(date +%F).tar.gz .mimo2codex/\n```\n\n恢复就反过来解压。\n\n## 镜像 tag 说明\n\nGitHub Actions workflow(`.github/workflows/docker.yml`)自动构建并推送以下 tag:\n\n| Tag | 何时更新 | 适用场景 |\n|---|---|---|\n| `:latest` | 每次 push main + 每次发版 | \"跟着主分支走\" / 懒人 |\n| `:0.2.15` | 打 `v0.2.15` git tag 时 | 生产环境,锁版本 |\n| `:0.2` | 同 minor 版本最新 patch | 生产环境,允许 patch 升级 |\n| `:main` | 每次 push 到 main | 测试新特性(开发版) |\n| `:pr-N` | PR build 验证 | 不推送,仅 CI 内部 |\n\n发版流程:\n```bash\n# 1. main 改完,bump 版本号\nnpm run release:patch # 0.2.14 → 0.2.15,自动 git tag + push --tags\n\n# 2. workflow 自动触发:\n# - 产出 ghcr.io/7as0nch/mimo2codex:0.2.15\n# - 产出 ghcr.io/7as0nch/mimo2codex:0.2\n# - 更新 ghcr.io/7as0nch/mimo2codex:latest\n```\n\n## 常用运维命令\n\n```bash\n# ─── 启停 ─────────────────────────────────────\ndocker compose up -d # 后台启动\ndocker compose stop # 只停容器(保留容器和卷)\ndocker compose down # 停 + 删容器(数据在挂载卷里,不丢)\ndocker compose down -v # ⚠️ 同时删 named volume(bind mount 不受影响)\n\n# ─── 日志 ─────────────────────────────────────\ndocker compose logs -f mimo2codex # 实时追\ndocker compose logs -f --tail=200 mimo2codex\ndocker compose logs --since 10m mimo2codex # 最近 10 分钟\n\n# ─── 进容器调试 ──────────────────────────────\ndocker compose exec mimo2codex sh\ndocker compose exec mimo2codex env | grep MIMO # 看注入的环境变量\ndocker compose exec mimo2codex ls /data # 看持久化数据\n\n# ─── 升级 ─────────────────────────────────────\ndocker compose pull # 拉最新镜像\ndocker compose up -d # 重建容器,数据保留\n\n# ─── 重启(改 .env 必须)────────────────────\ndocker compose restart\n# 或更彻底(确保环境变量重新注入):\ndocker compose down && docker compose up -d\n\n# ─── 状态 ─────────────────────────────────────\ndocker compose ps # 容器状态\ndocker stats mimo2codex # CPU / 内存实时\n```\n\n## 常见问题\n\n### Q: `docker compose up` 报 `ghcr.io/7as0nch/mimo2codex:latest not found`\n\n`metadata-action` 默认不生成 `:latest`。如果你 fork 的 workflow 文件早于 [66ca82d](https://github.com/7as0nch/mimo2codex/commit/66ca82d),会缺这个 tag。两个选择:\n\n**A. 临时改 compose 用 `:main`:**\n```yaml\nimage: ghcr.io/7as0nch/mimo2codex:main\n```\n\n**B. 同步最新 workflow,等 build 完:**\n```bash\ngit pull && git push # 触发新 build\n# 等 ~5-10 分钟(多架构慢),然后:\ndocker compose pull && docker compose up -d\n```\n\n### Q: 改了 admin UI 配置,容器重建就丢\n\n挂载卷没生效。检查:\n\n```bash\ndocker compose config | grep -A3 volumes # 应看到挂载配置\ndocker compose exec mimo2codex ls /data # 应看到 sqlite.db、providers.json\n```\n\n确认 `docker-compose.yml` 有:\n```yaml\nenvironment:\n - MIMO2CODEX_DATA_DIR=/data\nvolumes:\n - ./.mimo2codex:/data\n```\n\n### Q: 端口 8788 被占用\n\n改 compose `ports:` 左侧(宿主机端口):\n```yaml\nports:\n - \"9999:8788\" # 宿主 9999 → 容器 8788\n```\n重启后浏览器开 http://localhost:9999。\n\n### Q: Apple Silicon Mac 上跑得慢\n\n不应该——镜像有 arm64 版,Docker Desktop 自动拉对应架构。确认:\n\n```bash\ndocker image inspect ghcr.io/7as0nch/mimo2codex:latest --format '{{.Architecture}}'\n# 应输出: arm64\n```\n\n如果输出 amd64,是被走了 Rosetta 翻译,强制重拉:\n```bash\ndocker pull --platform linux/arm64 ghcr.io/7as0nch/mimo2codex:latest\n```\n\n### Q: 改了 .env,重新 `docker compose up -d` 没生效\n\n`up -d` 看 image / config 没变就不动容器。**改环境变量必须重启**:\n\n```bash\ndocker compose restart\n# 或更彻底:\ndocker compose down && docker compose up -d\n```\n\n### Q: build 很慢\n\n多架构 build(QEMU 模拟 arm64 编译 native module)本来就慢。本地 build 单架构会快很多:\n\n```bash\ndocker build -t mimo2codex:local . # 只 build 当前 CPU 架构\n```\n\nGitHub Actions 首次 ~10 分钟,后续有 gha cache 命中会快很多。\n\n### Q: 怎么看容器实际在跑的版本\n\n```bash\ndocker compose exec mimo2codex node -e \"console.log(require('./package.json').version)\"\n```\n\n或:\n\n```bash\ndocker image inspect ghcr.io/7as0nch/mimo2codex:latest \\\n --format '{{index .Config.Labels \"org.opencontainers.image.version\"}}'\n```\n\n### Q: 想把 ghcr 镜像也镜像到 DockerHub / 私有 registry\n\n发个 issue 说一下场景,或者自己 fork workflow,加一个 `docker/login-action` + 第二个 `push:` 目标。\n\n## 多架构 / 多平台支持\n\nworkflow 自动构建:\n- `linux/amd64` — Intel / AMD Linux 服务器、Intel Mac、Windows + WSL2(默认)\n- `linux/arm64` — Apple Silicon Mac (M1/M2/M3/M4)、ARM Linux 服务器、Raspberry Pi 4/5 (64-bit OS)\n\n`docker pull` 时客户端自动根据宿主 CPU 选对应 manifest,**用户命令完全一致**。\n\n**不支持开箱用**:32-bit x86、32-bit ARM (armv7,老树莓派)、RISC-V 等。需要的话自己 build:\n```bash\ndocker build --platform linux/arm/v7 -t mimo2codex:armv7 .\n```\n(注意:alpine + node 在 armv7 上未测试过,可能要换基础镜像)\n\n各平台测试矩阵:\n\n| 平台 | 状态 | 备注 |\n|---|---|---|\n| macOS Intel | ✅ | Docker Desktop |\n| macOS Apple Silicon | ✅ | Docker Desktop / OrbStack,原生 arm64 |\n| Windows 10/11 + WSL2 | ✅ | Docker Desktop |\n| Windows + Hyper-V | ⚠️ | 建议切 WSL2 后端 |\n| Ubuntu / Debian / Arch | ✅ | 原生 docker engine |\n| Raspberry Pi 4/5 (64-bit) | ✅ | arm64 镜像直接拉 |\n| Raspberry Pi 3 及更老 | ❌ | 32-bit ARM,没 build |\n\n---\n\n更多文档:\n- [.env 与加载脚本](./env-setup.zh.md)\n- [Codex 启用](./codex-enable.zh.md)\n- [通用 provider](./generic-providers.zh.md)\n- [mimoskill](./mimoskill.zh.md)\n"}}, "doc/env-setup.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595188.954697, "n": 10316, "h": "e09dbcf4ba5d80e6", "c": "# .env + one-shot loader scripts · per-OS quick setup\n\n> English · [中文](./env-setup.zh.md)\n>\n> Back to: [README English](../README.md) · [README 中文](../README.zh.md)\n\nmimo2codex reads every provider's API key from an **environment variable** — keys never touch the repo, never sit naked in a config file. The downside: each new shell window is a fresh `export`.\n\nThere are two ways to persist your keys; **most users want option A**:\n\n| Option | For whom | Who loads the file |\n|---|---|---|\n| **A. Built-in `.env` auto-loader** (recommended) | Anyone who installed via npm / the curl one-liner | `mimo2codex` itself — it reads `~/.mimo2codex/.env` on every start, no extra step |\n| **B. Repo loader scripts → inject into your shell** | You cloned the source AND want the same keys visible to other tools (codex CLI, IDE, your own scripts) | You, via `source scripts/load-env.sh` or `. .\\scripts\\load-env.ps1` |\n\nOption A is covered first; option B is at the bottom for reference.\n\n## What it solves\n\n- You don't want to re-run `export MIMO_API_KEY=... && export DS_API_KEY=...` for every new shell\n- You want MiMo / DeepSeek / Qwen / Kimi / OpenAI keys collected in one file\n- You don't want keys living in `~/.zshrc` or your PowerShell `$PROFILE` (polluting every process, syncing to cloud backups)\n- You don't want `git commit` to ever pick up a real key (`.env` is in `.gitignore`)\n\n## Option A · Built-in loader (recommended, OS-agnostic)\n\n> No clone, no `source`. With `mimo2codex` installed, three steps total.\n\n```bash\nmimo2codex init # 1. seeds ~/.mimo2codex/.env + .env.example\n# edit ~/.mimo2codex/.env in any editor # 2. fill in real keys\nmimo2codex # 3. start — banner shows \"env file: ... (1 key: MIMO_API_KEY)\"\n```\n\n**Key points:**\n\n- `~/.mimo2codex/.env` is auto-loaded on **every** `mimo2codex` start. It doesn't depend on the shell, the OS, or how the process was launched (CLI, desktop GUI shortcut, scheduler, systemd unit, Docker entrypoint all see it the same way — mimo2codex reads the file in-process)\n- On Windows, `~/` resolves to `%USERPROFILE%`, so the path is `C:\\Users\\<you>\\.mimo2codex\\.env`\n- First bare `mimo2codex` run (no .env, no key in shell env) auto-bootstraps the file and tells you to edit and re-run — so step 1 (`init`) is optional, **plain `mimo2codex` does the right thing too**\n- Don't want auto-load? Pass `--no-load-env`, behavior reverts to \"shell env only\" (the pre-v0.2.8 behavior)\n- Custom location: `mimo2codex --data-dir /some/other/dir` puts `.env` there\n\n**`mimo2codex init` behavior:**\n\n| Scenario | What happens |\n|---|---|\n| Fresh machine | Creates `~/.mimo2codex/`, writes `.env.example`, copies it to `.env` |\n| `.env` already exists | Refreshes `.env.example` only (so you can diff for new keys); **leaves** your `.env` alone |\n| `--data-dir <path>` | Targets `<path>/.env` instead of the default |\n\n**Migrating from `export`:** if you already `export MIMO_API_KEY=...` in your shell and have no `.env`, mimo2codex detects the shell key and **skips** first-run bootstrap to avoid surprising you — it just uses your shell env. To switch to the file, run `mimo2codex init` once, then remove the `export` from your `.zshrc`.\n\n## Option B · Repo loader scripts → inject into shell (advanced, optional)\n\nWhen to use option B:\n\n- You cloned the source (didn't `npm i -g`)\n- You **want the same keys visible to other tools** — the codex CLI itself no longer needs them (it uses auth.json), but maybe your own scripts, IDE, or a third-party CLI does\n\nThe repo ships a pair of scripts: `scripts/load-env.sh` (bash/zsh/Git Bash/WSL) + `scripts/load-env.ps1` (Windows PowerShell). They do the same parsing as option A, but inject keys **into your current shell** so anything launched from that shell inherits them.\n\n### macOS / Linux (bash / zsh)\n\n```bash\ncp .env.example .env # repo root\nsource scripts/load-env.sh\necho $MIMO_API_KEY # sanity check\nmimo2codex\n```\n\n> ⚠️ You must `source` (or use `.`) — **don't** run `bash scripts/load-env.sh` directly. Direct execution spawns a child shell, sets the vars there, and they evaporate on exit. The script detects this and errors out.\n\n### Windows PowerShell\n\n```powershell\nCopy-Item .env.example .env\n. .\\scripts\\load-env.ps1 # mind the leading `. ` — dot-source syntax\necho $env:MIMO_API_KEY\nmimo2codex\n```\n\n**If you hit \"running scripts is disabled on this system\"**:\n\n```powershell\n# Temporary bypass — current PowerShell window only\nSet-ExecutionPolicy -Scope Process Bypass\n. .\\scripts\\load-env.ps1\n\n# Or: unblock just this one script\nUnblock-File .\\scripts\\load-env.ps1\n```\n\nTo allow scripts permanently for your user: `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`.\n\n### Windows Git Bash / WSL / Cygwin\n\nSame as macOS / Linux: `cp .env.example .env && source scripts/load-env.sh`.\n\n### Windows cmd.exe (not recommended)\n\nThe loader scripts don't support cmd. Either switch to PowerShell (`Win+X` → Windows PowerShell), or **just use option A** — `mimo2codex` runs fine under cmd and the `.env` auto-load doesn't care about the parent shell. As a manual fallback:\n\n```cmd\nset MIMO_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx\nmimo2codex\n```\n\n## `.env` syntax\n\nThe loaders follow the conventional dotenv rules:\n\n| Form | Behavior |\n|---|---|\n| `KEY=value` | Standard assignment |\n| `KEY=\"value\"` / `KEY='value'` | Surrounding quotes stripped; content is literal (**no** `$var` expansion, **no** `\\n` escapes) |\n| `# comment` on its own line | Skipped |\n| Blank line | Skipped |\n| `export KEY=value` | Tolerated for compatibility — equivalent to `KEY=value` |\n| Already-set env var with the same name | **Overwritten** (`.env` is the source of truth) |\n| Windows CRLF line endings | bash loader auto-strips `\\r`; PowerShell loader handles via `Get-Content` |\n| Invalid key name (starts with digit, special chars) | Skipped with a warning |\n\nFor the full annotated list of usable keys see [.env.example](../.env.example), which covers:\n\n- Built-in providers: `MIMO_API_KEY`, `DS_API_KEY` / `DEEPSEEK_API_KEY`\n- Generic provider, single-instance form: `GENERIC_BASE_URL` / `GENERIC_API_KEY` / `GENERIC_DEFAULT_MODEL`\n- Generic provider, multi-instance (matching the `envKey` field in your `providers.json`): `QWEN_API_KEY` / `KIMI_API_KEY` / `GLM_API_KEY` / `OPENAI_API_KEY` …\n- Runtime config: `MIMO2CODEX_HOST` / `MIMO2CODEX_PORT` / `MIMO2CODEX_DATA_DIR` / `MIMO2CODEX_DEFAULT_PROVIDER` / `MIMO2CODEX_NO_REASONING` / `MIMO2CODEX_VERBOSE` / `MIMO2CODEX_NO_ADMIN` / `MIMO2CODEX_CONTEXT_OVERFLOW_MODE`\n\n## FAQ\n\n<details>\n<summary><b>Can I use options A and B together? Do they conflict?</b></summary>\n\nNo conflict. Both write into `process.env`. Option A runs inside the mimo2codex process (only affects mimo2codex itself); option B runs in your shell (affects every subsequent process). If both happen and they declare the same key, whichever runs later wins (A runs later because mimo2codex starts after option B's source).\n\nThere's usually no reason to use both. Most npm users only need option A.\n</details>\n\n<details>\n<summary><b>With option A, do I need to do anything in each new terminal?</b></summary>\n\nNo. **Any** way of starting mimo2codex (shell command, desktop GUI shortcut, scheduler, systemd unit, Docker entrypoint) triggers auto-load — because mimo2codex reads `~/.mimo2codex/.env` itself, not via shell env inheritance.\n\nOption B (the shell scripts) is what needs re-sourcing per new shell — that's what injects into shell env.\n</details>\n\n<details>\n<summary><b>Option B: do I really have to source it every new terminal?</b></summary>\n\nYes — shell env vars are per-shell, gone when the window closes. To persist:\n\n- **macOS / Linux**: append `source /path/to/mimo2codex/scripts/load-env.sh` to `~/.zshrc` or `~/.bashrc`\n- **PowerShell**: edit `$PROFILE` (`code $PROFILE` or `notepad $PROFILE`) and add `. C:\\path\\to\\mimo2codex\\scripts\\load-env.ps1`\n\nThe trade-off: **every** child process from that shell sees your keys. The conservative alternative is to switch to option A — only mimo2codex itself sees them.\n</details>\n\n<details>\n<summary><b>Codex desktop doesn't see the env vars I sourced. What gives?</b></summary>\n\nGUI-launched desktop apps (Dock, Start menu) **don't inherit your shell environment** — that's the fundamental limitation option B can't solve, and a major reason option A exists.\n\nIf you're hit by this, switch to option A: mimo2codex reads its own file regardless of how the desktop app was launched.\n\nIf you stick with option B, launch the desktop app from the command line (macOS: `open -a Codex`, Windows: `Start-Process codex`) so it inherits the current shell's env. Note that mimo2codex's default `print-config` output uses the `auth.json` flow (`requires_openai_auth = true` makes Codex read `~/.codex/auth.json`) and doesn't need shell env at all — see [Configure Codex](../README.md#3-configure-codex).\n</details>\n\n<details>\n<summary><b>Can I see what got loaded without exposing the values?</b></summary>\n\nThe scripts only ever print **key names, never values**. `source scripts/load-env.sh` outputs:\n\n```\nload-env: 3 variable(s) loaded from .env\n - MIMO_API_KEY\n - DS_API_KEY\n - QWEN_API_KEY\n```\n\nIf you want to inspect a value, `echo $MIMO_API_KEY` works but writes the key into shell history. A safer probe: `printenv MIMO_API_KEY | head -c 10` (first 10 characters only).\n</details>\n\n<details>\n<summary><b>Load multiple .env files (dev / prod)?</b></summary>\n\nThe first arg to either script is the file path:\n\n```bash\nsource scripts/load-env.sh .env.dev # bash\n. .\\scripts\\load-env.ps1 .\\.env.prod # PowerShell\n```\n\nLater sources **override** earlier ones for same-named keys — the same way `.env` itself overrides pre-existing env.\n</details>\n\n<details>\n<summary><b>I accidentally committed .env. Help.</b></summary>\n\n`.gitignore` catches it by default, but if you renamed it or `git add -f`-ed it in:\n\n```bash\ngit rm --cached .env # remove from index, keep the local file\ngit commit -m \"untrack .env\"\n```\n\nIf you already pushed, **immediately revoke and reissue every leaked key** in each provider's console. You can purge history with `git filter-repo` / `bfg`, but anything that was once on the public remote should be considered leaked.\n</details>\n"}}, "doc/generic-providers.zh.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595190.647891, "n": 14620, "h": "1e95319d84d8b840", "c": "# 通用 OpenAI 兼容 Provider · 详细教程\n\n> [English](./generic-providers.md) · 中文\n>\n> 返回:[README 中文](../README.zh.md) · [README English](../README.md)\n\nmimo2codex 内置了 MiMo 和 DeepSeek 两个 provider。**通用 provider 机制**让你能在不改任何代码、不重新发包的前提下,把任何 **OpenAI Chat Completions 兼容**或**原生 Responses API** 的上游接到新版 Codex —— Qwen、GLM、Kimi、智谱、OpenAI 本身、本地 vLLM、Ollama、LM Studio …… 凡是接口长得像 OpenAI 的都能接。\n\n## 它解决什么\n\n新版 Codex 强制走 `wire_api = \"responses\"`,绝大多数三方模型只对外提供 Chat Completions。mimo2codex 把这个翻译做掉,你只需要在配置里登记一下你的上游就行。\n\n支持两种「上游协议」:\n\n| `wireApi` | 上游协议 | 适用场景 |\n|---|---|---|\n| `chat`(默认) | OpenAI Chat Completions | 99% 的第三方厂商(Qwen / GLM / DeepSeek / Kimi / Ollama / vLLM …) |\n| `responses` | OpenAI Responses API | 上游已经原生支持 Responses(如 OpenAI 自家、未来想跟进的厂商)。直透模式,不做协议翻译 |\n\n`responses` 直透有个额外优点:**协议升级时不用等 mimo2codex 跟进**——上游加什么字段就转什么字段,不会被中间层的旧翻译卡住。\n\n## 60 秒上手\n\n**最简方式**:一个 env,三件套。\n\n```bash\nexport GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1\nexport GENERIC_API_KEY=sk-your-qwen-key\nexport GENERIC_DEFAULT_MODEL=qwen3-max\nmimo2codex --model generic\n```\n\n启动横幅会显示 `provider: generic`、`upstream: https://dashscope...`,然后 `mimo2codex print-config --model generic` 把 `auth.json + config.toml` 两段打印出来,复制到 `~/.codex/` 即可。\n\n> ⚠️ env-only 模式只能配 **一个** 上游。要同时配多个,用下面的 `providers.json`。\n\n## 配置文件方式(多实例,推荐)\n\n写一个 `providers.json`,每个上游一项。默认路径:\n\n| 系统 | 路径 |\n|---|---|\n| macOS / Linux | `~/.mimo2codex/providers.json` |\n| Windows | `%USERPROFILE%\\.mimo2codex\\providers.json` |\n\n也可以用 `MIMO2CODEX_PROVIDERS_FILE=/some/path/providers.json` 显式指定。\n\n完整示例:\n\n```json\n{\n \"providers\": [\n {\n \"id\": \"qwen\",\n \"shortcut\": \"qwen\",\n \"displayName\": \"Qwen (DashScope)\",\n \"baseUrl\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"envKey\": \"QWEN_API_KEY\",\n \"defaultModel\": \"qwen3-max\",\n \"wireApi\": \"chat\",\n \"models\": [\n { \"id\": \"qwen3-max\", \"contextWindow\": 262144 },\n { \"id\": \"qwen3-coder-plus\", \"contextWindow\": 1048576 }\n ],\n \"features\": { \"forceParallelToolCalls\": true }\n },\n {\n \"id\": \"kimi\",\n \"shortcut\": \"kimi\",\n \"displayName\": \"Kimi K2\",\n \"baseUrl\": \"https://api.moonshot.cn/v1\",\n \"envKey\": \"KIMI_API_KEY\",\n \"defaultModel\": \"kimi-k2-0905-preview\"\n },\n {\n \"id\": \"ollama\",\n \"shortcut\": \"ol\",\n \"displayName\": \"Ollama (local)\",\n \"baseUrl\": \"http://127.0.0.1:11434/v1\",\n \"envKey\": \"OLLAMA_API_KEY\",\n \"defaultModel\": \"qwen2.5-coder:7b\"\n },\n {\n \"id\": \"openai-native\",\n \"displayName\": \"OpenAI (native Responses)\",\n \"baseUrl\": \"https://api.openai.com/v1\",\n \"envKey\": \"OPENAI_API_KEY\",\n \"defaultModel\": \"gpt-5\",\n \"wireApi\": \"responses\"\n }\n ]\n}\n```\n\n配好之后启动:\n\n```bash\nexport QWEN_API_KEY=sk-...\nexport KIMI_API_KEY=sk-...\nmimo2codex --model qwen # 默认 provider 是 qwen\n```\n\n`--model` 接收 `id` 或 `shortcut`(上面例子里 `qwen` 既是 id 又是 shortcut,`ollama` 的 shortcut 是 `ol`)。\n\n## 字段一览\n\n| 字段 | 必填 | 默认 | 说明 |\n|---|---|---|---|\n| `id` | ✓ | — | 唯一标识。不能用 `mimo` / `deepseek`(保留)。只允许字母数字 / `-` / `_` |\n| `displayName` | — | id | UI 和 print-config 里显示的名字 |\n| `shortcut` | — | id | `--model <短码>` 用 |\n| `baseUrl` | ✓ | — | 上游 base URL(**不要**带 `/chat/completions` 后缀,mimo2codex 自己拼) |\n| `envKey` | ✓ | — | 从哪个环境变量读 API key(如 `QWEN_API_KEY`) |\n| `defaultModel` | ✓ | — | 客户端没指定 / 未识别 model 字段时的兜底 |\n| `wireApi` | — | `\"chat\"` | `\"chat\"` 或 `\"responses\"`,见上文 |\n| `models` | — | `[]` | 声明该 provider 的模型清单(详见下节) |\n| `features.forceParallelToolCalls` | — | `false` | 强制开启 `parallel_tool_calls: true`(agentic 编程任务推荐打开) |\n| `features.webSearch` | — | `false` | 把 Codex 的 `web_search` 工具透传给上游(仅对支持 builtin web_search 的上游有意义) |\n| `features.minimaxCompat` | — | `false` | **MiniMax 一键预设**:默认包揽 `dropNullStrict` + `dropNullContent` + `dropToolChoiceAuto` + `mergeSystemMessages` + `extractThinkTags` 共 5 个开关。`dropStreamOptions` / `dropParallelToolCalls` **不**在预设里(它们是 OpenAI 标准字段,删了会让 token 统计变 0)。详见 [minimax.zh.md](./minimax.zh.md) |\n| `features.dropNullStrict` | — | `false` | 删 `tools[*].function.strict === null`(MiniMax 拒绝 null) |\n| `features.dropNullContent` | — | `false` | 删 assistant `content === null` 字段(MiniMax 拒绝 null) |\n| `features.dropToolChoiceAuto` | — | `false` | 删 `tool_choice === \"auto\"`(默认值;MiniMax 拒绝显式传) |\n| `features.dropStreamOptions` | — | `false` | 删 `stream_options`。⚠️ 上游将不再回传 `usage` → admin DB token 统计会变 0。**不**在 `minimaxCompat` 预设里 |\n| `features.dropParallelToolCalls` | — | `false` | 删 `parallel_tool_calls`。**不**在 `minimaxCompat` 预设里 |\n| `features.mergeSystemMessages` | — | `false` | 合并所有 `role: \"system\"` 消息为单条前置(MiniMax 只接受 1 条) |\n| `features.extractThinkTags` | — | `false` | 响应侧:把 `content` 里的 `<think>...</think>` 块切到 `reasoning_content`(MiniMax M1/M2/M3、GLM/Qwen-thinking 等使用 inline 思考) |\n| `forceDefaultModel` | — | `false` | 当 `models: []` 时让 resolveModel 返回 null,未知 model 名改写到 `defaultModel`。配 MiniMax env-var 单实例使用 |\n| `docsUrl` | — | — | \"缺 API key\" 错误消息里展示的链接 |\n\n`models[]` 每一项:\n\n| 字段 | 必填 | 说明 |\n|---|---|---|\n| `id` | ✓ | 上游真实 model id |\n| `aliases` | — | 客户端可能发的别名,路由时也算命中 |\n| `displayName` | — | UI 上显示的名字 |\n| `contextWindow` | — | print-config 里的 `model_context_window` |\n| `maxOutputTokens` | — | print-config 里的 `model_max_output_tokens` |\n| `supportsImages` / `supportsReasoning` / `supportsWebSearch` | — | 元信息,UI 展示用 |\n\n## 模型识别策略\n\n`models[]` **不是必填**。两种行为:\n\n**1. 声明了 `models[]`(严格模式)**\n\n只有列在 `models[]` 里的 id(及 alias)才算\"属于这个 provider\"。请求按 model id 精确匹配到 provider。客户端发了未在列表里的 id:\n- 如果该 provider 是**默认** provider → 把 model 重写为 `defaultModel`,并在日志里记一条 `rewriteNotice`\n- 否则不命中,走默认 provider 的兜底\n\n适合:知道自己用哪几个模型,想让 print-config 输出 `model_context_window`、想在 admin UI 看到清晰的模型清单。\n\n**2. 不写 `models[]`(任意透传)**\n\n客户端发什么 model id,就原样转发给上游。**不重写**、**不报错**。\n\n适合:上游模型清单变化快(Ollama、OpenRouter 这类聚合服务),或者你只想\"管道\"功能,不想每加一个模型就改配置。\n\n> 任意透传 provider **不会** 被自动 model-id 匹配命中——避免它\"吞掉\"所有 mimo / deepseek 的模型。要路由到它,必须把它设为默认 provider(`--model <id>`)。\n\n### 路由优先级\n\n同一个 model id 可能被多个 provider 声明(典型场景:你为内部 MiMo 代理建了个 generic provider,`models[]` 里也写了 `mimo-v2.5-pro`)。`selectProvider` 按下面的顺序挑:\n\n1. **带 key 的用户自定义 generic**(`models[]` 非空),按注册顺序匹配\n2. **带 key 的内置 provider**(mimo / deepseek)\n3. **默认 provider 兜底**——这里会把 model id 重写为默认 provider 的 `defaultModel`,并伴随一条 `rewriteNotice` 警告日志\n\n第 1 步把 generic 排在内置前面,是为了让\"内部代理\"场景能正常工作:当你只配了 `COMPANY_MIMO_API_KEY`、没配 `MIMO_API_KEY` 时,客户端发 `mimo-v2.5-pro` 仍然能路由到你的 generic(而不是因为内置 mimo 没 key 就掉到默认 provider)。如果同一 model id 被多个带 key 的 generic 声明,最先注册的赢。\n\n## wireApi 详解\n\n**`chat`**:mimo2codex 把 Codex Responses 请求翻译成 Chat Completions,发到 `${baseUrl}/chat/completions`,再把上游响应翻译回 Responses 给 Codex。\n\n```\nCodex ──[Responses]──> mimo2codex ──[Chat]──> 上游 ──[Chat]──> mimo2codex ──[Responses]──> Codex\n```\n\n**`responses`**:mimo2codex 把 Codex 的请求**直接转发**到 `${baseUrl}/responses`,不做任何翻译;上游响应也原样返回。\n\n```\nCodex ──[Responses]──> mimo2codex ──[Responses raw]──> 上游 ──[Responses raw]──> mimo2codex ──> Codex\n```\n\n什么时候用 `responses`:\n\n- 上游就是 OpenAI 自家\n- 上游声称\"完全兼容 OpenAI Responses API\"\n- 上游有 chat completions 不支持的字段(如 `reasoning.effort`、`text.verbosity`、新工具类型),翻译层会丢字段时\n\n注意事项:\n\n- 流式直透是**字节级 pipe**——上游 SSE 帧原样转发到 Codex,Codex 端 SSE 解析器负责切帧。低开销但也意味着 mimo2codex 不在中间做任何修改\n- 当前 admin UI 的\"按模型 token 统计\"对 `responses` 路径只能提取 `usage` 字段顶层,复杂的 usage breakdown 不解析\n\n## 几个真实上游配置\n\n### 阿里通义千问(DashScope OpenAI 兼容模式)\n\n```json\n{\n \"id\": \"qwen\",\n \"displayName\": \"Qwen (DashScope)\",\n \"baseUrl\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"envKey\": \"QWEN_API_KEY\",\n \"defaultModel\": \"qwen3-max\",\n \"models\": [\n { \"id\": \"qwen3-max\", \"contextWindow\": 262144 },\n { \"id\": \"qwen3-coder-plus\", \"contextWindow\": 1048576, \"supportsReasoning\": true }\n ],\n \"features\": { \"forceParallelToolCalls\": true }\n}\n```\n\n### 智谱 GLM\n\n```json\n{\n \"id\": \"glm\",\n \"displayName\": \"Zhipu GLM-4.6\",\n \"baseUrl\": \"https://open.bigmodel.cn/api/paas/v4\",\n \"envKey\": \"ZHIPU_API_KEY\",\n \"defaultModel\": \"glm-4.6\",\n \"models\": [\n { \"id\": \"glm-4.6\", \"contextWindow\": 200000 }\n ]\n}\n```\n\n### Moonshot Kimi\n\n```json\n{\n \"id\": \"kimi\",\n \"displayName\": \"Kimi K2\",\n \"baseUrl\": \"https://api.moonshot.cn/v1\",\n \"envKey\": \"KIMI_API_KEY\",\n \"defaultModel\": \"kimi-k2-0905-preview\",\n \"models\": [\n { \"id\": \"kimi-k2-0905-preview\", \"contextWindow\": 256000 }\n ]\n}\n```\n\n### 本地 Ollama / LM Studio(任意透传)\n\n```json\n{\n \"id\": \"ollama\",\n \"shortcut\": \"ol\",\n \"displayName\": \"Ollama (local)\",\n \"baseUrl\": \"http://127.0.0.1:11434/v1\",\n \"envKey\": \"OLLAMA_API_KEY\",\n \"defaultModel\": \"qwen2.5-coder:7b\"\n}\n```\n\nOllama 不验证 API key,但 `envKey` 是 schema 必填——随便给个值就行(`OLLAMA_API_KEY=ignored`)。\n\n### OpenAI 原生 Responses(直透)\n\n```json\n{\n \"id\": \"openai-native\",\n \"displayName\": \"OpenAI (native Responses)\",\n \"baseUrl\": \"https://api.openai.com/v1\",\n \"envKey\": \"OPENAI_API_KEY\",\n \"defaultModel\": \"gpt-5\",\n \"wireApi\": \"responses\"\n}\n```\n\n### MiniMax(严格 OpenAI 兼容)\n\nMiniMax 拒绝若干 OpenAI / MiMo / DeepSeek 都接受的字段。开 `minimaxCompat` 一键预设即可:\n\n```json\n{\n \"id\": \"minimax\",\n \"displayName\": \"MiniMax M2.7\",\n \"baseUrl\": \"https://api.minimaxi.com/v1\",\n \"envKey\": \"MINIMAX_API_KEY\",\n \"defaultModel\": \"MiniMax-M2.7\",\n \"models\": [\n { \"id\": \"MiniMax-M2.7\", \"contextWindow\": 245760 }\n ],\n \"features\": {\n \"minimaxCompat\": true,\n \"forceParallelToolCalls\": true\n }\n}\n```\n\n详见 [minimax.zh.md](./minimax.zh.md)(含 env-var 单实例写法 `GENERIC_FORCE_DEFAULT_MODEL=1`)。\n\n## 默认 provider 与路由规则(重要)\n\n加了 generic provider 之后,路由优先级:\n\n1. **客户端发的 model 字段命中某个 provider 的 `models[]`(含 alias)且该 provider 有 key** → 路由到该 provider\n2. **命中了 catalog 但 provider 没 key** → fall through 到默认 provider,model 被重写为 `defaultModel`,日志记 `client_model_rewritten`\n3. **没声明 `models[]` 的 provider(开放目录)** → 在自动路由阶段跳过(避免\"吞掉\"所有未知 id);只有显式 `--model <id>` 设它为默认 provider 时才会被路由到\n4. **都不命中** → 走默认 provider,model 被重写为 `defaultModel`,日志记 `client_model_rewritten`\n\n默认 provider 的选择:\n\n- `--model <id-or-shortcut>` 优先\n- 否则 `MIMO2CODEX_DEFAULT_PROVIDER` 环境变量\n- 否则 fallback 到 `\"mimo\"`\n\n### \"key 没设\" 的实际后果\n\n举个常见的坑:你在 `providers.json` 配了 qwen / kimi / glm 三个 generic,但启动时只设了 `MIMO_API_KEY`。这种情况下:\n\n```bash\n# 客户端发 qwen3-max\n# → byClientModel 命中 qwen catalog\n# → qwen 没 key → fall through\n# → 走默认 provider mimo → model 重写为 mimo-v2.5-pro\n# → 实际由 MiMo 用 mimo-v2.5-pro 回答\n```\n\n**对话过程没有任何提示**。在 admin 的「模型映射记录」表里能看到 `qwen3-max → mimo-v2.5-pro` 这条映射,chat 日志里也会带 `client_model_rewritten` 错误码。但如果你不主动看 admin UI,很容易以为「在用 qwen」实际「在用 mimo」。\n\n要避免这个静默降级,目前两个办法:\n\n1. **启动前确认 key 全配齐**:admin 首页的 Provider 卡片明确显示每个 provider「已检测到 key / 未检测到 key」,把所有需要用到的 key 都设上\n2. **改用单 provider 启动**:要专门用 qwen 就 `--model qwen` 并把 mimo 的 key 拿掉——这样如果 qwen 没 key 启动会直接报错而不是静默降级\n\n> 既有的 mimo / deepseek 用户**完全不受影响**:不写 providers.json 时默认 provider 仍是 mimo,所有行为字节级一致。\n\n## 在 admin webui 配置(不用手写 JSON)\n\n打开 `http://127.0.0.1:8788/admin/`:\n\n- **通用 Provider 页**(侧栏,[`/admin/providers`](http://127.0.0.1:8788/admin/providers)):可视化增删改查 generic providers\n - 表格列出 `providers.json` 里所有条目,每条可「编辑」/「删除」\n - 「+ 添加 Provider」弹出表单,所有字段都有占位符提示和实时校验(id 不能与内置冲突 / 不能含空格 / baseUrl 必填等)\n - 模型清单可动态增删,每个模型可填 contextWindow / maxOutputTokens / vision / reasoning / web search 等元信息\n - 「编辑原始 JSON」逃生口——直接编辑 `providers.json` 全文,校验通过才会写入\n - 保存后写 `~/.mimo2codex/providers.json`,UI 提示 **「重启 mimo2codex 让配置生效」**——目前不做热重载,启动期一次性加载\n- **对接指引页**([`/admin/setup`](http://127.0.0.1:8788/admin/setup)):下拉选 provider,三个 Tab 自动渲染 `auth.json + config.toml` 三种粘贴方式(直接修改 / env-key / cc-switch),每个 codeblock 有「复制」按钮\n- **概览页**:所有已注册 provider(含 generic)列在 Provider 卡片里,显示 key 是否已配置\n- **日志页**:按 provider 过滤(generic id 直接出现在下拉里)\n\n> 注意:UI 编辑**不能管理 API key**——key 不存数据库、不写配置文件,必须通过环境变量注入(如 `QWEN_API_KEY=sk-...`)。这是为了避免凭据落盘后被备份/泄漏。UI 只管 schema 配置,env 管 secret。\n\n## CLI 子命令对 generic 的支持\n\n```bash\nmimo2codex print-config --model qwen # 输出 qwen 的 auth.json + config.toml 片段\nmimo2codex print-config --model qwen --env-key # env-key 变种(仅 Codex CLI 适用)\nmimo2codex print-cc-switch --model qwen # cc-switch 自定义供应商片段\n```\n\ntoml 输出里 `model_provider` 命名规则:\n\n- mimo → `[model_providers.mimo]`(保留历史)\n- deepseek → `[model_providers.mimo2codex]`(保留历史)\n- 其他 generic → `[model_providers.mimo2codex-<id>]`(加前缀避免与用户已有 toml 段冲突)\n\n## 故障排查\n\n<details>\n<summary><b>报 <code>provider id \"xxx\" must be alphanumeric + dash/underscore</code></b></summary>\n\n`id` 字段只允许字母数字、`-`、`_`,不能有空格、点、斜杠。改成 `kimi`、`my-qwen`、`local_dev` 这样的。\n\n</details>\n\n<details>\n<summary><b>报 <code>generic provider id \"mimo\" conflicts with a built-in provider</code></b></summary>\n\n`mimo` 和 `deepseek` 是保留 id。改成 `mimo-custom` 之类的。\n\n</details>\n\n<details>\n<summary><b>报 <code>missing API key for ...</code> 但我明明设了 env</b></summary>\n\n检查:\n1. env 变量名是否和 spec 里的 `envKey` 完全一致(区分大小写)\n2. 是否用对了 shell:PowerShell 设的 `$env:X` 在 cmd 看不到,反之亦然\n3. 是不是把 key 设到了 `MIMO2CODEX_DEFAULT_PROVIDER` 指定的 provider 上(默认必须有 key,否则启动报错)\n\n</details>\n\n<details>\n<summary><b>启动横幅没显示我的 generic provider</b></summary>\n\n- 横幅只显示**有 API key**的 provider。检查 `envKey` 是否设了\n- 检查 providers.json 路径:是否在 `~/.mimo2codex/`,或显式 `MIMO2CODEX_PROVIDERS_FILE`\n- JSON 语法错会启动失败并打错误,不会静默\n\n</details>\n\n<details>\n<summary><b>路由没按预期走 — 发 qwen3-max 却到了 mimo</b></summary>\n\n如果你的 generic provider 没声明 `models[]`,它**不会**被 `byClientModel` 自动命中。两条路:\n- 给 spec 加 `models: [{ \"id\": \"qwen3-max\" }]`(推荐)\n- 或者把 generic 设为默认 provider:`mimo2codex --model qwen`\n\n</details>\n\n<details>\n<summary><b>上游报 400,错误信息说不认识 reasoning / thinking 字段</b></summary>\n\n非 MiMo 的上游通常不支持 MiMo 特有的 `thinking` 字段。generic provider 已经默认会剥掉这些字段。如果还报错,**用 `--verbose` 看实际转发的 body**——可能是 Codex 端发了别的字段,那是 Codex 客户端的兼容性问题,与代理无关。\n\n</details>\n\n<details>\n<summary><b>wireApi: \"responses\" 上游返回 404 / 405</b></summary>\n\n上游可能根本没实现 `/v1/responses` 端点。绝大多数三方厂商目前只有 `/v1/chat/completions`——把 `wireApi` 改回 `\"chat\"`(或删掉,默认就是 chat)。\n\n</details>\n\n<details>\n<summary><b>同一个 id 在 providers.json 里出现两次</b></summary>\n\n启动会报错并退出。每个 id 必须唯一。\n\n</details>\n\n## 设计取舍备忘\n\n- **为什么默认 provider 仍是 mimo?** 向后兼容。既有 mimo / deepseek 用户升级到带 generic 支持的版本,行为零变化\n- **为什么开放目录的 generic 不参与 `byClientModel`?** 否则它会\"吞掉\"所有未知 model id,包括 mimo / deepseek 的合法 id。把开放目录 generic 设为默认 provider 才能用它做\"全部 model 透传\"\n- **为什么 toml provider key 加 `mimo2codex-` 前缀?** 用户的 `~/.codex/config.toml` 里可能已经有 `[model_providers.qwen]`(直接连 Qwen 的旧配置),用前缀避免被覆盖\n- **为什么不做 admin UI 的可视化编辑?** 第一版先把\"可用\"做出来。后续 UI 表单可以在不破坏现有架构的情况下加入(providers.json 反正本来就是配置文件)\n\n## 相关源码\n\n- [src/providers/generic.ts](../src/providers/generic.ts) — 工厂函数\n- [src/providers/genericLoader.ts](../src/providers/genericLoader.ts) — 配置加载 + env 兜底\n- [src/providers/registry.ts](../src/providers/registry.ts) — 运行时注册 + 路由防护\n- [src/upstream/openaiCompatClient.ts](../src/upstream/openaiCompatClient.ts) — chat / responses 两个上游客户端\n- [src/server.ts](../src/server.ts) — `handleResponses` 里的 wireApi 分支\n- [test/providers.generic.test.ts](../test/providers.generic.test.ts) — 18 个测试用例\n"}}, "doc/generic-providers.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595194.5235555, "n": 19841, "h": "63f315775ed2f4eb", "c": "# Generic OpenAI-compatible Providers · Detailed Guide\n\n> English · [中文](./generic-providers.zh.md)\n>\n> Back to: [README English](../README.md) · [README 中文](../README.zh.md)\n\nmimo2codex ships with two built-in providers — MiMo and DeepSeek. The **generic provider mechanism** lets you wire any **OpenAI Chat Completions-compatible** or **native Responses API** upstream to the latest Codex without modifying any code: Qwen, GLM, Kimi, Zhipu, OpenAI itself, local vLLM, Ollama, LM Studio — anything with an OpenAI-shaped HTTP interface.\n\n## What it solves\n\nThe latest Codex hard-requires `wire_api = \"responses\"`, but almost every third-party model only exposes Chat Completions. mimo2codex does the translation; you just register your upstream in a config file.\n\nTwo wire-protocol modes are supported:\n\n| `wireApi` | Upstream protocol | When to use |\n|---|---|---|\n| `chat` (default) | OpenAI Chat Completions | 99% of third-party providers (Qwen / GLM / DeepSeek / Kimi / Ollama / vLLM …) |\n| `responses` | OpenAI Responses API | Upstream natively speaks Responses (OpenAI itself, future-leaning providers). Direct passthrough — no protocol translation |\n\nThe `responses` passthrough has a side benefit: **when the upstream's protocol evolves, you don't wait for mimo2codex to catch up** — new fields flow straight through without being stripped by an outdated translator.\n\n## 60-second start\n\n**Simplest path** — one provider, three env vars.\n\n```bash\nexport GENERIC_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1\nexport GENERIC_API_KEY=sk-your-qwen-key\nexport GENERIC_DEFAULT_MODEL=qwen3-max\nmimo2codex --model generic\n```\n\nThe startup banner prints `provider: generic`, `upstream: https://dashscope...`, and `mimo2codex print-config --model generic` outputs the `auth.json + config.toml` snippets — paste into `~/.codex/`.\n\n> ⚠️ env-only mode supports **one** upstream. For multiple, use the `providers.json` route below.\n\n## Config-file route (multi-instance, recommended)\n\nWrite a `providers.json` with one entry per upstream. Default path:\n\n| OS | Path |\n|---|---|\n| macOS / Linux | `~/.mimo2codex/providers.json` |\n| Windows | `%USERPROFILE%\\.mimo2codex\\providers.json` |\n\nOverride with `MIMO2CODEX_PROVIDERS_FILE=/some/path/providers.json`.\n\nFull example:\n\n```json\n{\n \"providers\": [\n {\n \"id\": \"qwen\",\n \"shortcut\": \"qwen\",\n \"displayName\": \"Qwen (DashScope)\",\n \"baseUrl\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"envKey\": \"QWEN_API_KEY\",\n \"defaultModel\": \"qwen3-max\",\n \"wireApi\": \"chat\",\n \"models\": [\n { \"id\": \"qwen3-max\", \"contextWindow\": 262144 },\n { \"id\": \"qwen3-coder-plus\", \"contextWindow\": 1048576 }\n ],\n \"features\": { \"forceParallelToolCalls\": true }\n },\n {\n \"id\": \"kimi\",\n \"shortcut\": \"kimi\",\n \"displayName\": \"Kimi K2\",\n \"baseUrl\": \"https://api.moonshot.cn/v1\",\n \"envKey\": \"KIMI_API_KEY\",\n \"defaultModel\": \"kimi-k2-0905-preview\"\n },\n {\n \"id\": \"ollama\",\n \"shortcut\": \"ol\",\n \"displayName\": \"Ollama (local)\",\n \"baseUrl\": \"http://127.0.0.1:11434/v1\",\n \"envKey\": \"OLLAMA_API_KEY\",\n \"defaultModel\": \"qwen2.5-coder:7b\"\n },\n {\n \"id\": \"openai-native\",\n \"displayName\": \"OpenAI (native Responses)\",\n \"baseUrl\": \"https://api.openai.com/v1\",\n \"envKey\": \"OPENAI_API_KEY\",\n \"defaultModel\": \"gpt-5\",\n \"wireApi\": \"responses\"\n }\n ]\n}\n```\n\nThen start:\n\n```bash\nexport QWEN_API_KEY=sk-...\nexport KIMI_API_KEY=sk-...\nmimo2codex --model qwen # default provider = qwen\n```\n\n`--model` accepts either `id` or `shortcut`.\n\n## Field reference\n\n| Field | Required | Default | Notes |\n|---|---|---|---|\n| `id` | ✓ | — | Unique identifier. Cannot be `mimo` / `deepseek` (reserved). Alphanumeric + `-` / `_` only |\n| `displayName` | — | id | Shown in UI and print-config output |\n| `shortcut` | — | id | Used with `--model <shortcut>` |\n| `baseUrl` | ✓ | — | Upstream base URL (**do not** include `/chat/completions` — mimo2codex appends paths) |\n| `envKey` | ✓ | — | Env var to read the API key from (e.g. `QWEN_API_KEY`) |\n| `defaultModel` | ✓ | — | Fallback when client didn't specify or sent an unknown id |\n| `wireApi` | — | `\"chat\"` | `\"chat\"` or `\"responses\"`, see above |\n| `models` | — | `[]` | Declared model catalog for this provider (see next section) |\n| `features.forceParallelToolCalls` | — | `false` | Force `parallel_tool_calls: true` (recommended for agentic-coding upstreams) |\n| `features.webSearch` | — | `false` | Forward Codex's `web_search` tool (only meaningful if upstream has a builtin web_search) |\n| `features.minimaxCompat` | — | `false` | **MiniMax preset**: enables `dropNullStrict` + `dropNullContent` + `dropToolChoiceAuto` + `mergeSystemMessages` + `extractThinkTags` (5 switches). `dropStreamOptions` / `dropParallelToolCalls` are NOT included (they're OpenAI standard fields and dropping them breaks token statistics). See [minimax.md](./minimax.md) |\n| `features.dropNullStrict` | — | `false` | Strip `tools[*].function.strict === null` (MiniMax rejects null) |\n| `features.dropNullContent` | — | `false` | Strip assistant `content === null` fields (MiniMax rejects null) |\n| `features.dropToolChoiceAuto` | — | `false` | Strip `tool_choice === \"auto\"` (the default; MiniMax rejects explicit \"auto\") |\n| `features.dropStreamOptions` | — | `false` | Strip `stream_options`. ⚠️ Upstream stops returning `usage` → admin DB token stats become 0. NOT in `minimaxCompat` preset |\n| `features.dropParallelToolCalls` | — | `false` | Strip `parallel_tool_calls`. NOT in `minimaxCompat` preset |\n| `features.mergeSystemMessages` | — | `false` | Merge all `role: \"system\"` messages into one leading entry (MiniMax accepts only one) |\n| `features.extractThinkTags` | — | `false` | Response side: extract `<think>...</think>` blocks out of `content` into `reasoning_content` (MiniMax M1/M2/M3, GLM/Qwen-thinking emit inline thinking) |\n| `forceDefaultModel` | — | `false` | When `models: []`, return null from resolveModel so unknown client model ids rewrite to `defaultModel`. Use with MiniMax env-var single-instance |\n| `docsUrl` | — | — | Link shown in the \"missing API key\" error |\n\nEach `models[]` entry:\n\n| Field | Required | Notes |\n|---|---|---|\n| `id` | ✓ | The upstream's real model id |\n| `aliases` | — | Client-side names that route to this model too |\n| `displayName` | — | UI label |\n| `contextWindow` | — | Emitted as `model_context_window` in print-config |\n| `maxOutputTokens` | — | Emitted as `model_max_output_tokens` in print-config |\n| `supportsImages` / `supportsReasoning` / `supportsWebSearch` | — | Metadata, surfaced in admin UI |\n\n## Model identification strategy\n\n`models[]` is **optional**. Two modes:\n\n**1. With declared `models[]` (strict mode)**\n\nOnly ids in `models[]` (or their aliases) are considered \"owned\" by this provider. Requests are routed to the provider whose catalog exactly matches the client-supplied model id. If the client sends an unlisted id:\n- And this provider is the **default** → rewrite to `defaultModel`, log a `rewriteNotice`\n- Otherwise → falls through to the default provider's fallback\n\nGood for: known model lineup, wanting print-config to emit `model_context_window`, clean admin UI catalog.\n\n**2. No `models[]` (open-catalog passthrough)**\n\nWhatever model id the client sends, forward verbatim. **No rewriting, no errors.**\n\nGood for: upstreams with fast-changing catalogs (Ollama, OpenRouter), when you just want a pipe.\n\n> Open-catalog generics are **not** auto-matched by model id — otherwise they'd \"swallow\" every mimo / deepseek id. To route to them, set them as the default provider with `--model <id>`.\n\n### Routing priority\n\nThe same model id may be declared by multiple providers (typical case: you spun up a generic provider for your internal MiMo proxy and listed `mimo-v2.5-pro` in its `models[]`). `selectProvider` picks in this order:\n\n1. **User-declared generics with a key** (non-empty `models[]`), in registration order\n2. **Built-in providers with a key** (mimo / deepseek)\n3. **Default-provider fallback** — model id is rewritten to the default provider's `defaultModel`, with a `rewriteNotice` warning logged\n\nStep 1 prioritizes user-declared generics over built-ins so that the \"internal proxy\" case works: if you only set `COMPANY_MIMO_API_KEY` (no `MIMO_API_KEY`), a client request for `mimo-v2.5-pro` still routes to your generic (instead of falling through to the default provider because the built-in mimo has no key). If multiple keyed generics declare the same model id, the first one registered wins.\n\n## wireApi explained\n\n**`chat`**: mimo2codex translates Codex's Responses request to Chat Completions, sends to `${baseUrl}/chat/completions`, translates the response back.\n\n```\nCodex ──[Responses]──> mimo2codex ──[Chat]──> upstream ──[Chat]──> mimo2codex ──[Responses]──> Codex\n```\n\n**`responses`**: mimo2codex forwards Codex's request **as-is** to `${baseUrl}/responses`. No translation either direction.\n\n```\nCodex ──[Responses]──> mimo2codex ──[Responses raw]──> upstream ──[Responses raw]──> mimo2codex ──> Codex\n```\n\nUse `responses` when:\n\n- Upstream is OpenAI itself\n- Upstream claims \"full OpenAI Responses API parity\"\n- Upstream supports fields chat completions can't carry (`reasoning.effort`, `text.verbosity`, new tool types) and you don't want them dropped\n\nNotes:\n\n- Streaming passthrough is **byte-level pipe** — upstream SSE frames forward unmodified, Codex's parser handles framing. Lower overhead but mimo2codex makes zero modifications mid-stream\n- Admin UI's per-model token stats can only extract top-level `usage` fields on the `responses` path; nested usage breakdowns aren't parsed\n\n## Real-world examples\n\n### Alibaba Qwen (DashScope OpenAI-compatible mode)\n\n```json\n{\n \"id\": \"qwen\",\n \"displayName\": \"Qwen (DashScope)\",\n \"baseUrl\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"envKey\": \"QWEN_API_KEY\",\n \"defaultModel\": \"qwen3-max\",\n \"models\": [\n { \"id\": \"qwen3-max\", \"contextWindow\": 262144 },\n { \"id\": \"qwen3-coder-plus\", \"contextWindow\": 1048576, \"supportsReasoning\": true }\n ],\n \"features\": { \"forceParallelToolCalls\": true }\n}\n```\n\n### Zhipu GLM\n\n```json\n{\n \"id\": \"glm\",\n \"displayName\": \"Zhipu GLM-4.6\",\n \"baseUrl\": \"https://open.bigmodel.cn/api/paas/v4\",\n \"envKey\": \"ZHIPU_API_KEY\",\n \"defaultModel\": \"glm-4.6\",\n \"models\": [\n { \"id\": \"glm-4.6\", \"contextWindow\": 200000 }\n ]\n}\n```\n\n### Moonshot Kimi\n\n```json\n{\n \"id\": \"kimi\",\n \"displayName\": \"Kimi K2\",\n \"baseUrl\": \"https://api.moonshot.cn/v1\",\n \"envKey\": \"KIMI_API_KEY\",\n \"defaultModel\": \"kimi-k2-0905-preview\",\n \"models\": [\n { \"id\": \"kimi-k2-0905-preview\", \"contextWindow\": 256000 }\n ]\n}\n```\n\n### Local Ollama / LM Studio (open-catalog)\n\n```json\n{\n \"id\": \"ollama\",\n \"shortcut\": \"ol\",\n \"displayName\": \"Ollama (local)\",\n \"baseUrl\": \"http://127.0.0.1:11434/v1\",\n \"envKey\": \"OLLAMA_API_KEY\",\n \"defaultModel\": \"qwen2.5-coder:7b\"\n}\n```\n\nOllama doesn't validate API keys, but `envKey` is schema-required — just set anything (`OLLAMA_API_KEY=ignored`).\n\n### OpenAI native Responses (passthrough)\n\n```json\n{\n \"id\": \"openai-native\",\n \"displayName\": \"OpenAI (native Responses)\",\n \"baseUrl\": \"https://api.openai.com/v1\",\n \"envKey\": \"OPENAI_API_KEY\",\n \"defaultModel\": \"gpt-5\",\n \"wireApi\": \"responses\"\n}\n```\n\n### MiniMax (strict OpenAI-compat)\n\nMiniMax rejects several fields that OpenAI / MiMo / DeepSeek accept. Use the `minimaxCompat` preset:\n\n```json\n{\n \"id\": \"minimax\",\n \"displayName\": \"MiniMax M2.7\",\n \"baseUrl\": \"https://api.minimaxi.com/v1\",\n \"envKey\": \"MINIMAX_API_KEY\",\n \"defaultModel\": \"MiniMax-M2.7\",\n \"models\": [\n { \"id\": \"MiniMax-M2.7\", \"contextWindow\": 245760 }\n ],\n \"features\": {\n \"minimaxCompat\": true,\n \"forceParallelToolCalls\": true\n }\n}\n```\n\nSee [minimax.md](./minimax.md) for the full breakdown and the env-var single-instance flow (`GENERIC_FORCE_DEFAULT_MODEL=1`).\n\n## Default provider & routing rules (important)\n\nAfter adding generic providers, routing priority:\n\n1. Client `model` matches some provider's `models[]` (incl. aliases) **and that provider has a key** → route there\n2. Catalog matched but the provider has no key → falls through to the default provider; model is rewritten to its `defaultModel`, logged as `client_model_rewritten`\n3. Open-catalog provider (no `models[]`) → skipped during auto-routing (so it doesn't swallow unknown ids); reachable only by setting it as the default with `--model <id>`\n4. Nothing matches → falls back to the default provider, rewriting model to its `defaultModel`, logged as `client_model_rewritten`\n\nDefault provider selection:\n\n- `--model <id-or-shortcut>` takes priority\n- Otherwise `MIMO2CODEX_DEFAULT_PROVIDER` env var\n- Otherwise falls back to `\"mimo\"`\n\n### What \"no key\" actually does\n\nA common foot-gun: you configure qwen / kimi / glm in `providers.json` but only set `MIMO_API_KEY` at startup. Then:\n\n```bash\n# Client sends qwen3-max\n# → byClientModel matches the qwen catalog\n# → qwen has no key → fall through\n# → default provider mimo → model rewritten to mimo-v2.5-pro\n# → the response actually comes from MiMo on mimo-v2.5-pro\n```\n\n**No mid-conversation warning.** The admin \"model mappings\" table shows the `qwen3-max → mimo-v2.5-pro` rewrite, and chat logs carry the `client_model_rewritten` error code. But if you don't open the admin UI, it's easy to believe you're using Qwen when you're actually using MiMo.\n\nTo avoid this silent fallback, today's options:\n\n1. **Make sure all keys are set up-front** — the admin Dashboard's Provider cards explicitly show \"key detected / not detected\" per provider; set all the keys you intend to use\n2. **Single-provider startup** — to specifically use qwen, run `--model qwen` without the mimo key. Then if qwen has no key, startup errors out instead of silently downgrading\n\n> Existing mimo / deepseek users **are not affected**: without `providers.json`, the default provider stays `mimo` and all behavior is byte-identical.\n\n## Manage in admin webui (no manual JSON editing)\n\nOpen `http://127.0.0.1:8788/admin/`:\n\n- **Generic Providers page** (sidebar, [`/admin/providers`](http://127.0.0.1:8788/admin/providers)): visual CRUD for generic providers\n - Table lists every entry in `providers.json`; each row has Edit / Delete\n - \"+ Add Provider\" opens a form with placeholders, inline validation (id can't collide with builtins, no spaces, baseUrl required, etc.)\n - Models list is dynamically editable — each model takes contextWindow / maxOutputTokens / vision / reasoning / web search metadata\n - \"Edit raw JSON\" escape hatch — edit the full `providers.json` text, only writes when JSON validates\n - On save, writes `~/.mimo2codex/providers.json` and shows a **\"Restart mimo2codex to apply\"** banner — there is no hot reload; the registry initializes once at startup\n- **Setup guide** ([`/admin/setup`](http://127.0.0.1:8788/admin/setup)): provider dropdown, three tabs auto-render `auth.json + config.toml` for direct / env-key / cc-switch flows. Each code block has a Copy button\n- **Dashboard**: all registered providers (including generics) shown in Provider cards with key-presence status\n- **Logs**: filter by provider (generic ids appear in the dropdown)\n\n> Note: the UI **does not manage API keys** — keys are not stored in the database or any config file; they must be injected via environment variables (e.g. `QWEN_API_KEY=sk-...`). This avoids credentials landing on disk and getting backed up or leaked. UI handles schema config, env handles secrets.\n\n## CLI subcommands\n\n```bash\nmimo2codex print-config --model qwen # qwen's auth.json + config.toml snippets\nmimo2codex print-config --model qwen --env-key # env-key variant (Codex CLI only)\nmimo2codex print-cc-switch --model qwen # cc-switch custom-provider snippets\n```\n\n`model_provider` naming convention in the toml:\n\n- mimo → `[model_providers.mimo]` (legacy preserved)\n- deepseek → `[model_providers.mimo2codex]` (legacy preserved)\n- other generics → `[model_providers.mimo2codex-<id>]` (prefixed to avoid colliding with the user's existing toml sections)\n\n## Troubleshooting\n\n<details>\n<summary><b><code>provider id \"xxx\" must be alphanumeric + dash/underscore</code></b></summary>\n\n`id` only allows letters, digits, `-`, `_`. No spaces, dots, slashes. Use `kimi`, `my-qwen`, `local_dev` etc.\n\n</details>\n\n<details>\n<summary><b><code>generic provider id \"mimo\" conflicts with a built-in provider</code></b></summary>\n\n`mimo` and `deepseek` are reserved. Rename to e.g. `mimo-custom`.\n\n</details>\n\n<details>\n<summary><b><code>missing API key for ...</code> but I set the env</b></summary>\n\nCheck:\n1. The env var name matches `envKey` in the spec exactly (case-sensitive)\n2. Right shell — PowerShell `$env:X` is invisible to cmd, and vice versa\n3. You actually set the key for the provider that `MIMO2CODEX_DEFAULT_PROVIDER` (or `--model`) points at — the default provider must have a key, or startup fails\n\n</details>\n\n<details>\n<summary><b>Startup banner doesn't show my generic provider</b></summary>\n\n- Banner only lists providers with **API keys set**. Check `envKey`\n- Verify the providers.json path: `~/.mimo2codex/` or explicit `MIMO2CODEX_PROVIDERS_FILE`\n- JSON syntax errors fail startup loudly — they don't silently skip\n\n</details>\n\n<details>\n<summary><b>Routing wrong — sent qwen3-max but got mimo</b></summary>\n\nIf your generic doesn't declare `models[]`, it **won't** be auto-matched by `byClientModel`. Two fixes:\n- Add `models: [{ \"id\": \"qwen3-max\" }]` to the spec (recommended)\n- Or make the generic the default provider: `mimo2codex --model qwen`\n\n</details>\n\n<details>\n<summary><b>Upstream 400, error says reasoning / thinking field not recognized</b></summary>\n\nNon-MiMo upstreams usually don't understand MiMo's `thinking` field. Generic providers already strip these by default. If you still see this, run with `--verbose` to inspect the actual forwarded body — likely something Codex itself emitted that the upstream doesn't accept (a Codex-side compat issue, unrelated to the proxy).\n\n</details>\n\n<details>\n<summary><b>wireApi: \"responses\" upstream returns 404 / 405</b></summary>\n\nThe upstream probably doesn't implement `/v1/responses`. Most third parties only have `/v1/chat/completions` today — set `wireApi` back to `\"chat\"` (or omit it; default is chat).\n\n</details>\n\n<details>\n<summary><b>Same id appears twice in providers.json</b></summary>\n\nStartup fails with an error. Each id must be unique.\n\n</details>\n\n## Design notes\n\n- **Why is the default provider still mimo?** Backwards compatibility. Existing mimo / deepseek users see zero behavior change after upgrading\n- **Why don't open-catalog generics participate in `byClientModel`?** They'd swallow every unknown id, including mimo / deepseek's legit ids. They need an explicit `--model <id>` to be used as a catch-all\n- **Why the `mimo2codex-` prefix on toml provider keys?** Users' `~/.codex/config.toml` may already have `[model_providers.qwen]` (pointing directly at Qwen). The prefix avoids overwriting it\n- **Why no admin-UI form for editing generic providers?** Ship \"it works\" first. UI forms can be added later without changing the architecture (providers.json was already a config file)\n\n## Source files\n\n- [src/providers/generic.ts](../src/providers/generic.ts) — factory function\n- [src/providers/genericLoader.ts](../src/providers/genericLoader.ts) — config loading + env fallback\n- [src/providers/registry.ts](../src/providers/registry.ts) — runtime registration + routing guards\n- [src/upstream/openaiCompatClient.ts](../src/upstream/openaiCompatClient.ts) — chat / responses upstream clients\n- [src/server.ts](../src/server.ts) — wireApi branch in `handleResponses`\n- [test/providers.generic.test.ts](../test/providers.generic.test.ts) — 18 test cases\n"}}, "src/admin/router.ts": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595195.7319133, "n": 101569, "h": "bca177970a7f1469", "c": "import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { existsSync, readFileSync, statSync } from \"node:fs\";\nimport { dirname, extname, join, normalize, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport type { Config } from \"../config.js\";\nimport { PROVIDER_LIST, PROVIDERS } from \"../providers/registry.js\";\nimport {\n aggregateErrors,\n aggregateLatency,\n aggregateMappings,\n aggregateProviderHealth,\n aggregateStats,\n aggregateTokensTimeseries,\n aggregateUsagePerUser,\n deleteAllLogs,\n deleteLogsBefore,\n diskFreeBytes,\n getDbSizeBytes,\n getLogById,\n queryLogs,\n vacuumDb,\n} from \"../db/logs.js\";\nimport {\n deleteModel,\n insertCustomModel,\n listModels,\n patchModel,\n} from \"../db/models.js\";\nimport {\n deleteSetting,\n ForbiddenSettingError,\n getSetting,\n isForbiddenSettingKey,\n listSettings,\n setSetting,\n} from \"../db/settings.js\";\nimport type { ProviderId } from \"../providers/types.js\";\nimport { isProviderId } from \"../providers/registry.js\";\nimport { log } from \"../util/log.js\";\nimport { buildSnippetBundle, resolveSnippetTarget, tomlProviderKeyFor } from \"../setup/snippets.js\";\nimport {\n GenericLoaderError,\n locateProvidersFile,\n readSpecsFromFile,\n writeSpecsToFile,\n} from \"../providers/genericLoader.js\";\nimport type { GenericProviderSpec } from \"../providers/generic.js\";\nimport { PROVIDER_PRESETS } from \"../providers/presets.js\";\nimport { isAbsolute as pathIsAbsolute } from \"node:path\";\nimport { applyCodex, deleteBackupPair, readCodexState, restoreCodex } from \"../codex/state.js\";\nimport { applyCodexPreserveLogin } from \"../codex/preserveLogin.js\";\nimport { detectAuthJsonOwner } from \"../codex/files.js\";\nimport {\n CodexBusyError,\n listCodexSessions,\n migrateSessionProvider,\n} from \"../codex/sessions.js\";\nimport { isCodexRunning, launchCodex, restartCodex } from \"../codex/restart.js\";\nimport { parseTranscript, resolveRolloutPath } from \"../codex/transcript.js\";\nimport { resolveDataDirInfo } from \"../db/dataDir.js\";\nimport { pointerFilePath } from \"../db/dataDirPointer.js\";\nimport {\n previewMigration,\n runMigration,\n type MigrationEvent,\n} from \"./migration.js\";\nimport {\n isRestartRequired,\n getRestartInfo,\n isMaintenance,\n} from \"../util/maintenance.js\";\nimport { authJsonPath, configTomlPath } from \"../codex/paths.js\";\nimport {\n renderApplyPowerShellScript,\n renderApplyShellScript,\n} from \"../codex/bundle.js\";\nimport {\n appendCodexHistory,\n deleteCodexHistory,\n getCodexHistoryById,\n hasInitialHistory,\n listCodexHistory,\n} from \"../db/codexHistory.js\";\nimport { buildCcSwitchFiles } from \"../setup/snippets.js\";\nimport {\n clearActiveOverride,\n getActiveOverride,\n setActiveOverride,\n} from \"../db/overrides.js\";\nimport {\n parseLogBodyMode,\n parseLogRetentionDays,\n parseMaxDbSizeMb,\n resolveLogBodyMode,\n resolveLogRetentionDays,\n resolveMaxDbSizeMb,\n} from \"../logging/settings.js\";\nimport {\n callOpenAICompat,\n callResponsesPassthrough,\n UpstreamError,\n} from \"../upstream/openaiCompatClient.js\";\nimport type { ChatRequest, ResponsesRequest } from \"../translate/types.js\";\nimport {\n compareVersions,\n DEFAULT_TTL_MS,\n getCachedStatus,\n refreshCacheInBackground,\n resolveStatus,\n type UpdateStatus,\n} from \"../util/checkUpdate.js\";\nimport { detectUpdateMethod } from \"../setup/updateMethod.js\";\nimport { runUpdate } from \"../setup/runUpdate.js\";\nimport type { AuthContext } from \"../auth/middleware.js\";\nimport {\n buildSessionCookie,\n clearSessionCookieHeader,\n} from \"../auth/middleware.js\";\nimport {\n countUsers,\n createUser,\n findUserByUsername,\n listUsers,\n updateUser,\n type UserRow,\n} from \"../db/users.js\";\nimport { hashPassword, verifyPassword } from \"../security/passwords.js\";\nimport {\n createSession,\n deleteSession,\n} from \"../db/sessions.js\";\nimport {\n createApiKey,\n listApiKeys,\n revokeApiKey,\n} from \"../db/apiKeys.js\";\nimport {\n deleteUpstreamKey,\n listUpstreamKeys,\n setUpstreamKey,\n} from \"../db/upstreamKeys.js\";\nimport { loadMasterKey } from \"../security/masterKey.js\";\nimport {\n deleteOAuthClient,\n listOAuthClients,\n upsertOAuthClient,\n type OAuthProvider,\n} from \"../db/oauthClients.js\";\n\nconst SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n\nfunction publicUser(u: UserRow): Record<string, unknown> {\n return {\n id: u.id,\n username: u.username,\n display_name: u.display_name,\n is_admin: u.is_admin === 1,\n status: u.status,\n created_at: u.created_at,\n updated_at: u.updated_at,\n };\n}\n\nfunction sessionCookieOpts(cfg: Config): { ttlMs: number; secure: boolean } {\n return { ttlMs: SESSION_TTL_MS, secure: cfg.cookieSecure };\n}\n\n// Locate dist/web/ relative to THIS module's location, not process.cwd().\n// When mimo2codex is installed globally (`npm install -g`), the user invokes\n// it from any working directory, so cwd is never the install root.\n//\n// Two layouts to support:\n// - production (`node dist/cli.js`): <root>/dist/admin/router.js → ../web\n// - dev mode (`tsx src/cli.ts`): <root>/src/admin/router.ts → ../../dist/web\n//\n// The list is checked in order; whichever exists wins. If neither exists we\n// fall back to the production path for the 503 message — that's the path the\n// user is most likely meant to populate via `npm run web:build`.\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\nconst STATIC_ROOT = (() => {\n const candidates = [\n resolve(__dirname, \"..\", \"web\"), // dist/admin → dist/web\n resolve(__dirname, \"..\", \"..\", \"dist\", \"web\"), // src/admin → dist/web\n ];\n for (const c of candidates) {\n if (existsSync(c)) return c;\n }\n return candidates[0];\n})();\n\nconst MIME: Record<string, string> = {\n \".html\": \"text/html; charset=utf-8\",\n \".js\": \"application/javascript; charset=utf-8\",\n \".css\": \"text/css; charset=utf-8\",\n \".json\": \"application/json; charset=utf-8\",\n \".svg\": \"image/svg+xml\",\n \".png\": \"image/png\",\n \".jpg\": \"image/jpeg\",\n \".jpeg\": \"image/jpeg\",\n \".ico\": \"image/x-icon\",\n \".woff\": \"font/woff\",\n \".woff2\": \"font/woff2\",\n};\n\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.statusCode = status;\n res.setHeader(\"Content-Type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify(body));\n}\n\nfunction sendError(res: ServerResponse, status: number, code: string, message: string): void {\n sendJson(res, status, { error: { code, message, status } });\n}\n\nasync function readJsonBody<T>(req: IncomingMessage, maxBytes = 1024 * 1024): Promise<T> {\n return await new Promise<T>((resolve, reject) => {\n const chunks: Buffer[] = [];\n let total = 0;\n req.on(\"data\", (c: Buffer) => {\n total += c.length;\n if (total > maxBytes) {\n reject(new Error(\"admin body too large\"));\n req.destroy();\n return;\n }\n chunks.push(c);\n });\n req.on(\"end\", () => {\n try {\n const text = Buffer.concat(chunks).toString(\"utf-8\");\n if (!text) return resolve({} as T);\n resolve(JSON.parse(text) as T);\n } catch (err) {\n reject(err);\n }\n });\n req.on(\"error\", reject);\n });\n}\n\nfunction parseUrl(req: IncomingMessage): { pathname: string; query: URLSearchParams } {\n const u = new URL(req.url ?? \"/\", \"http://localhost\");\n return { pathname: u.pathname, query: u.searchParams };\n}\n\nfunction providerStateFor(cfg: Config): Array<Record<string, unknown>> {\n return PROVIDER_LIST.map((p) => {\n const runtime = cfg.providers[p.id];\n return {\n id: p.id,\n shortcut: p.shortcut,\n display_name: p.displayName,\n default: cfg.defaultProviderId === p.id,\n enabled: !!runtime,\n api_key_present: !!runtime,\n api_key_env: p.envKeys,\n base_url: runtime?.baseUrl ?? p.defaultBaseUrl,\n default_model: p.defaultModel,\n flags: runtime?.flags ?? {},\n };\n });\n}\n\ninterface RouteContext {\n cfg: Config;\n req: IncomingMessage;\n res: ServerResponse;\n pathname: string;\n query: URLSearchParams;\n auth: AuthContext;\n}\n\nasync function handleApi(ctx: RouteContext): Promise<void> {\n const { cfg, req, res, pathname, query } = ctx;\n\n // GET /admin/api/health — quick liveness probe for the UI\n if (req.method === \"GET\" && pathname === \"/admin/api/health\") {\n // userAgent is \"mimo2codex/<version>\"; split out the version part for\n // the footer (cli.ts is the only place that has the package.json version\n // and it stashes it on cfg.userAgent during startup).\n const version = cfg.userAgent.startsWith(\"mimo2codex/\")\n ? cfg.userAgent.slice(\"mimo2codex/\".length)\n : cfg.userAgent;\n const restart = isRestartRequired() ? getRestartInfo() : null;\n return sendJson(res, 200, {\n ok: true,\n // Always true here: this handler only runs when admin is enabled. The\n // crash-disabled path in server.ts answers /admin/api/health itself with\n // adminEnabled:false, so probes can rely on this field in both states.\n adminEnabled: true,\n dataDir: cfg.dataDir,\n version,\n authMode: cfg.authMode,\n maintenance: isMaintenance(),\n restartRequired: !!restart,\n restartReason: restart?.reason ?? null,\n restartTargetDir: restart?.targetDir ?? null,\n });\n }\n\n // GET /admin/api/desktop/sentinel — tell the admin UI whether it's running\n // INSIDE the Electron desktop shell. The sidecar process spawned by the\n // desktop main has MIMO2CODEX_DESKTOP_PARENT=1 injected into its env (see\n // package/desktop/src/paths.ts → SidecarPaths.env). The admin React UI uses\n // this to decide whether to show the \"Open Desktop Settings\" button — it\n // only makes sense when there IS a desktop shell to call back to. This\n // endpoint is unauthenticated on purpose: the answer is the same for every\n // caller and contains no secrets.\n if (req.method === \"GET\" && pathname === \"/admin/api/desktop/sentinel\") {\n return sendJson(res, 200, {\n inDesktop: process.env.MIMO2CODEX_DESKTOP_PARENT === \"1\",\n });\n }\n\n // POST /admin/api/desktop/signal — body { action: \"open-settings\" }. The\n // sidecar writes a one-line JSON to <dataDir>/.desktop-signal.json, which\n // the Electron main watches via fs.watch and acts on (e.g. opening the\n // Settings window). This indirection avoids exposing a custom protocol or\n // running Electron's preload bridge inside the admin BrowserWindow.\n // Locked to the in-desktop case so a stray POST from a browser tab on a\n // CLI install can't make anything happen.\n if (req.method === \"POST\" && pathname === \"/admin/api/desktop/signal\") {\n if (process.env.MIMO2CODEX_DESKTOP_PARENT !== \"1\") {\n return sendError(\n res,\n 404,\n \"not_in_desktop\",\n \"this endpoint is only available when running inside the desktop shell\"\n );\n }\n type Body = { action?: unknown };\n const body = await readJsonBody<Body>(req);\n const action = typeof body.action === \"string\" ? body.action : null;\n if (action !== \"open-settings\") {\n return sendError(res, 400, \"invalid_action\", \"action must be 'open-settings'\");\n }\n try {\n const { writeFileSync } = await import(\"node:fs\");\n const signalPath = join(cfg.dataDir, \".desktop-signal.json\");\n writeFileSync(\n signalPath,\n JSON.stringify({ action, ts: Date.now() }) + \"\\n\",\n \"utf8\"\n );\n log.info(\"desktop signal written\", { signalPath, action });\n return sendJson(res, 200, { ok: true });\n } catch (err) {\n log.error(\"failed to write desktop signal\", { error: (err as Error).message });\n return sendError(res, 500, \"signal_write_failed\", (err as Error).message);\n }\n }\n\n // GET /admin/api/data-dir/info — current path + resolution source for the\n // settings UI. The SPA renders different copy depending on whether the user\n // can actually change the path (pointer/default) or not (cli/env).\n if (req.method === \"GET\" && pathname === \"/admin/api/data-dir/info\") {\n // Re-resolve *without* the CLI override (which we no longer have access to\n // after boot) to learn what env/pointer/default would have produced. Then\n // compare against cfg.dataDir — if they diverge, the operator passed\n // --data-dir at boot, so the UI must be read-only.\n const info = resolveDataDirInfo(undefined, process.env);\n const current = cfg.dataDir;\n let source: \"cli\" | \"env\" | \"pointer\" | \"default\";\n if (info.envOverride && current === info.envOverride) {\n source = \"env\";\n } else if (info.pointerValue && current === info.pointerValue) {\n source = \"pointer\";\n } else if (current === info.defaultDir) {\n source = \"default\";\n } else {\n source = \"cli\";\n }\n return sendJson(res, 200, {\n current,\n source,\n defaultDir: info.defaultDir,\n envOverride: info.envOverride,\n pointerValue: info.pointerValue,\n pointerPath: pointerFilePath(),\n editable: source === \"default\" || source === \"pointer\",\n });\n }\n\n // POST /admin/api/data-dir/preview — body { targetDir }. Returns whether\n // the target is acceptable and how big the planned copy is.\n if (req.method === \"POST\" && pathname === \"/admin/api/data-dir/preview\") {\n if (isRestartRequired()) {\n return sendError(\n res,\n 409,\n \"restart_required\",\n \"a previous migration already succeeded — restart the process before starting another\"\n );\n }\n type Body = { targetDir?: unknown };\n const body = await readJsonBody<Body>(req);\n if (typeof body.targetDir !== \"string\") {\n return sendError(res, 400, \"invalid_body\", \"targetDir is required\");\n }\n const result = previewMigration(cfg.dataDir, body.targetDir);\n return sendJson(res, 200, result);\n }\n\n // POST /admin/api/data-dir/migrate — body { targetDir }. SSE stream of\n // progress events. Connection ends after `done` or `error`. The server\n // stays in maintenance mode and the restartRequired flag flips to true on\n // success; the SPA shows a persistent banner until the user restarts.\n if (req.method === \"POST\" && pathname === \"/admin/api/data-dir/migrate\") {\n if (isRestartRequired()) {\n return sendError(\n res,\n 409,\n \"restart_required\",\n \"a previous migration already succeeded — restart the process before starting another\"\n );\n }\n if (isMaintenance()) {\n return sendError(\n res,\n 409,\n \"maintenance_in_progress\",\n \"a migration is already in progress\"\n );\n }\n type Body = { targetDir?: unknown };\n const body = await readJsonBody<Body>(req);\n if (typeof body.targetDir !== \"string\" || !body.targetDir) {\n return sendError(res, 400, \"invalid_body\", \"targetDir is required\");\n }\n res.statusCode = 200;\n res.setHeader(\"Content-Type\", \"text/event-stream; charset=utf-8\");\n res.setHeader(\"Cache-Control\", \"no-cache, no-store\");\n res.setHeader(\"Connection\", \"keep-alive\");\n res.setHeader(\"X-Accel-Buffering\", \"no\");\n res.flushHeaders?.();\n const write = (evt: MigrationEvent): void => {\n res.write(`event: ${evt.type}\\ndata: ${JSON.stringify(evt)}\\n\\n`);\n };\n try {\n await runMigration(cfg.dataDir, body.targetDir, write);\n } catch (err) {\n // Defensive — runMigration emits its own error events; this catches\n // anything that escapes the inner try/catches.\n write({\n type: \"error\",\n code: \"internal_error\",\n message: (err as Error).message,\n });\n log.error(\"data-dir migration crashed\", {\n error: (err as Error).message,\n });\n }\n res.end();\n return;\n }\n\n // GET /admin/api/auth/me — returns the active user or null. In authMode=off\n // always returns { user: null, authMode: \"off\" } so the SPA can detect the\n // mode without 401-checking. In authMode=on returns the resolved user;\n // unauthenticated callers see { user: null, authMode: \"on\" } and the SPA\n // routes them to /admin/login.\n if (req.method === \"GET\" && pathname === \"/admin/api/auth/me\") {\n let allowRegister = false;\n try {\n allowRegister = getSetting(\"auth.allowRegister\") === \"1\";\n } catch {\n // settings not available (e.g. pre-migration startup) — default off.\n }\n return sendJson(res, 200, {\n authMode: cfg.authMode,\n user: ctx.auth.user ? publicUser(ctx.auth.user) : null,\n // Hint for SPA: when authMode=on AND no users exist yet, the bootstrap\n // page should be shown instead of login.\n needsBootstrap: cfg.authMode === \"on\" && safeCountUsers() === 0,\n // Whether the Login page should surface a \"create account\" link.\n allowRegister: cfg.authMode === \"on\" && allowRegister,\n });\n }\n\n // POST /admin/api/auth/login { username, password } → sets session cookie.\n if (req.method === \"POST\" && pathname === \"/admin/api/auth/login\") {\n if (cfg.authMode !== \"on\") {\n return sendError(res, 400, \"auth_off\", \"login is unavailable in local mode\");\n }\n const body = await readJsonBody<{ username?: unknown; password?: unknown }>(req);\n if (typeof body.username !== \"string\" || typeof body.password !== \"string\") {\n return sendError(res, 400, \"invalid_body\", \"username and password are required\");\n }\n const user = findUserByUsername(body.username);\n if (!user || !user.password_hash || user.status !== \"active\") {\n return sendError(res, 401, \"invalid_credentials\", \"incorrect username or password\");\n }\n const ok = await verifyPassword(body.password, user.password_hash);\n if (!ok) {\n return sendError(res, 401, \"invalid_credentials\", \"incorrect username or password\");\n }\n const { token } = createSession({\n userId: user.id,\n userAgent: (req.headers[\"user-agent\"] as string | undefined) ?? null,\n ip: req.socket?.remoteAddress ?? null,\n ttlMs: SESSION_TTL_MS,\n });\n res.setHeader(\"Set-Cookie\", buildSessionCookie(token, sessionCookieOpts(cfg)));\n return sendJson(res, 200, { user: publicUser(user) });\n }\n\n // POST /admin/api/auth/register { username, password, displayName? }\n // Self-registration. Only accepted when authMode=on AND the operator has\n // flipped the `auth.allowRegister` setting on (default OFF). Result user\n // is a normal (non-admin) account, immediately signed in.\n if (req.method === \"POST\" && pathname === \"/admin/api/auth/register\") {\n if (cfg.authMode !== \"on\") {\n return sendError(res, 400, \"auth_off\", \"registration is unavailable in local mode\");\n }\n const allowed = (() => {\n try {\n return getSetting(\"auth.allowRegister\") === \"1\";\n } catch {\n return false;\n }\n })();\n if (!allowed) {\n return sendError(res, 403, \"register_disabled\", \"open registration is disabled by admin\");\n }\n const body = await readJsonBody<{\n username?: unknown;\n password?: unknown;\n displayName?: unknown;\n }>(req);\n if (typeof body.username !== \"string\" || typeof body.password !== \"string\") {\n return sendError(res, 400, \"invalid_body\", \"username and password are required\");\n }\n if (body.password.length < 8) {\n return sendError(res, 400, \"weak_password\", \"password must be at least 8 characters\");\n }\n if (findUserByUsername(body.username)) {\n return sendError(res, 409, \"username_taken\", \"username is already in use\");\n }\n const hash = await hashPassword(body.password);\n const user = createUser({\n username: body.username,\n displayName: typeof body.displayName === \"string\" ? body.displayName : null,\n passwordHash: hash,\n isAdmin: false,\n });\n log.info(`user self-registered: username=${user.username}`);\n const { token } = createSession({\n userId: user.id,\n userAgent: (req.headers[\"user-agent\"] as string | undefined) ?? null,\n ip: req.socket?.remoteAddress ?? null,\n ttlMs: SESSION_TTL_MS,\n });\n res.setHeader(\"Set-Cookie\", buildSessionCookie(token, sessionCookieOpts(cfg)));\n return sendJson(res, 200, { user: publicUser(user) });\n }\n\n // POST /admin/api/auth/logout — clears session cookie + DB row (if any).\n if (req.method === \"POST\" && pathname === \"/admin/api/auth/logout\") {\n if (ctx.auth.session) deleteSession(ctx.auth.session.id);\n res.setHeader(\"Set-Cookie\", clearSessionCookieHeader(cfg.cookieSecure));\n return sendJson(res, 200, { ok: true });\n }\n\n // POST /admin/api/bootstrap { username, password, displayName? }\n // Creates the first admin. Valid only while the users table is empty AND\n // authMode=on. Standard first-run-wizard pattern (Jellyfin / Nextcloud /\n // Synology): whoever sets the password first wins. The race window is\n // between container start and the operator opening /admin/.\n if (req.method === \"POST\" && pathname === \"/admin/api/bootstrap\") {\n if (cfg.authMode !== \"on\") {\n return sendError(res, 400, \"auth_off\", \"bootstrap is unavailable in local mode\");\n }\n if (safeCountUsers() > 0) {\n return sendError(res, 409, \"already_initialized\", \"an admin user already exists\");\n }\n const body = await readJsonBody<{\n username?: unknown;\n password?: unknown;\n displayName?: unknown;\n }>(req);\n if (typeof body.username !== \"string\" || typeof body.password !== \"string\") {\n return sendError(res, 400, \"invalid_body\", \"username and password are required\");\n }\n if (body.password.length < 8) {\n return sendError(res, 400, \"weak_password\", \"password must be at least 8 characters\");\n }\n const hash = await hashPassword(body.password);\n const user = createUser({\n username: body.username,\n displayName: typeof body.displayName === \"string\" ? body.displayName : null,\n passwordHash: hash,\n isAdmin: true,\n });\n log.info(`first admin created via bootstrap: username=${user.username}`);\n const { token } = createSession({\n userId: user.id,\n userAgent: (req.headers[\"user-agent\"] as string | undefined) ?? null,\n ip: req.socket?.remoteAddress ?? null,\n ttlMs: SESSION_TTL_MS,\n });\n res.setHeader(\"Set-Cookie\", buildSessionCookie(token, sessionCookieOpts(cfg)));\n return sendJson(res, 200, { user: publicUser(user) });\n }\n\n // GET /admin/api/users — admin-only. Returns each user with their lifetime\n // request/token usage joined from chat_logs (v5 schema). Empty stats (new\n // user, never made a request) come through as zeros.\n if (req.method === \"GET\" && pathname === \"/admin/api/users\") {\n if (!requireAdmin(ctx)) return;\n let usage: ReturnType<typeof aggregateUsagePerUser> = [];\n try {\n usage = aggregateUsagePerUser();\n } catch {\n // Pre-v5 DB shouldn't be reachable in normal operation, but defensively\n // fall back to empty usage so the page still loads.\n }\n const byUser = new Map(usage.map((u) => [u.user_id, u]));\n return sendJson(res, 200, {\n users: listUsers().map((u) => ({\n ...publicUser(u),\n request_count: byUser.get(u.id)?.request_count ?? 0,\n total_tokens: byUser.get(u.id)?.total_tokens ?? 0,\n last_activity: byUser.get(u.id)?.last_activity ?? null,\n })),\n });\n }\n\n // POST /admin/api/users { username, password, displayName?, isAdmin? }\n if (req.method === \"POST\" && pathname === \"/admin/api/users\") {\n if (!requireAdmin(ctx)) return;\n const body = await readJsonBody<{\n username?: unknown;\n password?: unknown;\n displayName?: unknown;\n isAdmin?: unknown;\n }>(req);\n if (typeof body.username !== \"string\" || typeof body.password !== \"string\") {\n return sendError(res, 400, \"invalid_body\", \"username and password required\");\n }\n if (body.password.length < 8) {\n return sendError(res, 400, \"weak_password\", \"password must be at least 8 characters\");\n }\n if (findUserByUsername(body.username)) {\n return sendError(res, 409, \"username_taken\", \"username is already in use\");\n }\n const hash = await hashPassword(body.password);\n const user = createUser({\n username: body.username,\n displayName: typeof body.displayName === \"string\" ? body.displayName : null,\n passwordHash: hash,\n isAdmin: body.isAdmin === true,\n });\n log.info(`user created by admin: username=${user.username} admin=${user.is_admin}`);\n return sendJson(res, 200, { user: publicUser(user) });\n }\n\n // GET /admin/api/auth/oauth-providers — publicly readable so the login\n // page can render the right set of social-login buttons. No secret material\n // is exposed; only {provider, enabled}.\n if (req.method === \"GET\" && pathname === \"/admin/api/auth/oauth-providers\") {\n if (cfg.authMode !== \"on\") {\n return sendJson(res, 200, { providers: [] });\n }\n let rows: ReturnType<typeof listOAuthClients> = [];\n try {\n rows = listOAuthClients();\n } catch {\n // settings db could be unopened in degenerate setups — return empty.\n }\n return sendJson(res, 200, {\n providers: rows\n .filter((r) => r.enabled)\n .map((r) => ({ provider: r.provider, callback_url: r.callback_url })),\n });\n }\n\n // GET /admin/api/oauth-clients — admin-only list (full metadata, no secret).\n if (req.method === \"GET\" && pathname === \"/admin/api/oauth-clients\") {\n if (!requireAdmin(ctx)) return;\n return sendJson(res, 200, { clients: listOAuthClients() });\n }\n\n // PUT /admin/api/oauth-clients/:provider — admin upsert. clientSecret may\n // be omitted on subsequent edits to preserve the existing ciphertext.\n // DELETE /admin/api/oauth-clients/:provider — admin remove.\n {\n const m = /^\\/admin\\/api\\/oauth-clients\\/(gitee|github)$/.exec(pathname);\n if (m) {\n if (!requireAdmin(ctx)) return;\n const provider = m[1] as OAuthProvider;\n if (req.method === \"PUT\") {\n const body = await readJsonBody<{\n clientId?: unknown;\n clientSecret?: unknown;\n callbackUrl?: unknown;\n enabled?: unknown;\n }>(req);\n if (\n typeof body.clientId !== \"string\" ||\n typeof body.callbackUrl !== \"string\"\n ) {\n return sendError(res, 400, \"invalid_body\", \"clientId and callbackUrl required\");\n }\n const { key } = loadMasterKey(cfg.dataDir);\n try {\n upsertOAuthClient(\n {\n provider,\n clientId: body.clientId,\n clientSecret:\n typeof body.clientSecret === \"string\" && body.clientSecret.length > 0\n ? body.clientSecret\n : null,\n callbackUrl: body.callbackUrl,\n enabled: body.enabled === true,\n },\n key\n );\n } catch (err) {\n return sendError(res, 400, \"invalid_body\", (err as Error).message);\n }\n log.info(`oauth client upserted: provider=${provider} enabled=${body.enabled === true}`);\n return sendJson(res, 200, { ok: true });\n }\n if (req.method === \"DELETE\") {\n const deleted = deleteOAuthClient(provider);\n return sendJson(res, 200, { deleted });\n }\n }\n }\n\n // GET /admin/api/me/api-keys — current user's bearer tokens (no secret).\n if (req.method === \"GET\" && pathname === \"/admin/api/me/api-keys\") {\n if (!requireUser(ctx)) return;\n const rows = listApiKeys(ctx.auth.user!.id).map((r) => ({\n id: r.id,\n name: r.name,\n key_prefix: r.key_prefix,\n created_at: r.created_at,\n last_used_at: r.last_used_at,\n revoked_at: r.revoked_at,\n }));\n return sendJson(res, 200, { api_keys: rows });\n }\n\n // POST /admin/api/me/api-keys { name } — mint a new bearer token. Plaintext\n // is returned EXACTLY ONCE in the response — the UI must surface it\n // immediately because the hash-only storage means we can't show it again.\n if (req.method === \"POST\" && pathname === \"/admin/api/me/api-keys\") {\n if (!requireUser(ctx)) return;\n const body = await readJsonBody<{ name?: unknown }>(req);\n const name = typeof body.name === \"string\" && body.name.trim() ? body.name.trim() : \"default\";\n const { token, row } = createApiKey(ctx.auth.user!.id, name);\n return sendJson(res, 200, {\n token,\n api_key: {\n id: row.id,\n name: row.name,\n key_prefix: row.key_prefix,\n created_at: row.created_at,\n last_used_at: row.last_used_at,\n revoked_at: row.revoked_at,\n },\n });\n }\n\n // DELETE /admin/api/me/api-keys/:id — revoke (soft-delete) one of the\n // current user's keys. We use soft-delete so future logs can still attribute\n // older requests to a named key.\n {\n const m = /^\\/admin\\/api\\/me\\/api-keys\\/(\\d+)$/.exec(pathname);\n if (m && req.method === \"DELETE\") {\n if (!requireUser(ctx)) return;\n const ok = revokeApiKey(ctx.auth.user!.id, Number(m[1]));\n if (!ok) return sendError(res, 404, \"not_found\", \"no such api key\");\n return sendJson(res, 200, { revoked: true });\n }\n }\n\n // GET /admin/api/me/upstream-keys — BYOK provider list (no ciphertext).\n if (req.method === \"GET\" && pathname === \"/admin/api/me/upstream-keys\") {\n if (!requireUser(ctx)) return;\n return sendJson(res, 200, { upstream_keys: listUpstreamKeys(ctx.auth.user!.id) });\n }\n\n // PUT /admin/api/me/upstream-keys/:providerId { apiKey } — set / replace.\n // DELETE /admin/api/me/upstream-keys/:providerId — clear.\n {\n const m = /^\\/admin\\/api\\/me\\/upstream-keys\\/([A-Za-z0-9_-]+)$/.exec(pathname);\n if (m) {\n if (!requireUser(ctx)) return;\n const providerId = m[1];\n // Validate the provider exists — both built-in and any user-declared\n // generic are acceptable targets.\n if (!PROVIDER_LIST.find((p) => p.id === providerId)) {\n return sendError(res, 404, \"unknown_provider\", `provider ${providerId} not registered`);\n }\n if (req.method === \"PUT\") {\n const body = await readJsonBody<{ apiKey?: unknown }>(req);\n if (typeof body.apiKey !== \"string\" || !body.apiKey.trim()) {\n return sendError(res, 400, \"invalid_body\", \"apiKey is required\");\n }\n const { key } = loadMasterKey(cfg.dataDir);\n setUpstreamKey(ctx.auth.user!.id, providerId, body.apiKey.trim(), key);\n return sendJson(res, 200, { ok: true, provider_id: providerId });\n }\n if (req.method === \"DELETE\") {\n const deleted = deleteUpstreamKey(ctx.auth.user!.id, providerId);\n return sendJson(res, 200, { deleted });\n }\n }\n }\n\n // GET / PUT /admin/api/auth/register-policy — admin-only toggle for\n // self-registration. Mirrors the settings.auth.allowRegister setting but\n // gives the UI a focused endpoint without exposing the whole settings KV.\n if (pathname === \"/admin/api/auth/register-policy\") {\n if (req.method === \"GET\") {\n if (!requireAdmin(ctx)) return;\n let allowRegister = false;\n try {\n allowRegister = getSetting(\"auth.allowRegister\") === \"1\";\n } catch {\n /* default false */\n }\n return sendJson(res, 200, { allowRegister });\n }\n if (req.method === \"PUT\") {\n if (!requireAdmin(ctx)) return;\n const body = await readJsonBody<{ allowRegister?: unknown }>(req);\n if (typeof body.allowRegister !== \"boolean\") {\n return sendError(res, 400, \"invalid_body\", \"allowRegister: boolean is required\");\n }\n setSetting(\"auth.allowRegister\", body.allowRegister ? \"1\" : \"0\");\n log.info(`auth.allowRegister set to ${body.allowRegister} via admin UI`);\n return sendJson(res, 200, { allowRegister: body.allowRegister });\n }\n }\n\n // PATCH /admin/api/users/:id { displayName?, isAdmin?, status?, password? }\n {\n const m = /^\\/admin\\/api\\/users\\/(\\d+)$/.exec(pathname);\n if (m && req.method === \"PATCH\") {\n if (!requireAdmin(ctx)) return;\n const id = Number(m[1]);\n const body = await readJsonBody<{\n displayName?: unknown;\n isAdmin?: unknown;\n status?: unknown;\n password?: unknown;\n }>(req);\n const patch: Parameters<typeof updateUser>[1] = {};\n if (typeof body.displayName === \"string\" || body.displayName === null) {\n patch.displayName = body.displayName as string | null;\n }\n if (typeof body.isAdmin === \"boolean\") patch.isAdmin = body.isAdmin;\n if (body.status === \"active\" || body.status === \"disabled\") patch.status = body.status;\n if (typeof body.password === \"string\") {\n if (body.password.length < 8) {\n return sendError(res, 400, \"weak_password\", \"password must be at least 8 characters\");\n }\n patch.passwordHash = await hashPassword(body.password);\n }\n const updated = updateUser(id, patch);\n if (!updated) return sendError(res, 404, \"not_found\", `no user with id ${id}`);\n return sendJson(res, 200, { user: publicUser(updated) });\n }\n }\n\n if (req.method === \"GET\" && pathname === \"/admin/api/providers\") {\n return sendJson(res, 200, { providers: providerStateFor(cfg) });\n }\n\n // GET /admin/api/generic-providers\n // Returns the raw spec list from providers.json + metadata about where\n // the file lives. The admin UI uses this to populate its editor.\n if (req.method === \"GET\" && pathname === \"/admin/api/generic-providers\") {\n const loc = locateProvidersFile(process.env, cfg.dataDir);\n if (!loc) {\n // dataDir is unset (admin runs without persistence) — no canonical\n // path to edit. UI surfaces this as a read-only banner.\n return sendJson(res, 200, {\n specs: [],\n path: null,\n source: null,\n exists: false,\n editable: false,\n notice:\n \"no providers.json location available — admin UI cannot edit when --no-admin is set\",\n });\n }\n let specs: GenericProviderSpec[] = [];\n if (loc.exists) {\n try {\n specs = readSpecsFromFile(loc.path);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n return sendJson(res, 200, {\n specs: [],\n path: loc.path,\n source: loc.source,\n exists: true,\n editable: true,\n error: err.message,\n });\n }\n throw err;\n }\n }\n return sendJson(res, 200, {\n specs,\n path: loc.path,\n source: loc.source,\n exists: loc.exists,\n editable: true,\n });\n }\n\n // PUT /admin/api/generic-providers\n // Body: { providers: GenericProviderSpec[] }\n // Validates every spec, then atomically writes to providers.json. A\n // restart is still required for the change to take effect (the in-memory\n // registry is initialized once at startup).\n if (req.method === \"PUT\" && pathname === \"/admin/api/generic-providers\") {\n const loc = locateProvidersFile(process.env, cfg.dataDir);\n if (!loc) {\n return sendError(\n res,\n 400,\n \"no_writable_location\",\n \"no providers.json path is available — set MIMO2CODEX_DATA_DIR or restart without --no-admin\"\n );\n }\n let body: { providers?: unknown };\n try {\n body = await readJsonBody<{ providers?: unknown }>(req);\n } catch (err) {\n return sendError(res, 400, \"invalid_json\", (err as Error).message);\n }\n if (!Array.isArray(body.providers)) {\n return sendError(res, 400, \"invalid_body\", \"body must include providers: array\");\n }\n try {\n writeSpecsToFile(loc.path, body.providers as GenericProviderSpec[]);\n } catch (err) {\n if (err instanceof GenericLoaderError) {\n return sendError(res, 400, \"validation_failed\", err.message);\n }\n return sendError(res, 500, \"write_failed\", (err as Error).message);\n }\n log.info(\n `providers.json updated via admin UI (${(body.providers as unknown[]).length} entries, restart required)`\n );\n return sendJson(res, 200, {\n ok: true,\n path: loc.path,\n restartRequired: true,\n });\n }\n\n // GET /admin/api/thinking-state\n // PUT /admin/api/thinking-state body { disabled?: boolean, forceHighEffort?: boolean }\n // PUT 写 settings.thinking.disabled / .forceHighEffort(同一请求可只改一个,未传字段不变)。\n // GET 返回两个开关的 effective + cliOverride 标志。disableThinking 受 CLI flag 控制;\n // forceHighEffort 目前仅 settings 控制。\n if (pathname === \"/admin/api/thinking-state\") {\n if (req.method === \"GET\") {\n const cliOverride = cfg.disableThinkingFromCli ?? null;\n const disabledFromSetting = (() => {\n try {\n return getSetting(\"thinking.disabled\") === \"1\";\n } catch {\n return false;\n }\n })();\n const forceHighEffortFromSetting = (() => {\n try {\n return getSetting(\"thinking.forceHighEffort\") === \"1\";\n } catch {\n return false;\n }\n })();\n const effective = cliOverride !== null ? cliOverride : disabledFromSetting;\n return sendJson(res, 200, {\n effective,\n cliOverride,\n setting: disabledFromSetting,\n forceHighEffort: forceHighEffortFromSetting,\n });\n }\n if (req.method === \"PUT\") {\n const body = await readJsonBody<{ disabled?: unknown; forceHighEffort?: unknown }>(req);\n let changed = false;\n if (typeof body.disabled === \"boolean\") {\n setSetting(\"thinking.disabled\", body.disabled ? \"1\" : \"0\");\n log.info(`thinking.disabled set to ${body.disabled} via admin UI`);\n changed = true;\n }\n if (typeof body.forceHighEffort === \"boolean\") {\n setSetting(\"thinking.forceHighEffort\", body.forceHighEffort ? \"1\" : \"0\");\n log.info(`thinking.forceHighEffort set to ${body.forceHighEffort} via admin UI`);\n changed = true;\n }\n if (!changed) {\n return sendError(\n res,\n 400,\n \"invalid_body\",\n \"body must include at least one of: disabled (boolean), forceHighEffort (boolean)\",\n );\n }\n return sendJson(res, 200, { ok: true });\n }\n return sendError(res, 405, \"method_not_allowed\", \"use GET or PUT\");\n }\n\n // GET /admin/api/web-search-state\n // PUT /admin/api/web-search-state body { enabled: boolean }\n // Controls whether mimo2codex forwards the web_search tool to the upstream.\n // OFF by default — MiMo's Web Search Plugin is separately billed and off by\n // default; forwarding without it 400s. CLI flag / env (--web-search /\n // MIMO2CODEX_WEB_SEARCH) overrides the DB setting when set.\n if (pathname === \"/admin/api/web-search-state\") {\n if (req.method === \"GET\") {\n const cliOverride = cfg.webSearchFromCli ?? null;\n const setting = (() => {\n try {\n return getSetting(\"mimo.webSearchEnabled\") === \"1\";\n } catch {\n return false;\n }\n })();\n const effective = cliOverride !== null ? cliOverride : setting;\n return sendJson(res, 200, { effective, cliOverride, setting });\n }\n if (req.method === \"PUT\") {\n const body = await readJsonBody<{ enabled?: unknown }>(req);\n if (typeof body.enabled !== \"boolean\") {\n return sendError(res, 400, \"invalid_body\", \"body must include enabled (boolean)\");\n }\n setSetting(\"mimo.webSearchEnabled\", body.enabled ? \"1\" : \"0\");\n log.info(`mimo.webSearchEnabled set to ${body.enabled} via admin UI`);\n return sendJson(res, 200, { ok: true });\n }\n return sendError(res, 405, \"method_not_allowed\", \"use GET or PUT\");\n }\n\n // GET/PUT /admin/api/vision-fallback — multimodal fallback toggle + model.\n // When enabled, requests containing images are automatically routed to a\n // vision-capable model even if the client's model doesn't support images.\n if (pathname === \"/admin/api/vision-fallback\") {\n if (req.method === \"GET\") {\n const enabled = (() => {\n try {\n return getSetting(\"codex.visionFallbackEnabled\") === \"1\";\n } catch {\n return false;\n }\n })();\n const model = (() => {\n try {\n return getSetting(\"codex.visionFallbackModel\") || \"mimo-v2.5\";\n } catch {\n return \"mimo-v2.5\";\n }\n })();\n return sendJson(res, 200, { enabled, model });\n }\n if (req.method === \"PUT\") {\n const body = await readJsonBody<{ enabled?: unknown; model?: unknown }>(req);\n let changed = false;\n if (typeof body.enabled === \"boolean\") {\n setSetting(\"codex.visionFallbackEnabled\", body.enabled ? \"1\" : \"0\");\n log.info(`codex.visionFallbackEnabled set to ${body.enabled} via admin UI`);\n changed = true;\n }\n if (typeof body.model === \"string\") {\n const trimmed = body.model.trim();\n if (!trimmed) {\n return sendError(res, 400, \"invalid_body\", \"model must be a non-empty string\");\n }\n setSetting(\"codex.visionFallbackModel\", trimmed);\n log.info(`codex.visionFallbackModel set to \"${trimmed}\" via admin UI`);\n changed = true;\n }\n if (!changed) {\n return sendError(\n res,\n 400,\n \"invalid_body\",\n \"body must include at least one of: enabled (boolean), model (string)\",\n );\n }\n return sendJson(res, 200, { ok: true });\n }\n return sendError(res, 405, \"method_not_allowed\", \"use GET or PUT\");\n }\n\n // GET/PUT /admin/api/log-settings — quick toggle for the \"model fallback\n // applied\" rewrite log. Default is silent (suppressed). env\n // MIMO2CODEX_SILENT_REWRITE, when set, overrides and disables the toggle.\n if (pathname === \"/admin/api/log-settings\") {\n if (req.method === \"GET\") {\n const cliOverride = cfg.silentRewriteFromCli ?? null;\n const bodyModeCliOverride = cfg.logBodyModeFromCli ?? null;\n const retentionDaysCliOverride =\n cfg.logRetentionDaysFromCli === undefined ? null : cfg.logRetentionDaysFromCli;\n const retentionDaysCliOverrideActive = cfg.logRetentionDaysFromCli !== undefined;\n const setting = (() => {\n try {\n const s = getSetting(\"logging.silentRewrite\");\n return s === null ? true : s !== \"0\";\n } catch {\n return true;\n }\n })();\n const effective = cliOverride !== null ? cliOverride : setting;\n return sendJson(res, 200, {\n silentRewrite: effective,\n cliOverride,\n bodyMode: resolveLogBodyMode(cfg),\n bodyModeCliOverride,\n retentionDays: resolveLogRetentionDays(cfg),\n retentionDaysCliOverride,\n retentionDaysCliOverrideActive,\n maxDbSizeMb: resolveMaxDbSizeMb(),\n });\n }\n if (req.method === \"PUT\") {\n const body = await readJsonBody<{\n silentRewrite?: unknown;\n bodyMode?: unknown;\n retentionDays?: unknown;\n maxDbSizeMb?: unknown;\n }>(req);\n const writes: Array<{ key: string; value: string; logMessage: string }> = [];\n if (body.silentRewrite !== undefined) {\n if (typeof body.silentRewrite !== \"boolean\") {\n return sendError(res, 400, \"invalid_body\", \"silentRewrite must be a boolean\");\n }\n writes.push({\n key: \"logging.silentRewrite\",\n value: body.silentRewrite ? \"1\" : \"0\",\n logMessage: `logging.silentRewrite set to ${body.silentRewrite} via admin UI`,\n });\n }\n if (body.bodyMode !== undefined) {\n if (typeof body.bodyMode !== \"string\" || !parseLogBodyMode(body.bodyMode)) {\n return sendError(res, 400, \"invalid_body\", \"bodyMode must be full, errors-only, or off\");\n }\n writes.push({\n key: \"logging.bodyMode\",\n value: body.bodyMode,\n logMessage: `logging.bodyMode set to ${body.bodyMode} via admin UI`,\n });\n }\n if (body.retentionDays !== undefined) {\n if (body.retentionDays !== null && !Number.isInteger(body.retentionDays)) {\n return sendError(res, 400, \"invalid_body\", \"retentionDays must be null or an integer\");\n }\n const parsed =\n body.retentionDays === null\n ? null\n : parseLogRetentionDays(String(body.retentionDays));\n if (parsed === undefined) {\n return sendError(\n res,\n 400,\n \"invalid_body\",\n \"retentionDays must be null, 0, or a positive integer\"\n );\n }\n writes.push({\n key: \"logging.retentionDays\",\n value: parsed === null ? \"0\" : String(parsed),\n logMessage: `logging.retentionDays set to ${parsed === null ? \"disabled\" : parsed} via admin UI`,\n });\n }\n if (body.maxDbSizeMb !== undefined) {\n if (body.maxDbSizeMb !== null && !Number.isInteger(body.maxDbSizeMb)) {\n return sendError(res, 400, \"invalid_body\", \"maxDbSizeMb must be null or an integer\");\n }\n const parsedMax =\n body.maxDbSizeMb === null ? null : parseMaxDbSizeMb(String(body.maxDbSizeMb));\n writes.push({\n key: \"logging.maxDbSizeMb\",\n value: parsedMax === null ? \"0\" : String(parsedMax),\n logMessage: `logging.maxDbSizeMb set to ${parsedMax === null ? \"disabled\" : parsedMax} via admin UI`,\n });\n }\n if (writes.length === 0) {\n return sendError(\n res,\n 400,\n \"invalid_body\",\n \"body must include at least one of: silentRewrite, bodyMode, retentionDays\"\n );\n }\n for (const write of writes) {\n setSetting(write.key, write.value);\n log.info(write.logMessage);\n }\n return sendJson(res, 200, { ok: true });\n }\n return sendError(res, 405, \"method_not_allowed\", \"use GET or PUT\");\n }\n\n // GET /admin/api/provider-presets\n // Returns the known-vendor preset metadata (matchBaseUrl / matchModelPrefix /\n // recommendedSpec) so the admin UI can auto-fill features when the user types\n // a known vendor's baseUrl/model into the New/Edit Generic Provider form. No\n // auth concerns — this is static metadata baked into the binary.\n if (req.method === \"GET\" && pathname === \"/admin/api/provider-presets\") {\n return sendJson(res, 200, { presets: PROVIDER_PRESETS });\n }\n\n // GET /admin/api/setup-snippets?provider=<id>\n // Returns every Codex-integration snippet variant (default auth.json,\n // env-key, cc-switch) so the Setup page can render all three tabs in one\n // round-trip. When `provider` is omitted, defaults to the configured\n // default provider — same fallback the CLI uses.\n if (req.method === \"GET\" && pathname === \"/admin/api/setup-snippets\") {\n const hint = query.get(\"provider\") ?? cfg.defaultProviderId;\n const bundle = buildSnippetBundle(hint, { host: cfg.host, port: cfg.port });\n return sendJson(res, 200, {\n bundle,\n defaultProviderId: cfg.defaultProviderId,\n providers: PROVIDER_LIST.map((p) => ({\n id: p.id,\n shortcut: p.shortcut,\n display_name: p.displayName,\n })),\n });\n }\n\n // /admin/api/providers/:id/models\n const provModels = pathname.match(/^\\/admin\\/api\\/providers\\/([^/]+)\\/models$/);\n if (provModels) {\n const id = provModels[1];\n if (!isProviderId(id)) return sendError(res, 404, \"unknown_provider\", `unknown provider ${id}`);\n if (req.method === \"GET\") {\n return sendJson(res, 200, { models: listModels(id) });\n }\n if (req.method === \"POST\") {\n const body = await readJsonBody<\n Partial<{ upstream_id: string; display_name: string; context_window: number | null }>\n >(req);\n if (!body.upstream_id) return sendError(res, 400, \"missing_upstream_id\", \"upstream_id required\");\n // Default new user-added models to a 1M context window — matches the\n // builtin MiMo / DeepSeek caps so the generated Codex toml doesn't\n // preemptively /compact, and matches user expectation that \"newly\n // added models advertise the same window as the rest of the catalog\".\n // Explicit values from the client (including null) still win.\n const contextWindow =\n body.context_window === undefined ? 1_000_000 : body.context_window;\n try {\n const row = insertCustomModel(id as ProviderId, {\n upstream_id: body.upstream_id,\n display_name: body.display_name,\n context_window: contextWindow,\n });\n return sendJson(res, 201, { model: row });\n } catch (err) {\n return sendError(res, 400, \"insert_failed\", (err as Error).message);\n }\n }\n return sendError(res, 405, \"method_not_allowed\", \"use GET or POST\");\n }\n\n // /admin/api/models/:id\n const modelId = pathname.match(/^\\/admin\\/api\\/models\\/(\\d+)$/);\n if (modelId) {\n const id = Number(modelId[1]);\n if (req.method === \"PATCH\") {\n const body = await readJsonBody<Record<string, unknown>>(req);\n try {\n const row = patchModel(id, body);\n if (!row) return sendError(res, 404, \"not_found\", `model ${id} not found`);\n return sendJson(res, 200, { model: row });\n } catch (err) {\n return sendError(res, 400, \"patch_failed\", (err as Error).message);\n }\n }\n if (req.method === \"DELETE\") {\n try {\n const ok = deleteModel(id);\n if (!ok) return sendError(res, 404, \"not_found\", `model ${id} not found`);\n return sendJson(res, 200, { deleted: true });\n } catch (err) {\n return sendError(res, 400, \"delete_failed\", (err as Error).message);\n }\n }\n return sendError(res, 405, \"method_not_allowed\", \"use PATCH or DELETE\");\n }\n\n if (req.method === \"GET\" && pathname === \"/admin/api/logs\") {\n const provider = query.get(\"provider\") ?? undefined;\n const model = query.get(\"model\") ?? undefined;\n const statusMin = query.get(\"statusMin\") ? Number(query.get(\"statusMin\")) : undefined;\n const statusMax = query.get(\"statusMax\") ? Number(query.get(\"statusMax\")) : undefined;\n const from = query.get(\"from\") ? Number(query.get(\"from\")) : undefined;\n const to = query.get(\"to\") ? Number(query.get(\"to\")) : undefined;\n const limit = query.get(\"limit\") ? Number(query.get(\"limit\")) : undefined;\n const offset = query.get(\"offset\") ? Number(query.get(\"offset\")) : undefined;\n return sendJson(res, 200, {\n logs: queryLogs({ provider, model, statusMin, statusMax, from, to, limit, offset }),\n });\n }\n\n if (req.method === \"GET\" && pathname === \"/admin/api/stats/errors\") {\n const range = query.get(\"range\") ?? \"24h\";\n return sendJson(res, 200, aggregateErrors(range));\n }\n\n if (req.method === \"GET\" && pathname === \"/admin/api/stats/latency\") {\n const range = query.get(\"range\") ?? \"24h\";\n return sendJson(res, 200, aggregateLatency(range));\n }\n\n if (req.method === \"GET\" && pathname === \"/admin/api/provider-health\") {\n // Window defaults to 1h; allow override via ?ms=<ms> for ad-hoc widening.\n const ms = query.get(\"ms\") ? Number(query.get(\"ms\")) : undefined;\n return sendJson(res, 200, { rows: aggregateProviderHealth(ms) });\n }\n\n if (req.method === \"DELETE\" && pathname === \"/admin/api/logs\") {\n if (query.get(\"all\") === \"1\") {\n return sendJson(res, 200, { removed: deleteAllLogs() });\n }\n const keepDays = query.get(\"keepDays\");\n if (keepDays !== null) {\n const n = Number(keepDays);\n if (!Number.isFinite(n) || n < 0) {\n return sendError(res, 400, \"invalid_query\", \"keepDays must be a non-negative number\");\n }\n const cutoff = Date.now() - n * 24 * 60 * 60 * 1000;\n return sendJson(res, 200, { removed: deleteLogsBefore(cutoff) });\n }\n const before = query.get(\"before\");\n if (!before) {\n return sendError(\n res,\n 400,\n \"missing_param\",\n \"one of ?all=1, ?keepDays=<n>, or ?before=<ts_ms> required\"\n );\n }\n return sendJson(res, 200, { removed: deleteLogsBefore(Number(before)) });\n }\n\n if (req.method === \"GET\" && pathname === \"/admin/api/db/size\") {\n const size = getDbSizeBytes();\n return sendJson(res, 200, {\n totalBytes: size.total,\n mainBytes: size.main,\n walBytes: size.wal,\n shmBytes: size.shm,\n });\n }\n\n if (req.method === \"POST\" && pathname === \"/admin/api/db/vacuum\") {\n const before = getDbSizeBytes().total;\n // VACUUM rewrites the whole db into a temp file — it needs roughly a second\n // copy of the db in free space. Pre-check so we fail with a clear message\n // instead of a half-written SQLITE_FULL mid-rebuild.\n const free = diskFreeBytes(cfg.dataDir);\n if (free < before) {\n return sendError(\n res,\n 400,\n \"insufficient_disk\",\n `VACUUM needs ~${Math.ceil(before / 1024 / 1024)} MB free but only ${Math.floor(\n free / 1024 / 1024\n )} MB is available`\n );\n }\n const { beforeBytes, afterBytes } = vacuumDb();\n log.info(`admin VACUUM: ${beforeBytes} → ${afterBytes} bytes`);\n return sendJson(res, 200, {\n beforeBytes,\n afterBytes,\n freedBytes: beforeBytes - afterBytes,\n });\n }\n\n // /admin/api/logs/:id — single log row including request_body + response_body.\n // Kept off the list endpoint so a 100-row table fetch doesn't haul megabytes\n // of payload across the wire on every refresh.\n const logIdMatch = pathname.match(/^\\/admin\\/api\\/logs\\/(\\d+)$/);\n if (logIdMatch && req.method === \"GET\") {\n const id = Number(logIdMatch[1]);\n const row = getLogById(id);\n if (!row) return sendError(res, 404, \"not_found\", `log ${id} not found`);\n return sendJson(res, 200, { log: row });\n }\n\n if (req.method === \"GET\" && pathname === \"/admin/api/mappings\") {\n return sendJson(res, 200, { mappings: aggregateMappings() });\n }\n\n if (req.method === \"GET\" && pathname === \"/admin/api/stats\") {\n const range = query.get(\"range\") ?? \"24h\";\n return sendJson(res, 200, aggregateStats(range));\n }\n\n // Per-bucket token timeseries for the dashboard chart. Dense (every\n // bucket in the window appears in `buckets`, zero-filled). Bucket size\n // is `?bucket=day` (default) or `?bucket=hour`.\n if (req.method === \"GET\" && pathname === \"/admin/api/stats/timeseries\") {\n const range = query.get(\"range\") ?? \"7d\";\n const bucketParam = query.get(\"bucket\");\n const bucket = bucketParam === \"hour\" ? \"hour\" : \"day\";\n return sendJson(res, 200, aggregateTokensTimeseries(range, bucket));\n }\n\n if (req.method === \"GET\" && pathname === \"/admin/api/settings\") {\n return sendJson(res, 200, { settings: listSettings() });\n }\n\n const settingKey = pathname.match(/^\\/admin\\/api\\/settings\\/([^/]+)$/);\n if (settingKey) {\n const key = decodeURIComponent(settingKey[1]);\n if (req.method === \"PUT\") {\n if (isForbiddenSettingKey(key)) {\n return sendError(\n res,\n 400,\n \"forbidden_setting\",\n `${key} cannot be stored in the UI — set the corresponding env var instead (MIMO_API_KEY, DS_API_KEY, DEEPSEEK_API_KEY) and restart mimo2codex.`\n );\n }\n const body = await readJsonBody<{ value?: unknown }>(req);\n if (typeof body.value !== \"string\") {\n return sendError(res, 400, \"invalid_value\", \"value must be a string\");\n }\n try {\n setSetting(key, body.value);\n return sendJson(res, 200, { key, value: body.value });\n } catch (err) {\n if (err instanceof ForbiddenSettingError) {\n return sendError(res, 400, \"forbidden_setting\", err.message);\n }\n throw err;\n }\n }\n if (req.method === \"DELETE\") {\n const ok = deleteSetting(key);\n if (!ok) return sendError(res, 404, \"not_found\", `setting ${key} not found`);\n return sendJson(res, 200, { deleted: true });\n }\n return sendError(res, 405, \"method_not_allowed\", \"use PUT or DELETE\");\n }\n\n // ──────────── Codex 启用 (replaces ccswitch) ────────────\n //\n // codex-state: read-only snapshot of ~/.codex/ ownership + backup list +\n // active runtime override. UI reads this on every page load so it can\n // surface the right warnings (e.g. \"your auth.json has a real OpenAI key,\n // overwriting will back it up\").\n if (req.method === \"GET\" && pathname === \"/admin/api/codex-state\") {\n const state = readCodexState();\n return sendJson(res, 200, {\n ...state,\n activeOverride: getActiveOverride(),\n });\n }\n\n // codex-targets: aggregated (provider × model) pickable from the UI.\n // Built-in models come from PROVIDER_LIST; custom models come from the\n // sqlite models table. We surface hasKey so the UI can disable the\n // runtime-override button on providers without an api key (the file-write\n // button is fine without a key — the user might be setting up first).\n if (req.method === \"GET\" && pathname === \"/admin/api/codex-targets\") {\n const state = readCodexState();\n const override = getActiveOverride();\n const targets: Array<Record<string, unknown>> = [];\n for (const p of PROVIDER_LIST) {\n const runtime = cfg.providers[p.id];\n // Built-in catalog (declared by Provider.builtinModels).\n for (const m of p.builtinModels) {\n if (m.deprecatedAfter) continue;\n targets.push({\n providerId: p.id,\n providerDisplayName: p.displayName,\n providerKey: tomlProviderKeyFor(p.id),\n modelId: m.id,\n displayName: m.displayName ?? null,\n contextWindow: m.contextWindow ?? null,\n maxOutputTokens: m.maxOutputTokens ?? null,\n note: m.note ?? null,\n source: \"builtin\",\n hasKey: !!runtime,\n isCurrentOverride:\n override?.providerId === p.id && override?.modelId === m.id,\n });\n }\n // Custom models from the admin's models table — only when admin/db\n // is up (we're inside the admin router, so it is).\n try {\n const customRows = listModels(p.id);\n for (const row of customRows) {\n if (row.is_builtin === 1) continue; // dedup against builtinModels above\n targets.push({\n providerId: p.id,\n providerDisplayName: p.displayName,\n providerKey: tomlProviderKeyFor(p.id),\n modelId: row.upstream_id,\n displayName: row.display_name ?? null,\n contextWindow: row.context_window ?? null,\n maxOutputTokens: null,\n note: null,\n source: \"custom\",\n hasKey: !!runtime,\n isCurrentOverride:\n override?.providerId === p.id && override?.modelId === row.upstream_id,\n });\n }\n } catch {\n // listModels needs db open; skip silently if it's not.\n }\n }\n return sendJson(res, 200, {\n targets,\n activeOverride: override,\n authJsonOwner: state.authJsonOwner,\n });\n }\n\n // codex-apply: write ~/.codex/auth.json + config.toml for (provider, model).\n // Replaces ccswitch. The user must restart Codex for changes to take effect.\n if (req.method === \"POST\" && pathname === \"/admin/api/codex-apply\") {\n let body: { providerId?: unknown; modelId?: unknown; preserveLogin?: unknown };\n try {\n body = await readJsonBody<{ providerId?: unknown; modelId?: unknown; preserveLogin?: unknown }>(req);\n } catch (err) {\n return sendError(res, 400, \"invalid_json\", (err as Error).message);\n }\n if (typeof body.providerId !== \"string\" || typeof body.modelId !== \"string\") {\n return sendError(res, 400, \"invalid_body\", \"providerId and modelId must be strings\");\n }\n if (!isProviderId(body.providerId)) {\n return sendError(res, 400, \"unknown_provider\", `unknown provider ${body.providerId}`);\n }\n const provider = PROVIDERS[body.providerId];\n // Validate the model exists in either the built-in catalog or the\n // custom-models table. Forwarding an arbitrary unknown id would write\n // a config Codex can't actually use.\n const builtinHit = provider.builtinModels.some((m) => m.id === body.modelId);\n let customHit = false;\n if (!builtinHit) {\n try {\n customHit = listModels(provider.id).some((r) => r.upstream_id === body.modelId);\n } catch {\n /* db not open — only built-in validation available */\n }\n }\n if (!builtinHit && !customHit) {\n return sendError(\n res,\n 400,\n \"unknown_model\",\n `model \"${body.modelId}\" is not in ${provider.id}'s catalog`\n );\n }\n // Build the SnippetTarget the writer expects. Reuse resolveSnippetTarget\n // for the default, then override modelId so we honor the user's pick.\n const baseTarget = resolveSnippetTarget(body.providerId);\n const targetModelMeta = provider.builtinModels.find((m) => m.id === body.modelId);\n const target = {\n ...baseTarget,\n modelId: body.modelId,\n contextWindow: targetModelMeta?.contextWindow ?? baseTarget.contextWindow,\n maxOutputTokens: targetModelMeta?.maxOutputTokens ?? baseTarget.maxOutputTokens,\n };\n try {\n const userId = ctx.auth.user?.id ?? null;\n // First-ever apply for this user (or local timeline): snapshot the\n // pre-existing ~/.codex state as `initial` so the user can always\n // roll back to \"what was there before mimo2codex touched anything\".\n // Server-mode containers can't read the user's machine, so this is\n // best-effort and only fires in local mode where the path resolves\n // to the host filesystem.\n if (cfg.authMode !== \"on\" && !hasInitialHistory(userId)) {\n try {\n captureInitialCodexSnapshot(userId);\n } catch {\n // If ~/.codex doesn't exist yet, that's the genuine \"initial\" —\n // skip the snapshot; future restores simply have no rollback target.\n }\n }\n\n // Render the would-be config from a single source of truth. Both modes\n // store these strings in history; local mode also calls applyCodex()\n // to write them to disk via the existing backup-aware path.\n const { authJson, configToml } = buildCcSwitchFiles(\n { host: cfg.host, port: cfg.port },\n target\n );\n\n let backupTs: number | null = null;\n let authBackup: string | null = null;\n let tomlBackup: string | null = null;\n let authJsonOwnerBefore: \"mimo2codex\" | \"external\" | \"missing\" = \"missing\";\n let preserved = false;\n let authPreserved = false;\n\n if (cfg.authMode !== \"on\") {\n // \"Preserve login\" keeps a real \"Sign in with ChatGPT\" auth.json intact\n // (so OpenAI's official Codex mobile/remote feature keeps working) and\n // only redirects the model backend. Default ON whenever a real foreign\n // login is detected; the client may force it either way.\n const wantPreserve =\n typeof body.preserveLogin === \"boolean\"\n ? body.preserveLogin\n : detectAuthJsonOwner() === \"external\";\n const result = wantPreserve\n ? applyCodexPreserveLogin(target, { host: cfg.host, port: cfg.port })\n : applyCodex(target, { host: cfg.host, port: cfg.port });\n backupTs = result.backupTs;\n authBackup = result.authBackup;\n tomlBackup = result.tomlBackup;\n authJsonOwnerBefore = result.authJsonOwnerBefore;\n preserved = result.preserved;\n authPreserved = (result as { authPreserved?: boolean }).authPreserved ?? false;\n log.info(\n `codex profile applied via webui: provider=${provider.id} model=${body.modelId} ` +\n `authJsonOwnerBefore=${result.authJsonOwnerBefore} backupTs=${result.backupTs} ` +\n `preserveLogin=${wantPreserve} authPreserved=${authPreserved}`\n );\n } else {\n log.info(\n `codex profile recorded for user=${userId ?? \"?\"}: provider=${provider.id} model=${body.modelId} ` +\n `(server-mode — no local file write)`\n );\n }\n\n const history = appendCodexHistory({\n userId,\n kind: \"apply\",\n providerId: provider.id,\n modelId: body.modelId,\n authJson,\n configToml,\n note: null,\n });\n\n return sendJson(res, 200, {\n ok: true,\n backupTs,\n authBackup,\n tomlBackup,\n authJsonOwnerBefore,\n preserved,\n authPreserved,\n restartRequired: true,\n historyId: history.id,\n // In server mode, the UI must show the download CTA instead of the\n // \"we wrote files locally\" success message.\n bundleUrl:\n cfg.authMode === \"on\"\n ? `/admin/api/codex-history/${history.id}/bundle`\n : null,\n });\n } catch (err) {\n log.error(\"codex-apply failed\", { error: (err as Error).message });\n return sendError(res, 500, \"apply_failed\", (err as Error).message);\n }\n }\n\n // GET /admin/api/codex-history — list rows for the current user (or the\n // shared local timeline when authMode≠on).\n if (req.method === \"GET\" && pathname === \"/admin/api/codex-history\") {\n if (cfg.authMode === \"on\" && !ctx.auth.user) {\n return sendError(res, 401, \"no_session\", \"session required\");\n }\n const userId = cfg.authMode === \"on\" ? ctx.auth.user?.id ?? null : null;\n const rows = listCodexHistory(userId, 50).map((r) => ({\n id: r.id,\n ts: r.ts,\n kind: r.kind,\n provider_id: r.provider_id,\n model_id: r.model_id,\n note: r.note,\n }));\n return sendJson(res, 200, { history: rows });\n }\n\n // GET /admin/api/codex-history/:id/bundle — return the rendered files +\n // ready-to-run apply scripts for either platform.\n {\n const m = /^\\/admin\\/api\\/codex-history\\/(\\d+)\\/bundle$/.exec(pathname);\n if (m && req.method === \"GET\") {\n const id = Number(m[1]);\n const row = getCodexHistoryById(id);\n if (!row) return sendError(res, 404, \"not_found\", `no history row id=${id}`);\n // Scope check: per-user in server mode; in local mode only the NULL\n // (shared) timeline is accessible.\n if (cfg.authMode !== \"on\" && row.user_id !== null) {\n return sendError(res, 403, \"forbidden\", \"row belongs to a user; local mode cannot access\");\n }\n if (cfg.authMode === \"on\" && (row.user_id == null || row.user_id !== ctx.auth.user?.id)) {\n return sendError(res, 403, \"forbidden\", \"row does not belong to the current user\");\n }\n // We return the stored auth.json verbatim — including the\n // \"mimo2codex-local\" placeholder. Users go to /admin/account, mint a\n // key explicitly, and paste it into the downloaded auth.json before\n // running the apply script. Earlier iterations auto-minted a key per\n // download, which was confusing (silent key creation, multiple keys\n // accumulating for one user, no clear UX moment for the user to copy\n // the value somewhere). Explicit > implicit.\n return sendJson(res, 200, {\n history: {\n id: row.id,\n ts: row.ts,\n kind: row.kind,\n provider_id: row.provider_id,\n model_id: row.model_id,\n note: row.note,\n },\n files: {\n authJson: row.auth_json,\n configToml: row.config_toml,\n },\n scripts: {\n posix: renderApplyShellScript({\n authJson: row.auth_json,\n configToml: row.config_toml,\n providerId: row.provider_id ?? \"?\",\n modelId: row.model_id ?? \"?\",\n }),\n powershell: renderApplyPowerShellScript({\n authJson: row.auth_json,\n configToml: row.config_toml,\n providerId: row.provider_id ?? \"?\",\n modelId: row.model_id ?? \"?\",\n }),\n },\n // Kept for response-shape stability with previous releases.\n mintedKey: null,\n });\n }\n }\n\n // POST /admin/api/codex-import { authJson, configToml, providerId?, modelId?, note? }\n // User uploads an auth.json + config.toml pair (typically from another\n // machine) and we file it as a new history row of kind='apply'. Same UX\n // shape as a normal apply: local mode also writes the files to ~/.codex/,\n // server mode just records and the user later downloads the bundle.\n if (req.method === \"POST\" && pathname === \"/admin/api/codex-import\") {\n if (cfg.authMode === \"on\" && !ctx.auth.user) {\n return sendError(res, 401, \"no_session\", \"session required\");\n }\n const body = await readJsonBody<{\n authJson?: unknown;\n configToml?: unknown;\n providerId?: unknown;\n modelId?: unknown;\n note?: unknown;\n }>(req);\n if (typeof body.authJson !== \"string\" || typeof body.configToml !== \"string\") {\n return sendError(res, 400, \"invalid_body\", \"authJson and configToml are required strings\");\n }\n if (body.authJson.length > 64 * 1024 || body.configToml.length > 64 * 1024) {\n return sendError(res, 400, \"too_large\", \"uploaded file exceeds 64KB\");\n }\n // Cheap shape validation so users can't accidentally upload non-JSON or\n // non-TOML and end up with broken Codex configs at restore time.\n try {\n const parsed = JSON.parse(body.authJson);\n if (!parsed || typeof parsed !== \"object\") throw new Error(\"not an object\");\n } catch (err) {\n return sendError(res, 400, \"invalid_auth_json\", `auth.json must be valid JSON: ${(err as Error).message}`);\n }\n const userId = cfg.authMode === \"on\" ? ctx.auth.user?.id ?? null : null;\n if (cfg.authMode !== \"on\" && !hasInitialHistory(userId)) {\n try {\n captureInitialCodexSnapshot(userId);\n } catch {\n /* best effort */\n }\n }\n // In local mode we write the uploaded files straight to disk so users get\n // the same one-click experience as a regular Apply.\n if (cfg.authMode !== \"on\") {\n try {\n const authPath = authJsonPath();\n const tomlPath = configTomlPath();\n // Hand-roll the backup ts so both halves stay paired.\n const ts = Date.now();\n // Lazily import the file helpers — they're already pulled in by\n // applyCodex's path, but referencing through \"../codex/files.js\" keeps\n // the import surface tidy.\n const { atomicWrite, backupFile } = await import(\"../codex/files.js\");\n backupFile(authPath, ts);\n backupFile(tomlPath, ts);\n atomicWrite(authPath, body.authJson);\n atomicWrite(tomlPath, body.configToml);\n } catch (err) {\n return sendError(res, 500, \"write_failed\", (err as Error).message);\n }\n }\n const history = appendCodexHistory({\n userId,\n kind: \"apply\",\n providerId: typeof body.providerId === \"string\" ? body.providerId : null,\n modelId: typeof body.modelId === \"string\" ? body.modelId : null,\n authJson: body.authJson,\n configToml: body.configToml,\n note: typeof body.note === \"string\" ? body.note : \"imported from local\",\n });\n log.info(\n `codex import recorded: user=${userId ?? \"?\"} historyId=${history.id} ` +\n `provider=${typeof body.providerId === \"string\" ? body.providerId : \"n/a\"}`\n );\n return sendJson(res, 200, {\n ok: true,\n historyId: history.id,\n restartRequired: cfg.authMode !== \"on\",\n bundleUrl:\n cfg.authMode === \"on\"\n ? `/admin/api/codex-history/${history.id}/bundle`\n : null,\n });\n }\n\n // GET /admin/api/codex-current-bundle — \"export the currently-active config\n // to my local machine\" entry point. Finds the most recent apply (per-user\n // in server mode, NULL timeline in local mode) and reuses the bundle\n // endpoint's renderer + mint-key flow.\n if (req.method === \"GET\" && pathname === \"/admin/api/codex-current-bundle\") {\n if (cfg.authMode === \"on\" && !ctx.auth.user) {\n return sendError(res, 401, \"no_session\", \"session required\");\n }\n const userId = cfg.authMode === \"on\" ? ctx.auth.user?.id ?? null : null;\n const history = listCodexHistory(userId, 50);\n const target = history.find((r) => r.kind !== \"initial\") ?? history[0];\n if (!target) {\n return sendError(\n res,\n 404,\n \"no_history\",\n \"no Codex config has been applied yet — apply one from this page first\"\n );\n }\n // Redirect to the existing bundle endpoint so all the mint-key + script-\n // rendering logic stays in one place.\n res.statusCode = 302;\n res.setHeader(\"Location\", `/admin/api/codex-history/${target.id}/bundle`);\n res.end();\n return;\n }\n\n // DELETE /admin/api/codex-history/:id — drop a non-initial row from the\n // current user's timeline. Initial rows are protected (they're the only\n // path back to the pre-mimo2codex state).\n {\n const m = /^\\/admin\\/api\\/codex-history\\/(\\d+)$/.exec(pathname);\n if (m && req.method === \"DELETE\") {\n if (cfg.authMode === \"on\" && !ctx.auth.user) {\n return sendError(res, 401, \"no_session\", \"session required\");\n }\n const id = Number(m[1]);\n const userId = cfg.authMode === \"on\" ? ctx.auth.user?.id ?? null : null;\n const ok = deleteCodexHistory(id, userId);\n if (!ok) return sendError(res, 400, \"delete_blocked\", \"row missing or is the protected initial snapshot\");\n return sendJson(res, 200, { deleted: true });\n }\n }\n\n // codex-backups: manual delete. Used by the UI's trash button on each\n // backup row. Preserved pairs (those that captured an external auth.json)\n // require `?force=1` so a careless click can't lose the only path back\n // to the user's original Codex config.\n const backupTsMatch = pathname.match(/^\\/admin\\/api\\/codex-backups\\/(\\d+)$/);\n if (backupTsMatch && req.method === \"DELETE\") {\n const ts = Number(backupTsMatch[1]);\n const force = query.get(\"force\") === \"1\" || query.get(\"force\") === \"true\";\n try {\n const removed = deleteBackupPair(ts, { force });\n log.info(`codex backup deleted: ts=${ts} force=${force} files=${removed}`);\n return sendJson(res, 200, { ok: true, removed });\n } catch (err) {\n const msg = (err as Error).message;\n if (msg.includes(\"no backup pair\")) {\n return sendError(res, 404, \"not_found\", msg);\n }\n if (msg.includes(\"preserved\")) {\n return sendError(res, 400, \"preserved_backup\", msg);\n }\n return sendError(res, 500, \"delete_failed\", msg);\n }\n }\n\n // codex-restore: undo a previous apply by restoring both files from a\n // paired backup. ts comes from /codex-state.backups.\n if (req.method === \"POST\" && pathname === \"/admin/api/codex-restore\") {\n let body: { ts?: unknown };\n try {\n body = await readJsonBody<{ ts?: unknown }>(req);\n } catch (err) {\n return sendError(res, 400, \"invalid_json\", (err as Error).message);\n }\n if (typeof body.ts !== \"number\" || !Number.isFinite(body.ts)) {\n return sendError(res, 400, \"invalid_body\", \"ts must be a number\");\n }\n try {\n restoreCodex(body.ts);\n log.info(`codex profile restored from backup ts=${body.ts}`);\n // Record the restore in history (local-mode timeline). We re-read the\n // now-restored files so the entry reflects what was actually written.\n try {\n const userId = cfg.authMode === \"on\" ? ctx.auth.user?.id ?? null : null;\n const authPath = authJsonPath();\n const tomlPath = configTomlPath();\n appendCodexHistory({\n userId,\n kind: \"restore\",\n providerId: null,\n modelId: null,\n authJson: existsSync(authPath) ? readFileSync(authPath, \"utf-8\") : \"\",\n configToml: existsSync(tomlPath) ? readFileSync(tomlPath, \"utf-8\") : \"\",\n note: `restored from backup ts=${body.ts}`,\n });\n } catch {\n // History recording is best-effort; the actual restore already succeeded.\n }\n return sendJson(res, 200, { ok: true, restartRequired: true });\n } catch (err) {\n const msg = (err as Error).message;\n const code = msg.includes(\"no backup pair\")\n ? \"not_found\"\n : msg.includes(\"incomplete\")\n ? \"incomplete_pair\"\n : \"restore_failed\";\n const status = code === \"not_found\" ? 404 : 400;\n return sendError(res, status, code, msg);\n }\n }\n\n // GET /admin/api/codex-sessions — list Codex Desktop sessions (read-only),\n // grouped client-side by provider → project (cwd) → session. Reads Codex's\n // own state_<N>.sqlite. Local mode only: a server-mode container has no\n // access to the operator's ~/.codex.\n if (req.method === \"GET\" && pathname === \"/admin/api/codex-sessions\") {\n if (cfg.authMode === \"on\") {\n return sendJson(res, 200, {\n localOnly: true,\n dbPath: null,\n available: false,\n sessions: [],\n providers: [],\n });\n }\n try {\n const result = listCodexSessions();\n return sendJson(res, 200, { localOnly: false, ...result });\n } catch (err) {\n return sendError(res, 500, \"sessions_read_failed\", (err as Error).message);\n }\n }\n\n // GET /admin/api/codex-sessions/transcript?id=<id> — parse a session's\n // rollout JSONL into a readable transcript (messages / tool calls / patches)\n // for the preview drawer. Read-only, local mode only.\n if (req.method === \"GET\" && pathname === \"/admin/api/codex-sessions/transcript\") {\n if (cfg.authMode === \"on\") {\n return sendJson(res, 200, { localOnly: true, available: false, cwd: null, model: null, items: [] });\n }\n const id = query.get(\"id\");\n if (!id) return sendError(res, 400, \"invalid_body\", \"id query param is required\");\n try {\n const session = listCodexSessions().sessions.find((s) => s.id === id);\n const rolloutPath = resolveRolloutPath(id, session?.rolloutPath ?? null);\n const transcript = parseTranscript(rolloutPath);\n return sendJson(res, 200, { localOnly: false, title: session?.title ?? \"\", ...transcript });\n } catch (err) {\n return sendError(res, 500, \"transcript_failed\", (err as Error).message);\n }\n }\n\n // POST /admin/api/codex-sessions/migrate — move a session to another\n // provider by rewriting its model_provider (DB + rollout). Backs up first\n // and refuses while Codex Desktop holds the DB lock. Local mode only.\n if (req.method === \"POST\" && pathname === \"/admin/api/codex-sessions/migrate\") {\n if (cfg.authMode === \"on\") {\n return sendError(res, 400, \"local_only\", \"session migration is only available in local mode\");\n }\n let body: { id?: unknown; toProvider?: unknown };\n try {\n body = await readJsonBody<{ id?: unknown; toProvider?: unknown }>(req);\n } catch (err) {\n return sendError(res, 400, \"invalid_json\", (err as Error).message);\n }\n if (typeof body.id !== \"string\" || !body.id.trim()) {\n return sendError(res, 400, \"invalid_body\", \"id must be a non-empty string\");\n }\n if (typeof body.toProvider !== \"string\" || !body.toProvider.trim()) {\n return sendError(res, 400, \"invalid_body\", \"toProvider must be a non-empty string\");\n }\n try {\n const result = migrateSessionProvider(body.id, body.toProvider);\n log.info(\n `codex session migrated: id=${result.id} ${result.fromProvider} → ${result.toProvider}`\n );\n return sendJson(res, 200, { ok: true, restartRequired: true, ...result });\n } catch (err) {\n if (err instanceof CodexBusyError) {\n return sendError(\n res,\n 409,\n \"codex_running\",\n \"Codex Desktop appears to be running — fully quit it before migrating a session\"\n );\n }\n const msg = (err as Error).message;\n const status = msg.includes(\"not found\") ? 404 : 400;\n return sendError(res, status, \"migrate_failed\", msg);\n }\n }\n\n // GET /admin/api/codex-status — is the Codex Desktop app running? Used by the\n // desktop shell to offer to launch it on startup. Local mode only.\n if (req.method === \"GET\" && pathname === \"/admin/api/codex-status\") {\n if (cfg.authMode === \"on\") {\n return sendJson(res, 200, { localOnly: true, supported: false, running: false });\n }\n try {\n const status = await isCodexRunning();\n return sendJson(res, 200, { localOnly: false, ...status });\n } catch (err) {\n return sendError(res, 500, \"status_failed\", (err as Error).message);\n }\n }\n\n // POST /admin/api/codex-launch — launch Codex Desktop (no kill). Local only.\n if (req.method === \"POST\" && pathname === \"/admin/api/codex-launch\") {\n if (cfg.authMode === \"on\") {\n return sendError(res, 400, \"local_only\", \"launching Codex is only available in local mode\");\n }\n try {\n const result = await launchCodex();\n if (!result.supported) {\n return sendError(res, 400, \"unsupported_platform\", `launching Codex isn't supported on ${process.platform}`);\n }\n log.info(`codex launch requested: launched=${result.launched}`);\n return sendJson(res, 200, { ok: true, ...result });\n } catch (err) {\n return sendError(res, 500, \"launch_failed\", (err as Error).message);\n }\n }\n\n // POST /admin/api/codex-restart — kill + relaunch the Codex Desktop app so\n // a freshly-applied config takes effect without a manual restart. Launches\n // Codex if it wasn't running. Local mode only.\n if (req.method === \"POST\" && pathname === \"/admin/api/codex-restart\") {\n if (cfg.authMode === \"on\") {\n return sendError(res, 400, \"local_only\", \"restarting Codex is only available in local mode\");\n }\n try {\n const result = await restartCodex();\n if (!result.supported) {\n return sendError(\n res,\n 400,\n \"unsupported_platform\",\n `restarting Codex isn't supported on ${result.platform} — please restart it manually`\n );\n }\n log.info(\n `codex restart: wasRunning=${result.wasRunning} killed=${result.killed} relaunched=${result.relaunched}`\n );\n return sendJson(res, 200, { ok: true, ...result });\n } catch (err) {\n return sendError(res, 500, \"restart_failed\", (err as Error).message);\n }\n }\n\n // active-override: runtime model override stored in settings DB. Pass-0\n // of selectProvider() honors it before any normal routing logic.\n if (req.method === \"GET\" && pathname === \"/admin/api/active-override\") {\n return sendJson(res, 200, { override: getActiveOverride() });\n }\n if (req.method === \"PUT\" && pathname === \"/admin/api/active-override\") {\n let body: { providerId?: unknown; modelId?: unknown };\n try {\n body = await readJsonBody<{ providerId?: unknown; modelId?: unknown }>(req);\n } catch (err) {\n return sendError(res, 400, \"invalid_json\", (err as Error).message);\n }\n if (typeof body.providerId !== \"string\" || typeof body.modelId !== \"string\") {\n return sendError(res, 400, \"invalid_body\", \"providerId and modelId must be strings\");\n }\n if (!isProviderId(body.providerId)) {\n return sendError(res, 400, \"unknown_provider\", `unknown provider ${body.providerId}`);\n }\n // Require the provider to have a runtime (api key); without it the\n // override would be silently ignored at request time and the user\n // would think the switch worked.\n if (!cfg.providers[body.providerId]) {\n return sendError(\n res,\n 400,\n \"provider_has_no_key\",\n `provider ${body.providerId} has no api key configured — override would have no effect`\n );\n }\n setActiveOverride(body.providerId, body.modelId);\n log.info(`active override set: provider=${body.providerId} model=${body.modelId}`);\n return sendJson(res, 200, { override: { providerId: body.providerId, modelId: body.modelId } });\n }\n if (req.method === \"DELETE\" && pathname === \"/admin/api/active-override\") {\n clearActiveOverride();\n log.info(\"active override cleared\");\n return sendJson(res, 200, { deleted: true });\n }\n\n // codex-dir: get / set / clear the codex directory override. The override\n // is stored in settings.codex.dir; codexDir() in src/codex/paths.ts reads\n // it before falling back to CODEX_HOME env or ~/.codex. GET returns the\n // currently-effective dir + the override source (so the UI can show\n // \"default\" vs \"env\" vs \"user-set\"), PUT validates and writes, DELETE\n // clears the override.\n if (pathname === \"/admin/api/codex-dir\") {\n if (req.method === \"GET\") {\n const override = getSetting(\"codex.dir\");\n const envOverride = process.env.CODEX_HOME ?? null;\n const source: \"user\" | \"env\" | \"default\" = override\n ? \"user\"\n : envOverride\n ? \"env\"\n : \"default\";\n const effective = readCodexState().codexDir;\n return sendJson(res, 200, { effective, override, envOverride, source });\n }\n if (req.method === \"PUT\") {\n const body = await readJsonBody<{ dir?: unknown }>(req);\n if (typeof body.dir !== \"string\" || !body.dir.trim()) {\n return sendError(res, 400, \"invalid_body\", \"dir must be a non-empty string\");\n }\n const dir = body.dir.trim();\n if (!pathIsAbsolute(dir)) {\n return sendError(\n res,\n 400,\n \"not_absolute\",\n `codex dir must be an absolute path; got ${dir}`\n );\n }\n // Tolerate non-existent paths — the user may be configuring this before\n // Codex is installed, or before mkdir-ing the dir themselves. Only\n // reject if the path exists AND points at a non-directory (a file or\n // device), which is unambiguously wrong.\n if (existsSync(dir)) {\n const stat = statSync(dir);\n if (!stat.isDirectory()) {\n return sendError(\n res,\n 400,\n \"not_a_directory\",\n `${dir} exists but is not a directory`\n );\n }\n }\n setSetting(\"codex.dir\", dir);\n log.info(`codex dir override set: ${dir}`);\n return sendJson(res, 200, { effective: dir, override: dir, source: \"user\" });\n }\n if (req.method === \"DELETE\") {\n deleteSetting(\"codex.dir\");\n log.info(\"codex dir override cleared\");\n const envOverride = process.env.CODEX_HOME ?? null;\n const effective = readCodexState().codexDir;\n return sendJson(res, 200, {\n effective,\n override: null,\n envOverride,\n source: envOverride ? \"env\" : \"default\",\n });\n }\n return sendError(res, 405, \"method_not_allowed\", \"use GET / PUT / DELETE\");\n }\n\n // probe-model: send a minimal chat / responses ping to the upstream so the\n // user can verify a (provider, model) actually works end-to-end (api key\n // valid, base url reachable, model id recognized) before flipping Codex\n // to it. Times out at PROBE_TIMEOUT_MS so a hung upstream can't lock up\n // the admin UI.\n if (req.method === \"POST\" && pathname === \"/admin/api/probe-model\") {\n const body = await readJsonBody<{ providerId?: unknown; modelId?: unknown }>(req);\n if (typeof body.providerId !== \"string\" || typeof body.modelId !== \"string\") {\n return sendError(res, 400, \"invalid_body\", \"providerId and modelId must be strings\");\n }\n if (!isProviderId(body.providerId)) {\n return sendError(res, 400, \"unknown_provider\", `unknown provider ${body.providerId}`);\n }\n const provider = PROVIDERS[body.providerId];\n const runtime = cfg.providers[body.providerId];\n if (!runtime) {\n return sendJson(res, 200, {\n ok: false,\n latencyMs: 0,\n error: {\n code: \"no_api_key\",\n message: `provider ${body.providerId} has no API key configured`,\n },\n });\n }\n\n const PROBE_TIMEOUT_MS = 15_000;\n const ac = new AbortController();\n const timer = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);\n const start = Date.now();\n try {\n const upstreamCfg = {\n baseUrl: runtime.baseUrl,\n apiKey: runtime.apiKey,\n userAgent: cfg.userAgent,\n enhanceError: provider.enhanceError,\n // Probes don't carry conversation history — context overflow is\n // impossible by construction, so the \"friendly\" rewrite has nothing\n // to do; passthrough keeps any upstream 400 verbatim for debugging.\n contextOverflowMode: \"passthrough\" as const,\n };\n let httpRes: Response;\n let upstreamPath: string;\n if (provider.wireApi === \"responses\") {\n const responsesBody: ResponsesRequest = {\n model: body.modelId,\n input: [\n {\n type: \"message\",\n role: \"user\",\n content: [{ type: \"input_text\", text: \"ping\" }],\n },\n ],\n stream: false,\n max_output_tokens: 16,\n } as ResponsesRequest;\n upstreamPath = \"/responses\";\n httpRes = await callResponsesPassthrough(upstreamCfg, responsesBody, ac.signal);\n } else {\n const chatBody: ChatRequest = {\n model: body.modelId,\n messages: [{ role: \"user\", content: \"ping\" }],\n stream: false,\n max_completion_tokens: 16,\n };\n upstreamPath = \"/chat/completions\";\n httpRes = await callOpenAICompat(upstreamCfg, chatBody, ac.signal);\n }\n const json = (await httpRes.json()) as Record<string, unknown>;\n const latencyMs = Date.now() - start;\n const sample = extractProbeSample(json);\n return sendJson(res, 200, {\n ok: true,\n latencyMs,\n statusCode: httpRes.status,\n upstreamPath,\n sample,\n });\n } catch (err) {\n const latencyMs = Date.now() - start;\n if ((err as Error).name === \"AbortError\") {\n return sendJson(res, 200, {\n ok: false,\n latencyMs,\n error: {\n code: \"timeout\",\n message: `probe exceeded ${PROBE_TIMEOUT_MS}ms — upstream slow or unreachable`,\n },\n });\n }\n if (err instanceof UpstreamError) {\n return sendJson(res, 200, {\n ok: false,\n latencyMs,\n statusCode: err.status,\n error: { code: err.code, message: err.message },\n });\n }\n return sendJson(res, 200, {\n ok: false,\n latencyMs,\n error: { code: \"unknown\", message: (err as Error).message },\n });\n } finally {\n clearTimeout(timer);\n }\n }\n\n // ─── Update-check endpoints ─────────────────────────────────────────────\n // Layered so the webui can: poll status without touching the network, force\n // a fresh check on demand, kick off the actual update with streamed log\n // output, and persist a \"stop bugging me\" preference.\n if (req.method === \"GET\" && pathname === \"/admin/api/update-status\") {\n return sendJson(res, 200, buildUpdateStatusPayload(cfg));\n }\n\n if (req.method === \"POST\" && pathname === \"/admin/api/check-update\") {\n const version = parseCurrentVersion(cfg);\n try {\n const status = await resolveStatus({\n currentVersion: version,\n dataDir: cfg.dataDir || null,\n ttlMs: 0, // user explicitly asked: always hit the network\n });\n return sendJson(res, 200, decorateStatus(status, cfg));\n } catch (err) {\n return sendError(res, 502, \"network_failure\", (err as Error).message);\n }\n }\n\n if (req.method === \"POST\" && pathname === \"/admin/api/update\") {\n // Stream the update's stdout/stderr to the browser via SSE. After the\n // last frame, schedule a graceful shutdown so the user re-launches into\n // the upgraded binary. The frontend shows a \"please restart\" message\n // when the connection drops cleanly.\n res.statusCode = 200;\n res.setHeader(\"Content-Type\", \"text/event-stream; charset=utf-8\");\n res.setHeader(\"Cache-Control\", \"no-cache, no-store\");\n res.setHeader(\"Connection\", \"keep-alive\");\n res.setHeader(\"X-Accel-Buffering\", \"no\");\n res.flushHeaders?.();\n const writeEvent = (event: string, data: unknown): void => {\n res.write(`event: ${event}\\ndata: ${JSON.stringify(data)}\\n\\n`);\n };\n try {\n writeEvent(\"start\", { ts: Date.now() });\n const result = await runUpdate({\n onLine: (line, stream) => writeEvent(\"line\", { line, stream }),\n });\n writeEvent(\"done\", {\n exitCode: result.exitCode,\n method: result.method,\n skipped: result.skipped,\n });\n res.end();\n // Only schedule self-shutdown on a successful update — keep the server\n // alive on failure so the user can retry from the webui.\n if (!result.skipped && result.exitCode === 0) {\n log.info(\"update succeeded via webui — scheduling shutdown\");\n setTimeout(() => process.exit(0), 500).unref();\n }\n } catch (err) {\n writeEvent(\"error\", { message: (err as Error).message });\n res.end();\n }\n return;\n }\n\n if (req.method === \"POST\" && pathname === \"/admin/api/update-preference\") {\n type PrefBody = { updateCheckDisabled?: unknown; ignoredVersion?: unknown };\n const body = await readJsonBody<PrefBody>(req);\n if (typeof body.updateCheckDisabled === \"boolean\") {\n setSetting(\"updateCheckDisabled\", body.updateCheckDisabled ? \"1\" : \"0\");\n }\n if (typeof body.ignoredVersion === \"string\") {\n setSetting(\"ignoredVersion\", body.ignoredVersion);\n } else if (body.ignoredVersion === null) {\n deleteSetting(\"ignoredVersion\");\n }\n return sendJson(res, 200, buildUpdateStatusPayload(cfg));\n }\n\n return sendError(res, 404, \"not_found\", `no admin route for ${req.method} ${pathname}`);\n}\n\n// Decorate a raw UpdateStatus with method/command (for the \"view command\"\n// modal) and the user's persistent prefs (so the UI can hide banners they\n// already dismissed).\nfunction decorateStatus(status: UpdateStatus, _cfg: Config): Record<string, unknown> {\n const method = detectUpdateMethod();\n const disabled = getSetting(\"updateCheckDisabled\") === \"1\";\n const ignored = getSetting(\"ignoredVersion\");\n const effectivelyDismissed =\n !!status.latest && ignored != null && compareVersions(status.latest, ignored) <= 0;\n return {\n ...status,\n method: method.method,\n command: method.command,\n rootDir: method.rootDir,\n preferences: {\n updateCheckDisabled: disabled,\n ignoredVersion: ignored,\n effectivelyDismissed,\n },\n };\n}\n\nfunction parseCurrentVersion(cfg: Config): string {\n return cfg.userAgent.startsWith(\"mimo2codex/\")\n ? cfg.userAgent.slice(\"mimo2codex/\".length)\n : cfg.userAgent;\n}\n\n// Opportunistic background refresh: every GET /update-status (i.e. every\n// webui mount + every \"Check now\" idle poll) sees if the cache is past the\n// 6h TTL and, if so, fires a single un-awaited refresh. The current request\n// still returns the stale cached value (fast), and the *next* request picks\n// up the freshly-written cache. Combined with the frontend's 3-second\n// follow-up fetch on mount, a single page refresh surfaces new versions\n// within ~3 seconds without ever blocking on the network.\n//\n// Deduped via `inflightUpdateRefresh` so concurrent webui tabs don't\n// stampede the npm registry — at most one in-flight refresh per process.\nlet inflightUpdateRefresh: Promise<unknown> | null = null;\nfunction maybeKickBackgroundRefresh(cfg: Config): void {\n const dataDir = cfg.dataDir || null;\n if (!dataDir) return;\n const version = parseCurrentVersion(cfg);\n const cached = getCachedStatus({ currentVersion: version, dataDir });\n const stale =\n cached.checkedAt === null || Date.now() - cached.checkedAt >= DEFAULT_TTL_MS;\n if (!stale) return;\n if (inflightUpdateRefresh) return;\n inflightUpdateRefresh = refreshCacheInBackground({\n currentVersion: version,\n dataDir,\n })\n .catch(() => {\n // Network / DNS failures must not break the admin endpoint — the\n // cache just stays stale until the next attempt.\n })\n .finally(() => {\n inflightUpdateRefresh = null;\n });\n}\n\nfunction buildUpdateStatusPayload(cfg: Config): Record<string, unknown> {\n const version = parseCurrentVersion(cfg);\n maybeKickBackgroundRefresh(cfg);\n const status = getCachedStatus({\n currentVersion: version,\n dataDir: cfg.dataDir || null,\n });\n return decorateStatus(status, cfg);\n}\n\n// Pull a short, human-readable text snippet out of an upstream chat /\n// responses success body. Best-effort: probes mostly care about success/fail,\n// but echoing the model's actual greeting confirms the wire is end-to-end.\nfunction extractProbeSample(json: Record<string, unknown>): string | null {\n // Chat completions: choices[0].message.content\n const choices = json.choices as unknown;\n if (Array.isArray(choices) && choices.length > 0) {\n const msg = (choices[0] as Record<string, unknown>)?.message as\n | Record<string, unknown>\n | undefined;\n const content = msg?.content;\n if (typeof content === \"string\") return content.slice(0, 200);\n }\n // Responses API: output[0].content[0].text or output_text shortcut.\n const outputText = json.output_text;\n if (typeof outputText === \"string\") return outputText.slice(0, 200);\n const output = json.output as unknown;\n if (Array.isArray(output)) {\n for (const item of output) {\n const content = (item as Record<string, unknown>)?.content;\n if (Array.isArray(content)) {\n for (const c of content) {\n const text = (c as Record<string, unknown>)?.text;\n if (typeof text === \"string\") return text.slice(0, 200);\n }\n }\n }\n }\n return null;\n}\n\nfunction serveStatic(res: ServerResponse, pathname: string): void {\n if (!existsSync(STATIC_ROOT)) {\n res.statusCode = 503;\n res.setHeader(\"Content-Type\", \"text/plain; charset=utf-8\");\n res.end(\n `Admin UI not built. Expected static bundle at ${STATIC_ROOT}.\\n` +\n \"Run `npm run web:build` (or `npm run build:all`) to populate dist/web/.\\n\"\n );\n return;\n }\n // Strip /admin prefix.\n const rel = pathname.replace(/^\\/admin\\/?/, \"\") || \"index.html\";\n const safe = normalize(rel).replace(/^[/\\\\]+/, \"\");\n if (safe.includes(\"..\")) {\n res.statusCode = 400;\n res.end(\"bad path\");\n return;\n }\n let filePath = join(STATIC_ROOT, safe);\n if (existsSync(filePath) && statSync(filePath).isDirectory()) {\n filePath = join(filePath, \"index.html\");\n }\n if (!existsSync(filePath)) {\n // SPA fallback\n filePath = join(STATIC_ROOT, \"index.html\");\n if (!existsSync(filePath)) {\n res.statusCode = 404;\n res.end(\"not found\");\n return;\n }\n }\n const ct = MIME[extname(filePath).toLowerCase()] ?? \"application/octet-stream\";\n res.statusCode = 200;\n res.setHeader(\"Content-Type\", ct);\n // Cache policy:\n // - /admin/assets/* → Vite hashes the filename (index-<hash>.js), so\n // the content is forever-immutable. Letting the browser cache these\n // aggressively makes refreshes near-instant.\n // - Everything else (index.html, favicon.ico, …) must revalidate so a\n // mimo2codex upgrade actually surfaces the new bundle on next refresh\n // instead of getting served the previous version from browser cache.\n if (pathname.startsWith(\"/admin/assets/\")) {\n res.setHeader(\"Cache-Control\", \"public, max-age=31536000, immutable\");\n } else {\n res.setHeader(\"Cache-Control\", \"no-cache, must-revalidate\");\n }\n res.end(readFileSync(filePath));\n}\n\nexport async function handleAdmin(\n cfg: Config,\n req: IncomingMessage,\n res: ServerResponse,\n auth: AuthContext = { user: null, via: \"none\" }\n): Promise<void> {\n const { pathname, query } = parseUrl(req);\n try {\n if (pathname.startsWith(\"/admin/api/\")) {\n await handleApi({ cfg, req, res, pathname, query, auth });\n return;\n }\n if (req.method === \"GET\" && (pathname === \"/admin\" || pathname.startsWith(\"/admin/\"))) {\n serveStatic(res, pathname);\n return;\n }\n sendError(res, 404, \"not_found\", `no admin route for ${req.method} ${pathname}`);\n } catch (err) {\n log.error(\"admin handler error\", { error: (err as Error).message, stack: (err as Error).stack });\n if (!res.headersSent) sendError(res, 500, \"internal_error\", (err as Error).message);\n }\n}\n\nfunction requireUser(ctx: RouteContext): boolean {\n // Per-user endpoints (BYOK, my-API-keys) only exist when authMode=on AND\n // the caller has a session. In local mode the response makes no sense —\n // there's nothing to scope by.\n if (ctx.cfg.authMode !== \"on\") {\n sendError(\n ctx.res,\n 400,\n \"auth_off\",\n \"this endpoint requires authMode=on (per-user state is not tracked in local mode)\"\n );\n return false;\n }\n if (!ctx.auth.user) {\n sendError(ctx.res, 401, \"no_session\", \"session required\");\n return false;\n }\n return true;\n}\n\nfunction requireAdmin(ctx: RouteContext): boolean {\n // Admin endpoints are reachable in two modes:\n // - authMode≠on: no users exist; treat caller as having full access.\n // - authMode=on: require an authenticated admin user.\n if (ctx.cfg.authMode !== \"on\") return true;\n if (!ctx.auth.user || ctx.auth.user.is_admin !== 1) {\n sendError(ctx.res, 403, \"forbidden\", \"admin privileges required\");\n return false;\n }\n return true;\n}\n\nfunction safeCountUsers(): number {\n try {\n return countUsers();\n } catch {\n return 0;\n }\n}\n\n// Snapshot the current ~/.codex/auth.json + config.toml into history as\n// `initial`. Called once per user (or once for the shared local timeline)\n// before the first apply, so we always have a rollback target representing\n// the user's pre-mimo2codex state.\nfunction captureInitialCodexSnapshot(userId: number | null): void {\n const authPath = authJsonPath();\n const tomlPath = configTomlPath();\n const auth = existsSync(authPath) ? readFileSync(authPath, \"utf-8\") : null;\n const toml = existsSync(tomlPath) ? readFileSync(tomlPath, \"utf-8\") : null;\n // If neither file exists, there's no \"original\" to preserve — but we still\n // record an empty initial row so subsequent restores have a sentinel that\n // means \"remove our writes\" (consumers treat empty strings as 'delete').\n appendCodexHistory({\n userId,\n kind: \"initial\",\n providerId: null,\n modelId: null,\n authJson: auth ?? \"\",\n configToml: toml ?? \"\",\n note: \"snapshot of pre-existing ~/.codex state\",\n });\n}\n"}}, "doc/codex-cli-isolated-windows.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595198.1642482, "n": 4346, "h": "d2dda21f74860721", "c": "# Windows: use mimo2codex for Codex CLI only, without touching Codex Desktop\n\n<p>\n <a href=\"./codex-cli-isolated-windows.md\"><strong>English</strong></a> ·\n <a href=\"./codex-cli-isolated-windows.zh.md\">简体中文</a>\n</p>\n\nThis guide describes a Windows-only isolated launch flow: `scripts/codex-mimo-isolated.ps1` starts the mimo2codex proxy and makes the current Codex CLI session use a separate `CODEX_HOME`.\n\n## When to use this\n\nUse it when you want:\n\n- Codex CLI to use MiMo via mimo2codex.\n- Codex Desktop to keep using its existing OpenAI/ChatGPT config.\n- To never write to or overwrite `%USERPROFILE%\\.codex`.\n- To not start `mimo2codex` by hand every time.\n- To not type an API key every time.\n\nIf instead you want both Codex CLI **and** Codex Desktop to use mimo2codex globally, prefer \"Codex Enable\" in the Admin Web UI.\n\n## How it works\n\nThe script does four things:\n\n1. Points the current process's `CODEX_HOME` at `%USERPROFILE%\\.codex-mimo`.\n2. Writes a minimal `auth.json` / `config.toml` into that isolated directory if they don't exist yet.\n3. Checks whether a mimo2codex proxy is already listening on `127.0.0.1:8788`; if not, starts one in the background.\n4. Launches Codex CLI, forwarding all arguments straight through to `codex`.\n\nBecause `CODEX_HOME` is set only inside the script's process, the default `%USERPROFILE%\\.codex` is never modified and Codex Desktop is unaffected.\n\n## Prerequisites\n\nInstall mimo2codex:\n\n```powershell\nnpm install -g mimo2codex\n```\n\nInstall Codex CLI and confirm `codex` is on PATH:\n\n```powershell\ncodex --version\n```\n\nPrepare an API key. The recommended way is mimo2codex's built-in `.env` loader:\n\n```powershell\nmimo2codex init\nnotepad \"$env:USERPROFILE\\.mimo2codex\\.env\"\n```\n\nFill in your `.env`:\n\n```text\nMIMO_API_KEY=your-key\nMIMO_BASE_URL=https://token-plan-ams.xiaomimimo.com/v1\n```\n\nNotes:\n\n- `tp-` Token Plan keys usually use a Token Plan host such as `token-plan-ams`.\n- `sk-` pay-as-you-go keys: configure per the MiMo console or the mimo2codex docs.\n- Never hardcode a real key into `scripts/codex-mimo-isolated.ps1`.\n\n## Usage\n\nRun it from any project directory:\n\n```powershell\n.\\scripts\\codex-mimo-isolated.ps1\n```\n\nIt prints something like:\n\n```text\n[*] CODEX_HOME: C:\\Users\\you\\.codex-mimo\n[*] mimo2codex API: http://127.0.0.1:8788/v1\n[*] mimo2codex admin: http://127.0.0.1:8788/admin/\n```\n\nthen drops you into Codex CLI.\n\n## Non-interactive test\n\nYou can forward arguments to Codex:\n\n```powershell\n.\\scripts\\codex-mimo-isolated.ps1 exec --json --ephemeral \"Reply with exactly: MIMO_OK\"\n```\n\nA clean response confirms that:\n\n- Codex CLI is reading `%USERPROFILE%\\.codex-mimo`.\n- the mimo2codex proxy is listening.\n- the MiMo key and upstream routing work.\n\n## Start the proxy and prepare config only, without entering Codex\n\n```powershell\n.\\scripts\\codex-mimo-isolated.ps1 -NoLaunchCodex\n```\n\nThis mode is handy for debugging proxy startup, opening the admin page, or verifying the isolated config.\n\n## Default isolated config\n\nThe script writes `%USERPROFILE%\\.codex-mimo\\auth.json`:\n\n```json\n{\n \"OPENAI_API_KEY\": \"mimo2codex-local\"\n}\n```\n\nThis value only exists to pass Codex's local auth check. mimo2codex does not validate this inbound key; the real upstream MiMo key still comes from `MIMO_API_KEY` or `%USERPROFILE%\\.mimo2codex\\.env`.\n\nThe script writes `%USERPROFILE%\\.codex-mimo\\config.toml`:\n\n```toml\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\nmodel_context_window = 1000000\nmodel_max_output_tokens = 131072\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://127.0.0.1:8788/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n```\n\n## Difference from Codex Enable\n\nThe Admin Web UI's \"Codex Enable\" writes to the default Codex directory:\n\n```text\n%USERPROFILE%\\.codex\\auth.json\n%USERPROFILE%\\.codex\\config.toml\n```\n\nThis script uses an isolated directory:\n\n```text\n%USERPROFILE%\\.codex-mimo\\auth.json\n%USERPROFILE%\\.codex-mimo\\config.toml\n```\n\nSo it only affects Codex CLI sessions started through this script, and leaves Codex Desktop alone.\n\n### Why not just set a Windows user-level environment variable?\n\nA user-level environment variable affects every new terminal. This script only affects the current Codex CLI session, which fits the \"CLI uses MiMo, Desktop stays as-is\" personal workflow better.\n"}}, "doc/codex-cli-isolated-windows.zh.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595198.215915, "n": 2993, "h": "6117920b4e8b31a0", "c": "# Windows:只让 Codex CLI 使用 mimo2codex,不改 Codex 桌面应用\n\n<p>\n <a href=\"./codex-cli-isolated-windows.md\">English</a> ·\n <a href=\"./codex-cli-isolated-windows.zh.md\"><strong>简体中文</strong></a>\n</p>\n\n这个文档介绍一种 Windows-only 的隔离启动方式:通过 `scripts/codex-mimo-isolated.ps1` 启动 mimo2codex 代理,并让当前 Codex CLI 会话使用独立的 `CODEX_HOME`。\n\n## 适用场景\n\n适合你想要:\n\n- Codex CLI 使用 MiMo/mimo2codex。\n- Codex 桌面应用继续使用原来的 OpenAI/ChatGPT 配置。\n- 不写入、不覆盖 `%USERPROFILE%\\.codex`。\n- 不每次手动启动 `mimo2codex`。\n- 不每次手动输入 API key。\n\n如果你希望 Codex CLI 和 Codex 桌面应用都全局使用 mimo2codex,请优先使用 Admin Web UI 里的 “Codex 启用”。\n\n## 工作原理\n\n脚本做四件事:\n\n1. 把当前进程的 `CODEX_HOME` 指向 `%USERPROFILE%\\.codex-mimo`。\n2. 如果隔离目录还没有 `auth.json` / `config.toml`,自动写入最小配置。\n3. 检查 `127.0.0.1:8788` 是否已有 mimo2codex 代理监听;没有就后台启动。\n4. 启动 Codex CLI,并把参数原样透传给 `codex`。\n\n由于 `CODEX_HOME` 只在脚本进程内设置,默认 `%USERPROFILE%\\.codex` 不会被修改,Codex 桌面应用不受影响。\n\n## 前置条件\n\n安装 mimo2codex:\n\n```powershell\nnpm install -g mimo2codex\n```\n\n安装 Codex CLI,并确认 `codex` 在 PATH 中:\n\n```powershell\ncodex --version\n```\n\n准备 API key。推荐使用 mimo2codex 内置 `.env` loader:\n\n```powershell\nmimo2codex init\nnotepad \"$env:USERPROFILE\\.mimo2codex\\.env\"\n```\n\n在 `.env` 中填入:\n\n```text\nMIMO_API_KEY=你的key\nMIMO_BASE_URL=https://token-plan-ams.xiaomimimo.com/v1\n```\n\n说明:\n\n- `tp-` Token Plan key 通常使用 `token-plan-ams` 这类 Token Plan host。\n- `sk-` pay-as-you-go key 按 MiMo 控制台或 mimo2codex 文档说明配置。\n- 不要把真实 key 写进 `scripts/codex-mimo-isolated.ps1`。\n\n## 使用方式\n\n在任意项目目录运行:\n\n```powershell\n.\\scripts\\codex-mimo-isolated.ps1\n```\n\n脚本会输出类似:\n\n```text\n[*] CODEX_HOME: C:\\Users\\you\\.codex-mimo\n[*] mimo2codex API: http://127.0.0.1:8788/v1\n[*] mimo2codex admin: http://127.0.0.1:8788/admin/\n```\n\n然后进入 Codex CLI。\n\n## 非交互测试\n\n可以把参数透传给 Codex:\n\n```powershell\n.\\scripts\\codex-mimo-isolated.ps1 exec --json --ephemeral \"Reply with exactly: MIMO_OK\"\n```\n\n如果返回正常,说明:\n\n- Codex CLI 读取的是 `%USERPROFILE%\\.codex-mimo`。\n- mimo2codex 代理已经监听。\n- MiMo key 和 upstream 路由可用。\n\n## 只启动代理和准备配置,不进入 Codex\n\n```powershell\n.\\scripts\\codex-mimo-isolated.ps1 -NoLaunchCodex\n```\n\n这个模式适合调试代理启动、查看 admin 页面或验证隔离配置。\n\n## 默认隔离配置\n\n脚本会在 `%USERPROFILE%\\.codex-mimo\\auth.json` 写入:\n\n```json\n{\n \"OPENAI_API_KEY\": \"mimo2codex-local\"\n}\n```\n\n这个值只用于通过 Codex 的本地认证检查。mimo2codex 不校验这个 inbound key,真正的上游 MiMo key 仍来自 `MIMO_API_KEY` 或 `%USERPROFILE%\\.mimo2codex\\.env`。\n\n脚本会在 `%USERPROFILE%\\.codex-mimo\\config.toml` 写入:\n\n```toml\nmodel = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\nmodel_context_window = 1000000\nmodel_max_output_tokens = 131072\n\n[model_providers.mimo]\nname = \"MiMo (via mimo2codex)\"\nbase_url = \"http://127.0.0.1:8788/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nrequest_max_retries = 1\n```\n\n## 和 Codex Enable 的区别\n\nAdmin Web UI 的 “Codex 启用” 会写入默认 Codex 目录:\n\n```text\n%USERPROFILE%\\.codex\\auth.json\n%USERPROFILE%\\.codex\\config.toml\n```\n\n这个脚本使用隔离目录:\n\n```text\n%USERPROFILE%\\.codex-mimo\\auth.json\n%USERPROFILE%\\.codex-mimo\\config.toml\n```\n\n因此它只影响通过这个脚本启动的 Codex CLI,不影响 Codex 桌面应用。\n\n### 为什么不直接设置 Windows 用户级环境变量?\n\n用户级环境变量会影响所有新终端。这个脚本只影响当前 Codex CLI 会话,更适合“CLI 用 MiMo,桌面版保持原样”的个人工作流。\n\n"}}, "docbackend/README.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595228.8380196, "n": 3260, "h": "ceaf288a4022f18c", "c": "# docbackend\n\nSmall Go service that backs the [docweb](../docweb/) site's interactive features:\n\n| Endpoint | Purpose |\n|---|---|\n| `POST /api/ideas` | Submit a public idea / pain point. New rows are `pending` until moderated. |\n| `GET /api/ideas` | Paginate publicly approved ideas (newest first) + per-idea comment count. |\n| `POST /api/ideas/{id}/comments` | Post a flat comment on a public idea. |\n| `GET /api/ideas/{id}/comments` | Paginate comments on a public idea (oldest first). |\n| `POST /api/ask` | Doc-grounded Q&A. Returns text/event-stream. |\n| `GET /api/health` | Liveness + loaded doc count. |\n\nFrontend pairing: [`docweb/src/api/client.ts`](../docweb/src/api/client.ts).\n\n## Stack\n\n- Go 1.25, [Kratos v2](https://go-kratos.dev/) for the lifecycle + HTTP server wrapper\n- [GORM](https://gorm.io/) on PostgreSQL (driver: `gorm.io/driver/postgres`)\n- [bluemonday](https://github.com/microcosm-cc/bluemonday) for sanitization\n- `golang.org/x/time/rate` for per-IP token-bucket throttling\n\n## One-time setup\n\n1. Set up the database:\n\n ```powershell\n $env:DOCBACKEND_TEST_DSN = \"postgres://user:pass@localhost:5432/yourdb?sslmode=disable\"\n make migrate\n ```\n\n This runs `TestAutoMigrate`, which creates `docbackend_ideas` and `docbackend_comments`. The same `AutoMigrate` also runs on each server start, so the test is just a fast bootstrap + CI schema-drift guard.\n\n2. Set the required environment variables (the config substitutes `${VAR}` placeholders against the process env at load time):\n\n | Variable | Used for |\n |---|---|\n | `DOCBACKEND_DSN` | Postgres DSN |\n | `DOCBACKEND_UPSTREAM_BASE_URL` | OpenAI-compatible base, e.g. `https://api.mimo.chat/v1` |\n | `DOCBACKEND_UPSTREAM_API_KEY` | API key for the upstream |\n | `DOCBACKEND_UPSTREAM_MODEL` | Model name, e.g. `mimo-v2.5` |\n | `DOCBACKEND_IP_SALT` | Any random string — mixed into sha256(ip + salt) for rate-limit bucketing |\n | `DOCBACKEND_DOCS_DIR` | (optional) Override the docs directory; defaults to `configs/../../doc`. |\n\n## Run locally\n\n```powershell\nmake run\n# or\ngo run ./cmd/docbackend -conf configs/config.yaml\n```\n\nThen:\n\n```powershell\ncurl http://127.0.0.1:8080/api/health\n```\n\n## Moderation\n\nBoth tables drop new rows in their default status:\n\n- ideas → `pending` (hidden until you approve)\n- comments → `public` (visible immediately; you hide bad ones)\n\nTo approve / hide:\n\n```sql\nUPDATE docbackend_ideas SET status = 'public' WHERE id = 42;\nUPDATE docbackend_comments SET status = 'hidden' WHERE id = 99;\n```\n\nNo admin UI in v1 — `psql` is the moderation surface.\n\n## Build a Docker image\n\n```powershell\n# from repo root (not docbackend/), so the Dockerfile can COPY ../doc\ndocker build -f docbackend/Dockerfile -t mimo2codex-docbackend .\n```\n\nOr use the helper PowerShell script (auto-detects version from root `package.json` and Docker Hub username from your `docker login`):\n\n```powershell\npwsh docbackend/scripts/docker-build-push.ps1\n```\n\n## Deploy shape\n\n```\nnginx (mimodoc.chengj.online)\n ├── / → docweb static files\n └── /api/* → docbackend:8080 (reverse_proxy, proxy_buffering off for SSE)\n```\n\nThe container is stateless; persistent data lives in the external PostgreSQL pointed to by `DOCBACKEND_DSN`.\n"}}, "src/db/index.ts": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595230.8775585, "n": 8976, "h": "c985940bf55bd562", "c": "import Database from \"better-sqlite3\";\nimport { join } from \"node:path\";\nimport { MIGRATIONS } from \"./schema.js\";\nimport { PROVIDER_LIST } from \"../providers/registry.js\";\nimport { log } from \"../util/log.js\";\n\nexport type DB = Database.Database;\n\nlet instance: DB | null = null;\nlet instancePath: string | null = null;\n\nexport function openDb(dataDir: string): DB {\n const dbPath = join(dataDir, \"data.db\");\n if (instance && instancePath === dbPath) return instance;\n if (instance) {\n instance.close();\n instance = null;\n }\n const db = new Database(dbPath);\n db.pragma(\"journal_mode = WAL\");\n db.pragma(\"foreign_keys = ON\");\n const wasFresh = applyMigrations(db);\n seedBuiltins(db);\n seedLoggingDefaults(db, wasFresh);\n seedStatsBackfill(db);\n instance = db;\n instancePath = dbPath;\n log.debug(`sqlite opened at ${dbPath}`);\n return db;\n}\n\nexport function closeDb(): void {\n if (instance) {\n instance.close();\n instance = null;\n instancePath = null;\n }\n}\n\nexport function getDb(): DB {\n if (!instance) {\n throw new Error(\"db not opened — call openDb(dataDir) before getDb()\");\n }\n return instance;\n}\n\nfunction applyMigrations(db: DB): boolean {\n db.exec(`CREATE TABLE IF NOT EXISTS schema_version (\n version INTEGER PRIMARY KEY,\n applied_at INTEGER NOT NULL\n );`);\n const row = db.prepare(\"SELECT MAX(version) AS v FROM schema_version\").get() as { v: number | null };\n const current = row.v ?? 0;\n const wasFresh = current === 0;\n for (const m of MIGRATIONS) {\n if (m.version <= current) continue;\n db.exec(m.sql);\n db.prepare(\"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)\").run(\n m.version,\n Date.now()\n );\n log.debug(`applied schema migration v${m.version}`);\n }\n return wasFresh;\n}\n\n// One-time seeding of logging defaults (issue #67). A fresh install gets\n// opinionated, db-friendly defaults (30-day retention, errors-only bodies) so\n// the database can't silently balloon; an existing install instead locks in the\n// historical \"keep everything / full bodies\" behavior so upgrading never\n// deletes a user's logs or changes body capture without consent. The\n// `logging.defaultsSeeded` marker makes this idempotent, and ON CONFLICT DO\n// NOTHING means any value the user already set always wins.\nexport function seedLoggingDefaults(db: DB, wasFresh: boolean): void {\n const seeded = db\n .prepare(\"SELECT 1 FROM settings WHERE key = 'logging.defaultsSeeded'\")\n .get();\n if (seeded) return;\n const insert = db.prepare(\n `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)\n ON CONFLICT(key) DO NOTHING`\n );\n const now = Date.now();\n if (wasFresh) {\n insert.run(\"logging.retentionDays\", \"30\", now);\n insert.run(\"logging.bodyMode\", \"errors-only\", now);\n } else {\n insert.run(\"logging.retentionDays\", \"off\", now);\n insert.run(\"logging.bodyMode\", \"full\", now);\n }\n insert.run(\"logging.defaultsSeeded\", \"1\", now);\n}\n\n// Snapshot the \"upgrade boundary\" for the hourly-stats backfill (issue #76),\n// ONCE, the first time the v6 rollup schema is live — before any new insert.\n// `stats.backfillMaxId` = MAX(chat_logs.id) at this moment: rows at/below it are\n// pre-existing and get folded into the rollup by the background backfill, while\n// rows above it are counted live by recordStatsForLog. Capturing it here (not\n// lazily later) guarantees no row is double-counted. A fresh/empty install has\n// nothing to backfill, so it's marked done immediately.\nexport function seedStatsBackfill(db: DB): void {\n const already = db\n .prepare(\"SELECT 1 FROM settings WHERE key = 'stats.backfillMaxId'\")\n .get();\n if (already) return;\n const maxId =\n (db.prepare(\"SELECT MAX(id) AS m FROM chat_logs\").get() as { m: number | null }).m ?? 0;\n const now = Date.now();\n const insert = db.prepare(\n `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)\n ON CONFLICT(key) DO NOTHING`\n );\n insert.run(\"stats.backfillMaxId\", String(maxId), now);\n insert.run(\"stats.backfillCursor\", String(maxId), now);\n insert.run(\"stats.backfillDone\", maxId > 0 ? \"0\" : \"1\", now);\n}\n\n// Drop providers whose shortcut already appeared (first writer wins). The\n// providers.shortcut column is UNIQUE, so a dirty providers.json carrying a\n// duplicate shortcut would otherwise crash the whole seed transaction with\n// \"UNIQUE constraint failed: providers.shortcut\" — which the cli.ts catch then\n// turns into a disabled admin (/admin/ 404). Skipping the offender keeps admin\n// alive so the user can fix it. Exported for testing. (issue #63)\nexport function dedupeProvidersByShortcut<T extends { id: string; shortcut: string }>(\n list: readonly T[]\n): { kept: T[]; skipped: Array<{ provider: T; conflictsWith: string }> } {\n const byShortcut = new Map<string, T>();\n const kept: T[] = [];\n const skipped: Array<{ provider: T; conflictsWith: string }> = [];\n for (const p of list) {\n const existing = byShortcut.get(p.shortcut);\n if (existing) {\n skipped.push({ provider: p, conflictsWith: existing.id });\n } else {\n byShortcut.set(p.shortcut, p);\n kept.push(p);\n }\n }\n return { kept, skipped };\n}\n\nfunction seedBuiltins(db: DB): void {\n // Provider rows sync source-of-truth fields on every boot — display_name,\n // base_url, default_model and api_key_env are runtime-essential, so the DB\n // never lags behind code changes (e.g. a base_url switch in mimo.ts must\n // take effect on the next start without manual DB intervention).\n const upsertProvider = db.prepare(`\n INSERT INTO providers (id, shortcut, display_name, base_url, default_model, api_key_env, updated_at)\n VALUES (@id, @shortcut, @display_name, @base_url, @default_model, @api_key_env, @updated_at)\n ON CONFLICT(id) DO UPDATE SET\n shortcut = excluded.shortcut,\n display_name = excluded.display_name,\n base_url = excluded.base_url,\n default_model = excluded.default_model,\n api_key_env = excluded.api_key_env,\n updated_at = excluded.updated_at\n `);\n // Builtin model rows likewise track source on each boot. The UI refuses to\n // edit builtin rows (patchModel guard), so there's no user state to preserve\n // here — capabilities, context window and display name all come from the\n // provider declaration in providers/*.ts.\n const upsertModel = db.prepare(`\n INSERT INTO models (\n provider_id, upstream_id, display_name,\n supports_images, supports_reasoning, supports_web_search,\n context_window, is_builtin, deprecated_after, sort_order\n ) VALUES (\n @provider_id, @upstream_id, @display_name,\n @supports_images, @supports_reasoning, @supports_web_search,\n @context_window, 1, @deprecated_after, @sort_order\n )\n ON CONFLICT(provider_id, upstream_id) DO UPDATE SET\n display_name = excluded.display_name,\n supports_images = excluded.supports_images,\n supports_reasoning = excluded.supports_reasoning,\n supports_web_search = excluded.supports_web_search,\n context_window = excluded.context_window,\n is_builtin = 1,\n deprecated_after = excluded.deprecated_after,\n sort_order = excluded.sort_order\n `);\n // Drop builtin rows whose upstream_id has been removed from source. This\n // is what prunes legacy seeds like the old \"mimo-v2.5-pro[1m]\" variant\n // after we consolidated the catalog. User-created custom models\n // (is_builtin=0) are never touched here.\n const pruneStale = db.prepare(\n `DELETE FROM models\n WHERE provider_id = @provider_id\n AND is_builtin = 1\n AND upstream_id NOT IN (SELECT value FROM json_each(@keep_json))`\n );\n\n const now = Date.now();\n const { kept, skipped } = dedupeProvidersByShortcut(PROVIDER_LIST);\n for (const s of skipped) {\n log.warn(\n `provider \"${s.provider.id}\" skipped during DB seed: shortcut \"${s.provider.shortcut}\" ` +\n `already used by \"${s.conflictsWith}\" — give it a unique shortcut in providers.json`\n );\n }\n const tx = db.transaction(() => {\n for (const p of kept) {\n upsertProvider.run({\n id: p.id,\n shortcut: p.shortcut,\n display_name: p.displayName,\n base_url: p.defaultBaseUrl,\n default_model: p.defaultModel,\n api_key_env: p.envKeys.join(\",\"),\n updated_at: now,\n });\n const keep: string[] = [];\n let order = 0;\n for (const m of p.builtinModels) {\n keep.push(m.id);\n upsertModel.run({\n provider_id: p.id,\n upstream_id: m.id,\n display_name: m.displayName ?? null,\n supports_images: m.supportsImages ? 1 : 0,\n supports_reasoning: m.supportsReasoning ? 1 : 0,\n supports_web_search: m.supportsWebSearch ? 1 : 0,\n context_window: m.contextWindow ?? null,\n deprecated_after: m.deprecatedAfter ?? null,\n sort_order: order++,\n });\n }\n pruneStale.run({ provider_id: p.id, keep_json: JSON.stringify(keep) });\n }\n });\n tx();\n}\n"}}, "mimoskill/references/models.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595268.3194659, "n": 3798, "h": "cf9f24cc6c8862db", "c": "# MiMo V2.5 model capability matrix\n\nAuthoritative source: <https://platform.xiaomimimo.com/docs>. This file summarizes\nthe bits the skill cares about — read the official docs for full schemas.\n\n## Models at a glance\n\n| Model | Text | Vision | Audio in | Image gen | Audio gen | Reasoning | Context | Best for |\n|---|---|---|---|---|---|---|---|---|\n| `mimo-v2.5-pro` | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ strong | 128K | Coding, agentic tasks, complex reasoning |\n| `mimo-v2.5` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ medium | 128K | Multimodal chat with image input |\n| `mimo-v2.5-tts` | ❌ | ❌ | ❌ | ❌ | ✅ | – | – | TTS (separate endpoint) |\n| `mimo-v2.5-asr` | ❌ | ❌ | ✅ | ❌ | ❌ | – | – | ASR (separate endpoint) |\n\n> **Image generation is not in this table because no MiMo model produces images.**\n> For image generation, see [pet_workflow.md](./pet_workflow.md) → \"Image gen alternatives\".\n\n## Endpoints\n\n| Capability | Endpoint | Compatible with |\n|---|---|---|\n| Chat (text + vision) | `https://api.xiaomimimo.com/v1/chat/completions` | OpenAI Python SDK / curl |\n| Chat (Anthropic shape) | `https://api.xiaomimimo.com/anthropic/v1/messages` | Anthropic SDK / Claude Code |\n| TTS | (consult MiMo TTS docs) | MiMo SDK |\n| ASR | (consult MiMo ASR docs) | MiMo SDK |\n\nToken Plan users substitute `https://token-plan-cn.xiaomimimo.com` for the host.\n\n## Authentication\n\nBoth header forms are accepted:\n\n```\nAuthorization: Bearer ${MIMO_API_KEY}\n```\n\nor\n\n```\napi-key: ${MIMO_API_KEY}\n```\n\nKey prefixes:\n\n- `sk-...` — pay-as-you-go (uses default `api.xiaomimimo.com` base URL)\n- `tp-...` — token plan subscription (uses `token-plan-cn.xiaomimimo.com` base URL)\n\n## Field quirks (vs OpenAI)\n\nThese trip people up:\n\n1. Use **`max_completion_tokens`**, not `max_tokens`. MiMo follows the newer spec.\n2. **Reasoning is exposed as `reasoning_content`** on assistant messages (DeepSeek-style), not OpenAI's `reasoning_summary`.\n3. For **multi-turn tool calls in thinking mode**, *re-inject all prior `reasoning_content`* on the next request — MiMo recommends this, drops in quality otherwise.\n4. **Image input requires both `image_url` AND a `text` part**. An image-only message returns `400 Param Incorrect: text is not set`. Add a `{type: \"text\", text: \" \"}` part if you don't have a real prompt.\n5. **`tool_choice` other than `\"auto\"`** is currently silently ignored upstream — behavior is always equivalent to `auto`.\n6. **Web Search builtin** is a tool of `type: \"web_search\"` (not a function tool). Requires the [Web Search Plugin](https://platform.xiaomimimo.com/#/console/plugin) to be activated in your console first; separately metered.\n7. Annotations come back on `message.annotations` as `{type: \"url_citation\", url, title, summary}`.\n\n## Tools and function calling\n\nStandard OpenAI function tool format works:\n\n```json\n{\n \"type\": \"function\",\n \"function\": {\n \"name\": \"...\",\n \"description\": \"...\",\n \"parameters\": { \"type\": \"object\", \"properties\": { ... } },\n \"strict\": false\n }\n}\n```\n\n`tool_calls` come back on the assistant message; reply with `{role: \"tool\", tool_call_id, content}`. Parallel tool calls are supported.\n\n## Web search\n\n```json\n{\n \"type\": \"web_search\",\n \"max_keyword\": 3,\n \"force_search\": true,\n \"limit\": 1,\n \"user_location\": {\n \"type\": \"approximate\",\n \"country\": \"China\",\n \"region\": \"Hubei\",\n \"city\": \"Wuhan\"\n }\n}\n```\n\nStreaming: search sources arrive in the **first** SSE chunk's `delta.annotations`.\n\n## Quick model picker\n\n```\nNeed image input? → mimo-v2.5\nDoing heavy reasoning/code? → mimo-v2.5-pro\nJust doing cheap text chat? → mimo-v2.5\nNeed TTS? → mimo-v2.5-tts (separate endpoint)\nNeed ASR? → mimo-v2.5-asr (separate endpoint)\nNeed image generation? → MiMo can't. Use gpt-image-1 / Pollinations / SD.\n```\n"}}, "scripts/install.ps1": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595268.8549154, "n": 5737, "h": "c14e46ef45070b1b", "c": "# mimo2codex bootstrap — Windows PowerShell (5.1+) and PowerShell 7+ on any OS.\n#\n# Usage:\n# irm <raw-url>/scripts/install.ps1 | iex # one-liner\n# .\\scripts\\install.ps1 # if already cloned\n# .\\scripts\\install.ps1 -Start # also start the proxy\n#\n# Env knobs:\n# $env:MIMO2CODEX_REPO — git URL to clone (default: official repo)\n# $env:MIMO2CODEX_DIR — target directory name (default: mimo2codex)\n# $env:MIMO_API_KEY — required when -Start is passed\n#\n# What this script does (idempotent):\n# 1. Verify git, node >= 18, npm\n# 2. Clone or fast-forward-pull the repo\n# 3. npm install\n# 4. npm run build\n# 5. npm test (best-effort)\n# 6. Print next-step commands\n# 7. (optional) start proxy if -Start was passed\n#\n[CmdletBinding()]\nparam(\n [switch]$Start\n)\n\n$ErrorActionPreference = \"Stop\"\n$script:RepoUrl = if ($env:MIMO2CODEX_REPO) { $env:MIMO2CODEX_REPO } else { \"https://github.com/7as0nch/mimo2codex.git\" }\n$script:Target = if ($env:MIMO2CODEX_DIR) { $env:MIMO2CODEX_DIR } else { \"mimo2codex\" }\n\nfunction Step($msg) { Write-Host \"==> $msg\" -ForegroundColor Cyan }\nfunction Ok($msg) { Write-Host \" ✓ $msg\" -ForegroundColor Green }\nfunction Warn($msg) { Write-Host \" ! $msg\" -ForegroundColor Yellow }\nfunction Err($msg) { Write-Host \" ✗ $msg\" -ForegroundColor Red }\n\n# ── 1. detect prereqs ──────────────────────────────────────────────────────\nStep \"Checking prerequisites\"\n\nif (-not (Get-Command git -ErrorAction SilentlyContinue)) {\n Err \"git is not installed\"\n Write-Host \" Install Git for Windows: https://git-scm.com/download/win\"\n Write-Host \" Or via winget: winget install --id Git.Git -e\"\n exit 1\n}\n$gitVer = (git --version) -replace '^git version ', ''\nOk \"git $gitVer\"\n\nif (-not (Get-Command node -ErrorAction SilentlyContinue)) {\n Err \"node is not installed\"\n Write-Host \" Install Node.js 18+ from https://nodejs.org/\"\n Write-Host \" Or via winget: winget install --id OpenJS.NodeJS.LTS -e\"\n Write-Host \" Or via nvm-windows: https://github.com/coreybutler/nvm-windows\"\n exit 1\n}\n$nodeMajor = [int]((node -v).TrimStart('v').Split('.')[0])\nif ($nodeMajor -lt 18) {\n Err \"node $(node -v) is too old; mimo2codex needs Node.js >= 18\"\n exit 1\n}\nOk \"node $(node -v)\"\n\nif (-not (Get-Command npm -ErrorAction SilentlyContinue)) {\n Err \"npm is not installed (it normally ships with Node.js)\"\n exit 1\n}\nOk \"npm $(npm -v)\"\n\n# ── 2. clone or pull ───────────────────────────────────────────────────────\n$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path\n$repoRoot = $null\n\nif ($scriptDir -and (Test-Path \"$scriptDir\\..\\package.json\")) {\n $pkg = Get-Content \"$scriptDir\\..\\package.json\" -Raw -ErrorAction SilentlyContinue\n if ($pkg -match '\"name\":\\s*\"mimo2codex\"') {\n $repoRoot = (Resolve-Path \"$scriptDir\\..\").Path\n Step \"Detected in-repo run; skipping clone (using $repoRoot)\"\n }\n}\n\nif (-not $repoRoot) {\n if (Test-Path \"$Target\\.git\") {\n Step \"Updating existing clone at .\\$Target\"\n git -C $Target pull --ff-only\n if ($LASTEXITCODE -ne 0) { throw \"git pull failed\" }\n $repoRoot = (Resolve-Path $Target).Path\n Ok \"pulled latest\"\n } else {\n Step \"Cloning $RepoUrl -> .\\$Target\"\n git clone --depth 1 $RepoUrl $Target\n if ($LASTEXITCODE -ne 0) { throw \"git clone failed\" }\n $repoRoot = (Resolve-Path $Target).Path\n Ok \"cloned\"\n }\n}\n\nSet-Location $repoRoot\n\n# ── 3. install ─────────────────────────────────────────────────────────────\nStep \"Installing dependencies (npm install)\"\nnpm install --no-audit --no-fund --loglevel=error\nif ($LASTEXITCODE -ne 0) { throw \"npm install failed\" }\nOk \"deps installed\"\n\n# ── 4. build ───────────────────────────────────────────────────────────────\nStep \"Building TypeScript (npm run build)\"\nnpm run build --silent\nif ($LASTEXITCODE -ne 0) { throw \"npm run build failed\" }\nOk \"built dist/\"\n\n# ── 5. tests (best effort) ─────────────────────────────────────────────────\nStep \"Running tests\"\n$null = npm test --silent 2>&1\nif ($LASTEXITCODE -eq 0) {\n Ok \"all tests pass\"\n} else {\n Warn \"tests failed — proxy may still work; rerun ``npm test`` for details\"\n}\n\n# ── 6. final instructions ──────────────────────────────────────────────────\nWrite-Host \"\"\nWrite-Host \"✓ mimo2codex is ready at $repoRoot\" -ForegroundColor Green\nWrite-Host \"\"\nWrite-Host \"Next steps:\" -ForegroundColor White\nWrite-Host \"\"\nWrite-Host \" 1) Get a MiMo API key (skip if you already have one):\" -ForegroundColor Cyan\nWrite-Host \" https://platform.xiaomimimo.com/#/console/api-keys\"\nWrite-Host \"\"\nWrite-Host \" 2) Start the proxy:\" -ForegroundColor Cyan\nWrite-Host \" cd `\"$repoRoot`\"\"\nWrite-Host \" `$env:MIMO_API_KEY = 'sk-your-real-mimo-key'\"\nWrite-Host \" node dist\\cli.js\"\nWrite-Host \"\"\nWrite-Host \" 3) Print the Codex config snippet (auth.json + config.toml):\" -ForegroundColor Cyan\nWrite-Host \" node dist\\cli.js print-config\"\nWrite-Host \"\"\nWrite-Host \" Or for cc-switch users:\"\nWrite-Host \" node dist\\cli.js print-cc-switch\"\nWrite-Host \"\"\nWrite-Host \" 4) Restart Codex (desktop app: fully quit + relaunch).\" -ForegroundColor Cyan\nWrite-Host \"\"\nWrite-Host \"See README.md / README.zh.md for cc-switch setup, troubleshooting,\"\nWrite-Host \"and how to keep the proxy running in the background.\"\nWrite-Host \"\"\n\n# ── 7. optional auto-start ─────────────────────────────────────────────────\nif ($Start) {\n if (-not $env:MIMO_API_KEY) {\n Err \"MIMO_API_KEY is not set.\"\n Write-Host \" `$env:MIMO_API_KEY='sk-...' and rerun, or omit -Start.\"\n exit 1\n }\n Step \"Starting proxy\"\n node dist\\cli.js\n}\n"}}, "src/providers/types.ts": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595269.920572, "n": 5812, "h": "87691c4fa02b620c", "c": "import type { ChatRequest, ResponsesRequest } from \"../translate/types.js\";\n\n// Provider id is a runtime-registered string. Built-ins are \"mimo\" / \"deepseek\";\n// generic OpenAI-compatible providers loaded from providers.json contribute\n// their own ids at startup. Kept as a type alias for semantic clarity at call\n// sites that previously expected the literal union.\nexport type ProviderId = string;\n\nexport interface ProviderRuntime {\n baseUrl: string;\n apiKey: string;\n flags: Record<string, boolean>;\n}\n\nexport interface ProviderModel {\n id: string;\n aliases?: string[];\n displayName?: string;\n supportsImages?: boolean;\n supportsReasoning?: boolean;\n supportsWebSearch?: boolean;\n contextWindow?: number;\n // Optional per-model output cap. Used by cli.ts when emitting\n // `model_max_output_tokens` in the toml snippet. DeepSeek used to hardcode\n // 393_216 in cli.ts; with this field, generic providers can declare their\n // own caps uniformly.\n maxOutputTokens?: number;\n deprecatedAfter?: string;\n // Optional short access/availability note surfaced in the generated\n // config.toml model list (e.g. \"apply to enable; API/PAYG key only\").\n // Plain text, kept terse — it renders as a trailing `# <note>` comment.\n note?: string;\n // When true the model is only served on the pay-as-you-go API host; a\n // token-plan / subscription (tp-) key can't use it. The server rejects such a\n // request early with a clear message (see paygOnlyBlock in server.ts).\n paygOnly?: boolean;\n}\n\nexport interface ProviderEnhancedError {\n code: string;\n message: string;\n}\n\nexport interface PreprocessCtx {\n runtime: ProviderRuntime;\n exposeReasoning: boolean;\n // When the active model can't ingest images and the proxy strips them, the\n // proxy materializes the image bytes to disk here so the agent (Codex) can\n // pass the path to mimoskill/scripts/ocr.py. Empty/undefined → falls back\n // to os.tmpdir() inside reqToChat. Typically `cfg.dataDir`.\n dataDir?: string;\n // 全局\"关思考\"开关:CLI --disable-thinking / env / admin UI 控制。\n // 各 provider 自己决定怎么落地到上游字段(mimo/deepseek: thinking:{type:\"disabled\"};\n // sensenova/generic: reasoning_effort:\"none\")。reqToChat 一并设两个,由 generic\n // provider 的 preprocessResponses 删掉 thinking,正好留 reasoning_effort 给 sensenova。\n disableThinking?: boolean;\n // 独立开关:兜底注入 reasoning_effort:\"high\"。当 Codex 没传 reasoning.effort 时主动让\n // 上游真高强度思考。默认 false,仅在 admin UI 显式打开时生效。disableThinking=true 时\n // 此开关被忽略(关思考路径接管)。\n forceHighEffort?: boolean;\n // 全局\"联网搜索\"开关:是否把 web_search 工具转发给上游。默认 undefined/false ——\n // MiMo 的 Web Search Plugin 单独计费、默认不开通,转发会 400 \"webSearchEnabled is\n // false\"。仅当用户在 CLI(--web-search) / env / admin UI 显式打开时为 true。各 provider\n // 自己决定是否参考(mimo 参考它且对 tp- 永远剥离;deepseek 硬关;generic 走 providers.json)。\n webSearchEnabled?: boolean;\n // The model id that's actually going to the upstream — after admin runtime\n // override / alias / provider defaultModel fallback. Capability checks\n // (vision, …) in reqToChat should follow THIS, not req.model (the client\n // literal). Without it, e.g. client `mimo-v2.5-pro` aliased to upstream\n // `mimo-v2.5` would still get images stripped because reqToChat would\n // judge by the non-vision client id.\n upstreamModel?: string;\n}\n\nexport interface Provider {\n id: ProviderId;\n shortcut: string;\n displayName: string;\n defaultBaseUrl: string;\n baseUrlEnv: string;\n envKeys: readonly string[];\n defaultModel: string;\n builtinModels: readonly ProviderModel[];\n // Wire protocol toward the upstream. \"chat\" (default) goes through\n // reqToChat/respToResponses translation. \"responses\" pipes the Codex\n // Responses payload straight to the upstream's /v1/responses endpoint —\n // useful when the upstream natively speaks Responses (OpenAI, future\n // Chat-Completions-deprecated providers) and translation would only\n // strip fields the upstream actually understands.\n wireApi?: \"chat\" | \"responses\";\n // Optional doc URL surfaced in \"missing API key\" error messages instead\n // of the hardcoded mimo / deepseek console links.\n docsUrl?: string;\n\n detectFlags(apiKey: string, baseUrl: string): Record<string, boolean>;\n // Some providers route different key tiers to different hosts. MiMo's\n // `tp-*` keys live behind `token-plan-cn.xiaomimimo.com`, while `sk-*` keys\n // hit the main pay-as-you-go host. Return null when the key gives no signal\n // — callers will then fall back to defaultBaseUrl.\n inferBaseUrlFromKey?(apiKey: string): string | null;\n resolveModel(clientModel: string): ProviderModel | null;\n // Optional vision capability probe. ONLY providers that implement this take\n // part in the multimodal (vision) fallback: the server skips the whole\n // fallback when a provider omits it, so the feature stays MiMo-specific and\n // never rewrites models for DeepSeek / generic providers. Returns whether the\n // given model can accept image input.\n supportsVision?(model: string): boolean;\n preprocessResponses(req: ResponsesRequest, ctx: PreprocessCtx): ChatRequest;\n preprocessChat(req: ChatRequest, ctx: PreprocessCtx): ChatRequest;\n // Lightweight hook for wireApi === \"responses\". Receives the original Codex\n // Responses payload; return the version to forward (model id rewrite,\n // field cleanup, etc). Default behavior when omitted is identity passthrough\n // with `model` substituted by the routing layer.\n preprocessResponsesPassthrough?(req: ResponsesRequest, ctx: PreprocessCtx): ResponsesRequest;\n enhanceError(ctx: { status: number; snippet?: string }): ProviderEnhancedError | null;\n // minimax-compat: 响应翻译层用到的开关。未实现时所有标记 false → 既有行为。\n // 目前只放 extractInlineThink(把 content 里的 <think>...</think> 切到 reasoning_content),\n // 后续若再加响应侧 sanitizer 共用这一个对象,避免接口爆炸。\n responseFlags?: {\n extractInlineThink?: boolean;\n };\n}\n"}}, "package/desktop/shared/types.ts": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595269.9572523, "n": 4917, "h": "18431a1e1d10417b", "c": "// Cross main/renderer typed surface. Imported via path alias in main\n// (NodeNext) and via Vite (Bundler resolution) in renderer.\n\nexport interface RuntimeConfig {\n port: number;\n autostart: boolean;\n /** Set by --autostart-launched on boot, NOT persisted */\n launchedByAutostart?: boolean;\n /** Was admin UI window opened on the most recent Save & Restart */\n showAdminUiAfterSave?: boolean;\n}\n\nexport type SidecarStatus =\n | { kind: \"starting\" }\n | { kind: \"running\"; port: number; pid: number }\n | { kind: \"crashed\"; exitCode: number | null; lastLog: string };\n\nexport interface ProviderEnvKey {\n provider: \"mimo\" | \"deepseek\" | \"generic\";\n envKey: \"MIMO_API_KEY\" | \"DEEPSEEK_API_KEY\" | \"GENERIC_API_KEY\";\n}\n\nexport const PROVIDER_KEYS: ProviderEnvKey[] = [\n { provider: \"mimo\", envKey: \"MIMO_API_KEY\" },\n { provider: \"deepseek\", envKey: \"DEEPSEEK_API_KEY\" },\n { provider: \"generic\", envKey: \"GENERIC_API_KEY\" },\n];\n\n// Extended schema (v0.5.6) for the multi-provider settings UI — the original\n// PROVIDER_KEYS only knew about the api key var, but the settings UI also\n// reads / writes the optional base-url override (MiMo's tp subscription host,\n// DeepSeek's enterprise tenant, the generic OpenAI-compatible endpoint) and\n// `GENERIC_DEFAULT_MODEL` (required when running with generic as default).\n// All fields use the exact env-var names mimo2codex's CLI consumes.\nexport interface ProviderConfigSpec {\n provider: \"mimo\" | \"deepseek\" | \"generic\";\n /** Display label shown in the settings UI */\n label: string;\n /** API key env var (required for the provider to be usable) */\n keyEnv: \"MIMO_API_KEY\" | \"DEEPSEEK_API_KEY\" | \"GENERIC_API_KEY\";\n /** Optional base URL override env var. Empty string ⇒ use the provider's built-in default. */\n baseUrlEnv: \"MIMO_BASE_URL\" | \"DEEPSEEK_BASE_URL\" | \"GENERIC_BASE_URL\";\n /** Default base URL hint shown as form placeholder */\n defaultBaseUrl: string;\n /** Optional default-model env var (only meaningful for generic). undefined for mimo/deepseek. */\n defaultModelEnv?: \"GENERIC_DEFAULT_MODEL\";\n /** Hint text under the api key field */\n keyHint: string;\n /** Optional hint under the base URL field (e.g. auto-detection rules) */\n baseUrlHint?: string;\n}\n\nexport const PROVIDER_SPECS: ProviderConfigSpec[] = [\n {\n provider: \"mimo\",\n label: \"MiMo (Xiaomi)\",\n keyEnv: \"MIMO_API_KEY\",\n baseUrlEnv: \"MIMO_BASE_URL\",\n defaultBaseUrl: \"https://api.xiaomimimo.com/v1\",\n keyHint: \"sk-xxxxxxxx 走 pay-as-you-go;tp-xxxxxxxx 走 token-plan 订阅。\",\n // MiMo provider auto-routes by key prefix (see src/providers/mimo.ts —\n // isTokenPlanRuntime checks apiKey.startsWith(\"tp-\")), so users almost\n // never need to fill this field. Surface that so people don't paste\n // the wrong host and 401.\n baseUrlHint:\n \"通常不用填 —— 代理会按 key 前缀自动选择主机:sk-* → api.xiaomimimo.com,tp-* → token-plan-cn.xiaomimimo.com。只有走自建网关或测试镜像时才需要覆盖。\",\n },\n {\n provider: \"deepseek\",\n label: \"DeepSeek\",\n keyEnv: \"DEEPSEEK_API_KEY\",\n baseUrlEnv: \"DEEPSEEK_BASE_URL\",\n defaultBaseUrl: \"https://api.deepseek.com/v1\",\n keyHint: \"sk-xxxxxxxx from https://platform.deepseek.com/api_keys\",\n },\n {\n provider: \"generic\",\n label: \"Generic OpenAI-compatible\",\n keyEnv: \"GENERIC_API_KEY\",\n baseUrlEnv: \"GENERIC_BASE_URL\",\n defaultBaseUrl: \"(your provider's /v1 endpoint, e.g. https://dashscope.aliyuncs.com/compatible-mode/v1)\",\n defaultModelEnv: \"GENERIC_DEFAULT_MODEL\",\n keyHint:\n \"Any provider speaking OpenAI Chat-Completions (Qwen / GLM / Kimi / vLLM / Ollama / LM Studio …). Base URL and default model are both required.\",\n },\n];\n\n/** Template placeholder string from .env.example */\nexport const KEY_PLACEHOLDER_PREFIX = \"sk-xxxxxxxxxxxxxxxxxxxx\";\n\n/**\n * Legacy CLI install probe result (v0.5.6 — A3). Surfaced by `settings:loaded`\n * payload so the first-run welcome card can offer a one-click migration.\n * `null` means we didn't find a usable legacy `.env` (file missing, empty, or\n * already imported earlier).\n */\nexport interface LegacyEnvProbe {\n /** Absolute path to the legacy `.env` (~/.mimo2codex/.env on every OS) */\n sourcePath: string;\n /** Subset of env keys present in the legacy file that we would import. Values redacted to the last 4 chars on the renderer side; full values stay on disk until import. */\n keys: string[];\n}\n\n// Renderer-side global injected by preload.cjs via contextBridge.\n// Kept loose (unknown) here because the strict union types live in src/ipc.ts\n// and can't be imported across the main/renderer boundary without bundling.\n// Renderers call `window.m2c.send(msg)` with their typed payloads directly.\ndeclare global {\n interface Window {\n m2c: {\n send: (msg: unknown) => void;\n on: (handler: (msg: import(\"../src/ipc.js\").MainToRenderer) => void) => () => void;\n openPath: (p: string) => void;\n chooseDataDir: () => void;\n };\n }\n}\n"}}, "mimoskill/scripts/mimo_chat.py": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595270.1617436, "n": 10383, "h": "eafd62d51fc56326", "c": "#!/usr/bin/env python3\n\"\"\"\nmimo_chat.py — single-shot or streaming chat. Works WITHOUT any API key.\n\nEngines (--engine):\n auto (default) — mimo if MIMO_API_KEY set, else pollinations\n mimo — Xiaomi MiMo V2.5 (best quality, needs MIMO_API_KEY)\n pollinations — pollinations.ai free public chat endpoint. NO KEY REQUIRED\n\nWhen the mimo engine is used, handles the MiMo-specific quirks:\n - max_completion_tokens (not max_tokens)\n - vision via mimo-v2.5 (and the required text part next to\n image_url, otherwise MiMo 400s with \"text is not set\")\n - web_search builtin: auto-enabled on pay-as-you-go (sk-*) keys, skipped on\n token-plan (tp-*) keys. Model decides when to invoke (tool_choice: auto).\n Requires the Web Search Plugin to be activated in the MiMo console.\n - reasoning_content extraction\n\nUsage:\n # Zero-setup\n python3 mimo_chat.py \"your prompt\"\n python3 mimo_chat.py --image https://x/y.png \"describe\"\n\n # MiMo key — gets best quality + native web search (when sk-*)\n export MIMO_API_KEY=sk-xxxx\n python3 mimo_chat.py \"今天上海天气?\"\n python3 mimo_chat.py --stream \"tell me a story\"\n\nOnly depends on the standard library — no `openai` SDK install needed.\n\"\"\"\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport sys\nimport urllib.request\nimport urllib.error\nfrom typing import Any\n\n\ndef build_messages(prompt: str, image: str | None) -> list[dict[str, Any]]:\n if image is None:\n return [{\"role\": \"user\", \"content\": prompt}]\n # MiMo requires BOTH image_url and a text part — sending image-only returns\n # 400 \"Param Incorrect: `text` is not set\". If the user gave no prompt,\n # fall back to a single space (the model will infer intent from the image).\n return [\n {\n \"role\": \"user\",\n \"content\": [\n {\"type\": \"image_url\", \"image_url\": {\"url\": image}},\n {\"type\": \"text\", \"text\": prompt or \" \"},\n ],\n }\n ]\n\n\nPOLLINATIONS_URL = \"https://text.pollinations.ai/openai\"\nPOLLINATIONS_DEFAULT_MODEL = \"openai\" # vision-capable, free, no key\n\n# MiMo retired its v2 generation (offline 2026-06-30). This script calls MiMo\n# directly (no proxy alias layer), so old names 400 upstream — coerce them to\n# their live replacement (mimo-v2-pro → mimo-v2.5-pro; omni/flash → mimo-v2.5).\nRETIRED_MIMO_MODELS = {\"mimo-v2-omni\", \"mimo-v2-flash\", \"mimo-v2-pro\"}\n\n\ndef build_body(\n *,\n prompt: str,\n image: str | None,\n model: str,\n stream: bool,\n enable_web_search: bool,\n max_tokens: int,\n temperature: float,\n engine: str,\n) -> dict[str, Any]:\n body: dict[str, Any] = {\n \"model\": model,\n \"messages\": build_messages(prompt, image),\n \"temperature\": temperature,\n \"stream\": stream,\n }\n if engine == \"mimo\":\n # MiMo's quirk: max_completion_tokens, not max_tokens.\n body[\"max_completion_tokens\"] = max_tokens\n else:\n body[\"max_tokens\"] = max_tokens\n if enable_web_search:\n # MiMo native web_search builtin. The model decides whether to invoke\n # it (tool_choice=auto). Requires the Web Search Plugin to be\n # activated at https://platform.xiaomimimo.com/#/console/plugin —\n # without that, MiMo returns 400 and the error body is printed.\n body[\"tools\"] = [{\"type\": \"web_search\"}]\n body[\"tool_choice\"] = \"auto\"\n return body\n\n\ndef post(url: str, body: dict[str, Any], api_key: str | None, stream: bool, *, engine: str) -> Any:\n headers = {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\" if stream else \"application/json\",\n \"User-Agent\": \"mimoskill/0.1\",\n }\n if api_key:\n headers[\"Authorization\"] = f\"Bearer {api_key}\"\n req = urllib.request.Request(\n url,\n method=\"POST\",\n data=json.dumps(body).encode(\"utf-8\"),\n headers=headers,\n )\n try:\n return urllib.request.urlopen(req, timeout=300)\n except urllib.error.HTTPError as e:\n snippet = e.read().decode(\"utf-8\", \"replace\")\n sys.stderr.write(f\"{engine} returned HTTP {e.code}: {snippet}\\n\")\n sys.exit(1)\n except urllib.error.URLError as e:\n sys.stderr.write(f\"connection failed ({engine}): {e}\\n\")\n sys.exit(1)\n\n\ndef stream_chat(resp: Any) -> None:\n annotations: list[dict[str, Any]] = []\n for raw in resp:\n line = raw.decode(\"utf-8\", \"replace\").strip()\n if not line.startswith(\"data:\"):\n continue\n data = line[5:].strip()\n if data == \"[DONE]\":\n break\n try:\n chunk = json.loads(data)\n except json.JSONDecodeError:\n continue\n choice = chunk.get(\"choices\", [{}])[0]\n delta = choice.get(\"delta\", {})\n for ann in delta.get(\"annotations\") or []:\n annotations.append(ann)\n # Print reasoning_content dimly to stderr, content to stdout\n if r := delta.get(\"reasoning_content\"):\n sys.stderr.write(r)\n sys.stderr.flush()\n if c := delta.get(\"content\"):\n sys.stdout.write(c)\n sys.stdout.flush()\n sys.stdout.write(\"\\n\")\n if annotations:\n sys.stderr.write(\"\\n--- citations ---\\n\")\n for a in annotations:\n sys.stderr.write(f\" • {a.get('title', '(no title)')}\\n {a.get('url')}\\n\")\n\n\ndef non_stream_chat(resp: Any) -> None:\n payload = json.loads(resp.read().decode(\"utf-8\"))\n msg = payload[\"choices\"][0][\"message\"]\n if reasoning := msg.get(\"reasoning_content\"):\n sys.stderr.write(f\"[reasoning]\\n{reasoning}\\n[/reasoning]\\n\\n\")\n print(msg.get(\"content\") or \"\")\n if anns := msg.get(\"annotations\"):\n sys.stderr.write(\"\\n--- citations ---\\n\")\n for a in anns:\n sys.stderr.write(f\" • {a.get('title', '(no title)')}\\n {a.get('url')}\\n\")\n\n\ndef main() -> None:\n p = argparse.ArgumentParser(description=__doc__.split(\"\\n\", 1)[0])\n p.add_argument(\"prompt\", nargs=\"?\", default=\"\", help=\"user message text\")\n p.add_argument(\"--model\", default=os.environ.get(\"MIMO_MODEL\", \"mimo-v2.5-pro\"))\n p.add_argument(\"--image\", help=\"image URL to attach (forces vision-capable model)\")\n p.add_argument(\"--stream\", action=\"store_true\", help=\"stream the response\")\n p.add_argument(\"--max-tokens\", type=int, default=2048)\n p.add_argument(\"--temperature\", type=float, default=0.7)\n p.add_argument(\n \"--engine\",\n choices=[\"auto\", \"mimo\", \"pollinations\"],\n default=os.environ.get(\"MIMO_CHAT_ENGINE\", \"auto\"),\n help=\"chat backend. auto = mimo if MIMO_API_KEY set, else pollinations \"\n \"(free, no key required). default: %(default)s\",\n )\n p.add_argument(\n \"--base-url\",\n default=os.environ.get(\"MIMO_BASE_URL\", \"https://api.xiaomimimo.com/v1\"),\n help=\"MiMo endpoint, ignored when --engine=pollinations \"\n \"(tp-* keys use https://token-plan-cn.xiaomimimo.com/v1)\",\n )\n p.add_argument(\n \"--pollinations-model\",\n default=os.environ.get(\"POLLINATIONS_MODEL\", POLLINATIONS_DEFAULT_MODEL),\n help=\"model id when --engine=pollinations (default: %(default)s)\",\n )\n args = p.parse_args()\n\n api_key = os.environ.get(\"MIMO_API_KEY\")\n\n # Resolve engine.\n if args.engine == \"mimo\":\n engine = \"mimo\"\n if not api_key:\n sys.stderr.write(\n \"error: --engine mimo requires MIMO_API_KEY.\\n\"\n \" get one at https://platform.xiaomimimo.com/#/console/api-keys\\n\"\n \" OR drop the flag to fall back to pollinations (free, no key required):\\n\"\n \" python3 mimo_chat.py <prompt>\\n\"\n )\n sys.exit(3)\n elif args.engine == \"pollinations\":\n engine = \"pollinations\"\n else: # auto\n engine = \"mimo\" if api_key else \"pollinations\"\n if engine == \"pollinations\":\n sys.stderr.write(\n \"[engine] auto -> pollinations (free, no key). \"\n \"Set MIMO_API_KEY for higher quality (mimo-v2.5).\\n\"\n )\n\n if not args.prompt and not args.image:\n sys.stderr.write(\"error: pass a prompt and/or --image\\n\")\n sys.exit(2)\n\n enable_web_search = False\n if engine == \"mimo\":\n model = args.model\n # Retired v2 names 400 upstream — coerce to the live replacement first.\n if model in RETIRED_MIMO_MODELS:\n replacement = \"mimo-v2.5-pro\" if model == \"mimo-v2-pro\" else \"mimo-v2.5\"\n sys.stderr.write(\n f\"note: model '{model}' is retired; using {replacement} for this call.\\n\"\n )\n model = replacement\n # Auto-bump to a vision model if user passed --image with a non-vision model.\n if args.image and model != \"mimo-v2.5\" and not model.startswith(\"mimo-v2.5[\"):\n sys.stderr.write(\n f\"note: --image given but model is '{model}' which doesn't see images.\\n\"\n f\" switching to mimo-v2.5 for this call.\\n\"\n )\n model = \"mimo-v2.5\"\n url = args.base_url.rstrip(\"/\") + \"/chat/completions\"\n auth: str | None = api_key\n # MiMo native web_search: pay-as-you-go (sk-*) supports it, token-plan\n # (tp-*) does not. Always include the tool on sk-* and let the model\n # decide via tool_choice=auto — no extra flag needed.\n enable_web_search = bool(api_key and api_key.startswith(\"sk-\"))\n else:\n # Pollinations: pick the configured vision-capable model. The user's\n # --model (mimo-*) is mimo-specific so we don't honor it here unless\n # they explicitly passed --pollinations-model.\n model = args.pollinations_model\n url = POLLINATIONS_URL\n auth = None\n\n sys.stderr.write(\n f\"[chat] engine={engine} model={model}\"\n + (\" web_search=on\" if enable_web_search else \"\")\n + \"\\n\"\n )\n\n body = build_body(\n prompt=args.prompt,\n image=args.image,\n model=model,\n stream=args.stream,\n enable_web_search=enable_web_search,\n max_tokens=args.max_tokens,\n temperature=args.temperature,\n engine=engine,\n )\n\n resp = post(url, body, auth, args.stream, engine=engine)\n if args.stream:\n stream_chat(resp)\n else:\n non_stream_chat(resp)\n\n\nif __name__ == \"__main__\":\n main()\n"}}, "scripts/install.sh": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595270.3127718, "n": 5545, "h": "d61e5ee8e3dae87d", "c": "#!/usr/bin/env bash\n# mimo2codex bootstrap — Linux / macOS / WSL / Git Bash on Windows.\n# Usage:\n# curl -fsSL <raw-url>/scripts/install.sh | bash\n# ./scripts/install.sh # if already cloned\n# ./scripts/install.sh --start # also start the proxy after install\n#\n# Env knobs:\n# MIMO2CODEX_REPO — git URL to clone (default: official repo)\n# MIMO2CODEX_DIR — target directory name (default: mimo2codex)\n# MIMO_API_KEY — set to auto-start proxy when --start is passed\n#\n# What this script does (idempotent):\n# 1. Verify git ≥ any, node ≥ 18, npm available\n# 2. Clone or fast-forward-pull the repo\n# 3. npm install (skips audit/fund noise)\n# 4. npm run build (compile TypeScript)\n# 5. npm test (best-effort; warn on failure but don't abort)\n# 6. Print next-step commands\n# 7. (optional) start the proxy if --start was passed\n#\nset -euo pipefail\n\nREPO_URL=\"${MIMO2CODEX_REPO:-https://github.com/7as0nch/mimo2codex.git}\"\nTARGET_DIR=\"${MIMO2CODEX_DIR:-mimo2codex}\"\nSTART_AFTER=false\nfor arg in \"$@\"; do\n case \"$arg\" in\n --start) START_AFTER=true ;;\n -h|--help)\n sed -n '2,15p' \"$0\" | sed 's/^# \\{0,1\\}//'\n exit 0\n ;;\n esac\ndone\n\n# ── colors ──────────────────────────────────────────────────────────────────\nif [[ -t 1 ]] && [[ \"${TERM:-}\" != \"dumb\" ]]; then\n C_RED='\\033[0;31m'; C_GRN='\\033[0;32m'; C_YEL='\\033[0;33m'\n C_CYN='\\033[0;36m'; C_BLD='\\033[1m'; C_RST='\\033[0m'\nelse\n C_RED=''; C_GRN=''; C_YEL=''; C_CYN=''; C_BLD=''; C_RST=''\nfi\nstep() { printf \"${C_CYN}${C_BLD}==>${C_RST} ${C_BLD}%s${C_RST}\\n\" \"$1\"; }\nok() { printf \"${C_GRN} ✓${C_RST} %s\\n\" \"$1\"; }\nwarn() { printf \"${C_YEL} !${C_RST} %s\\n\" \"$1\"; }\nerr() { printf \"${C_RED} ✗${C_RST} %s\\n\" \"$1\" >&2; }\n\n# ── 1. detect prereqs ──────────────────────────────────────────────────────\nstep \"Checking prerequisites\"\n\nif ! command -v git >/dev/null 2>&1; then\n err \"git is not installed\"\n echo \" macOS: xcode-select --install (or: brew install git)\"\n echo \" Linux: sudo apt-get install -y git (or your distro's package manager)\"\n echo \" Windows: install Git for Windows: https://git-scm.com/download/win\"\n exit 1\nfi\nok \"git $(git --version | awk '{print $3}')\"\n\nif ! command -v node >/dev/null 2>&1; then\n err \"node is not installed\"\n echo \" Install Node.js 18+ from https://nodejs.org/\"\n echo \" Or use a version manager:\"\n echo \" nvm: https://github.com/nvm-sh/nvm\"\n echo \" fnm: https://github.com/Schniz/fnm\"\n exit 1\nfi\nNODE_MAJOR=$(node -v | sed 's/^v//' | cut -d. -f1)\nif [[ \"$NODE_MAJOR\" -lt 18 ]]; then\n err \"node $(node -v) is too old; mimo2codex needs Node.js >= 18\"\n exit 1\nfi\nok \"node $(node -v)\"\n\nif ! command -v npm >/dev/null 2>&1; then\n err \"npm is not installed (it normally ships with Node.js)\"\n exit 1\nfi\nok \"npm $(npm -v)\"\n\n# ── 2. clone or pull ───────────────────────────────────────────────────────\n# If we're invoked from inside a working clone (e.g. a contributor running it\n# locally), skip the clone step and just use $PWD.\nSCRIPT_DIR=$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" &> /dev/null && pwd)\nROOT_DIR=\"\"\nif [[ -f \"$SCRIPT_DIR/../package.json\" ]] && grep -q '\"name\": \"mimo2codex\"' \"$SCRIPT_DIR/../package.json\" 2>/dev/null; then\n ROOT_DIR=$(cd -- \"$SCRIPT_DIR/..\" && pwd)\n step \"Detected in-repo run; skipping clone (using $ROOT_DIR)\"\nelif [[ -d \"$TARGET_DIR/.git\" ]]; then\n step \"Updating existing clone at ./$TARGET_DIR\"\n git -C \"$TARGET_DIR\" pull --ff-only\n ROOT_DIR=$(cd -- \"$TARGET_DIR\" && pwd)\n ok \"pulled latest\"\nelse\n step \"Cloning $REPO_URL → ./$TARGET_DIR\"\n git clone --depth 1 \"$REPO_URL\" \"$TARGET_DIR\"\n ROOT_DIR=$(cd -- \"$TARGET_DIR\" && pwd)\n ok \"cloned\"\nfi\n\ncd \"$ROOT_DIR\"\n\n# ── 3. install ─────────────────────────────────────────────────────────────\nstep \"Installing dependencies (npm install)\"\nnpm install --no-audit --no-fund --loglevel=error\nok \"deps installed\"\n\n# ── 4. build ───────────────────────────────────────────────────────────────\nstep \"Building TypeScript (npm run build)\"\nnpm run build --silent\nok \"built dist/\"\n\n# ── 5. tests (best effort) ─────────────────────────────────────────────────\nstep \"Running tests\"\nif npm test --silent >/dev/null 2>&1; then\n ok \"all tests pass\"\nelse\n warn \"tests failed — proxy may still work; rerun \\`npm test\\` for details\"\nfi\n\n# ── 6. final instructions ──────────────────────────────────────────────────\ncat <<EOF\n\n${C_GRN}${C_BLD}✓ mimo2codex is ready at${C_RST} ${C_BLD}$ROOT_DIR${C_RST}\n\n${C_BLD}Next steps:${C_RST}\n\n ${C_CYN}1)${C_RST} Get a MiMo API key (skip if you already have one):\n https://platform.xiaomimimo.com/#/console/api-keys\n\n ${C_CYN}2)${C_RST} Start the proxy:\n cd $ROOT_DIR\n export MIMO_API_KEY=sk-your-real-mimo-key\n node dist/cli.js\n\n ${C_CYN}3)${C_RST} Print the Codex config snippet (auth.json + config.toml):\n node dist/cli.js print-config\n\n Or for cc-switch users:\n node dist/cli.js print-cc-switch\n\n ${C_CYN}4)${C_RST} Restart Codex (desktop: fully quit + relaunch).\n\nSee README.md / README.zh.md for cc-switch setup, troubleshooting, and\nhow to keep the proxy running in the background (systemd / pm2).\n\nEOF\n\n# ── 7. optional auto-start ─────────────────────────────────────────────────\nif [[ \"$START_AFTER\" == \"true\" ]]; then\n if [[ -z \"${MIMO_API_KEY:-}\" ]]; then\n err \"MIMO_API_KEY is not set in your environment.\"\n echo \" export MIMO_API_KEY=sk-... and rerun, or omit --start.\"\n exit 1\n fi\n step \"Starting proxy\"\n exec node dist/cli.js\nfi\n"}}, "src/server.ts": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595270.5423732, "n": 67342, "h": "e4688bd0d0b9c1c8", "c": "import { createServer, type IncomingMessage, type Server, type ServerResponse } from \"node:http\";\nimport type { Config } from \"./config.js\";\nimport { respToResponses } from \"./translate/respToResponses.js\";\nimport { pipeChatStreamToResponses, type StreamPipelineResult } from \"./translate/streamToSse.js\";\nimport { iterChatStreamChunks } from \"./upstream/chatStream.js\";\nimport { maybeCompactChat, type ChatCaller } from \"./translate/autoCompact.js\";\nimport {\n callOpenAICompat,\n callResponsesPassthrough,\n UpstreamError,\n} from \"./upstream/openaiCompatClient.js\";\nimport { BUILTIN_PROVIDERS, PROVIDER_LIST, PROVIDERS } from \"./providers/registry.js\";\nimport type { Provider, ProviderModel, ProviderRuntime } from \"./providers/types.js\";\nimport { makeServerResponseSink } from \"./util/sse.js\";\nimport { log } from \"./util/log.js\";\nimport type { ChatRequest, ChatResponse, ChatUsage, ResponsesRequest } from \"./translate/types.js\";\nimport { handleAdmin } from \"./admin/router.js\";\nimport { authGuard } from \"./auth/middleware.js\";\nimport { resolveRuntimeForUser } from \"./auth/byok.js\";\nimport { handleOAuthRoutes } from \"./auth/oauthRoutes.js\";\nimport type { UserRow } from \"./db/users.js\";\nimport { insertLog, type ChatLogEntry } from \"./db/logs.js\";\nimport { getActiveOverride, type ActiveOverride } from \"./db/overrides.js\";\nimport { getSetting } from \"./db/settings.js\";\nimport { backfillStatsChunk, isStatsBackfillDone } from \"./db/stats.js\";\nimport { applyLogBodyMode, resolveLogBodyMode, runLogMaintenance } from \"./logging/settings.js\";\nimport { redactSensitive } from \"./util/redact.js\";\nimport { isMaintenance, getMaintenanceMessage } from \"./util/maintenance.js\";\n\n// Wraps getActiveOverride() so the per-request DB lookup is safe when:\n// - admin is disabled (no DB open → getDb() would throw),\n// - the settings table is missing for any reason,\n// - the rows have been deleted concurrently.\n// Returns null in every error path; selectProvider treats null as \"no override\".\nfunction readActiveOverrideSafely(cfg: Config): ActiveOverride | null {\n if (!cfg.adminEnabled) return null;\n try {\n return getActiveOverride();\n } catch {\n return null;\n }\n}\n\n// disableThinking 三段解析:CLI/env (cfg.disableThinkingFromCli) > admin settings DB > false。\n// CLI 显式设了 true/false 都尊重;CLI 没设时查 settings.thinking.disabled(\"1\"=开)。\n// 每个请求开始时调一次,让 admin UI 修改 settings 后**无需重启**立刻生效。\nfunction resolveDisableThinking(cfg: Config): boolean {\n if (cfg.disableThinkingFromCli !== undefined) return cfg.disableThinkingFromCli;\n if (!cfg.adminEnabled) return false;\n try {\n return getSetting(\"thinking.disabled\") === \"1\";\n } catch {\n return false;\n }\n}\n\n// forceHighEffort 解析:admin settings DB → false。当前没暴露 CLI flag(如有需求再加)。\n// 与 disableThinking 不同维度:disableThinking=true 时本开关被忽略。\nfunction resolveForceHighEffort(cfg: Config): boolean {\n if (!cfg.adminEnabled) return false;\n try {\n return getSetting(\"thinking.forceHighEffort\") === \"1\";\n } catch {\n return false;\n }\n}\n\n// web_search 转发解析:CLI/env (cfg.webSearchFromCli) > admin settings DB > false。\n// 默认关闭 —— MiMo 的 Web Search Plugin 单独计费、默认不开通,没开通时转发 web_search\n// 会 400 \"webSearchEnabled is false\"。仅在用户显式打开时才转发。每请求调用一次,admin\n// UI 改了即时生效、无需重启。\nfunction resolveWebSearchEnabled(cfg: Config): boolean {\n if (cfg.webSearchFromCli !== undefined) return cfg.webSearchFromCli;\n if (!cfg.adminEnabled) return false;\n try {\n return getSetting(\"mimo.webSearchEnabled\") === \"1\";\n } catch {\n return false;\n }\n}\n\n// silentRewrite 解析:env (cfg.silentRewriteFromCli) > admin settings DB > true。\n// 注意默认是 **静默**(true)—— admin UI 顶部「更多」里有快速开关。每请求调一次,\n// admin 改了立即生效,无需重启。\nfunction resolveSilentRewrite(cfg: Config): boolean {\n if (cfg.silentRewriteFromCli !== undefined) return cfg.silentRewriteFromCli;\n if (!cfg.adminEnabled) return true;\n try {\n const s = getSetting(\"logging.silentRewrite\");\n return s === null ? true : s !== \"0\";\n } catch {\n return true;\n }\n}\n\nconst KEEPALIVE_INTERVAL_MS = 15_000;\n\n// Serialized SSE `error` event used to deliver a terminal failure once the\n// 200 + SSE headers have already been flushed (so we can no longer answer with\n// a JSON 4xx). Codex parses this from the data field like any other event.\nfunction sseErrorEvent(code: string, message: string): string {\n return `event: error\\ndata: ${JSON.stringify({\n type: \"error\",\n code,\n message,\n sequence_number: 9999,\n })}\\n\\n`;\n}\n\n// Cheap image-payload summary for logging — counts image parts and sums the\n// length of their (base64/url) strings WITHOUT serializing the whole body, so\n// it stays fast even on multi-MB multimodal requests. Helps diagnose the\n// large-context/image \"stream disconnected\" reports (issue #65).\nfunction summarizeChatImages(chat: ChatRequest): { images: number; imageChars: number } {\n let images = 0;\n let imageChars = 0;\n for (const msg of chat.messages ?? []) {\n if (Array.isArray(msg.content)) {\n for (const part of msg.content) {\n if (part.type === \"image_url\" && part.image_url?.url) {\n images++;\n imageChars += part.image_url.url.length;\n }\n }\n }\n }\n return { images, imageChars };\n}\n\n// Request-body cap. Multimodal turns (base64 images) can be large, so the old\n// hard-coded 16MB was easy to exceed — and the old overflow path `destroy()`d\n// the socket mid-upload, which Codex saw as a connection reset (\"error sending\n// request for url\", issue #65) instead of a clean error. Now the cap is\n// configurable (MIMO2CODEX_MAX_REQUEST_BODY_MB, default 64MB) and overflow\n// drains the rest of the body before rejecting, so the caller can answer with a\n// proper 413 the client actually receives.\nfunction maxRequestBodyBytes(): number {\n const raw = process.env.MIMO2CODEX_MAX_REQUEST_BODY_MB;\n const def = 64;\n const mb = raw ? Number(raw) : def;\n const safe = Number.isFinite(mb) && mb > 0 ? mb : def;\n return Math.trunc(safe * 1024 * 1024);\n}\n\nexport class RequestBodyTooLargeError extends Error {\n limitBytes: number;\n constructor(limitBytes: number) {\n super(\n `request body exceeds ${Math.round(limitBytes / (1024 * 1024))}MB limit ` +\n `(too many or too large images). Reduce/compress images, or raise ` +\n `MIMO2CODEX_MAX_REQUEST_BODY_MB.`\n );\n this.name = \"RequestBodyTooLargeError\";\n this.limitBytes = limitBytes;\n }\n}\n\nasync function readJsonBody<T>(req: IncomingMessage, maxBytes = maxRequestBodyBytes()): Promise<T> {\n return await new Promise<T>((resolve, reject) => {\n const chunks: Buffer[] = [];\n let total = 0;\n let overflow = false;\n // Once we blow the cap, stop buffering (free memory) but keep draining the\n // socket so the client finishes sending and can RECEIVE our 413 — abruptly\n // destroying here is exactly what Codex reports as a disconnect. Cap the\n // drain so a runaway upload still can't waste unbounded time.\n const drainCeiling = maxBytes * 4;\n req.on(\"data\", (chunk: Buffer) => {\n total += chunk.length;\n if (overflow) {\n if (total > drainCeiling) req.destroy();\n return;\n }\n if (total > maxBytes) {\n overflow = true;\n chunks.length = 0;\n return;\n }\n chunks.push(chunk);\n });\n req.on(\"end\", () => {\n if (overflow) return reject(new RequestBodyTooLargeError(maxBytes));\n try {\n const text = Buffer.concat(chunks).toString(\"utf-8\");\n if (!text) return resolve({} as T);\n resolve(JSON.parse(text) as T);\n } catch (err) {\n reject(err);\n }\n });\n req.on(\"error\", reject);\n });\n}\n\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.statusCode = status;\n res.setHeader(\"Content-Type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify(body));\n}\n\nfunction errorEnvelope(status: number, code: string, message: string): {\n error: { type: string; code: string; message: string; status: number };\n} {\n return {\n error: {\n type:\n status === 401\n ? \"authentication_error\"\n : status === 429\n ? \"rate_limit_exceeded\"\n : status >= 500\n ? \"server_error\"\n : \"invalid_request_error\",\n code,\n message,\n status,\n },\n };\n}\n\nexport interface SelectedProvider {\n provider: Provider;\n runtime: ProviderRuntime;\n upstreamModel: string;\n // Resolved ProviderModel for upstreamModel, when the provider's catalog\n // knew it. Carries contextWindow so the upstream client can enrich\n // \"context length exceeded\" errors with the actual cap.\n modelInfo: ProviderModel | null;\n // Set when the client-supplied model id was NOT a known model for the\n // routed provider and we fell back to a different upstream id. Surfaced\n // in logs so the rewrite is visible (vs. silently changing the model id\n // and confusing the user when capabilities like vision diverge).\n rewriteNotice: { from: string; to: string; reason: string } | null;\n}\n\nfunction recordLog(cfg: Config, entry: Omit<ChatLogEntry, \"ts\">): void {\n if (!cfg.adminEnabled) return;\n const ts = Date.now();\n setImmediate(() => {\n try {\n const bodies = applyLogBodyMode(resolveLogBodyMode(cfg), entry.status_code, {\n requestBody: entry.request_body,\n responseBody: entry.response_body,\n });\n insertLog({\n ...entry,\n ts,\n request_body: bodies.requestBody,\n response_body: bodies.responseBody,\n });\n } catch (err) {\n log.warn(\"chat_logs insert failed\", { error: (err as Error).message });\n }\n });\n}\n\n// Returns a recordLog bound to the calling user — every chat_logs insert it\n// produces carries the user_id column. Used as a local shadow inside each\n// /v1/* handler so existing call sites don't need editing.\nfunction userLogger(user: UserRow | null): typeof recordLog {\n if (!user) return recordLog;\n const userId = user.id;\n return (cfg: Config, entry: Omit<ChatLogEntry, \"ts\">): void => {\n if (!cfg.adminEnabled) return;\n const ts = Date.now();\n setImmediate(() => {\n try {\n const bodies = applyLogBodyMode(resolveLogBodyMode(cfg), entry.status_code, {\n requestBody: entry.request_body,\n responseBody: entry.response_body,\n });\n insertLog({\n ...entry,\n ts,\n user_id: userId,\n request_body: bodies.requestBody,\n response_body: bodies.responseBody,\n });\n } catch (err) {\n log.warn(\"chat_logs insert failed\", { error: (err as Error).message });\n }\n });\n };\n}\n\nfunction usageFromChatResponse(u: ChatUsage | undefined): {\n prompt_tokens: number | null;\n completion_tokens: number | null;\n total_tokens: number | null;\n cached_tokens: number | null;\n} {\n if (!u)\n return {\n prompt_tokens: null,\n completion_tokens: null,\n total_tokens: null,\n cached_tokens: null,\n };\n return {\n prompt_tokens: u.prompt_tokens ?? null,\n completion_tokens: u.completion_tokens ?? null,\n total_tokens: u.total_tokens ?? null,\n cached_tokens: u.prompt_tokens_details?.cached_tokens ?? null,\n };\n}\n\n// Stringify and redact a value before persisting to chat_logs.request_body\n// or response_body. Returns null on serialization failure so a corrupt body\n// never blocks the log insert.\nfunction bodyForLog(value: unknown): string | null {\n if (value === undefined || value === null) return null;\n try {\n const text = typeof value === \"string\" ? value : JSON.stringify(value);\n return redactSensitive(text);\n } catch {\n return null;\n }\n}\n\nfunction countToolCallsInChatResponse(resp: ChatResponse | undefined): number | null {\n if (!resp || !Array.isArray(resp.choices)) return null;\n let n = 0;\n for (const c of resp.choices) {\n if (c.message?.tool_calls) n += c.message.tool_calls.length;\n }\n return n;\n}\n\n// Route a request to a provider based on the client-supplied model field:\n// 1. Walk every registered provider (built-ins first, then user-declared\n// generics). Pick the first one whose catalog contains the client model\n// AND has an API key configured. This lets generics shadow built-in\n// model names when the built-in has no key — e.g. an internal MiMo\n// proxy declared as a generic can serve `mimo-v2.5-pro` without the\n// built-in MiMo provider intercepting and triggering a rewrite.\n// 2. If no provider with a key matches, use the configured default\n// provider; the body.model is rewritten to the default provider's\n// defaultModel so we never forward an unknown id.\n//\n// Whenever the model id is rewritten on the way out (e.g. an unknown\n// `mimo-v2.5-vision-preview` is fallen back to `mimo-v2.5-pro`), we attach a\n// `rewriteNotice` so callers can log/persist the mismatch. Silent rewrites\n// hide capability mismatches like vision support and are the root of bugs\n// where a client thinks it's calling `mimo-v2.5` but the proxy sent\n// `mimo-v2.5-pro` upstream.\n// Compute built-in provider IDs from the canonical source in registry.ts.\n// Generic providers are forbidden from using these (RESERVED_IDS in generic.ts),\n// so checking the id reliably distinguishes built-ins from user-declared generics.\nconst BUILTIN_IDS = new Set(BUILTIN_PROVIDERS.map((p) => p.id));\n\n// Optional \"force this (provider, model)\" hint from the admin UI's\n// runtime-override feature. selectProvider does not read this from the DB\n// itself — the call site fetches it and passes it in, keeping selectProvider\n// pure and testable in isolation.\nexport interface ProviderOverride {\n providerId: string;\n modelId: string;\n}\n\n// Reject a payg-only model (e.g. UltraSpeed, issue #70) when the active key is a\n// token-plan (tp-) key — that host doesn't serve the model and would 404 with a\n// confusing message. Returns a user-facing reason string, or null when fine.\nexport function paygOnlyBlock(\n modelInfo: ProviderModel | null,\n runtime: ProviderRuntime\n): string | null {\n if (modelInfo?.paygOnly && runtime.flags.isTokenPlan) {\n return (\n `${modelInfo.displayName ?? modelInfo.id} requires a pay-as-you-go (sk-) API key ` +\n `with approved access — token-plan / subscription keys can't use it. ` +\n `Apply at https://platform.xiaomimimo.com/ultraspeed`\n );\n }\n return null;\n}\n\nexport function selectProvider(\n clientModel: string,\n cfg: Config,\n override?: ProviderOverride | null\n): SelectedProvider {\n // Routing priority: the client's REQUESTED model wins first.\n // 1) Pass 1 — user-declared generic providers that recognize the model\n // 2) Pass 2 — built-in providers (mimo, deepseek) that recognize the model\n // 3) runtime override (webui) — applied only when no catalog recognizes it\n // 4) default provider — bare fallback\n // An explicitly-configured Codex model thus takes precedence over a standing\n // runtime override; the override is the smart fallback for model ids that no\n // provider catalog claims (instead of silently rewriting to the default).\n\n // Pass 1: user-declared generic providers (non-empty models, has key).\n // Generics take priority over built-ins so that an internal MiMo/DeepSeek\n // proxy declared as a generic can serve the same model names (e.g.\n // `mimo-v2.5-pro`) without the built-in intercepting.\n for (const p of PROVIDER_LIST) {\n if (BUILTIN_IDS.has(p.id)) continue;\n const isOpenCatalog = !p.builtinModels || p.builtinModels.length === 0;\n if (isOpenCatalog) continue;\n if (!p.resolveModel(clientModel)) continue;\n const runtime = cfg.providers[p.id];\n if (!runtime) continue;\n const resolved = p.resolveModel(clientModel);\n return {\n provider: p,\n runtime,\n upstreamModel: resolved?.id ?? p.defaultModel,\n modelInfo: resolved ?? p.resolveModel(p.defaultModel),\n rewriteNotice: resolved\n ? null\n : {\n from: clientModel,\n to: p.defaultModel,\n reason: \"matched provider catalog but unknown model id → using provider's defaultModel\",\n },\n };\n }\n\n // Pass 2: built-in providers (mimo, deepseek) — only reached when no\n // generic claimed this model id.\n for (const p of PROVIDER_LIST) {\n if (!BUILTIN_IDS.has(p.id)) continue;\n if (!p.resolveModel(clientModel)) continue;\n const runtime = cfg.providers[p.id];\n if (!runtime) continue;\n const resolved = p.resolveModel(clientModel);\n return {\n provider: p,\n runtime,\n upstreamModel: resolved?.id ?? p.defaultModel,\n modelInfo: resolved ?? p.resolveModel(p.defaultModel),\n rewriteNotice: resolved\n ? null\n : {\n from: clientModel,\n to: p.defaultModel,\n reason: \"matched provider catalog but unknown model id → using provider's defaultModel\",\n },\n };\n }\n\n // Pass 3: runtime override (set via the webui) — reached only when no\n // registered+keyed provider recognized the client's model. It's the\n // next-priority pick before the bare default, honored only when the override\n // provider is registered AND has a runtime (api key); a stale override falls\n // through to the default below.\n if (override && override.providerId && override.modelId) {\n const p = PROVIDERS[override.providerId as keyof typeof PROVIDERS];\n const runtime = p ? cfg.providers[p.id] : null;\n if (p && runtime) {\n const resolved = p.resolveModel(override.modelId);\n const upstreamModel = resolved?.id ?? override.modelId;\n return {\n provider: p,\n runtime,\n upstreamModel,\n modelInfo: resolved ?? p.resolveModel(p.defaultModel),\n rewriteNotice: resolved\n ? null\n : {\n from: clientModel,\n to: upstreamModel,\n reason: `runtime override → ${p.id}/${override.modelId} (model id not in provider catalog, forwarded verbatim)`,\n },\n };\n }\n // override unusable → fall through to the default provider\n }\n\n // No provider with a key matches → fall back to the default provider.\n const provider = PROVIDERS[cfg.defaultProviderId];\n const runtime = cfg.providers[cfg.defaultProviderId];\n if (!runtime) {\n throw new Error(`provider ${cfg.defaultProviderId} has no runtime (missing api key)`);\n }\n const resolved = provider.resolveModel(clientModel);\n const upstreamModel = resolved?.id ?? provider.defaultModel;\n return {\n provider,\n runtime,\n upstreamModel,\n modelInfo: resolved ?? provider.resolveModel(provider.defaultModel),\n rewriteNotice: resolved\n ? null\n : {\n from: clientModel,\n to: upstreamModel,\n reason: `unknown client model — falling back to ${cfg.defaultProviderId} provider's defaultModel`,\n },\n };\n}\n\nfunction rewriteWarning(notice: { from: string; to: string; reason: string }): {\n code: string;\n message: string;\n} {\n return {\n code: \"client_model_rewritten\",\n message: `client model \"${notice.from}\" was rewritten to upstream \"${notice.to}\" — ${notice.reason}. If you wanted the original id, add it to the provider's builtinModels or configure an alias.`,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Vision (multimodal) fallback\n// ---------------------------------------------------------------------------\n\n// 读取 DB 设置,返回 vision fallback 模型名;未启用或 admin 关闭时返回 null。\nfunction resolveVisionFallback(cfg: Config): string | null {\n if (!cfg.adminEnabled) return null;\n try {\n if (getSetting(\"codex.visionFallbackEnabled\") !== \"1\") return null;\n const model = getSetting(\"codex.visionFallbackModel\");\n return model || \"mimo-v2.5\";\n } catch {\n return null;\n }\n}\n\n// Auto-compaction config. Resolution order matches the rest of the proxy:\n// CLI/env → admin DB → default. Default ON.\n//\n// The trigger SCALES WITH THE MODEL: by default it's `contextWindow × threshold`\n// (threshold default 0.8), so a 1M-window model compacts near ~800k, a 256k one\n// near ~205k — not at some tiny fixed number. Only summarize when the history is\n// genuinely close to the model's limit; the disconnect itself is already handled\n// by the upstream-timeout + keepalive fixes.\n//\n// If a provider's advertised `contextWindow` OVERSTATES its real usable cap\n// (some upstreams report a big number but 400 earlier), set an absolute trigger\n// with `MIMO2CODEX_AUTO_COMPACT_AT_TOKENS` (or admin `codex.autoCompactAtTokens`).\n// Returns atTokens=null when no trigger can be determined (no override AND\n// unknown window) → compaction is skipped.\nfunction resolveAutoCompact(\n cfg: Config,\n contextWindow?: number\n): { enabled: boolean; atTokens: number | null } {\n const readNum = (raw: string | null | undefined, pred: (n: number) => boolean): number | null => {\n if (!raw) return null;\n const n = Number(raw);\n return Number.isFinite(n) && pred(n) ? n : null;\n };\n\n let threshold = readNum(process.env.MIMO2CODEX_AUTO_COMPACT_THRESHOLD, (n) => n > 0 && n < 1) ?? 0.8;\n let override = readNum(process.env.MIMO2CODEX_AUTO_COMPACT_AT_TOKENS, (n) => n > 0);\n if (override !== null) override = Math.trunc(override);\n\n let enabled: boolean;\n const envEnabled = process.env.MIMO2CODEX_AUTO_COMPACT;\n if (envEnabled !== undefined) {\n enabled = !/^(0|false|off|no)$/i.test(envEnabled);\n } else if (cfg.adminEnabled) {\n try {\n const s = getSetting(\"codex.autoCompactEnabled\");\n enabled = s === null ? true : s === \"1\"; // default ON\n if (override === null) {\n const t = readNum(getSetting(\"codex.autoCompactAtTokens\"), (n) => n > 0);\n if (t !== null) override = Math.trunc(t);\n }\n const tt = readNum(getSetting(\"codex.autoCompactThreshold\"), (n) => n > 0 && n < 1);\n if (tt !== null) threshold = tt;\n } catch {\n enabled = true;\n }\n } else {\n enabled = true;\n }\n\n const atTokens =\n override !== null\n ? override\n : contextWindow && contextWindow > 0\n ? Math.floor(contextWindow * threshold)\n : null;\n return { enabled, atTokens };\n}\n\n// 检测 Responses API 请求是否包含图片(input_image 类型)。\nexport function requestContainsImages(payload: ResponsesRequest): boolean {\n if (!Array.isArray(payload.input)) return false;\n for (const item of payload.input) {\n if (item.type === \"message\" && Array.isArray(item.content)) {\n for (const part of item.content) {\n if (part.type === \"input_image\") return true;\n }\n }\n // function_call_output 也可能包含图片(tool 返回的图片)\n if (item.type === \"function_call_output\" && Array.isArray(item.output)) {\n for (const part of item.output) {\n if (part.type === \"input_image\") return true;\n }\n }\n }\n return false;\n}\n\n// 检测 Chat Completions API 请求是否包含图片(image_url 类型)。\nexport function chatRequestContainsImages(payload: ChatRequest): boolean {\n if (!Array.isArray(payload.messages)) return false;\n for (const msg of payload.messages) {\n if (Array.isArray(msg.content)) {\n for (const part of msg.content) {\n if (part.type === \"image_url\") return true;\n }\n }\n }\n return false;\n}\n\n/**\n * 从 Codex 请求的 tools 数组中提取 namespace 映射:toolName → namespaceName。\n * Codex Desktop 期望响应中的 function_call 带 namespace 字段才能路由到正确 handler。\n */\nfunction buildNamespaceMap(payload: ResponsesRequest): Map<string, string> | undefined {\n if (!payload.tools || payload.tools.length === 0) return undefined;\n const map = new Map<string, string>();\n for (const t of payload.tools) {\n if (t.type === \"namespace\") {\n const ns = t as unknown as { name?: string; tools?: Array<{ name?: string }> };\n if (ns.name && Array.isArray(ns.tools)) {\n for (const inner of ns.tools) {\n if (inner.name) map.set(inner.name, ns.name);\n }\n }\n }\n }\n return map.size > 0 ? map : undefined;\n}\n\nasync function handleResponses(\n cfg: Config,\n req: IncomingMessage,\n res: ServerResponse,\n user: UserRow | null\n): Promise<void> {\n // Shadow the module-level recordLog with a user-bound version. Every\n // chat_logs insert from this handler is automatically tagged with user_id.\n const recordLog = userLogger(user);\n let payload: ResponsesRequest;\n try {\n payload = await readJsonBody<ResponsesRequest>(req);\n } catch (err) {\n if (err instanceof RequestBodyTooLargeError) {\n return sendJson(res, 413, errorEnvelope(413, \"request_too_large\", err.message));\n }\n return sendJson(\n res,\n 400,\n errorEnvelope(400, \"invalid_json\", `failed to parse request body: ${(err as Error).message}`)\n );\n }\n if (!payload.model) {\n return sendJson(\n res,\n 400,\n errorEnvelope(400, \"missing_model\", \"request body must include 'model'\")\n );\n }\n\n log.debug(\"incoming POST /v1/responses\", {\n model: payload.model,\n stream: !!payload.stream,\n hasInput: Array.isArray(payload.input) ? payload.input.length : (typeof payload.input === \"string\" ? payload.input.length : \"n/a\"),\n hasInstructions: typeof payload.instructions === \"string\" ? payload.instructions.length : 0,\n keys: Object.keys(payload),\n });\n log.debug(\"incoming POST /v1/responses raw body\", payload);\n\n // Health-check probe short-circuit. Tools like cc-switch's \"test connection\"\n // send POST /v1/responses with just `{model, stream}` and no input — our\n // translation would forward `messages: []` to the upstream, which 400s.\n // Detect the probe shape (no input, no instructions) and answer with a\n // synthetic 200 without burning an upstream call.\n if (isResponsesProbe(payload)) {\n log.debug(\"matched probe shape — returning synthetic 200 without upstream call\");\n return respondToResponsesProbe(payload, res, !!payload.stream);\n }\n\n const selectedRaw = selectProvider(\n payload.model,\n cfg,\n readActiveOverrideSafely(cfg)\n );\n // 多模态 fallback:仅对声明了 vision 能力的 provider(目前只有 MiMo)生效,\n // 不影响其他 provider/模型。请求含图片但当前 model 看不了图 → 切到 vision 模型。\n const visionFallbackModel = resolveVisionFallback(cfg);\n const supportsVision = selectedRaw.provider.supportsVision?.bind(selectedRaw.provider);\n if (visionFallbackModel && supportsVision) {\n const effectiveModel = selectedRaw.upstreamModel;\n if (!supportsVision(effectiveModel) && requestContainsImages(payload)) {\n const resolved = selectedRaw.provider.resolveModel(visionFallbackModel);\n // Guard against cross-provider misrouting: only rewrite when the active\n // provider actually knows this vision model. Otherwise (e.g. DeepSeek\n // active + default \"mimo-v2.5\") we'd hand the provider's key to a model\n // it can't serve. Skip the fallback and keep the original model instead.\n if (!resolved) {\n log.warn(\"vision fallback skipped: active provider can't resolve fallback model\", {\n provider: selectedRaw.provider.id,\n from: effectiveModel,\n fallbackModel: visionFallbackModel,\n });\n } else {\n selectedRaw.rewriteNotice = {\n from: effectiveModel,\n to: resolved.id,\n reason: `multimodal fallback — request contains images but model \"${effectiveModel}\" does not support vision`,\n };\n selectedRaw.upstreamModel = resolved.id;\n selectedRaw.modelInfo = resolved;\n log.info(\"vision fallback applied\", {\n from: effectiveModel,\n to: resolved.id,\n provider: selectedRaw.provider.id,\n });\n }\n }\n }\n const { provider, upstreamModel, modelInfo, rewriteNotice } = selectedRaw;\n // BYOK: if a logged-in user has stored their own upstream API key for this\n // provider, swap it into the runtime. Local-mode / shared-key users keep\n // the existing runtime untouched.\n const { runtime, source: apiKeySource } = resolveRuntimeForUser(\n selectedRaw.runtime,\n provider.id,\n user,\n cfg\n );\n log.debug(`routing to provider=${provider.id}`, {\n baseUrl: runtime.baseUrl,\n clientModel: payload.model,\n upstreamModel,\n wireApi: provider.wireApi ?? \"chat\",\n apiKeySource,\n });\n if (rewriteNotice && !resolveSilentRewrite(cfg)) {\n // INFO, not WARN — this is a graceful fallback, not an error. The request\n // continues normally with the provider's default model. Kept visible (not\n // debug) because silent rewrites can mask capability mismatches (e.g. a\n // vision request silently routed to a non-vision default model).\n // Set MIMO2CODEX_SILENT_REWRITE=1 to suppress this message.\n log.info(\"model fallback applied — client sent unknown model id, request continues with provider default\", {\n provider: provider.id,\n from: rewriteNotice.from,\n to: rewriteNotice.to,\n reason: rewriteNotice.reason,\n });\n }\n\n const paygReason = paygOnlyBlock(modelInfo, runtime);\n if (paygReason) {\n sendJson(res, 400, errorEnvelope(400, \"model_requires_payg\", paygReason));\n return;\n }\n\n if (provider.wireApi === \"responses\") {\n return await handleResponsesPassthrough(\n cfg,\n req,\n res,\n payload,\n {\n provider,\n runtime,\n upstreamModel,\n modelInfo,\n rewriteNotice,\n },\n user\n );\n }\n\n const chat = provider.preprocessResponses(payload, {\n runtime,\n exposeReasoning: cfg.exposeReasoning,\n dataDir: cfg.dataDir,\n disableThinking: resolveDisableThinking(cfg),\n forceHighEffort: resolveForceHighEffort(cfg),\n webSearchEnabled: resolveWebSearchEnabled(cfg),\n upstreamModel,\n });\n chat.model = upstreamModel;\n chat.stream = !!payload.stream;\n const stream = !!payload.stream;\n\n const namespaceMap = buildNamespaceMap(payload);\n\n const ac = new AbortController();\n req.on(\"close\", () => ac.abort());\n\n // Auto-compaction: when the estimated input crosses the token trigger,\n // summarize the older middle of the conversation before forwarding. The\n // summary goes through the same provider/model via a non-streaming call.\n // maybeCompactChat is best-effort — it swallows its own summary failures and\n // leaves the history intact, so this never blocks the real turn.\n const autoCompact = resolveAutoCompact(cfg, modelInfo?.contextWindow);\n const callChatForSummary: ChatCaller = async (summaryReq) => {\n const r = await callOpenAICompat(\n {\n baseUrl: runtime.baseUrl,\n apiKey: runtime.apiKey,\n userAgent: cfg.userAgent,\n contextOverflowMode: \"passthrough\",\n modelInfo: modelInfo\n ? { id: modelInfo.id, contextWindow: modelInfo.contextWindow }\n : { id: upstreamModel },\n maxRetries: 1,\n },\n { ...summaryReq, model: upstreamModel },\n ac.signal\n );\n const j = (await r.json()) as ChatResponse;\n return j.choices?.[0]?.message?.content ?? \"\";\n };\n const runAutoCompact = async (): Promise<void> => {\n if (!autoCompact.enabled || autoCompact.atTokens == null) return;\n await maybeCompactChat(chat, { atTokens: autoCompact.atTokens, callChat: callChatForSummary });\n };\n\n const startedAt = Date.now();\n const requestBodySnapshot = bodyForLog(payload);\n const rewriteLogFields = rewriteNotice && !resolveSilentRewrite(cfg)\n ? (() => {\n const w = rewriteWarning(rewriteNotice);\n return { error_code: w.code, error_snippet: w.message };\n })()\n : { error_code: null, error_snippet: null };\n const baseEntry = {\n request_id: null as string | null,\n provider_id: provider.id,\n client_model: payload.model,\n upstream_model: upstreamModel,\n endpoint: \"/v1/responses\",\n stream,\n request_body: requestBodySnapshot,\n };\n\n if (!stream) {\n try {\n await runAutoCompact();\n const upstreamRes = await callOpenAICompat(\n {\n baseUrl: runtime.baseUrl,\n apiKey: runtime.apiKey,\n userAgent: cfg.userAgent,\n enhanceError: provider.enhanceError.bind(provider),\n contextOverflowMode: cfg.contextOverflowMode,\n modelInfo: modelInfo\n ? { id: modelInfo.id, contextWindow: modelInfo.contextWindow }\n : { id: upstreamModel },\n },\n chat,\n ac.signal\n );\n const chatJson = (await upstreamRes.json()) as ChatResponse;\n const responses = respToResponses(chatJson, payload, {\n exposeReasoning: cfg.exposeReasoning,\n // minimax-compat: 把 inline <think>...</think> 切到 reasoning_content。\n // 仅当 provider 声明开了(generic provider 的 features.minimaxCompat 或\n // features.extractThinkTags),其他 provider 这里是 undefined → 既有行为。\n extractInlineThink: !!provider.responseFlags?.extractInlineThink,\n namespaceMap,\n });\n sendJson(res, 200, responses);\n recordLog(cfg, {\n ...baseEntry,\n request_id: chatJson.id ?? null,\n status_code: 200,\n duration_ms: Date.now() - startedAt,\n ...usageFromChatResponse(chatJson.usage),\n ...rewriteLogFields,\n response_body: bodyForLog(responses),\n tool_call_count: countToolCallsInChatResponse(chatJson),\n });\n return;\n } catch (err) {\n if (err instanceof UpstreamError) {\n sendJson(res, err.status, errorEnvelope(err.status, err.code, err.message));\n recordLog(cfg, {\n ...baseEntry,\n status_code: err.status,\n duration_ms: Date.now() - startedAt,\n prompt_tokens: null,\n completion_tokens: null,\n total_tokens: null,\n error_code: err.code,\n error_snippet: err.bodySnippet ?? err.message,\n response_body: null,\n tool_call_count: null,\n });\n return;\n }\n log.error(\"non-stream request failed\", { error: (err as Error).message });\n sendJson(res, 500, errorEnvelope(500, \"internal_error\", (err as Error).message));\n recordLog(cfg, {\n ...baseEntry,\n status_code: 500,\n duration_ms: Date.now() - startedAt,\n prompt_tokens: null,\n completion_tokens: null,\n total_tokens: null,\n error_code: \"internal_error\",\n error_snippet: (err as Error).message,\n response_body: null,\n tool_call_count: null,\n });\n return;\n }\n }\n\n // Streaming path.\n // Flush the SSE 200 headers and start the keepalive BEFORE awaiting the\n // upstream. A slow first token (large context or a big base64 image makes the\n // prefill long) would otherwise leave Codex staring at a silent socket until\n // upstream headers arrive, tripping its own idle timeout → \"stream\n // disconnected before completion\" (issue #65). The cost: once 200 is on the\n // wire we can't answer a terminal upstream error with a JSON 4xx anymore, so\n // pre-stream errors are delivered as an SSE `error` event instead.\n const imgSummary = summarizeChatImages(chat);\n if (imgSummary.images > 0) {\n log.info(\"streaming request carries images\", {\n upstream_model: upstreamModel,\n images: imgSummary.images,\n approx_image_kb: Math.round(imgSummary.imageChars / 1024),\n });\n }\n const sink = makeServerResponseSink(res);\n const keepalive = setInterval(() => sink.comment(\"keepalive\"), KEEPALIVE_INTERVAL_MS);\n res.on(\"close\", () => clearInterval(keepalive));\n\n let upstreamRes: Response;\n try {\n // Runs with the keepalive already active, so the summary round-trip doesn't\n // re-introduce the silent-socket disconnect we just fixed.\n await runAutoCompact();\n upstreamRes = await callOpenAICompat(\n {\n baseUrl: runtime.baseUrl,\n apiKey: runtime.apiKey,\n userAgent: cfg.userAgent,\n enhanceError: provider.enhanceError.bind(provider),\n contextOverflowMode: cfg.contextOverflowMode,\n modelInfo: modelInfo\n ? { id: modelInfo.id, contextWindow: modelInfo.contextWindow }\n : { id: upstreamModel },\n },\n chat,\n ac.signal\n );\n } catch (err) {\n clearInterval(keepalive);\n const isUpstream = err instanceof UpstreamError;\n const status = isUpstream ? (err as UpstreamError).status : 500;\n const code = isUpstream ? (err as UpstreamError).code : \"internal_error\";\n const message = (err as Error).message;\n if (!isUpstream) {\n log.error(\"stream request failed (pre-stream)\", { error: message });\n }\n // Headers already flushed → deliver the error over SSE, not as JSON.\n if (!sink.closed()) {\n sink.write(\"error\", { type: \"error\", code, message, sequence_number: 9999 });\n sink.end();\n }\n recordLog(cfg, {\n ...baseEntry,\n status_code: status,\n duration_ms: Date.now() - startedAt,\n prompt_tokens: null,\n completion_tokens: null,\n total_tokens: null,\n error_code: code,\n error_snippet: isUpstream ? ((err as UpstreamError).bodySnippet ?? message) : message,\n response_body: null,\n tool_call_count: null,\n });\n return;\n }\n\n let streamError: Error | null = null;\n let pipeResult: StreamPipelineResult | undefined;\n try {\n const chunks = iterChatStreamChunks(upstreamRes);\n pipeResult = await pipeChatStreamToResponses(\n sink,\n { chunks },\n payload,\n {\n exposeReasoning: cfg.exposeReasoning,\n // minimax-compat: 同 respToResponses 调用点,对 inline-think 上游开启切分。\n extractInlineThink: !!provider.responseFlags?.extractInlineThink,\n namespaceMap,\n }\n );\n } catch (err) {\n streamError = err as Error;\n log.error(\"stream request failed (mid-stream)\", { error: streamError.message });\n if (!sink.closed()) {\n sink.write(\"error\", {\n type: \"error\",\n code: \"server_error\",\n message: streamError.message,\n sequence_number: 9999,\n });\n sink.end();\n }\n } finally {\n clearInterval(keepalive);\n const u = pipeResult?.usage;\n recordLog(cfg, {\n ...baseEntry,\n status_code: streamError ? 500 : 200,\n duration_ms: Date.now() - startedAt,\n prompt_tokens: u?.input_tokens ?? null,\n completion_tokens: u?.output_tokens ?? null,\n total_tokens: u?.total_tokens ?? null,\n cached_tokens: u?.input_tokens_details?.cached_tokens ?? null,\n error_code: streamError ? \"stream_error\" : rewriteLogFields.error_code,\n error_snippet: streamError ? streamError.message : rewriteLogFields.error_snippet,\n response_body: bodyForLog(pipeResult?.response),\n tool_call_count: pipeResult?.toolCallCount ?? null,\n });\n }\n}\n\n// wireApi === \"responses\" path: forward Codex's Responses payload directly to\n// the upstream's /v1/responses endpoint, with no Chat-Completions translation.\n// Streaming pipes raw SSE bytes; non-streaming JSON is forwarded verbatim.\nasync function handleResponsesPassthrough(\n cfg: Config,\n req: IncomingMessage,\n res: ServerResponse,\n payload: ResponsesRequest,\n selected: {\n provider: Provider;\n runtime: ProviderRuntime;\n upstreamModel: string;\n modelInfo: SelectedProvider[\"modelInfo\"];\n rewriteNotice: SelectedProvider[\"rewriteNotice\"];\n },\n user: UserRow | null\n): Promise<void> {\n const recordLog = userLogger(user);\n const { provider, runtime, upstreamModel, modelInfo, rewriteNotice } = selected;\n const stream = !!payload.stream;\n\n const preprocessed =\n provider.preprocessResponsesPassthrough?.(payload, {\n runtime,\n exposeReasoning: cfg.exposeReasoning,\n }) ?? payload;\n // The routing layer determined upstreamModel; honor it over whatever the\n // provider hook returned. preprocess hooks shouldn't normally rewrite model.\n const forwardBody: ResponsesRequest = {\n ...preprocessed,\n model: upstreamModel,\n stream,\n };\n\n const ac = new AbortController();\n req.on(\"close\", () => ac.abort());\n\n const startedAt = Date.now();\n const requestBodySnapshot = bodyForLog(payload);\n const rewriteLogFields = rewriteNotice && !resolveSilentRewrite(cfg)\n ? (() => {\n const w = rewriteWarning(rewriteNotice);\n return { error_code: w.code, error_snippet: w.message };\n })()\n : { error_code: null, error_snippet: null };\n const baseEntry = {\n request_id: null as string | null,\n provider_id: provider.id,\n client_model: payload.model,\n upstream_model: upstreamModel,\n endpoint: \"/v1/responses\",\n stream,\n request_body: requestBodySnapshot,\n };\n\n // Non-streaming path: parse upstream JSON and forward verbatim.\n if (!stream) {\n try {\n const upstreamRes = await callResponsesPassthrough(\n {\n baseUrl: runtime.baseUrl,\n apiKey: runtime.apiKey,\n userAgent: cfg.userAgent,\n enhanceError: provider.enhanceError.bind(provider),\n contextOverflowMode: cfg.contextOverflowMode,\n modelInfo: modelInfo\n ? { id: modelInfo.id, contextWindow: modelInfo.contextWindow }\n : { id: upstreamModel },\n },\n forwardBody,\n ac.signal\n );\n const json = (await upstreamRes.json()) as Record<string, unknown>;\n sendJson(res, 200, json);\n const usage = (json.usage ?? {}) as Record<string, unknown>;\n const cachedFromResponses =\n (usage.input_tokens_details as Record<string, unknown> | undefined)?.cached_tokens;\n recordLog(cfg, {\n ...baseEntry,\n request_id: typeof json.id === \"string\" ? json.id : null,\n status_code: 200,\n duration_ms: Date.now() - startedAt,\n prompt_tokens: typeof usage.input_tokens === \"number\" ? usage.input_tokens : null,\n completion_tokens:\n typeof usage.output_tokens === \"number\" ? usage.output_tokens : null,\n total_tokens: typeof usage.total_tokens === \"number\" ? usage.total_tokens : null,\n cached_tokens: typeof cachedFromResponses === \"number\" ? cachedFromResponses : null,\n ...rewriteLogFields,\n response_body: bodyForLog(json),\n tool_call_count: null,\n });\n return;\n } catch (err) {\n if (err instanceof UpstreamError) {\n sendJson(res, err.status, errorEnvelope(err.status, err.code, err.message));\n recordLog(cfg, {\n ...baseEntry,\n status_code: err.status,\n duration_ms: Date.now() - startedAt,\n prompt_tokens: null,\n completion_tokens: null,\n total_tokens: null,\n error_code: err.code,\n error_snippet: err.bodySnippet ?? err.message,\n response_body: null,\n tool_call_count: null,\n });\n return;\n }\n log.error(\"responses passthrough non-stream failed\", { error: (err as Error).message });\n sendJson(res, 500, errorEnvelope(500, \"internal_error\", (err as Error).message));\n recordLog(cfg, {\n ...baseEntry,\n status_code: 500,\n duration_ms: Date.now() - startedAt,\n prompt_tokens: null,\n completion_tokens: null,\n total_tokens: null,\n error_code: \"internal_error\",\n error_snippet: (err as Error).message,\n response_body: null,\n tool_call_count: null,\n });\n return;\n }\n }\n\n // Streaming path: pipe upstream SSE bytes directly to the client.\n // Same rationale as the translated path — flush 200 + SSE headers and start\n // the keepalive BEFORE awaiting the upstream so a long prefill (large\n // context / big image) doesn't leave Codex on a silent socket (issue #65).\n // Pre-stream errors are therefore emitted as an SSE `error` event.\n res.statusCode = 200;\n res.setHeader(\"Content-Type\", \"text/event-stream; charset=utf-8\");\n res.setHeader(\"Cache-Control\", \"no-cache, no-transform\");\n res.setHeader(\"Connection\", \"keep-alive\");\n if (typeof (res as unknown as { flushHeaders?: () => void }).flushHeaders === \"function\") {\n (res as unknown as { flushHeaders: () => void }).flushHeaders();\n }\n const keepalive = setInterval(() => {\n if (!res.writableEnded) res.write(\": keepalive\\n\\n\");\n }, KEEPALIVE_INTERVAL_MS);\n res.on(\"close\", () => clearInterval(keepalive));\n\n let upstreamRes: Response;\n try {\n upstreamRes = await callResponsesPassthrough(\n {\n baseUrl: runtime.baseUrl,\n apiKey: runtime.apiKey,\n userAgent: cfg.userAgent,\n enhanceError: provider.enhanceError.bind(provider),\n contextOverflowMode: cfg.contextOverflowMode,\n modelInfo: modelInfo\n ? { id: modelInfo.id, contextWindow: modelInfo.contextWindow }\n : { id: upstreamModel },\n },\n forwardBody,\n ac.signal\n );\n } catch (err) {\n clearInterval(keepalive);\n const isUpstream = err instanceof UpstreamError;\n const status = isUpstream ? (err as UpstreamError).status : 500;\n const code = isUpstream ? (err as UpstreamError).code : \"internal_error\";\n const message = (err as Error).message;\n if (!isUpstream) {\n log.error(\"responses passthrough stream pre-request failed\", { error: message });\n }\n if (!res.writableEnded) {\n res.write(sseErrorEvent(code, message));\n res.end();\n }\n recordLog(cfg, {\n ...baseEntry,\n status_code: status,\n duration_ms: Date.now() - startedAt,\n prompt_tokens: null,\n completion_tokens: null,\n total_tokens: null,\n error_code: code,\n error_snippet: isUpstream ? ((err as UpstreamError).bodySnippet ?? message) : message,\n response_body: null,\n tool_call_count: null,\n });\n return;\n }\n\n let streamError: Error | null = null;\n try {\n if (!upstreamRes.body) {\n throw new Error(\"upstream responded without a body\");\n }\n const reader = upstreamRes.body.getReader();\n while (true) {\n const { value, done } = await reader.read();\n if (done) break;\n if (value && value.length > 0) {\n res.write(Buffer.from(value));\n }\n }\n if (!res.writableEnded) res.end();\n } catch (err) {\n streamError = err as Error;\n log.error(\"responses passthrough mid-stream failed\", { error: streamError.message });\n if (!res.writableEnded) {\n res.write(sseErrorEvent(\"server_error\", streamError.message));\n res.end();\n }\n } finally {\n clearInterval(keepalive);\n recordLog(cfg, {\n ...baseEntry,\n status_code: streamError ? 500 : 200,\n duration_ms: Date.now() - startedAt,\n prompt_tokens: null,\n completion_tokens: null,\n total_tokens: null,\n error_code: streamError ? \"stream_error\" : rewriteLogFields.error_code,\n error_snippet: streamError ? streamError.message : rewriteLogFields.error_snippet,\n response_body: null,\n tool_call_count: null,\n });\n }\n}\n\n// Probe-shape detection for POST /v1/responses. cc-switch's \"test connection\"\n// (and similar health-checks) send `{model, stream}` with no `input` and no\n// `instructions` — forwarding would give the upstream `messages: []` and a\n// 400. So we short-circuit those with a synthetic 200.\n//\n// issue #31: OpenAI's Responses API accepts `input` as either a string or an\n// array of message items. The previous check only matched the array form,\n// causing CodeX Desktop's `{input: \"write hello world\"}` requests to be\n// misidentified as probes and returned with an empty `output: []` (no\n// upstream call) — looks like \"model said nothing\" with zero error signal.\n// The string branch was added by 85339098-afk (PR #31).\n//\n// Exported so a focused unit test can lock the rule without spinning up the\n// full HTTP server + mock upstream.\nexport function isResponsesProbe(payload: ResponsesRequest): boolean {\n const hasInput =\n (typeof payload.input === \"string\" && payload.input.length > 0) ||\n (Array.isArray(payload.input) && payload.input.length > 0);\n const hasInstructions =\n typeof payload.instructions === \"string\" && payload.instructions.length > 0;\n return !hasInput && !hasInstructions;\n}\n\nfunction respondToResponsesProbe(\n payload: ResponsesRequest,\n res: ServerResponse,\n stream: boolean\n): void {\n const id = `resp_probe_${Date.now()}`;\n const created_at = Math.floor(Date.now() / 1000);\n const completed = {\n id,\n object: \"response\",\n created_at,\n status: \"completed\",\n model: payload.model,\n output: [],\n usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },\n parallel_tool_calls: true,\n tool_choice: \"auto\",\n text: { format: { type: \"text\" } },\n reasoning: { effort: null, summary: null },\n incomplete_details: null,\n error: null,\n metadata: null,\n };\n if (!stream) {\n sendJson(res, 200, completed);\n return;\n }\n res.statusCode = 200;\n res.setHeader(\"Content-Type\", \"text/event-stream; charset=utf-8\");\n res.setHeader(\"Cache-Control\", \"no-cache, no-transform\");\n res.setHeader(\"Connection\", \"keep-alive\");\n const inProgress = { ...completed, status: \"in_progress\" };\n res.write(\n `event: response.created\\ndata: ${JSON.stringify({ type: \"response.created\", response: inProgress, sequence_number: 0 })}\\n\\n`\n );\n res.write(\n `event: response.completed\\ndata: ${JSON.stringify({ type: \"response.completed\", response: completed, sequence_number: 1 })}\\n\\n`\n );\n res.end();\n}\n\nfunction respondToChatProbe(\n payload: ChatRequest,\n res: ServerResponse,\n stream: boolean\n): void {\n const id = `chatcmpl_probe_${Date.now()}`;\n const created = Math.floor(Date.now() / 1000);\n if (!stream) {\n sendJson(res, 200, {\n id,\n object: \"chat.completion\",\n created,\n model: payload.model,\n choices: [\n {\n index: 0,\n message: { role: \"assistant\", content: \"\" },\n finish_reason: \"stop\",\n },\n ],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n return;\n }\n res.statusCode = 200;\n res.setHeader(\"Content-Type\", \"text/event-stream; charset=utf-8\");\n res.setHeader(\"Cache-Control\", \"no-cache, no-transform\");\n res.setHeader(\"Connection\", \"keep-alive\");\n const chunk = (delta: object, finish: string | null): string =>\n `data: ${JSON.stringify({\n id,\n object: \"chat.completion.chunk\",\n created,\n model: payload.model,\n choices: [{ index: 0, delta, finish_reason: finish }],\n })}\\n\\n`;\n res.write(chunk({ role: \"assistant\", content: \"\" }, null));\n res.write(chunk({}, \"stop\"));\n res.write(`data: [DONE]\\n\\n`);\n res.end();\n}\n\nasync function handleChatPassthrough(\n cfg: Config,\n req: IncomingMessage,\n res: ServerResponse,\n user: UserRow | null\n): Promise<void> {\n const recordLog = userLogger(user);\n let payload: ChatRequest;\n try {\n payload = await readJsonBody<ChatRequest>(req);\n } catch (err) {\n if (err instanceof RequestBodyTooLargeError) {\n return sendJson(res, 413, errorEnvelope(413, \"request_too_large\", err.message));\n }\n return sendJson(\n res,\n 400,\n errorEnvelope(400, \"invalid_json\", `failed to parse request body: ${(err as Error).message}`)\n );\n }\n if (!payload.model) {\n return sendJson(\n res,\n 400,\n errorEnvelope(400, \"missing_model\", \"request body must include 'model'\")\n );\n }\n\n log.debug(\"incoming POST /v1/chat/completions\", {\n model: payload.model,\n stream: !!payload.stream,\n messages: Array.isArray(payload.messages) ? payload.messages.length : \"n/a\",\n keys: Object.keys(payload),\n });\n log.debug(\"incoming POST /v1/chat/completions raw body\", payload);\n\n if (!Array.isArray(payload.messages) || payload.messages.length === 0) {\n log.debug(\"matched probe shape — returning synthetic 200 without upstream call\");\n return respondToChatProbe(payload, res, !!payload.stream);\n }\n\n const selectedRaw = selectProvider(\n payload.model,\n cfg,\n readActiveOverrideSafely(cfg)\n );\n // 多模态 fallback(chat completions 路径):仅对声明了 vision 能力的 provider\n // (目前只有 MiMo)生效,不影响其他 provider/模型。\n const visionFallbackModel = resolveVisionFallback(cfg);\n const supportsVision = selectedRaw.provider.supportsVision?.bind(selectedRaw.provider);\n if (visionFallbackModel && supportsVision) {\n const effectiveModel = selectedRaw.upstreamModel;\n if (!supportsVision(effectiveModel) && chatRequestContainsImages(payload)) {\n const resolved = selectedRaw.provider.resolveModel(visionFallbackModel);\n // Guard against cross-provider misrouting: only rewrite when the active\n // provider actually knows this vision model. Otherwise (e.g. DeepSeek\n // active + default \"mimo-v2.5\") we'd hand the provider's key to a model\n // it can't serve. Skip the fallback and keep the original model instead.\n if (!resolved) {\n log.warn(\"vision fallback skipped: active provider can't resolve fallback model\", {\n provider: selectedRaw.provider.id,\n from: effectiveModel,\n fallbackModel: visionFallbackModel,\n });\n } else {\n selectedRaw.rewriteNotice = {\n from: effectiveModel,\n to: resolved.id,\n reason: `multimodal fallback — request contains images but model \"${effectiveModel}\" does not support vision`,\n };\n selectedRaw.upstreamModel = resolved.id;\n selectedRaw.modelInfo = resolved;\n log.info(\"vision fallback applied\", {\n from: effectiveModel,\n to: resolved.id,\n provider: selectedRaw.provider.id,\n });\n }\n }\n }\n const { provider, upstreamModel, modelInfo, rewriteNotice } = selectedRaw;\n const { runtime, source: apiKeySource } = resolveRuntimeForUser(\n selectedRaw.runtime,\n provider.id,\n user,\n cfg\n );\n log.debug(`routing chat passthrough to provider=${provider.id}`, {\n clientModel: payload.model,\n upstreamModel,\n apiKeySource,\n });\n if (rewriteNotice && !resolveSilentRewrite(cfg)) {\n // INFO, not WARN — see handleResponses for the rationale.\n log.info(\"model fallback applied — client sent unknown model id, request continues with provider default\", {\n provider: provider.id,\n from: rewriteNotice.from,\n to: rewriteNotice.to,\n reason: rewriteNotice.reason,\n });\n }\n\n const paygReason = paygOnlyBlock(modelInfo, runtime);\n if (paygReason) {\n sendJson(res, 400, errorEnvelope(400, \"model_requires_payg\", paygReason));\n return;\n }\n\n const body = provider.preprocessChat(payload, {\n runtime,\n exposeReasoning: cfg.exposeReasoning,\n disableThinking: resolveDisableThinking(cfg),\n forceHighEffort: resolveForceHighEffort(cfg),\n webSearchEnabled: resolveWebSearchEnabled(cfg),\n upstreamModel,\n });\n body.model = upstreamModel;\n\n const ac = new AbortController();\n req.on(\"close\", () => ac.abort());\n\n const startedAt = Date.now();\n const requestBodySnapshot = bodyForLog(payload);\n const rewriteLogFields = rewriteNotice && !resolveSilentRewrite(cfg)\n ? (() => {\n const w = rewriteWarning(rewriteNotice);\n return { error_code: w.code, error_snippet: w.message };\n })()\n : { error_code: null, error_snippet: null };\n const baseEntry = {\n request_id: null as string | null,\n provider_id: provider.id,\n client_model: payload.model,\n upstream_model: upstreamModel,\n endpoint: \"/v1/chat/completions\",\n stream: !!payload.stream,\n request_body: requestBodySnapshot,\n };\n\n let upstreamRes: Response;\n try {\n upstreamRes = await callOpenAICompat(\n {\n baseUrl: runtime.baseUrl,\n apiKey: runtime.apiKey,\n userAgent: cfg.userAgent,\n enhanceError: provider.enhanceError.bind(provider),\n contextOverflowMode: cfg.contextOverflowMode,\n modelInfo: modelInfo\n ? { id: modelInfo.id, contextWindow: modelInfo.contextWindow }\n : { id: upstreamModel },\n },\n body,\n ac.signal\n );\n } catch (err) {\n if (err instanceof UpstreamError) {\n sendJson(res, err.status, errorEnvelope(err.status, err.code, err.message));\n recordLog(cfg, {\n ...baseEntry,\n status_code: err.status,\n duration_ms: Date.now() - startedAt,\n prompt_tokens: null,\n completion_tokens: null,\n total_tokens: null,\n error_code: err.code,\n error_snippet: err.bodySnippet ?? err.message,\n response_body: null,\n tool_call_count: null,\n });\n return;\n }\n log.error(\"chat passthrough failed\", { error: (err as Error).message });\n sendJson(res, 500, errorEnvelope(500, \"internal_error\", (err as Error).message));\n recordLog(cfg, {\n ...baseEntry,\n status_code: 500,\n duration_ms: Date.now() - startedAt,\n prompt_tokens: null,\n completion_tokens: null,\n total_tokens: null,\n error_code: \"internal_error\",\n error_snippet: (err as Error).message,\n response_body: null,\n tool_call_count: null,\n });\n return;\n }\n\n const contentType = upstreamRes.headers.get(\"content-type\") ?? \"application/json\";\n res.statusCode = 200;\n res.setHeader(\"Content-Type\", contentType);\n\n if (payload.stream) {\n if (!upstreamRes.body) {\n res.end();\n recordLog(cfg, {\n ...baseEntry,\n status_code: 200,\n duration_ms: Date.now() - startedAt,\n prompt_tokens: null,\n completion_tokens: null,\n total_tokens: null,\n ...rewriteLogFields,\n response_body: null,\n tool_call_count: null,\n });\n return;\n }\n const reader = upstreamRes.body.getReader();\n let streamError: Error | null = null;\n // Buffer the SSE bytes as they fly through so we can persist the\n // assembled response body and pull usage / tool_calls out of the final\n // chunk. This is a passthrough so we don't decode events — just keep\n // the raw text and parse the trailing `data:` lines after the stream\n // completes.\n const collectedChunks: Buffer[] = [];\n try {\n // eslint-disable-next-line no-constant-condition\n while (true) {\n const { value, done } = await reader.read();\n if (done) break;\n if (value) {\n const buf = Buffer.from(value);\n collectedChunks.push(buf);\n res.write(buf);\n }\n }\n } catch (err) {\n streamError = err as Error;\n log.error(\"chat passthrough stream error\", { error: streamError.message });\n } finally {\n res.end();\n const collected = Buffer.concat(collectedChunks).toString(\"utf-8\");\n const { usage, toolCallCount } = summarizeChatSseStream(collected);\n recordLog(cfg, {\n ...baseEntry,\n status_code: streamError ? 500 : 200,\n duration_ms: Date.now() - startedAt,\n ...usageFromChatResponse(usage),\n error_code: streamError ? \"stream_error\" : rewriteLogFields.error_code,\n error_snippet: streamError ? streamError.message : rewriteLogFields.error_snippet,\n response_body: collected ? redactSensitive(collected) : null,\n tool_call_count: toolCallCount,\n });\n }\n return;\n }\n\n const text = await upstreamRes.text();\n res.end(text);\n // Try to extract token usage from the JSON body so logs reflect cost.\n let usage: ChatUsage | undefined;\n let toolCallCount: number | null = null;\n try {\n const parsed = JSON.parse(text) as ChatResponse;\n usage = parsed.usage;\n toolCallCount = countToolCallsInChatResponse(parsed);\n } catch {\n // ignore\n }\n recordLog(cfg, {\n ...baseEntry,\n status_code: 200,\n duration_ms: Date.now() - startedAt,\n ...usageFromChatResponse(usage),\n ...rewriteLogFields,\n response_body: text ? redactSensitive(text) : null,\n tool_call_count: toolCallCount,\n });\n}\n\n// Walk the SSE bytes from a /v1/chat/completions stream and pluck out the\n// final usage chunk plus the running set of tool_calls. We accept best-effort\n// parsing — malformed lines are skipped silently.\nfunction summarizeChatSseStream(text: string): {\n usage: ChatUsage | undefined;\n toolCallCount: number | null;\n} {\n if (!text) return { usage: undefined, toolCallCount: null };\n let usage: ChatUsage | undefined;\n const toolCallIndices = new Set<number>();\n let sawAnyChunk = false;\n for (const line of text.split(/\\r?\\n/)) {\n if (!line.startsWith(\"data:\")) continue;\n const payload = line.slice(5).trim();\n if (!payload || payload === \"[DONE]\") continue;\n try {\n const obj = JSON.parse(payload) as {\n usage?: ChatUsage;\n choices?: Array<{ delta?: { tool_calls?: Array<{ index?: number }> } }>;\n };\n sawAnyChunk = true;\n if (obj.usage) usage = obj.usage;\n const tc = obj.choices?.[0]?.delta?.tool_calls;\n if (Array.isArray(tc)) {\n for (const t of tc) {\n if (typeof t.index === \"number\") toolCallIndices.add(t.index);\n }\n }\n } catch {\n // skip malformed\n }\n }\n return {\n usage,\n toolCallCount: sawAnyChunk ? toolCallIndices.size : null,\n };\n}\n\nfunction handleModels(cfg: Config, res: ServerResponse): void {\n // Aggregate the catalogs of every provider whose api key is configured. The\n // default provider's catalog comes first so existing tools that pick the top\n // entry keep their previous behavior.\n const ordered: Provider[] = [\n PROVIDERS[cfg.defaultProviderId],\n ...PROVIDER_LIST.filter((p) => p.id !== cfg.defaultProviderId),\n ];\n const data: Array<{ id: string; object: \"model\"; owned_by: string }> = [];\n for (const p of ordered) {\n if (!cfg.providers[p.id]) continue;\n const ownedBy = p.id === \"mimo\" ? \"xiaomi\" : \"deepseek\";\n for (const m of p.builtinModels) {\n data.push({ id: m.id, object: \"model\", owned_by: ownedBy });\n }\n }\n sendJson(res, 200, { object: \"list\", data });\n}\n\nexport function startServer(cfg: Config): Server {\n const server = createServer((req, res) => {\n const url = req.url ?? \"/\";\n\n if (req.method === \"GET\" && (url === \"/healthz\" || url === \"/\")) {\n sendJson(res, 200, {\n ok: true,\n name: \"mimo2codex\",\n provider: cfg.defaultProviderId,\n baseUrl: cfg.baseUrl,\n });\n return;\n }\n\n // Maintenance mode: while a data-directory migration is in progress, every\n // route except the admin endpoints that drive/observe the migration itself\n // returns 503 so the SQLite file stays untouched. The whitelist must keep\n // the SPA shell loading too (otherwise the user can't see the progress UI).\n if (isMaintenance()) {\n const allowAdmin =\n url.startsWith(\"/admin/api/health\") ||\n url.startsWith(\"/admin/api/data-dir\") ||\n // Allow the static SPA assets and admin shell so the page can stay open.\n (cfg.adminEnabled &&\n (url === \"/admin\" || url === \"/admin/\" ||\n (url.startsWith(\"/admin/\") && !url.startsWith(\"/admin/api/\"))));\n if (!allowAdmin) {\n res.statusCode = 503;\n res.setHeader(\"Retry-After\", \"60\");\n sendJson(\n res,\n 503,\n errorEnvelope(\n 503,\n \"maintenance_mode\",\n getMaintenanceMessage() ??\n \"server is migrating data directory; please wait or restart\"\n )\n );\n return;\n }\n }\n\n // Resolve the calling user (or null in local mode). The guard may also\n // short-circuit with a 401 — in that case it sets `handled=true` and we\n // stop here.\n const guard = authGuard(cfg, req, res);\n if (guard.handled) return;\n\n // OAuth login/callback routes live outside /admin/ so providers can\n // redirect back to a stable path. The OAuth handler may set a session\n // cookie + 302 to /admin/, after which the SPA sees the user as logged in.\n if (url.startsWith(\"/oauth/\")) {\n void handleOAuthRoutes(cfg, req, res).then((handled) => {\n if (!handled) {\n sendJson(res, 404, errorEnvelope(404, \"not_found\", `no route for ${req.method} ${url}`));\n }\n });\n return;\n }\n\n if (req.method === \"GET\" && url.startsWith(\"/v1/models\")) {\n handleModels(cfg, res);\n return;\n }\n if (req.method === \"POST\" && url.startsWith(\"/v1/responses\")) {\n void handleResponses(cfg, req, res, guard.ctx.user);\n return;\n }\n if (req.method === \"POST\" && url.startsWith(\"/v1/chat/completions\")) {\n void handleChatPassthrough(cfg, req, res, guard.ctx.user);\n return;\n }\n if (cfg.adminEnabled && (url === \"/admin\" || url.startsWith(\"/admin/\"))) {\n void handleAdmin(cfg, req, res, guard.ctx);\n return;\n }\n // Admin was force-disabled at startup because the SQLite native module\n // failed to load (wrong arch / ABI / unsigned .node — typical on the\n // bundled desktop sidecar). Without this, /admin/ falls through to the\n // generic 404 below and the user sees a baffling `no route for GET /admin/`\n // (the exact symptom of the mac-arm64 regression). Surface the real reason\n // instead. handleAdmin isn't registered here (it needs the DB), so we\n // answer inline. An intentional --no-admin leaves adminDisabledReason unset\n // and still gets the historic 404.\n if (\n !cfg.adminEnabled &&\n cfg.adminDisabledReason &&\n (url === \"/admin\" || url.startsWith(\"/admin/\"))\n ) {\n const reason = cfg.adminDisabledReason;\n // Keep /admin/api/health reachable as a 200 liveness probe (CI / desktop\n // shell poll it) — mirror the maintenance-mode whitelist above.\n if (url.startsWith(\"/admin/api/health\")) {\n sendJson(res, 200, {\n ok: false,\n adminEnabled: false,\n reason: \"db_unavailable\",\n message: reason.message,\n likelyBinding: reason.likelyBinding,\n dataDir: reason.dataDir,\n });\n return;\n }\n res.setHeader(\"Retry-After\", \"300\");\n sendJson(\n res,\n 503,\n errorEnvelope(\n 503,\n \"admin_db_unavailable\",\n `管理后台已禁用:本机 better-sqlite3 原生模块加载失败(${reason.message})。` +\n (reason.likelyBinding\n ? \" 修复:重装应用 / 确认安装包架构与本机匹配;macOS 可执行 \" +\n \"`xattr -cr /Applications/mimo2codex.app` 后重启。\"\n : ` 修复:确认数据目录可写(${reason.dataDir}),或用 --data-dir <可写路径> 指定其它位置。`)\n )\n );\n return;\n }\n sendJson(res, 404, errorEnvelope(404, \"not_found\", `no route for ${req.method} ${url}`));\n });\n\n if (cfg.adminEnabled) {\n const maintain = () => {\n const result = runLogMaintenance(cfg);\n if (result.removed > 0 || result.removedBySize > 0) {\n const parts: string[] = [];\n if (result.removed > 0) {\n parts.push(`${result.removed} rows older than ${result.retentionDays} day(s)`);\n }\n if (result.removedBySize > 0) {\n parts.push(`${result.removedBySize} oldest rows over the size cap`);\n }\n log.info(\n `log maintenance removed ${parts.join(\" + \")}${result.vacuumed ? \", then vacuumed the db\" : \"\"}`\n );\n }\n };\n maintain();\n const tid = setInterval(maintain, 6 * 60 * 60 * 1000);\n server.on(\"close\", () => clearInterval(tid));\n\n // Background stats backfill (issue #76): fold pre-upgrade chat_logs rows\n // into the hourly rollup in small chunks (newest-first) so the Dashboard's\n // historical charts become accurate within minutes, without blocking\n // startup or requests. Stops itself once complete.\n if (!isStatsBackfillDone()) {\n const btid = setInterval(() => {\n try {\n const { done } = backfillStatsChunk();\n if (done) {\n clearInterval(btid);\n log.info(\"stats backfill complete\");\n }\n } catch (err) {\n log.warn(`stats backfill chunk failed (will retry next tick): ${(err as Error).message}`);\n }\n }, 2000);\n btid.unref?.();\n server.on(\"close\", () => clearInterval(btid));\n }\n }\n\n server.listen(cfg.port, cfg.host);\n return server;\n}\n"}}, "src/providers/mimo.ts": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595271.3603895, "n": 11732, "h": "f8205aa195f339ac", "c": "import type { ChatRequest, ResponsesRequest } from \"../translate/types.js\";\nimport { modelSupportsImages, reqToChat } from \"../translate/reqToChat.js\";\nimport type { PreprocessCtx, Provider, ProviderEnhancedError, ProviderModel } from \"./types.js\";\n\n// Marker MiMo emits in 400 responses when web_search is forwarded but the\n// account doesn't have the Web Search Plugin activated.\nconst WEB_SEARCH_DISABLED_MARKER = \"webSearchEnabled is false\";\n\nconst WEB_SEARCH_HINT =\n \"MiMo Web Search Plugin is not activated for this account. \" +\n \"Either activate it at https://platform.xiaomimimo.com/#/console/plugin (separately billed) \" +\n \"and restart mimo2codex, OR turn OFF web search in mimo2codex (Codex Enable page → \" +\n \"Thinking & Override → Web search, or run without --web-search). Web search is off by \" +\n \"default; you only see this if it was explicitly enabled without the plugin.\";\n\n// Per https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/multimodal-understanding/image-understanding,\n// only `mimo-v2.5` accepts image input; `mimo-v2.5-pro` (and the retired v2\n// variants) do not — they return 404 \"No endpoints found that support image\n// input\" if sent images. The retired `mimo-v2-omni` is aliased to `mimo-v2.5`,\n// so image input keeps working for clients still sending the old name.\n//\n// maxOutputTokens defaults match\n// https://platform.xiaomimimo.com/docs/zh-CN/api/chat/openai-api `max_completion_tokens`:\n// v2.5-pro / v2.5-pro-ultraspeed: 131072 | v2.5: 32768\n//\n// contextWindow is 1M for every model — MiMo's real published context window\n// (NOT a fabricated number; an earlier comment here wrongly claimed the real\n// cap was 128K). Declaring it keeps the generated `model_context_window` in the\n// user's Codex config.toml large enough that Codex doesn't preemptively\n// /compact at a small window (some Codex builds default to 256K and 400 when we\n// declare a smaller cap than they prepare for). If a conversation genuinely\n// exceeds the model's window the upstream 400s and our `detectContextOverflow`\n// handler surfaces a friendly /compact hint (see src/upstream/contextOverflow.ts);\n// mimo2codex's own auto-compaction also kicks in at ~80% of this window first.\nconst MIMO_CONTEXT_WINDOW = 1_000_000;\n\nconst BUILTIN_MODELS: readonly ProviderModel[] = [\n {\n id: \"mimo-v2.5-pro\",\n // Retired `mimo-v2-pro` is transparently aliased here — MiMo's official\n // replacement, API params fully compatible. Existing config.toml files\n // sending the old name keep working and hit the live model.\n aliases: [\"mimo-v2-pro\"],\n displayName: \"MiMo V2.5 Pro\",\n supportsImages: false,\n supportsReasoning: true,\n supportsWebSearch: true,\n contextWindow: MIMO_CONTEXT_WINDOW,\n maxOutputTokens: 131_072,\n },\n {\n // MiMo V2.5 Pro UltraSpeed (issue #70) — 1T-param flagship \"experience\"\n // mode, 500-1000 tok/s. Per the official spec it's text-only with deep\n // thinking + tool calling; web search is NOT listed, so supportsWebSearch\n // is false. Same 1M window and 131072 max output as Pro.\n //\n // Access: limited daily approval — must be applied for at\n // https://platform.xiaomimimo.com/ultraspeed — and it's served ONLY on the\n // pay-as-you-go API host (sk- keys). Token-plan / subscription (tp- keys)\n // accounts can't use it. `note` surfaces this in the config.toml snippet.\n id: \"mimo-v2.5-pro-ultraspeed\",\n displayName: \"MiMo V2.5 Pro UltraSpeed\",\n supportsImages: false,\n supportsReasoning: true,\n supportsWebSearch: false,\n contextWindow: MIMO_CONTEXT_WINDOW,\n maxOutputTokens: 131_072,\n note: \"apply to enable (limited); API / pay-as-you-go (sk-) key only — token-plan/subscription can't use it\",\n paygOnly: true,\n },\n {\n id: \"mimo-v2.5\",\n // Retired `mimo-v2-omni` and `mimo-v2-flash` are both officially replaced\n // by `mimo-v2.5`, so both are aliased here. NOTE this is a behavior change\n // for old flash users: v2.5 defaults thinking ON (flash was off) and does\n // support vision. Thinking-mode sampling is normalized in normalizeMimoBody\n // (see MIMO_THINKING_STRIPS_SAMPLING).\n aliases: [\"mimo-v2-omni\", \"mimo-v2-flash\"],\n displayName: \"MiMo V2.5 (Vision)\",\n supportsImages: true,\n supportsReasoning: true,\n supportsWebSearch: true,\n contextWindow: MIMO_CONTEXT_WINDOW,\n maxOutputTokens: 32_768,\n },\n];\n\n// MiMo runs two hosts:\n// - pay-as-you-go (`sk-*` keys): https://api.xiaomimimo.com/v1\n// - token-plan (`tp-*` keys): https://token-plan-cn.xiaomimimo.com/v1\n// Sending a tp-* key to the pay-as-you-go host (or vice versa) yields a 401.\nconst PAYG_BASE_URL = \"https://api.xiaomimimo.com/v1\";\nconst TOKEN_PLAN_BASE_URL = \"https://token-plan-cn.xiaomimimo.com/v1\";\n\nfunction isTokenPlanRuntime(apiKey: string, baseUrl: string): boolean {\n return /token-plan/i.test(baseUrl) || apiKey.startsWith(\"tp-\");\n}\n\n// web_search is forwarded to MiMo only when the user explicitly opted in\n// (ctx.webSearchEnabled) AND the account isn't token-plan (tp- never has the\n// plugin). Default off — the Web Search Plugin is separately billed and off by\n// default, so forwarding it unprompted 400s (\"webSearchEnabled is false\").\nfunction webSearchAllowed(ctx: PreprocessCtx): boolean {\n return ctx.webSearchEnabled === true && !ctx.runtime.flags.isTokenPlan;\n}\n\n// Models whose upstream default for `thinking` is \"disabled\" — we leave the\n// field off the request so the upstream-side default kicks in. Empty since the\n// retirement of `mimo-v2-flash` (2026-06-30): every live model defaults thinking\n// ON. Kept as a named set so a future disabled-by-default model can be re-added.\nconst MIMO_THINKING_DEFAULT_DISABLED = new Set<string>([]);\n\n// Models that, per official docs, ignore custom `temperature` / `top_p` while in\n// thinking mode (the upstream forces temperature:1.0 and top_p:0.95). We strip\n// both client-side so the request matches the eventual behavior. Scoped to the\n// v2.5 reasoning family; kept as an explicit set (not an unconditional strip) so\n// a future MiMo model that DOES honor custom sampling isn't silently overridden.\nconst MIMO_THINKING_STRIPS_SAMPLING = new Set([\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\",\n \"mimo-v2.5-pro-ultraspeed\",\n]);\n\n// Normalize a chat-completions body for MiMo upstream per\n// https://platform.xiaomimimo.com/docs/zh-CN/api/chat/openai-api :\n// - inject thinking default by model id (all live models → enabled)\n// - drop `temperature` / `top_p` on the v2.5 family in thinking mode\n// - drop `tool_choice` when set to a non-\"auto\" value (upstream removes it)\n// `modelId` is the UPSTREAM model id (post-alias), not the client literal — so a\n// retired name aliased to v2.5 gets the v2.5 normalization.\nfunction normalizeMimoBody(chat: ChatRequest, modelId: string): ChatRequest {\n if (chat.thinking === undefined && !MIMO_THINKING_DEFAULT_DISABLED.has(modelId)) {\n chat.thinking = { type: \"enabled\" };\n }\n if (\n chat.thinking?.type === \"enabled\" &&\n MIMO_THINKING_STRIPS_SAMPLING.has(modelId)\n ) {\n delete chat.temperature;\n delete chat.top_p;\n }\n if (chat.tool_choice && chat.tool_choice !== \"auto\") {\n delete chat.tool_choice;\n }\n // mimo schema 仅允许 reasoning_effort ∈ {low, medium, high};\"none\" 是 sensenova 扩展,\n // 走错了路径会 400。任何形如 thinking:disabled 的请求都不需要这个字段,直接 strip 防御。\n if (chat.thinking?.type === \"disabled\" && chat.reasoning_effort === \"none\") {\n delete chat.reasoning_effort;\n }\n return chat;\n}\n\nexport const mimo: Provider = {\n id: \"mimo\",\n shortcut: \"mimo\",\n displayName: \"MiMo (via mimo2codex)\",\n defaultBaseUrl: PAYG_BASE_URL,\n baseUrlEnv: \"MIMO_BASE_URL\",\n envKeys: [\"MIMO_API_KEY\"] as const,\n defaultModel: \"mimo-v2.5-pro\",\n builtinModels: BUILTIN_MODELS,\n\n detectFlags(apiKey, baseUrl) {\n return { isTokenPlan: isTokenPlanRuntime(apiKey, baseUrl) };\n },\n\n inferBaseUrlFromKey(apiKey) {\n if (apiKey.startsWith(\"tp-\")) return TOKEN_PLAN_BASE_URL;\n if (apiKey.startsWith(\"sk-\")) return PAYG_BASE_URL;\n return null;\n },\n\n resolveModel(clientModel) {\n // Match by id first, then by `aliases` — this is how retired names\n // (`mimo-v2-pro` → `mimo-v2.5-pro`, `mimo-v2-omni`/`mimo-v2-flash` →\n // `mimo-v2.5`) resolve to their live replacement. The returned model's `id`\n // becomes the upstream model, so the alias is a clean resolve (no rewrite\n // notice). Mirrors deepseek.ts.\n for (const m of BUILTIN_MODELS) {\n if (m.id === clientModel) return m;\n if (m.aliases?.includes(clientModel)) return m;\n }\n return null;\n },\n\n // Vision capability lives here (MiMo-specific): only `mimo-v2.5` accepts\n // images (the `*-omni` substring branch in modelSupportsImages is kept for\n // the aliased/legacy omni name). Exposing it as a provider method is what\n // scopes the multimodal fallback to MiMo — other providers don't implement it.\n supportsVision(model) {\n return modelSupportsImages(model);\n },\n\n preprocessResponses(req: ResponsesRequest, ctx: PreprocessCtx): ChatRequest {\n // parallel_tool_calls is forced on (batches tool calls per turn) to\n // compensate for MiMo's weaker agentic-coding training vs GPT-5 / Claude.\n //\n // web_search is forwarded ONLY when explicitly opted in (the global \"web\n // search\" toggle) and the account isn't token-plan. It's OFF by default\n // because the Web Search Plugin is separately billed and forwarding it\n // unprompted 400s \"webSearchEnabled is false\".\n const chat = reqToChat(req, {\n forceParallelToolCalls: true,\n enableWebSearch: webSearchAllowed(ctx),\n imageDropDir: ctx.dataDir,\n disableThinking: ctx.disableThinking,\n forceHighEffort: ctx.forceHighEffort,\n upstreamModel: ctx.upstreamModel,\n });\n // Key normalization off the UPSTREAM model (post-alias), not the client\n // literal — a retired name aliased to v2.5 must get v2.5's thinking/sampling\n // rules. ctx.upstreamModel is always set in production (resolved id ??\n // defaultModel); the fallback only covers direct unit-test callers.\n return normalizeMimoBody(chat, ctx.upstreamModel ?? req.model);\n },\n\n preprocessChat(req: ChatRequest, ctx: PreprocessCtx): ChatRequest {\n // Chat passthrough: forward verbatim. MiMo is itself Chat-Completions-native.\n const out = { ...req };\n if (ctx.disableThinking) {\n // mimo 上游用 thinking:{type:\"disabled\"} 关思考。**不要**碰 reasoning_effort ——\n // mimo schema 只接受 low/medium/high,\"none\" 会 400。\n out.thinking = { type: \"disabled\" };\n }\n // Same web_search gate as preprocessResponses: drop the builtin web_search\n // tool unless explicitly opted in (and not token-plan), so a passthrough\n // chat request can't 400 on an account without the plugin. Reassigning a\n // new array leaves the caller's req.tools untouched.\n if (!webSearchAllowed(ctx) && Array.isArray(out.tools)) {\n const filtered = out.tools.filter((t) => (t as { type?: string }).type !== \"web_search\");\n if (filtered.length !== out.tools.length) out.tools = filtered;\n }\n // Key off the upstream model (post-alias), same as preprocessResponses.\n // out.model is still the client literal here (the server overwrites it with\n // the upstream id only after this returns), so keying off it would mis-apply\n // the v2.5 rules to an aliased legacy name.\n return normalizeMimoBody(out, ctx.upstreamModel ?? out.model);\n },\n\n enhanceError({ status, snippet }): ProviderEnhancedError | null {\n if (status === 400 && snippet?.includes(WEB_SEARCH_DISABLED_MARKER)) {\n return {\n code: \"web_search_plugin_not_activated\",\n message: `${WEB_SEARCH_HINT} (raw: ${snippet})`,\n };\n }\n return null;\n },\n};\n"}}, "src/config.ts": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785595275.7786112, "n": 14526, "h": "058218116735117f", "c": "import { byShortcut, isProviderId, PROVIDER_LIST, PROVIDERS } from \"./providers/registry.js\";\nimport type { Provider, ProviderId, ProviderRuntime } from \"./providers/types.js\";\nimport { resolveDataDir } from \"./db/dataDir.js\";\nimport type { ContextOverflowMode } from \"./upstream/openaiCompatClient.js\";\nimport { parseLogBodyMode, parseLogRetentionDays, type LogBodyMode } from \"./logging/settings.js\";\n\n// Documentation URLs surfaced in \"missing API key\" errors. Built-ins are\n// hardcoded here so error messages stay user-friendly; generic providers\n// supply their own via Provider.docsUrl.\nconst BUILTIN_DOCS_URL: Record<string, string> = {\n mimo: \"https://platform.xiaomimimo.com/#/console/api-keys\",\n deepseek: \"https://platform.deepseek.com/api_keys\",\n};\n\nexport interface Config {\n host: string;\n port: number;\n baseUrl: string; // resolved base url for the default provider\n apiKey: string; // resolved api key for the default provider\n exposeReasoning: boolean;\n verbose: boolean;\n userAgent: string;\n defaultProviderId: ProviderId;\n providers: Record<ProviderId, ProviderRuntime | null>;\n // Convenience: same as providers[defaultProviderId]!.flags.isTokenPlan\n // when default is mimo. Kept on Config for log-banner ergonomics.\n isTokenPlan: boolean;\n dataDir: string;\n adminEnabled: boolean;\n // Set ONLY when admin was force-disabled at startup because the SQLite\n // native module failed to load (openDb threw) — NOT when the user opted out\n // via --no-admin / MIMO2CODEX_NO_ADMIN (those leave this undefined). Lets\n // server.ts tell \"admin crashed\" apart from \"admin intentionally off\" and\n // serve a clear diagnostic on /admin/ instead of a confusing `no route` 404.\n // See cli.ts openDb catch + server.ts admin routing.\n adminDisabledReason?: { message: string; likelyBinding: boolean; dataDir: string };\n // How to render upstream 400s identified as \"context window exceeded\".\n // \"friendly\" (default): rewrite to a bilingual hint that points users at\n // codex's /compact command. \"passthrough\": forward the raw upstream error.\n // Controlled via MIMO2CODEX_CONTEXT_OVERFLOW_MODE.\n contextOverflowMode: ContextOverflowMode;\n // --disable-thinking CLI flag (or MIMO2CODEX_DISABLE_THINKING env)。三态:\n // true → CLI/env 显式开启了\"关思考\"\n // false → CLI/env 显式 --reasoning(等同 --no-disable-thinking)/未来留口\n // undefined → 未显式设置,让运行时读 settings DB(admin UI 控制)\n // server.ts 的 resolveDisableThinking() 实现 CLI > settings > false 的优先级。\n disableThinkingFromCli?: boolean;\n // --web-search CLI flag (or MIMO2CODEX_WEB_SEARCH env)。三态,同 disableThinking:\n // true → 显式打开\"向上游转发 web_search\" / false → 显式关闭 / undefined → 读 settings DB。\n // 默认关闭(MiMo 联网插件单独计费、默认不开通)。server.ts 的 resolveWebSearchEnabled()\n // 实现 CLI/env > settings(mimo.webSearchEnabled) > false 的优先级。\n webSearchFromCli?: boolean;\n // Authentication mode. \"off\" (default for the native CLI binary) keeps the\n // historic local-only behavior: /admin/* and /v1/* are fully open, no users,\n // no sessions, no per-request key checks. \"on\" (default in the Docker image\n // via ENV MIMO2CODEX_AUTH=on) enforces session cookies on /admin/* and\n // bearer tokens on /v1/*, and exposes the bootstrap/login/users/BYOK\n // surfaces. The new tables exist in both modes; \"off\" simply doesn't\n // populate or consult them.\n authMode: \"off\" | \"on\";\n // When true the session cookie is marked Secure (HTTPS-only). Defaults\n // to false; deployers behind nginx/caddy set MIMO2CODEX_COOKIE_SECURE=1.\n cookieSecure: boolean;\n // Suppress the \"model fallback applied\" info log when Codex sends a model id\n // that differs from the provider's catalog (e.g. \"gpt-5.4\" → \"mimo-v2.5-pro\").\n // true/false → env MIMO2CODEX_SILENT_REWRITE explicitly set, forces it\n // undefined → runtime reads settings DB (admin UI toggle), default silent\n // server.ts resolveSilentRewrite() implements env > settings > true.\n silentRewriteFromCli?: boolean;\n logBodyModeFromCli?: LogBodyMode;\n logRetentionDaysFromCli?: number | null;\n}\n\nconst DEFAULTS = {\n host: \"127.0.0.1\",\n port: 8788,\n};\n\nexport interface ParsedArgs {\n host?: string;\n port?: number;\n baseUrl?: string;\n apiKey?: string;\n model?: string;\n exposeReasoning?: boolean;\n verbose?: boolean;\n envKey?: boolean;\n dataDir?: string;\n noAdmin?: boolean;\n noLoadEnv?: boolean;\n noUpdateCheck?: boolean;\n disableThinking?: boolean;\n webSearch?: boolean;\n authMode?: \"off\" | \"on\";\n logBodyMode?: LogBodyMode;\n logRetentionDays?: number | null;\n positional: string[];\n showHelp: boolean;\n showVersion: boolean;\n}\n\nexport function parseArgv(argv: string[]): ParsedArgs {\n const out: ParsedArgs = { positional: [], showHelp: false, showVersion: false };\n\n for (let i = 0; i < argv.length; i++) {\n const a = argv[i];\n const next = (): string => {\n const v = argv[i + 1];\n if (v === undefined) throw new Error(`flag ${a} requires a value`);\n i++;\n return v;\n };\n switch (a) {\n case \"--port\":\n case \"-p\":\n out.port = Number(next());\n if (Number.isNaN(out.port)) throw new Error(\"--port must be a number\");\n break;\n case \"--host\":\n out.host = next();\n break;\n case \"--base-url\":\n case \"--baseurl\":\n out.baseUrl = next();\n break;\n case \"--api-key\":\n out.apiKey = next();\n break;\n case \"--model\":\n out.model = next();\n break;\n case \"--no-reasoning\":\n out.exposeReasoning = false;\n break;\n case \"--reasoning\":\n out.exposeReasoning = true;\n break;\n case \"--verbose\":\n case \"-v\":\n out.verbose = true;\n break;\n case \"--env-key\":\n out.envKey = true;\n break;\n case \"--data-dir\":\n out.dataDir = next();\n break;\n case \"--no-admin\":\n out.noAdmin = true;\n break;\n case \"--no-load-env\":\n out.noLoadEnv = true;\n break;\n case \"--no-update-check\":\n out.noUpdateCheck = true;\n break;\n case \"--disable-thinking\":\n out.disableThinking = true;\n break;\n case \"--web-search\":\n out.webSearch = true;\n break;\n case \"--no-web-search\":\n out.webSearch = false;\n break;\n case \"--auth\": {\n const v = next().toLowerCase();\n if (v !== \"on\" && v !== \"off\") {\n throw new Error(\"--auth must be 'on' or 'off'\");\n }\n out.authMode = v;\n break;\n }\n case \"--log-body-mode\": {\n const mode = parseLogBodyMode(next());\n if (!mode) throw new Error(\"--log-body-mode must be one of: full, errors-only, off\");\n out.logBodyMode = mode;\n break;\n }\n case \"--log-retention-days\": {\n const parsed = parseLogRetentionDays(next());\n if (parsed === undefined) {\n throw new Error(\"--log-retention-days must be a positive integer or 0 to disable\");\n }\n out.logRetentionDays = parsed;\n break;\n }\n case \"--help\":\n case \"-h\":\n out.showHelp = true;\n break;\n case \"--version\":\n case \"-V\":\n out.showVersion = true;\n break;\n default:\n if (a.startsWith(\"--\")) {\n throw new Error(`unknown flag: ${a}`);\n }\n out.positional.push(a);\n }\n }\n return out;\n}\n\n// An empty / whitespace-only base-url override (from env or CLI) means \"use the\n// default\", not \"use an empty host\". The desktop Settings intentionally writes\n// MIMO_BASE_URL= (empty) when the field is left blank (App.tsx clears fields by\n// writing them back empty), so without this the empty string wins the `??` chain\n// below and pins the upstream to \"\" — leaving the banner's `upstream:` blank and\n// every request hostless. Collapse blanks to undefined so the fallback chain\n// (key-based inference → defaultBaseUrl) runs.\nfunction blankToUndefined(v: string | undefined): string | undefined {\n const t = v?.trim();\n return t ? t : undefined;\n}\n\nfunction resolveProviderRuntime(\n provider: Provider,\n isDefault: boolean,\n parsed: ParsedArgs,\n env: NodeJS.ProcessEnv\n): ProviderRuntime | null {\n // CLI --api-key / --base-url apply only to the default provider.\n const apiKeyFromCli = isDefault ? parsed.apiKey : undefined;\n const baseUrlFromCli = isDefault ? parsed.baseUrl : undefined;\n\n let apiKey = apiKeyFromCli;\n if (!apiKey) {\n for (const k of provider.envKeys) {\n const v = env[k];\n if (v) {\n apiKey = v;\n break;\n }\n }\n }\n if (!apiKey) return null;\n\n // Priority: CLI --base-url > env > key-based inference > defaultBaseUrl.\n // Key-based inference handles MiMo's tp-* / sk-* tiers — using the wrong\n // host with a tp-* key 401s, so this auto-switches to the right one when\n // the user hasn't overridden it explicitly.\n const baseUrl =\n blankToUndefined(baseUrlFromCli) ??\n blankToUndefined(env[provider.baseUrlEnv]) ??\n provider.inferBaseUrlFromKey?.(apiKey) ??\n provider.defaultBaseUrl;\n return {\n apiKey,\n baseUrl,\n flags: provider.detectFlags(apiKey, baseUrl),\n };\n}\n\nexport function buildConfig(parsed: ParsedArgs, env: NodeJS.ProcessEnv, version: string): Config {\n const exposeReasoningEnv = env.MIMO2CODEX_NO_REASONING ? false : true;\n const verboseEnv = !!env.MIMO2CODEX_VERBOSE;\n\n // Resolve default provider. --model accepts either a known shortcut (\"ds\")\n // or a full provider id (\"deepseek\"). Default = mimo.\n let defaultProviderId: ProviderId = \"mimo\";\n if (parsed.model) {\n const p = byShortcut(parsed.model);\n if (!p) {\n const known = PROVIDER_LIST.map((x) => `${x.shortcut} (${x.id})`).join(\", \");\n throw new Error(`unknown --model \"${parsed.model}\". Known providers: ${known}`);\n }\n defaultProviderId = p.id;\n } else if (env.MIMO2CODEX_DEFAULT_PROVIDER) {\n if (!isProviderId(env.MIMO2CODEX_DEFAULT_PROVIDER)) {\n throw new Error(\n `MIMO2CODEX_DEFAULT_PROVIDER must be one of: ${PROVIDER_LIST.map((p) => p.id).join(\", \")}`\n );\n }\n defaultProviderId = env.MIMO2CODEX_DEFAULT_PROVIDER;\n }\n\n // Resolve runtime for every registered provider (built-ins + any generic\n // providers loaded via initRegistry). Runtime is null when no key is\n // available; the registry stays populated so per-request model-based\n // routing can still recognize the provider's catalog.\n const providers: Record<ProviderId, ProviderRuntime | null> = Object.fromEntries(\n PROVIDER_LIST.map((p) => [p.id, null])\n );\n for (const p of PROVIDER_LIST) {\n providers[p.id] = resolveProviderRuntime(p, p.id === defaultProviderId, parsed, env);\n }\n\n const defaultRuntime = providers[defaultProviderId];\n if (!defaultRuntime) {\n const def = PROVIDERS[defaultProviderId];\n const envHint = def.envKeys.join(\" or \");\n const docsUrl = def.docsUrl ?? BUILTIN_DOCS_URL[def.id];\n const docs = docsUrl\n ? `Get one at ${docsUrl}`\n : `Configure baseUrl + envKey in your providers.json entry for \"${def.id}\".`;\n throw new Error(\n `missing API key for ${def.displayName} — set ${envHint} env var or pass --api-key. ${docs}`\n );\n }\n\n const portFromEnv = env.MIMO2CODEX_PORT ? Number(env.MIMO2CODEX_PORT) : undefined;\n if (portFromEnv !== undefined && Number.isNaN(portFromEnv)) {\n throw new Error(\"MIMO2CODEX_PORT must be a number\");\n }\n\n const adminEnabled = parsed.noAdmin\n ? false\n : env.MIMO2CODEX_NO_ADMIN\n ? false\n : true;\n const dataDir = adminEnabled ? resolveDataDir(parsed.dataDir, env) : \"\";\n\n const overflowEnv = env.MIMO2CODEX_CONTEXT_OVERFLOW_MODE?.toLowerCase();\n const contextOverflowMode: ContextOverflowMode =\n overflowEnv === \"passthrough\" ? \"passthrough\" : \"friendly\";\n const logBodyModeFromCli =\n parsed.logBodyMode ?? parseLogBodyMode(env.MIMO2CODEX_LOG_BODY_MODE) ?? undefined;\n const logRetentionDaysFromCli =\n parsed.logRetentionDays ?? parseLogRetentionDays(env.MIMO2CODEX_LOG_RETENTION_DAYS);\n\n // CLI flag 优先,否则看 env (MIMO2CODEX_DISABLE_THINKING=1),否则留 undefined\n // 让 server 运行时读 settings DB(让 admin UI 改完立刻生效,无需重启)。\n const disableThinkingFromCli: boolean | undefined =\n parsed.disableThinking ??\n (env.MIMO2CODEX_DISABLE_THINKING === \"1\" || env.MIMO2CODEX_DISABLE_THINKING === \"true\"\n ? true\n : undefined);\n\n // --web-search / --no-web-search > MIMO2CODEX_WEB_SEARCH=1|0 > undefined (read\n // settings DB). Default OFF — the MiMo Web Search Plugin is separately billed\n // and off by default, so forwarding web_search unprompted 400s.\n const webSearchFromCli: boolean | undefined =\n parsed.webSearch ??\n (env.MIMO2CODEX_WEB_SEARCH === \"1\" || env.MIMO2CODEX_WEB_SEARCH === \"true\"\n ? true\n : env.MIMO2CODEX_WEB_SEARCH === \"0\" || env.MIMO2CODEX_WEB_SEARCH === \"false\"\n ? false\n : undefined);\n\n // --auth on|off > MIMO2CODEX_AUTH=on|1|true|off|0|false > \"off\" default.\n // Docker images flip this to \"on\" via ENV in the Dockerfile, so containers\n // are secure by default while npm/local CLI users keep the zero-auth UX.\n const authMode: \"off\" | \"on\" = resolveAuthMode(parsed.authMode, env);\n\n return {\n host: parsed.host ?? env.MIMO2CODEX_HOST ?? DEFAULTS.host,\n port: parsed.port ?? portFromEnv ?? DEFAULTS.port,\n baseUrl: defaultRuntime.baseUrl,\n apiKey: defaultRuntime.apiKey,\n exposeReasoning: parsed.exposeReasoning ?? exposeReasoningEnv,\n verbose: parsed.verbose ?? verboseEnv,\n userAgent: `mimo2codex/${version}`,\n defaultProviderId,\n providers,\n isTokenPlan: !!defaultRuntime.flags.isTokenPlan,\n dataDir,\n adminEnabled,\n contextOverflowMode,\n disableThinkingFromCli,\n webSearchFromCli,\n authMode,\n cookieSecure: env.MIMO2CODEX_COOKIE_SECURE === \"1\" || env.MIMO2CODEX_COOKIE_SECURE === \"true\",\n silentRewriteFromCli:\n env.MIMO2CODEX_SILENT_REWRITE === \"1\" || env.MIMO2CODEX_SILENT_REWRITE === \"true\"\n ? true\n : env.MIMO2CODEX_SILENT_REWRITE === \"0\" || env.MIMO2CODEX_SILENT_REWRITE === \"false\"\n ? false\n : undefined,\n logBodyModeFromCli,\n logRetentionDaysFromCli,\n };\n}\n\nfunction resolveAuthMode(\n fromCli: \"off\" | \"on\" | undefined,\n env: NodeJS.ProcessEnv\n): \"off\" | \"on\" {\n if (fromCli) return fromCli;\n const raw = env.MIMO2CODEX_AUTH?.toLowerCase().trim();\n if (raw === \"on\" || raw === \"1\" || raw === \"true\" || raw === \"yes\") return \"on\";\n if (raw === \"off\" || raw === \"0\" || raw === \"false\" || raw === \"no\") return \"off\";\n return \"off\";\n}\n"}}, "doc/kimi.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785674078.9315279, "n": 4349, "h": "4efaf5198f77bfb8", "c": "# Wiring up Kimi (Moonshot)\n\n> 中文:[kimi.zh.md](./kimi.zh.md)\n\n[Kimi API platform](https://platform.kimi.com/docs) (Moonshot AI) speaks OpenAI Chat Completions and is fully compatible with standard fields. mimo2codex integrates via a generic provider — type `https://api.moonshot.cn/v1` as baseUrl in the admin UI and it auto-detects + applies the recommended features.\n\n## Recommended setup — admin UI one-click\n\nAdmin UI → Providers → **+ Add Provider** → fill in:\n\n- baseUrl: `https://api.moonshot.cn/v1` (in-China) or `https://api.moonshot.ai/v1` (international)\n\nThe UI auto-detects \"Kimi (Moonshot)\" and applies:\n\n- `dropReasoningEffort: true` — Kimi doesn't recognize `reasoning_effort`; it uses `thinking: {enabled/disabled}` instead. If the admin UI \"Codex 启用\" page has \"Force high reasoning effort\" ON, this switch tells mimo2codex to strip `reasoning_effort` from outgoing Kimi requests to be safe.\n\nFill in id / envKey / defaultModel and save.\n\n## Recommended setup — providers.json\n\n```json\n{\n \"providers\": [\n {\n \"id\": \"kimi\",\n \"displayName\": \"Kimi (Moonshot)\",\n \"baseUrl\": \"https://api.moonshot.cn/v1\",\n \"envKey\": \"MOONSHOT_API_KEY\",\n \"defaultModel\": \"kimi-k2.6\",\n \"models\": [\n { \"id\": \"kimi-k2.6\", \"contextWindow\": 262144, \"maxOutputTokens\": 32768, \"supportsReasoning\": true },\n { \"id\": \"kimi-k2.5\", \"contextWindow\": 262144, \"maxOutputTokens\": 32768, \"supportsReasoning\": true },\n { \"id\": \"kimi-k2-thinking\", \"contextWindow\": 262144, \"maxOutputTokens\": 32768, \"supportsReasoning\": true },\n { \"id\": \"kimi-k2-thinking-turbo\", \"contextWindow\": 262144, \"supportsReasoning\": true },\n { \"id\": \"moonshot-v1-128k\", \"contextWindow\": 131072 },\n { \"id\": \"moonshot-v1-32k\", \"contextWindow\": 32768 },\n { \"id\": \"moonshot-v1-8k\", \"contextWindow\": 8192 }\n ],\n \"features\": {\n \"dropReasoningEffort\": true\n },\n \"docsUrl\": \"https://platform.kimi.com/docs\"\n }\n ]\n}\n```\n\n```bash\nexport MOONSHOT_API_KEY=sk-xxxxx # get one at https://platform.kimi.com/console/api-keys\nmimo2codex\n```\n\n## Available models\n\nSource: [Kimi API docs](https://platform.kimi.com/docs)\n\n| Model ID | Description | Default thinking | Notes |\n|---|---|---|---|\n| `kimi-k2.6` | Latest recommended, multimodal agentic | ON (can disable) | temperature fixed at 1.0; max_tokens ≥ 16K recommended |\n| `kimi-k2.5` | Previous generation | ON | |\n| `kimi-k2-thinking` | Dedicated thinking model | **Forced ON**, cannot disable | Reasoning traces are large, token consumption noticeable |\n| `kimi-k2-thinking-turbo` | Turbo variant | Forced ON | |\n| `moonshot-v1-8k/32k/128k` | Classic chat models | No active thinking | |\n\n## Thinking mechanism comparison\n\nKimi uses `thinking: {type: \"enabled\"|\"disabled\"}` (same as mimo / deepseek). It **does not recognize** `reasoning_effort`:\n\n| Field | Kimi behavior |\n|---|---|\n| `thinking: {type: \"enabled\"}` | Triggers thinking |\n| `thinking: {type: \"disabled\"}` | Disables thinking (except kimi-k2-thinking, which is forced on) |\n| `reasoning_effort: \"high\"` etc. | **Not recognized**, usually silently ignored; we strip it with dropReasoningEffort to be safe |\n\nThe `reasoning_content` field arrives as streaming deltas before `content` does. mimo2codex's streamToSse translates these correctly into Codex's reasoning_summary events.\n\n## Notes\n\n- **temperature**: Kimi-k2.6 docs say temperature is fixed at 1.0; other client values are fallback-coerced. mimo2codex passes the client value through; Kimi fixes it upstream.\n- **max_tokens**: Kimi docs recommend ≥ 16K (especially for thinking models, since reasoning_content tokens count toward max_tokens). Codex defaults are usually sufficient.\n- **Admin UI \"Force high reasoning effort\" switch**: **Has no effect on Kimi** — Kimi doesn't recognize reasoning_effort. With `dropReasoningEffort: true` set by the preset, the field is stripped anyway. Kimi thinking is controlled by the `thinking` field; kimi-k2.6 / kimi-k2-thinking are ON by default, no forcing needed.\n\n## Upgrade notes\n\nThis integration ships as a generic provider — no builtin id is reserved. If you previously had a generic `id=\"kimi\"`, it keeps working unchanged. Open it in the admin UI and let the moonshot baseUrl trigger the smart default to apply `dropReasoningEffort: true` (only if features were empty).\n"}}, "doc/kimi.zh.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785674080.4265695, "n": 3235, "h": "97b538d981efb805", "c": "# 接入 Kimi (Moonshot)\n\n> English: [kimi.md](./kimi.md)\n\n[Kimi API 平台](https://platform.kimi.com/docs)(Moonshot AI)走 OpenAI Chat Completions 协议,完全兼容标准字段。mimo2codex 通过 generic provider 接入,admin UI 输入 `https://api.moonshot.cn/v1` 会被自动识别并套上推荐 features。\n\n## 推荐配置 — admin UI 一键\n\nAdmin UI → Providers → **+ 添加 Provider** → 输入:\n\n- baseUrl:`https://api.moonshot.cn/v1`(国内访问,国际用 `https://api.moonshot.ai/v1`)\n\nUI 会自动识别为「Kimi (Moonshot)」并套用:\n\n- `dropReasoningEffort: true` —— Kimi 不识别 `reasoning_effort` 字段,靠 `thinking: {enabled/disabled}` 控制思考。如果 admin UI「Codex 启用」页开了\"强制高强度思考\",本开关让 mimo2codex 把 reasoning_effort 字段从发给 Kimi 的请求里删掉,避免风险。\n\n补齐 id / envKey / defaultModel 保存即可。\n\n## 推荐配置 — providers.json\n\n```json\n{\n \"providers\": [\n {\n \"id\": \"kimi\",\n \"displayName\": \"Kimi (Moonshot)\",\n \"baseUrl\": \"https://api.moonshot.cn/v1\",\n \"envKey\": \"MOONSHOT_API_KEY\",\n \"defaultModel\": \"kimi-k2.6\",\n \"models\": [\n { \"id\": \"kimi-k2.6\", \"contextWindow\": 262144, \"maxOutputTokens\": 32768, \"supportsReasoning\": true },\n { \"id\": \"kimi-k2.5\", \"contextWindow\": 262144, \"maxOutputTokens\": 32768, \"supportsReasoning\": true },\n { \"id\": \"kimi-k2-thinking\", \"contextWindow\": 262144, \"maxOutputTokens\": 32768, \"supportsReasoning\": true },\n { \"id\": \"kimi-k2-thinking-turbo\", \"contextWindow\": 262144, \"supportsReasoning\": true },\n { \"id\": \"moonshot-v1-128k\", \"contextWindow\": 131072 },\n { \"id\": \"moonshot-v1-32k\", \"contextWindow\": 32768 },\n { \"id\": \"moonshot-v1-8k\", \"contextWindow\": 8192 }\n ],\n \"features\": {\n \"dropReasoningEffort\": true\n },\n \"docsUrl\": \"https://platform.kimi.com/docs\"\n }\n ]\n}\n```\n\n```powershell\n$env:MOONSHOT_API_KEY = \"sk-xxxxx\" # 在 https://platform.kimi.com/console/api-keys 申请\nmimo2codex\n```\n\n## 模型一览\n\n来源:[Kimi API docs](https://platform.kimi.com/docs)\n\n| Model ID | 说明 | 默认思考 | 备注 |\n|---|---|---|---|\n| `kimi-k2.6` | 最新推荐,多模态智能体 | 开(可关)| temperature 固定 1.0;max_tokens 建议 ≥ 16K |\n| `kimi-k2.5` | k2.6 之前的版本 | 开 | |\n| `kimi-k2-thinking` | 思考模式专用 | **强制开**,不可关 | 推理过程明显,token 消耗大 |\n| `kimi-k2-thinking-turbo` | 思考模式 turbo 版 | 强制开 | |\n| `moonshot-v1-8k/32k/128k` | 经典 chat 模型 | 不主动思考 | |\n\n## 思考机制对比\n\nKimi 用 `thinking: {type: \"enabled\"|\"disabled\"}` 字段控制思考(与 mimo / deepseek 一致),**不识别** `reasoning_effort`:\n\n| 字段 | Kimi 行为 |\n|---|---|\n| `thinking: {type: \"enabled\"}` | 触发思考 |\n| `thinking: {type: \"disabled\"}` | 关思考(kimi-k2-thinking 例外,强制开) |\n| `reasoning_effort: \"high\"` 等 | **不识别**,多数情况忽略;保守起见用 dropReasoningEffort strip 掉 |\n\n响应里 `reasoning_content` 字段在 `content` 之前以流式 delta 形式出现,mimo2codex 的 streamToSse 已经正确翻译给 Codex(在 Codex 终端会看到思考摘要)。\n\n## 注意事项\n\n- **temperature**:kimi-k2.6 文档说 temperature 固定 1.0,客户端传的其他值会被 fallback。mimo2codex 透传客户端 temperature,但 Kimi 上游会自己 fix。\n- **max_tokens**:Kimi 文档建议 ≥ 16K(特别是 thinking 模型,因为 reasoning_content 计入 token 数)。Codex 默认 max_output_tokens 一般够。\n- **Admin UI 的\"强制高强度思考\"开关**:对 Kimi **无意义** —— Kimi 不认 reasoning_effort,开了反而会被 `dropReasoningEffort` 删掉。Kimi 思考由 `thinking` 字段控制,kimi-k2.6 / kimi-k2-thinking 默认就开思考,无需额外强制。\n\n## 升级须知\n\n本特性以 generic provider 方式接入,不占用 builtin id。已有 id=\"kimi\" 的 generic provider 配置可继续使用;建议在 admin UI 里编辑后,输入 moonshot baseUrl 触发智能默认,自动套上 `dropReasoningEffort: true`(如果之前 features 全空)。\n"}}, "mimoskill/scripts/ocr.py": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785674308.7651434, "n": 24778, "h": "99daa9fef372bc40", "c": "#!/usr/bin/env python3\n\"\"\"\nocr.py — OCR / image recognition that works without any API key.\n\nUse this when the surrounding chat model can't see images (mimo-v2.5-pro,\ndeepseek-*, or any text-only model).\n\nEngines (--engine):\n auto (default) — mimo if MIMO_API_KEY set, else tesseract if\n installed and --mode=text, else pollinations\n mimo — Xiaomi MiMo V2.5 vision. Highest quality. Needs MIMO_API_KEY\n tesseract — Local Tesseract OCR. NO KEY, NO NETWORK. Good for users\n behind firewalls (e.g. mainland China where pollinations.ai\n can be unreachable). Supports --mode text only.\n pollinations — pollinations.ai free public vision endpoint. No key but\n needs network reachable from the user (may be slow/unreliable\n in some regions).\n\nModes (--mode):\n text (default) verbatim OCR — raw text, preserves line breaks\n describe 2-4 sentence description of the image (mimo / pollinations only)\n structured single JSON object with text / language / regions / summary\n (mimo / pollinations only)\n markdown re-render the image as GitHub-flavored Markdown\n (mimo / pollinations only)\n\nImage inputs (positional, 0+):\n /path/to/file.png local file → base64 data URL\n https://example.com/x.png http(s) URL → forwarded as-is\n data:image/...;base64,... data URL → forwarded as-is\n - read one image from stdin (bytes)\n (none, stdin not a TTY) same as `-`\n\nUsage:\n # Zero-setup\n python3 ocr.py path/to/image.png\n python3 ocr.py --mode describe https://example.com/x.png\n\n # Behind GFW / offline — install tesseract once, never need network again\n # macOS: brew install tesseract tesseract-lang\n # Ubuntu: sudo apt install tesseract-ocr tesseract-ocr-chi-sim\n # Windows: https://github.com/UB-Mannheim/tesseract/wiki\n python3 ocr.py --engine tesseract --lang Chinese scan.png\n\n # Best quality (needs MiMo key)\n export MIMO_API_KEY=sk-xxxx\n python3 ocr.py --mode structured a.png b.jpg\n\nOnly depends on the standard library — no `openai` SDK install needed.\n\"\"\"\nfrom __future__ import annotations\n\nimport argparse\nimport base64\nimport json\nimport mimetypes\nimport os\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\nimport urllib.error\nimport urllib.request\nfrom pathlib import Path\nfrom typing import Any\n\n\n# --- modes ------------------------------------------------------------------\n\nMODE_PROMPTS: dict[str, str] = {\n \"text\": (\n \"Extract ALL legible text from the attached image(s) verbatim, \"\n \"preserving line breaks, reading order, and any obvious column/table \"\n \"layout using whitespace and pipes. Do not paraphrase, translate, \"\n \"summarize, or add commentary. If you cannot read part of it, output \"\n \"`[unreadable]` in place. If the image contains no text, output \"\n \"exactly the single line `[no text detected]`.\"\n ),\n \"describe\": (\n \"Describe the contents of the attached image(s) in 2-4 sentences. \"\n \"Mention layout, key visual elements, any visible text (quoted), and \"\n \"notable colors. Do not invent details that aren't visible.\"\n ),\n \"structured\": (\n \"Return ONE JSON object with keys `text` (string, full OCR — same \"\n \"rules as verbatim text extraction, preserve line breaks and reading \"\n \"order), `language` (BCP-47 best-guess like \\\"zh-Hans\\\" or \\\"en\\\"), \"\n \"`regions` (array of `{label, text, role}` where role is one of \"\n \"`title`, `paragraph`, `list`, `table`, `caption`, `ui`, \"\n \"`handwriting`, `other`), and `summary` (1-sentence description). \"\n \"Output ONLY the JSON, no markdown fences, no preamble.\"\n ),\n \"markdown\": (\n \"Re-render the attached image(s) as GitHub-flavored Markdown. \"\n \"Headings become `#`/`##`, tables become pipe tables, code-like text \"\n \"becomes fenced code blocks, lists become `-`. Preserve reading \"\n \"order. Output ONLY the markdown body — no preamble, no fences \"\n \"wrapping the whole thing.\"\n ),\n}\n\nSTRUCTURED_SYSTEM = (\n \"You are an OCR engine. Output strictly machine-parseable JSON, \"\n \"no markdown fences, no commentary.\"\n)\n\n\n# --- MIME sniffing ----------------------------------------------------------\n\n_MAGIC = [\n (b\"\\x89PNG\\r\\n\\x1a\\n\", \"image/png\"),\n (b\"\\xff\\xd8\\xff\", \"image/jpeg\"),\n (b\"GIF87a\", \"image/gif\"),\n (b\"GIF89a\", \"image/gif\"),\n (b\"BM\", \"image/bmp\"),\n]\n\n\ndef sniff_mime(data: bytes, hint_name: str | None = None) -> str:\n for sig, mime in _MAGIC:\n if data.startswith(sig):\n return mime\n # WebP: \"RIFF....WEBP\"\n if len(data) >= 12 and data[:4] == b\"RIFF\" and data[8:12] == b\"WEBP\":\n return \"image/webp\"\n if hint_name:\n guessed, _ = mimetypes.guess_type(hint_name)\n if guessed and guessed.startswith(\"image/\"):\n return guessed\n return \"image/png\"\n\n\ndef bytes_to_data_url(data: bytes, hint_name: str | None = None) -> str:\n mime = sniff_mime(data, hint_name)\n b64 = base64.b64encode(data).decode(\"ascii\")\n return f\"data:{mime};base64,{b64}\"\n\n\ndef resolve_image_arg(arg: str) -> str:\n \"\"\"Turn a positional IMAGE arg into a URL suitable for image_url.\"\"\"\n if arg == \"-\":\n if sys.stdin.isatty():\n sys.stderr.write(\"error: `-` requested but stdin is a TTY\\n\")\n sys.exit(2)\n data = sys.stdin.buffer.read()\n if not data:\n sys.stderr.write(\"error: stdin was empty\\n\")\n sys.exit(2)\n return bytes_to_data_url(data)\n if arg.startswith((\"http://\", \"https://\", \"data:\")):\n return arg\n path = Path(arg)\n if not path.exists():\n sys.stderr.write(f\"error: image not found: {arg}\\n\")\n sys.exit(4)\n try:\n data = path.read_bytes()\n except OSError as e:\n sys.stderr.write(f\"error: cannot read {arg}: {e}\\n\")\n sys.exit(4)\n return bytes_to_data_url(data, hint_name=path.name)\n\n\n# --- model auto-select ------------------------------------------------------\n\n# MiMo retired its v2 generation (offline 2026-06-30). This script calls MiMo\n# directly (no proxy alias layer), so the old names 400 upstream — coerce them\n# to their live replacement. `mimo-v2.5` is now the sole vision model.\nRETIRED_MIMO_MODELS = {\"mimo-v2-omni\", \"mimo-v2-flash\", \"mimo-v2-pro\"}\n\n\ndef model_supports_images(model: str) -> bool:\n \"\"\"Vision-capable MiMo model check — `mimo-v2.5` is now the only one.\n\n (src/translate/reqToChat.ts keeps a defensive `*-omni` branch behind the\n proxy's alias layer; this standalone script has no alias layer, so a retired\n omni name would 400 upstream — see pick_model's RETIRED_MIMO_MODELS coercion.)\n \"\"\"\n base = model.split(\"[\", 1)[0].lower()\n return base == \"mimo-v2.5\"\n\n\ndef pick_model(cli_model: str | None) -> tuple[str, str | None]:\n \"\"\"Returns (chosen_model, note_for_stderr_or_None).\"\"\"\n if cli_model:\n if cli_model in RETIRED_MIMO_MODELS:\n return \"mimo-v2.5\", (\n f\"note: model '{cli_model}' is retired; \"\n f\"using mimo-v2.5 (the current vision model) for this call.\\n\"\n )\n if model_supports_images(cli_model):\n return cli_model, None\n return \"mimo-v2.5\", (\n f\"note: model '{cli_model}' does not see images; \"\n f\"switching to mimo-v2.5 for this call.\\n\"\n )\n env_ocr = os.environ.get(\"MIMO_OCR_MODEL\")\n if env_ocr and model_supports_images(env_ocr):\n return env_ocr, None\n env_chat = os.environ.get(\"MIMO_MODEL\")\n if env_chat and model_supports_images(env_chat):\n return env_chat, None\n return \"mimo-v2.5\", None\n\n\n# --- message building -------------------------------------------------------\n\ndef build_messages(\n *, mode: str, image_urls: list[str], lang: str | None, extra_prompt: str | None\n) -> list[dict[str, Any]]:\n user_text = MODE_PROMPTS[mode]\n if lang:\n user_text += f\" Primary language: {lang}.\"\n if extra_prompt:\n user_text += f\" {extra_prompt}\"\n\n content: list[dict[str, Any]] = [\n {\"type\": \"image_url\", \"image_url\": {\"url\": u}} for u in image_urls\n ]\n content.append({\"type\": \"text\", \"text\": user_text})\n\n messages: list[dict[str, Any]] = []\n if mode == \"structured\":\n messages.append({\"role\": \"system\", \"content\": STRUCTURED_SYSTEM})\n messages.append({\"role\": \"user\", \"content\": content})\n return messages\n\n\n# --- Tesseract (local, offline) ---------------------------------------------\n\n# Lightweight mapping from human/--lang hints to tesseract trained-data codes.\n# Tesseract supports compound languages via \"+\": \"eng+chi_sim+chi_tra\".\n_TESSERACT_LANG_MAP: dict[str, str] = {\n \"chinese\": \"chi_sim+chi_tra\",\n \"zh\": \"chi_sim\",\n \"zh-hans\": \"chi_sim\",\n \"zh-cn\": \"chi_sim\",\n \"zh-hant\": \"chi_tra\",\n \"zh-tw\": \"chi_tra\",\n \"english\": \"eng\",\n \"en\": \"eng\",\n \"japanese\": \"jpn\",\n \"ja\": \"jpn\",\n \"日本語\": \"jpn\",\n \"korean\": \"kor\",\n \"ko\": \"kor\",\n \"한국어\": \"kor\",\n \"french\": \"fra\",\n \"fr\": \"fra\",\n \"german\": \"deu\",\n \"de\": \"deu\",\n \"spanish\": \"spa\",\n \"es\": \"spa\",\n \"russian\": \"rus\",\n \"ru\": \"rus\",\n}\n\n\ndef tesseract_available() -> bool:\n return shutil.which(\"tesseract\") is not None\n\n\ndef map_lang_to_tesseract(lang: str | None) -> str:\n \"\"\"Resolve --lang hint to a tesseract -l string. Default: eng+chi_sim.\"\"\"\n if not lang:\n return \"eng+chi_sim\"\n key = lang.strip().lower()\n if key in _TESSERACT_LANG_MAP:\n return _TESSERACT_LANG_MAP[key]\n # If the user passed a raw tesseract code (e.g. \"fra+ita\") we trust it.\n if all(part.isalnum() or part == \"_\" for part in key.replace(\"+\", \"_\")):\n return lang\n return \"eng+chi_sim\"\n\n\ndef resolve_to_local_file(image_arg: str) -> tuple[Path, bool]:\n \"\"\"Resolve a positional IMAGE arg to a path tesseract can read.\n Returns (path, is_temp). Caller should delete the file if is_temp.\"\"\"\n if image_arg == \"-\":\n if sys.stdin.isatty():\n sys.stderr.write(\"error: `-` requested but stdin is a TTY\\n\")\n sys.exit(2)\n data = sys.stdin.buffer.read()\n return _write_temp_image(data, hint_name=None)\n if image_arg.startswith((\"http://\", \"https://\")):\n try:\n with urllib.request.urlopen(image_arg, timeout=60) as resp:\n data = resp.read()\n except (urllib.error.URLError, urllib.error.HTTPError) as e:\n sys.stderr.write(f\"error: failed to fetch {image_arg}: {e}\\n\")\n sys.exit(1)\n return _write_temp_image(data, hint_name=image_arg.rsplit(\"/\", 1)[-1])\n if image_arg.startswith(\"data:\"):\n # data:image/png;base64,XXXX\n try:\n header, b64 = image_arg.split(\",\", 1)\n data = base64.b64decode(b64)\n except (ValueError, base64.binascii.Error) as e:\n sys.stderr.write(f\"error: malformed data URL: {e}\\n\")\n sys.exit(2)\n return _write_temp_image(data, hint_name=None)\n path = Path(image_arg)\n if not path.exists():\n sys.stderr.write(f\"error: image not found: {image_arg}\\n\")\n sys.exit(4)\n return path, False\n\n\ndef _write_temp_image(data: bytes, hint_name: str | None) -> tuple[Path, bool]:\n mime = sniff_mime(data, hint_name)\n ext = mime.split(\"/\")[1].split(\"+\")[0] if \"/\" in mime else \"png\"\n fd, name = tempfile.mkstemp(suffix=f\".{ext}\", prefix=\"mimoskill-ocr-\")\n try:\n with os.fdopen(fd, \"wb\") as f:\n f.write(data)\n except OSError as e:\n sys.stderr.write(f\"error: cannot write temp image: {e}\\n\")\n sys.exit(1)\n return Path(name), True\n\n\ndef run_tesseract(\n *, image_args: list[str], lang_arg: str, extra_prompt: str | None\n) -> str:\n \"\"\"Run tesseract over one or more images, return concatenated text.\"\"\"\n if extra_prompt:\n sys.stderr.write(\n \"note: --prompt is ignored on tesseract engine (no LLM in the loop).\\n\"\n )\n chunks: list[str] = []\n for arg in image_args:\n path, is_temp = resolve_to_local_file(arg)\n try:\n r = subprocess.run(\n [\"tesseract\", str(path), \"-\", \"-l\", lang_arg],\n capture_output=True,\n text=True,\n encoding=\"utf-8\",\n errors=\"replace\",\n )\n except FileNotFoundError:\n sys.stderr.write(\n \"error: tesseract not found on PATH. Install:\\n\"\n \" macOS: brew install tesseract tesseract-lang\\n\"\n \" Ubuntu: sudo apt install tesseract-ocr tesseract-ocr-chi-sim\\n\"\n \" Windows: https://github.com/UB-Mannheim/tesseract/wiki\\n\"\n )\n sys.exit(1)\n finally:\n if is_temp:\n try:\n path.unlink()\n except OSError:\n pass\n if r.returncode != 0:\n sys.stderr.write(f\"tesseract failed for {arg}: {r.stderr.strip()}\\n\")\n sys.exit(1)\n chunks.append(r.stdout.rstrip())\n return \"\\n\\n\".join(c for c in chunks if c)\n\n\n# --- HTTP -------------------------------------------------------------------\n\nPOLLINATIONS_URL = \"https://text.pollinations.ai/openai\"\nPOLLINATIONS_DEFAULT_MODEL = \"openai\" # vision-capable, free, no key\n\n\ndef post(url: str, body: dict[str, Any], api_key: str | None, stream: bool, *, engine: str) -> Any:\n headers = {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\" if stream else \"application/json\",\n \"User-Agent\": \"mimoskill-ocr/0.1\",\n }\n if api_key:\n headers[\"Authorization\"] = f\"Bearer {api_key}\"\n req = urllib.request.Request(\n url,\n method=\"POST\",\n data=json.dumps(body).encode(\"utf-8\"),\n headers=headers,\n )\n try:\n return urllib.request.urlopen(req, timeout=300)\n except urllib.error.HTTPError as e:\n snippet = e.read().decode(\"utf-8\", \"replace\")\n sys.stderr.write(f\"{engine} returned HTTP {e.code}: {snippet}\\n\")\n sys.exit(1)\n except urllib.error.URLError as e:\n sys.stderr.write(f\"connection failed ({engine}): {e}\\n\")\n if engine == \"pollinations\":\n # pollinations.ai is often slow / unreachable from mainland China.\n # Point at the offline & domestic-friendly alternatives.\n sys.stderr.write(\n \" pollinations.ai can be unreachable from mainland China.\\n\"\n \" Alternatives:\\n\"\n \" 1. Install tesseract for offline OCR (text mode only):\\n\"\n \" macOS: brew install tesseract tesseract-lang\\n\"\n \" Ubuntu: sudo apt install tesseract-ocr tesseract-ocr-chi-sim\\n\"\n \" Windows: https://github.com/UB-Mannheim/tesseract/wiki\\n\"\n \" then re-run: python3 ocr.py --engine tesseract --lang Chinese <image>\\n\"\n \" 2. Use MiMo: export MIMO_API_KEY=sk-xxxx ; python3 ocr.py <image>\\n\"\n \" 3. Use a network proxy (set HTTPS_PROXY env var) and retry.\\n\"\n )\n sys.exit(1)\n\n\ndef stream_chat(resp: Any) -> tuple[str, str]:\n \"\"\"Stream SSE chunks; returns (full_content, full_reasoning).\"\"\"\n buf_content: list[str] = []\n buf_reasoning: list[str] = []\n for raw in resp:\n line = raw.decode(\"utf-8\", \"replace\").strip()\n if not line.startswith(\"data:\"):\n continue\n data = line[5:].strip()\n if data == \"[DONE]\":\n break\n try:\n chunk = json.loads(data)\n except json.JSONDecodeError:\n continue\n choice = chunk.get(\"choices\", [{}])[0]\n delta = choice.get(\"delta\", {})\n if r := delta.get(\"reasoning_content\"):\n buf_reasoning.append(r)\n sys.stderr.write(r)\n sys.stderr.flush()\n if c := delta.get(\"content\"):\n buf_content.append(c)\n sys.stdout.write(c)\n sys.stdout.flush()\n sys.stdout.write(\"\\n\")\n return \"\".join(buf_content), \"\".join(buf_reasoning)\n\n\ndef non_stream_chat(resp: Any) -> tuple[str, str, dict[str, Any]]:\n \"\"\"Returns (content, reasoning_content, usage).\"\"\"\n payload = json.loads(resp.read().decode(\"utf-8\"))\n msg = payload[\"choices\"][0][\"message\"]\n return (\n msg.get(\"content\") or \"\",\n msg.get(\"reasoning_content\") or \"\",\n payload.get(\"usage\") or {},\n )\n\n\n# --- CLI --------------------------------------------------------------------\n\ndef main() -> None:\n p = argparse.ArgumentParser(\n description=__doc__.split(\"\\n\", 1)[0],\n formatter_class=argparse.RawDescriptionHelpFormatter,\n )\n p.add_argument(\n \"images\",\n nargs=\"*\",\n metavar=\"IMAGE\",\n help=\"image: local path, http(s) URL, data: URL, or `-` for stdin\",\n )\n p.add_argument(\n \"--mode\",\n choices=list(MODE_PROMPTS),\n default=\"text\",\n help=\"output mode (default: text)\",\n )\n p.add_argument(\n \"--model\",\n default=None,\n help=\"MiMo vision model (default: $MIMO_OCR_MODEL / $MIMO_MODEL if \"\n \"vision-capable / mimo-v2.5). Non-vision models are auto-switched.\",\n )\n p.add_argument(\n \"--lang\",\n default=None,\n help=\"primary language hint, e.g. 'Chinese', 'zh', '日本語'\",\n )\n p.add_argument(\"--max-tokens\", type=int, default=4096)\n p.add_argument(\"--temperature\", type=float, default=0.2)\n p.add_argument(\n \"--engine\",\n choices=[\"auto\", \"mimo\", \"tesseract\", \"pollinations\"],\n default=os.environ.get(\"MIMO_OCR_ENGINE\", \"auto\"),\n help=\"OCR backend. auto = mimo (if MIMO_API_KEY) > tesseract (if \"\n \"installed and --mode=text) > pollinations. tesseract is fully local \"\n \"(no key, no network) — recommended when pollinations is unreachable \"\n \"(e.g. behind GFW). default: %(default)s\",\n )\n p.add_argument(\n \"--base-url\",\n default=os.environ.get(\"MIMO_BASE_URL\", \"https://api.xiaomimimo.com/v1\"),\n help=\"MiMo OpenAI-compat endpoint, ignored when --engine=pollinations \"\n \"(default: %(default)s)\",\n )\n p.add_argument(\n \"--pollinations-model\",\n default=os.environ.get(\"POLLINATIONS_MODEL\", POLLINATIONS_DEFAULT_MODEL),\n help=\"model id when --engine=pollinations (default: %(default)s)\",\n )\n p.add_argument(\n \"--prompt\",\n default=None,\n help=\"extra instruction appended to the mode prompt\",\n )\n p.add_argument(\"--json\", action=\"store_true\", help=\"wrap stdout as JSON envelope\")\n p.add_argument(\"--stream\", action=\"store_true\", help=\"stream the response\")\n args = p.parse_args()\n\n api_key = os.environ.get(\"MIMO_API_KEY\")\n\n # Resolve engine.\n if args.engine == \"mimo\":\n engine = \"mimo\"\n if not api_key:\n sys.stderr.write(\n \"error: --engine mimo requires MIMO_API_KEY.\\n\"\n \" set one at https://platform.xiaomimimo.com/#/console/api-keys\\n\"\n \" OR drop the flag to use auto fallback (tesseract / pollinations).\\n\"\n )\n sys.exit(3)\n elif args.engine == \"tesseract\":\n engine = \"tesseract\"\n if not tesseract_available():\n sys.stderr.write(\n \"error: --engine tesseract requested but tesseract is not on PATH.\\n\"\n \" macOS: brew install tesseract tesseract-lang\\n\"\n \" Ubuntu: sudo apt install tesseract-ocr tesseract-ocr-chi-sim\\n\"\n \" Windows: https://github.com/UB-Mannheim/tesseract/wiki\\n\"\n )\n sys.exit(3)\n if args.mode != \"text\":\n sys.stderr.write(\n f\"error: --engine tesseract only supports --mode text \"\n f\"(you passed --mode {args.mode}). tesseract is an OCR engine, \"\n f\"not an LLM — `describe` / `structured` / `markdown` need a \"\n f\"vision LLM (mimo or pollinations).\\n\"\n )\n sys.exit(2)\n elif args.engine == \"pollinations\":\n engine = \"pollinations\"\n else: # auto\n if api_key:\n engine = \"mimo\"\n elif args.mode == \"text\" and tesseract_available():\n engine = \"tesseract\"\n sys.stderr.write(\n \"[engine] auto -> tesseract (local, no network). \"\n \"Set MIMO_API_KEY for higher quality (mimo-v2.5).\\n\"\n )\n else:\n engine = \"pollinations\"\n hint = \"\"\n if args.mode != \"text\" and tesseract_available():\n hint = (\n \" (tesseract is installed but only handles --mode text;\"\n \" using pollinations for this mode)\"\n )\n sys.stderr.write(\n f\"[engine] auto -> pollinations (free, no key){hint}.\\n\"\n \" Set MIMO_API_KEY for higher quality (mimo-v2.5), \"\n \"or install tesseract for offline OCR.\\n\"\n )\n\n # Resolve images: explicit args, else stdin if not a TTY.\n raw_args = args.images\n if not raw_args and not sys.stdin.isatty():\n raw_args = [\"-\"]\n if not raw_args:\n sys.stderr.write(\n \"error: no image given. Pass one or more IMAGE args or pipe bytes \"\n \"on stdin. See `ocr.py --help`.\\n\"\n )\n sys.exit(2)\n\n # Tesseract branch — fully local, no LLM call, no data-URL conversion.\n if engine == \"tesseract\":\n lang_arg = map_lang_to_tesseract(args.lang)\n sys.stderr.write(\n f\"[ocr] engine=tesseract mode=text lang={lang_arg} images={len(raw_args)}\\n\"\n )\n text = run_tesseract(\n image_args=raw_args, lang_arg=lang_arg, extra_prompt=args.prompt\n )\n if args.json:\n envelope = {\n \"mode\": \"text\",\n \"engine\": \"tesseract\",\n \"model\": f\"tesseract:{lang_arg}\",\n \"images\": len(raw_args),\n \"content\": text,\n \"reasoning_content\": \"\",\n \"usage\": {},\n }\n print(json.dumps(envelope, ensure_ascii=False, indent=2))\n else:\n print(text)\n return\n\n image_urls = [resolve_image_arg(a) for a in raw_args]\n\n if engine == \"mimo\":\n model, note = pick_model(args.model)\n if note:\n sys.stderr.write(note)\n else:\n if args.model:\n sys.stderr.write(\n f\"note: --model is mimo-specific; ignoring on pollinations \"\n f\"(use --pollinations-model instead).\\n\"\n )\n model = args.pollinations_model\n\n sys.stderr.write(\n f\"[ocr] engine={engine} mode={args.mode} model={model} images={len(image_urls)}\\n\"\n )\n\n messages = build_messages(\n mode=args.mode,\n image_urls=image_urls,\n lang=args.lang,\n extra_prompt=args.prompt,\n )\n\n body: dict[str, Any] = {\n \"model\": model,\n \"messages\": messages,\n \"temperature\": args.temperature,\n \"stream\": args.stream,\n }\n if engine == \"mimo\":\n # MiMo's quirk: max_completion_tokens, not max_tokens.\n body[\"max_completion_tokens\"] = args.max_tokens\n url = args.base_url.rstrip(\"/\") + \"/chat/completions\"\n auth = api_key\n else:\n body[\"max_tokens\"] = args.max_tokens\n url = POLLINATIONS_URL\n auth = None\n\n resp = post(url, body, auth, args.stream, engine=engine)\n\n if args.stream:\n content, reasoning = stream_chat(resp)\n usage: dict[str, Any] = {}\n else:\n content, reasoning, usage = non_stream_chat(resp)\n if reasoning:\n sys.stderr.write(f\"[reasoning]\\n{reasoning}\\n[/reasoning]\\n\\n\")\n if args.json:\n envelope = {\n \"mode\": args.mode,\n \"model\": model,\n \"images\": len(image_urls),\n \"content\": content,\n \"reasoning_content\": reasoning,\n \"usage\": usage,\n }\n print(json.dumps(envelope, ensure_ascii=False, indent=2))\n else:\n print(content)\n return\n\n # Streaming + --json: emit envelope after the streamed body.\n if args.json:\n envelope = {\n \"mode\": args.mode,\n \"model\": model,\n \"images\": len(image_urls),\n \"content\": content,\n \"reasoning_content\": reasoning,\n \"usage\": {},\n }\n sys.stdout.write(\"\\n---\\n\")\n sys.stdout.write(json.dumps(envelope, ensure_ascii=False, indent=2))\n sys.stdout.write(\"\\n\")\n\n\nif __name__ == \"__main__\":\n main()\n"}}, "web/src/i18n/locales/en-US/setup.json": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785674311.324539, "n": 2722, "h": "2abc7c188ac98ce6", "c": "{\n \"title\": \"Setup Guide\",\n \"intro\": \"Wire the running mimo2codex into Codex CLI / desktop. The snippets below are generated live from the current host:port — copy and paste as-is.\",\n \"providerLabel\": \"Provider\",\n \"currentModel\": \"Current model\",\n \"defaultTag\": \"(default)\",\n \"tab\": {\n \"auth\": \"Option 1 · auth.json + config.toml (recommended)\",\n \"preserve\": \"Keep ChatGPT login · config.toml only\",\n \"envkey\": \"Option 2 · env-key (CLI only)\",\n \"ccswitch\": \"Option 3 · cc-switch\"\n },\n \"auth\": {\n \"warn\": \"This overwrites your existing <1>{{path}}</1>. If you also sign into a real OpenAI account in Codex, use option 2 (env-key) or option 3 (cc-switch) instead.\",\n \"step1\": \"Step 1 · Write {{path}}\",\n \"step2\": \"Step 2 · Append to {{path}}\",\n \"step3Title\": \"Step 3 · Fully quit and restart Codex\",\n \"step3Body\": \"Codex desktop must fully quit (click Quit in the tray/menubar icon) so it re-reads auth.json on next launch. Then choose <1>{{label}}</1> as the provider inside Codex.\"\n },\n \"preserve\": {\n \"info\": \"Keeps your existing <1>{{path}}</1> (your Sign in with ChatGPT login) completely untouched — only config.toml is changed to route the model through mimo2codex. Use this so OpenAI's official Codex mobile/remote keeps working.\",\n \"tomlTitle\": \"Append / merge into {{path}}\",\n \"note\": \"Your ChatGPT OAuth token stays in auth.json and is attached to proxy requests via requires_openai_auth = true; mimo2codex ignores it and forwards to your real upstream. To drive this Codex from your phone, set up OpenAI's official 'Codex mobile' in the desktop app — it needs the very login this option preserves.\"\n },\n \"envkey\": {\n \"info\": \"This option leaves your existing <1>{{path}}</1> untouched and authenticates via the <3>MIMO2CODEX_KEY</3> env var. <5>CLI only</5> — Codex desktop launched from Finder/Start Menu doesn't inherit shell env.\",\n \"tomlTitle\": \"config.toml snippet\",\n \"envTitle\": \"Set the env var\",\n \"shellWin\": \"PowerShell\",\n \"shellMac\": \"macOS / Linux (bash/zsh)\",\n \"shellLinux\": \"Linux (bash/zsh)\",\n \"note\": \"Any non-empty string works — mimo2codex doesn't validate inbound credentials. Your real upstream keys (MIMO_API_KEY, DS_API_KEY, ...) stay in the environment of the machine running mimo2codex.\"\n },\n \"ccswitch\": {\n \"info\": \"<1>cc-switch</1> is a Codex provider switcher desktop app. Open it, <3>Add Provider → Codex tab → Custom</3>, paste each snippet below into the matching input.\",\n \"authTitle\": \"auth.json input\",\n \"tomlTitle\": \"config.toml input\",\n \"note\": \"OPENAI_API_KEY can be any non-empty string — mimo2codex doesn't validate inbound credentials. Real upstream keys stay on the mimo2codex host.\"\n }\n}\n"}}, "web/src/i18n/locales/zh-CN/setup.json": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785674311.9115188, "n": 1886, "h": "369678cc8f652f4c", "c": "{\n \"title\": \"对接指引\",\n \"intro\": \"把当前正在运行的 mimo2codex 接到 Codex CLI / 桌面端。下方片段是按本机 host:port 实时生成的,复制即用。\",\n \"providerLabel\": \"Provider\",\n \"currentModel\": \"当前选中模型\",\n \"defaultTag\": \"(默认)\",\n \"tab\": {\n \"auth\": \"方法 1:修改 auth.json + config.toml(推荐)\",\n \"preserve\": \"保留 ChatGPT 登录 · 仅改 config.toml\",\n \"envkey\": \"方法 2:env-key(仅 Codex CLI)\",\n \"ccswitch\": \"方法 3:cc-switch\"\n },\n \"auth\": {\n \"warn\": \"这个方法会覆盖你现有的 <1>{{path}}</1>。如果你同时用 Codex 登录真实的 OpenAI 账号,请改用方法 2(env-key)或方法 3(cc-switch)。\",\n \"step1\": \"步骤 1 · 写入 {{path}}\",\n \"step2\": \"步骤 2 · 追加到 {{path}}\",\n \"step3Title\": \"步骤 3 · 完全退出并重启 Codex\",\n \"step3Body\": \"Codex 桌面端必须完全退出(任务栏/菜单栏图标点 Quit),重新启动后才能读取新的 auth.json。然后在 Codex 里选择 <1>{{label}}</1> 这个 provider。\"\n },\n \"preserve\": {\n \"info\": \"完全保留你现有的 <1>{{path}}</1>(也就是你的 Sign in with ChatGPT 登录)不动 —— 只改 config.toml,把模型路由到 mimo2codex。想让 OpenAI 官方的 Codex 手机端/远程功能继续可用,就用这个方法。\",\n \"tomlTitle\": \"追加 / 合并到 {{path}}\",\n \"note\": \"你的 ChatGPT OAuth token 留在 auth.json 里,通过 requires_openai_auth = true 作为 bearer 附到代理请求上;mimo2codex 忽略它并转发到你的真实上游。想用手机操作这台 Codex,在桌面端按 OpenAI 官方流程设置「Codex 手机端」即可 —— 它正需要这个方法所保留的登录。\"\n },\n \"envkey\": {\n \"info\": \"这个方法保留你现有的 <1>{{path}}</1> 不动,通过环境变量 <3>MIMO2CODEX_KEY</3> 鉴权。<5>仅 Codex CLI 生效</5> —— 桌面端从 Finder/开始菜单启动时不继承 shell env。\",\n \"tomlTitle\": \"config.toml 片段\",\n \"envTitle\": \"设置环境变量\",\n \"shellWin\": \"PowerShell\",\n \"shellMac\": \"macOS / Linux (bash/zsh)\",\n \"shellLinux\": \"Linux (bash/zsh)\",\n \"note\": \"值可以是任意非空字符串 — mimo2codex 不验证入站凭据。你的真实上游 key(MIMO_API_KEY / DS_API_KEY 等)只保留在运行 mimo2codex 这台机器的环境里。\"\n },\n \"ccswitch\": {\n \"info\": \"<1>cc-switch</1> 是一个 Codex provider 切换器桌面应用。打开它,<3>Add Provider → Codex tab → Custom</3>,把下面两段粘到对应输入框。\",\n \"authTitle\": \"auth.json 输入框\",\n \"tomlTitle\": \"config.toml 输入框\",\n \"note\": \"OPENAI_API_KEY 可以是任意非空字符串 — mimo2codex 不验证入站凭据。真实上游 key 保留在运行 mimo2codex 这台机器的环境里。\"\n }\n}\n"}}, "web/src/i18n/locales/zh-CN/auth.json": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785674313.7800825, "n": 7913, "h": "ec7004361f314adf", "c": "{\n \"login\": {\n \"tagline\": \"登录 mimo2codex\",\n \"username\": \"用户名\",\n \"password\": \"密码\",\n \"usernameRequired\": \"请输入用户名\",\n \"passwordRequired\": \"请输入密码\",\n \"submit\": \"登录\",\n \"noAccount\": \"还没有账号?\",\n \"signup\": \"注册新账号\",\n \"orDivider\": \"或\",\n \"githubBtn\": \"使用 GitHub 登录\",\n \"giteeBtn\": \"使用 Gitee 登录\"\n },\n \"bootstrap\": {\n \"title\": \"首次启动初始化\",\n \"subtitle\": \"创建首位管理员账号——填入账号和密码即可,提交即拥有管理员权限。\",\n \"username\": \"管理员账号\",\n \"usernameRequired\": \"请填写账号\",\n \"displayName\": \"显示名(可选)\",\n \"displayNamePlaceholder\": \"在页面右上显示,默认与账号一致\",\n \"password\": \"密码\",\n \"passwordRequired\": \"请输入密码\",\n \"passwordTooShort\": \"密码长度至少 8 位\",\n \"submit\": \"创建管理员\"\n },\n \"register\": {\n \"title\": \"注册账号\",\n \"subtitle\": \"注册后管理员审核即可登录。\",\n \"subtitleOpen\": \"注册后即可登录使用本服务。\",\n \"username\": \"用户名\",\n \"usernameRequired\": \"请填写用户名\",\n \"displayName\": \"显示名(可选)\",\n \"displayNamePlaceholder\": \"页面顶部显示用,留空 = 与用户名一致\",\n \"password\": \"密码\",\n \"passwordRequired\": \"请输入密码\",\n \"passwordTooShort\": \"密码长度至少 8 位\",\n \"submit\": \"注册\",\n \"backToLogin\": \"已有账号?返回登录\",\n \"disabledHint\": \"管理员未开放公开注册,请联系管理员添加账号。\",\n \"successTitle\": \"注册成功\",\n \"successDesc\": \"已自动登录。\"\n },\n \"header\": {\n \"account\": \"我的账户\",\n \"users\": \"用户管理\",\n \"logout\": \"退出登录\"\n },\n \"account\": {\n \"title\": \"我的账户\",\n \"description\": \"管理你的代理 API key(Codex 调用本代理时用)和 BYOK 上游 key(代理回调 MiMo / DeepSeek 等用)。\",\n \"apiKeys\": {\n \"title\": \"我的 API Key\",\n \"subtitle\": \"本代理向 Codex 暴露 OpenAI 兼容接口时使用。每个 key 仅在创建时返回明文一次,请妥善保存。\",\n \"name\": \"名称\",\n \"prefix\": \"Key 前缀\",\n \"created\": \"创建时间\",\n \"lastUsed\": \"上次使用\",\n \"status\": \"状态\",\n \"active\": \"有效\",\n \"revoked\": \"已吊销\",\n \"revoke\": \"吊销\",\n \"revokeConfirm\": \"确定吊销该 key 吗?\",\n \"namePlaceholder\": \"Key 名称(如 laptop / codex-cli)\",\n \"create\": \"新建 Key\",\n \"createdAlert\": \"立即复制,关闭后无法再次查看\",\n \"copy\": \"复制\",\n \"dismiss\": \"关闭\",\n \"copied\": \"已复制\",\n \"empty\": \"—\"\n },\n \"byok\": {\n \"title\": \"我的上游 Key(BYOK)\",\n \"subtitle\": \"设置后你发起的请求会用你自己的上游 key 走,账单走到你的账号;未设置则使用部署者放在 env 里的共享 key。\",\n \"encrypted\": \"加密存储,保存后无法再次查看明文,请保管好原始 key。\",\n \"configured\": \"已配置\",\n \"usingShared\": \"正在使用部署共享的 key\",\n \"set\": \"设置 BYOK\",\n \"replace\": \"替换\",\n \"save\": \"保存\",\n \"cancel\": \"取消\",\n \"removeConfirm\": \"移除该 provider 的 BYOK?\",\n \"updated\": \"更新于\",\n \"placeholder\": \"{{envKey}} 的值\",\n \"saved\": \"已保存\"\n },\n \"oauth\": {\n \"title\": \"OAuth 配置(管理员)\",\n \"description\": \"配置 OAuth 凭据后,登录页会展示 Gitee / GitHub 登录按钮。回调 URL 必须和 provider 后台填的完全一致(通常是 `https://<你的域名>/oauth/callback/<provider>`)。\",\n \"enabled\": \"已启用\",\n \"disabled\": \"未启用\",\n \"clientId\": \"Client ID\",\n \"clientSecret\": \"Client Secret\",\n \"clientSecretKeep\": \"Client Secret(留空 = 保留现有)\",\n \"callbackUrl\": \"回调 URL\",\n \"callbackPlaceholder\": \"https://你的域名/oauth/callback/{{provider}}\",\n \"enable\": \"启用\",\n \"save\": \"保存\",\n \"remove\": \"删除\",\n \"removeConfirm\": \"删除 {{provider}} OAuth 配置?\",\n \"saved\": \"已保存\"\n }\n },\n \"codex\": {\n \"exportLocal\": \"导出到本地\",\n \"exportTip\": \"下载当前生效的 auth.json + config.toml + 一键应用脚本到本机\",\n \"exportTitle\": \"导出当前配置\",\n \"exportDesc\": \"下载当前生效的 auth.json / config.toml,以及一个本机执行的小脚本(会自动备份本地原有文件)。\",\n \"exportSuccess\": \"已下载,本机跑 apply.sh / apply.ps1 即可生效\",\n \"exportDoneTitle\": \"导出完成 — 下一步怎么用\",\n \"exportDoneIntro\": \"已下载 4 个文件到你的浏览器默认下载目录:\",\n \"exportFile1\": \"auth.json — Codex 用来鉴权,里面 OPENAI_API_KEY 字段是占位符,需要你手动替换成 m2c key\",\n \"exportFile2\": \"config.toml — Codex 客户端配置(base_url 指向 mimo2codex)\",\n \"exportFile3\": \"apply-*.sh — macOS / Linux 用,bash 直接跑\",\n \"exportFile4\": \"apply-*.ps1 — Windows PowerShell 用,右键运行\",\n \"exportSteps\": \"执行步骤\",\n \"exportStep0\": \"去「我的账户」→ 我的 API Key,新建一把 key 并复制明文\",\n \"exportStep0Action\": \"去新建 Key\",\n \"exportStep1\": \"打开 auth.json,把 `OPENAI_API_KEY` 字段的 `mimo2codex-local` 替换成上一步复制的 m2c key\",\n \"exportStep1b\": \"把 4 个文件放到同一个目录\",\n \"exportStep2Mac\": \"Mac / Linux:`bash apply-xxx.sh`\",\n \"exportStep2Win\": \"Windows:`powershell -ExecutionPolicy Bypass -File apply-xxx.ps1`\",\n \"exportStep3\": \"脚本会自动备份你本机的 ~/.codex/auth.json|config.toml 为 *.bak.<时间戳>,然后写入新文件\",\n \"exportStep4\": \"重启 Codex(关闭并重新打开 CLI / 桌面端)\",\n \"keyEmphasisTitle\": \"关于 m2c key\",\n \"keyEmphasisServer\": \"Server 模式下,下载的 auth.json 里 `OPENAI_API_KEY` 是占位符 `mimo2codex-local`,并不是可用的 key。请去「我的账户」→「我的 API Key」新建一把 key,复制明文(创建时仅展示一次),然后手动替换 auth.json 里的占位符。\",\n \"keyEmphasisServerMore\": \"该 key 在「我的账户」里可随时查看 / 吊销,**请勿与他人共享**。\",\n \"keyEmphasisLocal\": \"本地模式下,auth.json 里的 OPENAI_API_KEY 是占位符(mimo2codex-local),mimo2codex 不校验它的内容,可以保持原样。\",\n \"importLocal\": \"从本地导入\",\n \"importTip\": \"上传一份现有的 auth.json + config.toml,存为新的历史条目(可随时回撤)\",\n \"importTitle\": \"从本地导入配置\",\n \"importDesc\": \"把本机现有的 ~/.codex/auth.json + config.toml 粘贴进来,作为一条新的历史条目存档。本地模式会同时写入到 ~/.codex/;server 模式仅记录。任何时候都可以从历史里点 Bundle 回撤。\",\n \"importGuideTitle\": \"在哪里找到这两个文件?\",\n \"importGuideMac\": \"macOS / Linux:`~/.codex/auth.json` 和 `~/.codex/config.toml`\",\n \"importGuideWin\": \"Windows:`%USERPROFILE%\\\\.codex\\\\auth.json` 和 `%USERPROFILE%\\\\.codex\\\\config.toml`\",\n \"importGuideTip\": \"如果设了 CODEX_HOME 环境变量,路径以它为准。\",\n \"importKeyNote\": \"导入的 auth.json 里的 `OPENAI_API_KEY` 字段会原样存档。如果你想用 mimo2codex 的鉴权,请在「我的账户」里新建一把 m2c key 并把它填到这里的 auth.json 中(格式:`{\\\"OPENAI_API_KEY\\\": \\\"m2c_xxxxxxxx\\\"}`)。\",\n \"importAuthLabel\": \"auth.json 内容\",\n \"importTomlLabel\": \"config.toml 内容\",\n \"importProviderLabel\": \"Provider(可选,用于历史展示)\",\n \"importModelLabel\": \"Model(可选,用于历史展示)\",\n \"importNoteLabel\": \"备注(可选)\",\n \"importNotePlaceholder\": \"比如:来自 macbook / 出差前\",\n \"importSubmit\": \"导入并存档\",\n \"importSuccess\": \"已记录历史条目 #{{id}}\",\n \"importInvalidJson\": \"auth.json 不是合法 JSON\",\n \"viewGuide\": \"查看完整教程\",\n \"close\": \"关闭\",\n \"exportConfirmDownload\": \"确认下载 4 个文件\",\n \"exportDownloadedNote\": \"文件已下载到浏览器默认下载目录。请按上面的步骤在本机执行。\",\n \"importNextStep\": \"我已了解,开始填写\",\n \"importBackToGuide\": \"返回教程\",\n \"cancel\": \"取消\"\n },\n \"history\": {\n \"title\": \"Codex 配置历史\",\n \"tabLabel\": \"历史\",\n \"serverMode\": \"Server 模式\",\n \"localMode\": \"本地模式\",\n \"refresh\": \"刷新\",\n \"descServer\": \"Server 模式:点 Bundle 下载 auth.json + config.toml + 一个本机执行的小脚本(自动备份现有文件 + 写入新文件)。\",\n \"descLocal\": \"本地模式:apply 时已直接写入 ~/.codex/,History 仅做追溯记录。\",\n \"policy\": \"保留每用户最早的 initial 快照 + 最近 10 条 apply / restore。\",\n \"when\": \"时间\",\n \"kind\": \"类型\",\n \"provider\": \"Provider\",\n \"model\": \"模型\",\n \"note\": \"备注\",\n \"bundle\": \"Bundle\",\n \"deleteConfirm\": \"删除该历史条目?\",\n \"bundleSuccess\": \"Bundle 已下载——本机执行 apply.sh 或 apply.ps1 即可应用\"\n },\n \"users\": {\n \"title\": \"用户管理\",\n \"navLabel\": \"用户管理\",\n \"description\": \"管理员可以在这里查看所有注册用户、用量统计,禁用 / 启用账号,调整管理员角色。\",\n \"table\": {\n \"username\": \"用户名\",\n \"displayName\": \"显示名\",\n \"role\": \"角色\",\n \"status\": \"状态\",\n \"requests\": \"请求数\",\n \"totalTokens\": \"总 Token\",\n \"lastActivity\": \"上次活动\",\n \"created\": \"注册时间\",\n \"actions\": \"操作\"\n },\n \"role\": {\n \"admin\": \"管理员\",\n \"user\": \"用户\"\n },\n \"status\": {\n \"active\": \"正常\",\n \"disabled\": \"已禁用\"\n },\n \"actions\": {\n \"disable\": \"禁用\",\n \"enable\": \"启用\",\n \"makeAdmin\": \"升管理员\",\n \"revokeAdmin\": \"撤销管理员\",\n \"resetPassword\": \"重置密码\",\n \"disableConfirm\": \"禁用 {{username}}?该用户将无法登录、API key 也会停用。\",\n \"enableConfirm\": \"重新启用 {{username}}?\",\n \"makeAdminConfirm\": \"授予 {{username}} 管理员权限?\",\n \"revokeAdminConfirm\": \"撤销 {{username}} 的管理员权限?\",\n \"newPassword\": \"新密码(至少 8 位)\",\n \"newPasswordPlaceholder\": \"输入新密码后确认\"\n },\n \"register\": {\n \"title\": \"公开注册\",\n \"description\": \"开启后未登录用户可通过 /admin/register 自主注册账号。关闭则只能由管理员通过这个页面创建。\",\n \"switchLabel\": \"允许公开注册\",\n \"saved\": \"已更新\"\n },\n \"create\": {\n \"title\": \"新建用户\",\n \"username\": \"用户名\",\n \"password\": \"初始密码\",\n \"displayName\": \"显示名(可选)\",\n \"isAdmin\": \"授予管理员\",\n \"submit\": \"创建\",\n \"success\": \"已创建用户 {{username}}\"\n },\n \"saved\": \"已更新\"\n }\n}\n"}}, "web/src/i18n/locales/en-US/providers.json": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785674314.3760476, "n": 8275, "h": "e27c9339695edb75", "c": "{\n \"title\": \"Providers\",\n \"intro\": \"Manage generic OpenAI-compatible / Responses-passthrough providers from providers.json. Changes require a <strong>restart of mimo2codex</strong> for the runtime registry to reload. See <1>generic-providers docs</1> for field details.\",\n \"file\": {\n \"title\": \"File location\",\n \"explicit\": \"specified via MIMO2CODEX_PROVIDERS_FILE\",\n \"notCreated\": \"not created yet — will be created on save\",\n \"notEditable\": \"Not editable: {{notice}}\",\n \"currentError\": \"Current file has issues: {{error}} (you can still overwrite from the UI)\"\n },\n \"action\": {\n \"create\": \"+ Add Provider\",\n \"rawJson\": \"Edit raw JSON\",\n \"refresh\": \"Refresh\",\n \"edit\": \"Edit\",\n \"delete\": \"Delete\"\n },\n \"table\": {\n \"columns\": {\n \"id\": \"ID\",\n \"displayName\": \"Display name\",\n \"baseUrl\": \"baseUrl\",\n \"defaultModel\": \"Default model\",\n \"wireApi\": \"wireApi\",\n \"models\": \"Declared models\",\n \"ops\": \"Actions\"\n },\n \"shortcutTag\": \"shortcut {{value}}\",\n \"modelCountTag\": \"{{count}}\",\n \"passthroughTag\": \"passthrough\",\n \"empty\": \"No generic providers yet. Click '+ Add Provider' to start.\"\n },\n \"saved\": {\n \"withRestart\": \"Saved to {{path}} — restart mimo2codex to apply\",\n \"withoutRestart\": \"Saved to {{path}}\"\n },\n \"deleteConfirm\": \"Delete provider \\\"{{id}}\\\"?\",\n \"form\": {\n \"titleCreate\": \"Add Provider\",\n \"titleEdit\": \"Edit {{name}}\",\n \"fields\": {\n \"id\": \"ID\",\n \"idHint\": \"Letters / digits / - / _ only; must not conflict with builtin mimo / deepseek\",\n \"idPlaceholder\": \"qwen / kimi / my-vllm\",\n \"displayName\": \"Display name\",\n \"displayNamePlaceholder\": \"defaults to id if empty\",\n \"shortcut\": \"shortcut\",\n \"shortcutPlaceholder\": \"defaults to id if empty\",\n \"shortcutHint\": \"CLI uses --model <shortcut> to switch the default provider\",\n \"baseUrl\": \"baseUrl\",\n \"baseUrlPlaceholder\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"baseUrlHint\": \"Upstream base URL; do <strong>not</strong> include /chat/completions\",\n \"envKey\": \"envKey\",\n \"envKeyPlaceholder\": \"QWEN_API_KEY\",\n \"envKeyHint\": \"Which environment variable holds the API key\",\n \"defaultModel\": \"defaultModel\",\n \"defaultModelPlaceholder\": \"qwen3-max\",\n \"wireApi\": \"wireApi\",\n \"wireApiChat\": \"chat\",\n \"wireApiChatSub\": \"Default · translates into Chat Completions\",\n \"wireApiResponses\": \"responses\",\n \"wireApiResponsesSub\": \"Passthrough · no protocol translation\",\n \"features\": \"features\",\n \"forceParallelToolCalls\": \"forceParallelToolCalls\",\n \"forceParallelToolCallsSub\": \"Allow multiple tool calls per turn — helps agentic flows\",\n \"webSearch\": \"webSearch\",\n \"webSearchSub\": \"Only meaningful for upstreams with builtin web_search\",\n \"strictCompat\": \"Strict OpenAI compat\",\n \"strictCompatHint\": \"Sanitizers for strict OpenAI-compatible upstreams that reject non-standard fields. MiniMax is the first beneficiary; the switches also apply to any other strict gateway (private deployments, China-hosted LLM gateways, etc.) that rejects strict:null, content:null, stream_options, multiple system messages, and similar. All off by default — existing providers are unaffected.\",\n \"minimaxCompat\": \"minimaxCompat (preset)\",\n \"minimaxCompatSub\": \"Enables 5 switches by default: dropNullStrict / dropNullContent / dropToolChoiceAuto / mergeSystemMessages / extractThinkTags. dropStreamOptions / dropParallelToolCalls are OpenAI standard fields and are NOT included in the preset — enable them individually only if the upstream actually rejects them\",\n \"forceDefaultModel\": \"forceDefaultModel\",\n \"forceDefaultModelSub\": \"When models is empty, rewrite any unknown client model id (e.g. \\\"gpt-5.5\\\") to this provider's defaultModel. Required for MiniMax env-var single-instance; useful for other strict upstreams too\",\n \"strictCompatSubswitches\": \"Fine-grained switches (the preset already enables 5 of these; listed here so you can toggle individually or enable the two not included in the preset):\",\n \"dropNullStrictSub\": \"Strip tools[*].function.strict === null (included in preset)\",\n \"dropNullContentSub\": \"Strip assistant content === null field (included in preset)\",\n \"dropToolChoiceAutoSub\": \"Strip tool_choice === \\\"auto\\\" — auto is the default value (included in preset)\",\n \"dropStreamOptionsSub\": \"Strip stream_options. ⚠️ Upstream stops returning usage → admin DB token / cache-hit chart go to 0. NOT in preset — enable only if upstream actually 400s on it\",\n \"dropParallelToolCallsSub\": \"Strip parallel_tool_calls. OpenAI standard field, NOT in preset — enable only when upstream explicitly rejects it\",\n \"mergeSystemMessagesSub\": \"Merge multiple role:\\\"system\\\" messages into one leading entry (included in preset)\",\n \"extractThinkTagsSub\": \"Response side: extract <think>...</think> blocks from content into reasoning_content so they don't leak as plain assistant text in Codex (required for MiniMax M1/M2/M3, some GLM/Qwen-thinking models; included in preset)\",\n \"dropResponseFormatSub\": \"Strip response_format field (required for gateways that don't list it, e.g. SenseNova 6.7 Flash-Lite; MiniMax actually accepts it — NOT in preset)\",\n \"dropNonFunctionToolsSub\": \"Filter tools[] entries whose type is not function/custom (SenseNova schema only accepts these two; Claude Code/Codex's web_search / file_search builtins get rejected with 400)\",\n \"dropReasoningEffortSub\": \"Strip the reasoning_effort field (Kimi and similar upstreams don't recognize it — they use thinking:{enabled/disabled} instead. When admin UI's \\\"Force high reasoning effort\\\" is ON, mimo2codex would otherwise send reasoning_effort=\\\"high\\\" to every upstream; enable this for Kimi-style providers to keep that out)\",\n \"enhanceErrorPresetTitle\": \"Vendor quick-preset\",\n \"enhanceErrorPresetSub\": \"Picking sensenova / minimax does two things: (1) immediately apply that vendor's recommended fine-grained switches above (existing checks WILL be overwritten — this counts as an explicit user choice); (2) translate opaque 400 markers (e.g. sensenova's \\\"Errors in message queue response\\\") into readable diagnostic hints. Picking none disables both and leaves your current switches alone.\",\n \"enhanceErrorPresetNone\": \"none\",\n \"presetAutoApplied\": \"🎯 Detected {{name}} — applied recommended features (existing checked switches are preserved)\",\n \"presetClear\": \"Clear and customize\",\n \"docsUrl\": \"docsUrl\",\n \"docsUrlHint\": \"Optional — shown in the 'missing key' error as a link\"\n },\n \"models\": {\n \"title\": \"Declared models (optional)\",\n \"hint\": \"Leave empty for <strong>passthrough</strong> — any model id the client sends is forwarded. If you do declare models, only ids (and aliases) listed here will be routed to this provider via byClientModel; declaring contextWindow / maxOutputTokens lets print-config emit accurate toml fields.\",\n \"idPlaceholder\": \"model id (required, e.g. qwen3-max)\",\n \"contextPlaceholder\": \"contextWindow\",\n \"maxOutputPlaceholder\": \"maxOutputTokens\",\n \"addBtn\": \"+ Add model\",\n \"vision\": \"vision\",\n \"reasoning\": \"reasoning\",\n \"webSearch\": \"web search\"\n },\n \"validate\": {\n \"idRequired\": \"id is required\",\n \"idReserved\": \"id \\\"{{id}}\\\" conflicts with a builtin provider — pick another\",\n \"idFormat\": \"id \\\"{{id}}\\\" must start with a letter/digit; only letters, digits, - and _ are allowed (no spaces)\",\n \"idDup\": \"A provider with id \\\"{{id}}\\\" already exists — pick another\",\n \"baseUrlRequired\": \"baseUrl is required\",\n \"envKeyRequired\": \"envKey is required\",\n \"defaultModelRequired\": \"defaultModel is required\"\n }\n },\n \"rawJson\": {\n \"title\": \"Edit raw JSON\",\n \"hint\": \"Edit providers.json verbatim. Save is disabled while JSON is invalid.\",\n \"valid\": \"Valid JSON\",\n \"invalid\": \"Invalid JSON\",\n \"pasteBtn\": \"Paste from clipboard\",\n \"pasteFailed\": \"Cannot read clipboard\",\n \"parseError\": \"JSON parse failed: {{message}}\",\n \"notObject\": \"JSON must be an object\",\n \"missingProviders\": \"JSON must have a top-level 'providers' array\"\n }\n}\n"}}, "web/src/i18n/locales/zh-CN/providers.json": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785674314.379699, "n": 5949, "h": "d1f28bcc08965937", "c": "{\n \"title\": \"通用 Provider\",\n \"intro\": \"在这里管理 providers.json 里的通用 OpenAI 兼容 / Responses 直透 provider。修改后需要 <strong>重启 mimo2codex</strong> 让运行时 registry 加载新配置。详细字段说明见 <1>generic-providers 文档</1>。\",\n \"file\": {\n \"title\": \"文件位置\",\n \"explicit\": \"通过 MIMO2CODEX_PROVIDERS_FILE 指定\",\n \"notCreated\": \"尚未创建——保存后自动新建\",\n \"notEditable\": \"不可编辑:{{notice}}\",\n \"currentError\": \"当前文件有问题:{{error}}(仍可在 UI 里重写覆盖)\"\n },\n \"action\": {\n \"create\": \"添加 Provider\",\n \"rawJson\": \"编辑原始 JSON\",\n \"refresh\": \"刷新\",\n \"edit\": \"编辑\",\n \"delete\": \"删除\"\n },\n \"table\": {\n \"columns\": {\n \"id\": \"ID\",\n \"displayName\": \"显示名\",\n \"baseUrl\": \"baseUrl\",\n \"defaultModel\": \"默认模型\",\n \"wireApi\": \"wireApi\",\n \"models\": \"声明模型\",\n \"ops\": \"操作\"\n },\n \"shortcutTag\": \"短码 {{value}}\",\n \"modelCountTag\": \"{{count}} 个\",\n \"passthroughTag\": \"任意透传\",\n \"empty\": \"还没有通用 provider。点击「添加 Provider」开始。\"\n },\n \"saved\": {\n \"withRestart\": \"已保存到 {{path}} — 重启 mimo2codex 让配置生效\",\n \"withoutRestart\": \"已保存到 {{path}}\"\n },\n \"deleteConfirm\": \"删除 provider \\\"{{id}}\\\"?\",\n \"form\": {\n \"titleCreate\": \"添加 Provider\",\n \"titleEdit\": \"编辑 {{name}}\",\n \"fields\": {\n \"id\": \"ID\",\n \"idHint\": \"仅字母数字 / - / _,不能与内置 mimo / deepseek 冲突\",\n \"idPlaceholder\": \"qwen / kimi / my-vllm\",\n \"displayName\": \"显示名\",\n \"displayNamePlaceholder\": \"留空则用 id\",\n \"shortcut\": \"shortcut\",\n \"shortcutPlaceholder\": \"留空则用 id\",\n \"shortcutHint\": \"CLI 启动时用 --model <shortcut> 切换默认 provider\",\n \"baseUrl\": \"baseUrl\",\n \"baseUrlPlaceholder\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"baseUrlHint\": \"上游 base URL;<strong>不要</strong>带 /chat/completions 后缀\",\n \"envKey\": \"envKey\",\n \"envKeyPlaceholder\": \"QWEN_API_KEY\",\n \"envKeyHint\": \"从哪个环境变量读取 API key\",\n \"defaultModel\": \"defaultModel\",\n \"defaultModelPlaceholder\": \"qwen3-max\",\n \"wireApi\": \"wireApi\",\n \"wireApiChat\": \"chat\",\n \"wireApiChatSub\": \"默认 · 翻译为 Chat Completions\",\n \"wireApiResponses\": \"responses\",\n \"wireApiResponsesSub\": \"直透 · 不做协议翻译\",\n \"features\": \"features\",\n \"forceParallelToolCalls\": \"forceParallelToolCalls\",\n \"forceParallelToolCallsSub\": \"一回合多个工具调用,缓解 agentic 编程\",\n \"webSearch\": \"webSearch\",\n \"webSearchSub\": \"仅对支持 builtin web_search 的上游有意义\",\n \"strictCompat\": \"严格 OpenAI 兼容\",\n \"strictCompatHint\": \"为拒绝非标准字段的严格 OpenAI 兼容上游(MiniMax 是首位受益者;任何不接受 strict:null / content:null / stream_options / 多条 system 等字段的国产网关、私有部署模型也都适用)准备的一组 sanitizer。默认全部关闭——任何已经在用的 provider 都不会受影响。\",\n \"minimaxCompat\": \"minimaxCompat(一键预设)\",\n \"minimaxCompatSub\": \"默认包揽 dropNullStrict / dropNullContent / dropToolChoiceAuto / mergeSystemMessages / extractThinkTags 共 5 个子开关。dropStreamOptions / dropParallelToolCalls 是 OpenAI 标准字段、不在一键预设里——仅在上游真的拒绝时再单独勾选下面对应项\",\n \"forceDefaultModel\": \"forceDefaultModel\",\n \"forceDefaultModelSub\": \"models 留空时,客户端发的未知模型名(如 \\\"gpt-5.5\\\")自动改写到本 provider 的 defaultModel。MiniMax 用 env-var 单实例时必开;其他严格上游同样适用\",\n \"strictCompatSubswitches\": \"细粒度子开关(一键预设已包揽 5 个;这里列出全部 7 个,方便单独控制或开未被包揽的两个):\",\n \"dropNullStrictSub\": \"删 tools[*].function.strict === null(一键预设已含)\",\n \"dropNullContentSub\": \"删 assistant content === null 字段(一键预设已含)\",\n \"dropToolChoiceAutoSub\": \"删 tool_choice === \\\"auto\\\",auto 即默认值(一键预设已含)\",\n \"dropStreamOptionsSub\": \"删 stream_options。⚠️ 上游将不再回传 usage → admin DB token 统计 / 缓存命中柱状图变 0。**不**在一键预设里,仅在上游因此 400 时再开\",\n \"dropParallelToolCallsSub\": \"删 parallel_tool_calls。OpenAI 标准字段,**不**在一键预设里,仅在上游明确报错时再开\",\n \"mergeSystemMessagesSub\": \"合并多条 role:\\\"system\\\" 消息为单条前置(一键预设已含)\",\n \"extractThinkTagsSub\": \"响应侧:把 content 里的 <think>...</think> 块切到 reasoning_content,避免泄漏给 Codex 当作正文显示(MiniMax M1/M2/M3、部分 GLM/Qwen-thinking 模型必开;一键预设已含)\",\n \"dropResponseFormatSub\": \"删 response_format 字段(SenseNova 6.7 Flash-Lite 等不接受该字段的网关需要;MiniMax 实际接受,**不**在一键预设里)\",\n \"dropNonFunctionToolsSub\": \"过滤 tools 数组里 type 不是 function/custom 的条目(SenseNova schema 只认这俩,Claude Code/Codex 塞的 web_search / file_search 等会被一刀 400)\",\n \"dropReasoningEffortSub\": \"删 reasoning_effort 字段(Kimi 等不识别该字段;这类上游用 thinking:{enabled/disabled} 控制思考。开了 admin UI \\\"强制高强度思考\\\" 时上游会收到 reasoning_effort=\\\"high\\\",对 Kimi 是未知字段,建议给 Kimi 类 generic provider 勾上)\",\n \"enhanceErrorPresetTitle\": \"厂商快捷预设\",\n \"enhanceErrorPresetSub\": \"选 sensenova / minimax 会做两件事:① 立即把上面的细粒度子开关按该厂商推荐配置一键勾上(已有勾选会被覆盖,这是主动选择信号);② 上游 400 报错带特定 marker 时(如 sensenova 的 \\\"Errors in message queue response\\\")翻译成可读诊断 hint。选 none = 关闭这两个能力,保留当前已勾的细粒度开关。\",\n \"enhanceErrorPresetNone\": \"none\",\n \"presetAutoApplied\": \"🎯 已识别为 {{name}},已自动套用推荐 features 配置(不会覆盖已勾过的字段)\",\n \"presetClear\": \"清除并自定义\",\n \"docsUrl\": \"docsUrl\",\n \"docsUrlHint\": \"可选——缺 key 错误提示里的获取链接\"\n },\n \"models\": {\n \"title\": \"声明模型(可选)\",\n \"hint\": \"不填则<strong>任意透传</strong>——客户端发什么 model id 就转发什么。填了的话只有列在这里的 id(含别名)才会被 byClientModel 路由到此 provider;声明 contextWindow / maxOutputTokens 能让 print-config 输出正确的 toml 字段。\",\n \"idPlaceholder\": \"model id(必填,如 qwen3-max)\",\n \"contextPlaceholder\": \"contextWindow\",\n \"maxOutputPlaceholder\": \"maxOutputTokens\",\n \"addBtn\": \"+ 添加模型\",\n \"vision\": \"vision\",\n \"reasoning\": \"reasoning\",\n \"webSearch\": \"web search\"\n },\n \"validate\": {\n \"idRequired\": \"id 不能为空\",\n \"idReserved\": \"id \\\"{{id}}\\\" 与内置 provider 冲突,请改用其他 id\",\n \"idFormat\": \"id \\\"{{id}}\\\" 必须以字母数字开头,只允许字母数字 / - / _,不能含空格\",\n \"idDup\": \"已存在 id \\\"{{id}}\\\" 的 provider,请改用其他 id\",\n \"baseUrlRequired\": \"baseUrl 不能为空\",\n \"envKeyRequired\": \"envKey 不能为空\",\n \"defaultModelRequired\": \"defaultModel 不能为空\"\n }\n },\n \"rawJson\": {\n \"title\": \"原始 JSON 编辑\",\n \"hint\": \"直接编辑 providers.json 全文。校验失败时保存按钮会禁用。\",\n \"valid\": \"JSON 语法正确\",\n \"invalid\": \"JSON 语法错误\",\n \"pasteBtn\": \"从剪贴板粘贴\",\n \"pasteFailed\": \"无法读取剪贴板\",\n \"parseError\": \"JSON 解析失败:{{message}}\",\n \"notObject\": \"JSON 必须是对象\",\n \"missingProviders\": \"JSON 顶层必须包含 providers 数组\"\n }\n}\n"}}, "docker-compose.yml": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785745231.2251225, "n": 630, "h": "5cbbefbe523a2794", "c": "# mimo2codex docker-compose 部署\n# 用法:\n# 1. cp .env.example .env 并填入 API key (MIMO_API_KEY 或 DEEPSEEK_API_KEY 等)\n# 2. docker compose up -d\n# 3. 浏览器打开 http://localhost:8788\n#\n# 数据目录挂在 ./.mimo2codex/,包含 sqlite + providers.json + admin UI 状态。\n# 想复用现有 ~/.mimo2codex/,把 volumes 改成 ~/.mimo2codex:/data 即可。\n\nservices:\n mimo2codex:\n image: ghcr.io/7as0nch/mimo2codex:latest\n # 想本地构建而不是拉镜像?注释上一行,启用下一行:\n # build: .\n container_name: mimo2codex\n restart: unless-stopped\n ports:\n - \"8788:8788\"\n environment:\n - MIMO2CODEX_DATA_DIR=/data\n env_file:\n - .env\n volumes:\n - ./.mimo2codex:/data\n"}, "a40fea30bb9b4c2678b91c306666d3ff6193b48b": {"t": 1785748988.629007, "n": 630, "h": "5cbbefbe523a2794", "c": "# mimo2codex docker-compose 部署\n# 用法:\n# 1. cp .env.example .env 并填入 API key (MIMO_API_KEY 或 DEEPSEEK_API_KEY 等)\n# 2. docker compose up -d\n# 3. 浏览器打开 http://localhost:8788\n#\n# 数据目录挂在 ./.mimo2codex/,包含 sqlite + providers.json + admin UI 状态。\n# 想复用现有 ~/.mimo2codex/,把 volumes 改成 ~/.mimo2codex:/data 即可。\n\nservices:\n mimo2codex:\n image: ghcr.io/7as0nch/mimo2codex:latest\n # 想本地构建而不是拉镜像?注释上一行,启用下一行:\n # build: .\n container_name: mimo2codex\n restart: unless-stopped\n ports:\n - \"8788:8788\"\n environment:\n - MIMO2CODEX_DATA_DIR=/data\n env_file:\n - .env\n volumes:\n - ./.mimo2codex:/data\n"}}, "doc/sensenova.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785809860.7870371, "n": 6330, "h": "b2e85d1898195ae2", "c": "# Wiring up SenseNova (商汤日日新)\n\n> 中文:[sensenova.zh.md](./sensenova.zh.md)\n\n[SenseNova](https://platform.sensenova.cn/docs) (baseUrl `https://token.sensenova.cn/v1`) speaks the OpenAI Chat Completions wire format, but the 6.7 Flash-Lite gateway is stricter than OpenAI's own: **any field not listed in the official request-params table is rejected**, and validation errors are frequently replaced by the un-informative `Errors in message queue response`. This doc walks through the v0.2.9+ sensenova preset that handles it in one click.\n\n## Symptom (before)\n\nWiring SenseNova up as a plain generic provider, the first Claude Code / Codex request 400s with:\n\n```\nupstream returned 400: {\"error\":{\"message\":\"Errors in message queue response\",\n \"type\":\"invalid_request_error\",\"code\":\"3\"}}\n```\n\nThe message doesn't say **which field** is at fault.\n\n## Root cause\n\nSenseNova 6.7 Flash-Lite's [official request-params table](https://platform.sensenova.cn/docs) only lists:\n\n`model / messages / stream / stream_options / temperature / top_p / max_tokens / n / stop / frequency_penalty / presence_penalty / reasoning_effort / tools / tool_choice / parallel_tool_calls / seed`\n\n**Not listed** (and therefore rejected):\n\n| Field | OpenAI / DeepSeek | SenseNova 6.7 Flash-Lite |\n|---|---|---|\n| `response_format` | accepted | **rejected** (not listed) |\n| `thinking` / `enable_thinking` | / | **rejected** (the same platform's DeepSeek V4 Flash lists it; 6.7 doesn't) |\n| `tools[*].function.strict: true` | accepted | **not in schema** (defensive strip) |\n| `assistant.content: null` (paired with tool_calls) | accepted | **unspecified** |\n| Multiple `role: \"system\"` messages | accepted | **unspecified** |\n\nPlus boundary constraints:\n\n- `temperature ∈ [0, 2]`\n- `max_tokens ∈ [1, 65536]`\n- 256K context (input ≤ 252K, output ≤ 64K)\n\nError code `\"code\": \"3\"` is the umbrella `invalid_request_error` code — the real reason should be in `message` (the [docs example](https://platform.sensenova.cn/docs) shows `\"invalid temperature, should in [0,2].\"`). But the gateway sometimes replaces it with `Errors in message queue response`, making field-level diagnosis painful.\n\n## Recommended setup — admin UI one-click\n\nOpen the mimo2codex admin UI → Providers → **+ Add Provider** → fill in:\n\n- baseUrl: `https://token.sensenova.cn/v1`\n\nThe UI will auto-detect \"SenseNova\" and apply these features (click \"Clear and customize\" on the Alert if you want to override manually):\n\n- `dropNullStrict`: strip `tools[*].function.strict === null`\n- `dropNullContent`: strip assistant `content === null`\n- `dropToolChoiceAuto`: strip `tool_choice === \"auto\"` (auto is the default)\n- `mergeSystemMessages`: merge multiple system messages into one leading entry\n- `dropResponseFormat`: strip `response_format` (SenseNova doesn't accept it)\n- `enhanceErrorPreset: \"sensenova\"`: translate `Errors in message queue response` into a readable diagnostic hint\n\nFill in the rest (id, envKey, defaultModel), save, and restart mimo2codex for the runtime registry to pick it up.\n\n## Recommended setup — providers.json\n\n```json\n{\n \"providers\": [\n {\n \"id\": \"sensenova\",\n \"displayName\": \"SenseNova\",\n \"baseUrl\": \"https://token.sensenova.cn/v1\",\n \"envKey\": \"SENSENOVA_API_KEY\",\n \"defaultModel\": \"sensenova-6.7-flash-lite\",\n \"models\": [\n { \"id\": \"sensenova-6.7-flash-lite\", \"contextWindow\": 262144, \"maxOutputTokens\": 65536, \"supportsImages\": true },\n { \"id\": \"deepseek-v4-flash\", \"contextWindow\": 262144, \"maxOutputTokens\": 65536, \"supportsReasoning\": true }\n ],\n \"features\": {\n \"dropNullStrict\": true,\n \"dropNullContent\": true,\n \"dropToolChoiceAuto\": true,\n \"mergeSystemMessages\": true,\n \"dropResponseFormat\": true,\n \"enhanceErrorPreset\": \"sensenova\"\n },\n \"docsUrl\": \"https://platform.sensenova.cn/docs\"\n }\n ]\n}\n```\n\nSet the env var and start:\n\n```bash\nexport SENSENOVA_API_KEY=sk-xxxxx # get one at https://platform.sensenova.cn/console/keys\nmimo2codex\n```\n\n## Available models\n\nSource: [https://platform.sensenova.cn/docs](https://platform.sensenova.cn/docs) (\"Model overview\" section)\n\n| Model ID | Description | Quota | Multimodal | Reasoning | Notes |\n|---|---|---|---|---|---|\n| `sensenova-6.7-flash-lite` | Lightweight multimodal agent | 1500 / 5h | text + image | reasoning_effort | Default recommended |\n| `deepseek-v4-flash` | DeepSeek high-perf chat | 150 / 5h | text | thinking mode | `thinking` must NOT be sent through SenseNova's gateway (generic provider strips it) |\n| `sensenova-u1-fast` | Infographic generation | 1500 / 5h | / | / | **Image generation only**, uses `/v1/images/generations`, incompatible with chat completions — not supported by mimo2codex yet |\n\n## Troubleshooting 400s\n\nIf you've applied the features above and still get 400, work through:\n\n1. **Enable mimo2codex debug logs to see the actual request body**:\n ```bash\n export LOG_LEVEL=debug\n ```\n The log will contain `incoming POST /v1/chat/completions raw body { ... }` for failing requests — copy that.\n2. **Reproduce against SenseNova directly**: use the same body + `Authorization: Bearer $SENSENOVA_API_KEY` and curl `https://token.sensenova.cn/v1/chat/completions`. If it reproduces, the issue is in the body, not mimo2codex.\n3. **Bisect**: delete fields from the body one by one. Try removing `tools` first (test plain text), then suspicious fields.\n4. **Check boundaries**: `max_tokens ≤ 65536`; `temperature ∈ [0, 2]` (closed interval).\n5. **If you find a field with no existing sanitizer switch**: open an issue on the mimo2codex repo with the field name and a minimal repro.\n\n## Upgrade notes\n\nThis integration ships as a generic provider — it **does not reserve a builtin id**. If you previously configured `id=\"sensenova\"` as a generic provider:\n\n- It keeps working unchanged.\n- Recommended: open the admin UI edit dialog → type the baseUrl in to trigger the smart default. It will auto-apply the recommended features (only if your features were all empty).\n- If you had manually toggled any feature, the \"already customized → don't overwrite\" guard kicks in; you can either manually add the new `dropResponseFormat` / `enhanceErrorPreset` switches, or hit \"Clear and customize\" on the Alert to start fresh.\n"}}, "doc/sensenova.zh.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785809860.824208, "n": 4605, "h": "457e5a33ccf5c33a", "c": "# 接入商汤日日新 (SenseNova)\n\n> English: [sensenova.md](./sensenova.md)\n\n[商汤日日新 SenseNova](https://platform.sensenova.cn/docs)(baseUrl `https://token.sensenova.cn/v1`)兼容 OpenAI Chat Completions 规范,但 6.7 Flash-Lite 等模型的网关 schema 比 OpenAI 自家更严:**官方请求参数表里未列出的字段一律拒收**,并且具体校验失败原因经常被网关替换成无信息量的 `Errors in message queue response`。本文档说明如何用 v0.2.9+ 的 sensenova 预设一键解决。\n\n## 症状(修复前)\n\n把 SenseNova 当成普通 generic provider 配上去,使用 Claude Code / Codex 一发请求就 400:\n\n```\nupstream returned 400: {\"error\":{\"message\":\"Errors in message queue response\",\n \"type\":\"invalid_request_error\",\"code\":\"3\"}}\n```\n\n报错 message 不告诉你**哪个字段**有问题。\n\n## 根因\n\nSenseNova 6.7 Flash-Lite [官方请求参数表](https://platform.sensenova.cn/docs)只列以下字段:\n\n`model / messages / stream / stream_options / temperature / top_p / max_tokens / n / stop / frequency_penalty / presence_penalty / reasoning_effort / tools / tool_choice / parallel_tool_calls / seed`\n\n**没列出**:\n\n| 字段 | OpenAI / DeepSeek | SenseNova 6.7 Flash-Lite |\n|---|---|---|\n| `response_format` | 接受 | **拒绝**(未列出) |\n| `thinking` / `enable_thinking` | / | **拒绝**(同平台 DeepSeek V4 Flash 列了,6.7 没列) |\n| `tools[*].function.strict: true` | 接受 | **不在 schema 里**(防御性删) |\n| `assistant.content: null`(同消息带 tool_calls) | 接受 | **不明示** |\n| 多条 `role: \"system\"` 消息 | 接受 | **不明示** |\n\n外加边界约束:\n\n- `temperature ∈ [0, 2]`\n- `max_tokens ∈ [1, 65536]`\n- 上下文 256K(输入 ≤ 252K,输出 ≤ 64K)\n\n错误码 `\"code\": \"3\"` 对应 `invalid_request_error`,message 字段才是真实原因([文档错误码示例](https://platform.sensenova.cn/docs)给的是 `\"invalid temperature, should in [0,2].\"`)。但网关有时会把具体说明替换成 `Errors in message queue response`,导致排查困难。\n\n## 推荐配置 — admin UI 一键\n\n打开 mimo2codex admin UI → Providers → **+ 添加 Provider** → 输入:\n\n- baseUrl:`https://token.sensenova.cn/v1`\n\nUI 会自动识别为「商汤日日新」并应用以下 features(你可以点 Alert 上的「清除并自定义」改回手动控制):\n\n- `dropNullStrict`:删 `tools[*].function.strict === null`\n- `dropNullContent`:删 assistant `content === null`\n- `dropToolChoiceAuto`:删 `tool_choice === \"auto\"`(auto 即默认值)\n- `mergeSystemMessages`:合并多条 system 消息为单条前置\n- `dropResponseFormat`:删 `response_format` 字段(SenseNova 不接受)\n- `enhanceErrorPreset: \"sensenova\"`:把 `Errors in message queue response` 翻成中文诊断 hint\n\n补齐其他必填项(id、envKey、defaultModel)保存即可,重启 mimo2codex 让运行时注册。\n\n## 推荐配置 — providers.json\n\n```json\n{\n \"providers\": [\n {\n \"id\": \"sensenova\",\n \"displayName\": \"商汤日日新\",\n \"baseUrl\": \"https://token.sensenova.cn/v1\",\n \"envKey\": \"SENSENOVA_API_KEY\",\n \"defaultModel\": \"sensenova-6.7-flash-lite\",\n \"models\": [\n { \"id\": \"sensenova-6.7-flash-lite\", \"contextWindow\": 262144, \"maxOutputTokens\": 65536, \"supportsImages\": true },\n { \"id\": \"deepseek-v4-flash\", \"contextWindow\": 262144, \"maxOutputTokens\": 65536, \"supportsReasoning\": true }\n ],\n \"features\": {\n \"dropNullStrict\": true,\n \"dropNullContent\": true,\n \"dropToolChoiceAuto\": true,\n \"mergeSystemMessages\": true,\n \"dropResponseFormat\": true,\n \"enhanceErrorPreset\": \"sensenova\"\n },\n \"docsUrl\": \"https://platform.sensenova.cn/docs\"\n }\n ]\n}\n```\n\n设置环境变量并启动:\n\n```powershell\n$env:SENSENOVA_API_KEY = \"sk-xxxxx\" # 在 https://platform.sensenova.cn/console/keys 申请\nmimo2codex\n```\n\n## 模型列表\n\n来源:[https://platform.sensenova.cn/docs](https://platform.sensenova.cn/docs)(\"模型总览\"节)\n\n| Model ID | 说明 | 限额 | 多模态 | 推理模式 | 备注 |\n|---|---|---|---|---|---|\n| `sensenova-6.7-flash-lite` | 轻量多模态智能体 | 1500 次 / 5h | 文本 + 图像 | reasoning_effort | 默认推荐 |\n| `deepseek-v4-flash` | DeepSeek 高性能对话 | 150 次 / 5h | 文本 | thinking 模式 | 走 SenseNova 网关时,`thinking` 字段不能发(generic provider 会 strip) |\n| `sensenova-u1-fast` | 信息图生成 | 1500 次 / 5h | / | / | **图像生成专用**,走 `/v1/images/generations`,与 chat completions 路径不兼容,mimo2codex 暂不支持 |\n\n## 常见 400 排查\n\n如果套上以上 features 后还是 400,按顺序排查:\n\n1. **开 mimo2codex debug 日志看真实请求体**:\n ```powershell\n $env:LOG_LEVEL = \"debug\"\n ```\n 启动后命中报错请求时,日志里会有 `incoming POST /v1/chat/completions raw body { ... }`,把它复制出来。\n2. **直发 SenseNova 复现**:用同一份请求体 + `Authorization: Bearer $env:SENSENOVA_API_KEY` 直接 curl `https://token.sensenova.cn/v1/chat/completions`。能复现 → 与 mimo2codex 无关,是请求体某字段被拒。\n3. **二分定位**:把请求体里的字段一个一个删掉看哪个是元凶。先删 `tools`(测纯文本是否能过),再删可疑字段。\n4. **检查边界值**:`max_tokens` 不能超 65536;`temperature` 必须在 [0,2] 闭区间。\n5. **如果定位到的字段没有现成 sanitizer 子开关**:在 mimo2codex 仓库开 issue,附上字段名和最小复现。\n\n## 升级须知\n\n本特性以 generic provider 方式接入,**不占用 builtin id**。如果你之前已经用 `id=\"sensenova\"` 配过 generic provider:\n\n- 仍可继续使用,无任何冲突。\n- 建议在 admin UI 打开编辑弹窗 → 输入 baseUrl 触发智能默认,它会自动套上推荐 features(前提是你之前 features 全空)。\n- 若你之前手动勾过 features,会受到\"已自定义 → 不覆盖\"保护;可手动按需勾选新的 `dropResponseFormat` / `enhanceErrorPreset` 子开关,或点击 Alert 上的「清除并自定义」从头来过。\n"}}, ".mimocode/skills/provider-config/SKILL.md": {"5cb6f5c9506cd2e91de1b98248d75873f2e24972": {"t": 1785809861.5033696, "n": 5356, "h": "4b0bd990bfa7b8cc", "c": "---\nname: provider-config\ndescription: Configure and integrate new AI providers into mimo2codex. Use when the user wants to add a new provider, configure existing ones, or troubleshoot provider connectivity issues.\n---\n\n# Provider Configuration Skill\n\nConfigure and integrate AI providers into the mimo2codex proxy.\n\n## When to use\n\nTrigger this skill when:\n- User wants to add a new provider (e.g., \"接入MiniMax\", \"add a new provider\")\n- User provides provider configuration JSON\n- User asks to configure a provider (e.g., \"按要求配置\")\n- User has provider connectivity issues\n- User asks about provider compatibility\n\n## Workflow\n\n### 1. Understand Provider Requirements\n\nGather from user:\n- **Base URL**: API endpoint (e.g., `https://api.example.com/v1`)\n- **API Key**: Authentication method\n- **Models**: Which models to expose\n- **Wire API**: OpenAI-compatible? Custom format?\n- **Special features**: Vision, tools, streaming, etc.\n\n### 2. Check Existing Provider Patterns\n\nLook at `src/providers/` for examples:\n- `mimo.ts` - MiMo provider (built-in)\n- `deepseek.ts` - DeepSeek provider (built-in)\n- `registry.ts` - Provider registration\n- `types.ts` - Provider type definitions\n\n### 3. Create Provider Configuration\n\nFor generic OpenAI-compatible providers:\n\n```typescript\n// In src/providers/registry.ts or config\n{\n id: \"provider-name\",\n shortcut: \"provider-name\",\n displayName: \"Provider Display Name\",\n baseUrl: \"https://api.example.com/v1\",\n models: [\"model-1\", \"model-2\"],\n wireApi: \"openai-chat\", // or \"custom\"\n apiKeyEnv: \"PROVIDER_API_KEY\",\n}\n```\n\n### 4. Handle Provider Quirks\n\nCommon compatibility issues:\n- **`max_tokens` vs `max_completion_tokens`**: Some providers use different field names\n- **Image input**: Not all providers support vision\n- **Tool calling**: Some providers have limited tool support\n- **Streaming**: Check SSE format compatibility\n- **Rate limiting**: Implement retry logic if needed\n\n### 5. Test Provider Integration\n\n```bash\n# Test with direct API call\ncurl -X POST \"$BASE_URL/chat/completions\" \\\n -H \"Authorization: Bearer $API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"model-name\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n\n# Test through mimo2codex\ncurl -X POST http://localhost:8788/v1/chat/completions \\\n -H \"Authorization: Bearer your-key\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"provider-name/model-name\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n```\n\n### 6. Document Provider\n\nAdd to provider documentation:\n- Base URL and auth method\n- Supported models and capabilities\n- Known quirks or limitations\n- Example configuration\n\n## Example Usage\n\n```\nUser: 按要求配置:{ \"providers\": [ { \"id\": \"sensenova\", \"shortcut\": \"sensenova\", \"displayName\": \"商汤日日新\", \"baseUrl\": \"...\" } ] }\n\nAgent workflow:\n1. Parse the provider configuration JSON\n2. Check if provider needs special handling\n3. Add to registry.ts or config\n4. Test the integration\n5. Document any quirks found\n```\n\n## Provider Compatibility Matrix\n\n| Provider | Wire API | Vision | Tools | Streaming | Notes |\n|----------|----------|--------|-------|-----------|-------|\n| MiMo | openai-chat | ✅ | ✅ | ✅ | Built-in, `max_completion_tokens` |\n| DeepSeek | openai-chat | ❌ | ✅ | ✅ | Built-in, `reasoning_content` |\n| MiniMax | openai-chat | ✅ | ✅ | ✅ | May need `dropResponseFormat` |\n| Generic | openai-chat | ? | ? | ? | Depends on implementation |\n\n## Common Configuration Patterns\n\n### For OpenAI-Compatible APIs\n\n```json\n{\n \"id\": \"my-provider\",\n \"shortcut\": \"my-provider\",\n \"displayName\": \"My AI Provider\",\n \"baseUrl\": \"https://api.my-provider.com/v1\",\n \"models\": [\"gpt-4\", \"gpt-3.5-turbo\"],\n \"wireApi\": \"openai-chat\",\n \"apiKeyEnv\": \"MY_PROVIDER_API_KEY\"\n}\n```\n\n### For Custom APIs\n\n```json\n{\n \"id\": \"custom-ai\",\n \"shortcut\": \"custom-ai\",\n \"displayName\": \"Custom AI\",\n \"baseUrl\": \"https://custom-ai.example.com/api\",\n \"models\": [\"custom-model-v1\"],\n \"wireApi\": \"custom\",\n \"apiKeyEnv\": \"CUSTOM_AI_KEY\",\n \"transformRequest\": \"custom-transform.ts\",\n \"transformResponse\": \"custom-transform.ts\"\n}\n```\n\n## Troubleshooting\n\n### \"Unauthorized\" / 401 Errors\n- Check API key is correct\n- Verify API key has proper permissions\n- Check if key needs specific header format\n\n### \"Model Not Found\" / 404 Errors\n- Verify model name matches provider's expected format\n- Check if model is available in your region\n- Confirm model is enabled in your account\n\n### Streaming Issues\n- Check if provider supports SSE format\n- Verify `stream: true` in request\n- Look for newline-delimited JSON issues\n\n### Tool Calling Issues\n- Verify provider supports function calling\n- Check tool schema format compatibility\n- Look for `tool_choice` support\n\n## Key Commands Reference\n\n```bash\n# Print current config\nnode dist/cli.js print-config\n\n# Print cc-switch config\nnode dist/cli.js print-cc-switch\n\n# Test provider directly\ncurl -X POST \"$BASE_URL/chat/completions\" \\\n -H \"Authorization: Bearer $API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"model\":\"model-name\",\"messages\":[{\"role\":\"user\",\"content\":\"test\"}]}'\n\n# Check mimo2codex logs\ntail -f ~/.mimo2codex/logs/proxy.log\n```\n\n## Don't Use This Skill For\n\n- General API calls (use mimo_chat.py from mimoskill)\n- PR review (use pr-review skill)\n- Issue investigation (use issue-investigation skill)\n- Codebase analysis (use codebase-analysis skill)\n"}}}}