diff --git a/CHANGELOG.md b/CHANGELOG.md index 55bb558c0..abe32562a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,14 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) --- -## [1.15.5] - 2026-06-28 +## [Unreleased] + +### Added +- **Sarvam**: First-class `from_sarvam()` and `from_provider("sarvam/...")` support for Sarvam's OpenAI-compatible Indic chat API (`sarvam-30b`, `sarvam-105b`). + +### Changed +- **Sarvam**: Default structured-output mode is `Mode.MD_JSON`. +--- ### Fixed - **v2 imports**: Defer OpenAI SDK imports from core v2 modules until an OpenAI-specific path actually needs them, reducing import side effects for non-OpenAI usage. ([#2390](https://github.com/567-labs/instructor/pull/2390)) diff --git a/docs/concepts/from_provider.md b/docs/concepts/from_provider.md index d6cb0b22b..3de1c0f04 100644 --- a/docs/concepts/from_provider.md +++ b/docs/concepts/from_provider.md @@ -73,6 +73,7 @@ user = client.create( - Cohere: `"cohere/command-r-plus"` - Perplexity: `"perplexity/llama-3.1-sonar"` - DeepSeek: `"deepseek/deepseek-chat"` +- Sarvam: `"sarvam/sarvam-30b"` (Indic languages — Hindi, Tamil, Bengali, and more) - xAI: `"xai/grok-beta"` - OpenRouter: `"openrouter/meta-llama/llama-3.1-70b"` - Ollama: `"ollama/llama3.2"` (local models) diff --git a/docs/integrations/index.md b/docs/integrations/index.md index b22784b34..1b5178019 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -30,6 +30,7 @@ Learn how to integrate Instructor with various AI model providers. These compreh [:octicons-arrow-right-16: Cohere](./cohere.md) · [:octicons-arrow-right-16: Mistral](./mistral.md) · [:octicons-arrow-right-16: DeepSeek](./deepseek.md) · + [:octicons-arrow-right-16: Sarvam](./sarvam.md) · [:octicons-arrow-right-16: Together AI](./together.md) · [:octicons-arrow-right-16: Groq](./groq.md) · [:octicons-arrow-right-16: Fireworks](./fireworks.md) · diff --git a/docs/integrations/sarvam.md b/docs/integrations/sarvam.md new file mode 100644 index 000000000..47b6b3e52 --- /dev/null +++ b/docs/integrations/sarvam.md @@ -0,0 +1,118 @@ +--- +title: "Structured outputs with Sarvam AI" +description: "Use Instructor with Sarvam Indic LLMs for type-safe structured extraction across Indian languages." +--- + +# Structured outputs with Sarvam AI + +[Sarvam AI](https://www.sarvam.ai/models) provides Indic-focused LLMs such as **Sarvam 30B** and **Sarvam 105B** through an [OpenAI-compatible chat API](https://docs.sarvam.ai/). Instructor now supports Sarvam as a first-class provider via `from_provider("sarvam/...")` and `from_sarvam()`. + +## Install + +```bash +pip install "instructor[openai]" +``` + +Set your API key: + +```bash +export SARVAM_API_KEY='your-api-key-here' +``` + +## Quick start + +```python +import instructor +from pydantic import BaseModel + + +class User(BaseModel): + name: str + age: int + + +client = instructor.from_provider("sarvam/sarvam-30b") + +user = client.chat.completions.create( + model="sarvam-30b", + response_model=User, + messages=[{"role": "user", "content": "राहुल की उम्र 28 साल है।"}], +) + +print(user) +# > User(name='Rahul', age=28) +``` + +## Supported models + +Chat LLMs exposed via the Sarvam API (see [Sarvam models](https://www.sarvam.ai/models)): + +| Model ID | Description | +|----------|-------------| +| `sarvam-30b` | Efficient MoE chat model for high-throughput Indic workloads | +| `sarvam-105b` | Flagship chat model for highest-quality Indic understanding | + +Per [Sarvam's model guide](https://docs.sarvam.ai/api-reference-docs/getting-started/models), chat models support **11 languages** (10 Indian + English). `sarvam-m` is deprecated — use `sarvam-30b` or `sarvam-105b`. + +Speech (Saaras), TTS (Bulbul), translation (Mayura, Sarvam Translate), and document intelligence (Sarvam Vision) use separate APIs and are not covered by this integration yet. See [Extending Sarvam support](#extending-sarvam-support). + +## Modes + +Sarvam works with Instructor's OpenAI-compatible modes: + +| Mode | When to use | +|------|-------------| +| `Mode.MD_JSON` (default) | Most reliable across Indic scripts in our Jul 2026 benchmark | +| `Mode.TOOLS` | Fastest latency; best on `sarvam-105b`, but unreliable on `sarvam-30b` for extraction | +| `Mode.JSON_SCHEMA` | Native `response_format` JSON schema | + +!!! warning "`Mode.TOOLS` on `sarvam-30b`" + In the full 432-trial eval, `sarvam-30b + Mode.TOOLS` scored **29–46%** success on person extraction (vs **58–75%** for `Mode.MD_JSON`). Prefer `Mode.MD_JSON` for cost-sensitive extraction workloads. + +```python +import instructor + +client = instructor.from_provider( + "sarvam/sarvam-105b", + mode=instructor.Mode.JSON_SCHEMA, +) +``` + +Sarvam-specific API parameters such as `reasoning_effort` and `wiki_grounding` can be passed through `create()` kwargs. + +## Async usage + +```python +import asyncio +import instructor +from pydantic import BaseModel + + +class CityInfo(BaseModel): + city: str + state: str + + +async def main() -> None: + client = instructor.from_provider("sarvam/sarvam-30b", async_client=True) + result = await client.chat.completions.create( + model="sarvam-30b", + response_model=CityInfo, + messages=[{"role": "user", "content": "Extract city info: Bengaluru is in Karnataka."}], + ) + print(result) + + +asyncio.run(main()) +``` + +## Extending Sarvam support + +Future non-chat Sarvam products (Vision, Saaras STT, Bulbul TTS, Translate) would need dedicated provider handlers under `instructor/v2/providers/sarvam/` with separate API endpoints. The current integration is intentionally scoped to chat completions. + +## Related resources + +- [Sarvam models](https://www.sarvam.ai/models) +- [Sarvam API documentation](https://docs.sarvam.ai/) +- [Instructor modes comparison](../concepts/modes-comparison.md) +- [from_provider guide](../concepts/from_provider.md) diff --git a/instructor/__init__.py b/instructor/__init__.py index b23ee50b0..8bfca2478 100644 --- a/instructor/__init__.py +++ b/instructor/__init__.py @@ -53,6 +53,7 @@ from_anyscale as from_anyscale, from_databricks as from_databricks, from_deepseek as from_deepseek, + from_sarvam as from_sarvam, from_openai as from_openai, from_together as from_together, ) @@ -77,6 +78,7 @@ "from_together", "from_databricks", "from_deepseek", + "from_sarvam", "from_openrouter", "from_litellm", "from_vertexai", @@ -116,6 +118,7 @@ "from_together": (".v2.providers.openai.client", "from_together"), "from_databricks": (".v2.providers.openai.client", "from_databricks"), "from_deepseek": (".v2.providers.openai.client", "from_deepseek"), + "from_sarvam": (".v2.providers.openai.client", "from_sarvam"), "from_openrouter": (".v2.providers.openrouter.client", "from_openrouter"), "from_litellm": (".v2.providers.litellm.client", "from_litellm"), "Mode": (".mode", "Mode"), diff --git a/instructor/models.py b/instructor/models.py index e589a1b31..0ae76a221 100644 --- a/instructor/models.py +++ b/instructor/models.py @@ -159,5 +159,8 @@ # DeepSeek Models "deepseek/deepseek-chat", "deepseek/deepseek-reasoner", + # Sarvam Models - https://docs.sarvam.ai/api-reference-docs/getting-started/models + "sarvam/sarvam-30b", + "sarvam/sarvam-105b", ], ) diff --git a/instructor/v2/README.md b/instructor/v2/README.md index 0a4985c96..b577ce7cd 100644 --- a/instructor/v2/README.md +++ b/instructor/v2/README.md @@ -1142,7 +1142,7 @@ This checklist tracks which providers have been migrated to v2: - Tests: `tests/v2/test_provider_modes.py`, `tests/v2/test_handlers_parametrized.py` - Status: ✅ Complete -- [x] **OpenAI-Compatible** (`Provider.ANYSCALE`, `Provider.TOGETHER`, `Provider.DATABRICKS`, `Provider.DEEPSEEK`) +- [x] **OpenAI-Compatible** (`Provider.ANYSCALE`, `Provider.TOGETHER`, `Provider.DATABRICKS`, `Provider.DEEPSEEK`, `Provider.SARVAM`) - Location: `instructor/v2/providers/openai/` - Modes: `TOOLS`, `JSON`, `JSON_SCHEMA`, `MD_JSON`, `PARALLEL_TOOLS` - Tests: `tests/v2/test_handlers_parametrized.py`, `tests/v2/test_client_unified.py` diff --git a/instructor/v2/auto_client.py b/instructor/v2/auto_client.py index 54335e960..407785d53 100644 --- a/instructor/v2/auto_client.py +++ b/instructor/v2/auto_client.py @@ -1413,6 +1413,68 @@ def _build_deepseek( raise +def _build_sarvam( + *, + 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_sarvam + import os + + api_key = api_key or os.environ.get("SARVAM_API_KEY") + + if not api_key: + from instructor.v2.core.errors import ConfigurationError + + raise ConfigurationError( + "SARVAM_API_KEY is not set. " + "Set it with `export SARVAM_API_KEY=` or pass it as kwarg api_key=" + ) + + base_url = kwargs.pop("base_url", "https://api.sarvam.ai/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_sarvam( + client, + model=model_name, + mode=mode if mode else Mode.MD_JSON, + **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 Sarvam 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, @@ -1602,6 +1664,7 @@ def _build_litellm( "generative-ai": _build_generative_ai, "ollama": _build_ollama, "deepseek": _build_deepseek, + "sarvam": _build_sarvam, "xai": _build_xai, "openrouter": _build_openrouter, "litellm": _build_litellm, diff --git a/instructor/v2/core/mode.py b/instructor/v2/core/mode.py index 56c7c9e81..01a93d5ea 100644 --- a/instructor/v2/core/mode.py +++ b/instructor/v2/core/mode.py @@ -75,6 +75,10 @@ class Mode(enum.Enum): PERPLEXITY_JSON = "perplexity_json" OPENROUTER_STRUCTURED_OUTPUTS = "openrouter_structured_outputs" + # Sarvam modes + SARVAM_TOOLS = "sarvam_tools" + SARVAM_JSON = "sarvam_json" + # Classification helpers @classmethod def tool_modes(cls) -> set["Mode"]: @@ -102,6 +106,7 @@ def tool_modes(cls) -> set["Mode"]: cls.GENAI_TOOLS, cls.RESPONSES_TOOLS, cls.RESPONSES_TOOLS_WITH_INBUILT_TOOLS, + cls.SARVAM_TOOLS, } @classmethod @@ -124,6 +129,7 @@ def json_modes(cls) -> set["Mode"]: cls.OPENROUTER_STRUCTURED_OUTPUTS, cls.MISTRAL_STRUCTURED_OUTPUTS, cls.XAI_JSON, + cls.SARVAM_JSON, } @classmethod @@ -247,6 +253,9 @@ def warn_deprecated_mode(cls, mode: "Mode") -> None: Mode.GEMINI_JSON: Mode.MD_JSON, # OpenRouter legacy modes Mode.OPENROUTER_STRUCTURED_OUTPUTS: Mode.JSON_SCHEMA, + # Sarvam legacy modes + Mode.SARVAM_TOOLS: Mode.TOOLS, + Mode.SARVAM_JSON: Mode.MD_JSON, } diff --git a/instructor/v2/core/provider_specs.py b/instructor/v2/core/provider_specs.py index 3bc375fe6..73d498183 100644 --- a/instructor/v2/core/provider_specs.py +++ b/instructor/v2/core/provider_specs.py @@ -145,6 +145,23 @@ def _openai_compat_spec( alias="deepseek", from_function="from_deepseek", ), + Provider.SARVAM: _spec( + Provider.SARVAM, + aliases=("sarvam",), + handler_module="instructor.v2.providers.openai.handlers", + supported_modes=(Mode.TOOLS, Mode.JSON, Mode.JSON_SCHEMA, Mode.MD_JSON), + unsupported_modes=(Mode.PARALLEL_TOOLS, Mode.RESPONSES_TOOLS), + legacy_modes={ + Mode.SARVAM_TOOLS: Mode.TOOLS, + Mode.SARVAM_JSON: Mode.MD_JSON, + }, + from_function="from_sarvam", + client_module="instructor.v2.providers.openai.client", + sdk_module="openai", + provider_string="sarvam/sarvam-30b", + basic_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + async_modes=(Mode.TOOLS, Mode.JSON_SCHEMA, Mode.MD_JSON), + ), Provider.OPENROUTER: _spec( Provider.OPENROUTER, aliases=("openrouter",), diff --git a/instructor/v2/core/providers.py b/instructor/v2/core/providers.py index 131e3a7e5..622833769 100644 --- a/instructor/v2/core/providers.py +++ b/instructor/v2/core/providers.py @@ -36,6 +36,7 @@ class Provider(Enum): BEDROCK = "bedrock" PERPLEXITY = "perplexity" OPENROUTER = "openrouter" + SARVAM = "sarvam" def provider_from_mode(mode: Mode, default: Provider = Provider.OPENAI) -> Provider: @@ -69,6 +70,8 @@ def provider_from_mode(mode: Mode, default: Provider = Provider.OPENAI) -> Provi Mode.BEDROCK_JSON: Provider.BEDROCK, Mode.PERPLEXITY_JSON: Provider.PERPLEXITY, Mode.OPENROUTER_STRUCTURED_OUTPUTS: Provider.OPENROUTER, + Mode.SARVAM_TOOLS: Provider.SARVAM, + Mode.SARVAM_JSON: Provider.SARVAM, } return mapping.get(mode, default) @@ -119,6 +122,7 @@ def get_provider(base_url: str) -> Provider: ("openrouter", Provider.OPENROUTER), ("x.ai", Provider.XAI), ("xai", Provider.XAI), + ("sarvam", Provider.SARVAM), ) for token, provider in providers: if token in normalized: diff --git a/instructor/v2/providers/openai/client.py b/instructor/v2/providers/openai/client.py index 38dd91b58..d75caad2c 100644 --- a/instructor/v2/providers/openai/client.py +++ b/instructor/v2/providers/openai/client.py @@ -527,3 +527,100 @@ def from_deepseek( model=model, **kwargs, ) + + +@overload +def from_sarvam( + model_or_client: str, + mode: Mode = Mode.MD_JSON, + model: None = None, + async_client: Literal[False] = False, + **kwargs: Any, +) -> Instructor: ... + + +@overload +def from_sarvam( + model_or_client: str, + mode: Mode = Mode.MD_JSON, + model: None = None, + async_client: Literal[True] = True, + **kwargs: Any, +) -> AsyncInstructor: ... + + +@overload +def from_sarvam( + model_or_client: openai.OpenAI, + mode: Mode = Mode.MD_JSON, + model: str | None = None, + **kwargs: Any, +) -> Instructor: ... + + +@overload +def from_sarvam( + model_or_client: openai.AsyncOpenAI, + mode: Mode = Mode.MD_JSON, + model: str | None = None, + **kwargs: Any, +) -> AsyncInstructor: ... + + +def from_sarvam( + model_or_client: str | openai.OpenAI | openai.AsyncOpenAI, + mode: Mode = Mode.MD_JSON, + model: str | None = None, + async_client: bool = False, + **kwargs: Any, +) -> Instructor | AsyncInstructor: + """Create an Instructor instance for Sarvam AI. + + Sarvam exposes an OpenAI-compatible chat completions API for Indic LLMs + such as ``sarvam-30b`` and ``sarvam-105b``. + + Supports two usage patterns: + + 1. String-based (recommended): Pass a model name string + >>> from instructor.v2 import from_sarvam + >>> client = from_sarvam("sarvam-30b", mode=Mode.MD_JSON) + + 2. Client-based: Pass an OpenAI client pointed at Sarvam + >>> from openai import OpenAI + >>> client = OpenAI( + ... base_url="https://api.sarvam.ai/v1", + ... api_key="...", + ... ) + >>> instructor_client = from_sarvam(client, mode=Mode.MD_JSON) + + Args: + model_or_client: Model name string or OpenAI-compatible client instance + mode: The mode to use (defaults to Mode.MD_JSON) + model: Optional model name (only used with client-based usage) + async_client: Whether to return async client (string-based usage only) + **kwargs: Additional keyword arguments passed to from_provider or Instructor + + Returns: + An Instructor instance (sync or async depending on usage pattern) + + Raises: + ModeError: If mode is not registered for Sarvam + ClientError: If client is not a valid OpenAI client instance + """ + if isinstance(model_or_client, str): + from instructor import from_provider + + return from_provider( + f"sarvam/{model_or_client}", + mode=mode, + async_client=async_client, + **kwargs, + ) + + return _from_openai_compat( + model_or_client, + provider=Provider.SARVAM, + mode=mode, + model=model, + **kwargs, + ) diff --git a/instructor/v2/providers/openai/handlers.py b/instructor/v2/providers/openai/handlers.py index 4fc1f5826..3d2992dcd 100644 --- a/instructor/v2/providers/openai/handlers.py +++ b/instructor/v2/providers/openai/handlers.py @@ -59,6 +59,7 @@ Provider.GROQ, Provider.FIREWORKS, Provider.CEREBRAS, + Provider.SARVAM, ] OPENAI_PARALLEL_TOOL_PROVIDERS = [ @@ -80,6 +81,7 @@ Provider.GROQ, Provider.FIREWORKS, Provider.CEREBRAS, + Provider.SARVAM, ] diff --git a/mkdocs.yml b/mkdocs.yml index 8487fdac3..481b8d0eb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -168,6 +168,7 @@ nav: - OpenAI: 'integrations/openai.md' - OpenAI Responses: 'integrations/openai-responses.md' - DeepSeek: 'integrations/deepseek.md' + - Sarvam: 'integrations/sarvam.md' - llama-cpp-python: 'integrations/llama-cpp-python.md' - Gemini: 'integrations/google.md' - Anthropic: 'integrations/anthropic.md' diff --git a/tests/llm/shared_config.py b/tests/llm/shared_config.py index 741b5e358..4f0cbfa2f 100644 --- a/tests/llm/shared_config.py +++ b/tests/llm/shared_config.py @@ -76,6 +76,12 @@ "PERPLEXITY_API_KEY", "openai", # Perplexity transports over OpenAI-compatible API ), + ( + "sarvam/sarvam-30b", + instructor.Mode.TOOLS, + "SARVAM_API_KEY", + "openai", + ), ] diff --git a/tests/llm/test_core_providers/capabilities.py b/tests/llm/test_core_providers/capabilities.py index 47cc7f75d..902e0d603 100644 --- a/tests/llm/test_core_providers/capabilities.py +++ b/tests/llm/test_core_providers/capabilities.py @@ -121,6 +121,15 @@ "validation", "create_with_completion", }, + "sarvam": { + "streaming", + "partial_streaming", + "iterable_streaming", + "list_extraction", + "nested_models", + "validation", + "create_with_completion", + }, } diff --git a/tests/llm/test_sarvam/conftest.py b/tests/llm/test_sarvam/conftest.py new file mode 100644 index 000000000..9db793aa5 --- /dev/null +++ b/tests/llm/test_sarvam/conftest.py @@ -0,0 +1,57 @@ +"""Shared fixtures for Sarvam LLM integration tests.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +_TEST_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _TEST_DIR.parents[2] + +if str(_TEST_DIR) not in sys.path: + sys.path.insert(0, str(_TEST_DIR)) + +_EVALS_DIR = _TEST_DIR / "evals" +if str(_EVALS_DIR) not in sys.path: + sys.path.insert(0, str(_EVALS_DIR)) + + +def _load_dotenv() -> None: + env_path = _REPO_ROOT / ".env" + if not env_path.exists(): + return + try: + from dotenv import load_dotenv + except ImportError: + for line in env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip().strip("\"'")) + return + load_dotenv(env_path) + + +_load_dotenv() + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", + "sarvam: tests that call the live Sarvam API (requires SARVAM_API_KEY)", + ) + + +@pytest.fixture(scope="session") +def sarvam_api_key() -> str: + api_key = os.environ.get("SARVAM_API_KEY") + if not api_key: + pytest.skip( + "SARVAM_API_KEY is not set. " + "Add it to .env in the repo root (see .env.example)." + ) + return api_key diff --git a/tests/llm/test_sarvam/test_retries.py b/tests/llm/test_sarvam/test_retries.py new file mode 100644 index 000000000..b43c7334c --- /dev/null +++ b/tests/llm/test_sarvam/test_retries.py @@ -0,0 +1,51 @@ +"""Sarvam retry and validation tests.""" + +from __future__ import annotations + +from itertools import product + +import instructor +import pytest +from pydantic import BaseModel, Field, field_validator + +from util import models, modes + +pytestmark = pytest.mark.sarvam + + +class ValidatedUser(BaseModel): + name: str + age: int = Field(ge=0, le=120) + + @field_validator("name") + @classmethod + def name_must_have_content(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("Name must not be empty") + return value.strip() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_name, mode", product(models, modes)) +async def test_sarvam_max_retries( + model_name: str, + mode: instructor.Mode, + sarvam_api_key: str, +) -> None: + client = instructor.from_provider( + f"sarvam/{model_name}", + mode=mode, + api_key=sarvam_api_key, + async_client=True, + ) + + user = await client.chat.completions.create( + model=model_name, + response_model=ValidatedUser, + messages=[{"role": "user", "content": "Extract: Priya is 33 years old."}], + max_retries=3, + reasoning_effort="medium", + ) + + assert isinstance(user, ValidatedUser) + assert user.age == 33 diff --git a/tests/llm/test_sarvam/test_simple.py b/tests/llm/test_sarvam/test_simple.py new file mode 100644 index 000000000..7fe3fa9b0 --- /dev/null +++ b/tests/llm/test_sarvam/test_simple.py @@ -0,0 +1,79 @@ +"""Basic Sarvam extraction tests.""" + +from __future__ import annotations + +from itertools import product + +import instructor +import pytest +from pydantic import BaseModel + +from util import models, modes, name_matches + +pytestmark = pytest.mark.sarvam + + +class User(BaseModel): + name: str + age: int + + +@pytest.mark.parametrize("model_name, mode", product(models, modes)) +def test_sarvam_simple_extraction( + model_name: str, + mode: instructor.Mode, + sarvam_api_key: str, +) -> None: + client = instructor.from_provider( + f"sarvam/{model_name}", + mode=mode, + api_key=sarvam_api_key, + ) + + user = client.chat.completions.create( + model=model_name, + response_model=User, + messages=[ + { + "role": "user", + "content": "निकालें: अमित की उम्र 32 साल है।", + } + ], + reasoning_effort="medium", + max_retries=3, + ) + + assert user.age == 32 + assert name_matches(user.name, ("Amit", "अमित")), ( + f"Expected Amit, got {user.name!r}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_name, mode", product(models, modes)) +async def test_sarvam_async_extraction( + model_name: str, + mode: instructor.Mode, + sarvam_api_key: str, +) -> None: + client = instructor.from_provider( + f"sarvam/{model_name}", + mode=mode, + api_key=sarvam_api_key, + async_client=True, + ) + + user = await client.chat.completions.create( + model=model_name, + response_model=User, + messages=[ + { + "role": "user", + "content": "निकालें: अमित की उम्र 32 साल है।", + } + ], + reasoning_effort="medium", + ) + + assert user.age == 32 + assert user.name # romanized or native script diff --git a/tests/llm/test_sarvam/test_stream.py b/tests/llm/test_sarvam/test_stream.py new file mode 100644 index 000000000..bb61d0939 --- /dev/null +++ b/tests/llm/test_sarvam/test_stream.py @@ -0,0 +1,48 @@ +"""Sarvam streaming tests.""" + +from __future__ import annotations + +from itertools import product + +import instructor +import pytest +from pydantic import BaseModel + +from instructor.dsl.partial import Partial +from util import models, modes + +pytestmark = pytest.mark.sarvam + + +class User(BaseModel): + name: str + age: int + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_name, mode", product(models, modes)) +async def test_sarvam_partial_streaming( + model_name: str, + mode: instructor.Mode, + sarvam_api_key: str, +) -> None: + client = instructor.from_provider( + f"sarvam/{model_name}", + mode=mode, + api_key=sarvam_api_key, + async_client=True, + ) + + updates = [] + async for partial_user in await client.chat.completions.create( + model=model_name, + response_model=Partial[User], + messages=[{"role": "user", "content": "Rahul is 30 years old"}], + stream=True, + reasoning_effort="low", + ): + updates.append(partial_user) + + assert len(updates) >= 1 + final = updates[-1] + assert final.age == 30 diff --git a/tests/llm/test_sarvam/util.py b/tests/llm/test_sarvam/util.py new file mode 100644 index 000000000..f07161175 --- /dev/null +++ b/tests/llm/test_sarvam/util.py @@ -0,0 +1,22 @@ +"""Models, modes, and helpers for Sarvam provider integration tests.""" + +from __future__ import annotations + +import unicodedata + +import instructor + +models: list[str] = ["sarvam-30b"] +modes: list[instructor.Mode] = [instructor.Mode.TOOLS] + + +def normalize_name(value: str) -> str: + """Normalize names for cross-script comparison.""" + normalized = unicodedata.normalize("NFKC", value).strip().casefold() + return "".join(ch for ch in normalized if not ch.isspace()) + + +def name_matches(actual: str, acceptable_names: tuple[str, ...]) -> bool: + """Return True when actual matches any acceptable romanized or native name.""" + actual_norm = normalize_name(actual) + return any(normalize_name(candidate) == actual_norm for candidate in acceptable_names) diff --git a/tests/v2/test_client_unified.py b/tests/v2/test_client_unified.py index d6487af5d..f5fff875f 100644 --- a/tests/v2/test_client_unified.py +++ b/tests/v2/test_client_unified.py @@ -23,6 +23,7 @@ Provider.TOGETHER: _PROJECT_ROOT / "instructor/v2/providers/openai/handlers.py", Provider.DATABRICKS: _PROJECT_ROOT / "instructor/v2/providers/openai/handlers.py", Provider.DEEPSEEK: _PROJECT_ROOT / "instructor/v2/providers/openai/handlers.py", + Provider.SARVAM: _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.GEMINI: _PROJECT_ROOT / "instructor/v2/providers/gemini/handlers.py", @@ -356,6 +357,7 @@ def test_from_function_raises_without_sdk(provider: Provider) -> None: Provider.TOGETHER, Provider.DATABRICKS, Provider.DEEPSEEK, + Provider.SARVAM, ] diff --git a/tests/v2/test_handlers_parametrized.py b/tests/v2/test_handlers_parametrized.py index 255309714..7161ea636 100644 --- a/tests/v2/test_handlers_parametrized.py +++ b/tests/v2/test_handlers_parametrized.py @@ -28,6 +28,7 @@ Provider.TOGETHER: _PROJECT_ROOT / "instructor/v2/providers/openai/handlers.py", Provider.DATABRICKS: _PROJECT_ROOT / "instructor/v2/providers/openai/handlers.py", Provider.DEEPSEEK: _PROJECT_ROOT / "instructor/v2/providers/openai/handlers.py", + Provider.SARVAM: _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.GEMINI: _PROJECT_ROOT / "instructor/v2/providers/gemini/handlers.py", @@ -116,6 +117,11 @@ class User(ResponseSchema): Mode.JSON_SCHEMA: "text", Mode.MD_JSON: "markdown", }, + Provider.SARVAM: { + Mode.TOOLS: "tool_call", + Mode.JSON_SCHEMA: "text", + Mode.MD_JSON: "markdown", + }, Provider.OPENROUTER: { Mode.TOOLS: "tool_call", Mode.JSON_SCHEMA: "text", @@ -255,6 +261,7 @@ def tool_response(self, args: dict[str, Any]) -> Any: Provider.TOGETHER, Provider.DATABRICKS, Provider.DEEPSEEK, + Provider.SARVAM, Provider.OPENROUTER, Provider.PERPLEXITY, Provider.CEREBRAS, @@ -316,6 +323,7 @@ def text_response(self, text: str) -> Any: Provider.TOGETHER, Provider.DATABRICKS, Provider.DEEPSEEK, + Provider.SARVAM, Provider.OPENROUTER, Provider.PERPLEXITY, Provider.CEREBRAS, diff --git a/tests/v2/test_sarvam_client.py b/tests/v2/test_sarvam_client.py new file mode 100644 index 000000000..65f2a3890 --- /dev/null +++ b/tests/v2/test_sarvam_client.py @@ -0,0 +1,29 @@ +"""Provider-specific tests for Sarvam v2 client factory.""" + +from __future__ import annotations + +import pytest + +from instructor import Mode + + +class TestSarvamClient: + """Sarvam uses the OpenAI SDK with a custom base URL.""" + + def test_from_sarvam_with_invalid_client(self): + from instructor.core.exceptions import ClientError + from instructor.v2.providers.openai.client import from_sarvam + + with pytest.raises(ClientError, match="must be an instance"): + from_sarvam(123) # ty: ignore[no-matching-overload] + + def test_from_sarvam_with_invalid_mode(self): + import openai + + from instructor.core.exceptions import ModeError + from instructor.v2.providers.openai.client import from_sarvam + + client = openai.OpenAI(api_key="fake-key", base_url="https://api.sarvam.ai/v1") + + with pytest.raises(ModeError): + from_sarvam(client, mode=Mode.PARALLEL_TOOLS)