Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

> 把本机已经登录的消费级 AI 客户端,接成 OpenAI 兼容接口,给 Codex、OpenCode、Cherry Studio、NextChat 等用。默认打开 Work Buddy / CodeBuddy、QClaw、千问办公(QwenWork)、TraeWork 四个通道;管理页下拉选其中一个。一次请求只走一个通道。

当前版本 **2.1.0**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。
当前版本 **2.1.1**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。

## 这是什么?

Expand Down Expand Up @@ -155,6 +155,27 @@ python server.py

接口:`/v1/chat/completions`、`/v1/responses`、`/v1/models`。没加前缀的 `auto` 走这把 Key 绑定的通道。Codex 用 Responses 接口;管理页选 Codex 类型的 Key 会按 Codex 特征 prompt 做清洗(其它客户端借用这把 Key、但没有 Codex 特征时不改写)。

### 思考强度

智能体可以在 Chat Completions 中发送顶层 `reasoning_effort`,在 Responses 中发送标准的 `reasoning: {"effort": "high"}`。网关也兼容 OpenCode、DSH、Cherry 和 Claude 风格的 `reasoning.effort`、`reasoningEffort`、`thinking.type`、`thinking.effort`、`output_config.effort`、`enable_thinking` 等写法。可用档位为 `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`、`ultra`;`off` 等同于 `none`。

```json
{
"model": "deepseek-v4-pro",
"messages": [{"role": "user", "content": "分析这个问题"}],
"reasoning_effort": "high"
}
```

| 通道 | 实际能力 |
|---|---|
| WorkBuddy | DeepSeek V4 Pro/Flash 支持 `low` / `high` / `max`,标准档位会投影到这三档;未指定时默认 `high`,可用 `CB_GATEWAY_DEFAULT_REASONING_EFFORT=off` 关闭默认 |
| QClaw | 统一转换成 `reasoning_effort` 后透传;具体档位是否生效由所选上游模型决定,不额外注入默认值 |
| QwenWork | 协议只有 `is_reasoning` 开关;`none` 关闭,其它显式档位开启,无法区分多档强度 |
| TraeWork | 当前会话协议没有可验证的思考控制字段,因此暂不支持调档 |

Chat 流会保留 `reasoning_content`。Responses 流会转换成标准的 `response.reasoning_summary_*` 事件,仅有推理、没有最终正文的有效响应也会正常完成。

OpenCode 示例(WorkBuddy Key):

```json
Expand Down Expand Up @@ -204,6 +225,7 @@ QwenWork、QClaw、TraeWork 各用自己那把 Key,不要混用。
| `CB_GATEWAY_PROVIDERS` | 启用哪些通道,逗号分隔。默认 `workbuddy,qclaw,qwenwork,traework`。只想留一家时再改 |
| `CB_GATEWAY_AUTO_IMPORT` | 设 `1` 则启动时自动导入。默认 `0` |
| `CB_GATEWAY_CHECKIN_GAP_MS` | 一键领取间隔,默认 `800` |
| `CB_GATEWAY_DEFAULT_REASONING_EFFORT` | WorkBuddy DeepSeek V4 Pro/Flash 的默认思考强度,支持 `low` / `high` / `max`,默认 `high`;设为 `off` 可关闭默认值。Responses 的 `reasoning.effort` 或 Chat Completions 的 `reasoning_effort` 会覆盖它 |
| `CB_AUTH_DIR` | WorkBuddy 登录目录 |
| `CB_QCLAW_AUTH_DIR` | QClaw 登录目录 |
| `CB_QWENWORK_AUTH_DIR` | QwenWork 登录目录 |
Expand Down
17 changes: 16 additions & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

> Local consumer AI clients → one OpenAI-compatible API for Codex, OpenCode, Cherry Studio, NextChat, and similar agents. Work Buddy / CodeBuddy, QClaw, QwenWork, and TraeWork are on by default; pick one in the UI dropdown. Each request stays on one channel.

Release **2.1.0**. Local use only. Do not expose this on the public internet, and do not share credentials, API keys, or the database.
Release **2.1.1**. Local use only. Do not expose this on the public internet, and do not share credentials, API keys, or the database.

## What is this?

Expand Down Expand Up @@ -80,6 +80,19 @@ The database migrates on startup. Existing keys stay on `workbuddy`. Startup no

Unprefixed `auto` follows the key’s channel. Use a separate key per channel.

### Reasoning effort

Agent clients can send top-level `reasoning_effort` to Chat Completions and the standard `reasoning: {"effort": "high"}` object to Responses. Compatibility forms used by OpenCode, DSH, Cherry, and Claude-style clients are also accepted: `reasoning.effort`, `reasoningEffort`, `thinking.type`, `thinking.effort`, `output_config.effort`, and `enable_thinking`. Accepted levels are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`; `off` is an alias for `none`.

| Channel | Effective capability |
|---|---|
| WorkBuddy | DeepSeek V4 Pro/Flash supports `low` / `high` / `max`; standard levels are projected onto those tiers. The default is `high` and can be disabled with `CB_GATEWAY_DEFAULT_REASONING_EFFORT=off` |
| QClaw | The control is normalized to `reasoning_effort` and forwarded. Whether a tier takes effect depends on the selected upstream model; no gateway default is injected |
| QwenWork | The protocol exposes only an `is_reasoning` switch. `none` disables it and any other explicit tier enables it; distinct effort levels are unavailable |
| TraeWork | The current session protocol has no verified reasoning control field, so effort selection is not supported |

Chat streams preserve `reasoning_content`. Responses streams expose standard `response.reasoning_summary_*` events and accept valid reasoning-only completions.

```bash
curl http://127.0.0.1:8787/v1/chat/completions \
-H "Content-Type: application/json" \
Expand All @@ -91,6 +104,8 @@ curl http://127.0.0.1:8787/v1/chat/completions \

`CB_GATEWAY_PROVIDERS` (default `workbuddy,qclaw,qwenwork,traework`), `CB_GATEWAY_AUTO_IMPORT` (default `0`), `CB_AUTH_DIR` / `CB_QCLAW_AUTH_DIR` / `CB_QWENWORK_AUTH_DIR` / `CB_TRAEWORK_AUTH_DIR`, `CB_GATEWAY_ADMIN_TOKEN`, `CB_GATEWAY_MASTER_KEY`.

`CB_GATEWAY_DEFAULT_REASONING_EFFORT` controls the default reasoning effort for WorkBuddy DeepSeek V4 Pro/Flash. It accepts `low`, `high`, or `max`, defaults to `high`, and can be disabled with `off`. A Responses `reasoning.effort` or Chat Completions `reasoning_effort` value overrides the default.

Keep `--host 127.0.0.1`. Do not share the database, auth folders, or key screenshots.

## License
Expand Down
31 changes: 31 additions & 0 deletions docs/releases/v2.1.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Buddy2api v2.1.1

发布日期:2026-08-28

本版本补齐智能体思考强度兼容,并修复 Responses API 在多通道路由和推理输出上的行为。

## 思考强度

- Chat Completions 支持顶层 `reasoning_effort`,Responses 支持标准的 `reasoning: {"effort": "high"}`。
- 同时兼容 `reasoning.effort`、`reasoningEffort`、`thinking.type`、`thinking.effort`、`output_config.effort`、`enable_thinking` 等常见智能体写法。
- 接受 `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`、`ultra`;`off` 等同于 `none`。无效或冲突的控制返回 `400 invalid_reasoning_control`。
- WorkBuddy DeepSeek V4 Pro/Flash 映射到 `low` / `high` / `max`,未指定时默认 `high`;可用 `CB_GATEWAY_DEFAULT_REASONING_EFFORT=off` 关闭默认值。
- QClaw 规范化为 `reasoning_effort` 后透传;QwenWork 只能映射为开关;TraeWork 当前不支持调档。

## Responses 兼容

- `/v1/responses` 现在遵循 API Key 绑定的通道,不再固定走 WorkBuddy。
- Chat 的 `reasoning_content` 会转换为标准 `response.reasoning_summary_*` 事件。
- 支持只有推理内容、没有最终正文的有效完成,并避免兼容上游将相同推理文本复制到正文时重复显示。
- 补齐 Responses 的请求回显、usage 明细、错误结构和流式事件收尾。

## 升级说明

- 无数据库迁移。
- Docker 用户需要重新构建镜像并重启服务,才能加载新代码。
- 思考强度选择器是否显示仍由客户端自己的模型能力配置决定。例如 DSH 自定义模型需要在其本地配置中声明 `reasoningEfforts`;Buddy2api 不会通过 `/v1/models` 强制客户端显示控件。

## 验证

- 完整测试集:`238 passed`。
- 已实测 Chat 默认档位、`none`、`max`,以及 Responses `high` 的真实上游响应。
5 changes: 4 additions & 1 deletion providers/qclaw/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import auth_manager
import database as db
from reasoning_controls import normalize_chat_reasoning
from providers.qclaw.constants import AIZONE_BASE, ALIASES, CHANNEL_ID, RETRYABLE_STATUS
from providers.qclaw.sign import aizone_headers

Expand Down Expand Up @@ -44,6 +45,8 @@ def fill_empty_content(payload: dict) -> dict:
for key in ("reasoning_content", "reasoning"):
text = _alt_text(out.get(key))
if text:
if key == "reasoning":
out["reasoning_content"] = text
out["content"] = text
return out
return out
Expand Down Expand Up @@ -105,7 +108,7 @@ def _log(api_key_info, account, model_name, stream, prompt_t, completion_t, tota


def _build_body(payload: dict) -> tuple[dict, str]:
body = dict(payload)
body = normalize_chat_reasoning(payload)
body["model"] = translate_model(str(body.get("model") or "default"))
raw = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
return body, raw
Expand Down
7 changes: 5 additions & 2 deletions providers/qwenwork/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import auth_manager
import database as db
from reasoning_controls import resolve_reasoning_control
from providers.qwenwork import cosy
from providers.qwenwork.constants import (
ALIASES,
Expand Down Expand Up @@ -118,6 +119,8 @@ def build_body(payload: dict) -> tuple[dict, str, str]:
if system:
messages = [{"role": "system", "content": system}, *messages]
last_user = _last_user_text(messages)
reasoning_control = resolve_reasoning_control(payload)
is_reasoning = reasoning_control.enabled is True
parameters = {}
for key in ("temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty"):
if key in payload and payload[key] is not None:
Expand All @@ -136,7 +139,7 @@ def build_body(payload: dict) -> tuple[dict, str, str]:
"features": [],
"extra": {
"context": [],
"modelConfig": {"key": model, "is_reasoning": False},
"modelConfig": {"key": model, "is_reasoning": is_reasoning},
"originalContent": last_user,
},
"chatPrompt": "",
Expand All @@ -156,7 +159,7 @@ def build_body(payload: dict) -> tuple[dict, str, str]:
"model": "",
"format": "openai",
"is_vl": model == "qwork-advanced",
"is_reasoning": False,
"is_reasoning": is_reasoning,
"api_key": "",
"url": "",
"source": "system",
Expand Down
34 changes: 28 additions & 6 deletions proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@

import database as db
import auth_manager
from reasoning_controls import (
chat_reasoning_effort,
resolve_reasoning_control,
workbuddy_reasoning_effort,
)

BACKEND = "https://copilot.tencent.com"
RETRYABLE_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504}
Expand Down Expand Up @@ -126,6 +131,7 @@ async def _retry_delay(attempt: int):
"deepseek-v4-pro",
"deepseek-v4-flash",
})
_DEFAULT_REASONING_EFFORT = "high"
_VALID_REASONING_DEFAULTS = frozenset({"low", "high", "max"})
_BACKEND_ROLE_ALIASES = {
"developer": "system",
Expand Down Expand Up @@ -195,14 +201,18 @@ def resolve_model_alias(model: str) -> str:


def _configured_reasoning_default(model: str) -> str | None:
"""Return the opt-in reasoning default for supported DeepSeek V4 models."""
"""Return the configured reasoning default for supported DeepSeek V4 models."""
if model not in _REASONING_DEFAULT_MODEL_IDS:
return None
value = os.environ.get("CB_GATEWAY_DEFAULT_REASONING_EFFORT", "").strip().lower()
value = os.environ.get(
"CB_GATEWAY_DEFAULT_REASONING_EFFORT",
_DEFAULT_REASONING_EFFORT,
).strip().lower()
return value if value in _VALID_REASONING_DEFAULTS else None


def build_backend_body(payload: dict) -> dict:
reasoning_control = resolve_reasoning_control(payload)
body = {k: payload[k] for k in PASSTHROUGH_BODY_KEYS if k in payload}
messages = body.get("messages")
if isinstance(messages, list):
Expand All @@ -215,14 +225,21 @@ def build_backend_body(payload: dict) -> dict:
else message
for message in messages
]
has_explicit_thinking = "thinking" in payload
# Resolve model alias before forwarding
raw_model = body.get("model", "auto")
body["model"] = resolve_model_alias(raw_model)
if "reasoning_effort" not in body and not has_explicit_thinking:
body.pop("reasoning_effort", None)
if reasoning_control.mode == "default":
default_reasoning = _configured_reasoning_default(body["model"])
if default_reasoning:
body["reasoning_effort"] = default_reasoning
else:
if body["model"] in _REASONING_DEFAULT_MODEL_IDS:
reasoning_effort = workbuddy_reasoning_effort(reasoning_control)
else:
reasoning_effort = chat_reasoning_effort(reasoning_control)
if reasoning_effort:
body["reasoning_effort"] = reasoning_effort
body["stream"] = True
if "stream_options" not in body:
body["stream_options"] = {"include_usage": True}
Expand Down Expand Up @@ -283,6 +300,7 @@ def __init__(self, fallback_model: str, expected_choices: int = 1):
self.finish_reasons: dict[int, str | None] = {}
self.closed_choices: set[int] = set()
self.content_choices: set[int] = set()
self.reasoning_choices: set[int] = set()
self.tool_call_choices: set[int] = set()
self.tool_calls: dict[tuple[int, int], dict] = {}
self.malformed_data_event = False
Expand Down Expand Up @@ -423,6 +441,8 @@ def observe_event(self, data: bytes) -> dict | None:
if content:
self.content_parts.append(content)
self.content_choices.add(index)
if delta.get("reasoning_content"):
self.reasoning_choices.add(index)
tool_deltas = delta.get("tool_calls")
if tool_deltas is None:
continue
Expand Down Expand Up @@ -505,9 +525,10 @@ def eof_error(self) -> str | None:
if (
reason not in {"length", "content_filter"}
and choice_index not in self.content_choices
and choice_index not in self.reasoning_choices
and choice_index not in self.tool_call_choices
):
return "The upstream choice ended without content or a tool call."
return "The upstream choice ended without content, reasoning, or a tool call."
return None

def terminal_event(self, choice_indices: list[int]) -> bytes:
Expand Down Expand Up @@ -1141,14 +1162,15 @@ async def _collect_stream(

if (
not content_parts
and not reasoning_parts
and not tool_calls
and finish_reason not in {"length", "content_filter"}
):
return (
"error",
(502, {
"error": {
"message": "The upstream choice ended without content or a tool call.",
"message": "The upstream choice ended without content, reasoning, or a tool call.",
"type": "upstream_error",
},
}),
Expand Down
Loading