Selaa lähdekoodia

init: 初始化项目

- 创建了完整的LiteLLM LM Studio适配器服务,支持OpenAI兼容接口
- 实现了推理控制功能,能够翻译reasoning和enable_thinking参数到LM Studio原生API
- 支持非流式和流式chat/completions请求处理
- 提供了SSE流式事件桥接功能,将LM Studio原生事件转换为OpenAI兼容格式
- 添加了完整的项目目录结构:tools/, services/, docs/, tests/, artifacts/
- 创建了wrapper生成器工具和各种探针脚本用于LM Studio行为分析
- 编写了详细的中文技术文档和流程说明
- 实现了原生接口透传功能和错误处理机制
- 添加了全面的单元测试和集成测试验证
kekeZack 1 kuukausi sitten
sitoutus
33cf31d4a6

+ 47 - 0
.gitignore

@@ -0,0 +1,47 @@
+# ===========================
+# Python
+# ===========================
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+
+# Distribution / packaging
+build/
+dist/
+*.egg-info/
+*.egg
+
+# Virtual environments
+.venv/
+venv/
+env/
+
+# Environment variables
+.env
+.env.local
+
+# ===========================
+# Artifacts (generated output)
+# ===========================
+artifacts/
+
+# ===========================
+# IDE / Editor
+# ===========================
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# ===========================
+# OS
+# ===========================
+.DS_Store
+Thumbs.db
+
+# ===========================
+# Logs
+# ===========================
+*.log

+ 38 - 0
README.md

@@ -0,0 +1,38 @@
+# Fix HauhauCS Thinking Mode Toggle
+
+This repository now has three clear areas:
+
+## Layout
+
+- `tools/`
+  - LM Studio-focused utilities and probes
+  - `tools/lmstudio/wrapper_generator.py`
+  - `tools/lmstudio/openai_reasoning_proxy.py`
+  - `tools/lmstudio/probes/*`
+- `services/`
+  - long-running services
+  - `services/litellm-lmstudio-adapter/`
+  - standalone FastAPI adapter project for `LiteLLM -> LM Studio native`
+- `docs/`
+  - specs and analysis
+- `tests/`
+  - unit tests for top-level tools
+- `artifacts/`
+  - generated outputs, probe results, and runtime traces
+
+## Important Files
+
+- Wrapper generator:
+  - [wrapper_generator.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\wrapper_generator.py)
+- Early OpenAI reasoning proxy:
+  - [openai_reasoning_proxy.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\openai_reasoning_proxy.py)
+- Standalone LiteLLM adapter:
+  - [litellm_lmstudio_adapter.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\services\litellm-lmstudio-adapter\litellm_lmstudio_adapter.py)
+
+## Tests
+
+```bash
+python -m unittest tests.test_lmstudio_wrapper_generator
+python -m unittest tests.test_openai_reasoning_proxy
+python -m unittest discover -s services/litellm-lmstudio-adapter/tests -p "test_*.py"
+```

+ 78 - 0
docs/handoff/HANDOFF-2026-06-09.md

@@ -0,0 +1,78 @@
+# Handoff: 2026-06-09 17:18
+
+## Summary
+本次工作目标是梳理并实现一套围绕 `LM Studio` reasoning/toggle 问题的统一方案,包括分析模型与接口行为、实现一个可供 `LiteLLM` 使用的 `LM Studio` 原生适配服务、以及对整个项目目录做结构化整理。当前状态是:核心适配服务已经实现并完成本地验证,工具脚本与测试已按职责分区整理完成,文档也已补齐中文说明;项目当前不是 git 仓库,因此没有可记录的 branch/commit 信息。
+
+## Current State
+- **Complete:**
+  - 完成对 `LM Studio` OpenAI 兼容层与原生层 reasoning 行为的实测与对比
+  - 实现独立服务 `services/litellm-lmstudio-adapter/`
+  - 支持 `chat/completions` 非流式与流式
+  - 支持 `responses` 非流式与流式
+  - 支持 `LM Studio` 原生 `/api/v1/*`、`/api/v0/*` 透传
+  - 将 wrapper 生成器与 probe 脚本整理到 `tools/lmstudio/`
+  - 新增中文流程文档与工具说明文档
+  - 顶层 README 已更新为新的目录结构
+  - 相关测试全部通过
+- **In Progress:**
+  - 无明确功能开发中的 WIP
+  - 当前更多是“已完成,可继续增强”的状态
+- **Blocked:**
+  - 项目目录不是 git 仓库,无法提供 branch/commit/base branch 信息
+  - OpenAI 官方文档页面受 Cloudflare 限制,无法直接用普通 HTTP 拉取页面内容做补充引用
+
+## Key Decisions
+| Decision | Rationale | Alternative |
+|----------|-----------|-------------|
+| 保留 OpenAI 兼容接口,对内调用 `LM Studio /api/v1/chat` | 兼容上游调用方式,同时保住 `LM Studio` 原生 reasoning 控制能力 | 直接依赖 `LM Studio /v1/chat/completions`,但实测 reasoning 开关不可靠 |
+| 用独立服务项目承接 `LiteLLM -> LM Studio` 适配 | 这是长期运行组件,不应该和一次性工具混在一起 | 把代理继续留在通用脚本目录中 |
+| 将 wrapper 生成器与 probe 脚本移动到 `tools/lmstudio/` | 这些脚本本质上是工具,不是服务 | 继续放在旧的 `scripts/` 目录 |
+| `chat/completions` 流式桥接采用 SSE block 解析 | `LM Studio` 原生返回 `event:` + `data:` 的完整 SSE block,逐行猜测会丢事件或出协议错误 | 继续按逐行 `data:` 解析,实测会出现 incomplete chunked read |
+| `messages` 转 `LM Studio` 原生 `input` 时采用 transcript 压平方案 | 避免依赖 `LM Studio` 未完全公开稳定的 message schema,提升兼容性 | 直接把 OpenAI `messages` 原样塞到 `input`,实测会被原生接口拒绝 |
+
+## Files Touched
+| File | Purpose | Status |
+|------|---------|--------|
+| [README.md](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\README.md) | 顶层结构说明与测试命令 | modified |
+| [tools/lmstudio/wrapper_generator.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\wrapper_generator.py) | 生成 `LM Studio` wrapper 的工具脚本 | moved |
+| [tools/lmstudio/openai_reasoning_proxy.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\openai_reasoning_proxy.py) | 早期实验型 reasoning 代理 | moved |
+| [tools/lmstudio/README.md](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\README.md) | 中文工具总览与用途说明 | created |
+| [tools/lmstudio/probes/lmstudio_thinking_probe.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\probes\lmstudio_thinking_probe.py) | LM Studio 原生/兼容 reasoning 行为探针 | moved |
+| [tools/lmstudio/probes/lmstudio_openai_reasoning_probe.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\probes\lmstudio_openai_reasoning_probe.py) | OpenAI 兼容 reasoning 字段批量探针 | moved |
+| [tools/lmstudio/probes/llama_cpp_openai_probe.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\probes\llama_cpp_openai_probe.py) | llama.cpp OpenAI 兼容探针 | moved |
+| [tests/test_lmstudio_wrapper_generator.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tests\test_lmstudio_wrapper_generator.py) | wrapper 生成器测试,已适配新路径 | modified |
+| [tests/test_openai_reasoning_proxy.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tests\test_openai_reasoning_proxy.py) | 早期代理测试,已适配新路径 | modified |
+| [services/litellm-lmstudio-adapter/litellm_lmstudio_adapter.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\services\litellm-lmstudio-adapter\litellm_lmstudio_adapter.py) | 正式适配服务主程序 | created / moved / modified |
+| [services/litellm-lmstudio-adapter/README.md](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\services\litellm-lmstudio-adapter\README.md) | 服务使用说明 | modified |
+| [services/litellm-lmstudio-adapter/docs/2026-06-09-启动与请求处理流程.md](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\services\litellm-lmstudio-adapter\docs\2026-06-09-启动与请求处理流程.md) | 中文详细流程说明 | created / moved |
+| [services/litellm-lmstudio-adapter/tests/test_adapter.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\services\litellm-lmstudio-adapter\tests\test_adapter.py) | 适配服务测试 | created / moved |
+
+## Next Steps (Priority Order)
+1. 为 `services/litellm-lmstudio-adapter/` 增加启动脚本 — 推荐补一个 `run.bat` 或 `start.bat`,方便本机长期使用
+2. 为 `tools/lmstudio/wrapper_generator.py` 补一个更直接的示例文档或 `bat` 启动脚本 — 降低后续使用门槛
+3. 继续做真实接口覆盖验证 — 特别是 `embeddings`、`completions`、原生透传路径的端到端验证
+4. 如后续真要接 `LiteLLM` — 补一个可直接使用的 `proxy_config.yaml` 示例
+5. 视需要清理 `artifacts/` 中的旧实验产物 — 当前没有删除任何历史结果
+
+## Open Questions
+- 是否要给 `services/litellm-lmstudio-adapter/` 再补一份“部署与排障”中文文档 — 目前只有原理流程文档
+- 是否要把 `tools/lmstudio/openai_reasoning_proxy.py` 标记为 deprecated — 当前它仍有价值,但正式服务已由 `services/litellm-lmstudio-adapter/` 取代
+- 是否要继续扩适配服务的 stub 接口(audio/images/files/moderations) — 目前只返回 `501`
+
+## Known Pitfalls
+- `LM Studio` 原生流式事件不是简单的 `data:` 单行流,而是标准 SSE block,必须按 block 解析;此前逐行处理会导致流式协议错误
+- `LM Studio /api/v1/chat` 不能直接吃 OpenAI `messages` 结构作为 `input`,需要先做消息压平或使用其可接受的原生格式
+- 顶层目录不是 git 仓库,任何需要 branch/commit 的自动化逻辑都要先判断并优雅降级
+- `services/litellm-lmstudio-adapter` 目录名包含连字符,因此测试建议用 `unittest discover`,不要依赖不稳的模块路径导入
+
+## Verification Run
+- `python -m unittest tests.test_lmstudio_wrapper_generator`
+- `python -m unittest tests.test_openai_reasoning_proxy`
+- `python -m unittest discover -s services/litellm-lmstudio-adapter/tests -p "test_*.py"`
+
+以上三组测试最近一次均通过。
+
+## Branch Info
+- **Branch:** `N/A` — 当前目录不是 git 仓库
+- **Latest Commit:** `N/A` — 当前目录不是 git 仓库
+- **Base Branch:** `N/A` — 当前目录不是 git 仓库

+ 56 - 0
docs/specs/2026-06-09-lmstudio-hauhaucs-reasoning-wrapper-spec.md

@@ -0,0 +1,56 @@
+# LM Studio HauhauCS Reasoning Wrapper Spec
+
+## Goal
+
+Generate a portable LM Studio model wrapper for the local HauhauCS Qwen3.6-35B-A3B GGUF so LM Studio recognizes it as a reasoning-capable model in both the UI and the native `/api/v1/chat` API.
+
+## Scope
+
+- Do not modify the GGUF files.
+- Do not modify LM Studio `.internal` state.
+- Do not overwrite official Hub model packages.
+- Generate a standalone wrapper directory with LM Studio-style metadata files.
+- Reuse the official Qwen3.6-35B-A3B reasoning metadata and prompt-template behavior.
+
+## Output
+
+The generator script must create a wrapper directory containing:
+
+- `model.yaml`
+- `manifest.json`
+- `README.md`
+
+The wrapper must:
+
+- Declare `reasoning: true`
+- Declare `customFields.enableThinking`
+- Declare `customFields.preserveThinking`
+- Use the Qwen3.6 prompt template with `enable_thinking` and `preserve_thinking`
+- Reference the target HauhauCS local model identity instead of copying model weights
+
+## Automation
+
+Provide a one-click Python script that:
+
+- Accepts a target local model key
+- Accepts an output directory
+- Reads the official LM Studio Qwen3.6-35B-A3B package as the source template
+- Produces the wrapper files
+- Supports a dry-run mode
+- Emits a summary JSON file for inspection
+
+## Verification
+
+Minimum automated verification:
+
+- Generated `model.yaml` contains `reasoning: true`
+- Generated `model.yaml` contains `customFields.enableThinking`
+- Generated `manifest.json` identifies the wrapper model
+- Generated files mention the requested HauhauCS source model key
+- Dry-run mode performs validation without writing files
+
+Runtime validation remains a separate manual step:
+
+- Import or expose the wrapper to LM Studio
+- Confirm UI reasoning toggle appears
+- Confirm `/api/v1/chat` accepts `reasoning: "off"` and `reasoning: "on"` for the wrapped model

+ 42 - 0
docs/specs/2026-06-09-openai-reasoning-proxy-spec.md

@@ -0,0 +1,42 @@
+# OpenAI Reasoning Proxy Spec
+
+## Goal
+
+Provide a single-file FastAPI proxy that exposes an OpenAI-compatible `POST /v1/chat/completions` endpoint while translating reasoning controls to LM Studio native `POST /api/v1/chat`.
+
+## Scope
+
+- Implemented as one Python file.
+- Supports:
+  - `GET /healthz`
+  - `GET /v1/models`
+  - `POST /v1/chat/completions`
+- Accepts OpenAI-style `reasoning` and `enable_thinking`.
+- Translates them to LM Studio native `reasoning: "on" | "off"`.
+- Converts native LM Studio output back into OpenAI-style `choices[0].message`.
+
+## Behavior
+
+- `reasoning: "off"` wins over `enable_thinking`.
+- If `enable_thinking` is provided:
+  - `false` => native `reasoning: "off"`
+  - `true` => native `reasoning: "on"`
+- If neither is provided, default to native `reasoning: "on"`.
+- `stream=true` is rejected for now.
+
+## Message Mapping
+
+- OpenAI `system` messages are merged into native `system_prompt`.
+- Other messages are flattened into a plain-text transcript:
+  - `User: ...`
+  - `Assistant: ...`
+- This preserves basic chat context without depending on undocumented native message schemas.
+
+## Verification
+
+- Unit tests cover payload mapping and native-to-OpenAI response translation.
+- Real integration test confirmed:
+  - baseline => reasoning on
+  - `enable_thinking: false` => reasoning off
+  - `reasoning: "off"` => reasoning off
+  - `reasoning: "on"` => reasoning on

+ 82 - 0
services/litellm-lmstudio-adapter/README.md

@@ -0,0 +1,82 @@
+# LiteLLM LM Studio Adapter
+
+This folder contains a standalone FastAPI adapter that lets LiteLLM or any other OpenAI-compatible caller use LM Studio native APIs while preserving LM Studio-specific reasoning controls.
+
+## What it does
+
+- Exposes OpenAI-compatible endpoints:
+  - `GET /v1/models`
+  - `POST /v1/chat/completions`
+  - `POST /v1/responses`
+  - `POST /v1/embeddings`
+  - `POST /v1/completions`
+- Exposes LM Studio native passthrough endpoints without modification:
+  - `/api/v1/*`
+  - `/api/v0/*`
+- Supports SSE streaming for:
+  - `POST /v1/chat/completions`
+  - `POST /v1/responses`
+- Preserves LM Studio reasoning toggle by translating:
+  - `reasoning: "on" | "off"`
+  - `enable_thinking: true | false`
+
+## What is intentionally stubbed for now
+
+- `/v1/audio/*`
+- `/v1/images/*`
+- `/v1/moderations`
+- `/v1/files`
+
+These return OpenAI-shaped `501 not_implemented` errors for now.
+
+## Run
+
+```bash
+python services\litellm-lmstudio-adapter\litellm_lmstudio_adapter.py --host 127.0.0.1 --port 8010 --upstream http://127.0.0.1:7860
+```
+
+## LiteLLM usage
+
+Use this adapter as an OpenAI-compatible backend behind LiteLLM.
+
+Example LiteLLM config idea:
+
+```yaml
+model_list:
+  - model_name: qwen36-local
+    litellm_params:
+      model: openai/qwen/qwen3.6-35b-a3b
+      api_base: http://127.0.0.1:8010
+      api_key: dummy
+```
+
+Then call LiteLLM normally with:
+
+```json
+{
+  "model": "qwen36-local",
+  "messages": [
+    {"role": "user", "content": "Compute 317 * 29. Give the final answer only."}
+  ],
+  "reasoning": "off"
+}
+```
+
+## Tests
+
+```bash
+python -m unittest discover -s services/litellm-lmstudio-adapter/tests -p "test_*.py"
+```
+
+## Verified locally
+
+- `POST /v1/chat/completions` non-streaming:
+  - `reasoning: "off"` => direct answer, `reasoning_tokens = 0`
+  - `reasoning: "on"` => reasoning content returned
+- `POST /v1/chat/completions` streaming:
+  - emits OpenAI-style chat completion chunks
+  - ends with `[DONE]`
+- `POST /v1/responses` streaming:
+  - emits `response.created`
+  - emits `response.output_text.delta`
+  - emits `response.completed`

+ 715 - 0
services/litellm-lmstudio-adapter/docs/2026-06-09-启动与请求处理流程.md

@@ -0,0 +1,715 @@
+# LiteLLM LM Studio 适配器启动与请求处理流程
+
+## 1. 文档目的
+
+本文档说明独立适配器 [litellm_lmstudio_adapter.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\services\litellm-lmstudio-adapter\litellm_lmstudio_adapter.py) 从**启动**到**接收外部请求并处理返回**的完整工作流程。
+
+这个适配器解决的是一个很具体的问题:
+
+- 上游调用方希望使用 **OpenAI 兼容接口**
+- `LM Studio` 同时提供了 **OpenAI 兼容接口** 和 **原生接口**
+- `LM Studio` 原生 `/api/v1/chat` 支持真实可用的 `reasoning: "on" | "off"`
+- 但 `LM Studio` 的 OpenAI 兼容 `/v1/chat/completions` 对某些模型不一定会真正按请求关闭或开启 thinking
+
+所以这个适配器的思路是:
+
+- **对外保持 OpenAI 兼容**
+- **对内优先调用 LM Studio 原生能力**
+
+---
+
+## 2. 整体架构
+
+```mermaid
+flowchart LR
+    accTitle: 适配器整体请求路径
+    accDescr: 从 LiteLLM 或其他 OpenAI 兼容客户端发起请求,经适配器路由到 LM Studio 原生接口或兼容接口,再返回结果。
+
+    caller["客户端 / LiteLLM<br/>发起 OpenAI 兼容请求"]
+    adapter["FastAPI 适配器<br/>litellm_lmstudio_adapter.py"]
+    lm_openai["LM Studio 兼容接口<br/>/v1/models /v1/embeddings /v1/completions"]
+    lm_native["LM Studio 原生接口<br/>/api/v1/chat /api/v1/* /api/v0/*"]
+    response["返回 OpenAI 兼容响应<br/>或原生透传响应"]
+
+    caller --> adapter
+    adapter --> lm_openai
+    adapter --> lm_native
+    lm_openai --> adapter
+    lm_native --> adapter
+    adapter --> response
+
+    classDef client fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
+    classDef adapter_node fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#78350f
+    classDef backend fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d
+    classDef out fill:#f3e8ff,stroke:#7e22ce,stroke-width:2px,color:#581c87
+
+    class caller client
+    class adapter adapter_node
+    class lm_openai,lm_native backend
+    class response out
+```
+
+### 核心路由原则
+
+- 只要请求涉及 **reasoning/thinking 控制**,优先走 `LM Studio /api/v1/chat`
+- 可以直接复用的接口,直接代理到 `LM Studio /v1/*`
+- 如果调用方明确请求 `LM Studio` 原生接口,就原样透传,不改 body
+
+---
+
+## 3. 独立目录中的文件结构
+
+| 路径 | 作用 |
+| --- | --- |
+| [litellm_lmstudio_adapter.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\services\litellm-lmstudio-adapter\litellm_lmstudio_adapter.py) | 主程序,包含路由、请求翻译、流式桥接、原生透传 |
+| [README.md](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\services\litellm-lmstudio-adapter\README.md) | 简要使用说明 |
+| [tests/test_adapter.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\services\litellm-lmstudio-adapter\tests\test_adapter.py) | 单元测试,覆盖请求映射与流式事件翻译 |
+
+---
+
+## 4. 对外暴露的接口
+
+### OpenAI 兼容接口
+
+- `GET /v1/models`
+- `POST /v1/chat/completions`
+- `POST /v1/responses`
+- `POST /v1/embeddings`
+- `POST /v1/completions`
+
+### LM Studio 原生直通接口
+
+- `ANY /api/v1/*`
+- `ANY /api/v0/*`
+
+### 当前占位接口
+
+- `/v1/audio/*`
+- `/v1/images/*`
+- `/v1/moderations`
+- `/v1/files`
+
+这些接口目前统一返回 OpenAI 风格的 `501 not_implemented`。
+
+---
+
+## 5. 启动流程
+
+### 5.1 启动命令
+
+常见启动方式:
+
+```bash
+python services\litellm-lmstudio-adapter\litellm_lmstudio_adapter.py --host 127.0.0.1 --port 8010 --upstream http://127.0.0.1:7860
+```
+
+参数含义:
+
+| 参数 | 含义 |
+| --- | --- |
+| `--host` | 适配器绑定地址 |
+| `--port` | 适配器监听端口 |
+| `--upstream` | LM Studio 服务地址 |
+| `--timeout` | 适配器请求上游时使用的超时秒数 |
+
+### 5.2 `main()` 启动时具体做了什么
+
+`main()` 入口主要做四件事:
+
+1. 解析命令行参数
+2. 把运行时配置写入 `app.state`
+3. 记录上游 `LM Studio` 地址
+4. 启动 `uvicorn`
+
+### 5.3 启动后的运行态
+
+启动后,内存中最关键的两个状态是:
+
+- `app.state.upstream_base`
+  - 例如 `http://127.0.0.1:7860`
+- `app.state.request_timeout`
+  - 上游请求超时时间
+
+这个服务不依赖数据库,也没有持久化状态。
+
+---
+
+## 6. 路由分流逻辑
+
+```mermaid
+flowchart TD
+    accTitle: 路由分流决策图
+    accDescr: 适配器根据请求路径决定是做协议翻译、直接代理,还是返回占位错误。
+
+    start["收到 HTTP 请求"]
+    route_type["判断请求路径"]
+    chat["/v1/chat/completions"]
+    responses["/v1/responses"]
+    simple["/v1/models /v1/embeddings /v1/completions"]
+    native["/api/v1/* 或 /api/v0/*"]
+    stub["audio/images/files/moderations"]
+    translate["翻译为 LM Studio 原生请求"]
+    proxy["直接代理到上游"]
+    stub_resp["返回 501 not_implemented"]
+    openai_resp["返回 OpenAI 兼容结果"]
+    native_resp["返回原生透传结果"]
+
+    start --> route_type
+    route_type --> chat
+    route_type --> responses
+    route_type --> simple
+    route_type --> native
+    route_type --> stub
+
+    chat --> translate
+    responses --> translate
+    simple --> proxy
+    native --> proxy
+    stub --> stub_resp
+
+    translate --> openai_resp
+    proxy --> openai_resp
+    proxy --> native_resp
+
+    classDef main fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
+    classDef transform fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#78350f
+    classDef backend fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d
+    classDef error fill:#fee2e2,stroke:#dc2626,stroke-width:2px,color:#7f1d1d
+
+    class start,route_type,chat,responses,simple,native,stub main
+    class translate,proxy transform
+    class openai_resp,native_resp backend
+    class stub_resp error
+```
+
+---
+
+## 7. 非流式 `chat/completions` 请求流程
+
+适用场景:
+
+- `POST /v1/chat/completions`
+- `stream = false`
+
+### 7.1 外部请求示例
+
+```json
+{
+  "model": "qwen/qwen3.6-35b-a3b",
+  "messages": [
+    {
+      "role": "user",
+      "content": "Compute 317 * 29. Give the final answer only."
+    }
+  ],
+  "reasoning": "off",
+  "max_tokens": 96,
+  "temperature": 0.2,
+  "stream": false
+}
+```
+
+### 7.2 请求进入哪个函数
+
+入口函数:
+
+- `chat_completions(request: Request)`
+
+这个函数依次做:
+
+1. 读取请求 JSON
+2. 调用 `build_chat_native_payload(payload)` 组装原生请求
+3. 请求 `LM Studio /api/v1/chat`
+4. 调用 `translate_chat_response(native_response)` 转成 OpenAI 风格响应
+
+### 7.3 OpenAI 请求如何翻译成 LM Studio 原生请求
+
+核心函数:
+
+- `build_chat_native_payload(request_payload)`
+
+字段映射如下:
+
+| OpenAI 字段 | 处理方式 | LM Studio 原生字段 |
+| --- | --- | --- |
+| `model` | 直接复制 | `model` |
+| `messages` | 压平成 transcript | `input` |
+| `system` 消息 | 合并 | `system_prompt` |
+| `reasoning` | 规范化 | `reasoning` |
+| `enable_thinking` | 作为 reasoning 的后备来源 | `reasoning` |
+| `max_tokens` | 重命名 | `max_output_tokens` |
+| `stream` | 如为 `true` 则传递 | `stream` |
+
+### 7.4 reasoning 是怎么决定的
+
+核心函数:
+
+- `resolve_reasoning_mode(request_payload)`
+
+优先级:
+
+1. 如果 `reasoning` 存在且合法,优先使用
+2. 否则如果 `enable_thinking` 存在:
+   - `true -> "on"`
+   - `false -> "off"`
+3. 都没有时默认 `"on"`
+
+### 7.5 messages 是怎么压平的
+
+核心函数:
+
+- `messages_to_native_input(messages)`
+
+规则:
+
+- `system` 消息不放进主输入,单独合并到 `system_prompt`
+- `user` 消息变成 `User: ...`
+- `assistant` 消息变成 `Assistant: ...`
+- 其他 role 变成 `RoleName: ...`
+
+例如:
+
+```json
+[
+  {"role": "system", "content": "be precise"},
+  {"role": "user", "content": "hello"},
+  {"role": "assistant", "content": "hi"},
+  {"role": "user", "content": "continue"}
+]
+```
+
+会变成:
+
+```text
+system_prompt = "be precise"
+
+input =
+User: hello
+
+Assistant: hi
+
+User: continue
+```
+
+### 7.6 适配器发给 LM Studio 的原生请求长什么样
+
+示意:
+
+```json
+{
+  "model": "qwen/qwen3.6-35b-a3b",
+  "input": "User: Compute 317 * 29. Give the final answer only.",
+  "reasoning": "off",
+  "store": false,
+  "max_output_tokens": 96,
+  "temperature": 0.2
+}
+```
+
+### 7.7 LM Studio 原生返回后怎么再变回 OpenAI 风格
+
+核心函数:
+
+- `translate_chat_response(native_response)`
+
+它会读取:
+
+- `output[type=message]`
+- `output[type=reasoning]`
+- `stats`
+
+然后组装成:
+
+- `choices[0].message.content`
+- `choices[0].message.reasoning_content`
+- OpenAI 风格 `usage`
+
+### 7.8 本地实测结果
+
+#### reasoning 关闭时
+
+- `content = "9193"`
+- `reasoning_content = null`
+- `reasoning_tokens = 0`
+
+#### reasoning 开启时
+
+- `content = ""`
+- `reasoning_content` 有思维内容
+- `reasoning_tokens > 0`
+
+---
+
+## 8. 流式 `chat/completions` 请求流程
+
+适用场景:
+
+- `POST /v1/chat/completions`
+- `stream = true`
+
+### 8.1 为什么需要流式桥接
+
+`LM Studio` 原生流式事件不是 OpenAI chunk 格式。
+
+例如 `LM Studio` 原生会发:
+
+```text
+event: message.delta
+data: {"type":"message.delta","content":"9"}
+```
+
+而上游 OpenAI 调用方期望的是:
+
+```text
+data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"9"}}]}
+```
+
+适配器要做的就是把前者实时翻译成后者。
+
+### 8.2 整体流式路径
+
+```mermaid
+sequenceDiagram
+    autonumber
+    participant C as 客户端 / LiteLLM
+    participant A as 适配器
+    participant L as LM Studio 原生 /api/v1/chat
+
+    C->>A: POST /v1/chat/completions {stream:true}
+    A->>A: build_chat_native_payload()
+    A->>L: POST /api/v1/chat {stream:true}
+    L-->>A: SSE 事件流
+    A->>A: parse_sse_event_blocks()
+    A->>A: translate_chat_stream_event()
+    A-->>C: OpenAI chat.completion.chunk SSE
+    A-->>C: stop chunk
+    A-->>C: data: [DONE]
+```
+
+### 8.3 参与流式处理的关键函数
+
+#### `stream_lmstudio_events(native_payload, translator, model, final_frame)`
+
+作用:
+
+1. 以 SSE 方式请求 `LM Studio /api/v1/chat`
+2. 持续读取返回的文本块
+3. 用 `\n\n` 把 SSE block 重组出来
+4. 调用 `parse_sse_event_blocks(...)` 解析
+5. 把每个事件交给 translator 翻译
+6. 把翻译后的 SSE frame 回推给上游
+7. 最后补 stop chunk 和 `[DONE]`
+
+#### `parse_sse_event_blocks(raw_text)`
+
+作用:
+
+- 从原始 SSE 文本中提取:
+  - `event: ...`
+  - `data: ...`
+
+输出类似:
+
+```json
+{
+  "event": "message.delta",
+  "data": {"type":"message.delta","content":"9"},
+  "data_raw": "{\"type\":\"message.delta\",\"content\":\"9\"}"
+}
+```
+
+#### `translate_chat_stream_event(event, model, chunk_id)`
+
+作用:
+
+- `message.delta` -> `choices[0].delta.content`
+- `reasoning.delta` -> `choices[0].delta.reasoning_content`
+
+以下类型会被忽略:
+
+- `chat.start`
+- `prompt_processing.start`
+- `prompt_processing.progress`
+- `prompt_processing.end`
+- `message.start`
+- `message.end`
+
+### 8.4 为什么最后会有 stop chunk 和 `[DONE]`
+
+为了兼容 OpenAI 流式习惯,适配器在流结束时主动补两个东西:
+
+1. 一个 final chunk,`finish_reason = "stop"`
+2. 一行 `data: [DONE]`
+
+这就是本地实测看到的结果:
+
+```text
+data: {"id":"chatcmpl-...","choices":[{"delta":{"content":"9"},"finish_reason":null}]}
+data: {"id":"chatcmpl-...","choices":[{"delta":{"content":"1"},"finish_reason":null}]}
+data: {"id":"chatcmpl-...","choices":[{"delta":{"content":"9"},"finish_reason":null}]}
+data: {"id":"chatcmpl-...","choices":[{"delta":{"content":"3"},"finish_reason":null}]}
+data: {"id":"chatcmpl-...","choices":[{"delta":{},"finish_reason":"stop"}]}
+data: [DONE]
+```
+
+---
+
+## 9. `responses` 接口处理流程
+
+适用场景:
+
+- `POST /v1/responses`
+
+### 9.1 输入归一化
+
+入口函数:
+
+- `responses_api(request: Request)`
+
+它支持两种输入:
+
+- `input` 是字符串
+- `input` 是 message 列表
+
+然后统一改造成内部 chat 风格载荷:
+
+```json
+{
+  "model": "...",
+  "messages": [...],
+  "reasoning": "...",
+  "stream": true|false,
+  "max_tokens": ...
+}
+```
+
+### 9.2 非流式 responses
+
+当 `stream = false`:
+
+1. 适配器请求 `LM Studio /api/v1/chat`
+2. 拿到原生聊天结果
+3. 用 `build_responses_response(native_response)` 转成 Responses API 风格 JSON
+
+### 9.3 流式 responses
+
+当 `stream = true`:
+
+适配器会主动输出:
+
+1. `response.created`
+2. 多个 `response.output_text.delta`
+3. 如有 reasoning,再输出 `response.reasoning.delta`
+4. `response.completed`
+
+负责事件翻译的函数:
+
+- `translate_responses_stream_event(...)`
+
+本地实测到的输出形态:
+
+```text
+data: {"type":"response.created", ...}
+data: {"type":"response.output_text.delta","delta":"9"}
+data: {"type":"response.output_text.delta","delta":"1"}
+data: {"type":"response.output_text.delta","delta":"9"}
+data: {"type":"response.output_text.delta","delta":"3"}
+data: {"type":"response.completed", ...}
+```
+
+---
+
+## 10. 简单代理接口的工作方式
+
+下面这些接口不需要复杂翻译:
+
+- `GET /v1/models`
+- `POST /v1/embeddings`
+- `POST /v1/completions`
+
+它们统一通过:
+
+- `proxy_request(...)`
+
+来处理。
+
+### `proxy_request(...)` 做了什么
+
+1. 用 `httpx` 向上游发起请求
+2. 保持原请求方法和 body
+3. 原样保留响应内容
+4. 去掉一些纯传输层 header,例如:
+   - `content-length`
+   - `transfer-encoding`
+   - `connection`
+   - `content-encoding`
+
+这样能避免代理时 header 冲突。
+
+---
+
+## 11. 原生接口透传流程
+
+如果外部直接请求:
+
+- `/api/v1/...`
+- `/api/v0/...`
+
+适配器**不做翻译**。
+
+对应入口:
+
+- `native_v1_passthrough(...)`
+- `native_v0_passthrough(...)`
+
+它们会:
+
+1. 读取原始 body 字节
+2. 保留原请求方法
+3. 保留 query string
+4. 转发到 `LM Studio` 对应原生路径
+5. 原样返回上游结果
+
+所以这个适配器除了是 OpenAI 兼容层,也同时是一个 LM Studio 网关。
+
+---
+
+## 12. 占位接口流程
+
+当前以下能力先做成占位:
+
+- audio
+- images
+- files
+- moderations
+
+核心函数:
+
+- `build_stub_error(feature_name)`
+
+返回格式:
+
+```json
+{
+  "error": {
+    "message": "audio.transcriptions is not implemented by this adapter yet",
+    "type": "not_implemented",
+    "param": null,
+    "code": "not_implemented"
+  }
+}
+```
+
+状态码:
+
+- `501`
+
+这样调用方至少能拿到结构化错误,而不是路由不存在。
+
+---
+
+## 13. 错误处理策略
+
+### 13.1 本地参数校验错误
+
+例如:
+
+- `messages` 缺失
+- 全部消息都没有可用文本
+
+这些错误会在请求到达 `LM Studio` 之前就被适配器拦住。
+
+### 13.2 上游 HTTP 错误
+
+如果 `LM Studio` 返回 `>= 400`:
+
+- 非流式路径直接返回上游状态和结果
+- 流式路径会输出一个 SSE 错误 frame,随后输出 `[DONE]`
+
+### 13.3 功能未实现
+
+未实现接口统一返回:
+
+- `501 not_implemented`
+
+---
+
+## 14. LiteLLM 是怎么接这个适配器的
+
+推荐拓扑:
+
+```mermaid
+flowchart LR
+    accTitle: LiteLLM 集成拓扑
+    accDescr: LiteLLM 把适配器当成 OpenAI 兼容后端,而适配器再调用 LM Studio 原生能力。
+
+    app["你的应用"]
+    litellm["LiteLLM Proxy 或 SDK"]
+    adapter["适配器 :8010"]
+    lmstudio["LM Studio :7860"]
+
+    app --> litellm
+    litellm --> adapter
+    adapter --> lmstudio
+
+    classDef a fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
+    classDef b fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#78350f
+    classDef c fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d
+
+    class app,litellm a
+    class adapter b
+    class lmstudio c
+```
+
+对 LiteLLM 来说,它只需要把这个适配器当成普通 OpenAI-compatible backend:
+
+- `api_base = http://127.0.0.1:8010`
+- `model` 仍然写你要调用的 `LM Studio` 模型名
+
+这样:
+
+- LiteLLM 负责统一调用层
+- 适配器负责保留 `LM Studio` 原生 reasoning 控制能力
+
+---
+
+## 15. 本地已验证行为
+
+已对 `qwen/qwen3.6-35b-a3b` 做过实测。
+
+### 非流式 chat
+
+- `reasoning: "off"` -> 直接输出最终答案,`reasoning_tokens = 0`
+- `reasoning: "on"` -> 返回 reasoning 内容,`reasoning_tokens > 0`
+
+### 流式 chat
+
+- 能正确输出 SSE 内容 chunk
+- 能输出 final stop chunk
+- 能输出 `[DONE]`
+
+### 流式 responses
+
+- `response.created`
+- `response.output_text.delta`
+- `response.completed`
+
+---
+
+## 16. 总结
+
+这个适配器的工作机制可以归纳成五步:
+
+1. 接收 OpenAI 风格请求
+2. 归一化并翻译成 `LM Studio` 原生请求
+3. 调用上游 `LM Studio`
+4. 把原生返回或原生 SSE 事件翻译回 OpenAI 风格
+5. 返回给 LiteLLM 或其他调用方
+
+它真正解决的核心不是“重做一套 OpenAI API”,而是:
+
+- 对外维持统一协议
+- 对内保住 `LM Studio` 原生 reasoning 能力
+- 同时兼容原生接口直通与流式事件桥接
+
+这就是 `LiteLLM + LM Studio native reasoning` 能一起工作的根本原因。

+ 533 - 0
services/litellm-lmstudio-adapter/litellm_lmstudio_adapter.py

@@ -0,0 +1,533 @@
+#!/usr/bin/env python3
+import argparse
+import json
+import time
+from typing import Any
+
+import httpx
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse, Response, StreamingResponse
+import uvicorn
+
+
+DEFAULT_UPSTREAM = "http://127.0.0.1:7860"
+PASS_THROUGH_CHAT_FIELDS = {
+    "temperature",
+    "top_p",
+    "presence_penalty",
+    "frequency_penalty",
+    "stop",
+    "seed",
+}
+STUB_ENDPOINTS = {
+    "/v1/audio/speech": "audio.speech",
+    "/v1/audio/transcriptions": "audio.transcriptions",
+    "/v1/audio/translations": "audio.translations",
+    "/v1/images/generations": "images.generations",
+    "/v1/images/edits": "images.edits",
+    "/v1/images/variations": "images.variations",
+    "/v1/moderations": "moderations",
+    "/v1/files": "files",
+}
+
+app = FastAPI(title="LiteLLM LM Studio Adapter")
+app.state.upstream_base = DEFAULT_UPSTREAM
+app.state.request_timeout = 1800
+
+
+def normalize_reasoning_value(value: Any) -> str | None:
+    if isinstance(value, str):
+        lowered = value.strip().lower()
+        if lowered in {"on", "off"}:
+            return lowered
+    if isinstance(value, bool):
+        return "on" if value else "off"
+    return None
+
+
+def resolve_reasoning_mode(request_payload: dict[str, Any]) -> str:
+    normalized = normalize_reasoning_value(request_payload.get("reasoning"))
+    if normalized is not None:
+        return normalized
+
+    enable_thinking = request_payload.get("enable_thinking")
+    if isinstance(enable_thinking, bool):
+        return "on" if enable_thinking else "off"
+
+    return "on"
+
+
+def extract_text_content(content: Any) -> str:
+    if isinstance(content, str):
+        return content
+
+    if isinstance(content, list):
+        text_parts: list[str] = []
+        for item in content:
+            if isinstance(item, dict) and item.get("type") == "text":
+                text = item.get("text")
+                if isinstance(text, str):
+                    text_parts.append(text)
+        return "\n".join(part for part in text_parts if part)
+
+    return ""
+
+
+def messages_to_native_input(messages: list[dict[str, Any]]) -> tuple[str, str | None]:
+    transcript_parts: list[str] = []
+    system_parts: list[str] = []
+
+    for message in messages:
+        if not isinstance(message, dict):
+            continue
+
+        role = str(message.get("role") or "").strip().lower()
+        content = extract_text_content(message.get("content"))
+        if not content:
+            continue
+
+        if role == "system":
+            system_parts.append(content)
+        elif role == "user":
+            transcript_parts.append(f"User: {content}")
+        elif role == "assistant":
+            transcript_parts.append(f"Assistant: {content}")
+        else:
+            transcript_parts.append(f"{role.title() or 'Message'}: {content}")
+
+    return "\n\n".join(transcript_parts), "\n\n".join(system_parts) or None
+
+
+def build_chat_native_payload(request_payload: dict[str, Any]) -> dict[str, Any]:
+    messages = request_payload.get("messages")
+    if not isinstance(messages, list) or not messages:
+        raise ValueError("messages must be a non-empty list")
+
+    input_text, system_prompt = messages_to_native_input(messages)
+    if not input_text:
+        raise ValueError("messages must include at least one non-system text message")
+
+    native_payload: dict[str, Any] = {
+        "model": request_payload.get("model"),
+        "input": input_text,
+        "reasoning": resolve_reasoning_mode(request_payload),
+        "store": False,
+    }
+    if system_prompt:
+        native_payload["system_prompt"] = system_prompt
+    if "max_tokens" in request_payload:
+        native_payload["max_output_tokens"] = request_payload["max_tokens"]
+    for field in PASS_THROUGH_CHAT_FIELDS:
+        if field in request_payload:
+            native_payload[field] = request_payload[field]
+    if request_payload.get("stream") is True:
+        native_payload["stream"] = True
+    return native_payload
+
+
+def _collect_output_text(native_response: dict[str, Any], output_type: str) -> list[str]:
+    texts: list[str] = []
+    for item in native_response.get("output") or []:
+        if item.get("type") == output_type:
+            content = item.get("content")
+            if isinstance(content, str):
+                texts.append(content)
+    return texts
+
+
+def translate_chat_response(native_response: dict[str, Any]) -> dict[str, Any]:
+    model = native_response.get("model") or native_response.get("model_instance_id")
+    message_parts = _collect_output_text(native_response, "message")
+    reasoning_parts = _collect_output_text(native_response, "reasoning")
+    stats = native_response.get("stats") or {}
+    content = "\n".join(part for part in message_parts if part)
+    reasoning_content = "\n".join(part for part in reasoning_parts if part)
+    message = {"role": "assistant", "content": content, "tool_calls": []}
+    if reasoning_content:
+        message["reasoning_content"] = reasoning_content
+    return {
+        "id": native_response.get("id", f"chatcmpl-{int(time.time() * 1000)}"),
+        "object": "chat.completion",
+        "created": int(time.time()),
+        "model": model,
+        "choices": [
+            {
+                "index": 0,
+                "message": message,
+                "logprobs": None,
+                "finish_reason": "stop",
+            }
+        ],
+        "usage": {
+            "prompt_tokens": stats.get("input_tokens", 0),
+            "completion_tokens": stats.get("total_output_tokens", 0),
+            "total_tokens": stats.get("input_tokens", 0) + stats.get("total_output_tokens", 0),
+            "completion_tokens_details": {
+                "reasoning_tokens": stats.get("reasoning_output_tokens", 0),
+            },
+        },
+        "stats": stats,
+        "system_fingerprint": model,
+    }
+
+
+def build_responses_response(native_response: dict[str, Any]) -> dict[str, Any]:
+    chat_response = translate_chat_response(native_response)
+    message = chat_response["choices"][0]["message"]
+    output = []
+    if message.get("reasoning_content"):
+        output.append(
+            {
+                "id": "rs_reasoning_0",
+                "type": "reasoning",
+                "summary": [],
+                "content": [{"type": "output_text", "text": message["reasoning_content"]}],
+            }
+        )
+    output.append(
+        {
+            "id": "msg_0",
+            "type": "message",
+            "role": "assistant",
+            "content": [{"type": "output_text", "text": message.get("content", "")}],
+        }
+    )
+    return {
+        "id": f"resp_{int(time.time() * 1000)}",
+        "object": "response",
+        "created_at": int(time.time()),
+        "model": chat_response["model"],
+        "output": output,
+        "usage": chat_response["usage"],
+        "status": "completed",
+    }
+
+
+def sse_frame(data: dict[str, Any]) -> str:
+    return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
+
+
+def parse_sse_event_blocks(raw_text: str):
+    for block in raw_text.split("\n\n"):
+        block = block.strip()
+        if not block:
+            continue
+        event_name = None
+        data_lines: list[str] = []
+        for line in block.splitlines():
+            if line.startswith("event:"):
+                event_name = line[6:].strip()
+            elif line.startswith("data:"):
+                data_lines.append(line[5:].strip())
+        data_raw = "\n".join(data_lines)
+        parsed_data = None
+        if data_raw and data_raw != "[DONE]":
+            try:
+                parsed_data = json.loads(data_raw)
+            except json.JSONDecodeError:
+                parsed_data = None
+        yield {
+            "event": event_name,
+            "data": parsed_data,
+            "data_raw": data_raw,
+        }
+
+
+def translate_chat_stream_event(event: dict[str, Any], model: str, chunk_id: str) -> str | None:
+    event_type = event.get("type")
+    content = event.get("content")
+    if not isinstance(content, str):
+        return None
+
+    delta: dict[str, Any] = {}
+    if event_type in {"reasoning", "reasoning.delta"}:
+        delta["reasoning_content"] = content
+    elif event_type in {"message", "message.delta"}:
+        delta["content"] = content
+    else:
+        return None
+
+    payload = {
+        "id": chunk_id,
+        "object": "chat.completion.chunk",
+        "created": int(time.time()),
+        "model": model,
+        "choices": [{"index": 0, "delta": delta, "finish_reason": None}],
+    }
+    return sse_frame(payload)
+
+
+def translate_responses_stream_event(event: dict[str, Any], model: str, response_id: str) -> list[str]:
+    event_type = event.get("type")
+    content = event.get("content")
+    if not isinstance(content, str):
+        return []
+
+    if event_type in {"reasoning", "reasoning.delta"}:
+        return [
+            sse_frame(
+                {
+                    "type": "response.reasoning.delta",
+                    "response_id": response_id,
+                    "model": model,
+                    "delta": content,
+                }
+            )
+        ]
+
+    if event_type in {"message", "message.delta"}:
+        return [
+            sse_frame(
+                {
+                    "type": "response.output_text.delta",
+                    "response_id": response_id,
+                    "model": model,
+                    "delta": content,
+                }
+            )
+        ]
+
+    return []
+
+
+def build_stub_error(feature_name: str) -> JSONResponse:
+    return JSONResponse(
+        status_code=501,
+        content={
+            "error": {
+                "message": f"{feature_name} is not implemented by this adapter yet",
+                "type": "not_implemented",
+                "param": None,
+                "code": "not_implemented",
+            }
+        },
+    )
+
+
+async def get_async_client() -> httpx.AsyncClient:
+    return httpx.AsyncClient(timeout=app.state.request_timeout)
+
+
+async def proxy_request(method: str, path: str, body: bytes | None = None, headers: dict[str, str] | None = None) -> Response:
+    async with await get_async_client() as client:
+        response = await client.request(
+            method,
+            f"{app.state.upstream_base}{path}",
+            content=body,
+            headers=headers,
+        )
+    response_headers = {
+        key: value
+        for key, value in response.headers.items()
+        if key.lower() not in {"content-length", "transfer-encoding", "connection", "content-encoding"}
+    }
+    return Response(
+        content=response.content,
+        status_code=response.status_code,
+        headers=response_headers,
+        media_type=response.headers.get("content-type"),
+    )
+
+
+async def stream_lmstudio_events(native_payload: dict[str, Any], translator, model: str, final_frame: str):
+    async with httpx.AsyncClient(timeout=app.state.request_timeout) as client:
+        async with client.stream(
+            "POST",
+            f"{app.state.upstream_base}/api/v1/chat",
+            json=native_payload,
+            headers={"Accept": "text/event-stream"},
+        ) as response:
+            if response.status_code >= 400:
+                raw = await response.aread()
+                payload = {
+                    "error": {
+                        "message": raw.decode("utf-8", errors="replace"),
+                        "type": "upstream_error",
+                    }
+                }
+                yield sse_frame(payload)
+                yield "data: [DONE]\n\n"
+                return
+
+            buffer = ""
+            async for text in response.aiter_text():
+                buffer += text
+                while "\n\n" in buffer:
+                    block, buffer = buffer.split("\n\n", 1)
+                    for parsed_block in parse_sse_event_blocks(block + "\n\n"):
+                        if parsed_block["data_raw"] == "[DONE]":
+                            continue
+                        event = parsed_block["data"]
+                        if not isinstance(event, dict):
+                            continue
+                        translated = translator(event, model)
+                        if translated is None:
+                            continue
+                        if isinstance(translated, str):
+                            if translated:
+                                yield translated
+                        else:
+                            for frame in translated:
+                                yield frame
+    if final_frame:
+        yield final_frame
+    yield "data: [DONE]\n\n"
+
+
+@app.get("/healthz")
+async def healthz() -> dict[str, str]:
+    return {"status": "ok"}
+
+
+@app.get("/v1/models")
+async def list_models() -> Response:
+    return await proxy_request("GET", "/v1/models")
+
+
+@app.post("/v1/chat/completions")
+async def chat_completions(request: Request) -> Response:
+    payload = await request.json()
+    native_payload = build_chat_native_payload(payload)
+    model = str(payload.get("model") or "")
+
+    if payload.get("stream") is True:
+        chunk_id = f"chatcmpl-{int(time.time() * 1000)}"
+
+        def translator(event: dict[str, Any], event_model: str):
+            return translate_chat_stream_event(event, event_model, chunk_id)
+
+        final_payload = {
+            "id": chunk_id,
+            "object": "chat.completion.chunk",
+            "created": int(time.time()),
+            "model": model,
+            "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
+        }
+        return StreamingResponse(
+            stream_lmstudio_events(native_payload, translator, model, sse_frame(final_payload)),
+            media_type="text/event-stream",
+        )
+
+    async with await get_async_client() as client:
+        response = await client.post(f"{app.state.upstream_base}/api/v1/chat", json=native_payload)
+    return JSONResponse(status_code=response.status_code, content=translate_chat_response(response.json()))
+
+
+@app.post("/v1/responses")
+async def responses_api(request: Request) -> Response:
+    payload = await request.json()
+    messages = payload.get("input")
+    if isinstance(messages, str):
+        payload = {
+            "model": payload.get("model"),
+            "messages": [{"role": "user", "content": messages}],
+            "reasoning": payload.get("reasoning"),
+            "enable_thinking": payload.get("enable_thinking"),
+            "stream": payload.get("stream"),
+            "max_tokens": payload.get("max_output_tokens") or payload.get("max_tokens"),
+            "temperature": payload.get("temperature"),
+        }
+    elif isinstance(messages, list):
+        payload = {
+            "model": payload.get("model"),
+            "messages": messages,
+            "reasoning": payload.get("reasoning"),
+            "enable_thinking": payload.get("enable_thinking"),
+            "stream": payload.get("stream"),
+            "max_tokens": payload.get("max_output_tokens") or payload.get("max_tokens"),
+            "temperature": payload.get("temperature"),
+        }
+    else:
+        raise ValueError("responses input must be a string or a message list")
+
+    native_payload = build_chat_native_payload(payload)
+    model = str(payload.get("model") or "")
+    response_id = f"resp_{int(time.time() * 1000)}"
+
+    if payload.get("stream") is True:
+        def translator(event: dict[str, Any], event_model: str):
+            return translate_responses_stream_event(event, event_model, response_id)
+
+        final_frame = sse_frame(
+            {
+                "type": "response.completed",
+                "response": {
+                    "id": response_id,
+                    "model": model,
+                    "status": "completed",
+                },
+            }
+        )
+        initial_frame = sse_frame(
+            {
+                "type": "response.created",
+                "response": {
+                    "id": response_id,
+                    "model": model,
+                    "status": "in_progress",
+                },
+            }
+        )
+
+        async def generator():
+            yield initial_frame
+            async for frame in stream_lmstudio_events(native_payload, translator, model, final_frame):
+                yield frame
+
+        return StreamingResponse(generator(), media_type="text/event-stream")
+
+    async with await get_async_client() as client:
+        response = await client.post(f"{app.state.upstream_base}/api/v1/chat", json=native_payload)
+    return JSONResponse(status_code=response.status_code, content=build_responses_response(response.json()))
+
+
+@app.post("/v1/embeddings")
+async def embeddings(request: Request) -> Response:
+    body = await request.body()
+    return await proxy_request("POST", "/v1/embeddings", body=body, headers={"Content-Type": "application/json"})
+
+
+@app.post("/v1/completions")
+async def completions(request: Request) -> Response:
+    body = await request.body()
+    return await proxy_request("POST", "/v1/completions", body=body, headers={"Content-Type": "application/json"})
+
+
+@app.api_route("/api/v1/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
+async def native_v1_passthrough(path: str, request: Request) -> Response:
+    body = await request.body()
+    headers = {"Content-Type": request.headers.get("content-type", "application/json")}
+    query = f"?{request.url.query}" if request.url.query else ""
+    return await proxy_request(request.method, f"/api/v1/{path}{query}", body=body, headers=headers)
+
+
+@app.api_route("/api/v0/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
+async def native_v0_passthrough(path: str, request: Request) -> Response:
+    body = await request.body()
+    headers = {"Content-Type": request.headers.get("content-type", "application/json")}
+    query = f"?{request.url.query}" if request.url.query else ""
+    return await proxy_request(request.method, f"/api/v0/{path}{query}", body=body, headers=headers)
+
+
+for route_path, feature_name in STUB_ENDPOINTS.items():
+    async def _stub(feature=feature_name):
+        return build_stub_error(feature)
+
+    app.add_api_route(route_path, _stub, methods=["POST", "GET", "DELETE"])
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--host", default="127.0.0.1")
+    parser.add_argument("--port", type=int, default=8010)
+    parser.add_argument("--upstream", default=DEFAULT_UPSTREAM)
+    parser.add_argument("--timeout", type=int, default=1800)
+    args = parser.parse_args()
+    app.state.upstream_base = args.upstream.rstrip("/")
+    app.state.request_timeout = args.timeout
+    uvicorn.run(app, host=args.host, port=args.port)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 141 - 0
services/litellm-lmstudio-adapter/tests/test_adapter.py

@@ -0,0 +1,141 @@
+import json
+import importlib.util
+from pathlib import Path
+import unittest
+
+
+MODULE_PATH = (
+    Path(__file__).resolve().parent.parent / "litellm_lmstudio_adapter.py"
+)
+if MODULE_PATH.exists():
+    spec = importlib.util.spec_from_file_location("litellm_lmstudio_adapter", MODULE_PATH)
+    module = importlib.util.module_from_spec(spec)
+    assert spec is not None and spec.loader is not None
+    spec.loader.exec_module(module)
+    adapter = module
+else:  # pragma: no cover
+    adapter = None
+
+
+class LiteLLMLMStudioAdapterTests(unittest.TestCase):
+    def setUp(self):
+        if adapter is None:
+            self.fail("litellm_lmstudio_adapter.app is not available")
+
+    def test_build_chat_native_payload_maps_reasoning(self):
+        payload = {
+            "model": "qwen/qwen3.6-35b-a3b",
+            "messages": [
+                {"role": "system", "content": "be precise"},
+                {"role": "user", "content": "hello"},
+                {"role": "assistant", "content": "hi"},
+                {"role": "user", "content": "continue"},
+            ],
+            "reasoning": "off",
+            "max_tokens": 64,
+            "temperature": 0.2,
+        }
+
+        native_payload = adapter.build_chat_native_payload(payload)
+
+        self.assertEqual(native_payload["model"], "qwen/qwen3.6-35b-a3b")
+        self.assertEqual(
+            native_payload["input"],
+            "User: hello\n\nAssistant: hi\n\nUser: continue",
+        )
+        self.assertEqual(native_payload["system_prompt"], "be precise")
+        self.assertEqual(native_payload["reasoning"], "off")
+        self.assertEqual(native_payload["max_output_tokens"], 64)
+
+    def test_translate_chat_stream_chunk_emits_reasoning_delta(self):
+        event = {
+            "type": "reasoning.delta",
+            "content": "step 1",
+            "model": "qwen/qwen3.6-35b-a3b",
+        }
+
+        chunk = adapter.translate_chat_stream_event(
+            event=event,
+            model="qwen/qwen3.6-35b-a3b",
+            chunk_id="chatcmpl-test",
+        )
+
+        self.assertIsNotNone(chunk)
+        parsed = json.loads(chunk.removeprefix("data: ").strip())
+        self.assertEqual(parsed["object"], "chat.completion.chunk")
+        self.assertEqual(parsed["choices"][0]["delta"]["reasoning_content"], "step 1")
+
+    def test_translate_chat_stream_chunk_emits_content_delta(self):
+        event = {
+            "type": "message.delta",
+            "content": "9193",
+            "model": "qwen/qwen3.6-35b-a3b",
+        }
+
+        chunk = adapter.translate_chat_stream_event(
+            event=event,
+            model="qwen/qwen3.6-35b-a3b",
+            chunk_id="chatcmpl-test",
+        )
+
+        parsed = json.loads(chunk.removeprefix("data: ").strip())
+        self.assertEqual(parsed["choices"][0]["delta"]["content"], "9193")
+
+    def test_translate_chat_stream_chunk_ignores_non_content_events(self):
+        event = {
+            "type": "prompt_processing.start",
+        }
+
+        chunk = adapter.translate_chat_stream_event(
+            event=event,
+            model="qwen/qwen3.6-35b-a3b",
+            chunk_id="chatcmpl-test",
+        )
+
+        self.assertIsNone(chunk)
+
+    def test_translate_responses_stream_event_emits_output_text_delta(self):
+        event = {
+            "type": "message.delta",
+            "content": "9193",
+            "model": "qwen/qwen3.6-35b-a3b",
+        }
+
+        frames = adapter.translate_responses_stream_event(
+            event=event,
+            model="qwen/qwen3.6-35b-a3b",
+            response_id="resp_test",
+        )
+
+        self.assertTrue(frames)
+        parsed = json.loads(frames[0].removeprefix("data: ").strip())
+        self.assertEqual(parsed["type"], "response.output_text.delta")
+        self.assertEqual(parsed["delta"], "9193")
+
+    def test_parse_sse_event_blocks_extracts_event_and_data(self):
+        raw = (
+            "event: content_block.delta\n"
+            "data: {\"type\":\"message\",\"content\":\"9193\"}\n\n"
+            "event: done\n"
+            "data: [DONE]\n\n"
+        )
+
+        blocks = list(adapter.parse_sse_event_blocks(raw))
+
+        self.assertEqual(len(blocks), 2)
+        self.assertEqual(blocks[0]["event"], "content_block.delta")
+        self.assertEqual(blocks[0]["data"]["content"], "9193")
+        self.assertEqual(blocks[1]["event"], "done")
+        self.assertEqual(blocks[1]["data_raw"], "[DONE]")
+
+    def test_build_stub_error_uses_openai_shape(self):
+        response = adapter.build_stub_error("audio.transcriptions")
+
+        self.assertEqual(response.status_code, 501)
+        payload = json.loads(response.body.decode("utf-8"))
+        self.assertEqual(payload["error"]["type"], "not_implemented")
+        self.assertIn("audio.transcriptions", payload["error"]["message"])
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 120 - 0
tests/test_lmstudio_wrapper_generator.py

@@ -0,0 +1,120 @@
+import importlib.util
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+
+MODULE_PATH = Path(__file__).resolve().parent.parent / "tools" / "lmstudio" / "wrapper_generator.py"
+if MODULE_PATH.exists():
+    spec = importlib.util.spec_from_file_location("wrapper_generator", MODULE_PATH)
+    module = importlib.util.module_from_spec(spec)
+    assert spec is not None and spec.loader is not None
+    spec.loader.exec_module(module)
+    gen = module
+else:  # pragma: no cover
+    gen = None
+
+
+class WrapperGeneratorTests(unittest.TestCase):
+    def setUp(self):
+        if gen is None:
+            self.fail("tools.lmstudio.wrapper_generator is not available")
+
+        self.official_model_yaml = """\
+model: qwen/qwen3.6-35b-a3b
+base:
+  - key: lmstudio-community/qwen3.6-35b-a3b-gguf
+metadataOverrides:
+  domain: llm
+  architectures:
+    - qwen35moe
+  compatibilityTypes:
+    - gguf
+  reasoning: true
+config:
+  operation:
+    fields:
+      - key: llm.prediction.promptTemplate
+        value:
+          type: jinja
+          jinjaPromptTemplate:
+            template: |
+              {%- if add_generation_prompt %}
+                  {%- if enable_thinking is defined and enable_thinking is false %}
+                      {{- '<think>\\n\\n</think>\\n\\n' }}
+                  {%- else %}
+                      {{- '<think>\\n' }}
+                  {%- endif %}
+              {%- endif %}
+customFields:
+  - key: enableThinking
+    effects:
+      - type: setJinjaVariable
+        variable: enable_thinking
+  - key: preserveThinking
+    effects:
+      - type: setJinjaVariable
+        variable: preserve_thinking
+"""
+        self.official_manifest = {
+            "type": "model",
+            "owner": "qwen",
+            "name": "qwen3.6-35b-a3b",
+            "dependencies": [
+                {
+                    "type": "model",
+                    "purpose": "baseModel",
+                    "modelKeys": ["lmstudio-community/qwen3.6-35b-a3b-gguf"],
+                }
+            ],
+            "revision": 1,
+        }
+
+    def test_build_wrapper_files_contains_reasoning_and_target_key(self):
+        result = gen.build_wrapper_files(
+            target_model_key="qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
+            wrapper_owner="local",
+            wrapper_name="qwen3.6-35b-a3b-hauhaucs-aggressive",
+            official_model_yaml_text=self.official_model_yaml,
+            official_manifest=self.official_manifest,
+        )
+
+        self.assertIn("reasoning: true", result["model.yaml"])
+        self.assertIn("key: enableThinking", result["model.yaml"])
+        self.assertIn("key: preserveThinking", result["model.yaml"])
+        self.assertIn(
+            "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
+            result["model.yaml"],
+        )
+
+        manifest = json.loads(result["manifest.json"])
+        self.assertEqual(manifest["owner"], "local")
+        self.assertEqual(manifest["name"], "qwen3.6-35b-a3b-hauhaucs-aggressive")
+        self.assertEqual(
+            manifest["dependencies"][0]["modelKeys"],
+            ["qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive"],
+        )
+
+    def test_dry_run_does_not_write_output(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            output_dir = Path(tmpdir) / "wrapper"
+            summary = gen.generate_wrapper(
+                output_dir=output_dir,
+                target_model_key="qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
+                wrapper_owner="local",
+                wrapper_name="qwen3.6-35b-a3b-hauhaucs-aggressive",
+                official_model_yaml_text=self.official_model_yaml,
+                official_manifest=self.official_manifest,
+                dry_run=True,
+            )
+
+            self.assertFalse(output_dir.exists())
+            self.assertTrue(summary["dry_run"])
+            self.assertIn("model.yaml", summary["files"])
+            self.assertIn("manifest.json", summary["files"])
+            self.assertIn("README.md", summary["files"])
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 94 - 0
tests/test_openai_reasoning_proxy.py

@@ -0,0 +1,94 @@
+import importlib.util
+import unittest
+from pathlib import Path
+
+
+MODULE_PATH = Path(__file__).resolve().parent.parent / "tools" / "lmstudio" / "openai_reasoning_proxy.py"
+if MODULE_PATH.exists():
+    spec = importlib.util.spec_from_file_location("openai_reasoning_proxy", MODULE_PATH)
+    module = importlib.util.module_from_spec(spec)
+    assert spec is not None and spec.loader is not None
+    spec.loader.exec_module(module)
+    proxy = module
+else:  # pragma: no cover
+    proxy = None
+
+
+class OpenAIReasoningProxyTests(unittest.TestCase):
+    def setUp(self):
+        if proxy is None:
+            self.fail("tools.lmstudio.openai_reasoning_proxy is not available")
+
+    def test_build_native_payload_prefers_reasoning_field(self):
+        request_payload = {
+            "model": "qwen/qwen3.6-35b-a3b",
+            "messages": [
+                {"role": "system", "content": "be precise"},
+                {"role": "user", "content": "hello"},
+                {"role": "assistant", "content": "hi"},
+                {"role": "user", "content": "continue"},
+            ],
+            "max_tokens": 64,
+            "temperature": 0.1,
+            "reasoning": "off",
+            "enable_thinking": True,
+        }
+
+        native_payload = proxy.build_native_payload(request_payload)
+
+        self.assertEqual(native_payload["model"], "qwen/qwen3.6-35b-a3b")
+        self.assertEqual(
+            native_payload["input"],
+            "User: hello\n\nAssistant: hi\n\nUser: continue",
+        )
+        self.assertEqual(native_payload["max_output_tokens"], 64)
+        self.assertEqual(native_payload["temperature"], 0.1)
+        self.assertEqual(native_payload["reasoning"], "off")
+        self.assertEqual(native_payload["system_prompt"], "be precise")
+        self.assertFalse(native_payload["store"])
+
+    def test_build_native_payload_maps_enable_thinking_false_to_reasoning_off(self):
+        request_payload = {
+            "model": "qwen/qwen3.6-35b-a3b",
+            "messages": [{"role": "user", "content": "hello"}],
+            "enable_thinking": False,
+        }
+
+        native_payload = proxy.build_native_payload(request_payload)
+
+        self.assertEqual(native_payload["reasoning"], "off")
+
+    def test_translate_native_response_keeps_reasoning_content(self):
+        native_response = {
+            "id": "chatcmpl-native",
+            "model": "qwen/qwen3.6-35b-a3b",
+            "output": [
+                {"type": "reasoning", "content": "reasoning text"},
+                {"type": "message", "content": "final answer"},
+            ],
+            "stats": {
+                "input_tokens": 11,
+                "total_output_tokens": 19,
+                "reasoning_output_tokens": 7,
+            },
+        }
+
+        translated = proxy.translate_native_response(native_response)
+
+        self.assertEqual(translated["object"], "chat.completion")
+        self.assertEqual(translated["model"], "qwen/qwen3.6-35b-a3b")
+        self.assertEqual(translated["choices"][0]["message"]["content"], "final answer")
+        self.assertEqual(
+            translated["choices"][0]["message"]["reasoning_content"],
+            "reasoning text",
+        )
+        self.assertEqual(translated["usage"]["prompt_tokens"], 11)
+        self.assertEqual(translated["usage"]["completion_tokens"], 19)
+        self.assertEqual(
+            translated["usage"]["completion_tokens_details"]["reasoning_tokens"],
+            7,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 96 - 0
tools/lmstudio/README.md

@@ -0,0 +1,96 @@
+# LM Studio 工具集
+
+这个目录放的是和 `LM Studio` 相关的**工具脚本**,不是长期运行服务。
+
+## 目录定位
+
+- `tools/lmstudio/`
+  - 可执行工具
+  - 临时代理
+  - 包装器生成器
+- `tools/lmstudio/probes/`
+  - 实测脚本
+  - 行为探针
+  - 接口验证脚本
+
+如果一个组件需要长期运行、对外提供 HTTP 服务,它应该放到 `services/` 下,而不是这里。
+
+## 文件说明
+
+### [wrapper_generator.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\wrapper_generator.py)
+
+用途:
+
+- 基于官方 `Qwen3.6` 的 `model.yaml` / `manifest.json`
+- 生成一个面向本地模型的 `LM Studio wrapper`
+- 让本地模型具备 reasoning 元数据、UI reasoning 开关、原生 `/api/v1/chat` reasoning 能力
+
+适合场景:
+
+- 你有一个本地 GGUF
+- 模型本身支持 thinking 开关
+- 但 `LM Studio` 没把它识别成 reasoning-capable model
+
+### [openai_reasoning_proxy.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\openai_reasoning_proxy.py)
+
+用途:
+
+- 这是早期做的一个较小型 OpenAI 兼容代理
+- 重点是把 `reasoning` / `enable_thinking` 翻译到 `LM Studio /api/v1/chat`
+
+定位:
+
+- 工具型原型
+- 小范围实验和快速验证
+
+说明:
+
+- 现在更完整的长期服务方案已经在:
+  [services/litellm-lmstudio-adapter](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\services\litellm-lmstudio-adapter)
+
+## probes 子目录
+
+### [probes/lmstudio_thinking_probe.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\probes\lmstudio_thinking_probe.py)
+
+用途:
+
+- 顺序测试 `LM Studio` 的 OpenAI 兼容接口和原生接口
+- 对比官方模型、本地模型、wrapper 模型在 reasoning 开关上的差异
+
+### [probes/lmstudio_openai_reasoning_probe.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\probes\lmstudio_openai_reasoning_probe.py)
+
+用途:
+
+- 针对 `LM Studio /v1/chat/completions`
+- 批量测试各种可能的 reasoning 字段
+- 判断 OpenAI 兼容层是否真的把 thinking 开关传到了底层模板
+
+### [probes/llama_cpp_openai_probe.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\probes\llama_cpp_openai_probe.py)
+
+用途:
+
+- 测试 `llama.cpp` OpenAI 兼容接口
+- 对比官方模型和 HauhauCS 模型在 OpenAI 兼容层下的 reasoning 行为
+
+## 什么时候用哪个
+
+| 需求 | 用哪个 |
+| --- | --- |
+| 生成本地模型 wrapper | `wrapper_generator.py` |
+| 快速做一个 reasoning 翻译代理 | `openai_reasoning_proxy.py` |
+| 验证 LM Studio 原生与兼容层差异 | `probes/lmstudio_thinking_probe.py` |
+| 批量试 OpenAI reasoning 字段 | `probes/lmstudio_openai_reasoning_probe.py` |
+| 测 llama.cpp 路由器或兼容层 | `probes/llama_cpp_openai_probe.py` |
+
+## 和 services 的区别
+
+- `tools/` 是工具
+  - 运行一下就结束
+  - 用来生成、验证、排查
+- `services/` 是服务
+  - 需要启动后持续监听端口
+  - 用来接入 LiteLLM、应用、统一 API 调用链
+
+当前正式的服务型组件是:
+
+- [services/litellm-lmstudio-adapter](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\services\litellm-lmstudio-adapter)

+ 285 - 0
tools/lmstudio/openai_reasoning_proxy.py

@@ -0,0 +1,285 @@
+#!/usr/bin/env python3
+import argparse
+import json
+import time
+import urllib.error
+import urllib.request
+from typing import Any
+
+from fastapi import FastAPI, HTTPException, Request, Response
+from fastapi.responses import JSONResponse
+import uvicorn
+
+
+DEFAULT_UPSTREAM = "http://127.0.0.1:7860"
+PASS_THROUGH_FIELDS = {
+    "temperature",
+    "top_p",
+    "presence_penalty",
+    "frequency_penalty",
+    "stop",
+    "seed",
+}
+
+app = FastAPI(title="LM Studio OpenAI Reasoning Proxy")
+app.state.upstream_base = DEFAULT_UPSTREAM
+app.state.request_timeout = 1800
+
+
+def post_json(url: str, payload: dict[str, Any], timeout: int) -> tuple[int, str]:
+    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+    request = urllib.request.Request(
+        url,
+        data=body,
+        headers={"Content-Type": "application/json"},
+        method="POST",
+    )
+    try:
+        with urllib.request.urlopen(request, timeout=timeout) as response:
+            return response.status, response.read().decode("utf-8", errors="replace")
+    except urllib.error.HTTPError as exc:
+        return exc.code, exc.read().decode("utf-8", errors="replace")
+
+
+def get_json(url: str, timeout: int) -> tuple[int, str]:
+    request = urllib.request.Request(url, method="GET")
+    try:
+        with urllib.request.urlopen(request, timeout=timeout) as response:
+            return response.status, response.read().decode("utf-8", errors="replace")
+    except urllib.error.HTTPError as exc:
+        return exc.code, exc.read().decode("utf-8", errors="replace")
+
+
+def parse_json_maybe(raw_text: str) -> dict[str, Any] | None:
+    try:
+        return json.loads(raw_text)
+    except json.JSONDecodeError:
+        return None
+
+
+def normalize_reasoning_value(value: Any) -> str | None:
+    if isinstance(value, str):
+        lowered = value.strip().lower()
+        if lowered in {"on", "off"}:
+            return lowered
+    if isinstance(value, bool):
+        return "on" if value else "off"
+    return None
+
+
+def resolve_reasoning_mode(request_payload: dict[str, Any]) -> str:
+    normalized = normalize_reasoning_value(request_payload.get("reasoning"))
+    if normalized is not None:
+        return normalized
+
+    if "enable_thinking" in request_payload:
+        enable_thinking = request_payload.get("enable_thinking")
+        if isinstance(enable_thinking, bool):
+            return "on" if enable_thinking else "off"
+
+    return "on"
+
+
+def extract_text_content(content: Any) -> str:
+    if isinstance(content, str):
+        return content
+
+    if isinstance(content, list):
+        text_parts: list[str] = []
+        for item in content:
+            if isinstance(item, dict) and item.get("type") == "text":
+                text = item.get("text")
+                if isinstance(text, str):
+                    text_parts.append(text)
+        return "\n".join(part for part in text_parts if part)
+
+    return ""
+
+
+def messages_to_native_input(messages: list[dict[str, Any]]) -> tuple[str, str | None]:
+    transcript_parts: list[str] = []
+    system_parts: list[str] = []
+
+    for message in messages:
+        if not isinstance(message, dict):
+            continue
+
+        role = str(message.get("role") or "").strip().lower()
+        content = extract_text_content(message.get("content"))
+        if not content:
+            continue
+
+        if role == "system":
+            system_parts.append(content)
+            continue
+
+        if role == "user":
+            transcript_parts.append(f"User: {content}")
+            continue
+
+        if role == "assistant":
+            transcript_parts.append(f"Assistant: {content}")
+            continue
+
+        transcript_parts.append(f"{role.title() or 'Message'}: {content}")
+
+    transcript = "\n\n".join(transcript_parts)
+    system_prompt = "\n\n".join(system_parts) if system_parts else None
+    return transcript, system_prompt
+
+
+def build_native_payload(request_payload: dict[str, Any]) -> dict[str, Any]:
+    messages = request_payload.get("messages")
+    if not isinstance(messages, list) or not messages:
+        raise ValueError("messages must be a non-empty list")
+
+    input_text, system_prompt = messages_to_native_input(messages)
+    if not input_text:
+        raise ValueError("messages must include at least one non-system text message")
+
+    native_payload: dict[str, Any] = {
+        "model": request_payload.get("model"),
+        "input": input_text,
+        "reasoning": resolve_reasoning_mode(request_payload),
+        "store": False,
+    }
+
+    if system_prompt:
+        native_payload["system_prompt"] = system_prompt
+
+    if "max_tokens" in request_payload:
+        native_payload["max_output_tokens"] = request_payload["max_tokens"]
+
+    for field in PASS_THROUGH_FIELDS:
+        if field in request_payload:
+            native_payload[field] = request_payload[field]
+
+    return native_payload
+
+
+def _collect_output_text(native_response: dict[str, Any], output_type: str) -> list[str]:
+    texts: list[str] = []
+    for item in native_response.get("output") or []:
+        if item.get("type") == output_type:
+            content = item.get("content")
+            if isinstance(content, str):
+                texts.append(content)
+    return texts
+
+
+def translate_native_response(native_response: dict[str, Any]) -> dict[str, Any]:
+    message_parts = _collect_output_text(native_response, "message")
+    reasoning_parts = _collect_output_text(native_response, "reasoning")
+    stats = native_response.get("stats") or {}
+
+    content = "\n".join(part for part in message_parts if part)
+    reasoning_content = "\n".join(part for part in reasoning_parts if part)
+    choice_message = {
+        "role": "assistant",
+        "content": content,
+        "tool_calls": [],
+    }
+    if reasoning_content:
+        choice_message["reasoning_content"] = reasoning_content
+
+    return {
+        "id": native_response.get("id", f"chatcmpl-proxy-{int(time.time() * 1000)}"),
+        "object": "chat.completion",
+        "created": int(time.time()),
+        "model": native_response.get("model"),
+        "choices": [
+            {
+                "index": 0,
+                "message": choice_message,
+                "logprobs": None,
+                "finish_reason": "stop",
+            }
+        ],
+        "usage": {
+            "prompt_tokens": stats.get("input_tokens", 0),
+            "completion_tokens": stats.get("total_output_tokens", 0),
+            "total_tokens": stats.get("input_tokens", 0) + stats.get("total_output_tokens", 0),
+            "completion_tokens_details": {
+                "reasoning_tokens": stats.get("reasoning_output_tokens", 0),
+            },
+        },
+        "stats": stats,
+        "system_fingerprint": native_response.get("model"),
+    }
+
+
+def json_error(status_code: int, message: str, error_type: str = "invalid_request_error") -> JSONResponse:
+    return JSONResponse(
+        status_code=status_code,
+        content={
+            "error": {
+                "message": message,
+                "type": error_type,
+            }
+        },
+    )
+
+
+@app.get("/healthz")
+async def healthz() -> dict[str, str]:
+    return {"status": "ok"}
+
+
+@app.get("/v1/models")
+async def list_models() -> Response:
+    status_code, raw = get_json(
+        f"{app.state.upstream_base}/v1/models",
+        timeout=app.state.request_timeout,
+    )
+    return Response(content=raw, status_code=status_code, media_type="application/json")
+
+
+@app.post("/v1/chat/completions")
+async def chat_completions(request: Request) -> Response:
+    try:
+        request_payload = await request.json()
+    except Exception as exc:  # pragma: no cover
+        raise HTTPException(status_code=400, detail=f"invalid JSON: {exc}") from exc
+
+    if request_payload.get("stream") is True:
+        return json_error(400, "stream=true is not supported by this proxy")
+
+    try:
+        native_payload = build_native_payload(request_payload)
+    except ValueError as exc:
+        return json_error(400, str(exc))
+
+    status_code, raw = post_json(
+        f"{app.state.upstream_base}/api/v1/chat",
+        native_payload,
+        timeout=app.state.request_timeout,
+    )
+    parsed = parse_json_maybe(raw)
+    if status_code >= 400:
+        if parsed is not None:
+            return JSONResponse(status_code=status_code, content=parsed)
+        return json_error(status_code, "upstream returned a non-JSON error", "upstream_error")
+
+    if parsed is None:
+        return json_error(502, "upstream returned non-JSON content", "bad_gateway")
+
+    translated = translate_native_response(parsed)
+    return JSONResponse(status_code=200, content=translated)
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--host", default="127.0.0.1")
+    parser.add_argument("--port", type=int, default=8001)
+    parser.add_argument("--upstream", default=DEFAULT_UPSTREAM)
+    parser.add_argument("--timeout", type=int, default=1800)
+    args = parser.parse_args()
+
+    app.state.upstream_base = args.upstream.rstrip("/")
+    app.state.request_timeout = args.timeout
+    uvicorn.run(app, host=args.host, port=args.port)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 157 - 0
tools/lmstudio/probes/llama_cpp_openai_probe.py

@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+import argparse
+import io
+import json
+import math
+import struct
+import urllib.error
+import urllib.request
+from datetime import datetime
+from pathlib import Path
+
+
+CHAT_TESTS = [
+    ("baseline", {}),
+    ("enable_thinking_false", {"enable_thinking": False}),
+    ("reasoning_off", {"reasoning": "off"}),
+]
+
+
+def post_json(url: str, payload: dict, timeout: int) -> tuple[int, str]:
+    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+    req = urllib.request.Request(
+        url,
+        data=body,
+        headers={"Content-Type": "application/json"},
+        method="POST",
+    )
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            return resp.status, resp.read().decode("utf-8", errors="replace")
+    except urllib.error.HTTPError as exc:
+        return exc.code, exc.read().decode("utf-8", errors="replace")
+
+
+def post_multipart(url: str, fields: dict[str, str], file_name: str, file_bytes: bytes, timeout: int) -> tuple[int, str]:
+    boundary = "----CodexBoundary20260609"
+    body = io.BytesIO()
+
+    for key, value in fields.items():
+        body.write(f"--{boundary}\r\n".encode())
+        body.write(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode())
+        body.write(f"{value}\r\n".encode())
+
+    body.write(f"--{boundary}\r\n".encode())
+    body.write(
+        f'Content-Disposition: form-data; name="file"; filename="{file_name}"\r\n'.encode()
+    )
+    body.write(b"Content-Type: audio/wav\r\n\r\n")
+    body.write(file_bytes)
+    body.write(b"\r\n")
+    body.write(f"--{boundary}--\r\n".encode())
+
+    req = urllib.request.Request(
+        url,
+        data=body.getvalue(),
+        headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
+        method="POST",
+    )
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            return resp.status, resp.read().decode("utf-8", errors="replace")
+    except urllib.error.HTTPError as exc:
+        return exc.code, exc.read().decode("utf-8", errors="replace")
+
+
+def generate_silence_wav(sample_rate: int = 16000, duration_seconds: float = 1.0) -> bytes:
+    total_samples = int(sample_rate * duration_seconds)
+    pcm = b"".join(struct.pack("<h", 0) for _ in range(total_samples))
+    byte_rate = sample_rate * 2
+    block_align = 2
+    bits_per_sample = 16
+    data_size = len(pcm)
+    riff_size = 36 + data_size
+
+    header = struct.pack(
+        "<4sI4s4sIHHIIHH4sI",
+        b"RIFF",
+        riff_size,
+        b"WAVE",
+        b"fmt ",
+        16,
+        1,
+        1,
+        sample_rate,
+        byte_rate,
+        block_align,
+        bits_per_sample,
+        b"data",
+        data_size,
+    )
+    return header + pcm
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--port", type=int, default=18003)
+    parser.add_argument("--model", default="Qwen3-ASR-1.7B-Q8_0.gguf")
+    parser.add_argument("--timeout", type=int, default=300)
+    parser.add_argument("--output-root", default="artifacts/llama-cpp-openai-probe")
+    args = parser.parse_args()
+
+    run_dir = Path(args.output_root) / datetime.now().strftime("%Y%m%d-%H%M%S")
+    run_dir.mkdir(parents=True, exist_ok=True)
+
+    chat_url = f"http://127.0.0.1:{args.port}/v1/chat/completions"
+    audio_url = f"http://127.0.0.1:{args.port}/v1/audio/transcriptions"
+
+    chat_results = []
+    for index, (name, extra) in enumerate(CHAT_TESTS, start=1):
+        payload = {
+            "model": args.model,
+            "messages": [{"role": "user", "content": "Hello"}],
+            "temperature": 0.2,
+            "max_tokens": 64,
+            "stream": False,
+        }
+        payload.update(extra)
+        status, raw = post_json(chat_url, payload, timeout=args.timeout)
+        (run_dir / f"chat_{index:02d}_{name}.request.json").write_text(
+            json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
+            encoding="utf-8",
+        )
+        (run_dir / f"chat_{index:02d}_{name}.response.raw.json").write_text(raw, encoding="utf-8")
+        chat_results.append({"name": name, "status": status, "raw": raw})
+
+    wav_bytes = generate_silence_wav()
+    status, raw = post_multipart(
+        audio_url,
+        fields={"model": args.model},
+        file_name="silence.wav",
+        file_bytes=wav_bytes,
+        timeout=args.timeout,
+    )
+    (run_dir / "audio_silence.response.raw.json").write_text(raw, encoding="utf-8")
+    (run_dir / "audio_silence.summary.json").write_text(
+        json.dumps({"status": status, "raw": raw}, ensure_ascii=False, indent=2) + "\n",
+        encoding="utf-8",
+    )
+
+    (run_dir / "all_summaries.json").write_text(
+        json.dumps(
+            {
+                "chat_results": chat_results,
+                "audio_result": {"status": status, "raw": raw},
+            },
+            ensure_ascii=False,
+            indent=2,
+        )
+        + "\n",
+        encoding="utf-8",
+    )
+    print(run_dir)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 110 - 0
tools/lmstudio/probes/lmstudio_openai_reasoning_probe.py

@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+import argparse
+import json
+import urllib.error
+import urllib.request
+from datetime import datetime
+from pathlib import Path
+
+
+PROBE_FIELDS = [
+    ("baseline", {}),
+    ("enable_thinking_false", {"enable_thinking": False}),
+    ("reasoning_off_string", {"reasoning": "off"}),
+    ("reasoning_false_bool", {"reasoning": False}),
+    ("extra_body_enable_thinking_false", {"extra_body": {"enable_thinking": False}}),
+    ("extra_body_reasoning_off", {"extra_body": {"reasoning": "off"}}),
+    ("chat_template_kwargs_enable_thinking_false", {"chat_template_kwargs": {"enable_thinking": False}}),
+    ("chat_template_kwargs_preserve_false", {"chat_template_kwargs": {"preserve_thinking": False, "enable_thinking": False}}),
+    ("include_reasoning_false", {"include_reasoning": False}),
+    ("separate_reasoning_false", {"separate_reasoning": False}),
+]
+
+
+def post_json(url: str, payload: dict, timeout: int) -> tuple[int, str]:
+    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+    req = urllib.request.Request(
+        url,
+        data=body,
+        headers={"Content-Type": "application/json"},
+        method="POST",
+    )
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            return resp.status, resp.read().decode("utf-8", errors="replace")
+    except urllib.error.HTTPError as exc:
+        return exc.code, exc.read().decode("utf-8", errors="replace")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--port", type=int, default=7860)
+    parser.add_argument(
+        "--model",
+        default="local/qwen3.6-35b-a3b-hauhaucs-aggressive",
+    )
+    parser.add_argument(
+        "--prompt",
+        default="Compute 317 * 29. Give the final answer only.",
+    )
+    parser.add_argument("--output-root", default="artifacts/lmstudio-openai-reasoning-probe")
+    parser.add_argument("--max-tokens", type=int, default=64)
+    parser.add_argument("--timeout", type=int, default=300)
+    args = parser.parse_args()
+
+    run_dir = Path(args.output_root) / datetime.now().strftime("%Y%m%d-%H%M%S")
+    run_dir.mkdir(parents=True, exist_ok=True)
+
+    results = []
+    url = f"http://127.0.0.1:{args.port}/v1/chat/completions"
+    for index, (name, fields) in enumerate(PROBE_FIELDS, start=1):
+        payload = {
+            "model": args.model,
+            "messages": [{"role": "user", "content": args.prompt}],
+            "temperature": 0.2,
+            "max_tokens": args.max_tokens,
+            "stream": False,
+        }
+        payload.update(fields)
+        prefix = f"{index:02d}_{name}"
+        (run_dir / f"{prefix}.request.json").write_text(
+            json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
+            encoding="utf-8",
+        )
+
+        status_code, raw = post_json(url, payload, timeout=args.timeout)
+        (run_dir / f"{prefix}.response.raw.json").write_text(raw, encoding="utf-8")
+
+        parsed = None
+        try:
+            parsed = json.loads(raw)
+        except json.JSONDecodeError:
+            pass
+
+        msg = ((parsed or {}).get("choices") or [{}])[0].get("message") or {}
+        summary = {
+            "name": name,
+            "status_code": status_code,
+            "fields": fields,
+            "has_reasoning_content": "reasoning_content" in msg and msg.get("reasoning_content") is not None,
+            "content": msg.get("content"),
+            "reasoning_content": msg.get("reasoning_content"),
+            "error": (parsed or {}).get("error"),
+            "usage": (parsed or {}).get("usage"),
+        }
+        (run_dir / f"{prefix}.summary.json").write_text(
+            json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
+            encoding="utf-8",
+        )
+        results.append(summary)
+
+    (run_dir / "all_summaries.json").write_text(
+        json.dumps({"results": results}, ensure_ascii=False, indent=2) + "\n",
+        encoding="utf-8",
+    )
+    print(run_dir)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 354 - 0
tools/lmstudio/probes/lmstudio_thinking_probe.py

@@ -0,0 +1,354 @@
+#!/usr/bin/env python3
+import argparse
+import json
+import os
+import sys
+import time
+import urllib.error
+import urllib.request
+from datetime import datetime
+from pathlib import Path
+
+
+OPENAI_COMPAT_TESTS = [
+    {
+        "name": "official_disable",
+        "model": "qwen/qwen3.6-35b-a3b",
+        "enable_thinking": False,
+    },
+    {
+        "name": "official_enable",
+        "model": "qwen/qwen3.6-35b-a3b",
+        "enable_thinking": True,
+    },
+    {
+        "name": "hauhau_disable",
+        "model": "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
+        "enable_thinking": False,
+    },
+    {
+        "name": "hauhau_enable",
+        "model": "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
+        "enable_thinking": True,
+    },
+]
+
+NATIVE_TESTS = [
+    {
+        "name": "official_reasoning_off",
+        "model": "qwen/qwen3.6-35b-a3b",
+        "reasoning": "off",
+    },
+    {
+        "name": "official_reasoning_on",
+        "model": "qwen/qwen3.6-35b-a3b",
+        "reasoning": "on",
+    },
+    {
+        "name": "hauhau_reasoning_off",
+        "model": "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
+        "reasoning": "off",
+    },
+    {
+        "name": "hauhau_reasoning_on",
+        "model": "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
+        "reasoning": "on",
+    },
+    {
+        "name": "wrapped_reasoning_off",
+        "model": "local/qwen3.6-35b-a3b-hauhaucs-aggressive",
+        "reasoning": "off",
+    },
+    {
+        "name": "wrapped_reasoning_on",
+        "model": "local/qwen3.6-35b-a3b-hauhaucs-aggressive",
+        "reasoning": "on",
+    },
+]
+
+
+def post_json(url: str, payload: dict, timeout: int) -> tuple[int, str]:
+    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+    request = urllib.request.Request(
+        url,
+        data=body,
+        headers={"Content-Type": "application/json"},
+        method="POST",
+    )
+    try:
+        with urllib.request.urlopen(request, timeout=timeout) as response:
+            return response.status, response.read().decode("utf-8", errors="replace")
+    except urllib.error.HTTPError as exc:
+        return exc.code, exc.read().decode("utf-8", errors="replace")
+
+
+def get_json(url: str, timeout: int) -> tuple[int, str]:
+    request = urllib.request.Request(url, method="GET")
+    try:
+        with urllib.request.urlopen(request, timeout=timeout) as response:
+            return response.status, response.read().decode("utf-8", errors="replace")
+    except urllib.error.HTTPError as exc:
+        return exc.code, exc.read().decode("utf-8", errors="replace")
+
+
+def parse_json_maybe(raw: str):
+    try:
+        return json.loads(raw)
+    except json.JSONDecodeError:
+        return None
+
+
+def summarize_response(test: dict, status_code: int, raw_text: str) -> dict:
+    parsed = parse_json_maybe(raw_text)
+    summary = {
+        "test": test["name"],
+        "model": test["model"],
+        "enable_thinking": test["enable_thinking"],
+        "http_status": status_code,
+        "json_valid": parsed is not None,
+        "error": None,
+        "finish_reason": None,
+        "content": None,
+        "reasoning_content": None,
+        "contains_think_tag_in_content": None,
+        "contains_think_tag_in_reasoning": None,
+        "usage": None,
+    }
+
+    if parsed is None:
+        summary["error"] = "response_not_json"
+        return summary
+
+    if "error" in parsed:
+        summary["error"] = parsed["error"]
+
+    choices = parsed.get("choices") or []
+    if not choices:
+        return summary
+
+    choice = choices[0]
+    message = choice.get("message") or {}
+    content = message.get("content")
+    reasoning = message.get("reasoning_content")
+
+    summary["finish_reason"] = choice.get("finish_reason")
+    summary["content"] = content
+    summary["reasoning_content"] = reasoning
+    summary["contains_think_tag_in_content"] = (
+        isinstance(content, str) and ("<think>" in content or "</think>" in content)
+    )
+    summary["contains_think_tag_in_reasoning"] = (
+        isinstance(reasoning, str) and ("<think>" in reasoning or "</think>" in reasoning)
+    )
+    summary["usage"] = parsed.get("usage")
+    return summary
+
+
+def summarize_native_response(test: dict, status_code: int, raw_text: str) -> dict:
+    parsed = parse_json_maybe(raw_text)
+    summary = {
+        "test": test["name"],
+        "model": test["model"],
+        "reasoning": test["reasoning"],
+        "http_status": status_code,
+        "json_valid": parsed is not None,
+        "error": None,
+        "message_content": [],
+        "reasoning_content": [],
+        "output_types": [],
+        "stats": None,
+    }
+    if parsed is None:
+        summary["error"] = "response_not_json"
+        return summary
+
+    if "error" in parsed:
+        summary["error"] = parsed["error"]
+
+    output = parsed.get("output") or []
+    summary["output_types"] = [item.get("type") for item in output]
+    for item in output:
+        if item.get("type") == "message":
+            summary["message_content"].append(item.get("content"))
+        elif item.get("type") == "reasoning":
+            summary["reasoning_content"].append(item.get("content"))
+    summary["stats"] = parsed.get("stats")
+    return summary
+
+
+def ensure_dir(path: Path) -> None:
+    path.mkdir(parents=True, exist_ok=True)
+
+
+def write_text(path: Path, text: str) -> None:
+    path.write_text(text, encoding="utf-8")
+
+
+def write_json(path: Path, obj) -> None:
+    path.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--host", default="127.0.0.1")
+    parser.add_argument("--port", type=int, default=7860)
+    parser.add_argument("--timeout", type=int, default=1800)
+    parser.add_argument("--max-tokens", type=int, default=256)
+    parser.add_argument(
+        "--prompt",
+        default="Compute 317 * 29. Give the final answer only.",
+    )
+    parser.add_argument(
+        "--output-root",
+        default="artifacts/lmstudio-thinking-probe",
+    )
+    args = parser.parse_args()
+
+    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+    run_dir = Path(args.output_root) / timestamp
+    ensure_dir(run_dir)
+
+    base_url = f"http://{args.host}:{args.port}/v1"
+    metadata = {
+        "base_url": base_url,
+        "started_at": datetime.now().isoformat(),
+        "cwd": os.getcwd(),
+        "python": sys.version,
+        "openai_compat_tests": OPENAI_COMPAT_TESTS,
+        "native_tests": NATIVE_TESTS,
+        "prompt": args.prompt,
+        "timeout": args.timeout,
+        "max_tokens": args.max_tokens,
+    }
+    write_json(run_dir / "run_metadata.json", metadata)
+
+    status_code, models_raw = get_json(f"{base_url}/models", timeout=30)
+    write_text(run_dir / "models.raw.json", models_raw)
+    write_json(
+        run_dir / "models.summary.json",
+        {
+            "http_status": status_code,
+            "json_valid": parse_json_maybe(models_raw) is not None,
+        },
+    )
+
+    all_summaries = []
+    for index, test in enumerate(OPENAI_COMPAT_TESTS, start=1):
+        payload = {
+            "model": test["model"],
+            "messages": [{"role": "user", "content": args.prompt}],
+            "temperature": 0.2,
+            "max_tokens": args.max_tokens,
+            "stream": False,
+            "enable_thinking": test["enable_thinking"],
+        }
+
+        prefix = f"{index:02d}_{test['name']}"
+        write_json(run_dir / f"{prefix}.request.json", payload)
+
+        started = time.time()
+        status_code, raw_text = post_json(
+            f"{base_url}/chat/completions",
+            payload,
+            timeout=args.timeout,
+        )
+        elapsed = time.time() - started
+
+        write_text(run_dir / f"{prefix}.response.raw.json", raw_text)
+        summary = summarize_response(test, status_code, raw_text)
+        summary["elapsed_seconds"] = round(elapsed, 3)
+        write_json(run_dir / f"{prefix}.summary.json", summary)
+        all_summaries.append(summary)
+
+    native_summaries = []
+    for index, test in enumerate(NATIVE_TESTS, start=1):
+        payload = {
+            "model": test["model"],
+            "input": args.prompt,
+            "temperature": 0.2,
+            "max_output_tokens": args.max_tokens,
+            "reasoning": test["reasoning"],
+            "store": False,
+        }
+        prefix = f"native_{index:02d}_{test['name']}"
+        write_json(run_dir / f"{prefix}.request.json", payload)
+
+        started = time.time()
+        status_code, raw_text = post_json(
+            f"http://{args.host}:{args.port}/api/v1/chat",
+            payload,
+            timeout=args.timeout,
+        )
+        elapsed = time.time() - started
+
+        write_text(run_dir / f"{prefix}.response.raw.json", raw_text)
+        summary = summarize_native_response(test, status_code, raw_text)
+        summary["elapsed_seconds"] = round(elapsed, 3)
+        write_json(run_dir / f"{prefix}.summary.json", summary)
+        native_summaries.append(summary)
+
+    report_lines = [
+        "# LM Studio Thinking Probe",
+        "",
+        f"- Base URL: `{base_url}`",
+        f"- Prompt: `{args.prompt}`",
+        f"- Run dir: `{run_dir}`",
+        "",
+        "| Test | HTTP | enable_thinking | finish | reasoning_content | think tags in content | content |",
+        "| --- | --- | --- | --- | --- | --- | --- |",
+    ]
+    for summary in all_summaries:
+        content = summary["content"]
+        if isinstance(content, str):
+            content = content.replace("\n", "\\n")
+            content = content[:80]
+        reasoning_present = summary["reasoning_content"] is not None
+        report_lines.append(
+            "| {test} | {http_status} | {enable_thinking} | {finish_reason} | {reasoning_present} | {contains_think_tag_in_content} | {content} |".format(
+                test=summary["test"],
+                http_status=summary["http_status"],
+                enable_thinking=summary["enable_thinking"],
+                finish_reason=summary["finish_reason"],
+                reasoning_present=reasoning_present,
+                contains_think_tag_in_content=summary["contains_think_tag_in_content"],
+                content=content,
+            )
+        )
+
+    report_lines.extend(
+        [
+            "",
+            "## Native API",
+            "",
+            "| Test | HTTP | reasoning | reasoning items | message items | stats |",
+            "| --- | --- | --- | --- | --- | --- |",
+        ]
+    )
+    for summary in native_summaries:
+        report_lines.append(
+            "| {test} | {http_status} | {reasoning} | {reasoning_count} | {message_count} | {stats} |".format(
+                test=summary["test"],
+                http_status=summary["http_status"],
+                reasoning=summary["reasoning"],
+                reasoning_count=len(summary["reasoning_content"]),
+                message_count=len(summary["message_content"]),
+                stats=str(summary["stats"]).replace("\n", " "),
+            )
+        )
+
+    write_text(run_dir / "report.md", "\n".join(report_lines) + "\n")
+    write_json(
+        run_dir / "all_summaries.json",
+        {
+            "completed_at": datetime.now().isoformat(),
+            "openai_compat_results": all_summaries,
+            "native_results": native_summaries,
+        },
+    )
+
+    print(run_dir)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 222 - 0
tools/lmstudio/wrapper_generator.py

@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+import argparse
+import json
+from datetime import datetime
+from pathlib import Path
+
+
+DEFAULT_OFFICIAL_MODEL_YAML = Path(
+    r"G:\LMStudio\.lmstudio\hub\models\qwen\qwen3.6-35b-a3b\model.yaml"
+)
+DEFAULT_OFFICIAL_MANIFEST = Path(
+    r"G:\LMStudio\.lmstudio\hub\models\qwen\qwen3.6-35b-a3b\manifest.json"
+)
+DEFAULT_OUTPUT_ROOT = Path("artifacts/lmstudio-model-wrappers")
+DEFAULT_TARGET_MODEL_KEY = "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive"
+DEFAULT_WRAPPER_OWNER = "local"
+DEFAULT_WRAPPER_NAME = "qwen3.6-35b-a3b-hauhaucs-aggressive"
+
+
+def build_wrapper_model_yaml(
+    official_model_yaml_text: str,
+    target_model_key: str,
+    wrapper_owner: str,
+    wrapper_name: str,
+) -> str:
+    lines = official_model_yaml_text.splitlines()
+    output = []
+    replaced_model = False
+    inserted_base = False
+    skipped_original_base = False
+
+    for line in lines:
+        stripped = line.strip()
+
+        if not replaced_model and stripped.startswith("model: "):
+            output.append(f"model: {wrapper_owner}/{wrapper_name}")
+            replaced_model = True
+            continue
+
+        if not inserted_base and stripped.startswith("base:"):
+            output.append(f'base: "{target_model_key}"')
+            inserted_base = True
+            skipped_original_base = True
+            continue
+
+        if skipped_original_base:
+            if line.startswith("metadataOverrides:"):
+                skipped_original_base = False
+                output.append(line)
+            continue
+
+        output.append(line)
+
+    if not replaced_model:
+        raise ValueError("Official model.yaml is missing a model declaration")
+    if not inserted_base:
+        raise ValueError("Official model.yaml is missing a base declaration")
+
+    return "\n".join(output).rstrip() + "\n"
+
+
+def build_wrapper_manifest(
+    official_manifest: dict,
+    target_model_key: str,
+    wrapper_owner: str,
+    wrapper_name: str,
+) -> dict:
+    manifest = {
+        "type": "model",
+        "owner": wrapper_owner,
+        "name": wrapper_name,
+        "dependencies": [
+            {
+                "type": "model",
+                "purpose": "baseModel",
+                "modelKeys": [target_model_key],
+            }
+        ],
+        "revision": int(official_manifest.get("revision", 1)),
+    }
+
+    source_dep = (official_manifest.get("dependencies") or [{}])[0]
+    if source_dep.get("sources"):
+        manifest["dependencies"][0]["sources"] = source_dep["sources"]
+    return manifest
+
+
+def build_readme(
+    target_model_key: str,
+    wrapper_owner: str,
+    wrapper_name: str,
+) -> str:
+    return (
+        f"# {wrapper_owner}/{wrapper_name}\n\n"
+        "This wrapper exposes LM Studio reasoning metadata for a local HauhauCS GGUF.\n\n"
+        "## Source Model\n\n"
+        f"- Local model key: `{target_model_key}`\n\n"
+        "## Intent\n\n"
+        "- Preserve the original HauhauCS weights unchanged\n"
+        "- Reuse the official Qwen3.6 reasoning-capable model wrapper behavior\n"
+        "- Keep the wrapper portable and separate from LM Studio `.internal` state\n\n"
+        "## Next Steps\n\n"
+        "- Place or link this wrapper into an LM Studio-recognized local model package location\n"
+        "- Reload LM Studio model metadata\n"
+        "- Validate the wrapper via UI reasoning toggle and `/api/v1/chat`\n"
+    )
+
+
+def build_wrapper_files(
+    target_model_key: str,
+    wrapper_owner: str,
+    wrapper_name: str,
+    official_model_yaml_text: str,
+    official_manifest: dict,
+) -> dict:
+    model_yaml = build_wrapper_model_yaml(
+        official_model_yaml_text=official_model_yaml_text,
+        target_model_key=target_model_key,
+        wrapper_owner=wrapper_owner,
+        wrapper_name=wrapper_name,
+    )
+    manifest = build_wrapper_manifest(
+        official_manifest=official_manifest,
+        target_model_key=target_model_key,
+        wrapper_owner=wrapper_owner,
+        wrapper_name=wrapper_name,
+    )
+    readme = build_readme(
+        target_model_key=target_model_key,
+        wrapper_owner=wrapper_owner,
+        wrapper_name=wrapper_name,
+    )
+    return {
+        "model.yaml": model_yaml,
+        "manifest.json": json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
+        "README.md": readme,
+    }
+
+
+def generate_wrapper(
+    output_dir: Path,
+    target_model_key: str,
+    wrapper_owner: str,
+    wrapper_name: str,
+    official_model_yaml_text: str,
+    official_manifest: dict,
+    dry_run: bool,
+) -> dict:
+    files = build_wrapper_files(
+        target_model_key=target_model_key,
+        wrapper_owner=wrapper_owner,
+        wrapper_name=wrapper_name,
+        official_model_yaml_text=official_model_yaml_text,
+        official_manifest=official_manifest,
+    )
+
+    summary = {
+        "generated_at": datetime.now().isoformat(),
+        "dry_run": dry_run,
+        "output_dir": str(output_dir),
+        "wrapper_model": f"{wrapper_owner}/{wrapper_name}",
+        "target_model_key": target_model_key,
+        "files": sorted(files.keys()),
+    }
+
+    if dry_run:
+        return summary
+
+    output_dir.mkdir(parents=True, exist_ok=True)
+    for name, content in files.items():
+        (output_dir / name).write_text(content, encoding="utf-8")
+    (output_dir / "summary.json").write_text(
+        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
+        encoding="utf-8",
+    )
+    return summary
+
+
+def load_inputs(model_yaml_path: Path, manifest_path: Path) -> tuple[str, dict]:
+    return (
+        model_yaml_path.read_text(encoding="utf-8"),
+        json.loads(manifest_path.read_text(encoding="utf-8")),
+    )
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--target-model-key", default=DEFAULT_TARGET_MODEL_KEY)
+    parser.add_argument("--wrapper-owner", default=DEFAULT_WRAPPER_OWNER)
+    parser.add_argument("--wrapper-name", default=DEFAULT_WRAPPER_NAME)
+    parser.add_argument("--official-model-yaml", default=str(DEFAULT_OFFICIAL_MODEL_YAML))
+    parser.add_argument("--official-manifest", default=str(DEFAULT_OFFICIAL_MANIFEST))
+    parser.add_argument("--output-dir", default="")
+    parser.add_argument("--output-root", default=str(DEFAULT_OUTPUT_ROOT))
+    parser.add_argument("--dry-run", action="store_true")
+    args = parser.parse_args()
+
+    official_model_yaml_text, official_manifest = load_inputs(
+        Path(args.official_model_yaml),
+        Path(args.official_manifest),
+    )
+
+    if args.output_dir:
+        output_dir = Path(args.output_dir)
+    else:
+        output_dir = Path(args.output_root) / args.wrapper_owner / args.wrapper_name
+
+    summary = generate_wrapper(
+        output_dir=output_dir,
+        target_model_key=args.target_model_key,
+        wrapper_owner=args.wrapper_owner,
+        wrapper_name=args.wrapper_name,
+        official_model_yaml_text=official_model_yaml_text,
+        official_manifest=official_manifest,
+        dry_run=args.dry_run,
+    )
+    print(json.dumps(summary, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())