Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
## [Unreleased]

### Fixed
- **Credential redaction**: Hide common OAuth and Google API credential aliases in nested v2 debug logging while preserving non-secret token configuration. ([#2490](https://github.com/567-labs/instructor/issues/2490), [#2491](https://github.com/567-labs/instructor/pull/2491))
- **Retry and message integrity**: Preserve cache keys and caller-owned retry messages, retain empty-content legacy function calls, return Anthropic tool results for every parallel tool call, and handle missing OpenAI/Mistral tool calls as retryable parse failures. ([#2454](https://github.com/567-labs/instructor/issues/2454), [#2455](https://github.com/567-labs/instructor/pull/2455), [#2464](https://github.com/567-labs/instructor/issues/2464), [#2484](https://github.com/567-labs/instructor/pull/2484), [#2485](https://github.com/567-labs/instructor/issues/2485), [#2486](https://github.com/567-labs/instructor/pull/2486), [#2448](https://github.com/567-labs/instructor/pull/2448), [#2453](https://github.com/567-labs/instructor/pull/2453))
- **Streaming and DSL correctness**: Isolate partial-model recursion guards, preserve partial nested models and explicit nulls, harden citation matching, derive useful Iterable union names, and continue scanning JSON streams after non-JSON or multiple balanced values. ([#2422](https://github.com/567-labs/instructor/issues/2422), [#2430](https://github.com/567-labs/instructor/pull/2430), [#2431](https://github.com/567-labs/instructor/issues/2431), [#2452](https://github.com/567-labs/instructor/pull/2452), [#2456](https://github.com/567-labs/instructor/pull/2456), [#2461](https://github.com/567-labs/instructor/issues/2461), [#2463](https://github.com/567-labs/instructor/pull/2463), [#2476](https://github.com/567-labs/instructor/pull/2476), [#2487](https://github.com/567-labs/instructor/pull/2487), [#2489](https://github.com/567-labs/instructor/pull/2489))
- **Provider request handling**: Avoid mutating Gemini generation config and cached OpenAI schemas, disable Anthropic parallel calls for forced single-tool requests, forward Bedrock default models, and label OpenAI audio as WAV or MP3 without misrepresenting unsupported formats. ([#2450](https://github.com/567-labs/instructor/issues/2450), [#2451](https://github.com/567-labs/instructor/pull/2451), [#2465](https://github.com/567-labs/instructor/issues/2465), [#2467](https://github.com/567-labs/instructor/pull/2467), [#2477](https://github.com/567-labs/instructor/issues/2477), [#2478](https://github.com/567-labs/instructor/pull/2478), [#2447](https://github.com/567-labs/instructor/pull/2447), [#2415](https://github.com/567-labs/instructor/pull/2415))
- **Batch, CLI, and citation runtime**: Accept valid empty batch objects, use typed OpenAI file attributes in the CLI, normalize `None` message content, and install `regex` as the direct dependency required by `CitationMixin`. ([#2473](https://github.com/567-labs/instructor/pull/2473), [#2441](https://github.com/567-labs/instructor/pull/2441), [#2440](https://github.com/567-labs/instructor/pull/2440), [#2443](https://github.com/567-labs/instructor/pull/2443))
- **Provider documentation**: Refresh retired Cerebras model IDs, clarify current and deprecated Google provider prefixes, and fix the Vertex Google GenAI example so its default model is passed to `from_genai()`. ([#2494](https://github.com/567-labs/instructor/pull/2494), [#2289](https://github.com/567-labs/instructor/issues/2289), [#2343](https://github.com/567-labs/instructor/pull/2343), [#2416](https://github.com/567-labs/instructor/issues/2416), [#2475](https://github.com/567-labs/instructor/pull/2475))
- **Multimodal (Audio)**: Raise explicit `ValueError` or `FileNotFoundError` from `Audio.from_url()` and `Audio.from_path()` instead of relying on bare `assert` statements that can disappear under `python -O`. ([#2361](https://github.com/567-labs/instructor/pull/2361))
- **v2 message handling**: Preserve caller-owned message lists and nested content across request preparation and retries for OpenAI-compatible, Cohere, Mistral, OpenRouter, Writer, and xAI handlers. ([#2417](https://github.com/567-labs/instructor/issues/2417), [#2428](https://github.com/567-labs/instructor/issues/2428))
- **v2 JSON extraction**: Prefer the final complete top-level JSON value in text responses and retain every JSON object when multiple objects arrive in one streaming chunk.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Once you've done so, you can use the following code to get started.
import instructor
from pydantic import BaseModel

client = instructor.from_provider("cerebras/llama3.1-70b")
client = instructor.from_provider("cerebras/gpt-oss-120b")


class Person(BaseModel):
Expand All @@ -74,7 +74,7 @@ class Person(BaseModel):


resp = client.create(
model="llama3.1-70b",
model="gpt-oss-120b",
messages=[
{
"role": "user",
Expand Down Expand Up @@ -109,7 +109,7 @@ class Person(BaseModel):


resp = client.create(
model="llama3.1-70b",
model="gpt-oss-120b",
messages=[
{
"role": "user",
Expand Down
10 changes: 5 additions & 5 deletions docs/integrations/cerebras.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import instructor
from cerebras.cloud.sdk import Cerebras
from pydantic import BaseModel

client = instructor.from_provider("cerebras/llama3.1-70b")
client = instructor.from_provider("cerebras/gpt-oss-120b")

class User(BaseModel):
name: str
Expand Down Expand Up @@ -51,7 +51,7 @@ from pydantic import BaseModel
import asyncio

client = instructor.from_provider(
"cerebras/llama3.1-70b",
"cerebras/gpt-oss-120b",
async_client=True,
)

Expand Down Expand Up @@ -84,7 +84,7 @@ from pydantic import BaseModel
import instructor
from cerebras.cloud.sdk import Cerebras

client = instructor.from_provider("cerebras/llama3.1-70b")
client = instructor.from_provider("cerebras/gpt-oss-120b")


class Address(BaseModel):
Expand Down Expand Up @@ -149,7 +149,7 @@ from pydantic import BaseModel
from typing import Iterable

client = instructor.from_provider(
"cerebras/llama3.1-70b",
"cerebras/gpt-oss-120b",
mode=instructor.Mode.MD_JSON,
)

Expand Down Expand Up @@ -187,7 +187,7 @@ from pydantic import BaseModel
from typing import Iterable

client = instructor.from_provider(
"cerebras/llama3.1-70b",
"cerebras/gpt-oss-120b",
mode=instructor.Mode.MD_JSON,
)

Expand Down
6 changes: 6 additions & 0 deletions docs/integrations/google.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ Master structured data extraction using Google's Gemini models with Instructor.

Google's GenAI SDK is the recommended way to access Gemini models. It provides a unified interface for both the Gemini API and Vertex AI. This guide shows you how to use Instructor with Google's GenAI SDK for type-safe, validated responses.

!!! info "Choosing a provider prefix"

- `google/<model>` is recommended. It uses the current `google-genai` SDK for the Gemini API and, with `vertexai=True`, Vertex AI.
- `vertexai/<model>` is deprecated. Migrate to `google/<model>` with `vertexai=True`.
- `gemini/<model>` is legacy. It uses the older `google-generativeai` package; migrate to `google/<model>`.

```bash
pip install "instructor[google-genai]"
```
Expand Down
4 changes: 2 additions & 2 deletions docs/integrations/vertex.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,8 @@ client = from_genai(
vertexai=True,
project="your-project",
location="us-central1",
model="gemini-3-flash"
)
),
model="gemini-3-flash",
)
```

Expand Down
2 changes: 1 addition & 1 deletion instructor/batch/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ def parse_results(self, results_content: str) -> list[BatchResult]:
custom_id = data.get("custom_id", "unknown")
extracted_data = self._extract_from_response(data)

if extracted_data:
if extracted_data is not None:
try:
# Parse into response model
result = self.response_model(**extracted_data)
Expand Down
12 changes: 6 additions & 6 deletions instructor/cli/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ def generate_file_table(files: list[openai.types.FileObject]) -> Table:

for file in files:
table.add_row(
file["id"],
str(file["bytes"]),
str(datetime.fromtimestamp(file["created_at"])),
file["filename"],
file["purpose"],
file.id,
str(file.bytes),
str(datetime.fromtimestamp(file.created_at)),
file.filename,
file.purpose,
)

return table
Expand Down Expand Up @@ -61,7 +61,7 @@ def upload(
file_purpose = cast(Literal["fine-tune", "assistants"], purpose)
with open(filepath, "rb") as file:
response = client.files.create(file=file, purpose=file_purpose)
file_id = response["id"]
file_id = response.id
with console.status(f"Monitoring upload: {file_id}...") as status:
status.spinner_style = "dots"
while True:
Expand Down
1 change: 1 addition & 0 deletions instructor/v2/auto_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,7 @@ def _build_bedrock(
client,
mode=default_mode,
async_client=async_client,
model=model_name,
**kwargs,
)
logger.info(
Expand Down
31 changes: 28 additions & 3 deletions instructor/v2/core/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ def extract_json_from_stream(chunks: Iterable[str]) -> Generator[str, None, None
delimiter_stack: list[str] = []
buffer: list[str] = []
codeblock_buffer: list[str] = []
last_invalid_candidate: str | None = None
emitted_valid_candidate = False

for chunk in chunks:
for char in chunk:
Expand Down Expand Up @@ -142,9 +144,17 @@ def extract_json_from_stream(chunks: Iterable[str]) -> Generator[str, None, None
delimiter_stack.pop()
if not delimiter_stack:
buffer.append(char)
yield from buffer
candidate = "".join(buffer)
buffer = []
json_started = False
try:
json.loads(candidate)
except ValueError:
last_invalid_candidate = candidate
continue
emitted_valid_candidate = True
last_invalid_candidate = None
yield from candidate
continue

buffer.append(char)
Expand All @@ -157,6 +167,8 @@ def extract_json_from_stream(chunks: Iterable[str]) -> Generator[str, None, None

if json_started and buffer:
yield from buffer
elif not emitted_valid_candidate and last_invalid_candidate is not None:
yield from last_invalid_candidate


async def extract_json_from_stream_async(
Expand All @@ -171,6 +183,8 @@ async def extract_json_from_stream_async(
delimiter_stack: list[str] = []
buffer: list[str] = []
codeblock_buffer: list[str] = []
last_invalid_candidate: str | None = None
emitted_valid_candidate = False

async for chunk in chunks:
for char in chunk:
Expand Down Expand Up @@ -231,10 +245,18 @@ async def extract_json_from_stream_async(
delimiter_stack.pop()
if not delimiter_stack:
buffer.append(char)
for buffered_char in buffer:
yield buffered_char
candidate = "".join(buffer)
buffer = []
json_started = False
try:
json.loads(candidate)
except ValueError:
last_invalid_candidate = candidate
continue
emitted_valid_candidate = True
last_invalid_candidate = None
for buffered_char in candidate:
yield buffered_char
continue

buffer.append(char)
Expand All @@ -248,3 +270,6 @@ async def extract_json_from_stream_async(
if json_started and buffer:
for buffered_char in buffer:
yield buffered_char
elif not emitted_valid_candidate and last_invalid_candidate is not None:
for buffered_char in last_invalid_candidate:
yield buffered_char
18 changes: 13 additions & 5 deletions instructor/v2/core/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,24 @@ def copy_messages_for_mutation(messages: list[dict[str, Any]]) -> list[dict[str,
return copied


def isolate_retry_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
"""Copy request lists that reask handlers mutate during retries."""
isolated = dict(kwargs)
for key_name in ("messages", "contents", "chat_history"):
value = isolated.get(key_name)
if isinstance(value, list):
isolated[key_name] = list(value)
return isolated


def dump_message(message: ChatCompletionMessage) -> ChatCompletionMessageParam:
ret: ChatCompletionMessageParam = {
"role": message.role,
"content": message.content or "",
}
if hasattr(message, "tool_calls") and message.tool_calls is not None:
ret["tool_calls"] = message.model_dump()["tool_calls"]
if (
hasattr(message, "function_call")
and message.function_call is not None
and ret["content"]
):
if hasattr(message, "function_call") and message.function_call is not None:
if not isinstance(ret["content"], str):
response_message = ""
for content_message in ret["content"]:
Expand Down Expand Up @@ -83,6 +89,8 @@ def merge_consecutive_messages(messages: list[dict[str, Any]]) -> list[dict[str,
for message in messages:
role = message.get("role", "user")
new_content = message.get("content", "")
if new_content is None:
new_content = ""
if not flat_string and isinstance(new_content, str):
new_content = [{"type": "text", "text": new_content}]

Expand Down
13 changes: 9 additions & 4 deletions instructor/v2/core/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from instructor.v2.core.utils import is_async
from instructor.v2.core.exceptions import RegistryValidationMixin
from instructor.v2.core.registry import mode_registry
from instructor.v2.core.messages import isolate_retry_kwargs
from instructor.v2.core.response_model import prepare_response_model
from instructor.v2.core.retry import retry_async_v2, retry_sync_v2

Expand Down Expand Up @@ -247,7 +248,9 @@ def new_create_sync(
if cached is not None:
return cached # type: ignore[return-value]

# Use v2 retry logic with registry handlers
# Use v2 retry logic with registry handlers. Pass an isolated copy of the
# messages list so reask-handler mutations during the retry loop can't leak
# back into new_kwargs, which is read again below for the cache store key.
response = retry_sync_v2(
func=func,
response_model=response_model,
Expand All @@ -256,7 +259,7 @@ def new_create_sync(
context=context,
max_retries=max_retries,
args=args,
kwargs=new_kwargs,
kwargs=isolate_retry_kwargs(new_kwargs),
strict=strict,
hooks=hooks,
)
Expand Down Expand Up @@ -359,7 +362,9 @@ async def new_create_async(
if cached is not None:
return cached # type: ignore[return-value]

# Use v2 retry logic with registry handlers
# Use v2 retry logic with registry handlers. Pass an isolated copy of the
# messages list so reask-handler mutations during the retry loop can't leak
# back into new_kwargs, which is read again below for the cache store key.
response = await retry_async_v2(
func=func,
response_model=response_model,
Expand All @@ -368,7 +373,7 @@ async def new_create_async(
context=context,
max_retries=max_retries,
args=args,
kwargs=new_kwargs,
kwargs=isolate_retry_kwargs(new_kwargs),
strict=strict,
hooks=hooks,
)
Expand Down
29 changes: 23 additions & 6 deletions instructor/v2/core/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from __future__ import annotations

import threading
from dataclasses import dataclass
from typing import Callable

Expand Down Expand Up @@ -80,6 +81,12 @@ def __init__(self) -> None:
"""Initialize empty registry."""
self._handlers: dict[tuple[Provider, Mode], ModeHandlers] = {}
self._lazy_loaders: dict[tuple[Provider, Mode], Callable[[], ModeHandlers]] = {}
# Guards the lazy-load resolution (check -> pop -> import -> set) in
# get_handlers(). Without this, concurrent first-callers for the same
# mode_key race: one pops the loader, the others see neither dict
# populated yet and raise KeyError. Held for the whole resolution
# (not per-key) since lazy-loading only ever runs once per key.
self._lazy_load_lock = threading.Lock()

def register(
self,
Expand Down Expand Up @@ -181,12 +188,22 @@ def get_handlers(self, provider: Provider, mode: Mode) -> ModeHandlers:
if mode_key in self._handlers:
return self._handlers[mode_key]

# Try lazy loading
if mode_key in self._lazy_loaders:
loader = self._lazy_loaders.pop(mode_key)
handlers = loader()
self._handlers[mode_key] = handlers
return handlers
# Try lazy loading. Locked because the pop -> import -> set sequence
# below is not atomic: without the lock, a thread that loses the race
# to pop self._lazy_loaders[mode_key] would find it already gone and
# self._handlers[mode_key] not yet set, and raise KeyError even
# though the mode genuinely is registered (just still resolving).
with self._lazy_load_lock:
# Re-check: another thread may have finished loading this key
# while we were waiting for the lock.
if mode_key in self._handlers:
return self._handlers[mode_key]

if mode_key in self._lazy_loaders:
loader = self._lazy_loaders.pop(mode_key)
handlers = loader()
self._handlers[mode_key] = handlers
return handlers

raise KeyError(
f"Mode {mode_key} is not registered. "
Expand Down
12 changes: 11 additions & 1 deletion instructor/v2/core/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,17 @@ class User(BaseModel):
T = TypeVar("T")

_SENSITIVE_KEYS: frozenset[str] = frozenset(
{"api_key", "api_secret", "authorization", "token", "x_api_key"}
{
"access_token",
"api_key",
"api_secret",
"authorization",
"client_secret",
"refresh_token",
"token",
"x_api_key",
"x_goog_api_key",
}
)


Expand Down
Loading