diff --git a/.env.example b/.env.example index 432f372..d80f10e 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +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. +# 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). # Recommended options (see README): # OpenRouter: https://openrouter.ai/api/v1 # Aliyun DashScope: https://dashscope.aliyuncs.com/compatible-mode/v1 @@ -9,6 +10,11 @@ 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. +# VISION_API_PROTOCOL=chat_completions +# Reasoning effort sent with the responses protocol (optional). +# VISION_REASONING_EFFORT=medium # 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 dd27980..1c1cea5 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 +- 一个支持 `/chat/completions` 和 `image_url` 的 OpenAI-compatible 视觉 API;Python 客户端/代理也可配置 `/responses` + `input_image` ## 1. 定位并备份现有配置 @@ -58,6 +58,8 @@ VISION_API_KEY=... VISION_BASE_URL=... VISION_MODEL=... LANG=zh # 可选:视觉模型输出语言(zh/en),不填保持默认中文 +# VISION_API_PROTOCOL=chat_completions # 可选:Python 客户端/代理可改用 responses 协议 +# VISION_REASONING_EFFORT=medium # 可选:responses 协议下的推理强度 # VISION_USER_AGENT=custom-vision-client/1.0 # 可选:覆盖默认的浏览器兼容 User-Agent ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index ea8348b..0c63ee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable user-facing changes to agent-vision-toolkit are documented in this f ## [Unreleased] +### Added + +- Let the shared Python vision client call either Chat Completions or Responses APIs, including optional reasoning effort and explicit `store: false` data handling. +- 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. diff --git a/README.md b/README.md index 1d1d8c0..dd1b1a5 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,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`). 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`. Add `LANG=en` for English descriptions (default is Chinese). **2. Put the CLIs on your PATH:** @@ -322,7 +322,7 @@ Codex -> 127.0.0.1:19100 -> your existing text-only upstream
Environment variables -The toolkit and proxy use only these environment variables; just three are required: +The standalone CLIs and Python proxy use these environment variables; just three are required. The native Pi and OpenCode extensions use their own settings and currently call `/chat/completions` only. | Variable | Required | Description | |---|---:|---| @@ -330,6 +330,8 @@ 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_API_PROTOCOL` | No | Python client/proxy protocol: `chat_completions` (default) or `responses` | +| `VISION_REASONING_EFFORT` | No | Optional provider-supported reasoning effort for the Python client/proxy when using `responses` | | `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 |
@@ -349,7 +351,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` +- 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` - No other configuration is required ## FAQ diff --git a/README_CN.md b/README_CN.md index 971b072..9d87415 100644 --- a/README_CN.md +++ b/README_CN.md @@ -151,7 +151,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`)。需要英文描述时加 `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`。需要英文描述时加 `LANG=en`(默认中文)。 **2. 把 CLI 放进 PATH:** @@ -319,7 +319,7 @@ Codex -> 127.0.0.1:19100 -> 用户原有的纯文本模型上游
环境变量 -工具箱与代理都只需要这些环境变量,必填的只有3个: +独立 CLI 和 Python 代理使用这些环境变量,必填的只有 3 个。Pi 与 OpenCode 原生扩展使用各自设置,目前仅调用 `/chat/completions`。 | 变量 | 必需 | 说明 | |---|---:|---| @@ -327,6 +327,8 @@ Codex -> 127.0.0.1:19100 -> 用户原有的纯文本模型上游 | `VISION_BASE_URL` | 是 | OpenAI-compatible API 地址 | | `VISION_MODEL` | 是 | 多模态模型名 | | `LANG` | 否 | 视觉模型输出语言:`zh`=中文,`en`=English(默认 `zh`) | +| `VISION_API_PROTOCOL` | 否 | Python 客户端/代理的视觉 API 协议:`chat_completions`(默认)或 `responses` | +| `VISION_REASONING_EFFORT` | 否 | Python 客户端/代理使用 `responses` 时可选的服务商支持推理强度 | | `VISION_USER_AGENT` | 否 | Python 客户端/代理的出站 User-Agent;默认使用浏览器兼容值,也可按服务商要求覆盖 |
@@ -346,7 +348,7 @@ Codex -> 127.0.0.1:19100 -> 用户原有的纯文本模型上游 ## 前置条件 - 已接入(纯文本)模型(如 DeepSeek V4)并可正常使用的 coding agent -- 一个支持 `/chat/completions` 与 `image_url` 的 OpenAI-compatible 视觉 API +- 一个支持 `/chat/completions` 与 `image_url` 的 OpenAI-compatible 视觉 API;Python 客户端/代理也可通过 `VISION_API_PROTOCOL=responses` 使用 `/responses` + `input_image` - 没有其他需要的配置 ## 常见问题 diff --git a/skills/vision-tools/SKILL.md b/skills/vision-tools/SKILL.md index a45cf3d..8f42552 100644 --- a/skills/vision-tools/SKILL.md +++ b/skills/vision-tools/SKILL.md @@ -15,7 +15,8 @@ description: >- Five local CLIs that give a text-only agent eyes. They read one shared vision config (`VISION_API_KEY` / `VISION_BASE_URL` / `VISION_MODEL` / -`LANG`) — no extra credentials. +`LANG`), plus the optional Python-client settings `VISION_API_PROTOCOL`, +`VISION_REASONING_EFFORT`, and `VISION_USER_AGENT` — no extra credentials. Pick the tool by the question you are answering: diff --git a/tests/test_image_rewrite_shapes.py b/tests/test_image_rewrite_shapes.py index 38ee2ab..88ce4fb 100644 --- a/tests/test_image_rewrite_shapes.py +++ b/tests/test_image_rewrite_shapes.py @@ -60,6 +60,25 @@ def test_shapes(): print("PASS: shapes A (content) and B (output) rewritten; text-only untouched") +def test_openai_chat_images_are_rewritten(): + mod = _load_proxy() + body = {"model": "deepseek-v4-flash", "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "请读出图片里的标题"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ], + }]} + assert asyncio.run(mod._rewrite_image_inputs(body)) + content = body["messages"][0]["content"] + texts = [block["text"] for block in content if block.get("type") == "text"] + note = next(text for text in texts if text.startswith("[vision proxy]")) + assert "view_image" not in note, note + assert "submit the image again" in note, note + assert any("TEST-DESC" in text for text in texts), content + print("PASS: OpenAI Chat Completions image_url rewritten") + + def test_parallel(): mod = _load_proxy() real_desc = mod._image_desc_from_url @@ -150,6 +169,7 @@ def test_channel_note_lands_once_on_the_first_image(): if __name__ == "__main__": test_shapes() + test_openai_chat_images_are_rewritten() test_parallel() test_failure_is_not_forwarded() test_failure_reason_is_included() diff --git a/tests/test_vision_client.py b/tests/test_vision_client.py index d76ff65..658de7b 100644 --- a/tests/test_vision_client.py +++ b/tests/test_vision_client.py @@ -22,10 +22,12 @@ class Handler(BaseHTTPRequestHandler): calls = 0 last_body = b"" last_headers = {} + last_path = "" def do_POST(self): Handler.calls += 1 Handler.last_headers = dict(self.headers) + Handler.last_path = self.path length = int(self.headers.get("Content-Length", 0)) Handler.last_body = self.rfile.read(length) status = Handler.statuses.pop(0) @@ -67,8 +69,12 @@ 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_API_PROTOCOL", None) + environment.pop("VISION_REASONING_EFFORT", None) environment.pop("VISION_USER_AGENT", None) saved = dict(os.environ) + os.environ.pop("VISION_API_PROTOCOL", None) + os.environ.pop("VISION_REASONING_EFFORT", None) os.environ.pop("VISION_USER_AGENT", None) os.environ.update(environment) try: @@ -134,7 +140,8 @@ def fail_with_secret(*_args, **_kwargs): vision_client.describe_image("data:image/png;base64,AAAA") finally: os.environ.pop("LANG", None) - text = json.loads(Handler.last_body)["messages"][0]["content"][0]["text"] + parts = json.loads(Handler.last_body)["messages"][0]["content"] + text = next(part["text"] for part in parts if part.get("type") == "text") assert text.startswith("Please respond in English.") assert Handler.calls == 1 @@ -144,14 +151,16 @@ def fail_with_secret(*_args, **_kwargs): vision_client.describe_image("data:image/png;base64,AAAA") finally: os.environ.pop("LANG", None) - text = json.loads(Handler.last_body)["messages"][0]["content"][0]["text"] + parts = json.loads(Handler.last_body)["messages"][0]["content"] + text = next(part["text"] for part in parts if part.get("type") == "text") assert text.startswith("请使用简体中文回答。") assert Handler.calls == 1 Handler.calls, Handler.statuses, Handler.bodies = 0, [200], [] os.environ.pop("LANG", None) vision_client.describe_image("data:image/png;base64,AAAA") - text = json.loads(Handler.last_body)["messages"][0]["content"][0]["text"] + parts = json.loads(Handler.last_body)["messages"][0]["content"] + text = next(part["text"] for part in parts if part.get("type") == "text") assert "Please respond in English." not in text assert "请使用简体中文回答。" not in text assert Handler.calls == 1 @@ -159,10 +168,53 @@ def fail_with_secret(*_args, **_kwargs): Handler.calls, Handler.statuses, Handler.bodies = 0, [200], [] vision_client.describe_image(["data:image/png;base64,AAAA", "data:image/png;base64,BBBB"]) content = json.loads(Handler.last_body)["messages"][0]["content"] + assert content[0].get("type") == "image_url", \ + "vision payloads must put image parts before text for OpenCode Go MiMo compatibility" + assert Handler.last_headers.get("User-Agent") == vision_client.DEFAULT_USER_AGENT image_parts = [part for part in content if part.get("type") == "image_url"] 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({ + "object": "response", + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": "responses fixture answer"}], + }], + }).encode()] + os.environ["VISION_API_PROTOCOL"] = "responses" + os.environ["VISION_REASONING_EFFORT"] = "medium" + try: + assert vision_client.describe_image( + ["data:image/png;base64,AAAA", "data:image/png;base64,BBBB"], + prompt="read both images", + max_tokens=123, + ) == "responses fixture answer" + finally: + os.environ.pop("VISION_API_PROTOCOL", None) + os.environ.pop("VISION_REASONING_EFFORT", None) + assert Handler.last_path == "/v1/responses" + payload = json.loads(Handler.last_body) + content = payload["input"][0]["content"] + assert [part["type"] for part in content] == ["input_image", "input_image", "input_text"] + assert payload["store"] is False + assert payload["max_output_tokens"] == 123 + assert payload["reasoning"] == {"effort": "medium"} + assert Handler.calls == 1 + + Handler.calls, Handler.statuses, Handler.bodies = 0, [], [] + os.environ["VISION_API_PROTOCOL"] = "unsupported" + try: + try: + vision_client.describe_image("data:image/png;base64,AAAA") + except vision_client.VisionError as exc: + assert "Unsupported VISION_API_PROTOCOL" in str(exc) + else: + raise AssertionError("an unsupported protocol must fail before making a request") + finally: + os.environ.pop("VISION_API_PROTOCOL", None) + assert Handler.calls == 0 + Handler.calls, Handler.statuses, Handler.bodies = 0, [200], [] with tempfile.TemporaryDirectory() as raw: image = Path(raw) / "fixture.png" @@ -174,6 +226,7 @@ def fail_with_secret(*_args, **_kwargs): "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) glance = Path(__file__).resolve().parent.parent / "bin/glance" diff --git a/vision_client.py b/vision_client.py index 995cca3..3a829cf 100644 --- a/vision_client.py +++ b/vision_client.py @@ -98,6 +98,20 @@ def _message_text(message: object) -> str: return "" +def _responses_text(response: object) -> str: + if not isinstance(response, dict) or not isinstance(response.get("output"), list): + return "" + return "\n".join( + part["text"] + for item in response["output"] + if isinstance(item, dict) and item.get("type") == "message" + and isinstance(item.get("content"), list) + for part in item["content"] + if isinstance(part, dict) and part.get("type") == "output_text" + and isinstance(part.get("text"), str) + ).strip() + + def _redact(text: str, *secrets: str) -> str: for secret in secrets: if secret: @@ -123,16 +137,40 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to instruction = LANG_INSTRUCTIONS.get(os.environ.get("LANG", "").strip().lower()) if instruction: text = f"{instruction}\n\n{text}" - payload = { - "model": _required("VISION_MODEL"), - "messages": [{"role": "user", "content": [{"type": "text", "text": text}] + [ + model = _required("VISION_MODEL") + protocol = os.environ.get("VISION_API_PROTOCOL", "").strip().lower() or "chat_completions" + if protocol == "responses": + payload = { + "model": model, + "store": False, + "input": [{"role": "user", "content": [ + {"type": "input_image", "image_url": url} for url in urls + ] + [{"type": "input_text", "text": text}]}], + } + if max_tokens is not None: + payload["max_output_tokens"] = max_tokens + reasoning_effort = os.environ.get("VISION_REASONING_EFFORT", "").strip() + if reasoning_effort: + payload["reasoning"] = {"effort": reasoning_effort} + endpoint = "/responses" + extract_text = _responses_text + elif protocol == "chat_completions": + payload = { + "model": model, + "messages": [{"role": "user", "content": [ {"type": "image_url", "image_url": {"url": url}} for url in urls - ]}], - } - if max_tokens is not None: - payload["max_tokens"] = max_tokens + ] + [{"type": "text", "text": text}]}], + } + if max_tokens is not None: + payload["max_tokens"] = max_tokens + endpoint = "/chat/completions" + extract_text = lambda data: _message_text(data["choices"][0]["message"]["content"]) + else: + raise VisionError( + "Unsupported VISION_API_PROTOCOL; use chat_completions or responses" + ) request = urllib.request.Request( - base_url + "/chat/completions", + base_url + endpoint, data=json.dumps(payload).encode(), headers={ "Content-Type": "application/json", @@ -147,7 +185,7 @@ def describe_image(image_url: str | list[str], prompt: str | None = None, max_to with urllib.request.urlopen(request, timeout=timeout) as response: data = json.load(response) try: - text = _message_text(data["choices"][0]["message"]["content"]) + text = extract_text(data) except (KeyError, IndexError, TypeError) as exc: raise VisionError("Vision API returned an incompatible response structure") from exc if not text: diff --git a/vision_proxy.py b/vision_proxy.py index dda2502..dcad25f 100755 --- a/vision_proxy.py +++ b/vision_proxy.py @@ -105,6 +105,14 @@ def __str__(self): "is written to answer that." ) +_OPENAI_CHAT_CHANNEL_NOTE = ( + "[vision proxy] Images reach you as text here: a vision model reads each image " + "and writes a description — you never receive visual tokens. Each description " + "is written to answer the text sent with that image. Whenever a description " + "misses what you need, state the missing detail and submit the image again with " + "that specific question so the next description can focus on it." +) + # Codex-injected user-role blocks that are never "the user's current request". _INJECTED_PREFIXES = ("", "", "# AGENTS.md instructions") @@ -326,6 +334,38 @@ def _collect_anthropic_jobs(parsed): return jobs +def _openai_image_url(block): + """Return an OpenAI Chat Completions image URL from a content block.""" + value = block.get("image_url") + if isinstance(value, str): + return value + if isinstance(value, dict) and isinstance(value.get("url"), str): + return value["url"] + return None + + +def _collect_openai_chat_jobs(parsed): + """OpenAI Chat Completions API: messages[].content[] image_url blocks.""" + jobs = [] + for message in parsed["messages"]: + if not isinstance(message, dict) or message.get("role") != "user": + continue + content = message.get("content") + if not isinstance(content, list): + continue + text = "\n".join( + block["text"] for block in content + if isinstance(block, dict) and block.get("type") == "text" + and isinstance(block.get("text"), str) + ) + for index, block in enumerate(content): + if isinstance(block, dict) and block.get("type") == "image_url": + url = _openai_image_url(block) + if url: + jobs.append((content, index, url, _vision_prompt(text, "user"))) + return jobs + + def _detect_format(parsed): if isinstance(parsed.get("input"), list): return "responses" @@ -342,6 +382,8 @@ def _detect_format(parsed): if not isinstance(block, dict): continue kind = block.get("type") + if kind == "image_url": + return "openai_chat" if kind == "image": return "anthropic" if kind == "tool_result": @@ -355,6 +397,7 @@ def _detect_format(parsed): _FORMATS = { "responses": (_collect_responses_jobs, lambda text: {"type": "input_text", "text": text}, _CHANNEL_NOTE), "anthropic": (_collect_anthropic_jobs, lambda text: {"type": "text", "text": text}, _ANTHROPIC_CHANNEL_NOTE), + "openai_chat": (_collect_openai_chat_jobs, lambda text: {"type": "text", "text": text}, _OPENAI_CHAT_CHANNEL_NOTE), }