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
3 changes: 1 addition & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ CODEBUDDY2API_LOG=
# 正文日志最多预览 64 KiB;0 仅记录摘要,常见认证字段与图片 base64 会脱敏。
CODEBUDDY2API_LOG_BODY_LIMIT=65536

# 国际 WorkBuddy 一次性体验积分领取;默认关闭,仅对符合上游资格的账号生效。
CODEBUDDY2API_AUTO_TRIAL=false
# 一次性体验积分仅在 WebUI 凭证页手动领取,无自动领取开关。

# 换凭证重放次数:失败发生在向下游落第一个字节之前时,最多再换几个凭证就地重放。
# 默认 0(关闭):如实把上游 429/502 回给下游。只重放上游确定没收下请求体(建连失败/建连超时)
Expand Down
1 change: 1 addition & 0 deletions app/admin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def _public_credential(item):
"models", "remaining", "enterprise_id", "product", "status", "sync_pending", "sync_error",
"fail_until", "cooldown_until", "cooldown_remaining", "last_failure_at", "catalog_ready", "bindings",
"auto_checkin", "auto_travel", "travel_supported", "checkin", "travel",
"trial_supported", "trial",
"token_expired", "token_expires_at", "last_refresh_time", "sessions", "sticky_sessions", "last_error_code"}
result = {key: value for key, value in item.items() if key in fields}
identity = item.get("account_key") or item.get("id")
Expand Down
2 changes: 1 addition & 1 deletion app/control_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def _load(self):
data = json.loads(row[1])
if not isinstance(data, dict) or set(data) != {"settings", "models", "credentials"}:
raise ValueError("管理数据库状态无效")
validate_settings(data["settings"])
data["settings"] = validate_settings(data["settings"], legacy=True)
if not isinstance(data["models"], dict) or not isinstance(data["credentials"], dict):
raise ValueError("管理数据库策略无效")
for source, rule in data["models"].items():
Expand Down
24 changes: 18 additions & 6 deletions app/credential_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@

from fastapi import HTTPException

from . import checkin, model_policy, travel
from . import checkin, model_policy, travel, trial_management
from .credential_io import credential_file_lock


def run(gateway, action, identity=None):
if action not in {"refresh", "checkin", "sync", "travel", "travel-status"} or (action in {"refresh", "travel", "travel-status"} and identity is None):
if action not in {"refresh", "checkin", "sync", "travel", "travel-status", "trial"} or (action in {"refresh", "travel", "travel-status", "trial"} and identity is None):
raise HTTPException(404, "凭证操作不存在")
config = gateway.CONFIG
pool, ledger = config.get("cred_pool"), config.get("ledger")
if pool is None or (action != "refresh" and (ledger is None or gateway.credits_mod is None)):
if pool is None or (action not in {"refresh", "trial"} and (ledger is None or gateway.credits_mod is None)):
raise HTTPException(503, "凭证维护尚未就绪")
# Do not queue duplicate manual work behind the periodic maintenance sweep.
if not gateway._HOUSEKEEP_LOCK.acquire(blocking=False):
Expand All @@ -23,11 +23,15 @@ def run(gateway, action, identity=None):
entries = [dict(e) for e in pool.entries() if identity is None or e.get("account_key") == identity]
if identity is not None and not entries:
raise HTTPException(404, "凭证不存在或身份已变化")
if action == "trial":
entries = entries[:1] # Duplicate files for one identity still represent one manual action.
results = []
for entry in entries:
started = time.monotonic()
result = {"id": entry.get("account_key"), "name": Path(entry["id"]).name, "action": action, "ok": False}
if not model_policy.credential_enabled(config, entry):
result.update(skipped=True, message="账号已人工停用")
result.update(trial_management.failure("changed", skipped=True) if action == "trial"
else {"skipped": True, "message": "账号已人工停用"})
else:
try:
result.update(_one(gateway, pool, ledger, entry, action))
Expand All @@ -45,7 +49,13 @@ def run(gateway, action, identity=None):
audit = config.get("audit_store")
if audit:
try:
audit.event("admin", "credential." + action, {"credential": result["id"], "ok": result["ok"]})
details = {"credential": result["id"], "ok": result["ok"]}
if action == "trial":
details.update(outcome="success" if result["ok"] else "error", stage=result.get("state"),
status_code=result.get("status"),
code=str(result["code"]) if result.get("code") is not None else None,
duration_ms=(time.monotonic() - started) * 1000)
audit.event("admin", "credential." + action, details)
except Exception:
pass
response = {"ok": bool(results) and all(r["ok"] for r in results), "results": results}
Expand All @@ -57,6 +67,8 @@ def run(gateway, action, identity=None):


def _one(gateway, pool, ledger, entry, action, *, automatic=False):
if action == "trial":
return trial_management.perform(gateway, pool, entry)
cm, cid = entry["cm"], entry["id"]
if not model_policy.credential_enabled(gateway.CONFIG, entry):
return {"ok": False, "skipped": True, "message": "账号已人工停用"}
Expand Down Expand Up @@ -102,7 +114,7 @@ def can_write():
return checkin.normalize({"state": "changed"})
return result
failed = set()
ref = gateway._sync_credits(pool, ledger, entry, checkin=False, claim_trial=False, failed=failed,
ref = gateway._sync_credits(pool, ledger, entry, checkin=False, failed=failed,
expected_identity=entry.get("account_key"))
if ref is not None:
if not pool.apply_if_current(cm, ref[1], lambda: entry.update(catalog_dirty=True)):
Expand Down
9 changes: 6 additions & 3 deletions app/gateway_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from starlette.staticfiles import StaticFiles

from . import checkin, model_policy, travel
from . import checkin, model_policy, travel, trial_management


class Management:
Expand All @@ -25,6 +25,7 @@ def admin_credential_inventory(self):
return []
pool._rescan()
now = time.time()
trials = trial_management.inventory(self.CONFIG.get("trial_ledger"))
with pool._lock:
entries = {entry["id"]: entry for entry in pool.entries()}
result = []
Expand All @@ -48,6 +49,10 @@ def admin_credential_inventory(self):
auto_travel=model_policy.credential_auto_travel(self.CONFIG, entry),
travel_supported=travel.supported(entry.get("profile")),
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
else trial_management.failure("storage_error"))
if entry.get("profile") == "intl-work" else trial_management.failure("not_applicable"),
fail_until=until, cooldown_until=until,
cooldown_remaining=max(0, round(until - now)),
last_error_code=("http_401" if entry.get("last_error") == "backend HTTP 401" else
Expand Down Expand Up @@ -185,8 +190,6 @@ def admin_apply_settings(self, values):
self.CONFIG[key] = value
if "model_catalog_ttl" in values and self.CONFIG.get("model_cache") is not None:
self.CONFIG["model_cache"].ttl = values["model_catalog_ttl"]
if "auto_trial" in values and values["auto_trial"] and self.CONFIG.get("trial_ledger") is None:
self.CONFIG["trial_ledger"] = self.gateway.trial_rewards.TrialLedger(self.gateway.managed_auth_dir() / "trial-ledger.json")
self.gateway.invalidate_model_table()


Expand Down
3 changes: 1 addition & 2 deletions app/runtime_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ def initialize(gateway, args, argv=None):
for key in SCHEMA:
if hasattr(args, key) and key != "api_key":
setattr(args, key, config[key])
config["trial_ledger"] = (gateway.trial_rewards.TrialLedger(root / "trial-ledger.json")
if config["auto_trial"] else None)
config["trial_ledger"] = gateway.trial_rewards.TrialLedger(root / "trial-ledger.json")
try:
config["audit_store"] = AuditStore(root / "logs.sqlite3", max_bytes=config["audit_max_bytes"],
retention_days=config["audit_retention_days"],
Expand Down
5 changes: 3 additions & 2 deletions app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum=
"image_policy": _item("truncate", "string", "超额图片策略", env="CODEBUDDY2API_IMAGE_POLICY", choices=["truncate", "error"]),
"max_request_bytes": _item(32 * 1024 * 1024, "integer", "请求字节上限", env="CODEBUDDY2API_MAX_REQUEST_BYTES", minimum=1, maximum=1024**3),
"log_body_limit": _item(65536, "integer", "文本正文预览字节", env="CODEBUDDY2API_LOG_BODY_LIMIT", minimum=0, maximum=1024**2),
"auto_trial": _item(False, "boolean", "自动领取体验积分", env="CODEBUDDY2API_AUTO_TRIAL"),
"failover_max": _item(0, "integer", "换凭证重放次数", env="CODEBUDDY2API_FAILOVER_MAX",
minimum=0, maximum=10),
"retry_write_timeout": _item(False, "boolean", "写超时参与重放",
Expand All @@ -50,11 +49,13 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum=
}


def validate_settings(values):
def validate_settings(values, *, legacy=False):
if not isinstance(values, dict):
raise ValueError("values 必须是对象")
clean = {}
for key, value in values.items():
if legacy and key == "auto_trial" and type(value) is bool:
continue # Retired persisted switch: accept old databases without enabling claims.
spec = SCHEMA.get(key)
if spec is None or spec["sensitive"]:
raise ValueError("未知或启动来源锁定的配置项")
Expand Down
114 changes: 114 additions & 0 deletions app/trial_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Safe manual trial results over the existing per-account ledger."""
import time

from . import model_policy, trial_rewards

_MESSAGES = {
"available": "尚未领取,可手动向官方申请一次性体验积分",
"claimed": "体验积分已领取,可另行同步余额",
"already": "官方确认该账号已领取,无需重复申请",
"unconfirmed": "上次领取结果尚未确认,可能仍在执行;请勿立即重复申请",
"timeout": "领取请求超时,官方可能已处理;请先核对余额,勿重复点击",
"network_error": "连接官方失败或响应中断,请检查服务器网络、DNS 和代理;勿立即重复申请",
"invalid_response": "官方响应格式无效,未确认领取成功",
"response_too_large": "官方响应超过安全大小限制,已停止读取,未确认领取成功",
"auth_error": "官方拒绝了当前凭证,请刷新 Token 或重新登录后核对",
"credential_error": "凭证不可用或 Token 刷新失败,未发送领取请求;请先刷新凭证或重新登录",
"rejected": "官方未确认领取成功,资格和额度由官方决定",
"storage_error": "领取记录无法读取或保存,已停止操作;请检查凭证目录权限和磁盘空间",
"changed": "凭证身份、代次或启用状态已变化,已停止后续操作,请刷新列表核对",
"not_applicable": "仅支持国际 WorkBuddy 账号,国内账号及国际 CodeBuddy 不适用",
"internal_error": "领取操作异常,结果未确认;请先核对余额及领取记录,勿重复点击",
}


def failure(state, **fields):
return {"ok": False, "can_claim": False, "state": state, "message": _MESSAGES[state], **fields}


def view(record=None, *, now=None):
now = time.time() if now is None else now
record = record or {}
attempted = record.get("attempted_at")
retry_at = attempted + trial_rewards.RETRY_INTERVAL if attempted is not None else None
status, code = record.get("status"), record.get("code")
if record.get("ok") is True:
state = "claimed"
elif record.get("already") is True:
state = "already"
elif attempted is None:
state = "available"
elif record.get("finished_at") is None:
state = "unconfirmed"
elif status is None:
state = "network_error"
elif status in (401, 403):
state = "auth_error"
else:
state = "rejected"
Comment thread
maiphucgiang marked this conversation as resolved.
done = state in {"claimed", "already"}
return {"ok": done, "can_claim": not done and (retry_at is None or now >= retry_at),
"state": state, "message": _MESSAGES[state], "code": code, "status": status,
"attempted_at": attempted, "finished_at": record.get("finished_at"),
"retry_at": None if done else retry_at}


def inventory(ledger):
if ledger is None:
return None
try:
return ledger.snapshot()
except (OSError, ValueError):
return None


def perform(gateway, pool, entry):
"""Called only by a single-account admin POST under the maintenance lock."""
if entry.get("profile") != "intl-work":
return failure("not_applicable", skipped=True)
ledger = gateway.CONFIG.get("trial_ledger")
records = inventory(ledger)
if records is None:
return failure("storage_error")
key, cm = entry.get("account_key"), entry["cm"]
previous = view(records.get(key))
if not previous["can_claim"]:
return {**previous, "skipped": True}
try:
with cm._lock:
if cm.summary().get("account_key") != key:
return failure("changed")
try:
headers = cm.get_headers()
except Exception:
return failure("credential_error")
generation = cm._generation
profile = gateway.profile_for_headers(headers)
uid = headers.get("X-User-Id")
if (profile != "intl-work" or not uid or
gateway.account_key(profile, uid, headers.get("X-Enterprise-Id", "")) != key):
return failure("changed")

def current():
return (model_policy.credential_enabled(gateway.CONFIG, entry)
and pool.apply_if_current(cm, generation, lambda: None))

result = trial_rewards.attempt_trial(ledger, key, headers, can_claim=current)
# Retain the original account's result even if the token was replaced mid-flight.
records = inventory(ledger)
if records is None:
return failure("storage_error", claimed=result.get("ok") is True,
upstream_already=result.get("already") is True)
status = view(records.get(key))
if result.get("error") in _MESSAGES and result.get("status") not in (401, 403):
status.update(ok=False, can_claim=False, state=result["error"], message=_MESSAGES[result["error"]])
if not current():
status["message"] += ";凭证已变化,请刷新列表核对原账号"
return status
except trial_rewards.TrialSaveError as error:
return failure("storage_error", claimed=error.result["ok"], upstream_already=error.result["already"],
message="官方请求已结束,但领取记录保存失败;请先核对余额,不要删除记录或立即重试")
except (OSError, ValueError):
return failure("storage_error")
except Exception:
return failure("internal_error")
Loading
Loading