From fe3ffb7ab967a1d891a353a922808fa654b28189 Mon Sep 17 00:00:00 2001 From: Jason Liu Date: Sun, 7 Jun 2026 18:39:06 -0400 Subject: [PATCH 1/6] refactor(v2): centralize provider client contracts --- examples/v2-model-mode-benchmark/run.py | 383 +++++ instructor/batch/processor.py | 2 +- instructor/providers/anthropic/client.py | 3 +- instructor/providers/bedrock/client.py | 3 +- instructor/providers/cerebras/client.py | 3 +- instructor/providers/cohere/client.py | 3 +- instructor/providers/fireworks/client.py | 3 +- instructor/providers/gemini/client.py | 3 +- instructor/providers/genai/client.py | 3 +- instructor/providers/groq/client.py | 3 +- instructor/providers/mistral/client.py | 3 +- instructor/providers/perplexity/client.py | 3 +- instructor/providers/vertexai/client.py | 3 +- instructor/providers/writer/client.py | 3 +- instructor/providers/xai/client.py | 3 +- instructor/v2/auto_client.py | 1489 +---------------- instructor/v2/core/client_factory.py | 187 +++ instructor/v2/core/errors.py | 4 +- instructor/v2/core/multimodal.py | 32 +- instructor/v2/core/provider_specs.py | 252 ++- instructor/v2/core/providers.py | 48 +- instructor/v2/core/retry.py | 236 ++- instructor/v2/dsl/partial.pyi | 10 +- instructor/v2/providers/anthropic/client.py | 108 +- instructor/v2/providers/anthropic/handlers.py | 20 +- .../v2/providers/anthropic/multimodal.py | 41 +- instructor/v2/providers/anthropic/parallel.py | 6 +- instructor/v2/providers/bedrock/client.py | 101 +- instructor/v2/providers/cerebras/client.py | 87 +- instructor/v2/providers/cohere/client.py | 134 +- instructor/v2/providers/fireworks/client.py | 97 +- instructor/v2/providers/gemini/client.py | 84 +- instructor/v2/providers/gemini/utils.py | 22 +- instructor/v2/providers/genai/client.py | 128 +- instructor/v2/providers/genai/handlers.py | 6 +- instructor/v2/providers/genai/multimodal.py | 18 +- instructor/v2/providers/groq/client.py | 86 +- instructor/v2/providers/litellm/client.py | 26 + instructor/v2/providers/mistral/client.py | 120 +- instructor/v2/providers/mistral/handlers.py | 14 +- instructor/v2/providers/mistral/multimodal.py | 18 + instructor/v2/providers/openai/client.py | 212 +++ instructor/v2/providers/openai/handlers.py | 57 +- instructor/v2/providers/openai/multimodal.py | 15 + .../v2/providers/openrouter/__init__.py | 15 +- instructor/v2/providers/openrouter/client.py | 14 +- .../v2/providers/openrouter/handlers.py | 27 +- instructor/v2/providers/perplexity/client.py | 15 +- instructor/v2/providers/vertexai/client.py | 103 +- instructor/v2/providers/writer/client.py | 85 +- instructor/v2/providers/xai/client.py | 38 +- 51 files changed, 2007 insertions(+), 2372 deletions(-) create mode 100644 examples/v2-model-mode-benchmark/run.py create mode 100644 instructor/v2/core/client_factory.py diff --git a/examples/v2-model-mode-benchmark/run.py b/examples/v2-model-mode-benchmark/run.py new file mode 100644 index 000000000..2f83627bc --- /dev/null +++ b/examples/v2-model-mode-benchmark/run.py @@ -0,0 +1,383 @@ +"""Benchmark live v2 model/mode cells derived from the provider contract. + +The default run enumerates every provider with registered v2 handlers and each +of its declared modes. Cells without a configured model, optional SDK, or +credential are reported as skipped instead of failing the run. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass +import importlib.util +import json +import os +from pathlib import Path +import statistics +import time +from typing import Any + +import instructor +from pydantic import BaseModel + +from instructor.v2.core.errors import ConfigurationError +from instructor.v2.core.mode import Mode +from instructor.v2.core.provider_specs import ALIAS_TO_PROVIDER, PROVIDER_SPECS +from instructor.v2.core.providers import Provider + + +class Person(BaseModel): + """Small extraction target used for comparable smoke measurements.""" + + name: str + age: int + + +PROMPT = "Extract the person from this sentence: Jason is 36 years old." + +# API keys needed by model-string builders that can be safely preflighted. +# Cloud credential chains are opt-in through --allow-cloud-auth. +CREDENTIAL_ENV_VARS: dict[Provider, tuple[str, ...]] = { + Provider.OPENAI: ("OPENAI_API_KEY",), + Provider.ANYSCALE: ("ANYSCALE_API_KEY",), + Provider.TOGETHER: ("TOGETHER_API_KEY",), + Provider.DATABRICKS: ("DATABRICKS_TOKEN", "DATABRICKS_API_KEY"), + Provider.DEEPSEEK: ("DEEPSEEK_API_KEY",), + Provider.OPENROUTER: ("OPENROUTER_API_KEY",), + Provider.ANTHROPIC: ("ANTHROPIC_API_KEY",), + Provider.GENAI: ("GOOGLE_API_KEY",), + Provider.GEMINI: ("GOOGLE_API_KEY",), + Provider.COHERE: ("COHERE_API_KEY",), + Provider.PERPLEXITY: ("PERPLEXITY_API_KEY",), + Provider.XAI: ("XAI_API_KEY",), + Provider.GROQ: ("GROQ_API_KEY",), + Provider.MISTRAL: ("MISTRAL_API_KEY",), + Provider.FIREWORKS: ("FIREWORKS_API_KEY",), + Provider.CEREBRAS: ("CEREBRAS_API_KEY",), + Provider.WRITER: ("WRITER_API_KEY",), +} + + +@dataclass(frozen=True) +class BenchmarkCase: + provider: Provider + model: str | None + mode: Mode + + @property + def label(self) -> str: + return f"{self.model or self.provider.value + '/'} [{self.mode.name}]" + + +@dataclass(frozen=True) +class BenchmarkResult: + case: BenchmarkCase + status: str + successes: int + trials: int + latencies_ms: tuple[float, ...] = () + detail: str | None = None + + @property + def success_rate(self) -> float: + return self.successes / self.trials if self.trials else 0.0 + + @property + def median_ms(self) -> float | None: + if not self.latencies_ms: + return None + return statistics.median(self.latencies_ms) + + @property + def mean_ms(self) -> float | None: + if not self.latencies_ms: + return None + return statistics.fmean(self.latencies_ms) + + def as_dict(self) -> dict[str, Any]: + return { + "provider": self.case.provider.value, + "model": self.case.model, + "mode": self.case.mode.name, + "mode_value": self.case.mode.value, + "status": self.status, + "successes": self.successes, + "trials": self.trials, + "success_rate": self.success_rate, + "median_ms": self.median_ms, + "mean_ms": self.mean_ms, + "latencies_ms": list(self.latencies_ms), + "detail": self.detail, + } + + +def _benchmark_specs() -> dict[Provider, Any]: + return { + provider: spec + for provider, spec in PROVIDER_SPECS.items() + if spec.handler_module is not None and spec.supported_modes + } + + +def parse_mode(value: str) -> Mode: + """Accept either the enum name (`TOOLS`) or serialized mode value.""" + try: + return Mode[value.upper()] + except KeyError: + try: + return Mode(value) + except ValueError as exc: + choices = ", ".join(mode.name for mode in Mode) + raise argparse.ArgumentTypeError( + f"Unknown mode {value!r}; choose one of {choices}" + ) from exc + + +def _provider_for_model(model: str) -> Provider: + try: + alias, _ = model.split("/", 1) + except ValueError as exc: + raise ValueError(f"Model must be in provider/model form: {model!r}") from exc + provider = ALIAS_TO_PROVIDER.get(alias) + if provider is None: + raise ValueError(f"Unknown provider alias in model: {model!r}") + spec = PROVIDER_SPECS[provider] + if spec.handler_module is None or not spec.supported_modes: + raise ValueError( + f"Provider alias {alias!r} has no v2 capability contract to benchmark" + ) + return provider + + +def build_cases( + models: Sequence[str] = (), + modes: Sequence[Mode] = (), +) -> list[BenchmarkCase]: + """Build a complete provider-mode grid or a grid for explicit models.""" + specs = _benchmark_specs() + selected: list[tuple[Provider, str | None]] + if models: + selected = [(_provider_for_model(model), model) for model in models] + else: + selected = [ + (provider, spec.provider_string) for provider, spec in specs.items() + ] + + cases: list[BenchmarkCase] = [] + for provider, model in selected: + supported_modes = specs[provider].supported_modes + selected_modes = tuple(modes) if modes else supported_modes + cases.extend( + BenchmarkCase(provider=provider, model=model, mode=mode) + for mode in selected_modes + ) + return cases + + +def _module_available(module_name: str | None) -> bool: + if module_name is None: + return True + try: + return importlib.util.find_spec(module_name) is not None + except ModuleNotFoundError: + return False + + +def unavailable_reason( + case: BenchmarkCase, *, allow_cloud_auth: bool = False +) -> str | None: + """Return a skip reason before a live request is attempted.""" + spec = PROVIDER_SPECS[case.provider] + if case.model is None: + return "no default model configured; pass --model provider/model" + if case.mode not in spec.supported_modes: + return "mode is not declared as supported by this provider" + credentials = CREDENTIAL_ENV_VARS.get(case.provider) + if credentials is None and not allow_cloud_auth: + return "provider uses ambient/cloud credentials; pass --allow-cloud-auth" + if credentials and not any(os.environ.get(name) for name in credentials): + return f"missing credential: {' or '.join(credentials)}" + if not _module_available(spec.sdk_module): + return f"optional SDK is not installed: {spec.sdk_module}" + return None + + +def _short_error(exc: Exception) -> str: + message = " ".join(str(exc).split()) + return f"{type(exc).__name__}: {message[:180]}" + + +def run_case( + case: BenchmarkCase, + trials: int, + *, + client_factory: Callable[..., Any] = instructor.from_provider, + allow_cloud_auth: bool = False, +) -> BenchmarkResult: + """Run one live cell after preflight checks.""" + reason = unavailable_reason(case, allow_cloud_auth=allow_cloud_auth) + if reason is not None: + return BenchmarkResult(case, "skipped", 0, trials, detail=reason) + assert case.model is not None + + try: + client = client_factory(case.model, mode=case.mode) + except (ConfigurationError, ImportError, ModuleNotFoundError) as exc: + return BenchmarkResult(case, "skipped", 0, trials, detail=_short_error(exc)) + except Exception as exc: + return BenchmarkResult(case, "failed", 0, trials, detail=_short_error(exc)) + + durations: list[float] = [] + successes = 0 + errors: list[str] = [] + for _ in range(trials): + start = time.perf_counter() + try: + response_model = ( + Iterable[Person] if case.mode is Mode.PARALLEL_TOOLS else Person + ) + result = client.create( + response_model=response_model, + messages=[{"role": "user", "content": PROMPT}], + ) + people = list(result) if case.mode is Mode.PARALLEL_TOOLS else [result] + if len(people) == 1 and any( + person.name.strip().lower() == "jason" and person.age == 36 + for person in people + ): + successes += 1 + else: + errors.append( + f"unexpected result: {[person.model_dump() for person in people]!r}" + ) + except Exception as exc: + errors.append(_short_error(exc)) + durations.append((time.perf_counter() - start) * 1000) + + status = "passed" if successes == trials else "failed" + detail = "; ".join(errors[:2]) or None + return BenchmarkResult( + case=case, + status=status, + successes=successes, + trials=trials, + latencies_ms=tuple(durations), + detail=detail, + ) + + +def run_grid( + cases: Iterable[BenchmarkCase], + trials: int, + *, + client_factory: Callable[..., Any] = instructor.from_provider, + allow_cloud_auth: bool = False, +) -> list[BenchmarkResult]: + return [ + run_case( + case, + trials, + client_factory=client_factory, + allow_cloud_auth=allow_cloud_auth, + ) + for case in cases + ] + + +def render_markdown(results: Sequence[BenchmarkResult]) -> str: + """Render stable output that can be checked into a benchmark report.""" + completed = sorted( + (result for result in results if result.status != "skipped"), + key=lambda result: ( + -result.success_rate, + result.median_ms if result.median_ms is not None else float("inf"), + ), + ) + rows = ["# Instructor v2 model/mode benchmark", ""] + if completed: + rows.extend( + [ + "## Ranked completed cells", + "", + "| Rank | Model | Mode | Success | Median ms |", + "| ---: | --- | --- | ---: | ---: |", + ] + ) + for rank, result in enumerate(completed, start=1): + median = f"{result.median_ms:.1f}" if result.median_ms is not None else "-" + rows.append( + f"| {rank} | `{result.case.model}` | `{result.case.mode.name}` " + f"| {result.successes}/{result.trials} | {median} |" + ) + rows.extend(["", "## All cells", ""]) + rows.extend( + [ + "| Model | Mode | Status | Success | Median ms | Mean ms | Detail |", + "| --- | --- | --- | ---: | ---: | ---: | --- |", + ] + ) + for result in results: + median = f"{result.median_ms:.1f}" if result.median_ms is not None else "-" + mean = f"{result.mean_ms:.1f}" if result.mean_ms is not None else "-" + detail = (result.detail or "").replace("|", "\\|") + rows.append( + f"| `{result.case.model or result.case.provider.value + '/'}` " + f"| `{result.case.mode.name}` | {result.status} " + f"| {result.successes}/{result.trials} | {median} | {mean} | {detail} |" + ) + return "\n".join(rows) + "\n" + + +def _positive_int(value: str) -> int: + parsed = int(value) + if parsed < 1: + raise argparse.ArgumentTypeError("trials must be at least 1") + return parsed + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--model", + action="append", + default=[], + help="Limit the grid to a provider/model string; repeat for multiple models.", + ) + parser.add_argument( + "--mode", + action="append", + default=[], + type=parse_mode, + help="Limit each selected model to a mode name or value; repeat as needed.", + ) + parser.add_argument("--trials", type=_positive_int, default=3) + parser.add_argument( + "--allow-cloud-auth", + action="store_true", + help="Attempt providers that rely on ambient cloud credentials.", + ) + parser.add_argument("--json-out", type=Path) + parser.add_argument("--markdown-out", type=Path) + args = parser.parse_args(argv) + + results = run_grid( + build_cases(models=args.model, modes=args.mode), + trials=args.trials, + allow_cloud_auth=args.allow_cloud_auth, + ) + markdown = render_markdown(results) + print(markdown, end="") + + if args.json_out is not None: + args.json_out.write_text( + json.dumps([result.as_dict() for result in results], indent=2) + "\n", + encoding="utf-8", + ) + if args.markdown_out is not None: + args.markdown_out.write_text(markdown, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/instructor/batch/processor.py b/instructor/batch/processor.py index 1a2fccb6f..7e86fe1a5 100644 --- a/instructor/batch/processor.py +++ b/instructor/batch/processor.py @@ -95,7 +95,7 @@ def submit_batch( self, file_path_or_buffer: str | io.BytesIO, metadata: dict[str, Any] | None = None, - **kwargs, + **kwargs: Any, ) -> str: """Submit batch job to the provider and return job ID diff --git a/instructor/providers/anthropic/client.py b/instructor/providers/anthropic/client.py index 58691acea..7ada618d8 100644 --- a/instructor/providers/anthropic/client.py +++ b/instructor/providers/anthropic/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.anthropic.client``.""" -from instructor.v2.providers.anthropic.client import from_anthropic +from instructor.providers._compat import make_getattr __all__ = ["from_anthropic"] +__getattr__ = make_getattr("anthropic", ("client",)) diff --git a/instructor/providers/bedrock/client.py b/instructor/providers/bedrock/client.py index 7a6634baf..a3dbc83d9 100644 --- a/instructor/providers/bedrock/client.py +++ b/instructor/providers/bedrock/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.bedrock.client``.""" -from instructor.v2.providers.bedrock.client import from_bedrock +from instructor.providers._compat import make_getattr __all__ = ["from_bedrock"] +__getattr__ = make_getattr("bedrock", ("client",)) diff --git a/instructor/providers/cerebras/client.py b/instructor/providers/cerebras/client.py index 4569aafb4..ffd09f45e 100644 --- a/instructor/providers/cerebras/client.py +++ b/instructor/providers/cerebras/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.cerebras.client``.""" -from instructor.v2.providers.cerebras.client import from_cerebras +from instructor.providers._compat import make_getattr __all__ = ["from_cerebras"] +__getattr__ = make_getattr("cerebras", ("client",)) diff --git a/instructor/providers/cohere/client.py b/instructor/providers/cohere/client.py index ab5db396c..abc7fdac8 100644 --- a/instructor/providers/cohere/client.py +++ b/instructor/providers/cohere/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.cohere.client``.""" -from instructor.v2.providers.cohere.client import from_cohere +from instructor.providers._compat import make_getattr __all__ = ["from_cohere"] +__getattr__ = make_getattr("cohere", ("client",)) diff --git a/instructor/providers/fireworks/client.py b/instructor/providers/fireworks/client.py index a1e351f1a..c4af910c4 100644 --- a/instructor/providers/fireworks/client.py +++ b/instructor/providers/fireworks/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.fireworks.client``.""" -from instructor.v2.providers.fireworks.client import from_fireworks +from instructor.providers._compat import make_getattr __all__ = ["from_fireworks"] +__getattr__ = make_getattr("fireworks", ("client",)) diff --git a/instructor/providers/gemini/client.py b/instructor/providers/gemini/client.py index 8fd36e4f2..4e3a269c8 100644 --- a/instructor/providers/gemini/client.py +++ b/instructor/providers/gemini/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.gemini.client``.""" -from instructor.v2.providers.gemini.client import from_gemini +from instructor.providers._compat import make_getattr __all__ = ["from_gemini"] +__getattr__ = make_getattr("gemini", ("client",)) diff --git a/instructor/providers/genai/client.py b/instructor/providers/genai/client.py index 0a55fc60e..0a76881fe 100644 --- a/instructor/providers/genai/client.py +++ b/instructor/providers/genai/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.genai.client``.""" -from instructor.v2.providers.genai.client import from_genai +from instructor.providers._compat import make_getattr __all__ = ["from_genai"] +__getattr__ = make_getattr("genai", ("client",)) diff --git a/instructor/providers/groq/client.py b/instructor/providers/groq/client.py index 6bd9956c8..5a70048b4 100644 --- a/instructor/providers/groq/client.py +++ b/instructor/providers/groq/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.groq.client``.""" -from instructor.v2.providers.groq.client import from_groq +from instructor.providers._compat import make_getattr __all__ = ["from_groq"] +__getattr__ = make_getattr("groq", ("client",)) diff --git a/instructor/providers/mistral/client.py b/instructor/providers/mistral/client.py index a16e80535..9aa0f15e3 100644 --- a/instructor/providers/mistral/client.py +++ b/instructor/providers/mistral/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.mistral.client``.""" -from instructor.v2.providers.mistral.client import from_mistral +from instructor.providers._compat import make_getattr __all__ = ["from_mistral"] +__getattr__ = make_getattr("mistral", ("client",)) diff --git a/instructor/providers/perplexity/client.py b/instructor/providers/perplexity/client.py index 3d2e260ed..425a17706 100644 --- a/instructor/providers/perplexity/client.py +++ b/instructor/providers/perplexity/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.perplexity.client``.""" -from instructor.v2.providers.perplexity.client import from_perplexity +from instructor.providers._compat import make_getattr __all__ = ["from_perplexity"] +__getattr__ = make_getattr("perplexity", ("client",)) diff --git a/instructor/providers/vertexai/client.py b/instructor/providers/vertexai/client.py index 8c31cb06f..69f613c3c 100644 --- a/instructor/providers/vertexai/client.py +++ b/instructor/providers/vertexai/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.vertexai.client``.""" -from instructor.v2.providers.vertexai.client import from_vertexai +from instructor.providers._compat import make_getattr __all__ = ["from_vertexai"] +__getattr__ = make_getattr("vertexai", ("client",)) diff --git a/instructor/providers/writer/client.py b/instructor/providers/writer/client.py index 9a4af9e51..cca458f24 100644 --- a/instructor/providers/writer/client.py +++ b/instructor/providers/writer/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.writer.client``.""" -from instructor.v2.providers.writer.client import from_writer +from instructor.providers._compat import make_getattr __all__ = ["from_writer"] +__getattr__ = make_getattr("writer", ("client",)) diff --git a/instructor/providers/xai/client.py b/instructor/providers/xai/client.py index dfec66197..58855b0dc 100644 --- a/instructor/providers/xai/client.py +++ b/instructor/providers/xai/client.py @@ -1,5 +1,6 @@ """Compatibility facade for ``instructor.providers.xai.client``.""" -from instructor.v2.providers.xai.client import from_xai +from instructor.providers._compat import make_getattr __all__ = ["from_xai"] +__getattr__ = make_getattr("xai", ("client",)) diff --git a/instructor/v2/auto_client.py b/instructor/v2/auto_client.py index bc4f3fc67..268d2683e 100644 --- a/instructor/v2/auto_client.py +++ b/instructor/v2/auto_client.py @@ -1,25 +1,34 @@ from __future__ import annotations import importlib -from typing import Any, Callable, Literal, Union, cast, overload -from instructor.v2.core.client import AsyncInstructor, Instructor -from instructor import __version__ -from instructor.v2.core.mode import Mode -from instructor.models import KnownModelName -from instructor.cache import BaseCache -from instructor.v2.core.provider_specs import ALIAS_TO_PROVIDER -import warnings import logging +from collections.abc import Callable +from typing import Any, Literal, Protocol, Union, cast, overload -# Type alias for the return type -InstructorType = Union[Instructor, AsyncInstructor] +from instructor.cache import BaseCache +from instructor.models import KnownModelName +from instructor.v2.core.client import AsyncInstructor, Instructor +from instructor.v2.core.mode import Mode +from instructor.v2.core.provider_specs import ALIAS_TO_PROVIDER, PROVIDER_SPECS logger = logging.getLogger("instructor.auto_client") +class _BuilderModule(Protocol): + build_from_model: Callable[..., Instructor | AsyncInstructor] + + # Canonical strings and compatibility aliases accepted by from_provider(). supported_providers = list(ALIAS_TO_PROVIDER) +# Compatibility introspection for callers that previously inspected the private +# dispatch table. Runtime routing remains exclusively ProviderSpec-driven. +_PROVIDER_BUILDERS = { + alias: PROVIDER_SPECS[provider].model_builder_module + for alias, provider in ALIAS_TO_PROVIDER.items() + if PROVIDER_SPECS[provider].model_builder_module is not None +} + @overload def from_provider( @@ -138,8 +147,10 @@ def from_provider( extra=provider_info, ) - builder = _PROVIDER_BUILDERS.get(provider) - if builder is None: + provider_enum = ALIAS_TO_PROVIDER.get(provider) + spec = PROVIDER_SPECS.get(provider_enum) if provider_enum is not None else None + + if spec is None or spec.model_builder_module is None: from instructor.v2.core.errors import ConfigurationError logger.error( @@ -152,1457 +163,27 @@ def from_provider( f"Supported providers are: {supported_providers}" ) - return builder( - provider=provider, - model_name=model_name, - async_client=async_client, - mode=mode, - api_key=api_key, - kwargs=kwargs, - provider_info=provider_info, - ) - - -def _build_openai( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - import openai - import httpx - from openai import DEFAULT_MAX_RETRIES, NotGiven, Timeout, not_given - from collections.abc import Mapping - from typing import cast - except ImportError as err: - missing_root = (getattr(err, "name", "") or "").split(".")[0] - if missing_root not in {"openai", "httpx"}: - raise - - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The openai package is required to use the OpenAI provider. " - "Install it with `pip install openai`." - ) from None - - try: - # Extract base_url and other OpenAI client parameters from kwargs - base_url = kwargs.pop("base_url", None) - organization = cast(str | None, kwargs.pop("organization", None)) - - timeout_raw = kwargs.pop("timeout", not_given) - timeout: float | Timeout | None | NotGiven - timeout = ( - not_given - if timeout_raw is not_given - else cast(float | Timeout | None, timeout_raw) - ) - - max_retries_raw = kwargs.pop("max_retries", None) - max_retries = ( - DEFAULT_MAX_RETRIES - if max_retries_raw is None - else int(cast(int, max_retries_raw)) - ) - - default_headers = cast( - Mapping[str, str] | None, kwargs.pop("default_headers", None) - ) - default_query = cast( - Mapping[str, object] | None, kwargs.pop("default_query", None) - ) - http_client_raw = kwargs.pop("http_client", None) - strict_response_validation = bool( - kwargs.pop("_strict_response_validation", False) - ) - - if async_client: - http_client = cast(httpx.AsyncClient | None, http_client_raw) - client = openai.AsyncOpenAI( - api_key=api_key, - base_url=base_url, - organization=organization, - timeout=timeout, - max_retries=max_retries, - default_headers=default_headers, - default_query=default_query, - http_client=http_client, - _strict_response_validation=strict_response_validation, - ) - else: - http_client = cast(httpx.Client | None, http_client_raw) - client = openai.OpenAI( - api_key=api_key, - base_url=base_url, - organization=organization, - timeout=timeout, - max_retries=max_retries, - default_headers=default_headers, - default_query=default_query, - http_client=http_client, - _strict_response_validation=strict_response_validation, - ) - - import instructor - - result = instructor.from_openai( - client, - model=model_name, - mode=mode if mode else Mode.TOOLS, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_azure_openai( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - import os - from openai import AzureOpenAI, AsyncAzureOpenAI - from instructor.v2.providers.openai.client import from_openai - - # Get required Azure OpenAI configuration from environment - api_key = api_key or os.environ.get("AZURE_OPENAI_API_KEY") - azure_endpoint = kwargs.pop( - "azure_endpoint", os.environ.get("AZURE_OPENAI_ENDPOINT") - ) - api_version = kwargs.pop("api_version", "2024-02-01") - - if not api_key: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "AZURE_OPENAI_API_KEY is not set. " - "Set it with `export AZURE_OPENAI_API_KEY=` or pass it as kwarg api_key=" - ) - - if not azure_endpoint: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "AZURE_OPENAI_ENDPOINT is not set. " - "Set it with `export AZURE_OPENAI_ENDPOINT=` or pass it as kwarg azure_endpoint=" - ) - - client = ( - AsyncAzureOpenAI( - api_key=api_key, - api_version=api_version, - azure_endpoint=azure_endpoint, - ) - if async_client - else AzureOpenAI( - api_key=api_key, - api_version=api_version, - azure_endpoint=azure_endpoint, - ) - ) - result = from_openai( - client, - model=model_name, - mode=mode if mode else Mode.TOOLS, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The openai package is required to use the Azure OpenAI provider. " - "Install it with `pip install openai`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_openai_compatible( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], - env_var: str, - default_base_url: str, - factory_name: str, -) -> InstructorType: - try: - import os - import openai - from instructor.v2.providers.openai import client as openai_client - - api_key = api_key or os.environ.get(env_var) - if not api_key: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - f"{env_var} is not set. " - f"Set it with `export {env_var}=` or pass it as kwarg api_key=" - ) - - base_url = kwargs.pop("base_url", default_base_url) - client = ( - openai.AsyncOpenAI(api_key=api_key, base_url=base_url) - if async_client - else openai.OpenAI(api_key=api_key, base_url=base_url) - ) - factory = getattr(openai_client, factory_name) - result = factory( - client, - model=model_name, - mode=mode if mode else Mode.TOOLS, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - f"The openai package is required to use the {provider} provider. " - "Install it with `pip install openai`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_anyscale(**kwargs: Any) -> InstructorType: - return _build_openai_compatible( - **kwargs, - env_var="ANYSCALE_API_KEY", - default_base_url="https://api.endpoints.anyscale.com/v1", - factory_name="from_anyscale", - ) - - -def _build_together(**kwargs: Any) -> InstructorType: - return _build_openai_compatible( - **kwargs, - env_var="TOGETHER_API_KEY", - default_base_url="https://api.together.xyz/v1", - factory_name="from_together", - ) - - -def _build_databricks( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - import os - import openai - from instructor import from_openai - - api_key = ( - api_key - or os.environ.get("DATABRICKS_TOKEN") - or os.environ.get("DATABRICKS_API_KEY") - ) - if not api_key: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "DATABRICKS_TOKEN is not set. " - "Set it with `export DATABRICKS_TOKEN=` or `export DATABRICKS_API_KEY=` " - "or pass it as kwarg `api_key=`." - ) - - base_url = kwargs.pop("base_url", None) - if base_url is None: - base_url = ( - os.environ.get("DATABRICKS_BASE_URL") - or os.environ.get("DATABRICKS_HOST") - or os.environ.get("DATABRICKS_WORKSPACE_URL") - ) - - if not base_url: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "DATABRICKS_HOST is not set. " - "Set it with `export DATABRICKS_HOST=` or `export DATABRICKS_WORKSPACE_URL=` " - "or pass `base_url=`." - ) - - base_url = str(base_url).rstrip("/") - if not base_url.endswith("/serving-endpoints"): - base_url = f"{base_url}/serving-endpoints" - - openai_client_kwargs = {} - for key in ( - "organization", - "timeout", - "max_retries", - "default_headers", - "http_client", - "app_info", - ): - if key in kwargs: - openai_client_kwargs[key] = kwargs.pop(key) - - client = ( - openai.AsyncOpenAI( - api_key=api_key, base_url=base_url, **openai_client_kwargs - ) - if async_client - else openai.OpenAI( - api_key=api_key, base_url=base_url, **openai_client_kwargs - ) - ) - result = from_openai( - client, - model=model_name, - mode=mode if mode else Mode.TOOLS, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The openai package is required to use the Databricks provider. " - "Install it with `pip install openai`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_anthropic( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: try: - import anthropic - from instructor.v2.providers.anthropic.client import from_anthropic - - if from_anthropic is None: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "Failed to import Anthropic provider. " - "This may be due to a configuration error or missing dependencies." - ) - - client = ( - anthropic.AsyncAnthropic( - api_key=api_key, - default_headers={"User-Agent": f"instructor/{__version__}"}, - ) - if async_client - else anthropic.Anthropic( - api_key=api_key, - default_headers={"User-Agent": f"instructor/{__version__}"}, - ) + module = cast( + _BuilderModule, importlib.import_module(spec.model_builder_module) ) - # Set default max_tokens if not provided (like v1) - if "max_tokens" not in kwargs: - kwargs["max_tokens"] = 4096 - # Use Mode.TOOLS instead of Mode.ANTHROPIC_TOOLS - result = from_anthropic( - client, - model=model_name, - mode=mode if mode else Mode.TOOLS, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The anthropic package is required to use the Anthropic provider. " - "Install it with `pip install anthropic`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_google( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - # Import google-genai package - catch ImportError only for actual imports - try: - import google.genai as genai - except ImportError as e: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The google-genai package is required to use the Google provider. " - "Install it with `pip install google-genai`." - ) from e - - try: - import os - - # Remove vertexai from kwargs if present to avoid passing it twice - vertexai_flag = kwargs.pop("vertexai", False) - - # Get API key from kwargs or environment - api_key = api_key or os.environ.get("GOOGLE_API_KEY") - - # Extract client-specific parameters - client_kwargs = {} - for key in [ - "debug_config", - "http_options", - "credentials", - "project", - "location", - ]: - if key in kwargs: - client_kwargs[key] = kwargs.pop(key) - - client = genai.Client( - vertexai=vertexai_flag, + model_builder = module.build_from_model + result = model_builder( + provider=spec.provider, + model_name=model_name, + async_client=async_client, + mode=mode, api_key=api_key, - **client_kwargs, - ) - # Default to TOOLS for v2 - # Extract model from kwargs if present, otherwise use model_name - model_param = kwargs.pop("model", model_name) - import instructor - - result = instructor.from_genai( - client, - mode=mode if mode else Mode.TOOLS, - use_async=async_client, - model=model_param, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_gemini( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - import os - - genai = cast(Any, importlib.import_module("google.generativeai")) - from instructor.v2.providers.gemini.client import from_gemini - - api_key = api_key or os.environ.get("GOOGLE_API_KEY") - if api_key: - genai.configure(api_key=api_key) - - client = genai.GenerativeModel(model_name) - result = from_gemini( - client, - mode=mode if mode else Mode.MD_JSON, - use_async=async_client, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The google-generativeai package is required to use the Gemini provider. " - "Install it with `pip install google-generativeai`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_mistral( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, # noqa: ARG001 - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - from mistralai import Mistral - from instructor.v2.providers.mistral.client import from_mistral - import os - - api_key = api_key or os.environ.get("MISTRAL_API_KEY") - - if api_key: - client = Mistral(api_key=api_key) - else: - raise ValueError( - "MISTRAL_API_KEY is not set. " - "Set it with `export MISTRAL_API_KEY=`." - ) - - if async_client: - result = from_mistral(client, model=model_name, use_async=True, **kwargs) - else: - result = from_mistral(client, model=model_name, **kwargs) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The mistralai package is required to use the Mistral provider. " - "Install it with `pip install mistralai`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_cohere( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - import cohere - from instructor.v2.providers.cohere.client import from_cohere - - client = ( - cohere.AsyncClientV2(api_key=api_key) - if async_client - else cohere.ClientV2(api_key=api_key) - ) - # Use Mode.TOOLS as default for Cohere - result = from_cohere( - client, - mode=mode if mode else Mode.TOOLS, - model=model_name, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The cohere package is required to use the Cohere provider. " - "Install it with `pip install cohere`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_perplexity( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, # noqa: ARG001 - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - import openai - from instructor.v2.providers.perplexity.client import from_perplexity - import os - - api_key = api_key or os.environ.get("PERPLEXITY_API_KEY") - if not api_key: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "PERPLEXITY_API_KEY is not set. " - "Set it with `export PERPLEXITY_API_KEY=` or pass it as a kwarg api_key=" - ) - - client = ( - openai.AsyncOpenAI(api_key=api_key, base_url="https://api.perplexity.ai") - if async_client - else openai.OpenAI(api_key=api_key, base_url="https://api.perplexity.ai") - ) - result = from_perplexity(client, model=model_name, **kwargs) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, + kwargs=kwargs, ) + logger.info("Client initialized", extra={**provider_info, "status": "success"}) return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The openai package is required to use the Perplexity provider. " - "Install it with `pip install openai`." - ) from None - except Exception as e: + except Exception as exc: logger.error( "Error initializing %s client: %s", provider, - e, + exc, exc_info=True, extra={**provider_info, "status": "error"}, ) raise - - -def _build_groq( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, # noqa: ARG001 - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - import groq - from instructor.v2.providers.groq.client import from_groq - - client = ( - groq.AsyncGroq(api_key=api_key) - if async_client - else groq.Groq(api_key=api_key) - ) - result = from_groq(client, model=model_name, **kwargs) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The groq package is required to use the Groq provider. " - "Install it with `pip install groq`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_writer( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, # noqa: ARG001 - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - from writerai import AsyncWriter, Writer - from instructor.v2.providers.writer.client import from_writer - - client = ( - AsyncWriter(api_key=api_key) if async_client else Writer(api_key=api_key) - ) - result = from_writer(client, model=model_name, **kwargs) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The writerai package is required to use the Writer provider. " - "Install it with `pip install writer-sdk`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_bedrock( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, # noqa: ARG001 - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - import os - import boto3 - from instructor.v2.providers.bedrock.client import from_bedrock - - # Get AWS configuration from environment or kwargs - if "region" in kwargs: - region = kwargs.pop("region") - else: - logger.debug( - "AWS_DEFAULT_REGION is not set. Using default region us-east-1" - ) - region = os.environ.get("AWS_DEFAULT_REGION", "us-east-1") - - # Extract AWS-specific parameters - # Dictionary to collect AWS credentials and session parameters for boto3 client - aws_kwargs = {} - for key in [ - "aws_access_key_id", - "aws_secret_access_key", - "aws_session_token", - ]: - if key in kwargs: - aws_kwargs[key] = kwargs.pop(key) - elif key.upper() in os.environ: - logger.debug(f"Using {key.upper()} from environment variable") - aws_kwargs[key] = os.environ[key.upper()] - - # Add region to client configuration - aws_kwargs["region_name"] = region - - # Create bedrock-runtime client - client = boto3.client("bedrock-runtime", **aws_kwargs) - - # Determine default mode based on model - if mode is None: - # Anthropic models (Claude) support tools, others use JSON - if model_name and ( - "anthropic" in model_name.lower() or "claude" in model_name.lower() - ): - default_mode = Mode.TOOLS - else: - default_mode = Mode.MD_JSON - else: - default_mode = mode - - result = from_bedrock( - client, - mode=default_mode, - async_client=async_client, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The boto3 package is required to use the AWS Bedrock provider. " - "Install it with `pip install boto3`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_cerebras( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, # noqa: ARG001 - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - from cerebras.cloud.sdk import AsyncCerebras, Cerebras - from instructor.v2.providers.cerebras.client import from_cerebras - - client = ( - AsyncCerebras(api_key=api_key) - if async_client - else Cerebras(api_key=api_key) - ) - result = from_cerebras(client, model=model_name, **kwargs) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The cerebras package is required to use the Cerebras provider. " - "Install it with `pip install cerebras`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_fireworks( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, # noqa: ARG001 - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - from fireworks.client import AsyncFireworks, Fireworks - from instructor.v2.providers.fireworks.client import from_fireworks - - client = ( - AsyncFireworks(api_key=api_key) - if async_client - else Fireworks(api_key=api_key) - ) - result = from_fireworks(client, model=model_name, **kwargs) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The fireworks-ai package is required to use the Fireworks provider. " - "Install it with `pip install fireworks-ai`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_vertexai( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, # noqa: ARG001 - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - warnings.warn( - "The 'vertexai' provider is deprecated. Use 'google' provider with vertexai=True instead. " - "Example: instructor.from_provider('google/gemini-pro', vertexai=True)", - DeprecationWarning, - stacklevel=2, - ) - # Import Vertex AI SDK - try: - import vertexai - import vertexai.generative_models as gm - except ImportError as e: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The vertexai package is required to use the VertexAI provider. " - "Install it with `pip install google-cloud-aiplatform`." - ) from e - - try: - import os - - # Get project and location from kwargs or environment - project = kwargs.pop("project", os.environ.get("GOOGLE_CLOUD_PROJECT")) - location = kwargs.pop( - "location", os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1") - ) - - if not project: - raise ValueError( - "Project ID is required for Vertex AI. " - "Set it with `export GOOGLE_CLOUD_PROJECT=` " - "or pass it as kwarg project=" - ) - - credentials = kwargs.pop("credentials", None) - vertexai.init(project=project, location=location, credentials=credentials) - - client = gm.GenerativeModel(model_name) - import instructor - - result = instructor.from_vertexai( - client, - use_async=async_client, - mode=mode if mode else Mode.TOOLS, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_generative_ai( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - warnings.warn( - "The 'generative-ai' provider is deprecated. Use 'google' provider instead. " - "Example: instructor.from_provider('google/gemini-pro')", - DeprecationWarning, - stacklevel=2, - ) - # Import google-genai package - catch ImportError only for actual imports - try: - from google import genai - from instructor.v2.providers.genai.client import from_genai - except ImportError as e: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The google-genai package is required to use the Google GenAI provider. " - "Install it with `pip install google-genai`." - ) from e - - try: - import os - - # Get API key from kwargs or environment - api_key = api_key or os.environ.get("GOOGLE_API_KEY") - - client = genai.Client(vertexai=False, api_key=api_key) - if async_client: - result = from_genai( - client, - use_async=True, - model=model_name, - mode=mode if mode else Mode.TOOLS, - **kwargs, - ) - else: - result = from_genai( - client, - model=model_name, - mode=mode if mode else Mode.TOOLS, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_ollama( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - import openai - from instructor.v2.providers.openai.client import from_openai - - # Get base_url from kwargs or use default - base_url = kwargs.pop("base_url", "http://localhost:11434/v1") - api_key = kwargs.pop("api_key", "ollama") # required but unused - - client = ( - openai.AsyncOpenAI(base_url=base_url, api_key=api_key) - if async_client - else openai.OpenAI(base_url=base_url, api_key=api_key) - ) - - # Models that support function calling (tools mode) - tool_capable_models = { - "llama3.1", - "llama3.2", - "llama4", - "mistral-nemo", - "firefunction-v2", - "command-a", - "command-r", - "command-r-plus", - "command-r7b", - "qwen2.5", - "qwen2.5-coder", - "qwen3", - "devstral", - } - - # Check if model supports tools by looking at model name - supports_tools = any( - capable_model in model_name.lower() for capable_model in tool_capable_models - ) - - default_mode = Mode.TOOLS if supports_tools else Mode.JSON - - result = from_openai( - client, - model=model_name, - mode=mode if mode else default_mode, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The openai package is required to use the Ollama provider. " - "Install it with `pip install openai`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_deepseek( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - import openai - from instructor.v2.providers.openai.client import from_deepseek - import os - - # Get API key from kwargs or environment - api_key = api_key or os.environ.get("DEEPSEEK_API_KEY") - - if not api_key: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "DEEPSEEK_API_KEY is not set. " - "Set it with `export DEEPSEEK_API_KEY=` or pass it as kwarg api_key=" - ) - - # DeepSeek uses OpenAI-compatible API - base_url = kwargs.pop("base_url", "https://api.deepseek.com") - - client = ( - openai.AsyncOpenAI(api_key=api_key, base_url=base_url) - if async_client - else openai.OpenAI(api_key=api_key, base_url=base_url) - ) - - result = from_deepseek( - client, - model=model_name, - mode=mode if mode else Mode.TOOLS, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The openai package is required to use the DeepSeek provider. " - "Install it with `pip install openai`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_xai( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - from xai_sdk.sync.client import Client as SyncClient - from xai_sdk.aio.client import Client as AsyncClient - from instructor.v2.providers.xai.client import from_xai - - if from_xai is None: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "Failed to import xAI provider. " - "This may be due to a configuration error or missing dependencies." - ) - - client = ( - AsyncClient(api_key=api_key) - if async_client - else SyncClient(api_key=api_key) - ) - # Use Mode.TOOLS instead of Mode.XAI_TOOLS (v2 uses generic modes) - result = from_xai( - client, - mode=mode if mode else Mode.TOOLS, - model=model_name, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The xAI provider needs the optional dependency `xai-sdk`. " - 'Install it with `uv pip install "instructor[xai]"` (or `pip install "instructor[xai]"`). ' - "Note: xai-sdk requires Python 3.10+." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_openrouter( - *, - provider: str, - model_name: str, - async_client: bool, - mode: Mode | None, - api_key: str | None, - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - import openai - from instructor.v2.providers.openrouter.client import from_openrouter - import os - - # Get API key from kwargs or environment - api_key = api_key or os.environ.get("OPENROUTER_API_KEY") - - if not api_key: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "OPENROUTER_API_KEY is not set. " - "Set it with `export OPENROUTER_API_KEY=` or pass it as kwarg api_key=" - ) - - # OpenRouter uses OpenAI-compatible API - base_url = kwargs.pop("base_url", "https://openrouter.ai/api/v1") - - client = ( - openai.AsyncOpenAI(api_key=api_key, base_url=base_url) - if async_client - else openai.OpenAI(api_key=api_key, base_url=base_url) - ) - - result = from_openrouter( - client, - model=model_name, - mode=mode if mode else Mode.TOOLS, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The openai package is required to use the OpenRouter provider. " - "Install it with `pip install openai`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -def _build_litellm( - *, - provider: str, - model_name: str, # noqa: ARG001 - async_client: bool, - mode: Mode | None, - api_key: str | None, # noqa: ARG001 - kwargs: dict[str, Any], - provider_info: dict[str, str], -) -> InstructorType: - try: - from litellm import completion, acompletion - from instructor.v2.providers.litellm.client import from_litellm - - completion_func = acompletion if async_client else completion - result = from_litellm( - completion_func, - mode=mode if mode else Mode.TOOLS, - **kwargs, - ) - logger.info( - "Client initialized", - extra={**provider_info, "status": "success"}, - ) - return result - except ImportError: - from instructor.v2.core.errors import ConfigurationError - - raise ConfigurationError( - "The litellm package is required to use the LiteLLM provider. " - "Install it with `pip install litellm`." - ) from None - except Exception as e: - logger.error( - "Error initializing %s client: %s", - provider, - e, - exc_info=True, - extra={**provider_info, "status": "error"}, - ) - raise - - -ProviderBuilder = Callable[..., InstructorType] - -_PROVIDER_BUILDERS: dict[str, ProviderBuilder] = { - "openai": _build_openai, - "anyscale": _build_anyscale, - "together": _build_together, - "azure_openai": _build_azure_openai, - "databricks": _build_databricks, - "anthropic": _build_anthropic, - "google": _build_google, - "gemini": _build_gemini, - "mistral": _build_mistral, - "cohere": _build_cohere, - "perplexity": _build_perplexity, - "groq": _build_groq, - "writer": _build_writer, - "bedrock": _build_bedrock, - "cerebras": _build_cerebras, - "fireworks": _build_fireworks, - "vertexai": _build_vertexai, - "generative-ai": _build_generative_ai, - "ollama": _build_ollama, - "deepseek": _build_deepseek, - "xai": _build_xai, - "openrouter": _build_openrouter, - "litellm": _build_litellm, -} diff --git a/instructor/v2/core/client_factory.py b/instructor/v2/core/client_factory.py new file mode 100644 index 000000000..e222011b3 --- /dev/null +++ b/instructor/v2/core/client_factory.py @@ -0,0 +1,187 @@ +"""Shared native-client validation and Instructor construction.""" + +from __future__ import annotations + +import importlib +import inspect +from collections.abc import Callable +from functools import cache +from typing import Any + +from instructor.v2.core.client import AsyncInstructor, Instructor +from instructor.v2.core.errors import ClientError, ModeError +from instructor.v2.core.mode import Mode +from instructor.v2.core.patch import patch_v2 +from instructor.v2.core.provider_specs import PROVIDER_SPECS, ClientSpec +from instructor.v2.core.providers import Provider +from instructor.v2.core.registry import mode_registry, normalize_mode + + +@cache +def _resolve_type(path: str) -> type[Any]: + module_name, name = path.rsplit(".", 1) + module = importlib.import_module(module_name) + value = getattr(module, name) + if not isinstance(value, type): + raise TypeError(f"{path} does not resolve to a type") + return value + + +def _resolve_types(paths: tuple[str, ...], message: str) -> tuple[type[Any], ...]: + try: + return tuple(_resolve_type(path) for path in paths) + except (ImportError, ModuleNotFoundError, AttributeError) as exc: + raise ClientError(message) from exc + + +def _resolve_method(client: Any, path: str) -> Callable[..., Any]: + value = client + try: + for part in path.split("."): + value = getattr(value, part) + except AttributeError as exc: + raise ClientError( + f"Client {type(client).__name__} does not provide method {path!r}" + ) from exc + if not callable(value): + raise ClientError(f"Client method {path!r} is not callable") + return value + + +def _validate_mode(provider: Provider, mode: Mode) -> Mode: + normalized = normalize_mode(provider, mode) + if mode_registry.is_registered(provider, normalized): + return normalized + raise ModeError( + mode=str(mode.value), + provider=str(provider.value), + valid_modes=[ + str(item.value) for item in mode_registry.get_modes_for_provider(provider) + ], + ) + + +def _client_error(types: tuple[type[Any], ...], client: Any) -> ClientError: + names = ", ".join(item.__name__ for item in types) + return ClientError( + f"Client must be an instance of one of: {names}. Got: {type(client).__name__}" + ) + + +def _stream_switch( + create: Callable[..., Any], + stream: Callable[..., Any] | None, + *, + is_async: bool, + default_model: str | None = None, + falsey_model_fallback: bool = False, +) -> Callable[..., Any]: + if stream is None and not is_async and not falsey_model_fallback: + return create + + def prepare(kwargs: dict[str, Any]) -> None: + if falsey_model_fallback and not kwargs.get("model"): + kwargs["model"] = default_model or "" + + if is_async: + + async def async_create(*args: Any, **kwargs: Any) -> Any: + prepare(kwargs) + wants_stream = kwargs.pop("stream", False) if stream is not None else False + selected = stream if wants_stream and stream is not None else create + result = selected(*args, **kwargs) + return await result if inspect.isawaitable(result) else result + + return async_create + + def sync_create(*args: Any, **kwargs: Any) -> Any: + prepare(kwargs) + if kwargs.pop("stream", False): + assert stream is not None + return stream(*args, **kwargs) + return create(*args, **kwargs) + + return sync_create + + +def create_instructor( + client: Any, + *, + provider: Provider, + mode: Mode, + model: str | None = None, + use_async: bool | None = None, + create_path: str | None = None, + async_create_path: str | None = None, + stream_path: str | None = None, + async_stream_path: str | None = None, + sync_types: tuple[type[Any], ...] | None = None, + async_types: tuple[type[Any], ...] | None = None, + **kwargs: Any, +) -> Instructor | AsyncInstructor: + """Build a sync or async wrapper from a provider's declarative client spec.""" + provider_spec = PROVIDER_SPECS[provider] + contract = provider_spec.client + if contract is None: + raise ClientError(f"Provider {provider.value} has no native client contract") + + message = provider_spec.missing_sdk_message or ( + f"{provider_spec.sdk_module or provider.value} is not installed" + ) + if contract.validation_order == "mode-first": + normalized_mode = _validate_mode(provider, mode) + resolved_sync_types = sync_types or _resolve_types(contract.sync_types, message) + resolved_async_types = async_types or ( + _resolve_types(contract.async_types, message) if contract.async_types else () + ) + if contract.validation_order == "dependency-first": + normalized_mode = _validate_mode(provider, mode) + valid_types = (*resolved_sync_types, *resolved_async_types) + if not isinstance(client, valid_types): + raise _client_error(valid_types, client) + if contract.validation_order == "client-first": + normalized_mode = _validate_mode(provider, mode) + + is_async = ( + use_async + if use_async is not None + else bool(resolved_async_types and isinstance(client, resolved_async_types)) + ) + + if is_async: + selected_create_path = ( + async_create_path or create_path or contract.async_create or contract.create + ) + selected_stream_path = async_stream_path or stream_path or contract.async_stream + else: + selected_create_path = create_path or contract.create + selected_stream_path = stream_path or contract.stream + create = _resolve_method(client, selected_create_path) + stream = ( + _resolve_method(client, selected_stream_path) + if selected_stream_path is not None + else None + ) + patched = patch_v2( + func=_stream_switch( + create, + stream, + is_async=is_async, + default_model=model, + falsey_model_fallback=contract.falsey_model_fallback, + ), + provider=provider, + mode=normalized_mode, + default_model=model, + ) + wrapper = AsyncInstructor if is_async else Instructor + return wrapper( + client=client, + create=patched, + provider=provider, + mode=normalized_mode, + **kwargs, + ) + + +__all__ = ["ClientSpec", "create_instructor"] diff --git a/instructor/v2/core/errors.py b/instructor/v2/core/errors.py index 43bd30be0..430999050 100644 --- a/instructor/v2/core/errors.py +++ b/instructor/v2/core/errors.py @@ -2,7 +2,9 @@ from textwrap import dedent from typing import Any, NamedTuple + from jinja2 import Template +from typing_extensions import Self class InstructorError(Exception): @@ -46,7 +48,7 @@ class InstructorError(Exception): @classmethod def from_exception( cls, exception: Exception, failed_attempts: list[FailedAttempt] | None = None - ): + ) -> Self: """Create an InstructorError from another exception. Args: diff --git a/instructor/v2/core/multimodal.py b/instructor/v2/core/multimodal.py index 6a76e5104..928e10db3 100644 --- a/instructor/v2/core/multimodal.py +++ b/instructor/v2/core/multimodal.py @@ -677,6 +677,11 @@ def to_bedrock(self, name: str | None = None) -> dict[str, Any]: class PDFWithCacheControl(PDF): """PDF with Anthropic prompt caching support.""" + cache_control: OptionalCacheControlType = Field( + default_factory=lambda: {"type": "ephemeral"}, + description="Optional Anthropic cache control document", + ) + def to_anthropic(self) -> dict[str, Any]: from instructor.v2.providers.anthropic.multimodal import ( pdf_with_cache_control_to_anthropic, @@ -716,6 +721,7 @@ def convert_contents( list[Union[str, dict[str, Any], Image, Audio, PDF]], # noqa: UP007 ], mode: Mode, + media_converter: Callable[[Image | Audio | PDF], dict[str, Any]] | None = None, ) -> Union[str, list[dict[str, Any]]]: # noqa: UP007 """Convert content items to the appropriate format based on the specified mode.""" if isinstance(contents, str): @@ -735,7 +741,9 @@ def convert_contents( elif isinstance(content, dict): converted_contents.append(content) elif isinstance(content, (Image, Audio, PDF)): - if mode in { + if media_converter is not None: + converted_contents.append(media_converter(content)) + elif mode in { Mode.ANTHROPIC_JSON, Mode.ANTHROPIC_TOOLS, Mode.ANTHROPIC_REASONING_TOOLS, @@ -811,9 +819,12 @@ def convert_messages( ], mode: Mode, autodetect_images: bool = False, + media_converter: Callable[[Image | Audio | PDF], dict[str, Any]] | None = None, + image_param_converter: Callable[[ImageParams], Image] | None = None, ) -> list[dict[str, Any]]: """Convert messages to the appropriate format based on the specified mode.""" converted_messages: list[dict[str, Any]] = [] + image_factory = image_param_converter or ImageWithCacheControl.from_image_params def is_image_params(x: Any) -> bool: return isinstance(x, dict) and x.get("type") == "image" and "source" in x @@ -837,27 +848,28 @@ def is_image_params(x: Any) -> bool: if isinstance(item, str): new_content.append(autodetect_media(item)) elif is_image_params(item): - new_content.append( - ImageWithCacheControl.from_image_params( - cast(ImageParams, item) - ) - ) + new_content.append(image_factory(cast(ImageParams, item))) else: new_content.append(item) content = new_content elif isinstance(content, str): content = autodetect_media(content) elif is_image_params(content): - content = ImageWithCacheControl.from_image_params( - cast(ImageParams, content) - ) + content = image_factory(cast(ImageParams, content)) if isinstance(content, str): converted_messages.append( {"role": role, "content": content, **other_kwargs} ) else: # At this point content is narrowed to non-str types accepted by convert_contents - converted_content = convert_contents(content, mode) + if media_converter is None: + converted_content = convert_contents(content, mode) + else: + converted_content = convert_contents( + content, + mode, + media_converter=media_converter, + ) converted_messages.append( {"role": role, "content": converted_content, **other_kwargs} ) diff --git a/instructor/v2/core/provider_specs.py b/instructor/v2/core/provider_specs.py index 3bc375fe6..77f150628 100644 --- a/instructor/v2/core/provider_specs.py +++ b/instructor/v2/core/provider_specs.py @@ -5,11 +5,45 @@ from collections.abc import Mapping from dataclasses import dataclass from types import MappingProxyType +from typing import Literal from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider +@dataclass(frozen=True) +class ProviderCapabilities: + """User-visible provider features backed by deterministic conformance tests. + + ``multimodal_inputs`` describes typed v2 media that Instructor converts into + provider wire formats, not media a user may pre-encode for an SDK directly. + """ + + partial_stream_modes: tuple[Mode, ...] = () + iterable_stream_modes: tuple[Mode, ...] = () + multimodal_inputs: tuple[str, ...] = () + explicit_parallel_tools: bool = False + + +_NO_CAPABILITIES = ProviderCapabilities() + + +@dataclass(frozen=True) +class ClientSpec: + """Declarative native-client contract used by the shared factory runtime.""" + + sync_types: tuple[str, ...] + create: str + async_types: tuple[str, ...] = () + async_create: str | None = None + stream: str | None = None + async_stream: str | None = None + falsey_model_fallback: bool = False + validation_order: Literal["dependency-first", "mode-first", "client-first"] = ( + "dependency-first" + ) + + @dataclass(frozen=True) class ProviderSpec: provider: Provider @@ -22,10 +56,18 @@ class ProviderSpec: from_function: str | None client_module: str | None sdk_module: str | None + capabilities: ProviderCapabilities = _NO_CAPABILITIES + builder_module: str | None = None provider_string: str | None = None basic_modes: tuple[Mode, ...] = () async_modes: tuple[Mode, ...] = () missing_sdk_message: str | None = None + client: ClientSpec | None = None + + @property + def model_builder_module(self) -> str | None: + """Resolve the lazily imported model builder module by convention.""" + return self.builder_module or self.client_module def _spec( @@ -40,10 +82,13 @@ def _spec( from_function: str | None, client_module: str | None, sdk_module: str | None, + capabilities: ProviderCapabilities = _NO_CAPABILITIES, + builder_module: str | None = None, provider_string: str | None = None, basic_modes: tuple[Mode, ...] = (), async_modes: tuple[Mode, ...] = (), missing_sdk_message: str | None = None, + client: ClientSpec | None = None, ) -> ProviderSpec: return ProviderSpec( provider=provider, @@ -56,10 +101,13 @@ def _spec( from_function=from_function, client_module=client_module, sdk_module=sdk_module, + capabilities=capabilities, + builder_module=builder_module, provider_string=provider_string, basic_modes=basic_modes, async_modes=async_modes, missing_sdk_message=missing_sdk_message, + client=client, ) @@ -76,6 +124,18 @@ def _spec( Mode.TOOLS_STRICT: Mode.TOOLS, Mode.JSON_O1: Mode.JSON_SCHEMA, } +_OPENAI_MULTIMODAL_INPUTS = ("image", "audio", "pdf") +_OPENAI_COMPAT_CAPABILITIES = ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS, Mode.JSON, Mode.JSON_SCHEMA, Mode.MD_JSON), + iterable_stream_modes=(Mode.TOOLS,), + multimodal_inputs=_OPENAI_MULTIMODAL_INPUTS, + explicit_parallel_tools=True, +) +_OPENAI_CLIENT = ClientSpec( + sync_types=("openai.OpenAI", "openai.AzureOpenAI"), + async_types=("openai.AsyncOpenAI", "openai.AsyncAzureOpenAI"), + create="chat.completions.create", +) def _openai_compat_spec( @@ -94,6 +154,8 @@ def _openai_compat_spec( from_function=from_function, client_module="instructor.v2.providers.openai.client", sdk_module="openai", + capabilities=_OPENAI_COMPAT_CAPABILITIES, + client=_OPENAI_CLIENT, ) @@ -121,9 +183,22 @@ def _openai_compat_spec( from_function="from_openai", client_module="instructor.v2.providers.openai.client", sdk_module="openai", + capabilities=ProviderCapabilities( + partial_stream_modes=( + Mode.TOOLS, + Mode.JSON, + Mode.JSON_SCHEMA, + Mode.MD_JSON, + Mode.RESPONSES_TOOLS, + ), + iterable_stream_modes=(Mode.TOOLS, Mode.RESPONSES_TOOLS), + multimodal_inputs=_OPENAI_MULTIMODAL_INPUTS, + explicit_parallel_tools=True, + ), provider_string="openai/gpt-4o-mini", basic_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), async_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + client=_OPENAI_CLIENT, ), Provider.ANYSCALE: _openai_compat_spec( Provider.ANYSCALE, @@ -165,6 +240,13 @@ def _openai_compat_spec( from_function="from_openrouter", client_module="instructor.v2.providers.openrouter.client", sdk_module="openai", + capabilities=ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + iterable_stream_modes=(Mode.TOOLS,), + multimodal_inputs=_OPENAI_MULTIMODAL_INPUTS, + explicit_parallel_tools=True, + ), + client=_OPENAI_CLIENT, ), Provider.ANTHROPIC: _spec( Provider.ANTHROPIC, @@ -186,9 +268,31 @@ def _openai_compat_spec( from_function="from_anthropic", client_module="instructor.v2.providers.anthropic.client", sdk_module="anthropic", + capabilities=ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS, Mode.JSON, Mode.JSON_SCHEMA), + iterable_stream_modes=(Mode.TOOLS,), + multimodal_inputs=("image", "pdf"), + explicit_parallel_tools=True, + ), provider_string="anthropic/claude-sonnet-4-6", basic_modes=(Mode.TOOLS, Mode.JSON_SCHEMA), async_modes=(Mode.TOOLS, Mode.JSON_SCHEMA), + missing_sdk_message=( + "anthropic is not installed. Install it with: pip install anthropic" + ), + client=ClientSpec( + sync_types=( + "anthropic.Anthropic", + "anthropic.AnthropicBedrock", + "anthropic.AnthropicVertex", + ), + async_types=( + "anthropic.AsyncAnthropic", + "anthropic.AsyncAnthropicBedrock", + "anthropic.AsyncAnthropicVertex", + ), + create="messages.create", + ), ), Provider.GENAI: _spec( Provider.GENAI, @@ -204,9 +308,26 @@ def _openai_compat_spec( from_function="from_genai", client_module="instructor.v2.providers.genai.client", sdk_module="google.genai", + capabilities=ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS, Mode.JSON), + iterable_stream_modes=(Mode.TOOLS, Mode.JSON), + multimodal_inputs=("image", "audio", "pdf"), + ), provider_string="google/gemini-3.5-flash", basic_modes=(Mode.TOOLS, Mode.JSON), async_modes=(Mode.TOOLS, Mode.JSON), + missing_sdk_message=( + "google-genai is not installed. Install it with: pip install google-genai" + ), + client=ClientSpec( + sync_types=("google.genai.Client",), + create="models.generate_content", + async_create="aio.models.generate_content", + stream="models.generate_content_stream", + async_stream="aio.models.generate_content_stream", + falsey_model_fallback=True, + validation_order="client-first", + ), ), Provider.GENERATIVE_AI: _spec( Provider.GENERATIVE_AI, @@ -223,6 +344,7 @@ def _openai_compat_spec( from_function=None, client_module=None, sdk_module="google.genai", + builder_module="instructor.v2.providers.genai.client", ), Provider.GEMINI: _spec( Provider.GEMINI, @@ -242,6 +364,19 @@ def _openai_compat_spec( from_function="from_gemini", client_module="instructor.v2.providers.gemini.client", sdk_module="google.generativeai", + capabilities=ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS, Mode.MD_JSON), + ), + missing_sdk_message=( + "google-generativeai is not installed. Install it with: " + "pip install google-generativeai" + ), + client=ClientSpec( + sync_types=("google.generativeai.GenerativeModel",), + create="generate_content", + async_create="generate_content_async", + validation_order="mode-first", + ), ), Provider.COHERE: _spec( Provider.COHERE, @@ -256,9 +391,23 @@ def _openai_compat_spec( from_function="from_cohere", client_module="instructor.v2.providers.cohere.client", sdk_module="cohere", + capabilities=ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS,), + iterable_stream_modes=(Mode.TOOLS,), + ), provider_string="cohere/command-a-03-2025", basic_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), async_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + missing_sdk_message=( + "cohere is not installed. Install it with: pip install cohere" + ), + client=ClientSpec( + sync_types=("cohere.Client", "cohere.ClientV2"), + async_types=("cohere.AsyncClient", "cohere.AsyncClientV2"), + create="chat", + stream="chat_stream", + async_stream="chat_stream", + ), ), Provider.PERPLEXITY: _spec( Provider.PERPLEXITY, @@ -276,6 +425,10 @@ def _openai_compat_spec( from_function="from_perplexity", client_module="instructor.v2.providers.perplexity.client", sdk_module="openai", + capabilities=ProviderCapabilities( + multimodal_inputs=_OPENAI_MULTIMODAL_INPUTS, + ), + client=_OPENAI_CLIENT, ), Provider.XAI: _spec( Provider.XAI, @@ -292,6 +445,11 @@ def _openai_compat_spec( from_function="from_xai", client_module="instructor.v2.providers.xai.client", sdk_module="xai_sdk", + capabilities=ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS, Mode.JSON_SCHEMA), + iterable_stream_modes=(Mode.TOOLS, Mode.JSON_SCHEMA), + explicit_parallel_tools=True, + ), provider_string="xai/grok-4.20-reasoning", basic_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), async_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), @@ -306,9 +464,22 @@ def _openai_compat_spec( from_function="from_groq", client_module="instructor.v2.providers.groq.client", sdk_module="groq", + capabilities=ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + iterable_stream_modes=(Mode.TOOLS,), + multimodal_inputs=_OPENAI_MULTIMODAL_INPUTS, + ), provider_string="groq/llama-3.3-70b-versatile", basic_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), async_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + missing_sdk_message=( + "groq is not installed. Install it with: pip install groq" + ), + client=ClientSpec( + sync_types=("groq.Groq",), + async_types=("groq.AsyncGroq",), + create="chat.completions.create", + ), ), Provider.MISTRAL: _spec( Provider.MISTRAL, @@ -323,9 +494,24 @@ def _openai_compat_spec( from_function="from_mistral", client_module="instructor.v2.providers.mistral.client", sdk_module="mistralai", + capabilities=ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + iterable_stream_modes=(Mode.TOOLS,), + multimodal_inputs=("image", "audio", "pdf"), + ), provider_string="mistral/ministral-8b-latest", basic_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), async_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + missing_sdk_message=( + "mistralai is not installed. Install it with: pip install mistralai" + ), + client=ClientSpec( + sync_types=("mistralai.Mistral",), + create="chat.complete", + async_create="chat.complete_async", + stream="chat.stream", + async_stream="chat.stream_async", + ), ), Provider.FIREWORKS: _spec( Provider.FIREWORKS, @@ -340,9 +526,23 @@ def _openai_compat_spec( from_function="from_fireworks", client_module="instructor.v2.providers.fireworks.client", sdk_module="fireworks", + capabilities=ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + iterable_stream_modes=(Mode.TOOLS,), + multimodal_inputs=_OPENAI_MULTIMODAL_INPUTS, + ), provider_string="fireworks/accounts/fireworks/models/kimi-k2p5", basic_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), async_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + missing_sdk_message=( + "fireworks is not installed. Install it with: pip install fireworks-ai" + ), + client=ClientSpec( + sync_types=("fireworks.client.Fireworks",), + async_types=("fireworks.client.AsyncFireworks",), + create="chat.completions.create", + async_create="chat.completions.acreate", + ), ), Provider.CEREBRAS: _spec( Provider.CEREBRAS, @@ -362,10 +562,24 @@ def _openai_compat_spec( from_function="from_cerebras", client_module="instructor.v2.providers.cerebras.client", sdk_module="cerebras.cloud.sdk", + capabilities=ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + iterable_stream_modes=(Mode.TOOLS,), + multimodal_inputs=_OPENAI_MULTIMODAL_INPUTS, + explicit_parallel_tools=True, + ), provider_string="cerebras/gpt-oss-120b", basic_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), async_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), - missing_sdk_message="cerebras is not installed", + missing_sdk_message=( + "cerebras is not installed. Install it with: " + "pip install cerebras-cloud-sdk" + ), + client=ClientSpec( + sync_types=("cerebras.cloud.sdk.Cerebras",), + async_types=("cerebras.cloud.sdk.AsyncCerebras",), + create="chat.completions.create", + ), ), Provider.WRITER: _spec( Provider.WRITER, @@ -380,9 +594,22 @@ def _openai_compat_spec( from_function="from_writer", client_module="instructor.v2.providers.writer.client", sdk_module="writerai", + capabilities=ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + iterable_stream_modes=(Mode.TOOLS,), + multimodal_inputs=_OPENAI_MULTIMODAL_INPUTS, + ), provider_string="writer/palmyra-x5", basic_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), async_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + missing_sdk_message=( + "writerai is not installed. Install it with: pip install writer-sdk" + ), + client=ClientSpec( + sync_types=("writerai.Writer",), + async_types=("writerai.AsyncWriter",), + create="chat.chat", + ), ), Provider.BEDROCK: _spec( Provider.BEDROCK, @@ -404,6 +631,13 @@ def _openai_compat_spec( provider_string="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0", basic_modes=(Mode.TOOLS, Mode.MD_JSON), async_modes=(Mode.TOOLS, Mode.MD_JSON), + missing_sdk_message=( + "botocore is not installed. Install it with: pip install boto3" + ), + client=ClientSpec( + sync_types=("botocore.client.BaseClient",), + create="converse", + ), ), Provider.VERTEXAI: _spec( Provider.VERTEXAI, @@ -419,6 +653,20 @@ def _openai_compat_spec( from_function="from_vertexai", client_module="instructor.v2.providers.vertexai.client", sdk_module="vertexai", + capabilities=ProviderCapabilities( + partial_stream_modes=(Mode.TOOLS, Mode.MD_JSON), + explicit_parallel_tools=True, + ), + missing_sdk_message=( + "vertexai is not installed. Install it with: " + "pip install google-cloud-aiplatform" + ), + client=ClientSpec( + sync_types=("vertexai.generative_models.GenerativeModel",), + create="generate_content", + async_create="generate_content_async", + validation_order="mode-first", + ), ), Provider.AZURE_OPENAI: _spec( Provider.AZURE_OPENAI, @@ -431,6 +679,7 @@ def _openai_compat_spec( from_function=None, client_module=None, sdk_module="openai", + builder_module="instructor.v2.providers.openai.client", ), Provider.OLLAMA: _spec( Provider.OLLAMA, @@ -443,6 +692,7 @@ def _openai_compat_spec( from_function=None, client_module=None, sdk_module="openai", + builder_module="instructor.v2.providers.openai.client", ), Provider.LITELLM: _spec( Provider.LITELLM, diff --git a/instructor/v2/core/providers.py b/instructor/v2/core/providers.py index 131e3a7e5..3aea6a1ef 100644 --- a/instructor/v2/core/providers.py +++ b/instructor/v2/core/providers.py @@ -5,7 +5,7 @@ from enum import Enum -from instructor.v2.core.mode import DEPRECATED_TO_CORE, Mode +from instructor.v2.core.mode import Mode class Provider(Enum): @@ -40,45 +40,21 @@ class Provider(Enum): def provider_from_mode(mode: Mode, default: Provider = Provider.OPENAI) -> Provider: """Infer provider from a provider-specific Mode.""" - mapping = { - Mode.ANTHROPIC_TOOLS: Provider.ANTHROPIC, - Mode.ANTHROPIC_JSON: Provider.ANTHROPIC, - Mode.ANTHROPIC_PARALLEL_TOOLS: Provider.ANTHROPIC, - Mode.ANTHROPIC_REASONING_TOOLS: Provider.ANTHROPIC, - Mode.COHERE_TOOLS: Provider.COHERE, - Mode.COHERE_JSON_SCHEMA: Provider.COHERE, - Mode.MISTRAL_TOOLS: Provider.MISTRAL, - Mode.MISTRAL_STRUCTURED_OUTPUTS: Provider.MISTRAL, - Mode.VERTEXAI_TOOLS: Provider.VERTEXAI, - Mode.VERTEXAI_JSON: Provider.VERTEXAI, - Mode.VERTEXAI_PARALLEL_TOOLS: Provider.VERTEXAI, - Mode.GEMINI_TOOLS: Provider.GEMINI, - Mode.GEMINI_JSON: Provider.GEMINI, - Mode.GENAI_TOOLS: Provider.GENAI, - Mode.GENAI_JSON: Provider.GENAI, - Mode.GENAI_STRUCTURED_OUTPUTS: Provider.GENAI, - Mode.XAI_TOOLS: Provider.XAI, - Mode.XAI_JSON: Provider.XAI, - Mode.CEREBRAS_TOOLS: Provider.CEREBRAS, - Mode.CEREBRAS_JSON: Provider.CEREBRAS, - Mode.FIREWORKS_TOOLS: Provider.FIREWORKS, - Mode.FIREWORKS_JSON: Provider.FIREWORKS, - Mode.WRITER_TOOLS: Provider.WRITER, - Mode.WRITER_JSON: Provider.WRITER, - Mode.BEDROCK_TOOLS: Provider.BEDROCK, - Mode.BEDROCK_JSON: Provider.BEDROCK, - Mode.PERPLEXITY_JSON: Provider.PERPLEXITY, - Mode.OPENROUTER_STRUCTURED_OUTPUTS: Provider.OPENROUTER, + from instructor.v2.core.provider_specs import PROVIDER_SPECS + + owners = { + spec.canonical_provider + for spec in PROVIDER_SPECS.values() + if mode in spec.legacy_modes } - return mapping.get(mode, default) + return owners.pop() if len(owners) == 1 else default -def normalize_mode_for_provider(mode: Mode, _provider: Provider) -> Mode: +def normalize_mode_for_provider(mode: Mode, provider: Provider) -> Mode: """Apply provider-specific mode overrides before registry lookup.""" - if mode in DEPRECATED_TO_CORE: - Mode.warn_deprecated_mode(mode) - return DEPRECATED_TO_CORE[mode] - return mode + from instructor.v2.core.registry import normalize_mode + + return normalize_mode(provider, mode) def get_provider(base_url: str) -> Provider: diff --git a/instructor/v2/core/retry.py b/instructor/v2/core/retry.py index f9d9b7db8..b3a8d8a0e 100644 --- a/instructor/v2/core/retry.py +++ b/instructor/v2/core/retry.py @@ -14,6 +14,7 @@ from tenacity import ( AsyncRetrying, Retrying, + RetryError, retry_if_exception_type, stop_after_attempt, stop_after_delay, @@ -52,10 +53,6 @@ ) -def _max_attempts(max_retries: int | Retrying | AsyncRetrying) -> int | None: - return max(max_retries, 0) + 1 if isinstance(max_retries, int) else None - - def _attempt_metadata( *, attempt_number: int, @@ -69,6 +66,47 @@ def _attempt_metadata( } +def _emit_completion_failure( + hooks: Hooks | None, + error: Exception, + *, + attempt_number: int, + max_attempts: int | None, + is_last_attempt: bool, +) -> None: + if hooks is None: + return + metadata = { + "attempt_number": attempt_number, + "max_attempts": max_attempts, + "is_last_attempt": is_last_attempt, + } + hooks.emit_completion_error(error, **metadata) + if is_last_attempt: + hooks.emit_completion_last_attempt(error, **metadata) + + +def _is_terminal_provider_failure( + error: Exception, + *, + attempt_number: int, + max_attempts: int | None, + built_in_policy: bool, +) -> bool: + """Return whether the built-in retry policy is known to stop here.""" + if max_attempts is None: + return False + return attempt_number >= max_attempts or ( + built_in_policy and not isinstance(error, _RETRYABLE_PARSE_ERRORS) + ) + + +def _configured_max_attempts(retrying: Retrying | AsyncRetrying) -> int | None: + """Read a direct ``stop_after_attempt`` limit from a custom policy.""" + value = getattr(retrying.stop, "max_attempt_number", None) + return value if isinstance(value, int) else None + + def _finalize_parsed_response(parsed: Any, response: Any) -> Any: if isinstance(parsed, IterableBase): parsed = [task for task in parsed.tasks] @@ -143,16 +181,32 @@ def retry_sync_v2( InstructorRetryException: If max retries exceeded """ if response_model is None: - # No structured output, just call the API - return func(*args, **kwargs) + if hooks: + hooks.emit_completion_arguments(*args, **kwargs) + try: + response = func(*args, **kwargs) + except Exception as exc: + _emit_completion_failure( + hooks, + exc, + attempt_number=1, + max_attempts=1, + is_last_attempt=True, + ) + raise + if hooks: + hooks.emit_completion_response(response) + return response # Validate and get handlers from registry RegistryValidationMixin.validate_mode_registration(provider, mode) handlers = mode_registry.get_handlers(provider, mode) # Setup retrying + built_in_policy = isinstance(max_retries, int) if isinstance(max_retries, int): - stop_condition = stop_after_attempt(max(max_retries, 0) + 1) + max_attempts = max(max_retries, 0) + 1 + stop_condition = stop_after_attempt(max_attempts) timeout = kwargs.get("timeout") if isinstance(timeout, (int, float)): stop_condition = stop_condition | stop_after_delay(timeout) @@ -162,12 +216,14 @@ def retry_sync_v2( reraise=True, ) else: + max_attempts = _configured_max_attempts(max_retries) max_retries_instance = max_retries - max_attempts = _max_attempts(max_retries) failed_attempts: list[FailedAttempt] = [] last_exception: Exception | None = None + last_completion_error: Exception | None = None last_attempt_number = 0 + last_attempt_emitted = False total_usage = _initialize_usage(provider) try: @@ -177,29 +233,30 @@ def retry_sync_v2( last_attempt_number = attempt_number # Call API if hooks: - hooks.emit_completion_arguments(**kwargs) + hooks.emit_completion_arguments(*args, **kwargs) try: response = func(*args, **kwargs) except IncompleteOutputException: raise except Exception as e: + last_exception = e + last_completion_error = e + is_last_attempt = _is_terminal_provider_failure( + e, + attempt_number=attempt_number, + max_attempts=max_attempts, + built_in_policy=built_in_policy, + ) + _emit_completion_failure( + hooks, + e, + attempt_number=attempt_number, + max_attempts=max_attempts, + is_last_attempt=is_last_attempt, + ) + last_attempt_emitted = is_last_attempt logger.error(f"API call failed on attempt {attempt_number}: {e}") - if hooks: - hooks.emit_completion_error( - e, - **_attempt_metadata( - attempt_number=attempt_number, - max_attempts=max_attempts, - is_last_attempt=( - not isinstance(e, ValidationError) - or ( - max_attempts is not None - and attempt_number >= max_attempts - ) - ), - ), - ) raise if hooks: @@ -256,25 +313,43 @@ def retry_sync_v2( # Will retry with modified kwargs raise - except IncompleteOutputException: + except IncompleteOutputException as exc: + _emit_completion_failure( + hooks, + exc, + attempt_number=max(last_attempt_number, 1), + max_attempts=max_attempts, + is_last_attempt=True, + ) raise except Exception as e: # Max retries exceeded or non-validation error occurred - last_exception = e + if not isinstance(e, RetryError) or last_exception is None: + last_exception = e logger.error( f"Max retries exceeded. Total attempts: {last_attempt_number}, " f"Last error: {last_exception}" ) - if hooks: - hooks.emit_completion_last_attempt( - last_exception, - **_attempt_metadata( - attempt_number=last_attempt_number or len(failed_attempts), + if last_exception is not None and not last_attempt_emitted: + if ( + not isinstance(last_exception, _RETRYABLE_PARSE_ERRORS) + and last_completion_error is not last_exception + ): + _emit_completion_failure( + hooks, + last_exception, + attempt_number=max(last_attempt_number, 1), max_attempts=max_attempts, is_last_attempt=True, - ), - ) + ) + elif hooks: + hooks.emit_completion_last_attempt( + last_exception, + attempt_number=max(last_attempt_number, 1), + max_attempts=max_attempts, + is_last_attempt=True, + ) raise InstructorRetryException( str(last_exception), @@ -388,16 +463,32 @@ async def retry_async_v2( InstructorRetryException: If max retries exceeded """ if response_model is None: - # No structured output, just call the API - return await func(*args, **kwargs) + if hooks: + hooks.emit_completion_arguments(*args, **kwargs) + try: + response = await func(*args, **kwargs) + except Exception as exc: + _emit_completion_failure( + hooks, + exc, + attempt_number=1, + max_attempts=1, + is_last_attempt=True, + ) + raise + if hooks: + hooks.emit_completion_response(response) + return response # Validate and get handlers from registry RegistryValidationMixin.validate_mode_registration(provider, mode) handlers = mode_registry.get_handlers(provider, mode) # Setup retrying + built_in_policy = isinstance(max_retries, int) if isinstance(max_retries, int): - stop_condition = stop_after_attempt(max(max_retries, 0) + 1) + max_attempts = max(max_retries, 0) + 1 + stop_condition = stop_after_attempt(max_attempts) timeout = kwargs.get("timeout") if isinstance(timeout, (int, float)): stop_condition = stop_condition | stop_after_delay(timeout) @@ -407,12 +498,14 @@ async def retry_async_v2( reraise=True, ) else: + max_attempts = _configured_max_attempts(max_retries) max_retries_instance = max_retries - max_attempts = _max_attempts(max_retries) failed_attempts: list[FailedAttempt] = [] last_exception: Exception | None = None + last_completion_error: Exception | None = None last_attempt_number = 0 + last_attempt_emitted = False total_usage = _initialize_usage(provider) try: @@ -422,29 +515,30 @@ async def retry_async_v2( last_attempt_number = attempt_number # Call API if hooks: - hooks.emit_completion_arguments(**kwargs) + hooks.emit_completion_arguments(*args, **kwargs) try: response = await func(*args, **kwargs) except IncompleteOutputException: raise except Exception as e: + last_exception = e + last_completion_error = e + is_last_attempt = _is_terminal_provider_failure( + e, + attempt_number=attempt_number, + max_attempts=max_attempts, + built_in_policy=built_in_policy, + ) + _emit_completion_failure( + hooks, + e, + attempt_number=attempt_number, + max_attempts=max_attempts, + is_last_attempt=is_last_attempt, + ) + last_attempt_emitted = is_last_attempt logger.error(f"API call failed on attempt {attempt_number}: {e}") - if hooks: - hooks.emit_completion_error( - e, - **_attempt_metadata( - attempt_number=attempt_number, - max_attempts=max_attempts, - is_last_attempt=( - not isinstance(e, ValidationError) - or ( - max_attempts is not None - and attempt_number >= max_attempts - ) - ), - ), - ) raise if hooks: @@ -501,25 +595,43 @@ async def retry_async_v2( # Will retry with modified kwargs raise - except IncompleteOutputException: + except IncompleteOutputException as exc: + _emit_completion_failure( + hooks, + exc, + attempt_number=max(last_attempt_number, 1), + max_attempts=max_attempts, + is_last_attempt=True, + ) raise except Exception as e: # Max retries exceeded or non-validation error occurred - last_exception = e + if not isinstance(e, RetryError) or last_exception is None: + last_exception = e logger.error( f"Max retries exceeded. Total attempts: {last_attempt_number}, " f"Last error: {last_exception}" ) - if hooks: - hooks.emit_completion_last_attempt( - last_exception, - **_attempt_metadata( - attempt_number=last_attempt_number or len(failed_attempts), + if last_exception is not None and not last_attempt_emitted: + if ( + not isinstance(last_exception, _RETRYABLE_PARSE_ERRORS) + and last_completion_error is not last_exception + ): + _emit_completion_failure( + hooks, + last_exception, + attempt_number=max(last_attempt_number, 1), max_attempts=max_attempts, is_last_attempt=True, - ), - ) + ) + elif hooks: + hooks.emit_completion_last_attempt( + last_exception, + attempt_number=max(last_attempt_number, 1), + max_attempts=max_attempts, + is_last_attempt=True, + ) raise InstructorRetryException( str(last_exception), diff --git a/instructor/v2/dsl/partial.pyi b/instructor/v2/dsl/partial.pyi index e1114ca95..b8cb1135f 100644 --- a/instructor/v2/dsl/partial.pyi +++ b/instructor/v2/dsl/partial.pyi @@ -12,18 +12,18 @@ class PartialBase(BaseModel, Generic[T_Model]): @staticmethod def extract_json( completion: Iterable[Any], - stream_extractor: Callable[[Iterable[Any]], Generator[str, None, None]] | Any, + stream_extractor: Callable[[Iterable[Any]], Generator[str, None, None]] | Any, # noqa: UP043 on_event: Callable[..., Any] | None = None, - ) -> Generator[str, None, None]: ... + ) -> Generator[str, None, None]: ... # noqa: UP043 @staticmethod def extract_json_async( - completion: AsyncGenerator[Any, None], + completion: AsyncGenerator[Any, None], # noqa: UP043 stream_extractor: Callable[ - [AsyncGenerator[Any, None]], AsyncGenerator[str, None] + [AsyncGenerator[Any, None]], AsyncGenerator[str, None] # noqa: UP043 ] | Any, on_event: Callable[..., Any] | None = None, - ) -> AsyncGenerator[str, None]: ... + ) -> AsyncGenerator[str, None]: ... # noqa: UP043 class PartialLiteralMixin: ... diff --git a/instructor/v2/providers/anthropic/client.py b/instructor/v2/providers/anthropic/client.py index 3c416e32f..c8b5caa39 100644 --- a/instructor/v2/providers/anthropic/client.py +++ b/instructor/v2/providers/anthropic/client.py @@ -8,10 +8,10 @@ from typing import TYPE_CHECKING, Any, overload from instructor.v2.core.client import AsyncInstructor, Instructor +from instructor.v2.core.client_factory import create_instructor from instructor.v2.core.errors import ClientError from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from instructor.v2.core.patch import patch_v2 # Ensure handlers are registered (decorators auto-register on import) from instructor.v2.providers.anthropic import handlers # noqa: F401 @@ -92,78 +92,54 @@ def from_anthropic( >>> # Or use JSON mode >>> instructor_client = from_anthropic(client, mode=Mode.JSON) """ - from instructor.v2.core.registry import mode_registry, normalize_mode - if anthropic is None: raise ClientError( "anthropic is not installed. Install it with: pip install anthropic" ) - # Normalize provider-specific modes to generic modes - # ANTHROPIC_TOOLS -> TOOLS, ANTHROPIC_JSON -> JSON, ANTHROPIC_PARALLEL_TOOLS -> PARALLEL_TOOLS - normalized_mode = normalize_mode(Provider.ANTHROPIC, mode) - - # Validate mode is registered (use normalized mode for check) - if not mode_registry.is_registered(Provider.ANTHROPIC, normalized_mode): - from instructor.v2.core.errors import ModeError - - available_modes = mode_registry.get_modes_for_provider(Provider.ANTHROPIC) - raise ModeError( - mode=str(mode.value), - provider=Provider.ANTHROPIC.value, - valid_modes=[str(m.value) for m in available_modes], - ) - - # Use normalized mode for patching - mode = normalized_mode - - # Validate client type - valid_client_types = ( - anthropic.Anthropic, - anthropic.AsyncAnthropic, - anthropic.AnthropicBedrock, - anthropic.AnthropicVertex, - anthropic.AsyncAnthropicBedrock, - anthropic.AsyncAnthropicVertex, - ) - - if not isinstance(client, valid_client_types): - raise ClientError( - f"Client must be an instance of one of: {', '.join(t.__name__ for t in valid_client_types)}. " - f"Got: {type(client).__name__}" - ) - - # Get create function (beta or regular) - if beta: - create = client.beta.messages.create - else: - create = client.messages.create - - # Patch using v2 registry, passing the model for injection - patched_create = patch_v2( - func=create, + create_path = "beta.messages.create" if beta else None + return create_instructor( + client, provider=Provider.ANTHROPIC, mode=mode, - default_model=model, + model=model, + create_path=create_path, + async_create_path=create_path, + sync_types=( + anthropic.Anthropic, + anthropic.AnthropicBedrock, + anthropic.AnthropicVertex, + ), + async_types=( + anthropic.AsyncAnthropic, + anthropic.AsyncAnthropicBedrock, + anthropic.AsyncAnthropicVertex, + ), + **kwargs, ) - # Return sync or async instructor - if isinstance( - client, - (anthropic.Anthropic, anthropic.AnthropicBedrock, anthropic.AnthropicVertex), - ): - return Instructor( - client=client, - create=patched_create, - provider=Provider.ANTHROPIC, - mode=mode, - **kwargs, - ) - else: - return AsyncInstructor( - client=client, - create=patched_create, - provider=Provider.ANTHROPIC, - mode=mode, - **kwargs, + +def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + from instructor import __version__ + from instructor.v2.core.errors import ConfigurationError + + if anthropic is None: + raise ConfigurationError( + "The anthropic package is required to use the Anthropic provider. " + "Install it with `pip install anthropic`." ) + factory = anthropic.AsyncAnthropic if async_client else anthropic.Anthropic + client = factory( + api_key=api_key, + default_headers={"User-Agent": f"instructor/{__version__}"}, + ) + kwargs.setdefault("max_tokens", 4096) + return from_anthropic(client, model=model_name, mode=mode or Mode.TOOLS, **kwargs) diff --git a/instructor/v2/providers/anthropic/handlers.py b/instructor/v2/providers/anthropic/handlers.py index 8db231f91..88d375392 100644 --- a/instructor/v2/providers/anthropic/handlers.py +++ b/instructor/v2/providers/anthropic/handlers.py @@ -35,11 +35,15 @@ from instructor.v2.dsl.partial import PartialBase from instructor.v2.dsl.simple_type import AdapterBase from instructor.v2.core.multimodal import Audio, Image, PDF -from instructor.v2.core.multimodal import convert_messages as convert_messages_v1 +from instructor.v2.core.multimodal import convert_messages from instructor.v2.core.json import extract_json_from_codeblock -from instructor.v2.providers.anthropic.schema import generate_anthropic_schema from instructor.v2.core.decorators import register_mode_handler from instructor.v2.core.handler import ModeHandler +from instructor.v2.providers.anthropic.multimodal import ( + image_from_params, + media_to_anthropic, +) +from instructor.v2.providers.anthropic.schema import generate_anthropic_schema class SystemMessage(TypedDict, total=False): @@ -119,9 +123,9 @@ def serialize_message_content(content: Any) -> Any: """Serialize message content, converting Pydantic models to dicts.""" if isinstance(content, Image): - return content.to_anthropic() + return media_to_anthropic(content) if isinstance(content, PDF): - return content.to_anthropic() + return media_to_anthropic(content) if isinstance(content, Audio): source = str(content.source) if source.startswith(("http://", "https://")): @@ -262,8 +266,12 @@ def convert_messages( target_mode = Mode.ANTHROPIC_TOOLS else: target_mode = Mode.ANTHROPIC_JSON - return convert_messages_v1( - messages, target_mode, autodetect_images=autodetect_images + return convert_messages( + messages, + target_mode, + autodetect_images=autodetect_images, + media_converter=media_to_anthropic, + image_param_converter=image_from_params, ) def _parse_streaming_response( diff --git a/instructor/v2/providers/anthropic/multimodal.py b/instructor/v2/providers/anthropic/multimodal.py index 31a1c1d2c..9ce1be24a 100644 --- a/instructor/v2/providers/anthropic/multimodal.py +++ b/instructor/v2/providers/anthropic/multimodal.py @@ -3,10 +3,35 @@ from __future__ import annotations import base64 +from collections.abc import Mapping from typing import Any import requests +from instructor.v2.core.multimodal import ( + Audio, + Image, + ImageParams, + PDF, +) + + +class CacheableImage(Image): + """Anthropic-owned image representation with optional prompt caching.""" + + cache_control: Mapping[str, str] | None = None + + +def image_from_params(params: ImageParams) -> Image: + """Construct an Anthropic image from its cache-aware shorthand.""" + image = Image.autodetect(params["source"]) + return CacheableImage( + source=image.source, + media_type=image.media_type, + data=image.data, + cache_control=params.get("cache_control"), + ) + def image_to_anthropic(image: Any) -> dict[str, Any]: if ( @@ -47,16 +72,26 @@ def pdf_to_anthropic(pdf: Any) -> dict[str, Any]: def image_with_cache_control_to_anthropic(image: Any) -> dict[str, Any]: result = image_to_anthropic(image) - if image.cache_control: - result["cache_control"] = image.cache_control + if cache_control := getattr(image, "cache_control", None): + result["cache_control"] = cache_control return result def pdf_with_cache_control_to_anthropic(pdf: Any) -> dict[str, Any]: result = pdf_to_anthropic(pdf) - result["cache_control"] = {"type": "ephemeral"} + if cache_control := getattr(pdf, "cache_control", None): + result["cache_control"] = cache_control return result def audio_to_anthropic(_audio: Any) -> dict[str, Any]: raise NotImplementedError("Anthropic is not supported yet") + + +def media_to_anthropic(media: Image | Audio | PDF) -> dict[str, Any]: + """Encode a typed media item through Anthropic-owned conversion.""" + if isinstance(media, Image): + return image_with_cache_control_to_anthropic(media) + if isinstance(media, PDF): + return pdf_with_cache_control_to_anthropic(media) + return audio_to_anthropic(media) diff --git a/instructor/v2/providers/anthropic/parallel.py b/instructor/v2/providers/anthropic/parallel.py index f4a42715e..c59dc6765 100644 --- a/instructor/v2/providers/anthropic/parallel.py +++ b/instructor/v2/providers/anthropic/parallel.py @@ -8,18 +8,16 @@ from pydantic import BaseModel -from instructor.v2.core.function_calls import openai_schema from instructor.v2.core.mode import Mode from instructor.v2.dsl.parallel import ParallelBase, get_types_array +from instructor.v2.providers.anthropic.schema import generate_anthropic_schema T = TypeVar("T", bound=BaseModel) def handle_parallel_model(typehint: type[Iterable[T]]) -> list[dict[str, Any]]: """Build Anthropic tool schemas for a parallel model.""" - return [ - openai_schema(model).anthropic_schema for model in get_types_array(typehint) - ] + return [generate_anthropic_schema(model) for model in get_types_array(typehint)] class AnthropicParallelBase(ParallelBase[T]): diff --git a/instructor/v2/providers/bedrock/client.py b/instructor/v2/providers/bedrock/client.py index 72365b666..809bdf584 100644 --- a/instructor/v2/providers/bedrock/client.py +++ b/instructor/v2/providers/bedrock/client.py @@ -2,12 +2,13 @@ from __future__ import annotations +import os from typing import TYPE_CHECKING, Any, Literal, overload from instructor.v2.core.client import AsyncInstructor, Instructor +from instructor.v2.core.client_factory import create_instructor from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from instructor.v2.core.patch import patch_v2 # Ensure handlers are registered (decorators auto-register on import) from instructor.v2.providers.bedrock import handlers # noqa: F401 @@ -67,8 +68,6 @@ def from_bedrock( ModeError: If mode is not registered for Bedrock ClientError: If client is not a valid BaseClient or botocore not installed """ - from instructor.v2.core.registry import mode_registry, normalize_mode - if BaseClient is None: from instructor.v2.core.errors import ClientError @@ -76,63 +75,55 @@ def from_bedrock( "botocore is not installed. Install it with: pip install boto3" ) - normalized_mode = normalize_mode(Provider.BEDROCK, mode) - - if not mode_registry.is_registered(Provider.BEDROCK, normalized_mode): - from instructor.v2.core.errors import ModeError - - available_modes = mode_registry.get_modes_for_provider(Provider.BEDROCK) - raise ModeError( - mode=str(mode.value), - provider=Provider.BEDROCK.value, - valid_modes=[str(m.value) for m in available_modes], - ) - - mode = normalized_mode - - if not isinstance(client, BaseClient): - from instructor.v2.core.errors import ClientError - - raise ClientError( - f"Client must be an instance of botocore.client.BaseClient. " - f"Got: {type(client).__name__}" - ) - - create = client.converse - - if async_client: - - async def async_wrapper(**async_kwargs: Any): - return create(**async_kwargs) - - patched_create = patch_v2( - func=async_wrapper, - provider=Provider.BEDROCK, - mode=mode, - default_model=model, - ) - return AsyncInstructor( - client=client, - create=patched_create, - provider=Provider.BEDROCK, - mode=mode, - **kwargs, - ) - - patched_create = patch_v2( - func=create, + return create_instructor( + client, provider=Provider.BEDROCK, mode=mode, - default_model=model, + model=model, + use_async=async_client, + sync_types=(BaseClient,), + **kwargs, ) - return Instructor( - client=client, - create=patched_create, - provider=Provider.BEDROCK, - mode=mode, + +def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, # noqa: ARG001 + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + from instructor.v2.core.errors import ConfigurationError + + try: + import boto3 + except ImportError: + raise ConfigurationError( + "The boto3 package is required to use the AWS Bedrock provider. " + "Install it with `pip install boto3`." + ) from None + aws_kwargs = { + key: kwargs.pop(key, os.environ.get(key.upper())) + for key in ("aws_access_key_id", "aws_secret_access_key", "aws_session_token") + if key in kwargs or key.upper() in os.environ + } + aws_kwargs["region_name"] = kwargs.pop( + "region", os.environ.get("AWS_DEFAULT_REGION", "us-east-1") + ) + selected_mode = mode or ( + Mode.TOOLS + if "anthropic" in model_name.lower() or "claude" in model_name.lower() + else Mode.MD_JSON + ) + return from_bedrock( + boto3.client("bedrock-runtime", **aws_kwargs), + model=model_name, + mode=selected_mode, + async_client=async_client, **kwargs, ) -__all__ = ["from_bedrock"] +__all__ = ["build_from_model", "from_bedrock"] diff --git a/instructor/v2/providers/cerebras/client.py b/instructor/v2/providers/cerebras/client.py index 649791b99..9dea4f3be 100644 --- a/instructor/v2/providers/cerebras/client.py +++ b/instructor/v2/providers/cerebras/client.py @@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any, overload from instructor.v2.core.client import AsyncInstructor, Instructor +from instructor.v2.core.client_factory import create_instructor from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from instructor.v2.core.patch import patch_v2 # Ensure handlers are registered (decorators auto-register on import) # Cerebras uses OpenAI-compatible API, so handlers are registered via OpenAI handlers @@ -80,9 +80,6 @@ def from_cerebras( >>> # Or use MD_JSON mode for text extraction >>> instructor_client = from_cerebras(client, mode=Mode.MD_JSON) """ - from instructor.v2.core.registry import mode_registry, normalize_mode - - # Check if cerebras SDK is installed if Cerebras is None or AsyncCerebras is None: from instructor.v2.core.errors import ClientError @@ -90,63 +87,35 @@ def from_cerebras( "cerebras is not installed. Install it with: pip install cerebras-cloud-sdk" ) - # Normalize provider-specific modes to generic modes - # CEREBRAS_TOOLS -> TOOLS, CEREBRAS_JSON -> MD_JSON - normalized_mode = normalize_mode(Provider.CEREBRAS, mode) - - # Validate mode is registered (use normalized mode for check) - if not mode_registry.is_registered(Provider.CEREBRAS, normalized_mode): - from instructor.v2.core.errors import ModeError - - available_modes = mode_registry.get_modes_for_provider(Provider.CEREBRAS) - raise ModeError( - mode=str(mode.value), - provider=Provider.CEREBRAS.value, - valid_modes=[str(m.value) for m in available_modes], - ) - - # Use normalized mode for patching - mode = normalized_mode - - # Validate client type - valid_client_types = ( - Cerebras, - AsyncCerebras, - ) - - if not isinstance(client, valid_client_types): - from instructor.v2.core.errors import ClientError - - raise ClientError( - f"Client must be an instance of one of: {', '.join(t.__name__ for t in valid_client_types)}. " - f"Got: {type(client).__name__}" - ) - - # Get create function - Cerebras uses chat.completions.create like OpenAI - create = client.chat.completions.create - - # Patch using v2 registry, passing the model for injection - patched_create = patch_v2( - func=create, + return create_instructor( + client, provider=Provider.CEREBRAS, mode=mode, - default_model=model, + model=model, + sync_types=(Cerebras,), + async_types=(AsyncCerebras,), + **kwargs, ) - # Return sync or async instructor - if isinstance(client, Cerebras): - return Instructor( - client=client, - create=patched_create, - provider=Provider.CEREBRAS, - mode=mode, - **kwargs, - ) - else: - return AsyncInstructor( - client=client, - create=patched_create, - provider=Provider.CEREBRAS, - mode=mode, - **kwargs, + +def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + """Construct the native Cerebras client for `from_provider`.""" + if Cerebras is None or AsyncCerebras is None: + from instructor.v2.core.errors import ConfigurationError + + raise ConfigurationError( + "The cerebras package is required to use the Cerebras provider. " + "Install it with `pip install cerebras`." ) + client = ( + AsyncCerebras(api_key=api_key) if async_client else Cerebras(api_key=api_key) + ) + return from_cerebras(client, model=model_name, mode=mode or Mode.TOOLS, **kwargs) diff --git a/instructor/v2/providers/cohere/client.py b/instructor/v2/providers/cohere/client.py index cfe8ed8b2..2088a884c 100644 --- a/instructor/v2/providers/cohere/client.py +++ b/instructor/v2/providers/cohere/client.py @@ -6,14 +6,12 @@ from __future__ import annotations -import inspect -from collections.abc import Awaitable -from typing import TYPE_CHECKING, Any, cast, overload +from typing import TYPE_CHECKING, Any, overload from instructor.v2.core.client import AsyncInstructor, Instructor +from instructor.v2.core.client_factory import create_instructor from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from instructor.v2.core.patch import patch_v2 # Ensure handlers are registered (decorators auto-register on import) from instructor.v2.providers.cohere import handlers # noqa: F401 @@ -91,100 +89,44 @@ def from_cohere( >>> client = cohere.Client() >>> instructor_client = from_cohere(client, mode=Mode.JSON_SCHEMA) """ - from instructor.v2.core.registry import mode_registry, normalize_mode - - if cohere is None: - from instructor.v2.core.errors import ClientError - - raise ClientError( - "cohere is not installed. Install it with: pip install cohere" - ) - - # Normalize provider-specific modes to generic modes - # COHERE_TOOLS -> TOOLS, COHERE_JSON_SCHEMA -> JSON_SCHEMA - normalized_mode = normalize_mode(Provider.COHERE, mode) - - # Validate mode is registered - if not mode_registry.is_registered(Provider.COHERE, normalized_mode): - from instructor.v2.core.errors import ModeError - - available_modes = mode_registry.get_modes_for_provider(Provider.COHERE) - raise ModeError( - mode=str(mode.value), - provider=Provider.COHERE.value, - valid_modes=[str(m.value) for m in available_modes], - ) - - # Use normalized mode for patching - mode = normalized_mode - - # Validate client type - valid_client_types = ( - cohere.Client, - cohere.AsyncClient, - cohere.ClientV2, - cohere.AsyncClientV2, + is_v2 = cohere is not None and isinstance( + client, (cohere.ClientV2, cohere.AsyncClientV2) + ) + kwargs["_cohere_client_version"] = "v2" if is_v2 else "v1" + sync_types = (cohere.Client, cohere.ClientV2) if cohere is not None else None + async_types = ( + (cohere.AsyncClient, cohere.AsyncClientV2) if cohere is not None else None + ) + return create_instructor( + client, + provider=Provider.COHERE, + mode=mode, + sync_types=sync_types, + async_types=async_types, + **kwargs, ) - if not isinstance(client, valid_client_types): - from instructor.v2.core.errors import ClientError - - raise ClientError( - f"Client must be an instance of one of: {', '.join(t.__name__ for t in valid_client_types)}. " - f"Got: {type(client).__name__}" - ) - - # Detect client version for request formatting - if isinstance(client, (cohere.ClientV2, cohere.AsyncClientV2)): - client_version = "v2" - else: - client_version = "v1" - - kwargs["_cohere_client_version"] = client_version - - # Determine if async client - is_async = isinstance(client, (cohere.AsyncClient, cohere.AsyncClientV2)) - - if is_async: - - async def async_wrapper(*args: Any, **call_kwargs: Any) -> Any: - if call_kwargs.pop("stream", False): - return client.chat_stream(*args, **call_kwargs) - result = client.chat(*args, **call_kwargs) - if inspect.isawaitable(result): - return await cast(Awaitable[Any], result) - return result - - patched_create = patch_v2( - func=async_wrapper, - provider=Provider.COHERE, - mode=mode, - ) - - return AsyncInstructor( - client=client, - create=patched_create, - provider=Provider.COHERE, - mode=mode, - **kwargs, - ) - else: - - def sync_wrapper(*args: Any, **call_kwargs: Any) -> Any: - if call_kwargs.pop("stream", False): - return client.chat_stream(*args, **call_kwargs) - return client.chat(*args, **call_kwargs) - patched_create = patch_v2( - func=sync_wrapper, - provider=Provider.COHERE, - mode=mode, - ) +def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + """Construct the native Cohere client for `from_provider`.""" + if cohere is None: + from instructor.v2.core.errors import ConfigurationError - return Instructor( - client=client, - create=patched_create, - provider=Provider.COHERE, - mode=mode, - **kwargs, + raise ConfigurationError( + "The cohere package is required to use the Cohere provider. " + "Install it with `pip install cohere`." ) + client = ( + cohere.AsyncClientV2(api_key=api_key) + if async_client + else cohere.ClientV2(api_key=api_key) + ) + return from_cohere(client, mode=mode or Mode.TOOLS, model=model_name, **kwargs) diff --git a/instructor/v2/providers/fireworks/client.py b/instructor/v2/providers/fireworks/client.py index 57f86994c..655b04f52 100644 --- a/instructor/v2/providers/fireworks/client.py +++ b/instructor/v2/providers/fireworks/client.py @@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any, overload from instructor.v2.core.client import AsyncInstructor, Instructor +from instructor.v2.core.client_factory import create_instructor from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from instructor.v2.core.patch import patch_v2 # Ensure handlers are registered (decorators auto-register on import) # Fireworks uses OpenAI-compatible API, so handlers are registered via OpenAI handlers @@ -80,9 +80,6 @@ def from_fireworks( >>> # Or use MD_JSON mode for text extraction >>> instructor_client = from_fireworks(client, mode=Mode.MD_JSON) """ - from instructor.v2.core.registry import mode_registry, normalize_mode - - # Check if fireworks is installed if Fireworks is None or AsyncFireworks is None: from instructor.v2.core.errors import ClientError @@ -90,73 +87,35 @@ def from_fireworks( "fireworks is not installed. Install it with: pip install fireworks-ai" ) - # Normalize provider-specific modes to generic modes - # FIREWORKS_TOOLS -> TOOLS, FIREWORKS_JSON -> MD_JSON - normalized_mode = normalize_mode(Provider.FIREWORKS, mode) - - # Validate mode is registered (use normalized mode for check) - if not mode_registry.is_registered(Provider.FIREWORKS, normalized_mode): - from instructor.v2.core.errors import ModeError - - available_modes = mode_registry.get_modes_for_provider(Provider.FIREWORKS) - raise ModeError( - mode=str(mode.value), - provider=Provider.FIREWORKS.value, - valid_modes=[str(m.value) for m in available_modes], - ) - - # Use normalized mode for patching - mode = normalized_mode - - # Validate client type - valid_client_types = ( - Fireworks, - AsyncFireworks, - ) - - if not isinstance(client, valid_client_types): - from instructor.v2.core.errors import ClientError - - raise ClientError( - f"Client must be an instance of one of: {', '.join(t.__name__ for t in valid_client_types)}. " - f"Got: {type(client).__name__}" - ) - - # Get create function - Fireworks uses chat.completions.create like OpenAI - if isinstance(client, AsyncFireworks): - # Fireworks async client uses acreate method - async def async_create(*args: Any, **create_kwargs: Any) -> Any: - if create_kwargs.get("stream"): - # For streaming, await to get the async generator - return await client.chat.completions.acreate(*args, **create_kwargs) - return await client.chat.completions.acreate(*args, **create_kwargs) - - create = async_create - else: - create = client.chat.completions.create - - # Patch using v2 registry, passing the model for injection - patched_create = patch_v2( - func=create, + return create_instructor( + client, provider=Provider.FIREWORKS, mode=mode, - default_model=model, + model=model, + sync_types=(Fireworks,), + async_types=(AsyncFireworks,), + **kwargs, ) - # Return sync or async instructor - if isinstance(client, Fireworks): - return Instructor( - client=client, - create=patched_create, - provider=Provider.FIREWORKS, - mode=mode, - **kwargs, - ) - else: - return AsyncInstructor( - client=client, - create=patched_create, - provider=Provider.FIREWORKS, - mode=mode, - **kwargs, + +def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + """Construct the native Fireworks client for `from_provider`.""" + if Fireworks is None or AsyncFireworks is None: + from instructor.v2.core.errors import ConfigurationError + + raise ConfigurationError( + "The fireworks-ai package is required to use the Fireworks provider. " + "Install it with `pip install fireworks-ai`." ) + client = ( + AsyncFireworks(api_key=api_key) if async_client else Fireworks(api_key=api_key) + ) + return from_fireworks(client, model=model_name, mode=mode or Mode.TOOLS, **kwargs) diff --git a/instructor/v2/providers/gemini/client.py b/instructor/v2/providers/gemini/client.py index d90675519..9232b50dd 100644 --- a/instructor/v2/providers/gemini/client.py +++ b/instructor/v2/providers/gemini/client.py @@ -3,12 +3,13 @@ from __future__ import annotations import importlib -from typing import Any, Literal, overload +import os +from typing import Any, Literal, cast, overload from instructor.v2.core.client import AsyncInstructor, Instructor +from instructor.v2.core.client_factory import create_instructor from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from instructor.v2.core.patch import patch_v2 # Ensure handlers are registered. from instructor.v2.providers.gemini import handlers # noqa: F401 @@ -43,58 +44,47 @@ def from_gemini( use_async: bool = False, **kwargs: Any, ) -> Instructor | AsyncInstructor: - from instructor.v2.core.registry import mode_registry, normalize_mode - - normalized_mode = normalize_mode(Provider.GEMINI, mode) - if not mode_registry.is_registered(Provider.GEMINI, normalized_mode): - from instructor.v2.core.errors import ModeError - - available_modes = mode_registry.get_modes_for_provider(Provider.GEMINI) - raise ModeError( - mode=str(mode.value), - provider=Provider.GEMINI.value, - valid_modes=[str(m.value) for m in available_modes], - ) - - if genai is None: - from instructor.v2.core.errors import ClientError - - raise ClientError( - "google-generativeai is not installed. Install it with: " - "pip install google-generativeai" - ) - generative_model_type = getattr(genai, "GenerativeModel", None) - if generative_model_type is None or not isinstance(client, generative_model_type): - from instructor.v2.core.errors import ClientError - - raise ClientError( - "Client must be an instance of genai.GenerativeModel. " - f"Got: {type(client).__name__}" - ) + sync_types = ( + (generative_model_type,) if isinstance(generative_model_type, type) else None + ) - create = client.generate_content_async if use_async else client.generate_content - patched_create = patch_v2( - func=create, + return create_instructor( + client, provider=Provider.GEMINI, - mode=normalized_mode, + mode=mode, + use_async=use_async, + sync_types=sync_types, + **kwargs, ) - if use_async: - return AsyncInstructor( - client=client, - create=patched_create, - provider=Provider.GEMINI, - mode=normalized_mode, - **kwargs, + +def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + from instructor.v2.core.errors import ConfigurationError + + if genai is None: + raise ConfigurationError( + "The google-generativeai package is required to use the Gemini provider. " + "Install it with `pip install google-generativeai`." ) - return Instructor( - client=client, - create=patched_create, - provider=Provider.GEMINI, - mode=normalized_mode, + client_sdk = cast(Any, genai) + resolved_key = api_key or os.environ.get("GOOGLE_API_KEY") + if resolved_key: + client_sdk.configure(api_key=resolved_key) + return from_gemini( + client_sdk.GenerativeModel(model_name), + mode=mode or Mode.MD_JSON, + use_async=async_client, **kwargs, ) -__all__ = ["from_gemini"] +__all__ = ["build_from_model", "from_gemini"] diff --git a/instructor/v2/providers/gemini/utils.py b/instructor/v2/providers/gemini/utils.py index dc2c08388..48f1ecc79 100644 --- a/instructor/v2/providers/gemini/utils.py +++ b/instructor/v2/providers/gemini/utils.py @@ -15,6 +15,10 @@ from instructor.v2.core.errors import ConfigurationError from instructor.v2.core.multimodal import Audio, Image, PDF from instructor.v2.core.messages import get_message_content +from instructor.v2.providers.genai.multimodal import ( + extract_multimodal_content, + media_to_genai, +) if TYPE_CHECKING: from google.genai.types import Content as GenAIContent @@ -425,7 +429,7 @@ def convert_to_genai_messages( if isinstance(content_item, str): content_parts.append(types.Part.from_text(text=content_item)) elif isinstance(content_item, (Image, Audio, PDF)): - content_parts.append(content_item.to_genai()) + content_parts.append(media_to_genai(content_item)) else: raise ValueError( f"Unsupported content item type: {type(content_item)}" @@ -452,9 +456,7 @@ def handle_genai_message_conversion( new_kwargs["contents"] = convert_to_genai_messages(messages) - from instructor.v2.core.multimodal import extract_genai_multimodal_content - - new_kwargs["contents"] = extract_genai_multimodal_content( + new_kwargs["contents"] = extract_multimodal_content( new_kwargs["contents"], autodetect_images ) @@ -512,6 +514,8 @@ def handle_gemini_json( def handle_gemini_tools( response_model: type[Any] | None, new_kwargs: dict[str, Any] ) -> tuple[type[Any] | None, dict[str, Any]]: + from instructor.v2.providers.gemini.schema import generate_gemini_schema + if "model" in new_kwargs: raise ConfigurationError( "Gemini `model` must be set while patching the client, not passed as a parameter to the create method" @@ -521,7 +525,7 @@ def handle_gemini_tools( new_kwargs = update_gemini_kwargs(new_kwargs) return None, new_kwargs - new_kwargs["tools"] = [response_model.gemini_schema] + new_kwargs["tools"] = [generate_gemini_schema(response_model)] new_kwargs["tool_config"] = { "function_calling_config": { "mode": "ANY", @@ -571,9 +575,7 @@ def handle_genai_structured_outputs( new_kwargs["contents"] = convert_to_genai_messages(new_kwargs["messages"]) - from instructor.v2.core.multimodal import extract_genai_multimodal_content - - new_kwargs["contents"] = extract_genai_multimodal_content( + new_kwargs["contents"] = extract_multimodal_content( new_kwargs["contents"], autodetect_images ) @@ -656,9 +658,7 @@ def handle_genai_tools( new_kwargs["contents"] = convert_to_genai_messages(new_kwargs["messages"]) - from instructor.v2.core.multimodal import extract_genai_multimodal_content - - new_kwargs["contents"] = extract_genai_multimodal_content( + new_kwargs["contents"] = extract_multimodal_content( new_kwargs["contents"], autodetect_images ) diff --git a/instructor/v2/providers/genai/client.py b/instructor/v2/providers/genai/client.py index 75b20f949..80f60c727 100644 --- a/instructor/v2/providers/genai/client.py +++ b/instructor/v2/providers/genai/client.py @@ -1,13 +1,13 @@ from __future__ import annotations +import os +import warnings from typing import TYPE_CHECKING, Any, Literal, overload from instructor.v2.core.client import AsyncInstructor, Instructor -from instructor.v2.core.errors import ClientError +from instructor.v2.core.client_factory import create_instructor from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from ...core.patch import patch_v2 -from ...core.registry import mode_registry, normalize_mode # Ensure handlers are registered (decorators auto-register on import) from . import handlers # noqa: F401 @@ -61,86 +61,60 @@ def from_genai( model: Default model name to inject into requests if not provided **kwargs: Additional kwargs passed to Instructor constructor """ - if Client is None: - raise ClientError( - "google-genai is not installed. Install it with: pip install google-genai" - ) - - if not isinstance(client, Client): - raise ClientError( - f"Client must be an instance of google.genai.Client. Got: {type(client).__name__}" - ) + return create_instructor( + client, + provider=Provider.GENAI, + mode=mode, + model=model, + use_async=use_async, + sync_types=(Client,) if Client is not None else None, + **kwargs, + ) - # Normalize mode for handler lookup and client metadata. - normalized_mode = normalize_mode(Provider.GENAI, mode) - # Validate mode is registered (use normalized mode for check) - if not mode_registry.is_registered(Provider.GENAI, normalized_mode): - from instructor.v2.core.errors import ModeError +def build_from_model( + *, + provider: Provider, + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + from instructor.v2.core.errors import ConfigurationError - available_modes = mode_registry.get_modes_for_provider(Provider.GENAI) - raise ModeError( - mode=str(mode.value), - provider=Provider.GENAI.value, - valid_modes=[str(m.value) for m in available_modes], + if Client is None: + raise ConfigurationError( + "The google-genai package is required to use the Google provider. " + "Install it with `pip install google-genai`." ) - - if use_async: - - async def async_wrapper(*_args: Any, **call_kwargs: Any) -> Any: - # Extract model and stream from kwargs - # default_model will be injected by patch_v2 if not present - model_param: str = call_kwargs.pop("model", None) or model or "" - stream = call_kwargs.pop("stream", False) - - # contents should be in call_kwargs from handler - if stream: - return await client.aio.models.generate_content_stream( - model=model_param, **call_kwargs - ) # type: ignore[attr-defined] - - return await client.aio.models.generate_content( - model=model_param, **call_kwargs - ) # type: ignore[attr-defined] - - patched = patch_v2( - func=async_wrapper, - provider=Provider.GENAI, - mode=normalized_mode, - default_model=model, + if provider is Provider.GENERATIVE_AI: + warnings.warn( + "The 'generative-ai' provider is deprecated. Use 'google' provider instead. " + "Example: instructor.from_provider('google/gemini-pro')", + DeprecationWarning, + stacklevel=2, ) - return AsyncInstructor( - client=client, - create=patched, - provider=Provider.GENAI, - mode=normalized_mode, - **kwargs, + client_kwargs = { + key: kwargs.pop(key) + for key in ( + "debug_config", + "http_options", + "credentials", + "project", + "location", ) - - def sync_wrapper(*_args: Any, **call_kwargs: Any) -> Any: - # Extract model and stream from kwargs - # default_model will be injected by patch_v2 if not present - model_param: str = call_kwargs.pop("model", None) or model or "" - stream = call_kwargs.pop("stream", False) - - # contents should be in call_kwargs from handler - if stream: - return client.models.generate_content_stream( - model=model_param, **call_kwargs - ) - - return client.models.generate_content(model=model_param, **call_kwargs) - - patched = patch_v2( - func=sync_wrapper, - provider=Provider.GENAI, - mode=normalized_mode, - default_model=model, + if key in kwargs + } + client = Client( + vertexai=kwargs.pop("vertexai", False), + api_key=api_key or os.environ.get("GOOGLE_API_KEY"), + **client_kwargs, ) - return Instructor( - client=client, - create=patched, - provider=Provider.GENAI, - mode=normalized_mode, + return from_genai( + client, + mode=mode or Mode.TOOLS, + use_async=async_client, + model=kwargs.pop("model", model_name), **kwargs, ) diff --git a/instructor/v2/providers/genai/handlers.py b/instructor/v2/providers/genai/handlers.py index 433c94c43..c0b62ebd7 100644 --- a/instructor/v2/providers/genai/handlers.py +++ b/instructor/v2/providers/genai/handlers.py @@ -9,13 +9,13 @@ from instructor.v2.core.decorators import register_mode_handler from instructor.v2.core.handler import ModeHandler from instructor.v2.core.mode import Mode -from instructor.v2.core.multimodal import extract_genai_multimodal_content from instructor.v2.core.providers import Provider from instructor.v2.core.response_model import prepare_response_model from instructor.v2.dsl.iterable import IterableBase from instructor.v2.dsl.parallel import ParallelBase from instructor.v2.dsl.partial import Partial, PartialBase from instructor.v2.dsl.simple_type import AdapterBase +from instructor.v2.providers.genai.multimodal import extract_multimodal_content from instructor.v2.providers.gemini import utils as gemini_utils @@ -234,9 +234,7 @@ def _convert_messages_to_contents( autodetect_images: bool, ) -> dict[str, Any]: contents = gemini_utils.convert_to_genai_messages(kwargs.get("messages", [])) - kwargs["contents"] = extract_genai_multimodal_content( - contents, autodetect_images - ) + kwargs["contents"] = extract_multimodal_content(contents, autodetect_images) kwargs.pop("messages", None) return kwargs diff --git a/instructor/v2/providers/genai/multimodal.py b/instructor/v2/providers/genai/multimodal.py index 6751ab56b..fae877d95 100644 --- a/instructor/v2/providers/genai/multimodal.py +++ b/instructor/v2/providers/genai/multimodal.py @@ -7,7 +7,12 @@ import requests -from instructor.v2.core.multimodal import Audio, Image, PDF, autodetect_media +from instructor.v2.core.multimodal import ( + Audio, + Image, + PDF, + autodetect_media, +) def _types() -> Any: @@ -112,6 +117,15 @@ def uploaded_pdf_to_genai(pdf: Any) -> Any: return pdf_to_genai(pdf) +def media_to_genai(media: Image | Audio | PDF) -> Any: + """Encode a typed media item through the GenAI-owned converter.""" + if isinstance(media, Image): + return image_to_genai(media) + if isinstance(media, Audio): + return audio_to_genai(media) + return uploaded_pdf_to_genai(media) + + def extract_multimodal_content( contents: list[Any], autodetect_images: bool = True, @@ -134,7 +148,7 @@ def extract_multimodal_content( if content_part.text and autodetect_images: converted_item = autodetect_media(content_part.text) if isinstance(converted_item, (Image, Audio, PDF)): - converted_contents.append(converted_item.to_genai()) + converted_contents.append(media_to_genai(converted_item)) continue converted_contents.append(content_part) result.append(types.Content(parts=converted_contents, role=content.role)) diff --git a/instructor/v2/providers/groq/client.py b/instructor/v2/providers/groq/client.py index 1bdbc7c08..7543b15c5 100644 --- a/instructor/v2/providers/groq/client.py +++ b/instructor/v2/providers/groq/client.py @@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any, overload from instructor.v2.core.client import AsyncInstructor, Instructor +from instructor.v2.core.client_factory import create_instructor from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from instructor.v2.core.patch import patch_v2 # Ensure handlers are registered (decorators auto-register on import) # Groq uses OpenAI-compatible API, so handlers are registered via OpenAI handlers @@ -79,70 +79,40 @@ def from_groq( >>> # Or use MD_JSON mode for text extraction >>> instructor_client = from_groq(client, mode=Mode.MD_JSON) """ - from instructor.v2.core.registry import mode_registry, normalize_mode - - # Check if groq is installed if groq is None: from instructor.v2.core.errors import ClientError raise ClientError("groq is not installed. Install it with: pip install groq") - # Normalize provider-specific modes to generic modes - normalized_mode = normalize_mode(Provider.GROQ, mode) - - # Validate mode is registered (use normalized mode for check) - if not mode_registry.is_registered(Provider.GROQ, normalized_mode): - from instructor.v2.core.errors import ModeError - - available_modes = mode_registry.get_modes_for_provider(Provider.GROQ) - raise ModeError( - mode=str(mode.value), - provider=Provider.GROQ.value, - valid_modes=[str(m.value) for m in available_modes], - ) - - # Use normalized mode for patching - mode = normalized_mode - - # Validate client type - valid_client_types = ( - groq.Groq, - groq.AsyncGroq, - ) - - if not isinstance(client, valid_client_types): - from instructor.v2.core.errors import ClientError - - raise ClientError( - f"Client must be an instance of one of: {', '.join(t.__name__ for t in valid_client_types)}. " - f"Got: {type(client).__name__}" - ) - - # Get create function - create = client.chat.completions.create - - # Patch using v2 registry, passing the model for injection - patched_create = patch_v2( - func=create, + return create_instructor( + client, provider=Provider.GROQ, mode=mode, - default_model=model, + model=model, + sync_types=(groq.Groq,), + async_types=(groq.AsyncGroq,), + **kwargs, ) - # Return sync or async instructor - if isinstance(client, groq.Groq): - return Instructor( - client=client, - create=patched_create, - provider=Provider.GROQ, - mode=mode, - **kwargs, - ) - else: - return AsyncInstructor( - client=client, - create=patched_create, - provider=Provider.GROQ, - mode=mode, - **kwargs, + +def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + """Construct the native Groq client for `from_provider`.""" + if groq is None: + from instructor.v2.core.errors import ConfigurationError + + raise ConfigurationError( + "The groq package is required to use the Groq provider. " + "Install it with `pip install groq`." ) + client = ( + groq.AsyncGroq(api_key=api_key) if async_client else groq.Groq(api_key=api_key) + ) + return from_groq(client, model=model_name, mode=mode or Mode.TOOLS, **kwargs) diff --git a/instructor/v2/providers/litellm/client.py b/instructor/v2/providers/litellm/client.py index bca847628..b4362e870 100644 --- a/instructor/v2/providers/litellm/client.py +++ b/instructor/v2/providers/litellm/client.py @@ -64,3 +64,29 @@ def from_litellm( provider=Provider.OPENAI, **kwargs, ) + + +def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, # noqa: ARG001 + async_client: bool, + mode: Mode | None, + api_key: str | None, # noqa: ARG001 + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + from instructor.v2.core.errors import ConfigurationError + + try: + from litellm import acompletion, completion + except ImportError: + raise ConfigurationError( + "The litellm package is required to use the LiteLLM provider. " + "Install it with `pip install litellm`." + ) from None + return from_litellm( + acompletion if async_client else completion, + mode=mode or Mode.TOOLS, + async_client=async_client, + **kwargs, + ) diff --git a/instructor/v2/providers/mistral/client.py b/instructor/v2/providers/mistral/client.py index dc2e09772..b96faf550 100644 --- a/instructor/v2/providers/mistral/client.py +++ b/instructor/v2/providers/mistral/client.py @@ -11,12 +11,13 @@ from __future__ import annotations +import os from typing import TYPE_CHECKING, Any, Literal, overload from instructor.v2.core.client import AsyncInstructor, Instructor +from instructor.v2.core.client_factory import create_instructor from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from instructor.v2.core.patch import patch_v2 # Ensure handlers are registered (decorators auto-register on import) from instructor.v2.providers.mistral import handlers # noqa: F401 @@ -100,86 +101,45 @@ def from_mistral( >>> # Or use structured outputs >>> instructor_client = from_mistral(client, mode=Mode.JSON_SCHEMA) """ - from instructor.v2.core.registry import mode_registry, normalize_mode - - # Check if mistralai is installed + return create_instructor( + client, + provider=Provider.MISTRAL, + mode=mode, + model=model, + use_async=use_async, + sync_types=(Mistral,) if Mistral is not None else None, + **kwargs, + ) + + +def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + """Construct the native Mistral client for `from_provider`.""" if Mistral is None: - from instructor.v2.core.errors import ClientError - - raise ClientError( - "mistralai is not installed. Install it with: pip install mistralai" - ) - - # Normalize provider-specific modes to generic modes - normalized_mode = normalize_mode(Provider.MISTRAL, mode) - - # Validate mode is registered (use normalized mode for check) - if not mode_registry.is_registered(Provider.MISTRAL, normalized_mode): - from instructor.v2.core.errors import ModeError - - available_modes = mode_registry.get_modes_for_provider(Provider.MISTRAL) - raise ModeError( - mode=str(mode.value), - provider=Provider.MISTRAL.value, - valid_modes=[str(m.value) for m in available_modes], - ) + from instructor.v2.core.errors import ConfigurationError - # Use normalized mode for patching - mode = normalized_mode - - # Validate client type - if not isinstance(client, Mistral): - from instructor.v2.core.errors import ClientError - - raise ClientError( - f"Client must be an instance of mistralai.Mistral. " - f"Got: {type(client).__name__}" - ) - - # Create wrapper functions for Mistral's unique API - if use_async: - - async def async_wrapper(*args: Any, **wrapper_kwargs: Any) -> Any: - """Async wrapper that handles streaming.""" - if wrapper_kwargs.pop("stream", False): - return await client.chat.stream_async(*args, **wrapper_kwargs) - return await client.chat.complete_async(*args, **wrapper_kwargs) - - # Patch using v2 registry - patched_create = patch_v2( - func=async_wrapper, - provider=Provider.MISTRAL, - mode=mode, - default_model=model, + raise ConfigurationError( + "The mistralai package is required to use the Mistral provider. " + "Install it with `pip install mistralai`." ) - - return AsyncInstructor( - client=client, - create=patched_create, - provider=Provider.MISTRAL, - mode=mode, - **kwargs, - ) - else: - - def sync_wrapper(*args: Any, **wrapper_kwargs: Any) -> Any: - """Sync wrapper that handles streaming.""" - if wrapper_kwargs.pop("stream", False): - return client.chat.stream(*args, **wrapper_kwargs) - return client.chat.complete(*args, **wrapper_kwargs) - - # Patch using v2 registry - patched_create = patch_v2( - func=sync_wrapper, - provider=Provider.MISTRAL, - mode=mode, - default_model=model, - ) - - return Instructor( - client=client, - create=patched_create, - provider=Provider.MISTRAL, - mode=mode, - **kwargs, + api_key = api_key or os.environ.get("MISTRAL_API_KEY") + if not api_key: + raise ValueError( + "MISTRAL_API_KEY is not set. " + "Set it with `export MISTRAL_API_KEY=`." ) + client = Mistral(api_key=api_key) + return from_mistral( + client, + model=model_name, + mode=mode or Mode.TOOLS, + use_async=async_client, + **kwargs, + ) diff --git a/instructor/v2/providers/mistral/handlers.py b/instructor/v2/providers/mistral/handlers.py index 8e6a6c273..ff04c765d 100644 --- a/instructor/v2/providers/mistral/handlers.py +++ b/instructor/v2/providers/mistral/handlers.py @@ -36,7 +36,7 @@ from instructor.v2.dsl.parallel import ParallelBase, get_types_array from instructor.v2.dsl.partial import PartialBase from instructor.v2.dsl.simple_type import AdapterBase -from instructor.v2.core.multimodal import convert_messages as convert_messages_v1 +from instructor.v2.core.multimodal import convert_messages from instructor.v2.core.json import ( extract_json_from_codeblock, extract_json_from_stream, @@ -46,6 +46,10 @@ from instructor.v2.core.messages import dump_message, merge_consecutive_messages from instructor.v2.core.decorators import register_mode_handler from instructor.v2.core.handler import ModeHandler +from instructor.v2.providers.mistral.multimodal import ( + image_from_params, + media_to_mistral, +) class MistralHandlerBase(ModeHandler): @@ -151,8 +155,12 @@ def convert_messages( target_mode = Mode.MISTRAL_STRUCTURED_OUTPUTS else: target_mode = Mode.MD_JSON - return convert_messages_v1( - messages, target_mode, autodetect_images=autodetect_images + return convert_messages( + messages, + target_mode, + autodetect_images=autodetect_images, + media_converter=lambda media: media_to_mistral(media, target_mode), + image_param_converter=image_from_params, ) def _parse_streaming_response( diff --git a/instructor/v2/providers/mistral/multimodal.py b/instructor/v2/providers/mistral/multimodal.py index b82ae7b73..4b9e04bb1 100644 --- a/instructor/v2/providers/mistral/multimodal.py +++ b/instructor/v2/providers/mistral/multimodal.py @@ -4,6 +4,15 @@ from typing import Any +from instructor.v2.core.mode import Mode +from instructor.v2.core.multimodal import Audio, Image, ImageParams, PDF +from instructor.v2.providers.openai.multimodal import audio_to_openai, image_to_openai + + +def image_from_params(params: ImageParams) -> Image: + """Construct a Mistral image from the provider-neutral image shorthand.""" + return Image.autodetect(params["source"]) + def pdf_to_mistral(pdf: Any) -> dict[str, Any]: if ( @@ -13,3 +22,12 @@ def pdf_to_mistral(pdf: Any) -> dict[str, Any]: ): return {"type": "document_url", "document_url": pdf.source} raise ValueError("Mistral only supports document URLs for now") + + +def media_to_mistral(media: Image | Audio | PDF, mode: Mode) -> dict[str, Any]: + """Encode media through the Mistral-owned OpenAI-compatible boundary.""" + if isinstance(media, PDF): + return pdf_to_mistral(media) + if isinstance(media, Image): + return image_to_openai(media, mode) + return audio_to_openai(media, mode) diff --git a/instructor/v2/providers/openai/client.py b/instructor/v2/providers/openai/client.py index 38dd91b58..c4041f36b 100644 --- a/instructor/v2/providers/openai/client.py +++ b/instructor/v2/providers/openai/client.py @@ -5,6 +5,8 @@ from __future__ import annotations +import os +from collections.abc import Callable from functools import partial from typing import Any, Literal, overload @@ -527,3 +529,213 @@ def from_deepseek( model=model, **kwargs, ) + + +def _required_api_key( + api_key: str | None, env_var: str, *, value_name: str = "api-key" +) -> str: + from instructor.v2.core.errors import ConfigurationError + + resolved = api_key or os.environ.get(env_var) + if resolved: + return resolved + raise ConfigurationError( + f"{env_var} is not set. Set it with `export {env_var}=` " + f"or pass it as kwarg api_key=" + ) + + +def _openai_client( + *, + async_client: bool, + api_key: str | None, + base_url: str | None, + kwargs: dict[str, Any], +) -> openai.OpenAI | openai.AsyncOpenAI: + client_kwargs: dict[str, Any] = {"api_key": api_key} + if base_url is not None: + client_kwargs["base_url"] = base_url + for key in ( + "organization", + "timeout", + "max_retries", + "default_headers", + "default_query", + "http_client", + "_strict_response_validation", + ): + if key in kwargs: + value = kwargs.pop(key) + if key == "max_retries" and value is None: + value = openai.DEFAULT_MAX_RETRIES + client_kwargs[key] = value + factory = openai.AsyncOpenAI if async_client else openai.OpenAI + return factory(**client_kwargs) + + +def compatible_model_builder( + factory: Callable[..., Instructor | AsyncInstructor], + *, + env_var: str, + base_url: str, + default_mode: Mode = Mode.TOOLS, +) -> Callable[..., Instructor | AsyncInstructor]: + """Create a lazy model-string builder for identical OpenAI wire clients.""" + + def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, + kwargs: dict[str, Any], + ) -> Instructor | AsyncInstructor: + client = _openai_client( + async_client=async_client, + api_key=_required_api_key(api_key, env_var), + base_url=kwargs.pop("base_url", base_url), + kwargs=kwargs, + ) + return factory(client, model=model_name, mode=mode or default_mode, **kwargs) + + return build_from_model + + +_COMPAT_BUILDERS = { + Provider.ANYSCALE: compatible_model_builder( + from_anyscale, + env_var="ANYSCALE_API_KEY", + base_url="https://api.endpoints.anyscale.com/v1", + ), + Provider.TOGETHER: compatible_model_builder( + from_together, + env_var="TOGETHER_API_KEY", + base_url="https://api.together.xyz/v1", + ), + Provider.DEEPSEEK: compatible_model_builder( + from_deepseek, + env_var="DEEPSEEK_API_KEY", + base_url="https://api.deepseek.com", + ), +} + + +def build_from_model( + *, + provider: Provider, + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + """Build a model-string client for OpenAI and OpenAI-compatible providers.""" + selected_mode = mode or Mode.TOOLS + if provider is Provider.OPENAI: + client = _openai_client( + async_client=async_client, + api_key=api_key, + base_url=kwargs.pop("base_url", None), + kwargs=kwargs, + ) + return from_openai(client, model=model_name, mode=selected_mode, **kwargs) + + if provider is Provider.AZURE_OPENAI: + from instructor.v2.core.errors import ConfigurationError + + azure_key = _required_api_key(api_key, "AZURE_OPENAI_API_KEY") + endpoint = kwargs.pop("azure_endpoint", os.environ.get("AZURE_OPENAI_ENDPOINT")) + if not endpoint: + raise ConfigurationError( + "AZURE_OPENAI_ENDPOINT is not set. Set it with " + "`export AZURE_OPENAI_ENDPOINT=` or pass it as " + "kwarg azure_endpoint=" + ) + factory = openai.AsyncAzureOpenAI if async_client else openai.AzureOpenAI + client = factory( + api_key=azure_key, + api_version=kwargs.pop("api_version", "2024-02-01"), + azure_endpoint=endpoint, + ) + return from_openai(client, model=model_name, mode=selected_mode, **kwargs) + + if provider is Provider.DATABRICKS: + from instructor.v2.core.errors import ConfigurationError + + token = ( + api_key + or os.environ.get("DATABRICKS_TOKEN") + or os.environ.get("DATABRICKS_API_KEY") + ) + if not token: + raise ConfigurationError( + "DATABRICKS_TOKEN is not set. Set it with " + "`export DATABRICKS_TOKEN=` or " + "`export DATABRICKS_API_KEY=` or pass it as kwarg " + "`api_key=`." + ) + base_url = ( + kwargs.pop("base_url", None) + or os.environ.get("DATABRICKS_BASE_URL") + or os.environ.get("DATABRICKS_HOST") + or os.environ.get("DATABRICKS_WORKSPACE_URL") + ) + if not base_url: + raise ConfigurationError( + "DATABRICKS_HOST is not set. Set it with " + "`export DATABRICKS_HOST=` or " + "`export DATABRICKS_WORKSPACE_URL=` or pass " + "`base_url=`." + ) + normalized_url = str(base_url).rstrip("/") + if not normalized_url.endswith("/serving-endpoints"): + normalized_url += "/serving-endpoints" + client = _openai_client( + async_client=async_client, + api_key=token, + base_url=normalized_url, + kwargs=kwargs, + ) + return from_databricks(client, model=model_name, mode=selected_mode, **kwargs) + + if provider is Provider.OLLAMA: + base_url = kwargs.pop("base_url", "http://localhost:11434/v1") + client = _openai_client( + async_client=async_client, + api_key=api_key or "ollama", + base_url=base_url, + kwargs=kwargs, + ) + tool_models = { + "llama3.1", + "llama3.2", + "llama4", + "mistral-nemo", + "firefunction-v2", + "command-a", + "command-r", + "command-r-plus", + "command-r7b", + "qwen2.5", + "qwen2.5-coder", + "qwen3", + "devstral", + } + default_mode = ( + Mode.TOOLS + if any(name in model_name.lower() for name in tool_models) + else Mode.JSON + ) + return from_openai( + client, model=model_name, mode=mode or default_mode, **kwargs + ) + + return _COMPAT_BUILDERS[provider]( + provider=provider, + model_name=model_name, + async_client=async_client, + mode=mode, + api_key=api_key, + kwargs=kwargs, + ) diff --git a/instructor/v2/providers/openai/handlers.py b/instructor/v2/providers/openai/handlers.py index 4aa816b98..bdce074fc 100644 --- a/instructor/v2/providers/openai/handlers.py +++ b/instructor/v2/providers/openai/handlers.py @@ -34,50 +34,33 @@ from instructor.v2.dsl.parallel import ParallelBase, ParallelModel, get_types_array from instructor.v2.dsl.partial import PartialBase from instructor.v2.dsl.simple_type import AdapterBase -from instructor.v2.core.multimodal import convert_messages as convert_messages_v1 +from instructor.v2.core.multimodal import convert_messages from instructor.v2.core.json import ( extract_json_from_codeblock, extract_json_from_stream, extract_json_from_stream_async, ) from instructor.v2.core.messages import dump_message, merge_consecutive_messages +from instructor.v2.providers.openai.multimodal import image_from_params, media_to_openai from instructor.v2.providers.openai.schema import generate_openai_schema from instructor.v2.core.decorators import register_mode_handler from instructor.v2.core.handler import ModeHandler +from instructor.v2.core.provider_specs import PROVIDER_SPECS -OPENAI_COMPAT_PROVIDERS = [ - Provider.OPENAI, - Provider.ANYSCALE, - Provider.TOGETHER, - Provider.DATABRICKS, - Provider.DEEPSEEK, - Provider.OPENROUTER, - Provider.GROQ, - Provider.FIREWORKS, - Provider.CEREBRAS, -] +def _providers_for_mode(mode: Mode) -> list[Provider]: + return [ + spec.provider + for spec in PROVIDER_SPECS.values() + if spec.handler_module == __name__ and mode in spec.supported_modes + ] -OPENAI_PARALLEL_TOOL_PROVIDERS = [ - Provider.OPENAI, - Provider.ANYSCALE, - Provider.TOGETHER, - Provider.DATABRICKS, - Provider.DEEPSEEK, - Provider.OPENROUTER, - Provider.CEREBRAS, -] -OPENAI_JSON_SCHEMA_PROVIDERS = [ - Provider.OPENAI, - Provider.ANYSCALE, - Provider.TOGETHER, - Provider.DATABRICKS, - Provider.DEEPSEEK, - Provider.GROQ, - Provider.FIREWORKS, - Provider.CEREBRAS, -] +OPENAI_COMPAT_PROVIDERS = _providers_for_mode(Mode.TOOLS) +OPENAI_JSON_PROVIDERS = _providers_for_mode(Mode.JSON) +OPENAI_JSON_SCHEMA_PROVIDERS = _providers_for_mode(Mode.JSON_SCHEMA) +OPENAI_MD_JSON_PROVIDERS = _providers_for_mode(Mode.MD_JSON) +OPENAI_PARALLEL_TOOL_PROVIDERS = _providers_for_mode(Mode.PARALLEL_TOOLS) def _is_stream_response(response: Any) -> bool: @@ -428,8 +411,12 @@ def convert_messages( self, messages: list[dict[str, Any]], autodetect_images: bool = False ) -> list[dict[str, Any]]: """Convert multimodal messages for OpenAI-compatible formats.""" - return convert_messages_v1( - messages, self.mode, autodetect_images=autodetect_images + return convert_messages( + messages, + self.mode, + autodetect_images=autodetect_images, + media_converter=lambda media: media_to_openai(media, self.mode), + image_param_converter=image_from_params, ) def _parse_streaming_response( @@ -735,7 +722,7 @@ def parse_response( return self._finalize_parsed_result(response_model, response, parsed) -@register_mode_handler(OPENAI_COMPAT_PROVIDERS, Mode.JSON) +@register_mode_handler(OPENAI_JSON_PROVIDERS, Mode.JSON) class OpenAIJSONHandler(OpenAIHandlerBase): """Handler for OpenAI JSON mode (response_format=json_object).""" @@ -822,7 +809,7 @@ def parse_response( return self._finalize_parsed_result(response_model, response, parsed) -@register_mode_handler(OPENAI_COMPAT_PROVIDERS, Mode.MD_JSON) +@register_mode_handler(OPENAI_MD_JSON_PROVIDERS, Mode.MD_JSON) class OpenAIMDJSONHandler(OpenAIHandlerBase): """Handler for MD_JSON mode - extract JSON from markdown code blocks.""" diff --git a/instructor/v2/providers/openai/multimodal.py b/instructor/v2/providers/openai/multimodal.py index c18c2bf56..e1d719a54 100644 --- a/instructor/v2/providers/openai/multimodal.py +++ b/instructor/v2/providers/openai/multimodal.py @@ -8,10 +8,16 @@ import requests from instructor.v2.core.mode import Mode +from instructor.v2.core.multimodal import Audio, Image, ImageParams, PDF RESPONSES_MODES = {Mode.RESPONSES_TOOLS, Mode.RESPONSES_TOOLS_WITH_INBUILT_TOOLS} +def image_from_params(params: ImageParams) -> Image: + """Construct an OpenAI image from the provider-neutral image shorthand.""" + return Image.autodetect(params["source"]) + + def image_to_openai(image: Any, mode: Mode) -> dict[str, Any]: image_type = "input_image" if mode in RESPONSES_MODES else "image_url" if ( @@ -81,3 +87,12 @@ def pdf_to_openai(pdf: Any, mode: Mode) -> dict[str, Any]: }, } raise ValueError("PDF data is missing for base64 encoding.") + + +def media_to_openai(media: Image | Audio | PDF, mode: Mode) -> dict[str, Any]: + """Encode a typed media item through OpenAI-owned conversion.""" + if isinstance(media, Image): + return image_to_openai(media, mode) + if isinstance(media, Audio): + return audio_to_openai(media, mode) + return pdf_to_openai(media, mode) diff --git a/instructor/v2/providers/openrouter/__init__.py b/instructor/v2/providers/openrouter/__init__.py index c0a1324d4..9b8b4806e 100644 --- a/instructor/v2/providers/openrouter/__init__.py +++ b/instructor/v2/providers/openrouter/__init__.py @@ -1,6 +1,17 @@ """OpenRouter v2 provider handlers and client.""" from .client import from_openrouter -from .handlers import OpenRouterJSONSchemaHandler +from .handlers import ( + OpenRouterJSONSchemaHandler, + OpenRouterMDJSONHandler, + OpenRouterParallelToolsHandler, + OpenRouterToolsHandler, +) -__all__ = ["OpenRouterJSONSchemaHandler", "from_openrouter"] +__all__ = [ + "OpenRouterJSONSchemaHandler", + "OpenRouterMDJSONHandler", + "OpenRouterParallelToolsHandler", + "OpenRouterToolsHandler", + "from_openrouter", +] diff --git a/instructor/v2/providers/openrouter/client.py b/instructor/v2/providers/openrouter/client.py index d78f69a55..30a81d6ca 100644 --- a/instructor/v2/providers/openrouter/client.py +++ b/instructor/v2/providers/openrouter/client.py @@ -9,7 +9,10 @@ from instructor.v2.core.client import AsyncInstructor, Instructor from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from instructor.v2.providers.openai.client import _from_openai_compat +from instructor.v2.providers.openai.client import ( + _from_openai_compat, + compatible_model_builder, +) # Ensure OpenRouter handlers are registered (overrides JSON_SCHEMA). from instructor.v2.providers.openrouter import handlers # noqa: F401 @@ -48,4 +51,11 @@ def from_openrouter( ) -__all__ = ["from_openrouter"] +build_from_model = compatible_model_builder( + from_openrouter, + env_var="OPENROUTER_API_KEY", + base_url="https://openrouter.ai/api/v1", +) + + +__all__ = ["build_from_model", "from_openrouter"] diff --git a/instructor/v2/providers/openrouter/handlers.py b/instructor/v2/providers/openrouter/handlers.py index eb9ee29fc..13001814b 100644 --- a/instructor/v2/providers/openrouter/handlers.py +++ b/instructor/v2/providers/openrouter/handlers.py @@ -10,15 +10,31 @@ from instructor.v2.core.providers import Provider from instructor.v2.core.decorators import register_mode_handler -# Register OpenAI-compatible handlers (TOOLS, MD_JSON, PARALLEL_TOOLS) for OpenRouter. -from instructor.v2.providers.openai import handlers as _openai_handlers # noqa: F401 from instructor.v2.providers.openai.handlers import ( + OpenAIMDJSONHandler, OpenAIJSONSchemaHandler, + OpenAIParallelToolsHandler, + OpenAIToolsHandler, handle_openrouter_structured_outputs, reask_default, ) +@register_mode_handler(Provider.OPENROUTER, Mode.TOOLS) +class OpenRouterToolsHandler(OpenAIToolsHandler): + """OpenRouter tools use the OpenAI-compatible wire protocol.""" + + +@register_mode_handler(Provider.OPENROUTER, Mode.MD_JSON) +class OpenRouterMDJSONHandler(OpenAIMDJSONHandler): + """OpenRouter markdown JSON uses the OpenAI-compatible wire protocol.""" + + +@register_mode_handler(Provider.OPENROUTER, Mode.PARALLEL_TOOLS) +class OpenRouterParallelToolsHandler(OpenAIParallelToolsHandler): + """OpenRouter parallel tools use the OpenAI-compatible wire protocol.""" + + @register_mode_handler(Provider.OPENROUTER, Mode.JSON_SCHEMA) class OpenRouterJSONSchemaHandler(OpenAIJSONSchemaHandler): """Handler for OpenRouter structured outputs.""" @@ -44,4 +60,9 @@ def handle_reask( return reask_default(kwargs, response, exception) -__all__ = ["OpenRouterJSONSchemaHandler"] +__all__ = [ + "OpenRouterJSONSchemaHandler", + "OpenRouterMDJSONHandler", + "OpenRouterParallelToolsHandler", + "OpenRouterToolsHandler", +] diff --git a/instructor/v2/providers/perplexity/client.py b/instructor/v2/providers/perplexity/client.py index 946bc449a..b45a59908 100644 --- a/instructor/v2/providers/perplexity/client.py +++ b/instructor/v2/providers/perplexity/client.py @@ -9,7 +9,10 @@ from instructor.v2.core.client import AsyncInstructor, Instructor from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from instructor.v2.providers.openai.client import _from_openai_compat +from instructor.v2.providers.openai.client import ( + _from_openai_compat, + compatible_model_builder, +) # Ensure handlers are registered. from instructor.v2.providers.perplexity import handlers # noqa: F401 @@ -48,4 +51,12 @@ def from_perplexity( ) -__all__ = ["from_perplexity"] +build_from_model = compatible_model_builder( + from_perplexity, + env_var="PERPLEXITY_API_KEY", + base_url="https://api.perplexity.ai", + default_mode=Mode.MD_JSON, +) + + +__all__ = ["build_from_model", "from_perplexity"] diff --git a/instructor/v2/providers/vertexai/client.py b/instructor/v2/providers/vertexai/client.py index 556552ac9..53194f109 100644 --- a/instructor/v2/providers/vertexai/client.py +++ b/instructor/v2/providers/vertexai/client.py @@ -2,12 +2,14 @@ from __future__ import annotations +import os +import warnings from typing import Any, Literal, TYPE_CHECKING, overload from instructor.v2.core.client import AsyncInstructor, Instructor +from instructor.v2.core.client_factory import create_instructor from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from instructor.v2.core.patch import patch_v2 if TYPE_CHECKING: import vertexai.generative_models as gm @@ -42,56 +44,71 @@ def from_vertexai( use_async: bool = False, **kwargs: Any, ) -> Instructor | AsyncInstructor: - from instructor.v2.core.registry import mode_registry, normalize_mode + generative_model_type = getattr(gm, "GenerativeModel", None) + sync_types = ( + (generative_model_type,) if isinstance(generative_model_type, type) else None + ) - normalized_mode = normalize_mode(Provider.VERTEXAI, mode) - if not mode_registry.is_registered(Provider.VERTEXAI, normalized_mode): - from instructor.v2.core.errors import ModeError + return create_instructor( + client, + provider=Provider.VERTEXAI, + mode=mode, + use_async=use_async, + sync_types=sync_types, + **kwargs, + ) - available_modes = mode_registry.get_modes_for_provider(Provider.VERTEXAI) - raise ModeError( - mode=str(mode.value), - provider=Provider.VERTEXAI.value, - valid_modes=[str(m.value) for m in available_modes], - ) +def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, # noqa: ARG001 + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + from instructor.v2.core.errors import ConfigurationError + + warnings.warn( + "The 'vertexai' provider is deprecated. Use 'google' provider with " + "vertexai=True instead. Example: " + "instructor.from_provider('google/gemini-pro', vertexai=True)", + DeprecationWarning, + stacklevel=2, + ) + try: + import vertexai + except ImportError: + raise ConfigurationError( + "The vertexai package is required to use the VertexAI provider. " + "Install it with `pip install google-cloud-aiplatform`." + ) from None if gm is None: - from instructor.v2.core.errors import ClientError - - raise ClientError( - "vertexai is not installed. Install it with: pip install google-cloud-aiplatform" + raise ConfigurationError( + "The vertexai package is required to use the VertexAI provider. " + "Install it with `pip install google-cloud-aiplatform`." ) - - if not isinstance(client, gm.GenerativeModel): - from instructor.v2.core.errors import ClientError - - raise ClientError( - "Client must be an instance of vertexai.generative_models.GenerativeModel. " - f"Got: {type(client).__name__}" + project = kwargs.pop("project", os.environ.get("GOOGLE_CLOUD_PROJECT")) + if not project: + raise ValueError( + "Project ID is required for Vertex AI. Set it with " + "`export GOOGLE_CLOUD_PROJECT=` or pass it as " + "kwarg project=" ) - - create = client.generate_content_async if use_async else client.generate_content - patched_create = patch_v2( - func=create, - provider=Provider.VERTEXAI, - mode=normalized_mode, + vertexai.init( + project=project, + location=kwargs.pop( + "location", os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1") + ), + credentials=kwargs.pop("credentials", None), ) - - if use_async: - return AsyncInstructor( - client=client, - create=patched_create, - provider=Provider.VERTEXAI, - mode=normalized_mode, - **kwargs, - ) - return Instructor( - client=client, - create=patched_create, - provider=Provider.VERTEXAI, - mode=normalized_mode, + return from_vertexai( + gm.GenerativeModel(model_name), + use_async=async_client, + mode=mode or Mode.TOOLS, **kwargs, ) -__all__ = ["from_vertexai"] +__all__ = ["build_from_model", "from_vertexai"] diff --git a/instructor/v2/providers/writer/client.py b/instructor/v2/providers/writer/client.py index 397a68979..ab0921f6d 100644 --- a/instructor/v2/providers/writer/client.py +++ b/instructor/v2/providers/writer/client.py @@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any, overload from instructor.v2.core.client import AsyncInstructor, Instructor +from instructor.v2.core.client_factory import create_instructor from instructor.v2.core.mode import Mode from instructor.v2.core.providers import Provider -from instructor.v2.core.patch import patch_v2 # Ensure handlers are registered (decorators auto-register on import) from instructor.v2.providers.writer import handlers # noqa: F401 @@ -79,9 +79,6 @@ def from_writer( >>> # Or use MD_JSON mode for text extraction >>> instructor_client = from_writer(client, mode=Mode.MD_JSON) """ - from instructor.v2.core.registry import mode_registry, normalize_mode - - # Check if writerai SDK is installed if Writer is None or AsyncWriter is None: from instructor.v2.core.errors import ClientError @@ -89,63 +86,33 @@ def from_writer( "writerai is not installed. Install it with: pip install writer-sdk" ) - # Normalize provider-specific modes to generic modes - # WRITER_TOOLS -> TOOLS, WRITER_JSON -> MD_JSON - normalized_mode = normalize_mode(Provider.WRITER, mode) - - # Validate mode is registered (use normalized mode for check) - if not mode_registry.is_registered(Provider.WRITER, normalized_mode): - from instructor.v2.core.errors import ModeError - - available_modes = mode_registry.get_modes_for_provider(Provider.WRITER) - raise ModeError( - mode=str(mode.value), - provider=Provider.WRITER.value, - valid_modes=[str(m.value) for m in available_modes], - ) - - # Use normalized mode for patching - mode = normalized_mode - - # Validate client type - valid_client_types = ( - Writer, - AsyncWriter, - ) - - if not isinstance(client, valid_client_types): - from instructor.v2.core.errors import ClientError - - raise ClientError( - f"Client must be an instance of one of: {', '.join(t.__name__ for t in valid_client_types)}. " - f"Got: {type(client).__name__}" - ) - - # Get create function - Writer uses chat.chat instead of chat.completions.create - create = client.chat.chat - - # Patch using v2 registry, passing the model for injection - patched_create = patch_v2( - func=create, + return create_instructor( + client, provider=Provider.WRITER, mode=mode, - default_model=model, + model=model, + sync_types=(Writer,), + async_types=(AsyncWriter,), + **kwargs, ) - # Return sync or async instructor - if isinstance(client, Writer): - return Instructor( - client=client, - create=patched_create, - provider=Provider.WRITER, - mode=mode, - **kwargs, - ) - else: - return AsyncInstructor( - client=client, - create=patched_create, - provider=Provider.WRITER, - mode=mode, - **kwargs, + +def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + """Construct the native Writer client for `from_provider`.""" + if Writer is None or AsyncWriter is None: + from instructor.v2.core.errors import ConfigurationError + + raise ConfigurationError( + "The writerai package is required to use the Writer provider. " + "Install it with `pip install writer-sdk`." ) + client = AsyncWriter(api_key=api_key) if async_client else Writer(api_key=api_key) + return from_writer(client, model=model_name, mode=mode or Mode.TOOLS, **kwargs) diff --git a/instructor/v2/providers/xai/client.py b/instructor/v2/providers/xai/client.py index 11bbb54b4..f97ac7d71 100644 --- a/instructor/v2/providers/xai/client.py +++ b/instructor/v2/providers/xai/client.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +from collections.abc import AsyncGenerator from typing import TYPE_CHECKING, Any, cast, overload from pydantic import BaseModel @@ -296,12 +297,16 @@ async def acreate( schema=json.dumps(_get_model_schema(prepared_model)), ) ) - json_chunks = (chunk.content async for _, chunk in chat.stream()) # type: ignore[misc] + + async def json_chunks() -> AsyncGenerator[str, None]: + async for _, chunk in chat.stream(): # type: ignore[misc] + yield chunk.content + rm = cast(type[BaseModel], prepared_model) if issubclass(rm, IterableBase): - return rm.tasks_from_chunks_async(json_chunks) + return rm.tasks_from_chunks_async(json_chunks()) elif issubclass(rm, PartialBase): - return rm.model_from_chunks_async(json_chunks) # type: ignore + return rm.model_from_chunks_async(json_chunks()) # type: ignore else: raise ValueError( f"Unsupported response model type for streaming: {_get_model_name(response_model)}" @@ -609,3 +614,30 @@ def create( mode=mode, **kwargs, ) + + +def build_from_model( + *, + provider: Provider, # noqa: ARG001 + model_name: str, + async_client: bool, + mode: Mode | None, + api_key: str | None, + kwargs: dict[str, Any], +) -> Instructor | AsyncInstructor: + from instructor.v2.core.errors import ConfigurationError + + if SyncClient is None or AsyncClient is None: + raise ConfigurationError( + "The xAI provider needs the optional dependency `xai-sdk`. " + 'Install it with `uv pip install "instructor[xai]"` ' + '(or `pip install "instructor[xai]"`). ' + "Note: xai-sdk requires Python 3.10+." + ) + factory = AsyncClient if async_client else SyncClient + return from_xai( + factory(api_key=api_key), + mode=mode or Mode.TOOLS, + model=model_name, + **kwargs, + ) From a8f873ae93ccfb9a6c2883b60370ba23c22605f7 Mon Sep 17 00:00:00 2001 From: Jason Liu Date: Sun, 7 Jun 2026 18:42:57 -0400 Subject: [PATCH 2/6] fix(v2): type wrapped async callables --- instructor/v2/core/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/instructor/v2/core/utils.py b/instructor/v2/core/utils.py index bf181ba1f..e666350a8 100644 --- a/instructor/v2/core/utils.py +++ b/instructor/v2/core/utils.py @@ -15,9 +15,11 @@ def is_async(func: Callable[..., Any]) -> bool: """Return whether a callable is async, following wrapped callables.""" is_coroutine = inspect.iscoroutinefunction(func) - while callable(wrapped := getattr(func, "__wrapped__", None)): - func = wrapped + wrapped = getattr(func, "__wrapped__", None) + while callable(wrapped): + func = cast(Callable[..., Any], wrapped) is_coroutine = is_coroutine or inspect.iscoroutinefunction(func) + wrapped = getattr(func, "__wrapped__", None) return is_coroutine From a2259f763d6f5b1a419cf2a9b42375db2b1b0726 Mon Sep 17 00:00:00 2001 From: Jason Liu Date: Sun, 7 Jun 2026 20:00:36 -0400 Subject: [PATCH 3/6] test(v2): align baseline provider contracts --- tests/providers/test_auto_client.py | 253 +++++++--------- tests/v2/provider_matrix.py | 64 +++- tests/v2/test_auto_client_deterministic.py | 330 +++++++++++++++++---- tests/v2/test_client_unified.py | 4 + tests/v2/test_provider_modes.py | 142 +++------ 5 files changed, 506 insertions(+), 287 deletions(-) diff --git a/tests/providers/test_auto_client.py b/tests/providers/test_auto_client.py index 97623bdca..19541ebf4 100644 --- a/tests/providers/test_auto_client.py +++ b/tests/providers/test_auto_client.py @@ -1,5 +1,7 @@ from __future__ import annotations +from types import ModuleType + import pytest from instructor.auto_client import from_provider from instructor.core.exceptions import ( @@ -7,7 +9,6 @@ ConfigurationError, InstructorRetryException, ) -from openai.types.chat import ChatCompletionUserMessageParam from pydantic import BaseModel @@ -17,7 +18,7 @@ class User(BaseModel): age: int -USER_EXTRACTION_PROMPT: ChatCompletionUserMessageParam = { +USER_EXTRACTION_PROMPT = { "role": "user", "content": "Ivan is 28 and strays in Singapore. Extract it as a user object", } @@ -56,27 +57,34 @@ def should_skip_provider_exception(exc: Exception) -> bool: """Return True for provider failures caused by local environment setup.""" if isinstance(exc, (ClientError, ConfigurationError, ImportError)): return True - if isinstance(exc, InstructorRetryException): - message = str(exc).lower() - return any( - marker in message - for marker in ( - "api key", - "authentication", - "connection", - "connect", - "quota", - "rate limit", - "resource_exhausted", - ) + message = str(exc).lower() + if any( + marker in message + for marker in ( + "api key", + "api_key", + "authentication", + "credentials", + "project id is required", + "client must be instantiated", + "token", + "connection", + "connect", + "quota", + "rate limit", + "resource_exhausted", ) + ): + return True + if isinstance(exc, InstructorRetryException): + return False return False def skip_or_raise_provider_exception(provider_string: str, exc: Exception) -> None: if should_skip_provider_exception(exc): pytest.skip( - f"Provider {provider_string} not available in this environment: {exc}" # ty: ignore[too-many-positional-arguments] + f"Provider {provider_string} not available in this environment: {exc}" ) raise exc @@ -86,9 +94,7 @@ def test_user_extraction_sync(provider_string): """Test user extraction for each provider (sync).""" if should_skip_provider(provider_string): - pytest.skip( - f"Skipping provider {provider_string} on CI" # ty: ignore[too-many-positional-arguments] - ) + pytest.skip(f"Skipping provider {provider_string} on CI") return try: @@ -114,9 +120,7 @@ async def test_user_extraction_async(provider_string): """Test user extraction for each provider (async).""" if should_skip_provider(provider_string): - pytest.skip( - f"Skipping provider {provider_string} on CI" # ty: ignore[too-many-positional-arguments] - ) + pytest.skip(f"Skipping provider {provider_string} on CI") return try: @@ -165,18 +169,19 @@ def builder(**kwargs): calls.append(kwargs) return result - monkeypatch.setitem(auto_client._PROVIDER_BUILDERS, "openai", builder) + module = ModuleType("test_builder") + module.build_from_model = builder # type: ignore[attr-defined] + monkeypatch.setattr(auto_client.importlib, "import_module", lambda _path: module) assert from_provider("openai/gpt-4", api_key="test-key", extra="value") is result assert calls == [ { - "provider": "openai", + "provider": auto_client.ALIAS_TO_PROVIDER["openai"], "model_name": "gpt-4", "async_client": False, "mode": None, "api_key": "test-key", "kwargs": {"extra": "value"}, - "provider_info": {"provider": "openai", "operation": "initialize"}, } ] @@ -188,9 +193,7 @@ def test_additional_kwargs_passed(): import os if os.getenv("INSTRUCTOR_ENV") == "CI" or not os.getenv("ANTHROPIC_API_KEY"): - pytest.skip( - "Skipping live Anthropic test without credentials" # ty: ignore[too-many-positional-arguments] - ) + pytest.skip("Skipping live Anthropic test without credentials") return client = instructor.from_provider("anthropic/claude-sonnet-4-6", max_tokens=10) @@ -228,7 +231,9 @@ def test_openai_provider_base_url_handling(async_client, base_url, expected_base mock_client = MagicMock() mock_openai_class.return_value = mock_client - with patch("instructor.from_openai") as mock_from_openai: + with patch( + "instructor.v2.providers.openai.client.from_openai" + ) as mock_from_openai: mock_instructor = MagicMock() mock_from_openai.return_value = mock_instructor @@ -266,7 +271,9 @@ def test_databricks_provider_uses_environment_configuration(): mock_client = MagicMock() mock_openai_class.return_value = mock_client - with patch("instructor.from_openai") as mock_from_openai: + with patch( + "instructor.v2.providers.openai.client.from_databricks" + ) as mock_from_openai: mock_instructor = MagicMock() mock_from_openai.return_value = mock_instructor @@ -300,7 +307,9 @@ def test_databricks_provider_respects_custom_base_url(): mock_client = MagicMock() mock_openai_class.return_value = mock_client - with patch("instructor.from_openai") as mock_from_openai: + with patch( + "instructor.v2.providers.openai.client.from_databricks" + ) as mock_from_openai: mock_instructor = MagicMock() mock_from_openai.return_value = mock_instructor @@ -334,7 +343,9 @@ def test_databricks_provider_async_client(): mock_client = MagicMock() mock_async_openai_class.return_value = mock_client - with patch("instructor.from_openai") as mock_from_openai: + with patch( + "instructor.v2.providers.openai.client.from_databricks" + ) as mock_from_openai: mock_instructor = MagicMock() mock_from_openai.return_value = mock_instructor @@ -367,7 +378,9 @@ def test_databricks_provider_requires_token(): with patch("openai.OpenAI") as mock_openai_class: mock_openai_class.return_value = MagicMock() - with patch("instructor.from_openai") as mock_from_openai: + with patch( + "instructor.v2.providers.openai.client.from_databricks" + ) as mock_from_openai: mock_from_openai.return_value = MagicMock() with patch.dict( os.environ, @@ -388,7 +401,9 @@ def test_databricks_provider_requires_host(): with patch("openai.OpenAI") as mock_openai_class: mock_openai_class.return_value = MagicMock() - with patch("instructor.from_openai") as mock_from_openai: + with patch( + "instructor.v2.providers.openai.client.from_databricks" + ) as mock_from_openai: mock_from_openai.return_value = MagicMock() with patch.dict( os.environ, @@ -404,60 +419,50 @@ def test_databricks_provider_requires_host(): def test_genai_mode_parameter_passed_to_provider(): """Test that mode parameter is correctly passed to provider functions.""" from unittest.mock import patch, MagicMock - from types import ModuleType - import sys import instructor - mock_google = ModuleType("google") - mock_genai = ModuleType("google.genai") - mock_google.__dict__["genai"] = mock_genai - - with patch.dict(sys.modules, {"google": mock_google, "google.genai": mock_genai}): - with patch("google.genai.Client", create=True) as mock_genai_class: - mock_client = MagicMock() - mock_genai_class.return_value = mock_client + with patch("instructor.v2.providers.genai.client.Client") as mock_genai_class: + mock_client = MagicMock() + mock_genai_class.return_value = mock_client - with patch("instructor.from_genai", create=True) as mock_from_genai: - mock_instructor = MagicMock() - mock_from_genai.return_value = mock_instructor + with patch( + "instructor.v2.providers.genai.client.from_genai" + ) as mock_from_genai: + mock_instructor = MagicMock() + mock_from_genai.return_value = mock_instructor - from_provider( - "google/gemini-2.5-flash", - mode=instructor.Mode.GENAI_STRUCTURED_OUTPUTS, - ) + from_provider( + "google/gemini-2.5-flash", + mode=instructor.Mode.GENAI_STRUCTURED_OUTPUTS, + ) - mock_from_genai.assert_called_once() - _, kwargs = mock_from_genai.call_args - assert "mode" in kwargs - assert kwargs["mode"] == instructor.Mode.GENAI_STRUCTURED_OUTPUTS + mock_from_genai.assert_called_once() + _, kwargs = mock_from_genai.call_args + assert "mode" in kwargs + assert kwargs["mode"] == instructor.Mode.GENAI_STRUCTURED_OUTPUTS def test_genai_mode_defaults_when_not_provided(): """Test that GenAI provider uses generic TOOLS mode when mode is not provided.""" from unittest.mock import patch, MagicMock - from types import ModuleType - import sys import instructor - mock_google = ModuleType("google") - mock_genai = ModuleType("google.genai") - mock_google.__dict__["genai"] = mock_genai - - with patch.dict(sys.modules, {"google": mock_google, "google.genai": mock_genai}): - with patch("google.genai.Client", create=True) as mock_genai_class: - mock_client = MagicMock() - mock_genai_class.return_value = mock_client + with patch("instructor.v2.providers.genai.client.Client") as mock_genai_class: + mock_client = MagicMock() + mock_genai_class.return_value = mock_client - with patch("instructor.from_genai", create=True) as mock_from_genai: - mock_instructor = MagicMock() - mock_from_genai.return_value = mock_instructor + with patch( + "instructor.v2.providers.genai.client.from_genai" + ) as mock_from_genai: + mock_instructor = MagicMock() + mock_from_genai.return_value = mock_instructor - from_provider("google/gemini-2.5-flash") + from_provider("google/gemini-2.5-flash") - mock_from_genai.assert_called_once() - _, kwargs = mock_from_genai.call_args - assert "mode" in kwargs - assert kwargs["mode"] == instructor.Mode.TOOLS + mock_from_genai.assert_called_once() + _, kwargs = mock_from_genai.call_args + assert "mode" in kwargs + assert kwargs["mode"] == instructor.Mode.TOOLS def test_google_provider_runtime_import_error_propagates(): @@ -468,11 +473,7 @@ def test_google_provider_runtime_import_error_propagates(): This error should propagate instead of being caught and converted to ConfigurationError about missing google-genai package. """ - from unittest.mock import patch, MagicMock - import sys - - # Create mock module for google.genai - mock_genai_module = MagicMock() + from unittest.mock import patch # Simulate socksio ImportError during Client() initialization def client_init_raises(*_args, **_kwargs): @@ -481,60 +482,39 @@ def client_init_raises(*_args, **_kwargs): "Make sure to install httpx using `pip install httpx[socks]`." ) - mock_genai_module.Client = client_init_raises - - # Create a mock google module - mock_google = MagicMock() - mock_google.genai = mock_genai_module - - # Patch sys.modules to use our mock modules - with patch.dict( - sys.modules, - {"google": mock_google, "google.genai": mock_genai_module}, + with patch( + "instructor.v2.providers.genai.client.Client", + side_effect=client_init_raises, ): - mock_from_genai = MagicMock() - with patch.object( - __import__("instructor"), "from_genai", mock_from_genai, create=True - ): - with pytest.raises(ImportError) as excinfo: - from_provider("google/gemini-2.5-flash") + with pytest.raises(ImportError) as excinfo: + from_provider("google/gemini-2.5-flash") - # Should be the socksio error, NOT a ConfigurationError about google-genai - assert "socksio" in str(excinfo.value) - assert "google-genai" not in str(excinfo.value) + # Should be the socksio error, NOT a ConfigurationError about google-genai + assert "socksio" in str(excinfo.value) + assert "google-genai" not in str(excinfo.value) def test_vertexai_provider_uses_vertexai_sdk_path(): """The deprecated vertexai provider still routes through the Vertex AI SDK.""" from unittest.mock import MagicMock, patch - from types import ModuleType - import sys import warnings - mock_vertexai = ModuleType("vertexai") - mock_gener_models = ModuleType("vertexai.generative_models") - mock_vertexai.__dict__["generative_models"] = mock_gener_models - - with patch.dict( - sys.modules, - { - "vertexai": mock_vertexai, - "vertexai.generative_models": mock_gener_models, - }, - ): - with patch("vertexai.init", create=True) as mock_init: + pytest.importorskip("vertexai") + with patch("vertexai.init") as mock_init: + with patch( + "instructor.v2.providers.vertexai.client.gm.GenerativeModel" + ) as mock_model: + mock_model.return_value = MagicMock() with patch( - "vertexai.generative_models.GenerativeModel", create=True - ) as mock_model: - mock_model.return_value = MagicMock() - with patch("instructor.from_vertexai") as mock_from_vertexai: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - from_provider( - "vertexai/gemini-pro", - project="demo-project", - location="us-central1", - ) + "instructor.v2.providers.vertexai.client.from_vertexai" + ) as mock_from_vertexai: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from_provider( + "vertexai/gemini-pro", + project="demo-project", + location="us-central1", + ) mock_init.assert_called_once() mock_model.assert_called_once_with("gemini-pro") @@ -547,36 +527,23 @@ def test_generative_ai_provider_runtime_import_error_propagates(): Similar to test_google_provider_runtime_import_error_propagates but for the deprecated generative-ai provider. """ - from unittest.mock import patch, MagicMock + from unittest.mock import patch import warnings - # Create mock module for google.genai - mock_genai_module = MagicMock() - # Simulate socksio ImportError during Client() initialization def client_init_raises(*_args, **_kwargs): raise ImportError( "Using SOCKS proxy, but the 'socksio' package is not installed." ) - mock_genai_module.Client = client_init_raises - - # Create a mock google module with genai attribute - mock_google = MagicMock() - mock_google.genai = mock_genai_module - - with patch.dict( - "sys.modules", - {"google": mock_google, "google.genai": mock_genai_module}, + with patch( + "instructor.v2.providers.genai.client.Client", + side_effect=client_init_raises, ): - mock_from_genai = MagicMock() - with patch.object( - __import__("instructor"), "from_genai", mock_from_genai, create=True - ): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - with pytest.raises(ImportError) as excinfo: - from_provider("generative-ai/gemini-pro") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + with pytest.raises(ImportError) as excinfo: + from_provider("generative-ai/gemini-pro") # Should be the socksio error, NOT a ConfigurationError assert "socksio" in str(excinfo.value) diff --git a/tests/v2/provider_matrix.py b/tests/v2/provider_matrix.py index ba24b4657..4f034f04f 100644 --- a/tests/v2/provider_matrix.py +++ b/tests/v2/provider_matrix.py @@ -2,10 +2,15 @@ from __future__ import annotations +import importlib.util +from pathlib import Path from typing import Any +import pytest + from instructor import Provider from instructor.v2.core.provider_specs import PROVIDER_SPECS +from instructor.v2.core.registry import mode_registry TEST_PROVIDER_SPECS = { @@ -21,7 +26,7 @@ def legacy_config_dicts() -> dict[Provider, dict[str, Any]]: - """Expose the old dict shape while tests migrate to ProviderSpec.""" + """Expose the old dict shape while the baseline tests migrate.""" return { provider: { "provider_string": spec.provider_string, @@ -36,3 +41,60 @@ def legacy_config_dicts() -> dict[Provider, dict[str, Any]]: } for provider, spec in TEST_PROVIDER_SPECS.items() } + + +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +_HANDLERS_LOADED: set[Provider] = set() + + +def handler_module_path(provider: Provider) -> Path | None: + """Return the registered handler implementation path for a provider.""" + module = PROVIDER_SPECS[provider].handler_module + if module is None: + return None + return _PROJECT_ROOT / f"{module.replace('.', '/')}.py" + + +def _is_expected_missing_dependency(provider: Provider, exc: ImportError) -> bool: + sdk_module = PROVIDER_SPECS[provider].sdk_module + if sdk_module is None: + return False + expected_root = sdk_module.split(".")[0] + missing_name = getattr(exc, "name", None) + if missing_name: + return missing_name.split(".")[0] == expected_root + return f"No module named '{expected_root}'" in str(exc) + + +def ensure_handlers_loaded( + provider: Provider, *, skip_missing_dependency: bool = False +) -> None: + """Load handlers once from the manifest path so registration tests share setup.""" + if provider in _HANDLERS_LOADED: + return + provider_modes = PROVIDER_HANDLER_MODES.get(provider, ()) + if provider_modes and all( + mode_registry.is_registered(provider, mode) for mode in provider_modes + ): + _HANDLERS_LOADED.add(provider) + return + path = handler_module_path(provider) + if path is None or not path.exists(): + return + spec = importlib.util.spec_from_file_location( + f"tests.v2.handlers_{provider.value}", + path, + ) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load handler module for {provider}") + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + except (ImportError, ModuleNotFoundError) as exc: + if skip_missing_dependency and _is_expected_missing_dependency(provider, exc): + pytest.skip( + f"{provider.value} handlers require optional dependency " + f"{PROVIDER_SPECS[provider].sdk_module}" + ) + raise + _HANDLERS_LOADED.add(provider) diff --git a/tests/v2/test_auto_client_deterministic.py b/tests/v2/test_auto_client_deterministic.py index cae767607..5fb4a7a34 100644 --- a/tests/v2/test_auto_client_deterministic.py +++ b/tests/v2/test_auto_client_deterministic.py @@ -14,6 +14,12 @@ class DummyCache: pass +def _module(path: str) -> ModuleType: + module = ModuleType(path) + module.__path__ = [] # type: ignore[attr-defined] + return module + + def test_from_provider_requires_provider_prefix() -> None: with pytest.raises(ConfigurationError, match="Model string must be in format"): auto_client.from_provider("gpt-5") @@ -24,6 +30,15 @@ def test_from_provider_rejects_unknown_provider() -> None: auto_client.from_provider("mystery/model") +def test_provider_builders_are_derived_from_supported_aliases() -> None: + routed_aliases = { + alias + for alias, provider in auto_client.ALIAS_TO_PROVIDER.items() + if auto_client.PROVIDER_SPECS[provider].model_builder_module is not None + } + assert routed_aliases == set(auto_client.supported_providers) + + def test_from_provider_passes_cache_and_api_key_to_builder( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -34,9 +49,15 @@ def fake_builder(**kwargs: Any) -> str: captured.update(kwargs) return "client" - monkeypatch.setitem(auto_client._PROVIDER_BUILDERS, "openai", fake_builder) + builder_module = ModuleType("test_builder") + builder_module.build_from_model = fake_builder # type: ignore[attr-defined] + monkeypatch.setattr( + auto_client.importlib, + "import_module", + lambda _path: builder_module, + ) - result = auto_client.from_provider( # ty: ignore[no-matching-overload] + result = auto_client.from_provider( "openai/gpt-5-nano", cache=cache, api_key="secret", @@ -45,7 +66,7 @@ def fake_builder(**kwargs: Any) -> str: ) assert result == "client" - assert captured["provider"] == "openai" + assert captured["provider"] == auto_client.ALIAS_TO_PROVIDER["openai"] assert captured["model_name"] == "gpt-5-nano" assert captured["api_key"] == "secret" assert captured["mode"] == Mode.JSON_SCHEMA @@ -54,23 +75,149 @@ def fake_builder(**kwargs: Any) -> str: assert "api_key" not in captured["kwargs"] +@pytest.mark.parametrize( + ( + "_provider", + "sdk_modules", + "factory_module", + "factory_name", + "expected_default", + ), + [ + ( + "cohere", + {"cohere": ("ClientV2", "AsyncClientV2")}, + "instructor.v2.providers.cohere.client", + "from_cohere", + Mode.TOOLS, + ), + ( + "mistral", + {"mistralai": ("Mistral",)}, + "instructor.v2.providers.mistral.client", + "from_mistral", + Mode.TOOLS, + ), + ( + "groq", + {"groq": ("Groq", "AsyncGroq")}, + "instructor.v2.providers.groq.client", + "from_groq", + Mode.TOOLS, + ), + ( + "writer", + {"writerai": ("Writer", "AsyncWriter")}, + "instructor.v2.providers.writer.client", + "from_writer", + Mode.TOOLS, + ), + ( + "cerebras", + { + "cerebras": (), + "cerebras.cloud": (), + "cerebras.cloud.sdk": ("Cerebras", "AsyncCerebras"), + }, + "instructor.v2.providers.cerebras.client", + "from_cerebras", + Mode.TOOLS, + ), + ( + "fireworks", + { + "fireworks": (), + "fireworks.client": ("Fireworks", "AsyncFireworks"), + }, + "instructor.v2.providers.fireworks.client", + "from_fireworks", + Mode.TOOLS, + ), + ], +) +def test_builders_forward_requested_mode( + monkeypatch: pytest.MonkeyPatch, + _provider: str, + sdk_modules: dict[str, tuple[str, ...]], + factory_module: str, + factory_name: str, + expected_default: Mode, +) -> None: + class FakeClient: + def __init__(self, **_kwargs: Any) -> None: + pass + + factory_module_obj = __import__(factory_module, fromlist=[factory_name]) + for module_path, class_names in sdk_modules.items(): + module = _module(module_path) + for class_name in class_names: + setattr(module, class_name, FakeClient) + monkeypatch.setitem(__import__("sys").modules, module_path, module) + + factory_calls: list[dict[str, Any]] = [] + + def fake_factory(_client: Any, **kwargs: Any) -> dict[str, Any]: + factory_calls.append(kwargs) + return kwargs + + if _provider == "cohere": + monkeypatch.setattr( + factory_module_obj, "cohere", __import__("sys").modules["cohere"] + ) + elif _provider == "mistral": + monkeypatch.setattr(factory_module_obj, "Mistral", FakeClient) + elif _provider == "groq": + monkeypatch.setattr( + factory_module_obj, "groq", __import__("sys").modules["groq"] + ) + elif _provider == "writer": + monkeypatch.setattr(factory_module_obj, "Writer", FakeClient) + monkeypatch.setattr(factory_module_obj, "AsyncWriter", FakeClient) + elif _provider == "cerebras": + monkeypatch.setattr(factory_module_obj, "Cerebras", FakeClient) + monkeypatch.setattr(factory_module_obj, "AsyncCerebras", FakeClient) + elif _provider == "fireworks": + monkeypatch.setattr(factory_module_obj, "Fireworks", FakeClient) + monkeypatch.setattr(factory_module_obj, "AsyncFireworks", FakeClient) + monkeypatch.setattr(factory_module_obj, factory_name, fake_factory) + + builder = cast(Any, factory_module_obj).build_from_model + builder( + provider=auto_client.ALIAS_TO_PROVIDER[_provider], + model_name="test-model", + async_client=False, + mode=Mode.MD_JSON, + api_key="test-key", + kwargs={}, + ) + builder( + provider=auto_client.ALIAS_TO_PROVIDER[_provider], + model_name="test-model", + async_client=False, + mode=None, + api_key="test-key", + kwargs={}, + ) + + assert factory_calls[0]["mode"] == Mode.MD_JSON + assert factory_calls[1]["mode"] == expected_default + + def test_build_openai_compatible_requires_api_key( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("ANYSCALE_API_KEY", raising=False) + from instructor.v2.providers.openai import client as openai_client + with pytest.raises(ConfigurationError, match="ANYSCALE_API_KEY is not set"): - auto_client._build_openai_compatible( - provider="anyscale", + openai_client.build_from_model( + provider=auto_client.ALIAS_TO_PROVIDER["anyscale"], model_name="llama", async_client=False, mode=None, api_key=None, kwargs={}, - provider_info={"provider": "anyscale", "operation": "initialize"}, - env_var="ANYSCALE_API_KEY", - default_base_url="https://api.endpoints.anyscale.com/v1", - factory_name="from_anyscale", ) @@ -78,36 +225,113 @@ def test_build_openai_does_not_mask_runtime_import_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: openai_module = ModuleType("openai") - httpx_module = ModuleType("httpx") class FakeClient: def __init__(self, **_kwargs: Any) -> None: raise ImportError("Using SOCKS proxy, but socksio is not installed.") - setattr(openai_module, "OpenAI", FakeClient) # noqa: B010 - setattr(openai_module, "AsyncOpenAI", FakeClient) # noqa: B010 - setattr(openai_module, "DEFAULT_MAX_RETRIES", 2) # noqa: B010 - setattr(openai_module, "NotGiven", object) # noqa: B010 - setattr(openai_module, "Timeout", float) # noqa: B010 - setattr(openai_module, "not_given", object()) # noqa: B010 - setattr(httpx_module, "Client", object) # noqa: B010 - setattr(httpx_module, "AsyncClient", object) # noqa: B010 + openai_module.OpenAI = FakeClient # type: ignore[attr-defined] + openai_module.AsyncOpenAI = FakeClient # type: ignore[attr-defined] + openai_module.DEFAULT_MAX_RETRIES = 2 # type: ignore[attr-defined] + openai_module.NotGiven = object # type: ignore[attr-defined] + openai_module.Timeout = float # type: ignore[attr-defined] + openai_module.not_given = object() # type: ignore[attr-defined] + + from instructor.v2.providers.openai import client as openai_client - monkeypatch.setitem(__import__("sys").modules, "openai", openai_module) - monkeypatch.setitem(__import__("sys").modules, "httpx", httpx_module) + monkeypatch.setattr(openai_client, "openai", openai_module) with pytest.raises(ImportError, match="socksio"): - auto_client._build_openai( - provider="openai", + openai_client.build_from_model( + provider=auto_client.ALIAS_TO_PROVIDER["openai"], model_name="gpt-5", async_client=False, mode=Mode.TOOLS, api_key="test-key", kwargs={}, - provider_info={"provider": "openai", "operation": "initialize"}, ) +@pytest.mark.parametrize("async_client", [False, True]) +def test_openai_builder_restores_default_max_retries_for_none( + monkeypatch: pytest.MonkeyPatch, + async_client: bool, +) -> None: + openai_module = ModuleType("openai") + seen: dict[str, Any] = {} + + class FakeClient: + def __init__(self, **kwargs: Any) -> None: + seen["client_kwargs"] = kwargs + + openai_module.OpenAI = FakeClient # type: ignore[attr-defined] + openai_module.AsyncOpenAI = FakeClient # type: ignore[attr-defined] + openai_module.DEFAULT_MAX_RETRIES = 7 # type: ignore[attr-defined] + + from instructor.v2.providers.openai import client as openai_client + + monkeypatch.setattr(openai_client, "openai", openai_module) + + openai_client._openai_client( + async_client=async_client, + api_key="test-key", + base_url=None, + kwargs={"max_retries": None}, + ) + + assert seen["client_kwargs"]["max_retries"] == 7 + + +@pytest.mark.parametrize("async_client", [False, True]) +@pytest.mark.parametrize("compatible", [False, True], ids=["openai", "compatible"]) +def test_openai_builders_keep_app_info_for_instructor_wrapper( + monkeypatch: pytest.MonkeyPatch, + async_client: bool, + compatible: bool, +) -> None: + openai_module = ModuleType("openai") + seen: dict[str, Any] = {} + + class FakeClient: + def __init__(self, **kwargs: Any) -> None: + assert "app_info" not in kwargs + seen["client_kwargs"] = kwargs + + openai_module.OpenAI = FakeClient # type: ignore[attr-defined] + openai_module.AsyncOpenAI = FakeClient # type: ignore[attr-defined] + + from instructor.v2.providers.openai import client as openai_client + + monkeypatch.setattr(openai_client, "openai", openai_module) + + def fake_factory(_client: Any, **kwargs: Any) -> dict[str, Any]: + seen["factory_kwargs"] = kwargs + return kwargs + + if compatible: + builder = openai_client.compatible_model_builder( + cast(Any, fake_factory), + env_var="TEST_API_KEY", + base_url="https://example.com/v1", + ) + provider = auto_client.ALIAS_TO_PROVIDER["anyscale"] + else: + monkeypatch.setattr(openai_client, "from_openai", fake_factory) + builder = openai_client.build_from_model + provider = auto_client.ALIAS_TO_PROVIDER["openai"] + + builder( + provider=provider, + model_name="test-model", + async_client=async_client, + mode=Mode.TOOLS, + api_key="test-key", + kwargs={"app_info": {"name": "instructor"}}, + ) + + assert seen["factory_kwargs"]["app_info"] == {"name": "instructor"} + + def test_build_databricks_normalizes_base_url_and_forwards_client_kwargs( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -121,29 +345,28 @@ class FakeOpenAI: def __init__(self, **kwargs: Any) -> None: seen["client_kwargs"] = kwargs - setattr(openai_module, "OpenAI", FakeOpenAI) # noqa: B010 - setattr(openai_module, "AsyncOpenAI", FakeOpenAI) # noqa: B010 - monkeypatch.setitem(__import__("sys").modules, "openai", openai_module) + openai_module.OpenAI = FakeOpenAI # type: ignore[attr-defined] + openai_module.AsyncOpenAI = FakeOpenAI # type: ignore[attr-defined] + from instructor.v2.providers.openai import client as openai_client - import instructor + monkeypatch.setattr(openai_client, "openai", openai_module) - def fake_from_openai(_client: Any, **kwargs: Any) -> dict[str, Any]: + def fake_from_databricks(_client: Any, **kwargs: Any) -> dict[str, Any]: seen["client"] = _client seen["factory_kwargs"] = kwargs return {"client": _client, "kwargs": kwargs} - monkeypatch.setattr(instructor, "from_openai", fake_from_openai) + monkeypatch.setattr(openai_client, "from_databricks", fake_from_databricks) result = cast( dict[str, Any], - auto_client._build_databricks( - provider="databricks", + openai_client.build_from_model( + provider=auto_client.ALIAS_TO_PROVIDER["databricks"], model_name="meta-llama", async_client=False, mode=None, api_key=None, kwargs={"timeout": 10, "custom": "value"}, - provider_info={"provider": "databricks", "operation": "initialize"}, ), ) @@ -168,7 +391,7 @@ def fake_client(service_name: str, **kwargs: Any) -> object: boto3_calls.append((service_name, kwargs)) return object() - setattr(boto3_module, "client", fake_client) # noqa: B010 + boto3_module.client = fake_client # type: ignore[attr-defined] monkeypatch.setitem(__import__("sys").modules, "boto3", boto3_module) import instructor.v2.providers.bedrock.client as bedrock_client @@ -181,28 +404,28 @@ def fake_from_bedrock(_client: Any, **kwargs: Any) -> dict[str, Any]: monkeypatch.setattr(bedrock_client, "from_bedrock", fake_from_bedrock) - auto_client._build_bedrock( - provider="bedrock", + bedrock_client.build_from_model( + provider=auto_client.ALIAS_TO_PROVIDER["bedrock"], model_name="anthropic.claude-3-7-sonnet", async_client=False, mode=None, api_key=None, kwargs={}, - provider_info={"provider": "bedrock", "operation": "initialize"}, ) - auto_client._build_bedrock( - provider="bedrock", + bedrock_client.build_from_model( + provider=auto_client.ALIAS_TO_PROVIDER["bedrock"], model_name="amazon.titan-text", async_client=False, mode=None, api_key=None, kwargs={}, - provider_info={"provider": "bedrock", "operation": "initialize"}, ) assert boto3_calls[0][0] == "bedrock-runtime" assert calls[0]["mode"] == Mode.TOOLS + assert calls[0]["model"] == "anthropic.claude-3-7-sonnet" assert calls[1]["mode"] == Mode.MD_JSON + assert calls[1]["model"] == "amazon.titan-text" def test_build_ollama_uses_tool_mode_only_for_tool_capable_models( @@ -214,13 +437,20 @@ class FakeOpenAI: def __init__(self, **kwargs: Any) -> None: self.kwargs = kwargs - setattr(openai_module, "OpenAI", FakeOpenAI) # noqa: B010 - setattr(openai_module, "AsyncOpenAI", FakeOpenAI) # noqa: B010 - monkeypatch.setitem(__import__("sys").modules, "openai", openai_module) - + openai_module.OpenAI = FakeOpenAI # type: ignore[attr-defined] + openai_module.AsyncOpenAI = FakeOpenAI # type: ignore[attr-defined] import instructor.v2.providers.openai.client as openai_client_module + monkeypatch.setattr(openai_client_module, "openai", openai_module) calls: list[dict[str, Any]] = [] + client_kwargs: list[dict[str, Any]] = [] + + class CapturingOpenAI: + def __init__(self, **kwargs: Any) -> None: + client_kwargs.append(kwargs) + + openai_module.OpenAI = CapturingOpenAI # type: ignore[attr-defined] + openai_module.AsyncOpenAI = CapturingOpenAI # type: ignore[attr-defined] def fake_from_openai(_client: Any, **kwargs: Any) -> dict[str, Any]: calls.append(kwargs) @@ -228,24 +458,24 @@ def fake_from_openai(_client: Any, **kwargs: Any) -> dict[str, Any]: monkeypatch.setattr(openai_client_module, "from_openai", fake_from_openai) - auto_client._build_ollama( - provider="ollama", + openai_client_module.build_from_model( + provider=auto_client.ALIAS_TO_PROVIDER["ollama"], model_name="llama3.1:8b", async_client=False, mode=None, - api_key=None, + api_key="given-key", kwargs={}, - provider_info={"provider": "ollama", "operation": "initialize"}, ) - auto_client._build_ollama( - provider="ollama", + openai_client_module.build_from_model( + provider=auto_client.ALIAS_TO_PROVIDER["ollama"], model_name="phi4-mini", async_client=False, mode=None, api_key=None, kwargs={}, - provider_info={"provider": "ollama", "operation": "initialize"}, ) assert calls[0]["mode"] == Mode.TOOLS assert calls[1]["mode"] == Mode.JSON + assert client_kwargs[0]["api_key"] == "given-key" + assert client_kwargs[1]["api_key"] == "ollama" diff --git a/tests/v2/test_client_unified.py b/tests/v2/test_client_unified.py index d6487af5d..43da4bf6b 100644 --- a/tests/v2/test_client_unified.py +++ b/tests/v2/test_client_unified.py @@ -378,6 +378,7 @@ def test_string_based_initialization_delegates_to_from_provider( pytest.skip( f"{from_function} not available (SDK may not be installed)" # ty: ignore[too-many-positional-arguments] ) + assert callable(func) # Mock from_provider to verify it's called from unittest.mock import patch @@ -410,6 +411,7 @@ def test_string_based_initialization_with_async_client(provider: Provider) -> No pytest.skip( f"{from_function} not available (SDK may not be installed)" # ty: ignore[too-many-positional-arguments] ) + assert callable(func) # Mock from_provider to verify it's called from unittest.mock import patch @@ -443,6 +445,7 @@ def test_string_based_initialization_forwards_kwargs(provider: Provider) -> None pytest.skip( f"{from_function} not available (SDK may not be installed)" # ty: ignore[too-many-positional-arguments] ) + assert callable(func) # Mock from_provider to verify it's called from unittest.mock import patch @@ -495,6 +498,7 @@ def test_client_based_initialization_still_works( pytest.skip( f"{from_function} not available" # ty: ignore[too-many-positional-arguments] ) + assert callable(func) # Import OpenAI client try: diff --git a/tests/v2/test_provider_modes.py b/tests/v2/test_provider_modes.py index d919f074b..cb2c6c5fb 100644 --- a/tests/v2/test_provider_modes.py +++ b/tests/v2/test_provider_modes.py @@ -6,12 +6,10 @@ from __future__ import annotations +import importlib.util import os from collections.abc import Iterable -from typing import Literal, Union - -import importlib.util -from pathlib import Path +from typing import Literal, Union, cast import pytest from pydantic import BaseModel @@ -20,24 +18,7 @@ from instructor.core.exceptions import InstructorRetryException from instructor import Mode from instructor.v2 import Provider, mode_registry -from tests.v2.provider_matrix import legacy_config_dicts - -# Ensure handlers are loaded by dynamically importing them -_PROJECT_ROOT = Path(__file__).resolve().parents[2] -_HANDLER_MODULE_PATHS: dict[Provider, Path] = { - Provider.OPENAI: _PROJECT_ROOT / "instructor/v2/providers/openai/handlers.py", - Provider.ANTHROPIC: _PROJECT_ROOT / "instructor/v2/providers/anthropic/handlers.py", - Provider.GENAI: _PROJECT_ROOT / "instructor/v2/providers/genai/handlers.py", - Provider.COHERE: _PROJECT_ROOT / "instructor/v2/providers/cohere/handlers.py", - Provider.XAI: _PROJECT_ROOT / "instructor/v2/providers/xai/handlers.py", - Provider.GROQ: _PROJECT_ROOT / "instructor/v2/providers/openai/handlers.py", - Provider.MISTRAL: _PROJECT_ROOT / "instructor/v2/providers/mistral/handlers.py", - Provider.FIREWORKS: _PROJECT_ROOT / "instructor/v2/providers/openai/handlers.py", - Provider.BEDROCK: _PROJECT_ROOT / "instructor/v2/providers/bedrock/handlers.py", - Provider.CEREBRAS: _PROJECT_ROOT / "instructor/v2/providers/openai/handlers.py", - Provider.WRITER: _PROJECT_ROOT / "instructor/v2/providers/writer/handlers.py", -} -_HANDLERS_LOADED: set[Provider] = set() +from tests.v2.provider_matrix import TEST_PROVIDER_SPECS, ensure_handlers_loaded def _clear_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None: @@ -52,48 +33,6 @@ def _clear_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv(key, raising=False) -def _is_expected_missing_dependency(provider: Provider, exc: ImportError) -> bool: - """Return True when handler loading failed only because an optional SDK is absent.""" - sdk_module = PROVIDER_CONFIGS.get(provider, {}).get("sdk_module") - if not sdk_module: - return False - - expected_root = str(sdk_module).split(".")[0] - missing_name = getattr(exc, "name", None) - if missing_name: - return missing_name.split(".")[0] == expected_root - - return f"No module named '{expected_root}'" in str(exc) - - -def _ensure_handlers_loaded(provider: Provider) -> None: - """Dynamically load handler module to ensure handlers are registered.""" - if provider in _HANDLERS_LOADED: - return - handler_path = _HANDLER_MODULE_PATHS.get(provider) - if handler_path is None: - return - if not handler_path.exists(): - return - try: - spec = importlib.util.spec_from_file_location( - f"tests.v2.handlers_{provider.value}", - handler_path, - ) - if spec is None or spec.loader is None: - return - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - _HANDLERS_LOADED.add(provider) - except (ImportError, ModuleNotFoundError) as exc: - if _is_expected_missing_dependency(provider, exc): - pytest.skip( - f"{provider.value} handlers require optional dependency " # ty: ignore[too-many-positional-arguments] - f"{PROVIDER_CONFIGS[provider]['sdk_module']}" - ) - raise - - class Answer(BaseModel): """Simple answer model.""" @@ -113,17 +52,10 @@ class GoogleSearch(BaseModel): query: str -_PROVIDER_CLIENT_CONFIGS = legacy_config_dicts() PROVIDER_CONFIGS = { - provider: { - "provider_string": config["provider_string"], - "sdk_module": config["sdk_module"], - "modes": config["supported_modes"], - "basic_modes": config["basic_modes"], - "async_modes": config["async_modes"], - } - for provider, config in _PROVIDER_CLIENT_CONFIGS.items() - if config["provider_string"] is not None and config["basic_modes"] + provider: spec + for provider, spec in TEST_PROVIDER_SPECS.items() + if spec.provider_string is not None and spec.basic_modes } @@ -131,7 +63,7 @@ def _get_all_mode_params(): """Generate (provider, mode) tuples for all registered modes.""" params = [] for provider, config in PROVIDER_CONFIGS.items(): - for mode in config["modes"]: + for mode in config.supported_modes: params.append((provider, mode)) return params @@ -139,13 +71,11 @@ def _get_all_mode_params(): @pytest.mark.parametrize("provider,mode", _get_all_mode_params()) def test_mode_is_registered(provider: Provider, mode: Mode): """Verify each mode is registered in the v2 registry.""" - _ensure_handlers_loaded(provider) + ensure_handlers_loaded(provider, skip_missing_dependency=True) # Skip if handler module doesn't exist or isn't registered if not mode_registry.is_registered(provider, mode): - # fmt: off - pytest.skip(f"Mode {mode.value} not registered for {provider.value}") # ty: ignore[too-many-positional-arguments] - # fmt: on + pytest.skip(f"Mode {mode.value} not registered for {provider.value}") handlers = mode_registry.get_handlers(provider, mode) assert handlers.request_handler is not None @@ -157,7 +87,7 @@ def _get_basic_mode_params(): """Generate (provider, mode) tuples for basic extraction tests.""" params = [] for provider, config in PROVIDER_CONFIGS.items(): - for mode in config["basic_modes"]: + for mode in config.basic_modes: params.append((provider, mode)) return params @@ -169,9 +99,7 @@ def _skip_on_provider_quota(provider: Provider, exc: Exception) -> None: and isinstance(exc, InstructorRetryException) and "RESOURCE_EXHAUSTED" in str(exc) ): - # fmt: off - pytest.skip("GenAI quota exhausted for this environment") # ty: ignore[too-many-positional-arguments] - # fmt: on + pytest.skip("GenAI quota exhausted for this environment") if ( provider == Provider.OPENAI and isinstance(exc, InstructorRetryException) @@ -179,9 +107,35 @@ def _skip_on_provider_quota(provider: Provider, exc: Exception) -> None: ): if os.environ.get("CI") or os.environ.get("INSTRUCTOR_STRICT_PROVIDER_TESTS"): return - # fmt: off - pytest.skip("OpenAI connectivity is unavailable in this environment") # ty: ignore[too-many-positional-arguments] - # fmt: on + pytest.skip("OpenAI connectivity is unavailable in this environment") + + +def _skip_if_provider_sdk_missing(provider: Provider) -> None: + sdk_module = PROVIDER_CONFIGS[provider].sdk_module + if sdk_module is None: + return + try: + installed = importlib.util.find_spec(sdk_module) is not None + except ModuleNotFoundError: + installed = False + if provider is Provider.XAI: + from instructor.v2.providers.xai.client import SyncClient + + installed = installed and SyncClient is not None + if not installed: + pytest.skip(f"{sdk_module} is not installed or unusable") + + +def test_live_provider_matrix_skips_unusable_optional_sdk( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from instructor.v2.providers.xai import client as xai_client + + monkeypatch.setattr(importlib.util, "find_spec", lambda _module: object()) + monkeypatch.setattr(xai_client, "SyncClient", None) + + with pytest.raises(pytest.skip.Exception): + _skip_if_provider_sdk_missing(Provider.XAI) @pytest.mark.parametrize("provider,mode", _get_basic_mode_params()) @@ -191,11 +145,12 @@ def test_mode_basic_extraction( ): """Test basic extraction with each mode.""" config = PROVIDER_CONFIGS[provider] + assert config.provider_string is not None + _skip_if_provider_sdk_missing(provider) _clear_proxy_env(monkeypatch) - # All providers now use from_provider() client = instructor.from_provider( - config["provider_string"], + config.provider_string, mode=mode, ) @@ -222,7 +177,7 @@ def _get_async_mode_params(): """Generate (provider, mode) tuples for async extraction tests.""" params = [] for provider, config in PROVIDER_CONFIGS.items(): - for mode in config["async_modes"]: + for mode in config.async_modes: params.append((provider, mode)) return params @@ -235,11 +190,12 @@ async def test_mode_async_extraction( ): """Test async extraction with each mode.""" config = PROVIDER_CONFIGS[provider] + assert config.provider_string is not None + _skip_if_provider_sdk_missing(provider) _clear_proxy_env(monkeypatch) - # All providers now use from_provider() client = instructor.from_provider( - config["provider_string"], + config.provider_string, mode=mode, async_client=True, ) @@ -286,7 +242,7 @@ def test_anthropic_parallel_tools_extraction(): max_tokens=1000, ) - result = list(response) + result = list(cast(Iterable[Union[Weather, GoogleSearch]], response)) assert len(result) >= 1 assert all(isinstance(r, (Weather, GoogleSearch)) for r in result) @@ -331,7 +287,7 @@ def test_anthropic_reasoning_tools_normalizes_in_v2(): def test_all_modes_covered(provider: Provider): """Verify we're testing all registered modes for each provider.""" config = PROVIDER_CONFIGS[provider] - tested_modes = set(config["modes"]) + tested_modes = set(config.supported_modes) registered_modes = set(mode_registry.get_modes_for_provider(provider)) # All registered modes should be tested From 1b6dd92ce2337e02fa4ffa3d0003cafded9f0b2f Mon Sep 17 00:00:00 2001 From: Jason Liu Date: Sun, 7 Jun 2026 20:15:32 -0400 Subject: [PATCH 4/6] test(v2): satisfy strict provider typing --- tests/providers/test_auto_client.py | 22 ++++++--- tests/v2/provider_matrix.py | 2 +- tests/v2/test_auto_client_deterministic.py | 54 +++++++++++----------- tests/v2/test_provider_modes.py | 20 +++++--- 4 files changed, 57 insertions(+), 41 deletions(-) diff --git a/tests/providers/test_auto_client.py b/tests/providers/test_auto_client.py index 19541ebf4..ff575e856 100644 --- a/tests/providers/test_auto_client.py +++ b/tests/providers/test_auto_client.py @@ -1,6 +1,7 @@ from __future__ import annotations from types import ModuleType +from typing import Any, cast import pytest from instructor.auto_client import from_provider @@ -9,6 +10,7 @@ ConfigurationError, InstructorRetryException, ) +from openai.types.chat import ChatCompletionUserMessageParam from pydantic import BaseModel @@ -18,7 +20,7 @@ class User(BaseModel): age: int -USER_EXTRACTION_PROMPT = { +USER_EXTRACTION_PROMPT: ChatCompletionUserMessageParam = { "role": "user", "content": "Ivan is 28 and strays in Singapore. Extract it as a user object", } @@ -84,7 +86,7 @@ def should_skip_provider_exception(exc: Exception) -> bool: def skip_or_raise_provider_exception(provider_string: str, exc: Exception) -> None: if should_skip_provider_exception(exc): pytest.skip( - f"Provider {provider_string} not available in this environment: {exc}" + f"Provider {provider_string} not available in this environment: {exc}" # ty: ignore[too-many-positional-arguments] ) raise exc @@ -94,7 +96,9 @@ def test_user_extraction_sync(provider_string): """Test user extraction for each provider (sync).""" if should_skip_provider(provider_string): - pytest.skip(f"Skipping provider {provider_string} on CI") + pytest.skip( + f"Skipping provider {provider_string} on CI" # ty: ignore[too-many-positional-arguments] + ) return try: @@ -120,7 +124,9 @@ async def test_user_extraction_async(provider_string): """Test user extraction for each provider (async).""" if should_skip_provider(provider_string): - pytest.skip(f"Skipping provider {provider_string} on CI") + pytest.skip( + f"Skipping provider {provider_string} on CI" # ty: ignore[too-many-positional-arguments] + ) return try: @@ -169,8 +175,8 @@ def builder(**kwargs): calls.append(kwargs) return result - module = ModuleType("test_builder") - module.build_from_model = builder # type: ignore[attr-defined] + module = cast(Any, ModuleType("test_builder")) + module.build_from_model = builder monkeypatch.setattr(auto_client.importlib, "import_module", lambda _path: module) assert from_provider("openai/gpt-4", api_key="test-key", extra="value") is result @@ -193,7 +199,9 @@ def test_additional_kwargs_passed(): import os if os.getenv("INSTRUCTOR_ENV") == "CI" or not os.getenv("ANTHROPIC_API_KEY"): - pytest.skip("Skipping live Anthropic test without credentials") + pytest.skip( + "Skipping live Anthropic test without credentials" # ty: ignore[too-many-positional-arguments] + ) return client = instructor.from_provider("anthropic/claude-sonnet-4-6", max_tokens=10) diff --git a/tests/v2/provider_matrix.py b/tests/v2/provider_matrix.py index 4f034f04f..586d66949 100644 --- a/tests/v2/provider_matrix.py +++ b/tests/v2/provider_matrix.py @@ -93,7 +93,7 @@ def ensure_handlers_loaded( except (ImportError, ModuleNotFoundError) as exc: if skip_missing_dependency and _is_expected_missing_dependency(provider, exc): pytest.skip( - f"{provider.value} handlers require optional dependency " + f"{provider.value} handlers require optional dependency " # ty: ignore[too-many-positional-arguments] f"{PROVIDER_SPECS[provider].sdk_module}" ) raise diff --git a/tests/v2/test_auto_client_deterministic.py b/tests/v2/test_auto_client_deterministic.py index 5fb4a7a34..8310c3004 100644 --- a/tests/v2/test_auto_client_deterministic.py +++ b/tests/v2/test_auto_client_deterministic.py @@ -49,15 +49,15 @@ def fake_builder(**kwargs: Any) -> str: captured.update(kwargs) return "client" - builder_module = ModuleType("test_builder") - builder_module.build_from_model = fake_builder # type: ignore[attr-defined] + builder_module = cast(Any, ModuleType("test_builder")) + builder_module.build_from_model = fake_builder monkeypatch.setattr( auto_client.importlib, "import_module", lambda _path: builder_module, ) - result = auto_client.from_provider( + result = auto_client.from_provider( # ty: ignore[no-matching-overload] "openai/gpt-5-nano", cache=cache, api_key="secret", @@ -224,18 +224,18 @@ def test_build_openai_compatible_requires_api_key( def test_build_openai_does_not_mask_runtime_import_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: - openai_module = ModuleType("openai") + openai_module = cast(Any, ModuleType("openai")) class FakeClient: def __init__(self, **_kwargs: Any) -> None: raise ImportError("Using SOCKS proxy, but socksio is not installed.") - openai_module.OpenAI = FakeClient # type: ignore[attr-defined] - openai_module.AsyncOpenAI = FakeClient # type: ignore[attr-defined] - openai_module.DEFAULT_MAX_RETRIES = 2 # type: ignore[attr-defined] - openai_module.NotGiven = object # type: ignore[attr-defined] - openai_module.Timeout = float # type: ignore[attr-defined] - openai_module.not_given = object() # type: ignore[attr-defined] + openai_module.OpenAI = FakeClient + openai_module.AsyncOpenAI = FakeClient + openai_module.DEFAULT_MAX_RETRIES = 2 + openai_module.NotGiven = object + openai_module.Timeout = float + openai_module.not_given = object() from instructor.v2.providers.openai import client as openai_client @@ -257,16 +257,16 @@ def test_openai_builder_restores_default_max_retries_for_none( monkeypatch: pytest.MonkeyPatch, async_client: bool, ) -> None: - openai_module = ModuleType("openai") + openai_module = cast(Any, ModuleType("openai")) seen: dict[str, Any] = {} class FakeClient: def __init__(self, **kwargs: Any) -> None: seen["client_kwargs"] = kwargs - openai_module.OpenAI = FakeClient # type: ignore[attr-defined] - openai_module.AsyncOpenAI = FakeClient # type: ignore[attr-defined] - openai_module.DEFAULT_MAX_RETRIES = 7 # type: ignore[attr-defined] + openai_module.OpenAI = FakeClient + openai_module.AsyncOpenAI = FakeClient + openai_module.DEFAULT_MAX_RETRIES = 7 from instructor.v2.providers.openai import client as openai_client @@ -289,7 +289,7 @@ def test_openai_builders_keep_app_info_for_instructor_wrapper( async_client: bool, compatible: bool, ) -> None: - openai_module = ModuleType("openai") + openai_module = cast(Any, ModuleType("openai")) seen: dict[str, Any] = {} class FakeClient: @@ -297,8 +297,8 @@ def __init__(self, **kwargs: Any) -> None: assert "app_info" not in kwargs seen["client_kwargs"] = kwargs - openai_module.OpenAI = FakeClient # type: ignore[attr-defined] - openai_module.AsyncOpenAI = FakeClient # type: ignore[attr-defined] + openai_module.OpenAI = FakeClient + openai_module.AsyncOpenAI = FakeClient from instructor.v2.providers.openai import client as openai_client @@ -338,15 +338,15 @@ def test_build_databricks_normalizes_base_url_and_forwards_client_kwargs( monkeypatch.setenv("DATABRICKS_TOKEN", "db-token") monkeypatch.setenv("DATABRICKS_HOST", "https://workspace.databricks.com") - openai_module = ModuleType("openai") + openai_module = cast(Any, ModuleType("openai")) seen: dict[str, Any] = {} class FakeOpenAI: def __init__(self, **kwargs: Any) -> None: seen["client_kwargs"] = kwargs - openai_module.OpenAI = FakeOpenAI # type: ignore[attr-defined] - openai_module.AsyncOpenAI = FakeOpenAI # type: ignore[attr-defined] + openai_module.OpenAI = FakeOpenAI + openai_module.AsyncOpenAI = FakeOpenAI from instructor.v2.providers.openai import client as openai_client monkeypatch.setattr(openai_client, "openai", openai_module) @@ -384,14 +384,14 @@ def fake_from_databricks(_client: Any, **kwargs: Any) -> dict[str, Any]: def test_build_bedrock_chooses_default_mode_from_model_name( monkeypatch: pytest.MonkeyPatch, ) -> None: - boto3_module = ModuleType("boto3") + boto3_module = cast(Any, ModuleType("boto3")) boto3_calls: list[tuple[str, dict[str, Any]]] = [] def fake_client(service_name: str, **kwargs: Any) -> object: boto3_calls.append((service_name, kwargs)) return object() - boto3_module.client = fake_client # type: ignore[attr-defined] + boto3_module.client = fake_client monkeypatch.setitem(__import__("sys").modules, "boto3", boto3_module) import instructor.v2.providers.bedrock.client as bedrock_client @@ -431,14 +431,14 @@ def fake_from_bedrock(_client: Any, **kwargs: Any) -> dict[str, Any]: def test_build_ollama_uses_tool_mode_only_for_tool_capable_models( monkeypatch: pytest.MonkeyPatch, ) -> None: - openai_module = ModuleType("openai") + openai_module = cast(Any, ModuleType("openai")) class FakeOpenAI: def __init__(self, **kwargs: Any) -> None: self.kwargs = kwargs - openai_module.OpenAI = FakeOpenAI # type: ignore[attr-defined] - openai_module.AsyncOpenAI = FakeOpenAI # type: ignore[attr-defined] + openai_module.OpenAI = FakeOpenAI + openai_module.AsyncOpenAI = FakeOpenAI import instructor.v2.providers.openai.client as openai_client_module monkeypatch.setattr(openai_client_module, "openai", openai_module) @@ -449,8 +449,8 @@ class CapturingOpenAI: def __init__(self, **kwargs: Any) -> None: client_kwargs.append(kwargs) - openai_module.OpenAI = CapturingOpenAI # type: ignore[attr-defined] - openai_module.AsyncOpenAI = CapturingOpenAI # type: ignore[attr-defined] + openai_module.OpenAI = CapturingOpenAI + openai_module.AsyncOpenAI = CapturingOpenAI def fake_from_openai(_client: Any, **kwargs: Any) -> dict[str, Any]: calls.append(kwargs) diff --git a/tests/v2/test_provider_modes.py b/tests/v2/test_provider_modes.py index cb2c6c5fb..ddb1d91cb 100644 --- a/tests/v2/test_provider_modes.py +++ b/tests/v2/test_provider_modes.py @@ -9,7 +9,7 @@ import importlib.util import os from collections.abc import Iterable -from typing import Literal, Union, cast +from typing import Literal, Union import pytest from pydantic import BaseModel @@ -75,7 +75,9 @@ def test_mode_is_registered(provider: Provider, mode: Mode): # Skip if handler module doesn't exist or isn't registered if not mode_registry.is_registered(provider, mode): - pytest.skip(f"Mode {mode.value} not registered for {provider.value}") + pytest.skip( + f"Mode {mode.value} not registered for {provider.value}" # ty: ignore[too-many-positional-arguments] + ) handlers = mode_registry.get_handlers(provider, mode) assert handlers.request_handler is not None @@ -99,7 +101,9 @@ def _skip_on_provider_quota(provider: Provider, exc: Exception) -> None: and isinstance(exc, InstructorRetryException) and "RESOURCE_EXHAUSTED" in str(exc) ): - pytest.skip("GenAI quota exhausted for this environment") + pytest.skip( + "GenAI quota exhausted for this environment" # ty: ignore[too-many-positional-arguments] + ) if ( provider == Provider.OPENAI and isinstance(exc, InstructorRetryException) @@ -107,7 +111,9 @@ def _skip_on_provider_quota(provider: Provider, exc: Exception) -> None: ): if os.environ.get("CI") or os.environ.get("INSTRUCTOR_STRICT_PROVIDER_TESTS"): return - pytest.skip("OpenAI connectivity is unavailable in this environment") + pytest.skip( + "OpenAI connectivity is unavailable in this environment" # ty: ignore[too-many-positional-arguments] + ) def _skip_if_provider_sdk_missing(provider: Provider) -> None: @@ -123,7 +129,9 @@ def _skip_if_provider_sdk_missing(provider: Provider) -> None: installed = installed and SyncClient is not None if not installed: - pytest.skip(f"{sdk_module} is not installed or unusable") + pytest.skip( + f"{sdk_module} is not installed or unusable" # ty: ignore[too-many-positional-arguments] + ) def test_live_provider_matrix_skips_unusable_optional_sdk( @@ -242,7 +250,7 @@ def test_anthropic_parallel_tools_extraction(): max_tokens=1000, ) - result = list(cast(Iterable[Union[Weather, GoogleSearch]], response)) + result = list(response) assert len(result) >= 1 assert all(isinstance(r, (Weather, GoogleSearch)) for r in result) From 30a1fdc06d0837d0a0900bf9c7cdd93d6dcc3b5d Mon Sep 17 00:00:00 2001 From: Jason Liu Date: Sun, 7 Jun 2026 20:22:18 -0400 Subject: [PATCH 5/6] ci: retry transient provider integration failures --- .github/workflows/test.yml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9176ed3f6..7d7e39ee7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -186,14 +186,23 @@ jobs: || env.FIREWORKS_API_KEY != '' || env.WRITER_API_KEY != '' || env.PERPLEXITY_API_KEY != '' }} run: | - set +e - uv run pytest tests/llm/test_core_providers -v --asyncio-mode=auto -n auto -k "cohere or xai or mistral or cerebras or fireworks or writer or perplexity" - status=$? - set -e - if [ $status -eq 5 ]; then - echo "No tests collected; treating as success." - exit 0 - fi + for attempt in 1 2 3; do + set +e + uv run pytest tests/llm/test_core_providers -v --asyncio-mode=auto -n auto -k "cohere or xai or mistral or cerebras or fireworks or writer or perplexity" + status=$? + set -e + if [ $status -eq 0 ]; then + exit 0 + fi + if [ $status -eq 5 ]; then + echo "No tests collected; treating as success." + exit 0 + fi + if [ $attempt -lt 3 ]; then + echo "Live provider suite failed on attempt $attempt; retrying." + sleep 5 + fi + done exit $status env: INSTRUCTOR_ENV: CI From b3c44096ecd5f92619743bf3182ce21a3aef6e1d Mon Sep 17 00:00:00 2001 From: Jason Liu Date: Sun, 7 Jun 2026 20:28:29 -0400 Subject: [PATCH 6/6] ci: retry only failed provider tests --- .github/workflows/test.yml | 42 ++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d7e39ee7..76c92dbc7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -186,24 +186,30 @@ jobs: || env.FIREWORKS_API_KEY != '' || env.WRITER_API_KEY != '' || env.PERPLEXITY_API_KEY != '' }} run: | - for attempt in 1 2 3; do - set +e - uv run pytest tests/llm/test_core_providers -v --asyncio-mode=auto -n auto -k "cohere or xai or mistral or cerebras or fireworks or writer or perplexity" - status=$? - set -e - if [ $status -eq 0 ]; then - exit 0 - fi - if [ $status -eq 5 ]; then - echo "No tests collected; treating as success." - exit 0 - fi - if [ $attempt -lt 3 ]; then - echo "Live provider suite failed on attempt $attempt; retrying." - sleep 5 - fi - done - exit $status + set +e + provider_filter="cohere or xai or mistral or cerebras or fireworks or writer or perplexity" + uv run pytest tests/llm/test_core_providers -v --asyncio-mode=auto -n auto -k "$provider_filter" + status=$? + + if [ "$status" -eq 1 ]; then + echo "Retrying only failed live-provider tests once." + sleep 5 + uv run pytest tests/llm/test_core_providers -v --asyncio-mode=auto --lf --lfnf=none -k "$provider_filter" + retry_status=$? + else + retry_status=$status + fi + set -e + + if [ "$status" -eq 5 ]; then + echo "No tests collected; treating as success." + exit 0 + fi + if [ "$retry_status" -eq 5 ]; then + echo "Retry selected no failed tests; preserving the initial failure." + exit "$status" + fi + exit "$retry_status" env: INSTRUCTOR_ENV: CI COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}