From 9f8d0505b5bab7b91a8a8c3277a7c7031b0ac593 Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 14 Aug 2026 13:18:17 +0800 Subject: [PATCH 1/2] fix: send configurable browser-compatible vision UA --- .env.example | 3 +++ AGENT_INSTALL.md | 1 + CHANGELOG.md | 6 ++++++ README.md | 1 + README_CN.md | 1 + tests/test_vision_client.py | 39 +++++++++++++++++++++++++++++++++++-- vision_client.py | 23 +++++++++++++++++++--- 7 files changed, 69 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 565ef8f..432f372 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,6 @@ VISION_BASE_URL=https://openrouter.ai/api/v1 VISION_MODEL=google/gemini-3.6-flash # Vision model output language: zh=Chinese, en=English (defaults to Chinese when unset) LANG=zh +# Optional outbound User-Agent override. The default is browser-compatible to avoid +# gateways that block Python-urllib clients. +# VISION_USER_AGENT=custom-vision-client/1.0 diff --git a/AGENT_INSTALL.md b/AGENT_INSTALL.md index 12a7368..dd27980 100644 --- a/AGENT_INSTALL.md +++ b/AGENT_INSTALL.md @@ -58,6 +58,7 @@ VISION_API_KEY=... VISION_BASE_URL=... VISION_MODEL=... LANG=zh # 可选:视觉模型输出语言(zh/en),不填保持默认中文 +# VISION_USER_AGENT=custom-vision-client/1.0 # 可选:覆盖默认的浏览器兼容 User-Agent ``` 不要在 env 中写入上游模型的 key(如 `DEEPSEEK_API_KEY`)。上游鉴权仍由宿主发送。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 17b00d8..ea8348b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable user-facing changes to agent-vision-toolkit are documented in this file. +## [Unreleased] + +### Fixed + +- Send a browser-compatible, configurable User-Agent from the shared Python vision client so Cloudflare-backed OpenAI-compatible endpoints do not reject the default `Python-urllib` signature. + ## [0.1.0] - 2026-08-07 ### Added diff --git a/README.md b/README.md index 323a949..1d1d8c0 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,7 @@ The toolkit and proxy use only these environment variables; just three are requi | `VISION_BASE_URL` | Yes | OpenAI-compatible API base URL | | `VISION_MODEL` | Yes | Multimodal model name | | `LANG` | No | Vision model output language: `zh` (Chinese) or `en` (English); default `zh` | +| `VISION_USER_AGENT` | No | Outbound User-Agent for the Python client/proxy; defaults to a browser-compatible value and can be overridden for provider requirements | diff --git a/README_CN.md b/README_CN.md index a80e254..971b072 100644 --- a/README_CN.md +++ b/README_CN.md @@ -327,6 +327,7 @@ Codex -> 127.0.0.1:19100 -> 用户原有的纯文本模型上游 | `VISION_BASE_URL` | 是 | OpenAI-compatible API 地址 | | `VISION_MODEL` | 是 | 多模态模型名 | | `LANG` | 否 | 视觉模型输出语言:`zh`=中文,`en`=English(默认 `zh`) | +| `VISION_USER_AGENT` | 否 | Python 客户端/代理的出站 User-Agent;默认使用浏览器兼容值,也可按服务商要求覆盖 | diff --git a/tests/test_vision_client.py b/tests/test_vision_client.py index 34fd818..6ff69ce 100644 --- a/tests/test_vision_client.py +++ b/tests/test_vision_client.py @@ -9,6 +9,7 @@ import sys import tempfile import threading +import urllib.error sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) @@ -20,9 +21,11 @@ class Handler(BaseHTTPRequestHandler): bodies = [] calls = 0 last_body = b"" + last_headers = {} def do_POST(self): Handler.calls += 1 + Handler.last_headers = dict(self.headers) length = int(self.headers.get("Content-Length", 0)) Handler.last_body = self.rfile.read(length) status = Handler.statuses.pop(0) @@ -64,12 +67,24 @@ def main(): environment = dict(os.environ, VISION_API_KEY="test-key", VISION_BASE_URL=f"http://127.0.0.1:{server.server_port}/v1", VISION_MODEL="fixture-model") + environment.pop("VISION_USER_AGENT", None) saved = dict(os.environ) os.environ.update(environment) try: Handler.calls, Handler.statuses, Handler.bodies = 0, [429, 200], [] assert vision_client.describe_image("data:image/png;base64,AAAA") == "fixture answer" assert Handler.calls == 2 + assert Handler.last_headers.get("User-Agent") == vision_client.DEFAULT_USER_AGENT + assert not Handler.last_headers["User-Agent"].startswith("Python-urllib/") + + Handler.calls, Handler.statuses, Handler.bodies = 0, [200], [] + os.environ["VISION_USER_AGENT"] = "custom-vision-client/2.0" + try: + assert vision_client.describe_image("data:image/png;base64,AAAA") == "fixture answer" + finally: + os.environ.pop("VISION_USER_AGENT", None) + assert Handler.last_headers.get("User-Agent") == "custom-vision-client/2.0" + assert Handler.calls == 1 Handler.calls, Handler.statuses, Handler.bodies = 0, [401], [] try: @@ -81,7 +96,7 @@ def main(): assert Handler.calls == 1, "401 must not be retried" Handler.calls, Handler.statuses, Handler.bodies = ( - 0, [400], [b'{"error":"test-key must not leak"}'] + 0, [403], [b'{"error":"Cloudflare 1010 rejected test-key"}'] ) try: vision_client.describe_image("data:image/png;base64,AAAA") @@ -90,7 +105,27 @@ def main(): assert "" in str(exc) else: raise AssertionError("HTTP errors must fail cleanly") - assert Handler.calls == 1, "400 must not be retried" + assert Handler.calls == 1, "403 must not be retried" + + original_urlopen = vision_client.urllib.request.urlopen + original_sleep = vision_client.time.sleep + + def fail_with_secret(*_args, **_kwargs): + raise urllib.error.URLError("connection failed for test-key") + + vision_client.urllib.request.urlopen = fail_with_secret + vision_client.time.sleep = lambda _seconds: None + try: + try: + vision_client.describe_image("data:image/png;base64,AAAA") + except vision_client.VisionError as exc: + assert "test-key" not in str(exc) + assert "" in str(exc) + else: + raise AssertionError("network errors must fail with redacted details") + finally: + vision_client.urllib.request.urlopen = original_urlopen + vision_client.time.sleep = original_sleep Handler.calls, Handler.statuses, Handler.bodies = 0, [200], [] os.environ["LANG"] = "en" diff --git a/vision_client.py b/vision_client.py index fe3d2d2..995cca3 100644 --- a/vision_client.py +++ b/vision_client.py @@ -15,6 +15,11 @@ import urllib.request DEFAULT_PROMPT = "Please describe the contents of this image in detail." +DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/126.0.0.0 Safari/537.36" +) LANG_INSTRUCTIONS = { "zh": "请使用简体中文回答。", @@ -93,6 +98,13 @@ def _message_text(message: object) -> str: return "" +def _redact(text: str, *secrets: str) -> str: + for secret in secrets: + if secret: + text = text.replace(secret, "") + return text + + def describe_image(image_url: str | list[str], prompt: str | None = None, max_tokens: int = 4096, apply_lang: bool = True) -> str: """Describe one data/http image URL (str) or several (list) in a single call.""" @@ -105,6 +117,7 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to raise VisionError("Only data URLs or http(s) image URLs are supported") base_url = _required("VISION_BASE_URL").rstrip("/") api_key = _required("VISION_API_KEY") + user_agent = os.environ.get("VISION_USER_AGENT", "").strip() or DEFAULT_USER_AGENT text = prompt or DEFAULT_PROMPT if apply_lang: instruction = LANG_INSTRUCTIONS.get(os.environ.get("LANG", "").strip().lower()) @@ -121,7 +134,11 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to request = urllib.request.Request( base_url + "/chat/completions", data=json.dumps(payload).encode(), - headers={"Content-Type": "application/json", "Authorization": "Bearer " + api_key}, + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer " + api_key, + "User-Agent": user_agent, + }, ) retries = 2 timeout = 180 @@ -137,7 +154,7 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to raise VisionError("Vision API returned an empty description") return text except urllib.error.HTTPError as exc: - body = exc.read().decode(errors="replace")[:400].replace(api_key, "") + body = _redact(exc.read().decode(errors="replace")[:400], api_key) body = body.replace("\r", " ").replace("\n", " ") if exc.code in {429, 500, 502, 503, 504} and attempt < retries: print(f"vision: HTTP {exc.code}, retrying ({attempt + 1}/{retries})", file=sys.stderr) @@ -149,7 +166,7 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to print(f"vision: {type(exc).__name__}, retrying ({attempt + 1}/{retries})", file=sys.stderr) time.sleep(min(2 ** attempt, 4)) continue - reason = getattr(exc, "reason", str(exc)) + reason = _redact(str(getattr(exc, "reason", str(exc))), api_key) raise VisionError(f"Vision API network error: {reason}") from exc except json.JSONDecodeError as exc: raise VisionError("Vision API returned invalid JSON") from exc From 099e0d69453972be728d5bcfb0873effaec8aac8 Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 14 Aug 2026 13:21:27 +0800 Subject: [PATCH 2/2] test: isolate ambient vision user agent --- tests/test_vision_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_vision_client.py b/tests/test_vision_client.py index 6ff69ce..d76ff65 100644 --- a/tests/test_vision_client.py +++ b/tests/test_vision_client.py @@ -69,6 +69,7 @@ def main(): VISION_MODEL="fixture-model") environment.pop("VISION_USER_AGENT", None) saved = dict(os.environ) + os.environ.pop("VISION_USER_AGENT", None) os.environ.update(environment) try: Handler.calls, Handler.statuses, Handler.bodies = 0, [429, 200], []