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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
## [Unreleased]

### Fixed
- **Retry usage accounting**: Accumulate nested and newly added numeric usage fields across OpenAI and Anthropic retries, including prediction, cache-write, cache-creation, and server-tool counters, without treating boolean metadata as billable usage. ([#2493](https://github.com/567-labs/instructor/issues/2493), [#2500](https://github.com/567-labs/instructor/pull/2500))
- **OpenAI Responses reask**: Add a fallback correction message when a `RESPONSES_TOOLS` response contains no tool calls (e.g. reasoning-only output), so retries carry validation feedback instead of resending the identical request. ([#2498](https://github.com/567-labs/instructor/pull/2498))
- **v2 parallel tools**: Preserve raw iterable type hints through the sync and async patch wrappers so parallel tool schemas and results retain every requested model type. ([#2501](https://github.com/567-labs/instructor/pull/2501))
- **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))
Expand Down
12 changes: 8 additions & 4 deletions instructor/v2/core/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,15 @@ def new_create_sync(
# Get handlers from registry
handlers = mode_registry.get_handlers(provider, mode)

if response_model is not None:
if response_model is not None and mode not in Mode.parallel_modes():
response_model = prepare_response_model(response_model)

# Prepare request kwargs using registry handler
response_model, new_kwargs = handlers.request_handler(
prepared_model, new_kwargs = handlers.request_handler(
response_model=response_model, kwargs=kwargs
)
if mode not in Mode.parallel_modes():
response_model = prepared_model

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vertex parallel parse loses ParallelBase

Medium Severity

For Vertex parallel tool modes, the v2 patch wrapper keeps the caller’s raw Iterable[...] type and drops the handler’s prepared VertexAIParallelModel, while Vertex parse_response only runs parallel parsing when response_model is a ParallelBase instance, so wrapped from_vertexai parallel calls mis-parse multi-tool responses.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bbddca1. Configure here.

new_kwargs.pop("autodetect_images", None)
if handlers.message_converter and "messages" in new_kwargs:
new_kwargs["messages"] = handlers.message_converter(
Expand Down Expand Up @@ -323,13 +325,15 @@ async def new_create_async(
# Get handlers from registry
handlers = mode_registry.get_handlers(provider, mode)

if response_model is not None:
if response_model is not None and mode not in Mode.parallel_modes():
response_model = prepare_response_model(response_model)

# Prepare request kwargs using registry handler
response_model, new_kwargs = handlers.request_handler(
prepared_model, new_kwargs = handlers.request_handler(
response_model=response_model, kwargs=kwargs
)
if mode not in Mode.parallel_modes():
response_model = prepared_model
new_kwargs.pop("autodetect_images", None)
if handlers.message_converter and "messages" in new_kwargs:
new_kwargs["messages"] = handlers.message_converter(
Expand Down
84 changes: 55 additions & 29 deletions instructor/v2/core/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING, TypeVar
from numbers import Real
from typing import TYPE_CHECKING, TypeVar, cast

from pydantic import BaseModel

if TYPE_CHECKING:
from anthropic.types import Usage as AnthropicUsage
Expand All @@ -13,6 +16,56 @@
T_Response = TypeVar("T_Response")


def _field_names(model: BaseModel) -> set[str]:
"""Return declared and Pydantic extra field names for a model."""
return set(type(model).model_fields) | set(model.model_extra or {})


def _all_field_names(*models: BaseModel) -> set[str]:
return {field_name for model in models for field_name in _field_names(model)}


def _is_numeric(value: object) -> bool:
return isinstance(value, Real) and not isinstance(value, bool)


def _zero_numeric_fields(model: BaseModel) -> BaseModel:
for field_name in _field_names(model):
value = getattr(model, field_name, None)
if isinstance(value, BaseModel):
_zero_numeric_fields(value)
elif _is_numeric(value):
setattr(model, field_name, 0)
return model


def _accumulate_models(response: BaseModel, total: BaseModel) -> None:
for field_name in _all_field_names(response, total):
response_value = getattr(response, field_name, None)
total_value = getattr(total, field_name, None)
if isinstance(response_value, BaseModel):
if not isinstance(total_value, BaseModel):
total_value = _zero_numeric_fields(response_value.model_copy(deep=True))
setattr(total, field_name, total_value)
_accumulate_models(response_value, total_value)
setattr(response, field_name, total_value.model_copy(deep=True))
elif isinstance(total_value, BaseModel):
setattr(response, field_name, total_value.model_copy(deep=True))
elif _is_numeric(response_value):
response_number = cast(Real, response_value)
total_number = cast(Real, total_value) if _is_numeric(total_value) else 0
value = total_number + response_number
setattr(total, field_name, value)
setattr(response, field_name, value)
elif _is_numeric(total_value):
setattr(response, field_name, total_value)
elif response_value is not None:
setattr(total, field_name, response_value)
setattr(response, field_name, response_value)
elif total_value is not None:
setattr(response, field_name, total_value)


def update_total_usage(
response: T_Response | None,
total_usage: OpenAIUsage | AnthropicUsage,
Expand All @@ -26,34 +79,7 @@ def update_total_usage(
if isinstance(response_usage, _OpenAIUsage) and isinstance(
total_usage, _OpenAIUsage
):
total_usage.completion_tokens += response_usage.completion_tokens or 0
total_usage.prompt_tokens += response_usage.prompt_tokens or 0
total_usage.total_tokens += response_usage.total_tokens or 0
if (rtd := response_usage.completion_tokens_details) and (
ttd := total_usage.completion_tokens_details
):
ttd.audio_tokens = (ttd.audio_tokens or 0) + (rtd.audio_tokens or 0)
ttd.reasoning_tokens = (ttd.reasoning_tokens or 0) + (
rtd.reasoning_tokens or 0
)
if (rpd := response_usage.prompt_tokens_details) and (
tpd := total_usage.prompt_tokens_details
):
tpd.audio_tokens = (tpd.audio_tokens or 0) + (rpd.audio_tokens or 0)
tpd.cached_tokens = (tpd.cached_tokens or 0) + (rpd.cached_tokens or 0)
response_usage.completion_tokens = total_usage.completion_tokens
response_usage.prompt_tokens = total_usage.prompt_tokens
response_usage.total_tokens = total_usage.total_tokens
response_usage.completion_tokens_details = (
total_usage.completion_tokens_details.model_copy(deep=True)
if total_usage.completion_tokens_details is not None
else None
)
response_usage.prompt_tokens_details = (
total_usage.prompt_tokens_details.model_copy(deep=True)
if total_usage.prompt_tokens_details is not None
else None
)
_accumulate_models(response_usage, total_usage)
return response

try:
Expand Down
17 changes: 3 additions & 14 deletions instructor/v2/providers/anthropic/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from typing import Any

from instructor.v2.core.usage import _accumulate_models


def initialize_usage() -> Any:
"""Create an empty Anthropic usage accumulator."""
Expand All @@ -26,18 +28,5 @@ def update_total_usage(response_usage: Any, total_usage: Any) -> bool:
):
return False

if not total_usage.cache_creation_input_tokens:
total_usage.cache_creation_input_tokens = 0
if not total_usage.cache_read_input_tokens:
total_usage.cache_read_input_tokens = 0
total_usage.input_tokens += response_usage.input_tokens or 0
total_usage.output_tokens += response_usage.output_tokens or 0
total_usage.cache_creation_input_tokens += (
response_usage.cache_creation_input_tokens or 0
)
total_usage.cache_read_input_tokens += response_usage.cache_read_input_tokens or 0
response_usage.input_tokens = total_usage.input_tokens
response_usage.output_tokens = total_usage.output_tokens
response_usage.cache_creation_input_tokens = total_usage.cache_creation_input_tokens
response_usage.cache_read_input_tokens = total_usage.cache_read_input_tokens
_accumulate_models(response_usage, total_usage)
return True
15 changes: 15 additions & 0 deletions instructor/v2/providers/openai/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,21 @@ def reask_responses_tools(
}
)

if not reask_messages:
# Model produced no tool calls at all (e.g. a reasoning-only or plain
# message output). Fall back to a plain user correction so the retry
# carries feedback instead of resending the identical request,
# mirroring reask_tools and the Anthropic reask handler.
reask_messages.append(
{
"role": "user",
"content": (
f"Validation Error found:\n{exception}\n"
"Recall the function correctly, fix the errors"
),
}
)

kwargs["messages"].extend(reask_messages)
return kwargs

Expand Down
33 changes: 33 additions & 0 deletions tests/coverage/test_anthropic_support_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import httpx
import pytest
from anthropic.types import Usage
from anthropic.types.cache_creation import CacheCreation
from anthropic.types.server_tool_usage import ServerToolUsage
from pydantic import BaseModel, ValidationInfo, field_validator

from instructor.v2.core.client import AsyncInstructor, Instructor
Expand Down Expand Up @@ -397,3 +399,34 @@ def test_anthropic_usage_initializes_and_accumulates_sdk_usage() -> None:
) == (12, 10, 2, 5)
assert update_total_usage(object(), total) is False
assert update_total_usage(second, object()) is False


def test_anthropic_usage_accumulates_nested_provider_fields() -> None:
total = initialize_usage()
first = Usage(
input_tokens=100,
output_tokens=50,
cache_creation=CacheCreation(
ephemeral_1h_input_tokens=10, ephemeral_5m_input_tokens=20
),
server_tool_use=ServerToolUsage(web_fetch_requests=2, web_search_requests=3),
)
second = Usage(
input_tokens=100,
output_tokens=50,
cache_creation=CacheCreation(
ephemeral_1h_input_tokens=10, ephemeral_5m_input_tokens=20
),
server_tool_use=ServerToolUsage(web_fetch_requests=2, web_search_requests=3),
)

assert update_total_usage(first, total) is True
assert update_total_usage(second, total) is True
assert total.cache_creation == CacheCreation(
ephemeral_1h_input_tokens=20, ephemeral_5m_input_tokens=40
)
assert total.server_tool_use == ServerToolUsage(
web_fetch_requests=4, web_search_requests=6
)
assert second.cache_creation == total.cache_creation
assert second.server_tool_use == total.server_tool_use
Loading