Skip to content

Commit 36394b8

Browse files
jxnlcursoragentjxnl-oai
authored
fix(v2): consolidate minor runtime fixes (#2340)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Jason Liu <jasonliu@openai.com>
1 parent 1ae7d71 commit 36394b8

20 files changed

Lines changed: 942 additions & 101 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
99

1010
## [Unreleased]
1111

12+
### Fixed
13+
- **v2 cleanup**: Consolidate small provider/runtime fixes for Gemini JSON prompts, Cohere templating, JSON array extraction, iterable streaming, missing `jsonref` dependency guidance, retry semantics and hook metadata, and multimodal autodetection.
14+
1215
### Tests / CI
1316
- **Type checking**: Upgrade to `ty` 0.0.44, enforce warning-free checks with GitHub annotations, cover V2 tests, validate supported Python versions and platforms, and strengthen installed-package public API typing tests.
1417

instructor/v2/core/hooks.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,20 @@ def __call__(self, response: Any) -> None: ...
3434
class CompletionErrorHandler(Protocol):
3535
"""Protocol for completion error and last attempt handlers."""
3636

37-
def __call__(self, error: Exception) -> None: ...
37+
def __call__(
38+
self,
39+
error: Exception,
40+
*,
41+
attempt_number: int = ...,
42+
max_attempts: int | None = ...,
43+
is_last_attempt: bool = ...,
44+
) -> None: ...
3845

3946

4047
class ParseErrorHandler(Protocol):
4148
"""Protocol for parse error handlers."""
4249

43-
def __call__(self, error: Exception) -> None: ...
50+
def __call__(self, error: Exception, **kwargs: Any) -> None: ...
4451

4552

4653
# Type alias for hook name parameter
@@ -191,14 +198,15 @@ def emit_completion_last_attempt(self, error: Exception, **kwargs: Any) -> None:
191198
"""
192199
self.emit(HookName.COMPLETION_LAST_ATTEMPT, error, **kwargs)
193200

194-
def emit_parse_error(self, error: Exception) -> None:
201+
def emit_parse_error(self, error: Exception, **kwargs: Any) -> None:
195202
"""
196203
Emit a parse error event.
197204
198205
Args:
199206
error: The exception to pass to handlers
207+
**kwargs: Optional metadata (attempt_number, max_attempts, is_last_attempt)
200208
"""
201-
self.emit(HookName.PARSE_ERROR, error)
209+
self.emit(HookName.PARSE_ERROR, error, **kwargs)
202210

203211
def off(
204212
self,

instructor/v2/core/json.py

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,46 @@
22

33
from __future__ import annotations
44

5+
import json
56
from collections.abc import AsyncGenerator, Generator, Iterable
67

78

89
def extract_json_from_codeblock(content: str) -> str:
9-
"""Extract the first JSON object-like span from a text block."""
10-
first_brace = content.find("{")
11-
last_brace = content.rfind("}")
12-
if first_brace != -1 and last_brace != -1:
13-
return content[first_brace : last_brace + 1]
10+
"""Extract the first JSON object- or array-like span from a text block."""
11+
for start_index, start_char in enumerate(content):
12+
if start_char not in "{[":
13+
continue
14+
15+
end_stack = ["}" if start_char == "{" else "]"]
16+
in_string = False
17+
escape_next = False
18+
19+
for end_index in range(start_index + 1, len(content)):
20+
char = content[end_index]
21+
22+
if escape_next:
23+
escape_next = False
24+
elif char == "\\" and in_string:
25+
escape_next = True
26+
elif char == '"':
27+
in_string = not in_string
28+
29+
if in_string:
30+
continue
31+
32+
if char in "{[":
33+
end_stack.append("}" if char == "{" else "]")
34+
continue
35+
if end_stack and char == end_stack[-1]:
36+
end_stack.pop()
37+
if not end_stack:
38+
candidate = content[start_index : end_index + 1]
39+
try:
40+
json.loads(candidate)
41+
except Exception:
42+
break
43+
return candidate
44+
1445
return content
1546

1647

@@ -21,7 +52,7 @@ def extract_json_from_stream(chunks: Iterable[str]) -> Generator[str, None, None
2152
json_started = False
2253
in_string = False
2354
escape_next = False
24-
brace_stack: list[str] = []
55+
delimiter_stack: list[str] = []
2556
buffer: list[str] = []
2657
codeblock_buffer: list[str] = []
2758

@@ -47,21 +78,21 @@ def extract_json_from_stream(chunks: Iterable[str]) -> Generator[str, None, None
4778
if codeblock_delimiter_count > 0:
4879
codeblock_delimiter_count = 0
4980

50-
if char == "{":
81+
if char in "{[":
5182
json_started = True
52-
brace_stack.append("{")
83+
delimiter_stack.append("}" if char == "{" else "]")
5384
buffer.append(char)
5485
continue
5586

5687
if json_started:
57-
if char == '"' and not escape_next:
58-
in_string = not in_string
88+
if escape_next:
89+
escape_next = False
5990
elif char == "\\" and in_string:
6091
escape_next = True
6192
buffer.append(char)
6293
continue
63-
else:
64-
escape_next = False
94+
elif char == '"':
95+
in_string = not in_string
6596

6697
if in_codeblock and not in_string:
6798
if char == "`":
@@ -77,11 +108,11 @@ def extract_json_from_stream(chunks: Iterable[str]) -> Generator[str, None, None
77108
codeblock_delimiter_count = 0
78109

79110
if not in_string:
80-
if char == "{":
81-
brace_stack.append("{")
82-
elif char == "}" and brace_stack:
83-
brace_stack.pop()
84-
if not brace_stack:
111+
if char in "{[":
112+
delimiter_stack.append("}" if char == "{" else "]")
113+
elif delimiter_stack and char == delimiter_stack[-1]:
114+
delimiter_stack.pop()
115+
if not delimiter_stack:
85116
buffer.append(char)
86117
yield from buffer
87118
buffer = []
@@ -91,9 +122,9 @@ def extract_json_from_stream(chunks: Iterable[str]) -> Generator[str, None, None
91122
buffer.append(char)
92123
continue
93124

94-
if not in_codeblock and not json_started and char == "{":
125+
if not in_codeblock and not json_started and char in "{[":
95126
json_started = True
96-
brace_stack.append("{")
127+
delimiter_stack.append("}" if char == "{" else "]")
97128
buffer.append(char)
98129

99130
if json_started and buffer:
@@ -109,7 +140,7 @@ async def extract_json_from_stream_async(
109140
json_started = False
110141
in_string = False
111142
escape_next = False
112-
brace_stack: list[str] = []
143+
delimiter_stack: list[str] = []
113144
buffer: list[str] = []
114145
codeblock_buffer: list[str] = []
115146

@@ -135,21 +166,21 @@ async def extract_json_from_stream_async(
135166
if codeblock_delimiter_count > 0:
136167
codeblock_delimiter_count = 0
137168

138-
if char == "{":
169+
if char in "{[":
139170
json_started = True
140-
brace_stack.append("{")
171+
delimiter_stack.append("}" if char == "{" else "]")
141172
buffer.append(char)
142173
continue
143174

144175
if json_started:
145-
if char == '"' and not escape_next:
146-
in_string = not in_string
176+
if escape_next:
177+
escape_next = False
147178
elif char == "\\" and in_string:
148179
escape_next = True
149180
buffer.append(char)
150181
continue
151-
else:
152-
escape_next = False
182+
elif char == '"':
183+
in_string = not in_string
153184

154185
if in_codeblock and not in_string:
155186
if char == "`":
@@ -166,11 +197,11 @@ async def extract_json_from_stream_async(
166197
codeblock_delimiter_count = 0
167198

168199
if not in_string:
169-
if char == "{":
170-
brace_stack.append("{")
171-
elif char == "}" and brace_stack:
172-
brace_stack.pop()
173-
if not brace_stack:
200+
if char in "{[":
201+
delimiter_stack.append("}" if char == "{" else "]")
202+
elif delimiter_stack and char == delimiter_stack[-1]:
203+
delimiter_stack.pop()
204+
if not delimiter_stack:
174205
buffer.append(char)
175206
for buffered_char in buffer:
176207
yield buffered_char
@@ -181,9 +212,9 @@ async def extract_json_from_stream_async(
181212
buffer.append(char)
182213
continue
183214

184-
if not in_codeblock and not json_started and char == "{":
215+
if not in_codeblock and not json_started and char in "{[":
185216
json_started = True
186-
brace_stack.append("{")
217+
delimiter_stack.append("}" if char == "{" else "]")
187218
buffer.append(char)
188219

189220
if json_started and buffer:

instructor/v2/core/multimodal.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
from pathlib import Path
1717
from urllib.parse import urlparse
1818
import mimetypes
19+
1920
import requests
2021
from pydantic import BaseModel, Field
2122

2223
from instructor.v2.core.errors import MultimodalError
2324
from instructor.v2.core.mode import Mode
2425

26+
mimetypes.add_type("image/webp", ".webp")
27+
2528
F = TypeVar("F", bound=Callable[..., Any])
2629
K = TypeVar("K", bound=Hashable)
2730
V = TypeVar("V")
@@ -91,6 +94,8 @@ def autodetect(cls, source: str | Path) -> Image:
9194
if isinstance(source, Path):
9295
return cls.from_path(source)
9396

97+
raise ValueError(f"Unsupported image source type: {type(source).__name__}")
98+
9499
@classmethod
95100
def autodetect_safely(cls, source: Union[str, Path]) -> Union[Image, str]: # noqa: UP007
96101
"""Safely attempt to autodetect an image from a source string or path.
@@ -280,6 +285,8 @@ def autodetect(cls, source: str | Path) -> Audio:
280285
if isinstance(source, Path):
281286
return cls.from_path(source)
282287

288+
raise ValueError(f"Unsupported audio source type: {type(source).__name__}")
289+
283290
@classmethod
284291
def autodetect_safely(cls, source: Union[str, Path]) -> Union[Audio, str]: # noqa: UP007
285292
"""Safely attempt to autodetect an audio from a source string or path.
@@ -471,6 +478,8 @@ def autodetect(cls, source: str | Path) -> PDF:
471478
elif isinstance(source, Path):
472479
return cls.from_path(source)
473480

481+
raise ValueError(f"Unsupported PDF source type: {type(source).__name__}")
482+
474483
@classmethod
475484
def autodetect_safely(cls, source: Union[str, Path]) -> Union[PDF, str]: # noqa: UP007
476485
"""Safely attempt to autodetect a PDF from a source string or path.

0 commit comments

Comments
 (0)