From e298cc0bf17160c6c964365918b78b65c2023fa3 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 28 Aug 2026 12:28:24 +0200 Subject: [PATCH 1/2] feat: add ChatGPT file uploads --- README.md | 24 ++++- src/chatgpt_web2api/api_server.py | 67 ++++++++++++-- src/chatgpt_web2api/cdp_driver.py | 10 ++- src/chatgpt_web2api/chatgpt_dom.py | 91 ++++++++++++++++--- src/chatgpt_web2api/file_upload.py | 140 +++++++++++++++++++++++++++++ src/chatgpt_web2api/mcp_server.py | 17 ++++ tests/test_e2e_file_upload.py | 29 ++++++ tests/test_file_upload.py | 140 +++++++++++++++++++++++++++++ 8 files changed, 495 insertions(+), 23 deletions(-) create mode 100644 src/chatgpt_web2api/file_upload.py create mode 100644 tests/test_e2e_file_upload.py create mode 100644 tests/test_file_upload.py diff --git a/README.md b/README.md index 6aa75435..72bb77f5 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,25 @@ setups, local development, and debugging (logs go straight to stderr): Every tool includes rich descriptions with domain knowledge, Pydantic-validated input, structured output schemas, and proper `ToolAnnotations` — agents understand *how* and *when* to use each one without prompting. -### 🔒 MCP Tool Access Control +### File attachments + +The REST endpoint also accepts `multipart/form-data`. Send the `messages` field as +JSON and repeat the `file` field for every attachment: + +```bash +curl http://localhost:8080/v1/chat/completions \ + -F 'messages=[{"role":"user","content":"Inspect these files"}]' \ + -F 'model=auto' \ + -F 'file=@server.log' \ + -F 'file=@diagnostics.zip' \ + -F 'file=@screenshot.png' +``` + +The MCP `chat_completion` tool accepts `files`, a list of local paths, in addition +to `message`. File extensions and MIME types are not hard-coded: ChatGPT's web UI +makes the final acceptance decision. The bridge validates that every path is a +regular file and enforces `W2A_MAX_UPLOAD_BYTES` (default 50 MiB) per file. + The MCP server has **no authentication of its own** — any client that can reach it can call its tools. Since several tools mutate or delete data on your *real* ChatGPT account, tools are gated by risk tier so the dangerous ones are never exposed by accident: @@ -548,11 +566,11 @@ Key docs: - **Cookie expiry** — auth cookies expire ~2 weeks; re-login needed - **Serial requests** — one chat at a time through the browser *by default* (concurrent reads are fine). For per-tab parallelism on one shared Chrome, set `parallel_tabs: true` (requires `tab_mode: "owned"`); see [docs/deployment.md](docs/deployment.md) → "Parallel mode (one Chrome, many tabs)". - **Memory writes** — ChatGPT's `/backend-api/memories` is read-only; creating memories works via chat interface -- **No image input** — text only (CDP file upload not yet implemented) +- **File acceptance** — the web UI decides which file types are accepted; the bridge does not hard-code extensions. Each local file is limited by `W2A_MAX_UPLOAD_BYTES` (default 50 MiB) ## Roadmap -- [ ] Image/file upload to conversations via CDP drag-and-drop +- [x] Image/file upload to conversations via CDP file input - [ ] Headless mode with anti-detection patches - [ ] Concurrent chat pooling across multiple Chrome instances (per-tab parallelism on one Chrome landed via `parallel_tabs`; the cross-instance pool/router is still future work) - [ ] Web search mode (trigger ChatGPT's built-in search) diff --git a/src/chatgpt_web2api/api_server.py b/src/chatgpt_web2api/api_server.py index c0ce8894..27768c2e 100644 --- a/src/chatgpt_web2api/api_server.py +++ b/src/chatgpt_web2api/api_server.py @@ -12,8 +12,10 @@ import asyncio import json import logging +import tempfile import time import uuid +from pathlib import Path from aiohttp import web @@ -27,6 +29,7 @@ ) from .config import Config from .cross_process_lock import LockAcquisitionError +from .file_upload import UploadError, validate_upload_files from .lock_resolver import MutationLock, OwnedTabRequiredError, resolve_mutation_lock from .resilience import retry_on_rate_limit @@ -230,6 +233,54 @@ async def _handle_projects(self, request: web.Request) -> web.Response: projects = [] return web.json_response({"object": "list", "data": projects}) + async def _parse_chat_request(self, request: web.Request) -> tuple[dict, list[Path]]: + """Parse JSON or multipart chat input and materialize uploaded files.""" + content_type = getattr(request, "content_type", "") + if not isinstance(content_type, str) or not content_type.startswith("multipart/"): + return await request.json(), [] + + reader = await request.multipart() + fields: dict[str, str] = {} + upload_paths: list[Path] = [] + try: + while part := await reader.next(): + if part.name in {"file", "files"} and part.filename: + suffix = Path(part.filename).suffix + handle = tempfile.NamedTemporaryFile( + prefix="chatgpt-web2api-upload-", suffix=suffix, delete=False + ) + path = Path(handle.name) + try: + while chunk := await part.read_chunk(): + handle.write(chunk) + finally: + handle.close() + upload_paths.append(path) + else: + fields[part.name] = await part.text() + if "messages" in fields: + body = {"messages": json.loads(fields["messages"])} + for name in ("model", "stream", "project_id", "gizmo_id", "conversation_id"): + if name in fields: + value = fields[name] + body[name] = json.loads(value) if name == "stream" else value + elif "body" in fields: + body = json.loads(fields["body"]) + else: + raise UploadError("Multipart requests must include a JSON 'messages' field") + if upload_paths: + validate_upload_files(upload_paths) + return body, upload_paths + except Exception: + for path in upload_paths: + path.unlink(missing_ok=True) + raise + + @staticmethod + def _cleanup_uploads(paths: list[Path]) -> None: + for path in paths: + path.unlink(missing_ok=True) + async def _handle_chat(self, request: web.Request) -> web.Response: if err := self._check_auth(request): return err @@ -237,10 +288,10 @@ async def _handle_chat(self, request: web.Request) -> web.Response: self._request_count += 1 try: - body = await request.json() - except json.JSONDecodeError: + body, upload_paths = await self._parse_chat_request(request) + except (json.JSONDecodeError, UploadError, ValueError) as exc: return web.json_response( - {"error": {"message": "Invalid JSON", "type": "invalid_request_error"}}, + {"error": {"message": str(exc), "type": "invalid_request_error"}}, status=400, ) @@ -387,12 +438,18 @@ async def _handle_chat(self, request: web.Request) -> web.Response: await self._driver.navigate_new_chat(gizmo_id=project_id) self._last_project_id = project_id + if upload_paths: + await self._driver.upload_files([str(path) for path in upload_paths]) if stream: - return await self._stream_response(request, model_slug, full_text, timeout) + response = await self._stream_response(request, model_slug, full_text, timeout) else: - return await self._full_response(request, model_slug, full_text, timeout) + response = await self._full_response(request, model_slug, full_text, timeout) + self._cleanup_uploads(upload_paths) + upload_paths = [] + return response except Exception as e: + self._cleanup_uploads(upload_paths) logger.error("Chat error: %s", e, exc_info=True) self._last_error = f"{type(e).__name__}: {e}" return self._error_response(e) diff --git a/src/chatgpt_web2api/cdp_driver.py b/src/chatgpt_web2api/cdp_driver.py index 3bc3eae4..79362f1e 100644 --- a/src/chatgpt_web2api/cdp_driver.py +++ b/src/chatgpt_web2api/cdp_driver.py @@ -14,6 +14,7 @@ import asyncio import json import logging +import os import re import time import urllib.parse @@ -1448,6 +1449,12 @@ async def ensure_current_conversation(self, conversation_id: str) -> None: # ── Message Input ───────────────────────────────────────── + async def upload_files(self, paths: list[str | os.PathLike[str]]) -> list: + """Attach local files to the current ChatGPT composer.""" + from .file_upload import upload_files_to_composer + + return await upload_files_to_composer(self, paths) + async def type_message(self, text: str) -> None: """Type text into the ChatGPT composer. @@ -1596,7 +1603,8 @@ async def _verify_send_acknowledged(self) -> bool | None: Polls briefly (3s at 0.5s intervals). Never raises. """ import time as _time - from .chatgpt_dom import COMPOSER_SELECTOR, COMPOSER_FALLBACK_SELECTOR + + from .chatgpt_dom import COMPOSER_FALLBACK_SELECTOR, COMPOSER_SELECTOR pre_send_count = getattr(self, "_pre_send_user_count", None) if pre_send_count is None: diff --git a/src/chatgpt_web2api/chatgpt_dom.py b/src/chatgpt_web2api/chatgpt_dom.py index fc76f168..691c7923 100644 --- a/src/chatgpt_web2api/chatgpt_dom.py +++ b/src/chatgpt_web2api/chatgpt_dom.py @@ -450,20 +450,83 @@ async def click_send(self) -> None: break await asyncio.sleep(SEND_BUTTON_POLL_INTERVAL_S) - result = await d._js( - "(function() {" - f" var btn = document.querySelector('{SEND_BUTTON_SELECTOR}')" - f" || document.querySelector('{SEND_BUTTON_FALLBACK_SELECTOR}')" - f" || document.querySelector('{SEND_BUTTON_BROAD_SELECTOR}');" - " if (!btn) return 'no send button';" - " if (btn.disabled) return 'button disabled';" - " var evts = ['pointerdown','mousedown','pointerup','mouseup','click'];" - " for (var i = 0; i < evts.length; i++) {" - " btn.dispatchEvent(new MouseEvent(evts[i], {bubbles:true, cancelable:true, view:window}));" - " }" - " return 'sent';" - "})()" - ) + if getattr(d, "_file_upload_active", False) is True: + rect_raw = await d._js( + """(function() { + var btn = document.querySelector('button[aria-label*="Send" i]:not([data-testid="stop-button"])') + || document.querySelector('button[data-testid="send-button"]'); + if (!btn) return ''; + var r = btn.getBoundingClientRect(); + return JSON.stringify({x:r.left + r.width / 2, y:r.top + r.height / 2}); + })()""" + ) + try: + rect = json.loads(rect_raw) + mouse_params = { + "x": rect["x"], + "y": rect["y"], + "button": "left", + "clickCount": 1, + } + for event_type in ("mousePressed", "mouseReleased"): + await d._cdp( + "Input.dispatchMouseEvent", + {"type": event_type, "buttons": 1 if event_type == "mousePressed" else 0, **mouse_params}, + ) + + # With attachments, ChatGPT starts the upload on the first send + # click. The button remains enabled, but the UI briefly exposes + # "File upload pending" and ignores that click as a submission. + # Wait for that transition and click again only after it clears. + saw_pending = False + for _ in range(60): + state = await d._js( + """(function() { + return /file upload pending/i.test((document.body && document.body.innerText) || '') + ? 'pending' : 'ready'; + })()""" + ) + if state == "pending": + saw_pending = True + elif saw_pending: + break + await asyncio.sleep(0.5) + if saw_pending: + rect_raw = await d._js( + """(function() { + var btn = document.querySelector('button[aria-label*="Send" i]:not([data-testid="stop-button"])') + || document.querySelector('button[data-testid="send-button"]'); + if (!btn) return ''; + var r = btn.getBoundingClientRect(); + return JSON.stringify({x:r.left + r.width / 2, y:r.top + r.height / 2}); + })()""" + ) + rect = json.loads(rect_raw) + mouse_params.update({"x": rect["x"], "y": rect["y"]}) + for event_type in ("mousePressed", "mouseReleased"): + await d._cdp( + "Input.dispatchMouseEvent", + {"type": event_type, "buttons": 1 if event_type == "mousePressed" else 0, **mouse_params}, + ) + d._file_upload_active = False + result = "sent" + except (json.JSONDecodeError, KeyError, TypeError): + result = "send button coordinates unavailable" + else: + result = await d._js( + "(function() {" + f" var btn = document.querySelector('{SEND_BUTTON_SELECTOR}')" + f" || document.querySelector('{SEND_BUTTON_FALLBACK_SELECTOR}')" + f" || document.querySelector('{SEND_BUTTON_BROAD_SELECTOR}');" + " if (!btn) return 'no send button';" + " if (btn.disabled) return 'button disabled';" + " var evts = ['pointerdown','mousedown','pointerup','mouseup','click'];" + " for (var i = 0; i < evts.length; i++) {" + " btn.dispatchEvent(new MouseEvent(evts[i], {bubbles:true, cancelable:true, view:window}));" + " }" + " return 'sent';" + "})()" + ) if result != "sent": await d._capture_selector_diagnostic("send-button (click_send)") if d._breakers: diff --git a/src/chatgpt_web2api/file_upload.py b/src/chatgpt_web2api/file_upload.py new file mode 100644 index 00000000..b4083fa2 --- /dev/null +++ b/src/chatgpt_web2api/file_upload.py @@ -0,0 +1,140 @@ +"""Safe file validation and ChatGPT composer uploads via Chrome CDP.""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import Sequence +from pathlib import Path + +DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024 +_FILE_INPUT_SELECTOR = 'input[type="file"]' + + +class UploadError(ValueError): + """Raised when an upload cannot be safely prepared or attached.""" + + +def max_upload_bytes() -> int: + raw = os.environ.get("W2A_MAX_UPLOAD_BYTES", str(DEFAULT_MAX_UPLOAD_BYTES)) + try: + value = int(raw) + except ValueError as exc: + raise UploadError("W2A_MAX_UPLOAD_BYTES must be a positive integer") from exc + if value <= 0: + raise UploadError("W2A_MAX_UPLOAD_BYTES must be a positive integer") + return value + + +def validate_upload_files(paths: Sequence[str | os.PathLike[str]]) -> list[Path]: + """Resolve and validate local files without restricting their extension.""" + limit = max_upload_bytes() + resolved: list[Path] = [] + for raw_path in paths: + path = Path(raw_path).expanduser() + if not path.exists(): + raise UploadError(f"Upload file does not exist: {path}") + if not path.is_file(): + raise UploadError(f"Upload path is not a regular file: {path}") + path = path.resolve() + size = path.stat().st_size + if size > limit: + raise UploadError( + f"Upload file exceeds maximum size ({size} > {limit} bytes): {path.name}" + ) + resolved.append(path) + if not resolved: + raise UploadError("At least one upload file is required") + return resolved + + +async def upload_files_to_composer( + driver, paths: Sequence[str | os.PathLike[str]] +) -> list[Path]: + """Attach local files to the current ChatGPT composer using CDP.""" + files = validate_upload_files(paths) + clicked = await driver._js( + """(function() { + const labels = ['Attach files', 'Add files', 'Upload files', 'Attach']; + const buttons = [...document.querySelectorAll('button,[role="button"]')]; + const button = buttons.find(el => { + const label = (el.getAttribute('aria-label') || el.getAttribute('title') || '') + .trim().toLowerCase(); + return labels.some(candidate => label === candidate.toLowerCase() || + label.includes(candidate.toLowerCase())); + }); + if (!button) return 'not-found'; + button.click(); + return 'clicked'; + })()""" + ) + if clicked != "clicked": + raise UploadError("ChatGPT attachment button was not found") + + document = await driver._cdp("DOM.getDocument", {"depth": -1, "pierce": True}) + document_result = document.get("result", document) + root_id = document_result.get("root", {}).get("nodeId") + if not root_id: + raise UploadError("ChatGPT DOM root was not available after opening attachments") + node = await driver._cdp( + "DOM.querySelector", {"nodeId": root_id, "selector": _FILE_INPUT_SELECTOR} + ) + node_result = node.get("result", node) + node_id = node_result.get("nodeId") + if not node_id: + raise UploadError("ChatGPT file input was not available after opening attachments") + await driver._cdp( + "DOM.setFileInputFiles", + {"nodeId": node_id, "files": [str(path) for path in files]}, + ) + await driver._js( + """(function() { + const input = [...document.querySelectorAll('input[type="file"]')] + .find(el => el.files && el.files.length); + if (!input) return 'input-not-found'; + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + return 'events-dispatched'; + })()""" + ) + for _ in range(60): + state = await driver._js( + """(function() { + const text = (document.body && document.body.innerText) || ''; + return /file upload pending/i.test(text) ? 'pending' : 'ready'; + })()""" + ) + if state == "ready": + break + await asyncio.sleep(0.5) + else: + raise UploadError("ChatGPT file upload stayed pending for 30 seconds") + + driver._file_upload_active = True + await driver._js( + """(function() { + const visible = el => { + const style = getComputedStyle(el); + const box = el.getBoundingClientRect(); + return style.visibility !== 'hidden' && style.display !== 'none' && + box.width > 0 && box.height > 0; + }; + const menuItem = [...document.querySelectorAll('*')].find(el => + visible(el) && (el.textContent || '').trim() === 'Upload from computer'); + if (!menuItem) return 'already-closed'; + const button = [...document.querySelectorAll('button,[role="button"]')].find(el => + /add files and more/i.test(el.getAttribute('aria-label') || '') && visible(el)); + if (!button) return 'button-not-found'; + button.click(); + return 'closed'; + })()""" + ) + await driver._cdp( + "Input.dispatchKeyEvent", + {"type": "rawKeyDown", "key": "Escape", "code": "Escape", "windowsVirtualKeyCode": 27}, + ) + await driver._cdp( + "Input.dispatchKeyEvent", + {"type": "keyUp", "key": "Escape", "code": "Escape", "windowsVirtualKeyCode": 27}, + ) + return files diff --git a/src/chatgpt_web2api/mcp_server.py b/src/chatgpt_web2api/mcp_server.py index 8387f0c4..90271eff 100644 --- a/src/chatgpt_web2api/mcp_server.py +++ b/src/chatgpt_web2api/mcp_server.py @@ -51,6 +51,7 @@ ) from .config import Config from .cross_process_lock import LockAcquisitionError +from .file_upload import UploadError from .lock_resolver import ( MutationLock, OwnedTabRequiredError, @@ -82,6 +83,14 @@ class ChatCompletionInput(BaseModel): """Input schema for chat_completion tool.""" message: str = Field(description="The user message to send to ChatGPT") + files: list[str] = Field( + default_factory=list, + description=( + "Optional local file paths to attach before sending the message. " + "ChatGPT decides whether each file type is accepted; paths must be regular " + "files and are limited by W2A_MAX_UPLOAD_BYTES." + ), + ) system_prompt: str | None = Field( default=None, description=( @@ -769,6 +778,9 @@ async def do_chat_completion( else: await driver.navigate_new_chat(gizmo_id=project_id) + if validated.files: + await driver.upload_files(validated.files) + # Send and collect response. Progress notifications reset the MCP client's # idle timer during long generations so the tool call isn't killed at # ~30s. on_progress is None when the client can't receive progress. @@ -1500,6 +1512,11 @@ def _map_tool_exception(exc: Exception) -> object: "— no DOM progress. (generation_stuck)"))], isError=True, ) + if isinstance(exc, UploadError): + return mcp_types.CallToolResult( + content=[mcp_types.TextContent(type="text", text=f"{exc} (upload_error)")], + isError=True, + ) if isinstance(exc, LockAcquisitionError): return mcp_types.CallToolResult( content=[mcp_types.TextContent(type="text", diff --git a/tests/test_e2e_file_upload.py b/tests/test_e2e_file_upload.py new file mode 100644 index 00000000..7c0f5485 --- /dev/null +++ b/tests/test_e2e_file_upload.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import uuid +from pathlib import Path + +import pytest + +from chatgpt_web2api.cdp_driver import CDPDriver +from chatgpt_web2api.config import Config + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_file_upload_reaches_chatgpt( + e2e_driver: CDPDriver, e2e_config: Config, e2e_created: dict, tmp_path: Path +): + path = tmp_path / f"w2a-upload-{uuid.uuid4().hex[:8]}.log" + path.write_text("W2A_UPLOAD_SENTINEL_7f3d", encoding="utf-8") + await e2e_driver.navigate_new_chat() + await e2e_driver.upload_files([path]) + + prompt = "Read the attached file and reply with exactly: W2A_UPLOAD_RECEIVED" + response = "" + async for chunk in e2e_driver.send_and_stream(prompt, timeout=120): + response += chunk.delta + if e2e_driver._current_conv_id: + e2e_created["conversations"].add(e2e_driver._current_conv_id) + + assert "W2A_UPLOAD_RECEIVED" in response diff --git a/tests/test_file_upload.py b/tests/test_file_upload.py new file mode 100644 index 00000000..b5c1f5b5 --- /dev/null +++ b/tests/test_file_upload.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from chatgpt_web2api.file_upload import UploadError, validate_upload_files + + +def test_validate_upload_files_accepts_log_zip_and_image(tmp_path: Path): + files = [] + for name, payload in (("server.log", b"line 1\n"), ("bundle.zip", b"PK"), ("screen.png", b"png")): + path = tmp_path / name + path.write_bytes(payload) + files.append(path) + + result = validate_upload_files(files) + + assert result == [path.resolve() for path in files] + + +def test_validate_upload_files_rejects_missing_and_directories(tmp_path: Path): + with pytest.raises(UploadError, match="does not exist"): + validate_upload_files([tmp_path / "missing.log"]) + with pytest.raises(UploadError, match="not a regular file"): + validate_upload_files([tmp_path]) + + +def test_validate_upload_files_enforces_configured_size_limit(tmp_path: Path, monkeypatch): + path = tmp_path / "large.log" + path.write_bytes(b"12345") + monkeypatch.setenv("W2A_MAX_UPLOAD_BYTES", "4") + + with pytest.raises(UploadError, match="exceeds maximum"): + validate_upload_files([path]) + + +@pytest.mark.asyncio +async def test_mcp_chat_completion_uploads_files_before_send(tmp_path: Path): + path = tmp_path / "diagnostic.log" + path.write_text("failure") + + from chatgpt_web2api.cdp_driver import StreamChunk + from chatgpt_web2api.mcp_server import do_chat_completion + + class Driver: + _current_conv_id = None + uploaded = None + + async def navigate_new_chat(self, gizmo_id=None): + return None + + async def upload_files(self, paths): + self.uploaded = paths + + async def send_and_stream(self, *args, **kwargs): + assert self.uploaded == [str(path)] + yield StreamChunk(delta="ok") + yield StreamChunk(delta="", finish_reason="stop") + + result = await do_chat_completion(Driver(), {"message": "inspect", "files": [str(path)]}, None) + + assert result["content"] == "ok" + + +@pytest.mark.asyncio +async def test_upload_files_sets_file_input_via_cdp(tmp_path: Path): + path = tmp_path / "server.log" + path.write_text("hello") + driver = object.__new__(type("Driver", (), {})) + driver._js = AsyncMock( + side_effect=["clicked", "events-dispatched", "ready", "already-closed"] + ) + driver._cdp = AsyncMock( + side_effect=[ + {"result": {"root": {"nodeId": 7}}}, + {"result": {"nodeId": 11}}, + {}, + {}, + {}, + {}, + ] + ) + + from chatgpt_web2api.file_upload import upload_files_to_composer + + await upload_files_to_composer(driver, [path]) + + assert driver._cdp.await_args_list[0].args[0] == "DOM.getDocument" + assert driver._cdp.await_args_list[1].args[0] == "DOM.querySelector" + assert driver._cdp.await_args_list[2].args[0] == "DOM.setFileInputFiles" + assert driver._cdp.await_args_list[2].args[1] == {"nodeId": 11, "files": [str(path.resolve())]} + + +@pytest.mark.asyncio +async def test_rest_multipart_parser_materializes_and_validates_file(tmp_path: Path): + from chatgpt_web2api.api_server import APIServer + + class Part: + def __init__(self, name, *, filename=None, payload=b"", text_value=""): + self.name = name + self.filename = filename + self._payload = payload + self._text = text_value + self._read = False + + async def read_chunk(self): + if self._read: + return b"" + self._read = True + return self._payload + + async def text(self): + return self._text + + class Reader: + def __init__(self, parts): + self.parts = iter(parts) + + async def next(self): + return next(self.parts, None) + + class Request: + content_type = "multipart/form-data" + + async def multipart(self): + return Reader([ + Part("messages", text_value='[{"role":"user","content":"inspect"}]'), + Part("file", filename="server.log", payload=b"line 1\n"), + ]) + + server = object.__new__(APIServer) + body, paths = await server._parse_chat_request(Request()) + try: + assert body == {"messages": [{"role": "user", "content": "inspect"}]} + assert len(paths) == 1 + assert paths[0].read_bytes() == b"line 1\n" + finally: + server._cleanup_uploads(paths) From ce86178dcc6f5ca9e02f5a3b38846cad2a2d55e4 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 28 Aug 2026 13:47:47 +0200 Subject: [PATCH 2/2] fix: normalize web conversation ids --- src/chatgpt_web2api/backend_client.py | 17 +++++++++++++++++ tests/test_conversation_id.py | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tests/test_conversation_id.py diff --git a/src/chatgpt_web2api/backend_client.py b/src/chatgpt_web2api/backend_client.py index 4ea582bb..e314b38f 100644 --- a/src/chatgpt_web2api/backend_client.py +++ b/src/chatgpt_web2api/backend_client.py @@ -38,12 +38,28 @@ import json import logging import time +import uuid from .breakers import BreakerKind logger = logging.getLogger(__name__) +def normalize_conversation_id(conversation_id: str) -> str: + """Return the native UUID from a UI/backend conversation identifier. + + Some ChatGPT Web surfaces expose IDs with a transport namespace such as + ``WEB:``. The backend endpoint accepts only the UUID path segment. + """ + value = str(conversation_id or "").strip() + if value.startswith("WEB:"): + value = value[4:] + try: + return str(uuid.UUID(value)) + except (ValueError, AttributeError, TypeError) as exc: + raise ValueError(f"invalid conversation id: {conversation_id!r}") from exc + + class _Transient404(Exception): """Sentinel raised by ``_fetch_recent_conversation_projection`` on a backend 404. @@ -280,6 +296,7 @@ async def _fetch_recent_conversation_projection( from .cdp_driver import AuthExpiredError, CDPJSError d = self._driver + conversation_id = normalize_conversation_id(conversation_id) await self._driver.ensure_token() raw = await d._js_with_data_strict( CONVERSATION_PROJECTION_JS, diff --git a/tests/test_conversation_id.py b/tests/test_conversation_id.py new file mode 100644 index 00000000..e9c7c88b --- /dev/null +++ b/tests/test_conversation_id.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import uuid + +import pytest + +from chatgpt_web2api.backend_client import normalize_conversation_id + + +def test_normalize_conversation_id_removes_web_transport_prefix(): + conversation_id = str(uuid.uuid4()) + + assert normalize_conversation_id(f"WEB:{conversation_id}") == conversation_id + + +def test_normalize_conversation_id_preserves_native_uuid(): + conversation_id = str(uuid.uuid4()) + + assert normalize_conversation_id(conversation_id) == conversation_id + + +def test_normalize_conversation_id_rejects_empty_or_malformed_values(): + with pytest.raises(ValueError, match="conversation id"): + normalize_conversation_id("WEB:") + with pytest.raises(ValueError, match="conversation id"): + normalize_conversation_id("not-an-id")