Skip to content

Commit 8983c68

Browse files
jerannclaude
andauthored
fix(models): handle binary file downloads in tool execution (#190)
* fix(models): handle binary file downloads in tool execution Download actions (e.g. googledrive_unified_download_file) serve raw binary with the file's own MIME type and a Content-Disposition header, so StackOneTool.execute() calling response.json() unconditionally raised UnicodeDecodeError on the first non-UTF-8 byte. Branch on Content-Type: parse JSON only for JSON media types (application/json and +json suffixes); otherwise return the file as {content: bytes, content_type, status_code, headers, file_name}, matching the StackOne generated SDKs' download response shape. file_name is parsed from Content-Disposition (incl. RFC 5987 filename*). Existing JSON behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(readme): document file-download return shape from tool execution Add a "File Downloads" section covering the bytes-plus-metadata dict that execute()/call() return for non-JSON responses, the Content-Type detection rule, and the not-JSON-serializable caveat for LLM-facing re-serialization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(models): honour declared charset in RFC 5987 filename* parsing The Content-Disposition filename* branch captured the charset in its regex but discarded it, so unquote() always decoded with UTF-8. RFC 5987 also permits ISO-8859-1, so an ISO-8859-1 percent-encoded filename decoded to mojibake despite the helper claiming RFC 5987 support. - Decode the extended value with its declared charset; fall back to UTF-8 for an unknown or empty charset label (LookupError) instead of raising. - Strip surrounding quotes off non-conformant quoted filename* values. - Add tests for ISO-8859-1, an unknown charset, and a quoted filename*. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d332c27 commit 8983c68

3 files changed

Lines changed: 283 additions & 6 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,35 @@ tools = toolset.fetch_tools(providers=["hibob"])
114114
- Glob pattern: `["*_list_employees"]` matches all tools ending with `_list_employees`
115115
- Provider prefix: `["workday_*"]` matches all Workday tools
116116

117+
## File Downloads
118+
119+
Actions that return a file — e.g. `googledrive_unified_download_file`, `documents_download_file`, any `*_unified_download_file` — resolve to **raw bytes plus metadata**, not parsed JSON. The SDK decides this from the response `Content-Type`: a JSON content type is parsed as before; anything else is treated as a file download. This applies to both `tool.execute()` and `tool.call()`.
120+
121+
```python
122+
tools = toolset.fetch_tools(actions=["googledrive_*"], account_ids=[account_id])
123+
download = tools.get_tool("googledrive_unified_download_file")
124+
125+
result = download.execute({"id": "file-id"})
126+
127+
# `result` is a dict describing the file — write the bytes straight to disk:
128+
with open(result["file_name"] or "download.bin", "wb") as f:
129+
f.write(result["content"])
130+
```
131+
132+
The returned dict:
133+
134+
| Key | Type | Description |
135+
| -------------- | ------------- | -------------------------------------------------------------------------------------------- |
136+
| `content` | `bytes` | Raw file bytes. **Not JSON-serializable** — see the caveat below. |
137+
| `content_type` | `str` | The file's MIME type (e.g. `application/pdf`), or `application/octet-stream` if unspecified. |
138+
| `status_code` | `int` | HTTP status of the download response. |
139+
| `headers` | `dict` | Response headers. |
140+
| `file_name` | `str \| None` | Filename from the `Content-Disposition` header (handles RFC 5987 `filename*`), else `None`. |
141+
142+
> **Caveat:** `content` holds raw bytes, which are not JSON-serializable. If you forward tool results to an LLM — or anywhere that re-serializes them to JSON — handle or strip the `content` key (for example, base64-encode it on the LLM-facing path).
143+
144+
JSON responses are unchanged: any action returning `application/json` (or a `…+json` type) is parsed and returned as a dict exactly as before.
145+
117146
## Implicit Feedback (Beta)
118147

119148
The Python SDK can emit implicit behavioral feedback to LangSmith so you can triage low-quality tool results without manually tagging runs.

stackone_ai/models.py

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
import base64
44
import json
55
import logging
6+
import re
67
from collections.abc import Sequence
78
from datetime import datetime, timezone
89
from enum import Enum
910
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, TypeAlias, cast
10-
from urllib.parse import quote
11+
from urllib.parse import quote, unquote
1112

1213
import httpx
1314
from langchain_core.tools import BaseTool
@@ -57,6 +58,47 @@ def validate_method(v: str) -> str:
5758
return method
5859

5960

61+
def _is_json_content_type(content_type: str) -> bool:
62+
"""Whether a response body should be parsed as JSON based on its Content-Type.
63+
64+
Only genuine JSON media types are parsed (``application/json`` and structured
65+
suffixes such as ``application/problem+json``). Anything else - including a
66+
missing Content-Type - is treated as opaque content (a file download), so the
67+
raw bytes are returned instead of being force-decoded as UTF-8/JSON. This mirrors
68+
how the StackOne generated SDKs default unknown bodies to ``application/octet-stream``.
69+
"""
70+
media_type = content_type.split(";", 1)[0].strip().lower()
71+
return media_type == "application/json" or media_type.endswith("+json")
72+
73+
74+
def _filename_from_content_disposition(value: str | None) -> str | None:
75+
"""Extract the filename from a Content-Disposition header value, if present.
76+
77+
Handles both the plain ``filename="example.pdf"`` form and the RFC 5987 extended
78+
``filename*=UTF-8''example%20file.pdf`` form (which takes precedence when present).
79+
The extended form is percent-decoded using its declared charset (RFC 5987 permits
80+
both ``UTF-8`` and ``ISO-8859-1``); an unknown or empty charset falls back to UTF-8.
81+
"""
82+
if not value:
83+
return None
84+
extended = re.search(r"filename\*\s*=\s*([^']*)'[^']*'([^;]+)", value, re.IGNORECASE)
85+
if extended:
86+
charset = extended.group(1).strip() or "utf-8"
87+
encoded = extended.group(2).strip().strip('"')
88+
try:
89+
return unquote(encoded, encoding=charset, errors="replace") or None
90+
except LookupError:
91+
# Unrecognised charset label - decode as UTF-8 rather than failing.
92+
return unquote(encoded, encoding="utf-8", errors="replace") or None
93+
quoted = re.search(r'filename\s*=\s*"([^"]*)"', value, re.IGNORECASE)
94+
if quoted:
95+
return quoted.group(1).strip() or None
96+
bare = re.search(r"filename\s*=\s*([^;]+)", value, re.IGNORECASE)
97+
if bare:
98+
return bare.group(1).strip().strip('"') or None
99+
return None
100+
101+
60102
class ExecuteConfig(BaseModel):
61103
"""Configuration for executing a tool against an API endpoint"""
62104

@@ -206,7 +248,14 @@ def execute(
206248
options: Execution options (e.g. feedback metadata)
207249
208250
Returns:
209-
API response as dict
251+
For JSON responses, the parsed API response as a dict.
252+
253+
For file downloads (any non-JSON Content-Type, e.g. a
254+
``documents_download_file`` action), a dict describing the file:
255+
``{"content": <bytes>, "content_type": str, "status_code": int,
256+
"headers": dict, "file_name": str | None}``. Note ``content`` holds
257+
the raw bytes and is therefore not JSON-serializable - callers that
258+
re-serialize tool results (e.g. for an LLM) should handle this key.
210259
211260
Raises:
212261
StackOneAPIError: If the API request fails
@@ -257,9 +306,23 @@ def execute(
257306
response_status = response.status_code
258307
response.raise_for_status()
259308

260-
result = response.json()
261-
result_payload = cast(JsonDict, result) if isinstance(result, dict) else {"result": result}
262-
return result_payload
309+
content_type = response.headers.get("content-type", "")
310+
if _is_json_content_type(content_type):
311+
result = response.json()
312+
result_payload = cast(JsonDict, result) if isinstance(result, dict) else {"result": result}
313+
return result_payload
314+
315+
# Non-JSON bodies are file downloads (e.g. documents_download_file), which the
316+
# API serves as raw binary with the file's own MIME type and a Content-Disposition
317+
# header. Return the bytes plus metadata rather than forcing a JSON/UTF-8 decode.
318+
# The shape mirrors the StackOne generated SDKs' download response.
319+
return {
320+
"content": response.content,
321+
"content_type": content_type or "application/octet-stream",
322+
"status_code": response.status_code,
323+
"headers": dict(response.headers),
324+
"file_name": _filename_from_content_disposition(response.headers.get("content-disposition")),
325+
}
263326

264327
except json.JSONDecodeError as exc:
265328
status = "error"

tests/test_tool_calling.py

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
import respx
88

99
from stackone_ai import StackOneTool
10-
from stackone_ai.models import ExecuteConfig, ToolParameters
10+
from stackone_ai.models import (
11+
ExecuteConfig,
12+
ToolParameters,
13+
_filename_from_content_disposition,
14+
_is_json_content_type,
15+
)
1116
from stackone_ai.toolset import _StackOneRpcTool
1217
from tests.conftest import TEST_BASE_URL
1318

@@ -332,3 +337,183 @@ def test_extract_record_with_non_dict(self, rpc_tool):
332337
assert rpc_tool._extract_record("string") is None
333338
assert rpc_tool._extract_record(123) is None
334339
assert rpc_tool._extract_record(None) is None
340+
341+
342+
class TestBinaryDownloadResponse:
343+
"""File-download actions return raw bytes + metadata instead of failing on JSON parsing.
344+
345+
The StackOne API serves file downloads as raw binary with the file's own MIME type
346+
(e.g. application/pdf) and a Content-Disposition header - never JSON. The returned
347+
shape mirrors the StackOne generated SDKs' download response (content + content_type +
348+
status_code + headers), with content as raw bytes (the Python analog of the Java
349+
client's byte[] body / the TypeScript client's response stream).
350+
"""
351+
352+
@respx.mock
353+
def test_binary_response_returns_content_dict(self, mock_tool):
354+
"""A non-JSON (binary) body is returned as bytes + metadata, not JSON-parsed."""
355+
# Leading bytes of a real PDF; the 0xc4 byte is invalid UTF-8 and is exactly
356+
# what makes the unconditional response.json() raise UnicodeDecodeError.
357+
pdf_bytes = b"%PDF-1.4\n%\xc4\xe5\xf2\xe5\xeb\xa7\xf3\xa0\xd0\xc4\xc6\n1 0 obj\n"
358+
respx.post("https://api.example.com/test").mock(
359+
return_value=httpx.Response(
360+
200,
361+
headers={
362+
"content-type": "application/pdf",
363+
"content-disposition": 'attachment; filename="download.pdf"',
364+
},
365+
content=pdf_bytes,
366+
)
367+
)
368+
369+
result = mock_tool.execute({"name": "report", "value": 1})
370+
371+
assert result["content"] == pdf_bytes
372+
assert result["content_type"] == "application/pdf"
373+
assert result["status_code"] == 200
374+
assert result["file_name"] == "download.pdf"
375+
assert result["headers"]["content-type"] == "application/pdf"
376+
377+
@respx.mock
378+
def test_rpc_download_action_returns_content_dict(self):
379+
"""The RPC download path (e.g. googledrive_unified_download_file) returns bytes.
380+
381+
Reproduces the reported failure: a download action invoked through /actions/rpc
382+
previously raised UnicodeDecodeError because the binary body was JSON-parsed.
383+
"""
384+
parameters = ToolParameters(
385+
type="object",
386+
properties={"id": {"type": "string", "description": "File ID"}},
387+
)
388+
tool = _StackOneRpcTool(
389+
name="googledrive_unified_download_file",
390+
description="Download a file",
391+
parameters=parameters,
392+
api_key="test_api_key",
393+
base_url=TEST_BASE_URL,
394+
account_id="test_account",
395+
)
396+
397+
rtf_bytes = b"{\\rtf1\\ansi\\ansicpg1252\\\xc4\xe5 hello}"
398+
respx.post(f"{TEST_BASE_URL}/actions/rpc").mock(
399+
return_value=httpx.Response(
400+
200,
401+
headers={
402+
"content-type": "application/rtf",
403+
"content-disposition": 'attachment; filename="download.rtf"',
404+
},
405+
content=rtf_bytes,
406+
)
407+
)
408+
409+
result = tool.execute({"path": {"id": "file-123"}})
410+
411+
assert result["content"] == rtf_bytes
412+
assert result["content_type"] == "application/rtf"
413+
assert result["file_name"] == "download.rtf"
414+
415+
@respx.mock
416+
def test_octet_stream_without_filename(self, mock_tool):
417+
"""A binary body with no Content-Disposition still returns content with file_name=None."""
418+
blob = b"\x00\x01\x02\xc4\xff\xfe"
419+
respx.post("https://api.example.com/test").mock(
420+
return_value=httpx.Response(
421+
200,
422+
headers={"content-type": "application/octet-stream"},
423+
content=blob,
424+
)
425+
)
426+
427+
result = mock_tool.execute({})
428+
429+
assert result["content"] == blob
430+
assert result["content_type"] == "application/octet-stream"
431+
assert result["file_name"] is None
432+
433+
@respx.mock
434+
def test_json_response_still_parsed(self, mock_tool):
435+
"""Regression guard: JSON responses are unchanged - parsed to a dict, not wrapped."""
436+
respx.post("https://api.example.com/test").mock(
437+
return_value=httpx.Response(200, json={"id": "123", "ok": True})
438+
)
439+
440+
result = mock_tool.execute({"name": "x", "value": 1})
441+
442+
assert result == {"id": "123", "ok": True}
443+
assert "content" not in result
444+
445+
@respx.mock
446+
def test_json_with_charset_param_still_parsed(self, mock_tool):
447+
"""A JSON Content-Type with parameters (charset) is still parsed as JSON."""
448+
respx.post("https://api.example.com/test").mock(
449+
return_value=httpx.Response(
450+
200,
451+
headers={"content-type": "application/json; charset=utf-8"},
452+
content=b'{"ok": true}',
453+
)
454+
)
455+
456+
result = mock_tool.execute({})
457+
458+
assert result == {"ok": True}
459+
460+
@respx.mock
461+
def test_missing_content_type_returns_bytes(self, mock_tool):
462+
"""A body with no Content-Type is treated as opaque content (bytes), not JSON.
463+
464+
Pins the deliberate contract: the SDK trusts Content-Type to decide JSON vs
465+
file, so an absent Content-Type is returned as raw bytes rather than risking
466+
a UTF-8/JSON decode of binary. (StackOne always labels JSON as application/json.)
467+
"""
468+
blob = b"\xff\xd8\xff\xe0\x00\x10JFIF" # JPEG magic bytes, no content-type
469+
respx.post("https://api.example.com/test").mock(return_value=httpx.Response(200, content=blob))
470+
471+
result = mock_tool.execute({})
472+
473+
assert result["content"] == blob
474+
assert result["content_type"] == "application/octet-stream"
475+
assert result["file_name"] is None
476+
477+
478+
class TestResponseHelpers:
479+
"""Unit tests for the Content-Type and Content-Disposition helpers."""
480+
481+
@pytest.mark.parametrize(
482+
("content_type", "expected"),
483+
[
484+
("application/json", True),
485+
("application/json; charset=utf-8", True),
486+
("APPLICATION/JSON", True),
487+
("application/problem+json", True),
488+
("application/vnd.api+json", True),
489+
("", False),
490+
("application/pdf", False),
491+
("application/octet-stream", False),
492+
("text/plain", False),
493+
("text/json-but-not-really", False),
494+
],
495+
)
496+
def test_is_json_content_type(self, content_type, expected):
497+
assert _is_json_content_type(content_type) is expected
498+
499+
@pytest.mark.parametrize(
500+
("header", "expected"),
501+
[
502+
('attachment; filename="download.pdf"', "download.pdf"),
503+
("attachment; filename=download.pdf", "download.pdf"),
504+
('inline; filename="my report.docx"', "my report.docx"),
505+
# RFC 5987 extended form is percent-decoded and takes precedence.
506+
("attachment; filename=\"fallback.txt\"; filename*=UTF-8''na%C3%AFve.txt", "naïve.txt"),
507+
# Non-UTF-8 charset is honoured: 0xA3 is "£" in ISO-8859-1, not UTF-8.
508+
("attachment; filename*=ISO-8859-1'en'%A3%20rates.txt", "£ rates.txt"),
509+
# Unknown charset label falls back to UTF-8 instead of raising.
510+
("attachment; filename*=bogus-charset''%C2%A3.txt", "£.txt"),
511+
# Non-conformant quoted extended value: surrounding quotes are stripped.
512+
("attachment; filename*=\"UTF-8''na%C3%AFve.txt\"", "naïve.txt"),
513+
("attachment", None),
514+
(None, None),
515+
("", None),
516+
],
517+
)
518+
def test_filename_from_content_disposition(self, header, expected):
519+
assert _filename_from_content_disposition(header) == expected

0 commit comments

Comments
 (0)