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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Use your **WorkBuddy / CodeBuddy (Tencent)** subscription as local **OpenAI- and
- Chat Completions, Responses and Anthropic Messages, with tool calling and streaming.
- Built-in **WebUI** for browser login, models, credentials, logs and settings — no desktop client required.
- Automatic multi-account routing across domestic and international sites, with credential refresh.
- Per-account automation: domestic check-in then Buddy travel by default, with independent switches; international check-in is opt-in.
- Per-account automation: domestic personal accounts check in and dispatch Buddy travel by default, with independent switches; international check-in is opt-in. The growth center is personal-only, so enterprise accounts are excluded.

## Quick start

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- 支持 Chat Completions、Responses 和 Anthropic Messages,包含工具调用与流式输出。
- 内置 **WebUI**:扫码添加账号,管理模型、凭证、日志与设置,无需桌面端。
- 多账号自动选路,兼容国内/国际站,自动刷新凭证。
- 按账号配置自动任务:国内默认签到后派 Buddy 旅行,可分别关闭;国际自动签到默认关闭。
- 按账号配置自动任务:国内个人账号默认签到后派 Buddy 旅行,可分别关闭;国际自动签到默认关闭。成长中心仅面向个人账号,企业账号不参与。

## 快速开始

Expand Down
9 changes: 7 additions & 2 deletions app/credential_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,12 @@ def _one(gateway, pool, ledger, entry, action, *, automatic=False, consent_revis
cm, cid = entry["cm"], entry["id"]
if not model_policy.credential_enabled(gateway.CONFIG, entry):
return {"ok": False, "skipped": True, "message": "账号已人工停用"}
if action in {"travel", "travel-status"} and not travel.supported(entry.get("profile")):
return travel.unavailable()
if action in {"travel", "travel-status"} and not travel.supported(entry.get("profile"), entry.get("enterprise") is True):
# Record the terminal exemption so a previous HTTP refusal stops being shown as
# the account's current travel state.
notice = travel.unavailable(entry.get("profile"), entry.get("enterprise") is True)
travel.clear_unsupported(ledger, entry["id"], notice)
return notice
with cm._lock:
if cm.summary().get("account_key") != entry.get("account_key"):
return {"ok": False, "state": "changed", "message": "凭证身份已变化,请刷新列表"}
Expand All @@ -168,6 +172,7 @@ def can_write():
and pool.apply_if_current(cm, generation, lambda: None))
result = travel.perform(gateway._bearer_token(headers), gateway.profile_for_headers(headers),
read_only=action == "travel-status", can_write=can_write,
enterprise=entry.get("enterprise") is True,
buddy_context=gateway._buddy_context(entry, headers, consent_revision))
if not pool.apply_if_current(cm, generation, lambda: travel.remember(ledger, cid, result)):
return {**result, "ok": False, "stale": True, "message": "凭证已变化,旅行结果未写入,请刷新核验"}
Expand Down
7 changes: 4 additions & 3 deletions app/gateway_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def admin_credential_inventory(self):
auto_checkin=model_policy.credential_auto_checkin(self.CONFIG, entry),
checkin=checkin.view(balance.get("checkin") or {}),
auto_travel=model_policy.credential_auto_travel(self.CONFIG, entry),
travel_supported=travel.supported(entry.get("profile")),
travel_supported=travel.supported(entry.get("profile"), entry.get("enterprise") is True),
travel=balance.get("travel") or {"state": "unknown", "message": "尚未查询旅行状态"},
trial_supported=entry.get("profile") == "intl-work",
trial=(trial_management.view(trials.get(identity), now=now) if trials is not None
Expand Down Expand Up @@ -110,8 +110,9 @@ def admin_set_auto_travel(self, identity, enabled):
entry = next((entry for entry in pool.entries() if entry.get("account_key") == identity), None)
if entry is None:
raise HTTPException(404, "凭证不存在")
if enabled and not travel.supported(entry.get("profile")):
raise HTTPException(400, "旅行仅适用于国内账号")
if enabled and not travel.supported(entry.get("profile"), entry.get("enterprise") is True):
raise HTTPException(400, "企业账号不参与成长中心,无法旅行"
if entry.get("enterprise") is True else "旅行仅适用于国内账号")
self.CONFIG["control_store"].set_auto_travel(identity, enabled)

def admin_set_auto_daily_chat(self, identity, enabled):
Expand Down
5 changes: 4 additions & 1 deletion app/model_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from fastapi import HTTPException

from app import travel
from app.model_capabilities import route_metadata
_request_policy = ContextVar("gateway_model_policy", default=None)

Expand Down Expand Up @@ -37,7 +38,9 @@ def credential_auto_checkin(config, entry):


def credential_auto_travel(config, entry):
supported = entry.get("profile") in {"cn-cli", "cn-work"}
# The growth center is personal-only, so an enterprise tenant must never be scheduled
# for travel even though it shares the domestic product profile.
supported = travel.supported(entry.get("profile"), entry.get("enterprise") is True)
return supported and snapshot(config)["credentials"].get(entry.get("account_key"), {}).get("auto_travel", True)


Expand Down
55 changes: 47 additions & 8 deletions app/travel.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,42 @@ def __init__(self, kind, http_status=200, code=0, reason=None):
self.diagnostics["reason"] = reason


def supported(profile):
return profile in {"cn-cli", "cn-work"}
PERSONAL_PROFILES = frozenset({"cn-cli", "cn-work"})


def unavailable():
return {"ok": False, "skipped": True, "state": "unavailable", "message": "旅行仅适用于国内账号"}
def supported(profile, enterprise=False):
"""Whether the growth-center travel program can serve this account.

The upstream growth center is personal-only: an enterprise tenant is refused
with ``growth system is only available for personal users`` (HTTP 403 /
business code 403) on every buddy and travel path, so the region/product
profile alone is not sufficient and the tenant type must be honoured first.
"""
return profile in PERSONAL_PROFILES and not enterprise


def unavailable(profile=None, enterprise=False):
"""Explain the exemption, naming the most specific cause for the operator."""
if enterprise and (profile is None or profile in PERSONAL_PROFILES):
message = "企业账号不参与成长中心,无法旅行"
elif profile is None or profile in PERSONAL_PROFILES:
message = "当前账号不参与成长中心,无法旅行"
else:
message = "旅行仅适用于国内账号"
return {"ok": False, "skipped": True, "state": "unavailable", "message": message}


def clear_unsupported(ledger, cid, notice):
"""Persist the terminal exemption once so a stale refusal cannot outlive the gate.

Returns whether the stored record changed, which keeps the periodic sweep from
rewriting the ledger on every pass for accounts that can never travel.
"""
previous = ledger.entry(cid).get("travel") or {}
if previous.get("state") == "unavailable" and previous.get("message") == notice["message"]:
return False
ledger.update_travel(cid, {**notice, "at": time.time()})
return True


def _number(value):
Expand Down Expand Up @@ -59,6 +89,9 @@ def _request(client, token, operation, *, body=None):
reason = next((value for text, value in {
"no active buddy": "no_active_buddy", "daily limit": "daily_limit",
"already traveling": "already_traveling", "location not available": "location_unavailable",
# A tenant that reaches the growth center anyway is exempt, not broken: label it
# so the operator sees a capability boundary instead of a bare HTTP failure.
"only available for personal users": "personal_only",
}.items() if isinstance(message, str) and len(message) <= 512 and text in message.lower()), None)
if status != 200:
raise _Failure("http", status, code, reason)
Expand Down Expand Up @@ -105,9 +138,10 @@ def _status(data):
"server_now": server_now, "remaining_seconds": remaining}


def perform(token, profile, *, read_only=False, can_write=lambda: True, buddy_context=None):
if not supported(profile):
return unavailable()
def perform(token, profile, *, read_only=False, can_write=lambda: True, buddy_context=None,
enterprise=False):
if not supported(profile, enterprise):
return unavailable(profile, enterprise)
result = {"ok": False, "state": "unknown", "claimed": False, "departed": False, "stale": False, "phase": "status"}
phase = "status"
context = buddy_context or {}
Expand Down Expand Up @@ -282,7 +316,12 @@ def record_departure(stage, outcome):
"daily_limit": "官方拒绝派遣:今日派遣已达上限",
"already_traveling": "官方拒绝派遣:猫猫已在旅行,请查询最新状态",
"location_unavailable": "官方拒绝派遣:该地点暂时不可用"}.get(result.get("reason"))
if phase == "depart" and refusal:
if result.get("reason") == "personal_only":
# A capability boundary, not a transient failure: say so in every phase so the
# operator does not read a permanent exemption as a retryable outage.
result["message"] = ("旅行积分已领取;" if result["claimed"] else "") + \
"该账号不参与成长中心(仅个人账号可用),已停止旅行操作"
elif phase == "depart" and refusal:
result["message"] = ("旅行积分已领取;" if result["claimed"] else "") + refusal
if diagnostics["error_kind"] == "storage":
receipt = ("领取已确认,但" if write_operation == "claim" else "派遣已确认,但") if confirmed else ""
Expand Down
11 changes: 11 additions & 0 deletions converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,7 @@ def reload(self, paths: list[Path], *, reset: bool = True):
if entry.get("uid"):
have_uids.pop(old_identity, None)
entry.update(uid=summary.get("uid"), profile=summary["profile"], site=summary["site"],
enterprise=summary.get("enterprise") is True,
account_key=identity, generation=generation, catalog_dirty=True)
self._bind_entry(entry)
if reset or replaced:
Expand Down Expand Up @@ -655,6 +656,7 @@ def reload(self, paths: list[Path], *, reset: bool = True):
continue
entry = {"id": cid, "cm": manager, "fail_until": 0.0 if summary else time.time() + CRED_COOLDOWN, "uid": uid,
"site": summary.get("site"), "profile": profile, "generation": manager._generation,
"enterprise": summary.get("enterprise") is True,
"account_key": identity_key, "catalog_dirty": True}
self._bind_entry(entry)
self._adopt_cooldowns(entry)
Expand Down Expand Up @@ -1546,12 +1548,21 @@ def can_claim():
_sync_error(pool, ledger, entry, generation, "checkin", error)
if not model_policy.credential_enabled(CONFIG, entry):
return None
if checkin and not travel.supported(entry.get("profile"), entry.get("enterprise") is True):
# The exemption is terminal, so keep the ledger honest instead of leaving the
# previous upstream refusal as this account's current travel state. A stored
# notice is only rewritten when it differs, so the sweep stays a no-op after.
notice = travel.unavailable(entry.get("profile"), entry.get("enterprise") is True)
if not pool.apply_if_current(cm, generation, lambda: travel.clear_unsupported(ledger, cid, notice)):
failed.add(cid)
return None
if checkin and model_policy.credential_auto_travel(CONFIG, entry):
try:
def can_travel():
return (model_policy.credential_auto_travel(CONFIG, entry)
and pool.apply_if_current(cm, generation, lambda: None))
trip = travel.perform(token, profile_for_headers(headers), can_write=can_travel,
enterprise=entry.get("enterprise") is True,
buddy_context=_buddy_context(entry, headers))
if not pool.apply_if_current(cm, generation, lambda: travel.remember(ledger, cid, trip)):
failed.add(cid)
Expand Down
2 changes: 1 addition & 1 deletion docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Switch back to `legacy` to restore old behavior for new requests; in-flight requ

## Automation and rewards

Automatic check-in and Buddy travel are per-account switches (WebUI credentials page): on by default domestically, off internationally, applied live without restart.
Automatic check-in and Buddy travel are per-account switches (WebUI credentials page): on by default for domestic personal accounts, off internationally, applied live without restart. The growth center is personal-only, so enterprise accounts never travel and no longer surface the upstream 403.

`CODEBUDDY2API_AUTO_ACCEPT_BUDDY` is startup-only and defaults to `false`. It preauthorizes enabled domestic accounts for first-Buddy onboarding, agreement and travel; automatic travel still respects its account switch. `first_buddy` needs no acceptance API: pending states, including `not_accepted`, allow one real domestic WorkBuddy conversation on that account. Prefer an eligible zero-rate model, otherwise the lowest known rate; request at most 32 output tokens with possible credit usage. Other reward tasks, paid boxes, pet switching and international trials are excluded.

Expand Down
2 changes: 1 addition & 1 deletion docs/advanced.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ scoped 模式可选传入 `X-Codebuddy-Session-ID`、`metadata.conversation_id`

## 自动化与奖励

自动签到与 Buddy 旅行是按账号设置的开关(WebUI 凭证页):国内默认开启,国际默认关闭,保存即生效,无需重启。
自动签到与 Buddy 旅行是按账号设置的开关(WebUI 凭证页):国内个人账号默认开启,国际默认关闭,保存即生效,无需重启。成长中心仅面向个人账号,企业账号不参与旅行(也不再显示上游 403)。

`CODEBUDDY2API_AUTO_ACCEPT_BUDDY` 仅启动读取,默认 `false`;预授权已启用国内账号完成首次领猫任务、协议及旅行,自动旅行仍受账号开关控制。`first_buddy` 无需单独接取,包含 `not_accepted` 在内的待完成状态可直接发起一次本账号的真实国内 WorkBuddy 对话;优先可用零倍率模型,否则取最低已知倍率,最多请求 32 个输出 token,可能消耗少量积分。不执行其他奖励任务、不付费开盒、不切猫、不领取国际试用积分。

Expand Down
2 changes: 1 addition & 1 deletion docs/webui.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Add accounts by browser login — Mainland China (CN), International WorkBuddy o

### Automation

Automatic check-in and travel are persisted per account and apply live: on by default for domestic accounts, off internationally. Disabled accounts run no automatic tasks; inactive or unconfirmed activities never authorize claims. Saving a preference does not claim immediately and cannot retract sent requests; the console shows last results, partial completion and uncertainty.
Automatic check-in and travel are persisted per account and apply live: on by default for domestic personal accounts, off internationally. Enterprise accounts are outside the personal-only growth center, so they never travel and no longer render the upstream 403. Disabled accounts run no automatic tasks; inactive or unconfirmed activities never authorize claims. Saving a preference does not claim immediately and cannot retract sent requests; the console shows last results, partial completion and uncertainty.

- **Domestic travel** follows its own switch, including when check-in is already complete or disabled. "Travel status" only queries; "Claim / dispatch" claims arrivals, rechecks idle state and the daily limit, then chooses a current upstream location. Invalid location configuration stops dispatch.
- **First-Buddy onboarding:** one agreement checkbox completes `first_buddy` through one real WorkBuddy conversation if needed, then adopts and dispatches. No separate task acceptance is required. The conversation requests at most 32 output tokens and may use credits; an eligible zero-rate model is preferred, and official completion is checked before adoption. `CODEBUDDY2API_AUTO_ACCEPT_BUDDY=true` preauthorizes enabled domestic accounts, including future imports, after restart; default is false, automatic follow-up respects the travel switch, and no other reward tasks, paid boxes or trial claims run.
Expand Down
2 changes: 1 addition & 1 deletion docs/webui.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

### 自动化

自动签到与旅行按账号持久化并即时生效:国内账号默认开启,国际默认关闭。停用的账号不执行任何自动任务;未开始或未确认的活动不会触发领取。保存开关不会立即领取,也无法撤回已发出的请求;界面展示最近一次结果、部分完成与不确定状态。
自动签到与旅行按账号持久化并即时生效:国内个人账号默认开启,国际默认关闭;企业账号不参与成长中心,因此不旅行且不再显示上游 403。停用的账号不执行任何自动任务;未开始或未确认的活动不会触发领取。保存开关不会立即领取,也无法撤回已发出的请求;界面展示最近一次结果、部分完成与不确定状态。

- **国内旅行** 有独立开关,即使签到已完成或关闭也按此开关执行。「查询状态」只读;「领取/派出」先领取到达奖励,复核空闲状态与每日上限,再选择当前上游位置。位置配置无效会停止派出。
- **首次领养(first_buddy):** 勾选一个协议复选框,必要时通过一次真实 WorkBuddy 对话完成官方新手任务,然后领养并派出。无需单独接取任务。该对话最多请求 32 个输出 token,可能消耗积分;优先使用可用的零倍率模型,并在领养前核验官方完成状态。`CODEBUDDY2API_AUTO_ACCEPT_BUDDY=true` 在重启后预授权所有启用的国内账号(含后续导入);默认关闭,自动后续仍受旅行开关控制,不执行其他奖励任务、付费开盒或试用领取。
Expand Down
Loading
Loading