Skip to content

Commit 2c62a71

Browse files
committed
fix(v2): consolidate additional runtime fixes
1 parent 8cbb592 commit 2c62a71

13 files changed

Lines changed: 389 additions & 99 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
1010
## [Unreleased]
1111

1212
### Fixed
13-
- **v2 cleanup**: Consolidate small provider/runtime fixes for Gemini JSON prompts, Cohere templating, JSON array extraction, iterable streaming, missing `jsonref` dependency guidance, and retry hook attempt metadata.
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, multimodal autodetection, and the Google GenAI default model.
1414

1515
---
1616

instructor/v2/core/json.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,12 @@ def extract_json_from_codeblock(content: str) -> str:
1919
for end_index in range(start_index + 1, len(content)):
2020
char = content[end_index]
2121

22-
if char == '"' and not escape_next:
23-
in_string = not in_string
22+
if escape_next:
23+
escape_next = False
2424
elif char == "\\" and in_string:
2525
escape_next = True
26-
continue
27-
else:
28-
escape_next = False
26+
elif char == '"':
27+
in_string = not in_string
2928

3029
if in_string:
3130
continue
@@ -86,14 +85,14 @@ def extract_json_from_stream(chunks: Iterable[str]) -> Generator[str, None, None
8685
continue
8786

8887
if json_started:
89-
if char == '"' and not escape_next:
90-
in_string = not in_string
88+
if escape_next:
89+
escape_next = False
9190
elif char == "\\" and in_string:
9291
escape_next = True
9392
buffer.append(char)
9493
continue
95-
else:
96-
escape_next = False
94+
elif char == '"':
95+
in_string = not in_string
9796

9897
if in_codeblock and not in_string:
9998
if char == "`":
@@ -174,14 +173,14 @@ async def extract_json_from_stream_async(
174173
continue
175174

176175
if json_started:
177-
if char == '"' and not escape_next:
178-
in_string = not in_string
176+
if escape_next:
177+
escape_next = False
179178
elif char == "\\" and in_string:
180179
escape_next = True
181180
buffer.append(char)
182181
continue
183-
else:
184-
escape_next = False
182+
elif char == '"':
183+
in_string = not in_string
185184

186185
if in_codeblock and not in_string:
187186
if char == "`":

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.

instructor/v2/core/provider_specs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ def _openai_compat_spec(
204204
from_function="from_genai",
205205
client_module="instructor.v2.providers.genai.client",
206206
sdk_module="google.genai",
207-
provider_string="google/gemini-2.0-flash",
207+
provider_string="google/gemini-2.5-flash",
208208
basic_modes=(Mode.TOOLS, Mode.JSON),
209209
async_modes=(Mode.TOOLS, Mode.JSON),
210210
),

instructor/v2/core/retry.py

Lines changed: 26 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import json
910
import logging
1011
from typing import TYPE_CHECKING, Any, TypeVar
1112

@@ -21,9 +22,11 @@
2122
from instructor.v2.core.mode import Mode
2223
from instructor.v2.core.providers import Provider
2324
from instructor.v2.core.errors import (
25+
AsyncValidationError,
2426
FailedAttempt,
2527
IncompleteOutputException,
2628
InstructorRetryException,
29+
ResponseParsingError,
2730
)
2831
from instructor.v2.dsl.iterable import IterableBase
2932
from instructor.v2.dsl.response_list import ListResponse
@@ -41,10 +44,16 @@
4144
logger = logging.getLogger("instructor.v2.retry")
4245

4346
T_Model = TypeVar("T_Model", bound=BaseModel)
47+
_RETRYABLE_PARSE_ERRORS = (
48+
ValidationError,
49+
json.JSONDecodeError,
50+
AsyncValidationError,
51+
ResponseParsingError,
52+
)
4453

4554

4655
def _max_attempts(max_retries: int | Retrying | AsyncRetrying) -> int | None:
47-
return max(max_retries, 1) if isinstance(max_retries, int) else None
56+
return max(max_retries, 0) + 1 if isinstance(max_retries, int) else None
4857

4958

5059
def _attempt_metadata(
@@ -121,7 +130,7 @@ def retry_sync_v2(
121130
provider: Provider enum
122131
mode: Mode enum
123132
context: Validation context
124-
max_retries: Max retry attempts or Retrying instance
133+
max_retries: Maximum retries after the initial attempt, or Retrying instance
125134
args: Positional args for func
126135
kwargs: Keyword args for func
127136
strict: Strict validation mode
@@ -143,13 +152,13 @@ def retry_sync_v2(
143152

144153
# Setup retrying
145154
if isinstance(max_retries, int):
146-
stop_condition = stop_after_attempt(max(max_retries, 1))
155+
stop_condition = stop_after_attempt(max(max_retries, 0) + 1)
147156
timeout = kwargs.get("timeout")
148157
if isinstance(timeout, (int, float)):
149158
stop_condition = stop_condition | stop_after_delay(timeout)
150159
max_retries_instance = Retrying(
151160
stop=stop_condition,
152-
retry=retry_if_exception_type(ValidationError),
161+
retry=retry_if_exception_type(_RETRYABLE_PARSE_ERRORS),
153162
reraise=True,
154163
)
155164
else:
@@ -217,7 +226,7 @@ def retry_sync_v2(
217226

218227
except IncompleteOutputException:
219228
raise
220-
except ValidationError as e:
229+
except _RETRYABLE_PARSE_ERRORS as e:
221230
logger.debug(f"Validation error on attempt {attempt_number}: {e}")
222231
failed_attempts.append(
223232
FailedAttempt(
@@ -237,15 +246,6 @@ def retry_sync_v2(
237246
is_last_attempt=max_attempts == attempt_number,
238247
),
239248
)
240-
hooks.emit_completion_error(
241-
e,
242-
**_attempt_metadata(
243-
attempt_number=attempt_number,
244-
max_attempts=max_attempts,
245-
is_last_attempt=max_attempts == attempt_number,
246-
),
247-
)
248-
249249
# Prepare reask using registry
250250
kwargs = handlers.reask_handler(
251251
kwargs=kwargs,
@@ -260,11 +260,10 @@ def retry_sync_v2(
260260
raise
261261
except Exception as e:
262262
# Max retries exceeded or non-validation error occurred
263-
if last_exception is None:
264-
last_exception = e
263+
last_exception = e
265264

266265
logger.error(
267-
f"Max retries exceeded. Total attempts: {len(failed_attempts)}, "
266+
f"Max retries exceeded. Total attempts: {last_attempt_number}, "
268267
f"Last error: {last_exception}"
269268
)
270269
if hooks:
@@ -280,7 +279,7 @@ def retry_sync_v2(
280279
raise InstructorRetryException(
281280
str(last_exception),
282281
last_completion=failed_attempts[-1].completion if failed_attempts else None,
283-
n_attempts=len(failed_attempts),
282+
n_attempts=last_attempt_number,
284283
total_usage=total_usage,
285284
messages=extract_messages(kwargs),
286285
create_kwargs=kwargs,
@@ -292,7 +291,7 @@ def retry_sync_v2(
292291
raise InstructorRetryException(
293292
str(last_exception) if last_exception else "Unknown error",
294293
last_completion=failed_attempts[-1].completion if failed_attempts else None,
295-
n_attempts=len(failed_attempts),
294+
n_attempts=last_attempt_number,
296295
total_usage=total_usage,
297296
messages=extract_messages(kwargs),
298297
create_kwargs=kwargs,
@@ -376,7 +375,7 @@ async def retry_async_v2(
376375
provider: Provider enum
377376
mode: Mode enum
378377
context: Validation context
379-
max_retries: Max retry attempts or AsyncRetrying instance
378+
max_retries: Maximum retries after the initial attempt, or AsyncRetrying instance
380379
args: Positional args for func
381380
kwargs: Keyword args for func
382381
strict: Strict validation mode
@@ -398,13 +397,13 @@ async def retry_async_v2(
398397

399398
# Setup retrying
400399
if isinstance(max_retries, int):
401-
stop_condition = stop_after_attempt(max(max_retries, 1))
400+
stop_condition = stop_after_attempt(max(max_retries, 0) + 1)
402401
timeout = kwargs.get("timeout")
403402
if isinstance(timeout, (int, float)):
404403
stop_condition = stop_condition | stop_after_delay(timeout)
405404
max_retries_instance = AsyncRetrying(
406405
stop=stop_condition,
407-
retry=retry_if_exception_type(ValidationError),
406+
retry=retry_if_exception_type(_RETRYABLE_PARSE_ERRORS),
408407
reraise=True,
409408
)
410409
else:
@@ -472,7 +471,7 @@ async def retry_async_v2(
472471

473472
except IncompleteOutputException:
474473
raise
475-
except ValidationError as e:
474+
except _RETRYABLE_PARSE_ERRORS as e:
476475
logger.debug(f"Validation error on attempt {attempt_number}: {e}")
477476
failed_attempts.append(
478477
FailedAttempt(
@@ -492,15 +491,6 @@ async def retry_async_v2(
492491
is_last_attempt=max_attempts == attempt_number,
493492
),
494493
)
495-
hooks.emit_completion_error(
496-
e,
497-
**_attempt_metadata(
498-
attempt_number=attempt_number,
499-
max_attempts=max_attempts,
500-
is_last_attempt=max_attempts == attempt_number,
501-
),
502-
)
503-
504494
# Prepare reask using registry
505495
kwargs = handlers.reask_handler(
506496
kwargs=kwargs,
@@ -515,11 +505,10 @@ async def retry_async_v2(
515505
raise
516506
except Exception as e:
517507
# Max retries exceeded or non-validation error occurred
518-
if last_exception is None:
519-
last_exception = e
508+
last_exception = e
520509

521510
logger.error(
522-
f"Max retries exceeded. Total attempts: {len(failed_attempts)}, "
511+
f"Max retries exceeded. Total attempts: {last_attempt_number}, "
523512
f"Last error: {last_exception}"
524513
)
525514
if hooks:
@@ -535,7 +524,7 @@ async def retry_async_v2(
535524
raise InstructorRetryException(
536525
str(last_exception),
537526
last_completion=failed_attempts[-1].completion if failed_attempts else None,
538-
n_attempts=len(failed_attempts),
527+
n_attempts=last_attempt_number,
539528
total_usage=total_usage,
540529
messages=extract_messages(kwargs),
541530
create_kwargs=kwargs,
@@ -547,7 +536,7 @@ async def retry_async_v2(
547536
raise InstructorRetryException(
548537
str(last_exception) if last_exception else "Unknown error",
549538
last_completion=failed_attempts[-1].completion if failed_attempts else None,
550-
n_attempts=len(failed_attempts),
539+
n_attempts=last_attempt_number,
551540
total_usage=total_usage,
552541
messages=extract_messages(kwargs),
553542
create_kwargs=kwargs,

instructor/v2/dsl/iterable.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,13 @@ def get_object(s: str, stack: int) -> tuple[Optional[str], str]:
176176
in_string = False
177177
escape_next = False
178178
for i, c in enumerate(s):
179-
if c == '"' and not escape_next:
180-
in_string = not in_string
179+
if escape_next:
180+
escape_next = False
181181
elif c == "\\" and in_string:
182182
escape_next = True
183183
continue
184-
else:
185-
escape_next = False
184+
elif c == '"':
185+
in_string = not in_string
186186

187187
if in_string:
188188
continue

tests/llm/test_gemini/util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import os
22
import instructor
33

4-
models: list[str] = [os.getenv("GOOGLE_GENAI_MODEL", "google/gemini-2.0-flash")]
4+
models: list[str] = [os.getenv("GOOGLE_GENAI_MODEL", "google/gemini-2.5-flash")]
55
modes = [instructor.Mode.GENAI_STRUCTURED_OUTPUTS]

tests/llm/test_genai/test_reask.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def test_genai_tools_validation_retry_preserves_model_content(mode):
99
"""Ensure GENAI_TOOLS validation retries are wired end-to-end."""
1010
from instructor.core.exceptions import InstructorRetryException
1111

12-
model = os.getenv("GOOGLE_GENAI_MODEL", "gemini-2.0-flash")
12+
model = os.getenv("GOOGLE_GENAI_MODEL", "gemini-2.5-flash")
1313

1414
class AlwaysInvalid(BaseModel):
1515
value: int

tests/llm/test_genai/util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import os
22
import instructor
33

4-
models = [os.getenv("GOOGLE_GENAI_MODEL", "google/gemini-2.0-flash")]
4+
models = [os.getenv("GOOGLE_GENAI_MODEL", "google/gemini-2.5-flash")]
55
modes = [instructor.Mode.GENAI_STRUCTURED_OUTPUTS]

tests/v2/test_core_multimodal_runtime.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,29 @@ def test_convert_contents_rejects_unknown_object() -> None:
4444
convert_contents([object()], Mode.TOOLS) # type: ignore[list-item]
4545

4646

47+
@pytest.mark.parametrize(
48+
("media_type", "autodetect"),
49+
[
50+
("image", Image.autodetect),
51+
("audio", Audio.autodetect),
52+
("PDF", PDF.autodetect),
53+
],
54+
)
55+
def test_autodetect_rejects_unsupported_source_types(
56+
media_type: str, autodetect: Any
57+
) -> None:
58+
with pytest.raises(
59+
ValueError, match=rf"Unsupported {media_type} source type: bytes"
60+
):
61+
autodetect(b"not a string or path")
62+
63+
64+
def test_webp_mime_type_is_registered() -> None:
65+
import mimetypes
66+
67+
assert mimetypes.guess_type("image.webp")[0] == "image/webp"
68+
69+
4770
def test_autodetect_media_uses_mime_type_shortcut(
4871
monkeypatch: pytest.MonkeyPatch,
4972
) -> None:

0 commit comments

Comments
 (0)