diff --git a/app/client_profiles.py b/app/client_profiles.py index 9542446..a38b1ae 100644 --- a/app/client_profiles.py +++ b/app/client_profiles.py @@ -8,12 +8,12 @@ from .site_routing import domain_for_auth, profile_for_auth, profile_product, profile_region -CLI_VERSION = "2.165.0" +CLI_VERSION = "2.179.3" WORKBUDDY_VERSION = "5.9.3" -WORKBUDDY_CLI_VERSION = CLI_VERSION -INTL_CLI_VERSION = "2.161.0" -INTL_WORKBUDDY_VERSION = "5.8.4" -INTL_WORKBUDDY_CLI_VERSION = INTL_CLI_VERSION +WORKBUDDY_CLI_VERSION = "2.165.0" +INTL_CLI_VERSION = "2.179.3" +INTL_WORKBUDDY_VERSION = "5.9.4" +INTL_WORKBUDDY_CLI_VERSION = "2.165.0" CLI_USER_AGENT = f"CLI/{CLI_VERSION} CodeBuddy/{CLI_VERSION}" SDK_HEADERS = { @@ -118,7 +118,14 @@ def _identity_claim_headers(auth: dict, account: dict) -> dict: def credential_headers(auth: dict, account: dict) -> dict: - return {**SDK_HEADERS, **_auth_headers(auth, account), **_identity_claim_headers(auth, account)} + profile = profile_for_auth(auth) + headers = {**SDK_HEADERS, **_auth_headers(auth, account), **_identity_claim_headers(auth, account)} + if profile in ("cn-cli", "intl-cli"): + headers["x-stainless-package-version"] = "7.39.0" + headers["X-Agent-Runtime"] = "other" + elif profile == "intl-work": + headers["X-Agent-Runtime"] = "local-agent" + return headers def _cn_work_nonchat_headers(headers: dict, profile: str) -> dict: diff --git a/app/request_context.py b/app/request_context.py index 671a3ab..c2304ac 100644 --- a/app/request_context.py +++ b/app/request_context.py @@ -105,8 +105,11 @@ def build_attempt_headers(headers, profile, *, root, message_id, request_id, tra "traceparent": f"00-{trace_id}-{span}-01", "b3": f"{trace_id}-{span}-1-{parent}", "X-B3-TraceId": trace_id, "X-B3-SpanId": span, "X-B3-ParentSpanId": parent, "X-B3-Sampled": "1"} - if profile == "cn-work": + if profile in _WORKBUDDY_PROFILES: outgoing["X-Agent-Runtime"] = "local-agent" + elif profile in ("cn-cli", "intl-cli"): + outgoing["X-Agent-Runtime"] = "other" + if profile == "cn-work": if conversation_id: outgoing["X-Session-ID"] = conversation_id outgoing["X-Root-Request-ID"] = _stable_uuid7(["root", conversation_id]).replace("-", "") diff --git a/app/site_routing.py b/app/site_routing.py index e84dbe7..6acaa9d 100644 --- a/app/site_routing.py +++ b/app/site_routing.py @@ -16,6 +16,7 @@ "intl-cli": INTERNATIONAL_ENDPOINT, "intl-work": "https://www.workbuddy.ai", } +WORKBUDDY_CN_BACKUP_ENDPOINT = "https://wb.tencentbuddy.com" # The paid bge-m3 endpoint is verified on WorkBuddy CN for both domestic products. EMBEDDING_ENDPOINTS = {"cn-cli": "https://www.workbuddy.cn", "cn-work": "https://www.workbuddy.cn"} DOMAIN_PROFILES = { diff --git a/app/upstream_compat.py b/app/upstream_compat.py index 26a8823..6bfd95c 100644 --- a/app/upstream_compat.py +++ b/app/upstream_compat.py @@ -6,7 +6,7 @@ import re from urllib.parse import urlsplit, urlunsplit -from .site_routing import DOMAIN_PROFILES, profile_region +from .site_routing import DOMAIN_PROFILES, WORKBUDDY_CN_BACKUP_ENDPOINT, profile_region _SCHEMA_MAPS = frozenset(("properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies")) @@ -202,3 +202,22 @@ def channel_fallback_url(url: str) -> str | None: if profile is None or profile_region(profile) != "intl": return None return urlunsplit(parts._replace(path="/console/chat/completions")) + + +def workbuddy_origin_fallback_url(url: str, profile: str) -> str | None: + """Return the official backup origin for domestic WorkBuddy chat.""" + if profile != "cn-work": + return None + try: + parts = urlsplit(url) + except ValueError: + return None + if (parts.scheme != "https" or parts.path != "/v2/chat/completions" + or parts.netloc not in ("www.workbuddy.cn", "copilot.tencent.com")): + return None + return urlunsplit(parts._replace(netloc=urlsplit(WORKBUDDY_CN_BACKUP_ENDPOINT).netloc)) + + +def should_origin_fallback(fallback_url: str | None, status: int) -> bool: + """Replay only official transient gateway statuses on the backup origin.""" + return bool(fallback_url) and status in (408, 502, 503, 504) diff --git a/converter.py b/converter.py index 07e92f8..9c52b28 100644 --- a/converter.py +++ b/converter.py @@ -102,7 +102,7 @@ def apply_system_prompt_mode(body, profile, mode): from app.admin_auth import SessionStoreError from app.content_filter import ContentFilterDetector, is_filter_error from app.upstream_compat import (channel_fallback_url, is_channel_check_error, normalize_tool_schemas, - should_channel_fallback) + should_channel_fallback, should_origin_fallback, workbuddy_origin_fallback_url) from app.request_limits import ImageLimitError, apply_image_policy from app.safe_logging import format_log_body, sanitize_log_text from app import perf_metrics @@ -5016,46 +5016,74 @@ def _check_upstream_status(status, raw, cred, model, *, headers=None, from_chann from_channel_fallback=from_channel_fallback) +def _origin_replay_safe(raw: bytes) -> bool: + """Preserve filter, channel and model-specific errors before origin replay.""" + if is_filter_error(raw): + return False + code, _, _ = _upstream_diagnostic(raw) + return code not in NO_FAILOVER_UPSTREAM_CODES and code not in MODEL_SCOPED_ERROR_CODES + + @asynccontextmanager async def _checked_chat_stream(url, headers, body, *, rid, model_name, cred, on_channel_body=None): - """Open a chat stream with one same-account console and role fallback.""" + """Open a chat stream with same-account channel and WorkBuddy origin fallback.""" + profile = profile_for_headers(headers) fallback_url = channel_fallback_url(url) + origin_url = workbuddy_origin_fallback_url(url, profile) from_channel_fallback = False channel_body_retried = False + + def switch_origin(*, cost_note="", **fields): + nonlocal url, origin_url + target = origin_url + observe_attempt("origin_fallback", profile=profile, upstream_model=body.get("model"), **fields) + _log(f"[{rid}] 上游入口不可用,切换 WorkBuddy 备份源站重试 | {model_name}{cost_note}") + url, origin_url = target, None + while True: - async with _backend_stream(url, headers, body, rid=rid, model_name=model_name) as response: - if response.status_code == 200: - _note_cred_model_ok(cred, body.get("model")) - yield response - return - raw = await read_bounded_error(response) - if should_channel_fallback(fallback_url, response.status_code, raw): - observe_attempt("channel_check_fallback", status_code=response.status_code, - profile=profile_for_headers(headers), upstream_model=body.get("model")) - _log(f"[{rid}] 上游渠道校验,切换 /console 端点重试 | {model_name}") - url, fallback_url, from_channel_fallback = fallback_url, None, True - continue - if (from_channel_fallback and not channel_body_retried - and response.status_code == 400 and is_channel_check_error(raw)): - transformed = system_as_user_body(body) - if transformed != body: - try: - _guard_request_size(transformed) - except HTTPException: - transformed = body + response_started = False + try: + async with _backend_stream(url, headers, body, rid=rid, model_name=model_name) as response: + if response.status_code == 200: + response_started = True + _note_cred_model_ok(cred, body.get("model")) + yield response + return + raw = await read_bounded_error(response) + if should_origin_fallback(origin_url, response.status_code) and _origin_replay_safe(raw): + switch_origin(status_code=response.status_code, + cost_note=_replay_cost_note(response.status_code)) + continue + if should_channel_fallback(fallback_url, response.status_code, raw): + observe_attempt("channel_check_fallback", status_code=response.status_code, + profile=profile, upstream_model=body.get("model")) + _log(f"[{rid}] 上游渠道校验,切换 /console 端点重试 | {model_name}") + url, fallback_url, from_channel_fallback = fallback_url, None, True + continue + if (from_channel_fallback and not channel_body_retried + and response.status_code == 400 and is_channel_check_error(raw)): + transformed = system_as_user_body(body) if transformed != body: - channel_body_retried = True - body = transformed - if on_channel_body is not None: - on_channel_body(transformed) - observe_attempt("channel_system_as_user_retry", status_code=response.status_code, - profile=profile_for_headers(headers), upstream_model=body.get("model")) - _log(f"[{rid}] 渠道拒绝,system 指令降级为 user 后同账号重试 | {model_name}") - continue - _check_upstream_status(response.status_code, raw, cred, body.get("model"), - headers=response.headers, from_channel_fallback=from_channel_fallback) - + try: + _guard_request_size(transformed) + except HTTPException: + transformed = body + if transformed != body: + channel_body_retried = True + body = transformed + if on_channel_body is not None: + on_channel_body(transformed) + observe_attempt("channel_system_as_user_retry", status_code=response.status_code, + profile=profile, upstream_model=body.get("model")) + _log(f"[{rid}] 渠道拒绝,system 指令降级为 user 后同账号重试 | {model_name}") + continue + _check_upstream_status(response.status_code, raw, cred, body.get("model"), + headers=response.headers, from_channel_fallback=from_channel_fallback) + except httpx.HTTPError as error: + if response_started or origin_url is None or not _failover_safe(error): + raise + switch_origin(error_code=type(error).__name__, cost_note=_replay_cost_note(error)) def _upstream_failure(error, model_name, t0, rid): """Normalize failure logs and payloads before endpoint-specific error wrapping.""" @@ -5295,6 +5323,8 @@ def _cred_manager(cred): def _replay_cost_note(error) -> str: """Label replays that may duplicate already billed work.""" + if isinstance(error, int) and error in POSSIBLY_CHARGED_CODES: + return " | 上游可能已处理该请求" if isinstance(error, UpstreamHTTPError) and error.status in POSSIBLY_CHARGED_CODES: return " | 上游可能已处理该请求" if isinstance(error, WRITE_TIMEOUT_TRANSPORT): diff --git a/docs/advanced.md b/docs/advanced.md index cdea74d..e52c090 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -137,7 +137,7 @@ Generation responses carry a gateway-generated `X-Request-ID` for correlation wi In scoped mode, optionally send `X-Codebuddy-Session-ID`, `metadata.conversation_id` / `metadata.conversationId`, or top-level `conversation_id` / `conversationId`. Values must agree; conflicting, non-string, control-character or over-512-UTF-8-byte values return 400. Empty values fall back to a fingerprint of adapted instructions and the first user input, including image references; URLs are not fetched. Without reliable input a temporary session is used. Identical inputs without explicit IDs remain indistinguishable; `user`, `metadata.user_id` and `prompt_cache_key` are not session IDs. Raw hints are neither logged nor forwarded upstream. Switch back to `legacy` to restore old behavior for new requests; in-flight requests retain their initial mode. -Upstream client identities follow verified official packages: domestic CLI 2.165.0 / WorkBuddy 5.9.3 and international CLI 2.161.0 / WorkBuddy 5.8.4. Domestic WorkBuddy uses session-stable UUIDv7 conversation IDs, per-attempt request/tracing headers and gzip for JSON bodies of at least 1024 bytes; CLI and international WorkBuddy use their existing ID relationships and always gzip chat JSON. Headers are generated locally without reading hardware IDs. +Upstream client identities use CLI 2.179.3 for domestic and international CodeBuddy; domestic WorkBuddy 5.9.3 and international WorkBuddy 5.9.4 keep embedded CLI 2.165.0. CLI profiles send `X-Agent-Runtime: other`; international WorkBuddy sends `local-agent`. Domestic WorkBuddy uses session-stable UUIDv7 conversation IDs, per-attempt request/tracing headers and gzip for JSON bodies of at least 1024 bytes; CLI and international WorkBuddy use their existing ID relationships and always gzip chat JSON. Before any response starts, domestic WorkBuddy retries 408/502/503/504 and connection failures once on `wb.tencentbuddy.com` with the same account and `X-Domain`; filter, channel and model-specific errors never trigger it; 502/504 retries log a possible-billing warning. Headers are generated locally without reading hardware IDs. ## Automation and rewards diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index 2880e5e..eeac40d 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -137,7 +137,7 @@ WebUI 系统设置可配置这两项;环境变量为 `CODEBUDDY2API_UPSTREAM_K scoped 模式可选传入 `X-Codebuddy-Session-ID`、`metadata.conversation_id` / `metadata.conversationId` 或顶层 `conversation_id` / `conversationId`。多处值须一致;冲突、非字符串、控制字符或超过 512 UTF-8 字节时返回 400。空值回退为协议适配后的指令与首条用户输入指纹,包含图片引用但不抓取 URL;缺少可靠输入时使用临时会话。无显式 ID 的相同输入仍无法区分;`user`、`metadata.user_id`、`prompt_cache_key` 不是会话 ID。原始标识不记录、不转发上游。 设回 `legacy` 即恢复新请求的旧行为,在途请求保留入口模式。 -上游客户端身份按已核实的官方包对齐:国内 CLI 2.165.0/WorkBuddy 5.9.3,国际 CLI 2.161.0/WorkBuddy 5.8.4。国内 WorkBuddy 使用会话稳定的 UUIDv7 会话 ID、逐次请求/追踪头,并在 JSON 请求体达到 1024 字节时使用 gzip;CLI 与国际 WorkBuddy 保持原有 ID 关系,聊天 JSON 始终使用 gzip。所有身份头均在本地生成,不读取硬件 ID。 +国内、国际 CodeBuddy 均使用 CLI 2.179.3;国内 WorkBuddy 5.9.3 与国际 WorkBuddy 5.9.4 的内嵌 CLI 均保持 2.165.0。CLI 均发送 `X-Agent-Runtime: other`,国际 WorkBuddy 发送 `local-agent`。国内 WorkBuddy 使用会话稳定的 UUIDv7 会话 ID、逐次请求/追踪头,并在 JSON 请求体达到 1024 字节时使用 gzip;CLI 与国际 WorkBuddy 保持原有 ID 关系,聊天 JSON 始终使用 gzip。国内 WorkBuddy 在响应开始前遇到 408/502/503/504 或建连失败时,以同账号和原 `X-Domain` 在 `wb.tencentbuddy.com` 重试一次;内容过滤、渠道和模型特定错误不触发域名回退;502/504 回退会记录可能重复计费警告。所有身份头均在本地生成,不读取硬件 ID。 ## 自动化与奖励 diff --git a/tests/test_api_flow.py b/tests/test_api_flow.py index fd535f2..7c9830c 100644 --- a/tests/test_api_flow.py +++ b/tests/test_api_flow.py @@ -82,7 +82,13 @@ def reject(request): self.assertEqual(response.headers.get("Retry-After"), "17") self.assertNotIn("X-Private", response.headers) self.assertEqual(response.json()["error"]["code"], "quota") - self.assertEqual(len(seen), 1, "A retry hint must not enable replay") + expected_attempts = 2 if profile == "cn-work" and status == 503 else 1 + self.assertEqual(len(seen), expected_attempts, + "Only domestic WorkBuddy may replay 503 on its backup origin") + if expected_attempts == 2: + self.assertEqual([request.url.host for request in seen], + ["www.workbuddy.cn", "wb.tencentbuddy.com"]) + self.assertEqual(len({request.headers["x-user-id"] for request in seen}), 1) entry = self.fx.entries[profile] self.assertEqual(entry["fail_until"], 0) if status == 429: diff --git a/tests/test_identity_sync.py b/tests/test_identity_sync.py index 6ba9c5f..a125fa8 100644 --- a/tests/test_identity_sync.py +++ b/tests/test_identity_sync.py @@ -92,10 +92,10 @@ def picked_uid(self, name, region="cn"): def test_official_versions_and_private_data_are_profile_scoped(self): expected = { - "cn-cli": ("CLI/2.165.0 CodeBuddy/2.165.0", "2.165.0", "false", "2.165.0"), - "intl-cli": ("CLI/2.161.0 CodeBuddy/2.161.0", "2.161.0", "false", "2.161.0"), + "cn-cli": ("CLI/2.179.3 CodeBuddy/2.179.3", "2.179.3", "false", "2.179.3"), + "intl-cli": ("CLI/2.179.3 CodeBuddy/2.179.3", "2.179.3", "false", "2.179.3"), "cn-work": ("WorkBuddy/5.9.3 WorkBuddy/5.9.3 CLI/2.165.0", "5.9.3", "false", "5.9.3:2.165.0"), - "intl-work": ("WorkBuddy/5.8.4 WorkBuddy AI/5.8.4 CLI/2.161.0", "5.8.4", "true", "5.8.4:2.161.0"), + "intl-work": ("WorkBuddy/5.9.4 WorkBuddy AI/5.9.4 CLI/2.165.0", "5.9.4", "true", "5.9.4:2.165.0"), } for profile, (user_agent, ide_version, private_data, revision) in expected.items(): with self.subTest(profile=profile): @@ -105,6 +105,13 @@ def test_official_versions_and_private_data_are_profile_scoped(self): self.assertEqual(headers["X-IDE-Version"], ide_version) self.assertEqual(headers["X-Private-Data"], private_data) self.assertEqual(client_profiles.catalog_cache_key(profile), f"{profile}:{revision}") + sdk_version = "7.39.0" if profile in ("cn-cli", "intl-cli") else "6.25.0" + self.assertEqual(headers["x-stainless-package-version"], sdk_version) + runtime = {"cn-cli": "other", "intl-cli": "other", "intl-work": "local-agent"}.get(profile) + if runtime is None: + self.assertNotIn("X-Agent-Runtime", headers) + else: + self.assertEqual(headers["X-Agent-Runtime"], runtime) def test_private_data_and_identity_headers_follow_the_real_account(self): for profile, domain in DOMAINS.items(): diff --git a/tests/test_request_context.py b/tests/test_request_context.py index d7a6ba6..f2f0869 100644 --- a/tests/test_request_context.py +++ b/tests/test_request_context.py @@ -166,11 +166,15 @@ def test_cn_work_session_ids_are_stable_uuid7_and_attempt_scoped(self): self.assertEqual(baggage["codebuddy.session_id"], conversation) self.assertEqual(baggage["codebuddy.conversation_request_id"], headers["X-Conversation-Request-ID"]) - for profile in ("cn-cli", "intl-cli", "intl-work"): - other = first.attempt_headers(base, first.start_attempt(), profile=profile) - self.assertNotIn("X-Agent-Runtime", other) - self.assertNotIn("acp-connection-id", other) - self.assertNotIn("baggage", other) + for profile, runtime in (("cn-cli", "other"), ("intl-cli", "other"), ("intl-work", "local-agent")): + with self.subTest(profile=profile): + other = first.attempt_headers(base, first.start_attempt(), profile=profile) + if runtime is None: + self.assertNotIn("X-Agent-Runtime", other) + else: + self.assertEqual(other["X-Agent-Runtime"], runtime) + self.assertNotIn("acp-connection-id", other) + self.assertNotIn("baggage", other) @@ -189,14 +193,19 @@ def assert_attempts(self, response, seen, *, same_account=True): headers = request.headers workbuddy = headers["X-IDE-Type"] == "WorkBuddy" trace = headers["X-Trace-ID"] - if gateway.profile_for_headers(headers) == "cn-work": + profile = gateway.profile_for_headers(headers) + if profile == "cn-work": self.assertRegex(headers["X-Root-Request-ID"], r"^[0-9a-f]{32}$") self.assertNotEqual(headers["X-Root-Request-ID"], root) self.assertEqual(headers["X-Session-ID"], headers["X-Conversation-ID"]) - self.assertEqual(headers["X-Agent-Runtime"], "local-agent") else: self.assertEqual(headers["X-Root-Request-ID"], root) + runtime = {"cn-cli": "other", "cn-work": "local-agent", + "intl-cli": "other", "intl-work": "local-agent"}.get(profile) + if runtime is None: self.assertNotIn("X-Agent-Runtime", headers) + else: + self.assertEqual(headers["X-Agent-Runtime"], runtime) if workbuddy: self.assertNotEqual(trace, root) self.assertNotEqual(headers["X-Request-ID"], headers["X-Conversation-Message-ID"]) @@ -285,6 +294,15 @@ def test_prompt_modes_cover_profiles_protocols_and_client_instructions(self): self.assertEqual(twice, once) self.assertEqual(original, {"messages": [{"role": "user", "content": "hi"}]}) + def test_legacy_runtime_headers(self): + for profile, runtime in (("cn-cli", "other"), ("intl-cli", "other"), ("intl-work", "local-agent")): + with self.subTest(profile=profile): + self.fx.configure(profiles=(profile,)) + with patch.dict(gateway.CONFIG, {"request_context_mode": "legacy"}): + response = self.fx.client.post("/v1/chat/completions", json=self.fx.payload("chat/completions")) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(self.fx.requests[-1].headers["X-Agent-Runtime"], runtime) + def test_legacy_workbuddy_attempt_ids_are_independent(self): for profile in ("cn-work", "intl-work"): with self.subTest(profile=profile): diff --git a/tests/test_upstream_compat.py b/tests/test_upstream_compat.py index c4e079a..befa778 100644 --- a/tests/test_upstream_compat.py +++ b/tests/test_upstream_compat.py @@ -5,6 +5,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. +from contextlib import asynccontextmanager from copy import deepcopy import gzip import json @@ -16,7 +17,8 @@ import converter from app import buddy_task, growth_events, rate_measurement -from app.upstream_compat import channel_fallback_url, is_channel_check_error, normalize_tool_schemas +from app.upstream_compat import (channel_fallback_url, is_channel_check_error, normalize_tool_schemas, + workbuddy_origin_fallback_url) from tests import test_region_routing as fixtures CHANNEL_ERROR = { @@ -137,11 +139,65 @@ def test_console_fallback_requires_international_primary_chat(self): self.assertIsNone(channel_fallback_url(url)) + def test_cn_work_origin_fallback_targets_official_backup(self): + for host in ("www.workbuddy.cn", "copilot.tencent.com"): + url = f"https://{host}/v2/chat/completions" + self.assertEqual(workbuddy_origin_fallback_url(url, "cn-work"), + "https://wb.tencentbuddy.com/v2/chat/completions") + for url, profile in (("https://copilot.tencent.com/v2/chat/completions", "cn-cli"), + ("https://wb.tencentbuddy.com/v2/chat/completions", "cn-work"), + ("https://www.workbuddy.ai/v2/chat/completions", "intl-work"), + ("https://www.workbuddy.cn/agenttool/v1/search", "cn-work")): + self.assertIsNone(workbuddy_origin_fallback_url(url, profile)) + + +class OriginStreamTests(unittest.IsolatedAsyncioTestCase): + async def test_origin_never_replays_after_response_started(self): + calls = [] + + @asynccontextmanager + async def backend(url, headers, body, **kwargs): + calls.append(url) + yield httpx.Response(200) + + headers = {"X-Domain": "www.workbuddy.cn"} + with patch.object(converter, "_backend_stream", backend): + with self.assertRaises(httpx.ReadError): + async with converter._checked_chat_stream( + "https://www.workbuddy.cn/v2/chat/completions", headers, + {"model": "shared-model"}, rid="test", model_name="shared-model", cred=None): + raise httpx.ReadError("midstream failure") + self.assertEqual(calls, ["https://www.workbuddy.cn/v2/chat/completions"]) + + async def test_origin_rejects_ambiguous_transport_by_default(self): + headers = {"X-Domain": "www.workbuddy.cn"} + for error in (httpx.WriteTimeout("partial write"), httpx.ReadTimeout("accepted request")): + with self.subTest(error=type(error).__name__): + calls = [] + + @asynccontextmanager + async def backend(url, headers, body, **kwargs): + calls.append(url) + raise error + yield + + with (patch.object(converter, "_backend_stream", backend), + patch.dict(converter.CONFIG, {"retry_write_timeout": False})): + with self.assertRaises(type(error)): + async with converter._checked_chat_stream( + "https://www.workbuddy.cn/v2/chat/completions", headers, + {"model": "shared-model"}, rid="test", model_name="shared-model", cred=None): + self.fail("unreachable") + self.assertEqual(calls, ["https://www.workbuddy.cn/v2/chat/completions"]) + + class UpstreamCompatEndpointTests(fixtures.RegionRoutingTests): def setUp(self): super().setUp() self.channel_mode = None self.channel_uid = None + self.origin_mode = None + self.origin_error = None def record(self, request): uid = request.headers.get("x-user-id") @@ -159,6 +215,18 @@ def handle_upstream(self, request): "results": [{"title": "t", "url": "https://example.com/", "snippet": "s", "site": "example.com"}], "total_results": 1, "response_time_ms": 1}) + if self.origin_mode and path == "/v2/chat/completions": + self.record(request) + if request.url.host == "www.workbuddy.cn": + if self.origin_mode == "protected": + return httpx.Response(503, json=self.origin_error) + if self.origin_mode == "transport": + raise httpx.ConnectError("synthetic connect failure", request=request) + return httpx.Response(502, json={"error": {"message": "synthetic unavailable"}}) + self.assertEqual(request.url.host, "wb.tencentbuddy.com") + self.assertEqual(request.headers["x-domain"], "www.workbuddy.cn") + return httpx.Response(200, content=fixtures.success_sse(), + headers={"Content-Type": "text/event-stream"}) if self.channel_mode and path in ("/v2/chat/completions", "/console/chat/completions"): uid = self.record(request) if self.channel_mode in ("first", "model_missing") and self.channel_uid is None: @@ -258,6 +326,51 @@ def test_rejection_on_both_endpoints_does_not_cool_the_account_or_fail_over(self self.assertEqual(self.requests[1].headers["x-user-id"], first_uid) self.assertLessEqual(self.current_entry(first_uid)["fail_until"], time.time()) + def test_cn_work_origin_fallback_covers_protocols_and_stream_modes(self): + self.configure_profiles("cn-work") + self.origin_mode = "http" + for endpoint in fixtures.GENERATIONS: + for stream in (False, True): + with self.subTest(endpoint=endpoint, stream=stream): + self.requests.clear() + response = self.client.post("/v1/" + endpoint, json=self.payload(endpoint, stream=stream)) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual([request.url.host for request in self.requests], + ["www.workbuddy.cn", "wb.tencentbuddy.com"]) + + def test_cn_work_transport_failure_uses_backup_after_local_retry(self): + self.configure_profiles("cn-work") + self.origin_mode = "transport" + response = self.client.post("/v1/chat/completions", json=self.payload(stream=True)) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual([request.url.host for request in self.requests], + ["www.workbuddy.cn", "www.workbuddy.cn", "wb.tencentbuddy.com"]) + + def test_cn_work_origin_fallback_preserves_protected_errors(self): + self.configure_profiles("cn-work") + self.origin_mode = "protected" + cases = { + "filter": {"error": {"code": "content_filter", "message": "filtered"}}, + "channel": {"code": 11128, "msg": "channel rejected"}, + "model": {"code": 11102, "msg": "model [shared-model] service info not found"}, + } + for label, payload in cases.items(): + for stream in (False, True): + with self.subTest(label=label, stream=stream): + self.origin_error = payload + self.requests.clear() + response = self.client.post("/v1/chat/completions", json=self.payload(stream=stream)) + self.assertEqual(response.status_code, 503, response.text) + self.assertEqual([request.url.host for request in self.requests], ["www.workbuddy.cn"]) + + def test_cn_work_origin_fallback_logs_possible_billing(self): + self.configure_profiles("cn-work") + self.origin_mode = "http" + with patch.object(converter, "_log") as log: + response = self.client.post("/v1/chat/completions", json=self.payload()) + self.assertEqual(response.status_code, 200, response.text) + self.assertTrue(any("上游可能已处理该请求" in call.args[0] for call in log.call_args_list)) + def test_channel_rejection_moves_system_to_user_once_on_same_account(self): self.configure_profiles("intl-work") self.channel_mode = "always"