From 2c87e20fd60421e59b588aad4d26f0359c9518ff Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 14 Aug 2026 17:00:17 +0800 Subject: [PATCH 1/4] feat: support Anthropic Messages vision requests --- tests/test_vision_client.py | 32 +++++++++++++++++++++ vision_client.py | 57 ++++++++++++++++++++++++++++++------- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/tests/test_vision_client.py b/tests/test_vision_client.py index 658de7b..2e553e0 100644 --- a/tests/test_vision_client.py +++ b/tests/test_vision_client.py @@ -202,6 +202,38 @@ def fail_with_secret(*_args, **_kwargs): assert payload["reasoning"] == {"effort": "medium"} assert Handler.calls == 1 + Handler.calls, Handler.statuses, Handler.bodies = 0, [200], [json.dumps({ + "content": [ + {"type": "thinking", "thinking": "internal reasoning"}, + {"type": "text", "text": "anthropic fixture answer"}, + {"type": "text", "text": "second text block"}, + ], + "usage": {"input_tokens": 42, "output_tokens": 7}, + }).encode()] + os.environ["VISION_API_PROTOCOL"] = "anthropic" + try: + assert vision_client.describe_image( + ["data:image/png;base64,AAAA", "https://example.com/remote.webp"], + prompt="read both images", + max_tokens=123, + ) == "anthropic fixture answer\nsecond text block" + finally: + os.environ.pop("VISION_API_PROTOCOL", None) + assert Handler.last_path == "/v1/messages" + assert next(v for k, v in Handler.last_headers.items() if k.lower() == "x-api-key") == "test-key" + assert next(v for k, v in Handler.last_headers.items() if k.lower() == "anthropic-version") == "2023-06-01" + assert not any(k.lower() == "authorization" for k in Handler.last_headers) + payload = json.loads(Handler.last_body) + assert payload["max_tokens"] == 123 + assert payload["thinking"] == {"type": "disabled"} + content = payload["messages"][0]["content"] + assert [part["type"] for part in content] == ["image", "image", "text"] + assert content[0]["source"] == { + "type": "base64", "media_type": "image/png", "data": "AAAA" + } + assert content[1]["source"] == {"type": "url", "url": "https://example.com/remote.webp"} + assert Handler.calls == 1 + Handler.calls, Handler.statuses, Handler.bodies = 0, [], [] os.environ["VISION_API_PROTOCOL"] = "unsupported" try: diff --git a/vision_client.py b/vision_client.py index 3a829cf..5cbc4e8 100644 --- a/vision_client.py +++ b/vision_client.py @@ -112,6 +112,29 @@ def _responses_text(response: object) -> str: ).strip() +def _anthropic_image_source(url: str) -> dict[str, str]: + if not url.startswith("data:"): + return {"type": "url", "url": url} + header, separator, data = url.partition(",") + if separator == "" or ";base64" not in header: + raise VisionError("Anthropic image data URLs must use base64 encoding") + media_type = header[5:].split(";", 1)[0] + if not media_type: + raise VisionError("Anthropic image data URLs must include a media type") + return {"type": "base64", "media_type": media_type, "data": data} + + +def _anthropic_text(response: object) -> str: + if not isinstance(response, dict) or not isinstance(response.get("content"), list): + return "" + return "\n".join( + block["text"] + for block in response["content"] + if isinstance(block, dict) and block.get("type") == "text" + and isinstance(block.get("text"), str) + ).strip() + + def _redact(text: str, *secrets: str) -> str: for secret in secrets: if secret: @@ -165,19 +188,33 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to payload["max_tokens"] = max_tokens endpoint = "/chat/completions" extract_text = lambda data: _message_text(data["choices"][0]["message"]["content"]) + elif protocol == "anthropic": + payload = { + "model": model, + "max_tokens": max_tokens if max_tokens is not None else 4096, + "messages": [{"role": "user", "content": [ + {"type": "image", "source": _anthropic_image_source(url)} for url in urls + ] + [{"type": "text", "text": text}]}], + "thinking": {"type": "disabled"}, + } + endpoint = "/messages" + extract_text = _anthropic_text else: raise VisionError( - "Unsupported VISION_API_PROTOCOL; use chat_completions or responses" + "Unsupported VISION_API_PROTOCOL; use chat_completions, responses, or anthropic" ) - request = urllib.request.Request( - base_url + endpoint, - data=json.dumps(payload).encode(), - headers={ - "Content-Type": "application/json", - "Authorization": "Bearer " + api_key, - "User-Agent": user_agent, - }, - ) + headers = { + "Content-Type": "application/json", + "User-Agent": user_agent, + } + if protocol == "anthropic": + headers.update({ + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + }) + else: + headers["Authorization"] = "Bearer " + api_key + request = urllib.request.Request(base_url + endpoint, data=json.dumps(payload).encode(), headers=headers) retries = 2 timeout = 180 for attempt in range(retries + 1): From 0f37ec94e492e1d94261421f9ee94034ff083fbe Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 14 Aug 2026 17:30:32 +0800 Subject: [PATCH 2/4] fix: make Anthropic transport model-aware --- .env.example | 6 ++-- AGENT_INSTALL.md | 3 +- CHANGELOG.md | 2 ++ README.md | 9 +++--- README_CN.md | 9 +++--- tests/test_vision_client.py | 58 +++++++++++++++++++++++++++++++++---- vision_client.py | 29 ++++++++++++++++--- 7 files changed, 96 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index d80f10e..6382fec 100644 --- a/.env.example +++ b/.env.example @@ -10,11 +10,13 @@ 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 -# Python client/proxy protocol: chat_completions (default) or responses. Choose -# "responses" for models exposed only through the Responses API. +# Python client/proxy protocol: chat_completions (default), responses, or +# anthropic. For anthropic, use a base URL ending in /v1, not /messages. # VISION_API_PROTOCOL=chat_completions # Reasoning effort sent with the responses protocol (optional). # VISION_REASONING_EFFORT=medium +# Anthropic thinking mode: omit (default), disabled, or adaptive. +# VISION_ANTHROPIC_THINKING=omit # 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 1c1cea5..5ea5e45 100644 --- a/AGENT_INSTALL.md +++ b/AGENT_INSTALL.md @@ -58,8 +58,9 @@ VISION_API_KEY=... VISION_BASE_URL=... VISION_MODEL=... LANG=zh # 可选:视觉模型输出语言(zh/en),不填保持默认中文 -# VISION_API_PROTOCOL=chat_completions # 可选:Python 客户端/代理可改用 responses 协议 +# VISION_API_PROTOCOL=chat_completions # 可选:chat_completions / responses / anthropic # VISION_REASONING_EFFORT=medium # 可选:responses 协议下的推理强度 +# VISION_ANTHROPIC_THINKING=omit # 可选:Anthropic thinking 为 omit / disabled / adaptive # VISION_USER_AGENT=custom-vision-client/1.0 # 可选:覆盖默认的浏览器兼容 User-Agent ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c63ee2..ebbd3d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,13 @@ All notable user-facing changes to agent-vision-toolkit are documented in this f ### Added - Let the shared Python vision client call either Chat Completions or Responses APIs, including optional reasoning effort and explicit `store: false` data handling. +- Add native Anthropic Messages requests with protocol-specific authentication, image sources, optional thinking control, and text-block response extraction. - Rewrite OpenAI Chat Completions `image_url` blocks through the existing vision-description pipeline with a host-neutral channel note. ### 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. +- Honor `Retry-After` and retry Anthropic 529 overload responses. ## [0.1.0] - 2026-08-07 diff --git a/README.md b/README.md index 5a3de9b..1208cc8 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ VISION_BASE_URL=https://openrouter.ai/api/v1 VISION_MODEL=google/gemini-3.6-flash ``` -Any OpenAI-compatible endpoint that supports `/chat/completions` with `image_url` works (e.g. Aliyun DashScope: `https://dashscope.aliyuncs.com/compatible-mode/v1` + `qwen-vl-max-latest`). The Python client/proxy can also use `/responses` with `input_image` by setting `VISION_API_PROTOCOL=responses`. Add `LANG=en` for English descriptions (default is Chinese). +Any OpenAI-compatible endpoint that supports `/chat/completions` with `image_url` works (e.g. Aliyun DashScope: `https://dashscope.aliyuncs.com/compatible-mode/v1` + `qwen-vl-max-latest`). The Python client/proxy can also use `/responses` with `input_image` by setting `VISION_API_PROTOCOL=responses`, or Anthropic Messages by setting `VISION_API_PROTOCOL=anthropic` and a base URL ending in `/v1` (not `/messages`). Add `LANG=en` for English descriptions (default is Chinese). **2. Put the CLIs on your PATH:** @@ -328,11 +328,12 @@ The standalone CLIs and Python proxy use these environment variables; just three | Variable | Required | Description | |---|---:|---| | `VISION_API_KEY` | Yes | API key of the multimodal model | -| `VISION_BASE_URL` | Yes | OpenAI-compatible API base URL | +| `VISION_BASE_URL` | Yes | Provider API base URL; include `/v1` but not the protocol endpoint such as `/messages` | | `VISION_MODEL` | Yes | Multimodal model name | | `LANG` | No | Vision model output language: `zh` (Chinese) or `en` (English); default `zh` | -| `VISION_API_PROTOCOL` | No | Python client/proxy protocol: `chat_completions` (default) or `responses` | +| `VISION_API_PROTOCOL` | No | Python client/proxy protocol: `chat_completions` (default), `responses`, or `anthropic`; Anthropic mode uses `x-api-key` and `anthropic-version` | | `VISION_REASONING_EFFORT` | No | Optional provider-supported reasoning effort for the Python client/proxy when using `responses` | +| `VISION_ANTHROPIC_THINKING` | No | Anthropic thinking mode: `omit` (default), `disabled`, or `adaptive` | | `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 | @@ -352,7 +353,7 @@ The route whose connection (TCP/TLS handshake) succeeds is kept in memory and re ## Prerequisites - A coding agent already working with a model, including a text-only model such as DeepSeek V4 -- An OpenAI-compatible vision API that supports `/chat/completions` and `image_url`; the Python client/proxy can also use `/responses` with `input_image` via `VISION_API_PROTOCOL=responses` +- A vision API supporting OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages; select the latter two with `VISION_API_PROTOCOL=responses` or `VISION_API_PROTOCOL=anthropic` - No other configuration is required ## FAQ diff --git a/README_CN.md b/README_CN.md index 0941370..b2bac66 100644 --- a/README_CN.md +++ b/README_CN.md @@ -152,7 +152,7 @@ VISION_BASE_URL=https://openrouter.ai/api/v1 VISION_MODEL=google/gemini-3.6-flash ``` -任何支持 `/chat/completions` 与 `image_url` 的 OpenAI-compatible 端点都可以(如阿里云百炼:`https://dashscope.aliyuncs.com/compatible-mode/v1` + `qwen-vl-max-latest`)。Python 客户端/代理也可设置 `VISION_API_PROTOCOL=responses` 使用 `/responses` + `input_image`。需要英文描述时加 `LANG=en`(默认中文)。 +任何支持 `/chat/completions` 与 `image_url` 的 OpenAI-compatible 端点都可以(如阿里云百炼:`https://dashscope.aliyuncs.com/compatible-mode/v1` + `qwen-vl-max-latest`)。Python 客户端/代理也可设置 `VISION_API_PROTOCOL=responses` 使用 `/responses` + `input_image`,或设置 `VISION_API_PROTOCOL=anthropic` 使用 Anthropic Messages;此时 Base URL 应以 `/v1` 结尾,不要包含 `/messages`。需要英文描述时加 `LANG=en`(默认中文)。 **2. 把 CLI 放进 PATH:** @@ -325,11 +325,12 @@ Codex -> 127.0.0.1:19100 -> 用户原有的纯文本模型上游 | 变量 | 必需 | 说明 | |---|---:|---| | `VISION_API_KEY` | 是 | 多模态模型的 API key | -| `VISION_BASE_URL` | 是 | OpenAI-compatible API 地址 | +| `VISION_BASE_URL` | 是 | 服务商 API Base URL;可包含 `/v1`,但不要包含 `/messages` 等协议端点 | | `VISION_MODEL` | 是 | 多模态模型名 | | `LANG` | 否 | 视觉模型输出语言:`zh`=中文,`en`=English(默认 `zh`) | -| `VISION_API_PROTOCOL` | 否 | Python 客户端/代理的视觉 API 协议:`chat_completions`(默认)或 `responses` | +| `VISION_API_PROTOCOL` | 否 | Python 客户端/代理的视觉 API 协议:`chat_completions`(默认)、`responses` 或 `anthropic`;Anthropic 模式使用 `x-api-key` 与 `anthropic-version` | | `VISION_REASONING_EFFORT` | 否 | Python 客户端/代理使用 `responses` 时可选的服务商支持推理强度 | +| `VISION_ANTHROPIC_THINKING` | 否 | Anthropic thinking 模式:`omit`(默认)、`disabled` 或 `adaptive` | | `VISION_USER_AGENT` | 否 | Python 客户端/代理的出站 User-Agent;默认使用浏览器兼容值,也可按服务商要求覆盖 | @@ -349,7 +350,7 @@ Codex -> 127.0.0.1:19100 -> 用户原有的纯文本模型上游 ## 前置条件 - 已接入(纯文本)模型(如 DeepSeek V4)并可正常使用的 coding agent -- 一个支持 `/chat/completions` 与 `image_url` 的 OpenAI-compatible 视觉 API;Python 客户端/代理也可通过 `VISION_API_PROTOCOL=responses` 使用 `/responses` + `input_image` +- 一个支持 OpenAI Chat Completions、OpenAI Responses 或 Anthropic Messages 的视觉 API;后两者分别使用 `VISION_API_PROTOCOL=responses` 与 `VISION_API_PROTOCOL=anthropic` - 没有其他需要的配置 ## 常见问题 diff --git a/tests/test_vision_client.py b/tests/test_vision_client.py index 2e553e0..363151f 100644 --- a/tests/test_vision_client.py +++ b/tests/test_vision_client.py @@ -19,6 +19,7 @@ class Handler(BaseHTTPRequestHandler): statuses = [] bodies = [] + response_headers = [] calls = 0 last_body = b"" last_headers = {} @@ -38,6 +39,9 @@ def do_POST(self): else: body = b'{"error":{"message":"fixture error"}}' self.send_response(status) + if Handler.response_headers: + for name, value in Handler.response_headers.pop(0).items(): + self.send_header(name, value) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) @@ -78,9 +82,18 @@ def main(): os.environ.pop("VISION_USER_AGENT", None) 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" + Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = ( + 0, [429, 200], [], [{"Retry-After": "17"}, {}] + ) + original_sleep = vision_client.time.sleep + delays = [] + vision_client.time.sleep = delays.append + try: + assert vision_client.describe_image("data:image/png;base64,AAAA") == "fixture answer" + finally: + vision_client.time.sleep = original_sleep assert Handler.calls == 2 + assert delays == [17.0] assert Handler.last_headers.get("User-Agent") == vision_client.DEFAULT_USER_AGENT assert not Handler.last_headers["User-Agent"].startswith("Python-urllib/") @@ -175,13 +188,13 @@ def fail_with_secret(*_args, **_kwargs): assert len(image_parts) == 2, "a list of URLs must become one request with all images" assert Handler.calls == 1 - Handler.calls, Handler.statuses, Handler.bodies = 0, [200], [json.dumps({ + Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = 0, [200], [json.dumps({ "object": "response", "output": [{ "type": "message", "content": [{"type": "output_text", "text": "responses fixture answer"}], }], - }).encode()] + }).encode()], [] os.environ["VISION_API_PROTOCOL"] = "responses" os.environ["VISION_REASONING_EFFORT"] = "medium" try: @@ -225,7 +238,7 @@ def fail_with_secret(*_args, **_kwargs): assert not any(k.lower() == "authorization" for k in Handler.last_headers) payload = json.loads(Handler.last_body) assert payload["max_tokens"] == 123 - assert payload["thinking"] == {"type": "disabled"} + assert "thinking" not in payload content = payload["messages"][0]["content"] assert [part["type"] for part in content] == ["image", "image", "text"] assert content[0]["source"] == { @@ -234,6 +247,41 @@ def fail_with_secret(*_args, **_kwargs): assert content[1]["source"] == {"type": "url", "url": "https://example.com/remote.webp"} assert Handler.calls == 1 + Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = ( + 0, [529, 200], [b'{"error":{"type":"overloaded_error"}}', json.dumps({ + "content": [{"type": "text", "text": "recovered"}], + }).encode()], [{"Retry-After": "3"}, {}] + ) + delays = [] + original_sleep = vision_client.time.sleep + vision_client.time.sleep = delays.append + os.environ["VISION_API_PROTOCOL"] = "anthropic" + os.environ["VISION_ANTHROPIC_THINKING"] = "disabled" + try: + assert vision_client.describe_image("data:image/png;base64,AAAA") == "recovered" + finally: + os.environ.pop("VISION_API_PROTOCOL", None) + os.environ.pop("VISION_ANTHROPIC_THINKING", None) + vision_client.time.sleep = original_sleep + assert json.loads(Handler.last_body)["thinking"] == {"type": "disabled"} + assert delays == [3.0] + assert Handler.calls == 2 + + Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = 0, [], [], [] + os.environ["VISION_API_PROTOCOL"] = "anthropic" + os.environ["VISION_ANTHROPIC_THINKING"] = "unsupported" + try: + try: + vision_client.describe_image("data:image/png;base64,AAAA") + except vision_client.VisionError as exc: + assert "Unsupported VISION_ANTHROPIC_THINKING" in str(exc) + else: + raise AssertionError("an unsupported thinking mode must fail before making a request") + finally: + os.environ.pop("VISION_API_PROTOCOL", None) + os.environ.pop("VISION_ANTHROPIC_THINKING", None) + assert Handler.calls == 0 + Handler.calls, Handler.statuses, Handler.bodies = 0, [], [] os.environ["VISION_API_PROTOCOL"] = "unsupported" try: diff --git a/vision_client.py b/vision_client.py index 5cbc4e8..cf211d8 100644 --- a/vision_client.py +++ b/vision_client.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""Shared OpenAI-compatible vision client used by the proxy and glance CLI.""" +"""Shared multi-provider vision client used by the proxy and glance CLI.""" from __future__ import annotations import base64 +from email.utils import parsedate_to_datetime import http.client import json import mimetypes @@ -142,6 +143,20 @@ def _redact(text: str, *secrets: str) -> str: return text +def _retry_delay(error: urllib.error.HTTPError, attempt: int) -> float: + value = error.headers.get("Retry-After") + if value: + try: + return max(0.0, min(float(value), 60.0)) + except ValueError: + try: + retry_at = parsedate_to_datetime(value) + return max(0.0, min(retry_at.timestamp() - time.time(), 60.0)) + except (TypeError, ValueError, OverflowError): + pass + return min(2 ** attempt, 4) + + 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.""" @@ -195,8 +210,14 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to "messages": [{"role": "user", "content": [ {"type": "image", "source": _anthropic_image_source(url)} for url in urls ] + [{"type": "text", "text": text}]}], - "thinking": {"type": "disabled"}, } + thinking = os.environ.get("VISION_ANTHROPIC_THINKING", "").strip().lower() or "omit" + if thinking != "omit": + if thinking not in {"disabled", "adaptive"}: + raise VisionError( + "Unsupported VISION_ANTHROPIC_THINKING; use omit, disabled, or adaptive" + ) + payload["thinking"] = {"type": thinking} endpoint = "/messages" extract_text = _anthropic_text else: @@ -231,9 +252,9 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to except urllib.error.HTTPError as exc: 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: + if exc.code in {429, 500, 502, 503, 504, 529} and attempt < retries: print(f"vision: HTTP {exc.code}, retrying ({attempt + 1}/{retries})", file=sys.stderr) - time.sleep(min(2 ** attempt, 4)) + time.sleep(_retry_delay(exc, attempt)) continue raise VisionError(f"Vision API HTTP {exc.code}: {body}") from exc except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead) as exc: From 4b3dbfe9fdd13061989c449bfe3ef3477835cb4d Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 14 Aug 2026 17:44:45 +0800 Subject: [PATCH 3/4] fix: make explicit env files authoritative --- tests/test_vision_client.py | 36 ++++++++++++++++++++++++++++++++++++ vision_client.py | 5 ++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/test_vision_client.py b/tests/test_vision_client.py index 363151f..0a1f1d1 100644 --- a/tests/test_vision_client.py +++ b/tests/test_vision_client.py @@ -51,6 +51,42 @@ def log_message(self, *_args): def main(): + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + explicit_env = root / "explicit.env" + explicit_env.write_text("ENV_PRIORITY_PROBE=explicit\n") + windows_env = root / "local-app-data" / "agent-vision-toolkit" / "env" + windows_env.parent.mkdir(parents=True) + windows_env.write_text("ENV_PRIORITY_PROBE=local-app-data\n") + cwd = root / "cwd" + cwd.mkdir() + (cwd / ".env").write_text("ENV_PRIORITY_PROBE=cwd\n") + previous_cwd = Path.cwd() + previous_home = os.environ.get("HOME") + previous_local_appdata = os.environ.get("LOCALAPPDATA") + previous_explicit = os.environ.get("VISION_ENV_FILE") + previous_probe = os.environ.get("ENV_PRIORITY_PROBE") + os.environ["HOME"] = raw + os.environ["LOCALAPPDATA"] = str(root / "local-app-data") + os.environ["VISION_ENV_FILE"] = str(explicit_env) + os.environ.pop("ENV_PRIORITY_PROBE", None) + os.chdir(cwd) + try: + vision_client.load_default_env() + assert os.environ.get("ENV_PRIORITY_PROBE") == "explicit" + finally: + os.chdir(previous_cwd) + for name, value in ( + ("HOME", previous_home), + ("LOCALAPPDATA", previous_local_appdata), + ("VISION_ENV_FILE", previous_explicit), + ("ENV_PRIORITY_PROBE", previous_probe), + ): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + with tempfile.TemporaryDirectory() as raw: windows_env = Path(raw) / "agent-vision-toolkit" / "env" windows_env.parent.mkdir() diff --git a/vision_client.py b/vision_client.py index cf211d8..432ed33 100644 --- a/vision_client.py +++ b/vision_client.py @@ -53,7 +53,10 @@ def load_env_file(path: str | os.PathLike[str] | None) -> None: def load_default_env() -> None: explicit = os.environ.get("VISION_ENV_FILE") - candidates = [Path(explicit).expanduser()] if explicit else [] + if explicit: + load_env_file(Path(explicit).expanduser()) + return + candidates = [] local_appdata = os.environ.get("LOCALAPPDATA") if local_appdata: candidates.append(Path(local_appdata) / "agent-vision-toolkit" / "env") From bc9803d7d6300c864d17460ecbb33540b26638e0 Mon Sep 17 00:00:00 2001 From: Anionex <1005128408@qq.com> Date: Fri, 14 Aug 2026 18:22:11 +0800 Subject: [PATCH 4/4] test: isolate Anthropic transport configuration --- .env.example | 8 +++++--- AGENT_INSTALL.md | 6 ++++-- README.md | 4 ++-- README_CN.md | 4 ++-- extensions/opencode/vision.ts | 9 +++++---- extensions/pi/vision.ts | 9 +++++---- tests/test_vision_client.py | 33 ++++++++++++++++++++++++++++----- 7 files changed, 51 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 6382fec..1d7d7da 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # Only the vision API is configured here. DeepSeek auth is still sent by Codex and passed through by the proxy. VISION_API_KEY= -# Any OpenAI-compatible endpoint that supports /chat/completions with image_url works; -# the Python client/proxy can also use /responses with input_image (see below). +# Use an endpoint that supports OpenAI Chat Completions, OpenAI Responses, or +# Anthropic Messages; select the protocol below. # Recommended options (see README): # OpenRouter: https://openrouter.ai/api/v1 # Aliyun DashScope: https://dashscope.aliyuncs.com/compatible-mode/v1 @@ -15,7 +15,9 @@ LANG=zh # VISION_API_PROTOCOL=chat_completions # Reasoning effort sent with the responses protocol (optional). # VISION_REASONING_EFFORT=medium -# Anthropic thinking mode: omit (default), disabled, or adaptive. +# Anthropic thinking mode. omit (default) sends no thinking field and has the +# broadest compatibility. Use disabled or adaptive only when the selected model +# documents that mode; restore omit first if the provider returns HTTP 400. # VISION_ANTHROPIC_THINKING=omit # Optional outbound User-Agent override. The default is browser-compatible to avoid # gateways that block Python-urllib clients. diff --git a/AGENT_INSTALL.md b/AGENT_INSTALL.md index 5ea5e45..193171e 100644 --- a/AGENT_INSTALL.md +++ b/AGENT_INSTALL.md @@ -27,7 +27,7 @@ - 已接入纯文本模型并能正常对话的宿主(Codex 或 Claude Code) - Python 3.11+ -- 一个支持 `/chat/completions` 和 `image_url` 的 OpenAI-compatible 视觉 API;Python 客户端/代理也可配置 `/responses` + `input_image` +- 一个支持 OpenAI Chat Completions、OpenAI Responses 或 Anthropic Messages 的视觉 API;通过 `VISION_API_PROTOCOL` 选择协议 ## 1. 定位并备份现有配置 @@ -60,12 +60,14 @@ VISION_MODEL=... LANG=zh # 可选:视觉模型输出语言(zh/en),不填保持默认中文 # VISION_API_PROTOCOL=chat_completions # 可选:chat_completions / responses / anthropic # VISION_REASONING_EFFORT=medium # 可选:responses 协议下的推理强度 -# VISION_ANTHROPIC_THINKING=omit # 可选:Anthropic thinking 为 omit / disabled / adaptive +# VISION_ANTHROPIC_THINKING=omit # 可选:omit 兼容性最好;仅在模型明确支持时使用 disabled / adaptive # VISION_USER_AGENT=custom-vision-client/1.0 # 可选:覆盖默认的浏览器兼容 User-Agent ``` 不要在 env 中写入上游模型的 key(如 `DEEPSEEK_API_KEY`)。上游鉴权仍由宿主发送。 +`VISION_ANTHROPIC_THINKING=omit` 不发送 thinking 字段,并保留模型默认行为。`disabled` 与 `adaptive` 具有模型兼容性限制;如果提供方返回 HTTP 400,先恢复 `omit`。当前不提供手动 `enabled` + `budget_tokens`。 + - macOS / Linux:执行 `chmod 600 `。 - Windows:把 env 保留在当前用户的 `%LOCALAPPDATA%` 下,不复制到公共目录。 diff --git a/README.md b/README.md index 1208cc8..270765b 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ When to use them, the order in which to call tools, and how to verify the result > Read https://github.com/Anionex/agent-vision-toolkit/blob/main/AGENT_INSTALL.md in full, then install the appropriate vision proxy or native extension/plugin for the agent application we are currently using. If the vision API is not configured, locate the configuration file for the current operating system and guide me through setting `VISION_API_KEY`, `VISION_BASE_URL`, and `VISION_MODEL`. -All you need to prepare is an OpenAI-compatible multimodal API base URL, API key, and model name. The agent will guide you through writing them to the appropriate configuration file. +All you need to prepare is a multimodal API supporting OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages, plus its base URL, API key, and model name. The agent will guide you through writing them to the appropriate configuration file. > After installing the optional integration and restarting the agent, paste an image directly or let the model call its built-in image tool. Pi, Oh My Pi, and OpenCode use single-file [native extensions](extensions/) rather than the proxy; see each agent's documentation. @@ -333,7 +333,7 @@ The standalone CLIs and Python proxy use these environment variables; just three | `LANG` | No | Vision model output language: `zh` (Chinese) or `en` (English); default `zh` | | `VISION_API_PROTOCOL` | No | Python client/proxy protocol: `chat_completions` (default), `responses`, or `anthropic`; Anthropic mode uses `x-api-key` and `anthropic-version` | | `VISION_REASONING_EFFORT` | No | Optional provider-supported reasoning effort for the Python client/proxy when using `responses` | -| `VISION_ANTHROPIC_THINKING` | No | Anthropic thinking mode: `omit` (default), `disabled`, or `adaptive` | +| `VISION_ANTHROPIC_THINKING` | No | Anthropic thinking mode. `omit` (default) sends no thinking field and has the broadest compatibility. Use `disabled` or `adaptive` only when the selected model documents that mode; restore `omit` first if the provider returns HTTP 400. Manual `enabled` plus `budget_tokens` is not exposed. | | `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 b2bac66..62f0199 100644 --- a/README_CN.md +++ b/README_CN.md @@ -137,7 +137,7 @@ > 完整阅读 https://github.com/Anionex/agent-vision-toolkit/blob/main/AGENT_INSTALL.md,根据我们当前使用的 agent 应用,安装适用的视觉代理或原生 extension/plugin。如果视觉 API 尚未配置,请按当前系统找到配置文件,并引导我填写 `VISION_API_KEY`、`VISION_BASE_URL` 和 `VISION_MODEL`。 -唯一要准备的是 OpenAI-compatible 多模态模型的 API base URL、API key 和模型名称;agent 会引导你把它们写入对应的配置文件。 +只需准备一个支持 OpenAI Chat Completions、OpenAI Responses 或 Anthropic Messages 的多模态 API,以及它的 base URL、API key 和模型名称;agent 会引导你把它们写入对应的配置文件。 > 对于可选接入层,安装完成并重启后,直接粘贴图片或让模型调用内置看图工具即可。Pi、Oh My Pi、OpenCode 走的是单文件[原生 extension](extensions/) 而不是代理,可见各 agent 的文档。 @@ -330,7 +330,7 @@ Codex -> 127.0.0.1:19100 -> 用户原有的纯文本模型上游 | `LANG` | 否 | 视觉模型输出语言:`zh`=中文,`en`=English(默认 `zh`) | | `VISION_API_PROTOCOL` | 否 | Python 客户端/代理的视觉 API 协议:`chat_completions`(默认)、`responses` 或 `anthropic`;Anthropic 模式使用 `x-api-key` 与 `anthropic-version` | | `VISION_REASONING_EFFORT` | 否 | Python 客户端/代理使用 `responses` 时可选的服务商支持推理强度 | -| `VISION_ANTHROPIC_THINKING` | 否 | Anthropic thinking 模式:`omit`(默认)、`disabled` 或 `adaptive` | +| `VISION_ANTHROPIC_THINKING` | 否 | Anthropic thinking 模式。`omit`(默认)不发送 thinking 字段,兼容性最好;仅当所选模型明确支持时使用 `disabled` 或 `adaptive`,提供方返回 HTTP 400 时应先恢复 `omit`。当前不提供手动 `enabled` + `budget_tokens`。 | | `VISION_USER_AGENT` | 否 | Python 客户端/代理的出站 User-Agent;默认使用浏览器兼容值,也可按服务商要求覆盖 | diff --git a/extensions/opencode/vision.ts b/extensions/opencode/vision.ts index 9f41bd7..8fa3e19 100644 --- a/extensions/opencode/vision.ts +++ b/extensions/opencode/vision.ts @@ -16,11 +16,12 @@ * auto-detect vision-capable primaries; set VISION_REWRITE=off in the * environment to disable rewriting when running a multimodal model. * - * Configuration comes from the same env chain as the agent-vision-toolkit repo + * Configuration comes from this extension's env chain * (VISION_API_KEY / VISION_BASE_URL / VISION_MODEL, optional LANG=zh|en): * $VISION_ENV_FILE, %LOCALAPPDATA%/agent-vision-toolkit/env, * ~/.config/agent-vision-toolkit/env, ./.env — later files override earlier ones - * and the process environment, matching vision_client.py. + * and the process environment. Unlike the Python client, this standalone + * extension keeps loading later fallback files after VISION_ENV_FILE. * * A sibling implementation for Pi / Oh My Pi lives at extensions/pi/vision.ts; * both files deliberately duplicate the small describe core so each stays a @@ -81,7 +82,7 @@ export interface VisionConfig { } // --------------------------------------------------------------------------- -// Env-chain configuration (ported from vision_client.load_default_env). +// Env-chain configuration for this standalone extension. function parseEnvFile(path: string, into: Record): void { let raw: string; @@ -98,7 +99,7 @@ function parseEnvFile(path: string, into: Record): void { let value = line.slice(eq + 1).trim(); value = value.replace(/^["']/, "").replace(/["']$/, ""); // The env file is the user's explicit configuration: whatever it sets - // wins, even over the process environment — same as vision_client.py. + // wins over values already collected from the process or earlier files. if (key) into[key] = value; } } diff --git a/extensions/pi/vision.ts b/extensions/pi/vision.ts index b0ee21c..9530daf 100644 --- a/extensions/pi/vision.ts +++ b/extensions/pi/vision.ts @@ -15,11 +15,12 @@ * so rewrites never touch the stored session and an in-process cache keyed on * (image, prompt) makes replayed turns free. * - * Configuration comes from the same env chain as the agent-vision-toolkit repo + * Configuration comes from this extension's env chain * (VISION_API_KEY / VISION_BASE_URL / VISION_MODEL, optional LANG=zh|en): * $VISION_ENV_FILE, %LOCALAPPDATA%/agent-vision-toolkit/env, * ~/.config/agent-vision-toolkit/env, ./.env — later files override earlier ones - * and the process environment, matching vision_client.py. + * and the process environment. Unlike the Python client, this standalone + * extension keeps loading later fallback files after VISION_ENV_FILE. * * A sibling implementation for OpenCode lives at extensions/opencode/vision.ts; * both files deliberately duplicate the small describe core so each stays a @@ -80,7 +81,7 @@ export interface VisionConfig { } // --------------------------------------------------------------------------- -// Env-chain configuration (ported from vision_client.load_default_env). +// Env-chain configuration for this standalone extension. function parseEnvFile(path: string, into: Record): void { let raw: string; @@ -97,7 +98,7 @@ function parseEnvFile(path: string, into: Record): void { let value = line.slice(eq + 1).trim(); value = value.replace(/^["']/, "").replace(/["']$/, ""); // The env file is the user's explicit configuration: whatever it sets - // wins, even over the process environment — same as vision_client.py. + // wins over values already collected from the process or earlier files. if (key) into[key] = value; } } diff --git a/tests/test_vision_client.py b/tests/test_vision_client.py index 0a1f1d1..a62b16f 100644 --- a/tests/test_vision_client.py +++ b/tests/test_vision_client.py @@ -92,13 +92,19 @@ def main(): windows_env.parent.mkdir() windows_env.write_text("WINDOWS_ENV_PROBE=loaded\n") previous_local_appdata = os.environ.get("LOCALAPPDATA") + previous_explicit = os.environ.get("VISION_ENV_FILE") os.environ["LOCALAPPDATA"] = raw + os.environ.pop("VISION_ENV_FILE", None) os.environ.pop("WINDOWS_ENV_PROBE", None) try: vision_client.load_default_env() assert os.environ.get("WINDOWS_ENV_PROBE") == "loaded" finally: os.environ.pop("WINDOWS_ENV_PROBE", None) + if previous_explicit is None: + os.environ.pop("VISION_ENV_FILE", None) + else: + os.environ["VISION_ENV_FILE"] = previous_explicit if previous_local_appdata is None: os.environ.pop("LOCALAPPDATA", None) else: @@ -111,11 +117,15 @@ def main(): VISION_MODEL="fixture-model") environment.pop("VISION_API_PROTOCOL", None) environment.pop("VISION_REASONING_EFFORT", None) + environment.pop("VISION_ANTHROPIC_THINKING", None) environment.pop("VISION_USER_AGENT", None) + environment.pop("VISION_ENV_FILE", None) saved = dict(os.environ) os.environ.pop("VISION_API_PROTOCOL", None) os.environ.pop("VISION_REASONING_EFFORT", None) + os.environ.pop("VISION_ANTHROPIC_THINKING", None) os.environ.pop("VISION_USER_AGENT", None) + os.environ.pop("VISION_ENV_FILE", None) os.environ.update(environment) try: Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = ( @@ -303,6 +313,19 @@ def fail_with_secret(*_args, **_kwargs): assert delays == [3.0] assert Handler.calls == 2 + Handler.calls, Handler.statuses, Handler.bodies = 0, [200], [json.dumps({ + "content": [{"type": "text", "text": "adaptive answer"}], + }).encode()] + os.environ["VISION_API_PROTOCOL"] = "anthropic" + os.environ["VISION_ANTHROPIC_THINKING"] = "adaptive" + try: + assert vision_client.describe_image("data:image/png;base64,AAAA") == "adaptive answer" + finally: + os.environ.pop("VISION_API_PROTOCOL", None) + os.environ.pop("VISION_ANTHROPIC_THINKING", None) + assert json.loads(Handler.last_body)["thinking"] == {"type": "adaptive"} + assert Handler.calls == 1 + Handler.calls, Handler.statuses, Handler.bodies, Handler.response_headers = 0, [], [], [] os.environ["VISION_API_PROTOCOL"] = "anthropic" os.environ["VISION_ANTHROPIC_THINKING"] = "unsupported" @@ -335,16 +358,16 @@ def fail_with_secret(*_args, **_kwargs): with tempfile.TemporaryDirectory() as raw: image = Path(raw) / "fixture.png" image.write_bytes(b"\x89PNG\r\n\x1a\nfixture") - # glance loads /.env and /.env last, and they override the - # process environment — run from a temp cwd whose .env carries the - # fixture config so a developer's real .env cannot leak in. - (Path(raw) / ".env").write_text( + # Pin the subprocess to one explicit fixture file so caller and + # checkout env files cannot redirect requests away from the server. + fixture_env = Path(raw) / "vision.env" + fixture_env.write_text( "VISION_API_KEY=test-key\n" f"VISION_BASE_URL=http://127.0.0.1:{server.server_port}/v1\n" "VISION_MODEL=fixture-model\n" "VISION_API_PROTOCOL=chat_completions\n" ) - isolated_env = dict(environment, HOME=raw) + isolated_env = dict(environment, HOME=raw, VISION_ENV_FILE=str(fixture_env)) glance = Path(__file__).resolve().parent.parent / "bin/glance" glance_cmd = [sys.executable, str(glance)] if os.name == "nt" else [str(glance)] result = subprocess.run(