From 701ae46c72b6049a184c41d9bdb12d410e0b26cb Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Mon, 15 Jun 2026 14:52:37 -0700 Subject: [PATCH] feat(attachments): native PDF + audio translators, accept on upload PDF and audio attachments now work end-to-end on the native provider lanes; non-native lanes degrade to a placeholder (client-side fallback lands next). Capability flags are populated but not yet consumed by a wire-build gate. - providers: Anthropic PDF -> base64 document; OpenAI Responses PDF -> input_file; compat/Google inline_document_parts PDF -> placeholder (fixes the base64-as-text mangle); audio = input_audio passthrough on the compat lane (omni), defensive text placeholders on Anthropic + Responses - capabilities: supports_pdf on cloud Claude + OpenAI chat models; local/default/compat stay False (-> client-side fallback) - upload: classifier accepts pdf (32 MiB) + audio (25 MiB); endpoint multipart read cap raised to PDF_SIZE_CAP - hygiene: consolidate the duplicated upload classification into one attachments.classify_upload (+ UploadRejection); collapse AttachmentUploadHelpers to a single classify_upload callable - tests: PDF/audio translator shapes, capability flags, classify_upload --- tests/test_attachments_pdf_audio.py | 37 ++++++ tests/test_coordinator_endpoints.py | 8 +- tests/test_providers_document_parts.py | 115 ++++++++++++++++++ turnstone/console/server.py | 10 +- turnstone/core/attachments.py | 96 +++++++++------ turnstone/core/providers/_anthropic.py | 33 +++++ turnstone/core/providers/_openai_common.py | 54 ++++++-- turnstone/core/providers/_openai_responses.py | 35 ++++-- turnstone/core/session_routes.py | 60 +++------ turnstone/server.py | 10 +- 10 files changed, 338 insertions(+), 120 deletions(-) diff --git a/tests/test_attachments_pdf_audio.py b/tests/test_attachments_pdf_audio.py index 638d50c3..762a6917 100644 --- a/tests/test_attachments_pdf_audio.py +++ b/tests/test_attachments_pdf_audio.py @@ -12,7 +12,9 @@ import base64 from turnstone.core.attachments import ( AUDIO_MIME_TO_FORMAT, + IMAGE_SIZE_CAP, Attachment, + classify_upload, sniff_audio_mime, sniff_pdf_mime, ) @@ -131,3 +133,38 @@ class TestAudioFormatMap: assert AUDIO_MIME_TO_FORMAT["audio/mpeg"] == "mp3" assert AUDIO_MIME_TO_FORMAT["audio/wav"] == "wav" assert AUDIO_MIME_TO_FORMAT["audio/mp4"] == "m4a" + + +class TestClassifyUpload: + def test_image(self) -> None: + assert classify_upload("x.png", "image/png", PNG) == ("image", "image/png", None) + + def test_pdf(self) -> None: + assert classify_upload("d.pdf", "application/pdf", PDF) == ( + "pdf", + "application/pdf", + None, + ) + + def test_audio(self) -> None: + assert classify_upload("a.wav", "audio/wav", WAV) == ("audio", "audio/wav", None) + + def test_text(self) -> None: + assert classify_upload("notes.md", "text/markdown", b"# hi") == ( + "text", + "text/markdown", + None, + ) + + def test_unsupported_binary_rejected(self) -> None: + kind, _mime, rej = classify_upload( + "blob.bin", "application/octet-stream", b"\x00\x01\x02\x03" + ) + assert kind is None + assert rej is not None and rej.code == "unsupported" and rej.status == 400 + + def test_oversize_rejected(self) -> None: + big = PNG + b"\x00" * IMAGE_SIZE_CAP # > image cap + kind, _mime, rej = classify_upload("big.png", "image/png", big) + assert kind is None + assert rej is not None and rej.code == "too_large" and rej.status == 413 diff --git a/tests/test_coordinator_endpoints.py b/tests/test_coordinator_endpoints.py index 5c98f089..61362198 100644 --- a/tests/test_coordinator_endpoints.py +++ b/tests/test_coordinator_endpoints.py @@ -48,10 +48,7 @@ from turnstone.console.server import ( coordinator_tasks, ) from turnstone.core.attachments import ( - classify_text_attachment as _coord_test_classify_text, -) -from turnstone.core.attachments import ( - sniff_image_mime as _coord_test_sniff_image, + classify_upload as _coord_test_classify_upload, ) from turnstone.core.auth import AuthResult from turnstone.core.session_routes import ( @@ -107,8 +104,7 @@ _coord_endpoint_config = SessionEndpointConfig( supports_attachments=True, attachment_owner_resolver=_coord_attach_owner, attachment_helpers=AttachmentUploadHelpers( - sniff_image_mime=_coord_test_sniff_image, - classify_text_attachment=_coord_test_classify_text, + classify_upload=_coord_test_classify_upload, ), spawn_metrics=None, emit_message_queued=True, diff --git a/tests/test_providers_document_parts.py b/tests/test_providers_document_parts.py index f1a8f0b1..4bffe50e 100644 --- a/tests/test_providers_document_parts.py +++ b/tests/test_providers_document_parts.py @@ -347,3 +347,118 @@ class TestOpenAIResponsesDocument: assert len(out) == 2 assert 'name="a.md"' in out[0]["text"] assert 'name="b.md"' in out[1]["text"] + + +# --------------------------------------------------------------------------- +# PDF + audio (Phase 2 native translators / defensive handling) +# --------------------------------------------------------------------------- + +_PDF_B64 = "JVBERi0xLjQK" # base64 of "%PDF-1.4\n" + + +def _pdf_part(name: str = "report.pdf") -> dict[str, Any]: + return { + "type": "document", + "document": {"name": name, "media_type": "application/pdf", "data": _PDF_B64}, + } + + +def _audio_part(fmt: str = "wav") -> dict[str, Any]: + return {"type": "input_audio", "input_audio": {"data": "AAAA", "format": fmt}} + + +class TestAnthropicPdfAndAudio: + def test_pdf_becomes_base64_document(self) -> None: + out = AnthropicProvider._convert_content_parts([_pdf_part()]) + assert out == [ + { + "type": "document", + "source": { + "type": "base64", + "media_type": "application/pdf", + "data": _PDF_B64, + }, + "title": "report.pdf", + } + ] + + def test_pdf_without_name_omits_title(self) -> None: + part = { + "type": "document", + "document": {"media_type": "application/pdf", "data": _PDF_B64}, + } + out = AnthropicProvider._convert_content_parts([part]) + assert "title" not in out[0] + assert out[0]["source"]["type"] == "base64" + + def test_audio_becomes_text_placeholder(self) -> None: + out = AnthropicProvider._convert_content_parts([_audio_part()]) + assert len(out) == 1 + assert out[0]["type"] == "text" + assert "not supported" in out[0]["text"] + + def test_text_document_still_text_source(self) -> None: + # Regression: a text doc must NOT take the PDF base64 path. + out = AnthropicProvider._convert_content_parts([_doc_part()]) + assert out[0]["source"]["type"] == "text" + + +class TestOpenAIResponsesPdfAndAudio: + def test_pdf_becomes_input_file(self) -> None: + out = _responses_convert_content_parts([_pdf_part(name="r.pdf")]) + assert out == [ + { + "type": "input_file", + "filename": "r.pdf", + "file_data": f"data:application/pdf;base64,{_PDF_B64}", + } + ] + + def test_audio_becomes_placeholder(self) -> None: + out = _responses_convert_content_parts([_audio_part()]) + assert out[0]["type"] == "input_text" + assert "not supported" in out[0]["text"] + + def test_text_document_still_wrapped(self) -> None: + out = _responses_convert_content_parts([_doc_part(name="x.md", data="hi")]) + assert out[0]["type"] == "input_text" + assert " None: + out = inline_document_parts([_pdf_part(name="r.pdf")]) + assert len(out) == 1 + assert out[0]["type"] == "text" + # The base64 payload must NOT be wrapped as a text blob. + assert _PDF_B64 not in out[0]["text"] + assert " None: + # The omni native path: sanitize_messages must not mangle input_audio. + msgs = [{"role": "user", "content": [{"type": "text", "text": "hi"}, _audio_part()]}] + out = sanitize_messages(msgs) + assert out[0]["content"][1] == _audio_part() + + +class TestProviderPdfCapabilities: + def test_anthropic_cloud_supports_pdf(self) -> None: + caps = AnthropicProvider().get_capabilities("claude-opus-4-8") + assert caps.supports_pdf is True + assert caps.supports_audio_input is False + + def test_openai_chat_supports_pdf_default_does_not(self) -> None: + from turnstone.core.providers._openai_common import ( + OPENAI_DEFAULT, + lookup_openai_capabilities, + ) + + assert lookup_openai_capabilities("gpt-5").supports_pdf is True + # Unknown / local models stay False (PDF → client-side fallback). + assert OPENAI_DEFAULT.supports_pdf is False + + def test_anthropic_compat_default_no_pdf(self) -> None: + from turnstone.core.providers._anthropic import _ANTHROPIC_COMPAT_DEFAULT + + assert _ANTHROPIC_COMPAT_DEFAULT.supports_pdf is False diff --git a/turnstone/console/server.py b/turnstone/console/server.py index c0c222b8..12d3a735 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -12834,16 +12834,10 @@ def create_app( return "", JSONResponse({"error": "coordinator not found"}, status_code=404) return ws.user_id or auth_user_id(request), None - from turnstone.core.attachments import ( - classify_text_attachment as _coord_classify_text, - ) - from turnstone.core.attachments import ( - sniff_image_mime as _coord_sniff_image, - ) + from turnstone.core.attachments import classify_upload as _coord_classify_upload coord_attachment_helpers = AttachmentUploadHelpers( - sniff_image_mime=_coord_sniff_image, - classify_text_attachment=_coord_classify_text, + classify_upload=_coord_classify_upload, ) coord_endpoint_config = SessionEndpointConfig( permission_gate=_require_admin_coordinator, diff --git a/turnstone/core/attachments.py b/turnstone/core/attachments.py index f79f9b74..68878882 100644 --- a/turnstone/core/attachments.py +++ b/turnstone/core/attachments.py @@ -212,6 +212,59 @@ def classify_text_attachment( return "text/plain", None +@dataclass(frozen=True) +class UploadRejection: + """A rejected upload: client-facing message, machine code, HTTP status. + + The single rejection shape both upload paths (the single-file endpoint and + the create-with-attachments batch) render into a JSON error response. + """ + + message: str + code: str + status: int + + +def _too_large(label: str, size: int, cap: int) -> UploadRejection: + return UploadRejection( + f"{label} too large ({size:,} bytes); cap is {cap:,} bytes.", "too_large", 413 + ) + + +def classify_upload( + filename: str, claimed_mime: str, data: bytes +) -> tuple[str | None, str | None, UploadRejection | None]: + """Classify one non-empty upload into ``(kind, canonical_mime, rejection)``. + + The single attachment-policy point, shared by the upload endpoint and the + create-with-attachments batch. Sniff order is image → pdf → audio (magic + bytes; the client-claimed ``Content-Type`` is never trusted), then UTF-8 + text by MIME/extension allowlist. Each kind enforces its own byte cap. + Returns ``(kind, mime, None)`` on success, or ``(None, None, rejection)`` on + the first failure. Callers pre-check for empty data. + """ + sniffed_image = sniff_image_mime(data) + if sniffed_image is not None: + if len(data) > IMAGE_SIZE_CAP: + return None, None, _too_large("Image", len(data), IMAGE_SIZE_CAP) + return "image", sniffed_image, None + if sniff_pdf_mime(data) is not None: + if len(data) > PDF_SIZE_CAP: + return None, None, _too_large("PDF", len(data), PDF_SIZE_CAP) + return "pdf", "application/pdf", None + sniffed_audio = sniff_audio_mime(data) + if sniffed_audio is not None: + if len(data) > AUDIO_SIZE_CAP: + return None, None, _too_large("Audio", len(data), AUDIO_SIZE_CAP) + return "audio", sniffed_audio, None + if len(data) > TEXT_DOC_SIZE_CAP: + return None, None, _too_large("Text document", len(data), TEXT_DOC_SIZE_CAP) + mime, err = classify_text_attachment(filename, claimed_mime, data) + if mime is None: + return None, None, UploadRejection(err or "Unsupported file type", "unsupported", 400) + return "text", mime, None + + def validate_and_save_uploaded_files( files: list[tuple[str, str, bytes]], ws_id: str, @@ -246,42 +299,13 @@ def validate_and_save_uploaded_files( for filename, claimed_mime, data in files: if not data: return saved_ids, _JSONResponse({"error": "Empty file"}, status_code=400) - sniffed_image = sniff_image_mime(data) - if sniffed_image is not None: - if len(data) > IMAGE_SIZE_CAP: - return saved_ids, _JSONResponse( - { - "error": ( - f"Image too large ({len(data):,} bytes); " - f"cap is {IMAGE_SIZE_CAP:,} bytes." - ), - "code": "too_large", - }, - status_code=413, - ) - kind = "image" - mime = sniffed_image - else: - if len(data) > TEXT_DOC_SIZE_CAP: - return saved_ids, _JSONResponse( - { - "error": ( - f"Text document too large ({len(data):,} bytes); " - f"cap is {TEXT_DOC_SIZE_CAP:,} bytes." - ), - "code": "too_large", - }, - status_code=413, - ) - mime_or_err = classify_text_attachment(filename, claimed_mime, data) - if mime_or_err[0] is None: - return saved_ids, _JSONResponse( - {"error": mime_or_err[1], "code": "unsupported"}, - status_code=400, - ) - kind = "text" - mime = mime_or_err[0] - + kind, mime, rejection = classify_upload(filename, claimed_mime, data) + if rejection is not None: + return saved_ids, _JSONResponse( + {"error": rejection.message, "code": rejection.code}, + status_code=rejection.status, + ) + assert kind is not None and mime is not None # success ⟹ both set staged = buffer.stage( ws_id=ws_id, user_id=user_id, diff --git a/turnstone/core/providers/_anthropic.py b/turnstone/core/providers/_anthropic.py index a766dc25..631eee0a 100644 --- a/turnstone/core/providers/_anthropic.py +++ b/turnstone/core/providers/_anthropic.py @@ -85,6 +85,7 @@ _ANTHROPIC_DEFAULT = ModelCapabilities( thinking_mode="manual", supports_web_search=True, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ) @@ -126,6 +127,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = { supports_web_search=True, supports_tool_search=True, supports_vision=True, + supports_pdf=True, supports_temperature=False, thinking_display="summarized", supports_reasoning_replay=True, @@ -141,6 +143,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = { supports_web_search=True, supports_tool_search=True, supports_vision=True, + supports_pdf=True, supports_temperature=False, thinking_display="summarized", supports_reasoning_replay=True, @@ -156,6 +159,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = { supports_web_search=True, supports_tool_search=True, supports_vision=True, + supports_pdf=True, supports_temperature=False, thinking_display="summarized", supports_reasoning_replay=True, @@ -170,6 +174,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = { supports_web_search=True, supports_tool_search=True, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), "claude-sonnet-4-6": ModelCapabilities( @@ -182,6 +187,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = { supports_web_search=True, supports_tool_search=True, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), "claude-haiku-4-5": ModelCapabilities( @@ -191,6 +197,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = { thinking_mode="manual", supports_web_search=True, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), "claude-sonnet-4-5": ModelCapabilities( @@ -200,6 +207,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = { thinking_mode="manual", supports_web_search=True, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), "claude-opus-4-5": ModelCapabilities( @@ -211,6 +219,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = { effort_levels=("low", "medium", "high"), supports_web_search=True, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), } @@ -643,6 +652,22 @@ class AnthropicProvider: for part in parts: if part.get("type") == "document": d = part.get("document", {}) + if d.get("media_type") == "application/pdf": + # Native PDF: base64 document source (Anthropic reads both + # text and page images). ``data`` is already base64 — see + # storage/_utils.attachment_to_content_part. + pdf_block: dict[str, Any] = { + "type": "document", + "source": { + "type": "base64", + "media_type": "application/pdf", + "data": d.get("data", ""), + }, + } + if d.get("name"): + pdf_block["title"] = d["name"] + converted.append(pdf_block) + continue # Anthropic's text-source documents only accept # ``text/plain``; coerce any other text MIME here and fold # the original type into the human-readable title so the @@ -665,6 +690,14 @@ class AnthropicProvider: block["title"] = original_mime converted.append(block) continue + if part.get("type") == "input_audio": + # Anthropic has no audio-input API. Phase 3 transcribes via the + # STT role upstream of this translator; until then surface a + # placeholder rather than passing an unsupported block to the API. + converted.append( + {"type": "text", "text": "[audio attachment — not supported by this model]"} + ) + continue if part.get("type") == "image_url": url = part.get("image_url", {}).get("url", "") if url.startswith("data:") and "," in url: diff --git a/turnstone/core/providers/_openai_common.py b/turnstone/core/providers/_openai_common.py index d7baf9da..6199059e 100644 --- a/turnstone/core/providers/_openai_common.py +++ b/turnstone/core/providers/_openai_common.py @@ -34,6 +34,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { reasoning_effort_values=("minimal", "low", "medium", "high"), default_reasoning_effort="medium", supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), "gpt-5-mini": ModelCapabilities( @@ -43,6 +44,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { reasoning_effort_values=("minimal", "low", "medium", "high"), default_reasoning_effort="medium", supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), "gpt-5-nano": ModelCapabilities( @@ -52,6 +54,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { reasoning_effort_values=("minimal", "low", "medium", "high"), default_reasoning_effort="medium", supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), # GPT-5 pro — high reasoning only, extended output @@ -62,6 +65,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { reasoning_effort_values=("high",), default_reasoning_effort="high", supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), # GPT-5.1 — temperature OK when reasoning_effort=none (default) @@ -71,6 +75,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { reasoning_effort_values=("none", "low", "medium", "high"), default_reasoning_effort="none", supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), # GPT-5.2 — adds xhigh @@ -80,6 +85,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { reasoning_effort_values=("none", "low", "medium", "high", "xhigh"), default_reasoning_effort="none", supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), # GPT-5.2 pro — always-reasoning variant @@ -90,6 +96,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { reasoning_effort_values=("medium", "high", "xhigh"), default_reasoning_effort="medium", supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), # GPT-5.3 — same capabilities as 5.2 (matches gpt-5.3-chat-latest, codex) @@ -99,6 +106,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { reasoning_effort_values=("none", "low", "medium", "high", "xhigh"), default_reasoning_effort="none", supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), # GPT-5.4 — 1M context window, native tool search @@ -109,6 +117,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { default_reasoning_effort="none", supports_tool_search=True, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), # GPT-5.4 pro — always-reasoning, 1M context, native tool search @@ -120,6 +129,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { default_reasoning_effort="medium", supports_tool_search=True, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), # GPT-5.5 — 1M context, native tool search, stronger agentic/tool use @@ -130,6 +140,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { default_reasoning_effort="none", supports_tool_search=True, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), # GPT-5.5 pro — always-reasoning, 1M context, native tool search @@ -141,6 +152,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { default_reasoning_effort="medium", supports_tool_search=True, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), # O-series reasoning models @@ -150,6 +162,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { supports_temperature=False, supports_streaming=False, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), "o1-mini": ModelCapabilities( @@ -158,6 +171,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { supports_temperature=False, supports_streaming=False, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), "o3": ModelCapabilities( @@ -165,6 +179,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { max_output_tokens=100000, supports_temperature=False, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), "o3-mini": ModelCapabilities( @@ -172,6 +187,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { max_output_tokens=100000, supports_temperature=False, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), "o3-pro": ModelCapabilities( @@ -180,6 +196,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { supports_temperature=False, supports_streaming=False, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), "o4-mini": ModelCapabilities( @@ -187,6 +204,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { max_output_tokens=100000, supports_temperature=False, supports_vision=True, + supports_pdf=True, supports_reasoning_replay=True, ), # Search models — always search on every request, no reasoning_effort @@ -197,6 +215,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = { supports_web_search=True, reasoning_effort_values=(), supports_vision=True, + supports_pdf=True, ), # Audio models — not chat/session models; used only as STT/TTS roles via # the /v1/audio/transcriptions and /v1/audio/speech endpoints. Prefixes @@ -424,16 +443,31 @@ def inline_document_parts(parts: list[Any]) -> list[Any]: for part in parts: if isinstance(part, dict) and part.get("type") == "document": d = part.get("document", {}) - out.append( - { - "type": "text", - "text": format_document_wrapper( - d.get("name", ""), - d.get("media_type", "text/plain"), - d.get("data", ""), - ), - } - ) + if d.get("media_type") == "application/pdf": + # This lane (OpenAI Chat / Google compat / local servers) has no + # native PDF block and ``data`` is base64 — text-wrapping it would + # emit garbage. Surface a placeholder; the Phase 3 capability- + # gated fallback (rasterize / text-extract) replaces it. + out.append( + { + "type": "text", + "text": ( + f"[PDF attachment '{d.get('name') or 'document.pdf'}' — " + "not supported by this model]" + ), + } + ) + else: + out.append( + { + "type": "text", + "text": format_document_wrapper( + d.get("name", ""), + d.get("media_type", "text/plain"), + d.get("data", ""), + ), + } + ) else: out.append(part) return out diff --git a/turnstone/core/providers/_openai_responses.py b/turnstone/core/providers/_openai_responses.py index 3972958c..e8489009 100644 --- a/turnstone/core/providers/_openai_responses.py +++ b/turnstone/core/providers/_openai_responses.py @@ -61,15 +61,34 @@ def convert_content_parts(parts: list[Any]) -> list[dict[str, Any]]: converted.append({"type": "input_image", "image_url": url}) elif ptype == "document": d = part.get("document", {}) + if d.get("media_type") == "application/pdf": + # Native PDF: Responses ``input_file`` with an inline base64 + # data URI (``data`` is already base64 — see + # storage/_utils.attachment_to_content_part). + converted.append( + { + "type": "input_file", + "filename": d.get("name") or "document.pdf", + "file_data": f"data:application/pdf;base64,{d.get('data', '')}", + } + ) + else: + converted.append( + { + "type": "input_text", + "text": format_document_wrapper( + d.get("name", ""), + d.get("media_type", "text/plain"), + d.get("data", ""), + ), + } + ) + elif ptype == "input_audio": + # Audio-input is not wired on the Responses lane; until the Phase 3 + # capability-gated fallback (STT) lands upstream, surface a + # placeholder rather than leaking an unhandled part to the API. converted.append( - { - "type": "input_text", - "text": format_document_wrapper( - d.get("name", ""), - d.get("media_type", "text/plain"), - d.get("data", ""), - ), - } + {"type": "input_text", "text": "[audio attachment — not supported by this model]"} ) else: converted.append(part) diff --git a/turnstone/core/session_routes.py b/turnstone/core/session_routes.py index 3df30800..430d70b4 100644 --- a/turnstone/core/session_routes.py +++ b/turnstone/core/session_routes.py @@ -276,10 +276,9 @@ class AttachmentUploadHelpers: serialize.) """ - sniff_image_mime: Callable[[bytes], str | None] - classify_text_attachment: Callable[ + classify_upload: Callable[ [str, str, bytes], - tuple[str | None, str | None], + tuple[str | None, str | None, Any], ] @@ -3731,16 +3730,16 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers: async def upload(request: Request) -> Response: from turnstone.core.attachment_buffer import get_attachment_buffer - from turnstone.core.attachments import IMAGE_SIZE_CAP, TEXT_DOC_SIZE_CAP + from turnstone.core.attachments import PDF_SIZE_CAP from turnstone.core.web_helpers import read_multipart_file_or_400 - # Sniffing helpers stay kind-specific because they're tied to - # the file-classification policy table; defer to the cfg's - # owning module via the upload-helper hook. + # The file-classification policy (sniff order, per-kind caps, allowlists) + # lives in one place — core.attachments.classify_upload — handed in via + # the upload-helper hook so the console surface can wire it without + # depending on the node-side server module. if cfg.attachment_helpers is None: return JSONResponse({"error": "attachment_helpers missing"}, status_code=500) - sniff_image = cfg.attachment_helpers.sniff_image_mime - classify_text = cfg.attachment_helpers.classify_text_attachment + classify = cfg.attachment_helpers.classify_upload err_gate = await _gate(request) if err_gate is not None: @@ -3754,47 +3753,20 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers: if err: return err - got = await read_multipart_file_or_400(request, field="file", max_bytes=IMAGE_SIZE_CAP) + got = await read_multipart_file_or_400(request, field="file", max_bytes=PDF_SIZE_CAP) if isinstance(got, JSONResponse): return got filename, claimed_mime, data = got if not data: return JSONResponse({"error": "Empty file"}, status_code=400) - sniffed_image = sniff_image(data) - if sniffed_image is not None: - if len(data) > IMAGE_SIZE_CAP: - return JSONResponse( - { - "error": ( - f"Image too large ({len(data):,} bytes); " - f"cap is {IMAGE_SIZE_CAP:,} bytes." - ), - "code": "too_large", - }, - status_code=413, - ) - kind = "image" - mime = sniffed_image - else: - if len(data) > TEXT_DOC_SIZE_CAP: - return JSONResponse( - { - "error": ( - f"Text document too large ({len(data):,} bytes); " - f"cap is {TEXT_DOC_SIZE_CAP:,} bytes." - ), - "code": "too_large", - }, - status_code=413, - ) - mime_or_err = classify_text(filename, claimed_mime, data) - if mime_or_err[0] is None: - return JSONResponse( - {"error": mime_or_err[1], "code": "unsupported"}, status_code=400 - ) - kind = "text" - mime = mime_or_err[0] + kind, mime, rejection = classify(filename, claimed_mime, data) + if rejection is not None: + return JSONResponse( + {"error": rejection.message, "code": rejection.code}, + status_code=rejection.status, + ) + assert kind is not None and mime is not None # success ⟹ both set # Stage in the per-node upload buffer (content-addressed: the id is the # content hash, so re-uploading identical bytes is idempotent). The diff --git a/turnstone/server.py b/turnstone/server.py index a8b2c291..c5c642f8 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -3746,16 +3746,10 @@ def create_app( ui._ws_messages += 1 ui._ws_turn_tool_calls = 0 - from turnstone.core.attachments import ( - classify_text_attachment as _classify_text_attachment, - ) - from turnstone.core.attachments import ( - sniff_image_mime as _sniff_image_mime, - ) + from turnstone.core.attachments import classify_upload as _classify_upload interactive_attachment_helpers = AttachmentUploadHelpers( - sniff_image_mime=_sniff_image_mime, - classify_text_attachment=_classify_text_attachment, + classify_upload=_classify_upload, ) from turnstone.core.memory import ( get_workstream_display_names as _get_ws_display_names,