diff --git a/CHANGELOG.md b/CHANGELOG.md index fe5bbfa..0e3dbe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-04-27 + +This release migrates Search and Extract to Parallel's v1 GA endpoints, surfaces citations + structured output on the chat model, and bumps the SDK to `0.5.1`. + +### Added + +- **Canonical naming**: new aliases `ChatParallel` and `ParallelSearchTool` are the recommended names going forward; the previous `ChatParallelWeb` and `ParallelWebSearchTool` continue to work indefinitely as aliases (same class objects). +- **Search/Extract GA endpoints**: `ParallelSearchTool` and `ParallelExtractTool` now call `client.search` / `client.extract` (the `/v1` GA paths). New parameters surfaced from the GA contract on both tools: `max_chars_total`, `client_model`, `session_id`, `location` (Search). The `advanced_settings` envelope is built automatically from the existing flat fields. +- **`ChatParallel.with_structured_output()`**: returns a `Runnable` producing a typed object (pydantic model or dict) via Parallel's `response_format` JSON-schema on the research models (`lite`, `base`, `core`). `method="json_schema"` (default), `method="json_mode"`, and `method="function_calling"` (routed to `json_schema` for cross-provider compatibility) are supported. Raises a clear `ValueError` on `model="speed"` since that model silently ignores structured-output requests. `include_raw=True` returns `{"raw", "parsed", "parsing_error"}` and properly captures parser failures. +- **Citations on chat responses**: for the research models, `AIMessage.response_metadata["basis"]` carries the API's per-field citations / reasoning / confidence list. `response_metadata["interaction_id"]` is surfaced for multi-turn context chaining; `system_fingerprint` is forwarded when present. +- **`SourcePolicy` pydantic model** in `langchain_parallel._types` mirroring the API's `include_domains` / `exclude_domains` / `after_date`. Both `SourcePolicy(...)` and a raw dict are accepted on `ParallelSearchTool`. + +### Deprecated + +- **Search without `search_queries`**: calls passing only `objective` route to the deprecated `/v1beta` endpoint with a `DeprecationWarning`. The fallback will be **removed in 0.4.0**; the Parallel API itself sunsets `/v1beta` no earlier than June 2026. Pair `objective` with `search_queries=[...]` (1-5 keyword strings, 3-6 words each) to use the GA `/v1` endpoint. +- **Legacy `mode` values**: `"fast"`, `"one-shot"`, and `"agentic"` continue to call the API correctly with a `DeprecationWarning` mapping them to the GA values (`"fast"` / `"one-shot"` → `"basic"`, `"agentic"` → `"advanced"`). The GA values `"basic"` and `"advanced"` are now the canonical set. +- **`Extract.excerpts=False`**: the GA Extract API always returns excerpts and has no flag to disable them; passing `False` is accepted with a `DeprecationWarning` and ignored. Use `ExcerptSettings(max_chars_per_result=…)` to control per-result size. + +### Changed + +- **`response_metadata["model_name"]`**: chat completions now emit the LangChain 1.x standard key `model_name` (was `model`). Tracing systems and `langchain-tests`' standard suite check for this name. +- **`parallel-web` SDK bumped** from `^0.3.3` to `^0.5.1`. Brings in the v1 GA Search/Extract types, `AdvancedSearchSettingsParam` / `AdvancedExtractSettingsParam`, and the FindAll / Task Group / Monitor surfaces (not yet exposed by this integration — see `IMPROVEMENT_PLAN.md` Phase 2). +- **Slimmed `_client.py`**: the four hand-rolled `ParallelSearchClient` / `AsyncParallelSearchClient` / `ParallelExtractClient` / `AsyncParallelExtractClient` wrapper classes have been removed in favor of using `parallel.Parallel` / `parallel.AsyncParallel` directly. Internal change; no public surface impact. +- **`_run`/`_arun` deduped**: extracted `_finalize_response`, `_start_text`, and `_completion_text` helpers on both tools so the sync and async bodies are now ~25 lines each instead of ~50. +- `ParallelExtractTool.full_content` precedence is now explicit: an explicit `FullContentSettings` (or dict) on the call always wins over the tool-level `max_chars_per_extract`; the latter only applies when `full_content=True` is passed as a plain bool. + +### Fixed + +- `ChatParallel(model="lite")` now actually selects the `lite` model. Pre-0.3.0 the `Field(alias="model_name")` on the `model` field silently swallowed the `model=` kwarg and forced callers into the default `"speed"`. Both `ChatParallel(model="lite")` and `ChatParallel(model_name="lite")` work in 0.3.0 — the latter via a `model_validator` that maps `model_name=` to `model=` for back-compat. `lc_attributes` still serializes the field as `model_name` for tracing parity. +- `py.typed` is now bundled into the wheel via the `[tool.poetry] include` directive, so downstream `mypy` runs see the package's type information. +- `with_structured_output(include_raw=True)` correctly populates `parsing_error` on parse failure (previously always `None`). + +### Migration + +For most users, **no code changes are required**. The recommended-but-optional updates to silence deprecation warnings: + +- **Search**: add `search_queries=[…]` (1-5 keyword strings, 3-6 words each) to use the GA `/v1` endpoint. + ```python + # 0.2.x (still works in 0.3.x with a DeprecationWarning; will break in 0.4.0) + tool.invoke({"objective": "What are the latest AI breakthroughs?"}) + + # 0.3.x preferred (GA /v1 endpoint) + tool.invoke({ + "search_queries": ["latest AI breakthroughs", "AI advances 2026"], + "objective": "What are the latest AI breakthroughs?", + }) + ``` +- **Search mode**: rename `mode="one-shot"`/`"fast"` → `mode="basic"` and `mode="agentic"` → `mode="advanced"`. +- **Chat**: prefer `ChatParallel(model="lite")` (or `"base"` / `"core"`) over `model_name="..."`. Read citations from `response.response_metadata["basis"]` and structured outputs via `chat.with_structured_output(MyPydanticModel)`. The old class name `ChatParallelWeb` continues to work. + ## [0.2.0] - 2025-12-01 ### Changed diff --git a/README.md b/README.md index 8da36ff..9d7f7bd 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,14 @@ This package provides LangChain integrations for [Parallel](https://docs.paralle ## Features -- **Chat Models**: `ChatParallelWeb` - Real-time web research chat completions -- **Search Tools**: `ParallelWebSearchTool` - Direct access to Parallel's Search API -- **Extract Tools**: `ParallelExtractTool` - Clean content extraction from web pages -- **Streaming Support**: Real-time response streaming -- **Async/Await**: Full asynchronous operation support -- **OpenAI Compatible**: Uses familiar OpenAI SDK patterns -- **LangChain Integration**: Seamless integration with LangChain ecosystem +- **Chat Models**: `ChatParallel` (formerly `ChatParallelWeb`) — real-time web research chat completions, with citations and structured output on the research models. +- **Search Tool**: `ParallelSearchTool` (formerly `ParallelWebSearchTool`) — direct access to Parallel's GA `/v1/search` endpoint. +- **Extract Tool**: `ParallelExtractTool` — clean content extraction from web pages via `/v1/extract`. +- **Streaming Support**: Real-time response streaming on chat. +- **Async/Await**: Full asynchronous operation support. +- **LangChain Integration**: Pydantic input schemas, `bind`-able tools, `with_structured_output()`, `lc_serializable`. + +> Note: the older names (`ChatParallelWeb`, `ParallelWebSearchTool`) continue to work as aliases. ## Installation @@ -33,28 +34,48 @@ export PARALLEL_API_KEY="your-api-key-here" The `ChatParallelWeb` class provides access to Parallel's Chat API, which combines language models with real-time web research capabilities. +#### Picking a model + +| Model | Latency | Citations (`response_metadata["basis"]`) | Structured output | +|-------|---------|------------------------------------------|-------------------| +| `speed` (default) | ~3s | none | not supported | +| `lite` | seconds | yes | `with_structured_output()` | +| `base` | seconds–minutes | yes | `with_structured_output()` | +| `core` | minutes | yes (most thorough) | `with_structured_output()` | + #### Basic Usage ```python from langchain_core.messages import HumanMessage, SystemMessage from langchain_parallel.chat_models import ChatParallelWeb -# Initialize the chat model -chat = ChatParallelWeb( - model="speed", # Parallel's chat model - temperature=0.7, # Optional: ignored by Parallel - max_tokens=None, # Optional: ignored by Parallel -) +chat = ChatParallelWeb(model="speed") -# Create messages messages = [ SystemMessage(content="You are a helpful assistant with access to real-time web information."), - HumanMessage(content="What are the latest developments in artificial intelligence?") + HumanMessage(content="What are the latest developments in artificial intelligence?"), ] -# Get response response = chat.invoke(messages) print(response.content) +# Citations on the research models (lite/base/core): +print(response.response_metadata.get("basis")) +``` + +#### Structured output (research models) + +```python +from pydantic import BaseModel, Field +from langchain_parallel import ChatParallelWeb + +class Founder(BaseModel): + name: str = Field(description="Full name of the founder") + company: str = Field(description="Company they founded") + +structured = ChatParallelWeb(model="lite").with_structured_output(Founder) +result = structured.invoke([("human", "Who founded SpaceX?")]) +print(result) +# Founder(name='Elon Musk', company='SpaceX') ``` #### Streaming Responses @@ -160,21 +181,7 @@ print(result) ### Agents -```python -from langchain.agents import create_openai_functions_agent, AgentExecutor -from langchain_core.prompts import ChatPromptTemplate - -# Create an agent with web research capabilities -prompt = ChatPromptTemplate.from_messages([ - ("system", "You are a helpful assistant with access to real-time web information."), - ("human", "{input}"), - ("placeholder", "{agent_scratchpad}"), -]) - -# Use with tools for additional capabilities -# agent = create_openai_functions_agent(chat, tools, prompt) -# agent_executor = AgentExecutor(agent=agent, tools=tools) -``` +Parallel's Chat API does not support tool calling, so `ChatParallelWeb` cannot be the LLM that drives an agent. Use it as a research assistant inside a chain (above), or use Parallel's tools (`ParallelWebSearchTool`, `ParallelExtractTool`) with a tool-calling chat model (Anthropic, OpenAI, etc.) — see the **Tool Usage in Agents** section below. ## Search API @@ -187,29 +194,16 @@ The search tool provides direct access to Parallel's Search API: ```python from langchain_parallel import ParallelWebSearchTool -# Initialize the search tool search_tool = ParallelWebSearchTool() -# Search with an objective result = search_tool.invoke({ - "objective": "What are the latest developments in renewable energy?", - "max_results": 5 + "search_queries": ["renewable energy 2026", "solar power developments"], + "max_results": 5, }) -print(result) -# { -# "search_id": "search_123...", -# "results": [ -# { -# "url": "https://example.com/renewable-energy", -# "title": "Latest Renewable Energy Developments", -# "excerpts": [ -# "Solar energy has seen remarkable growth...", -# "Wind power capacity increased by 15%..." -# ] -# } -# ] -# } +print(result["search_id"], len(result["results"])) +for r in result["results"]: + print(r["title"], "-", r["url"]) ``` @@ -220,14 +214,19 @@ print(result) | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `objective` | `Optional[str]` | `None` | Natural-language description of research goal | -| `search_queries` | `Optional[List[str]]` | `None` | Specific search queries (max 5, 200 chars each) | -| `max_results` | `int` | `10` | Maximum results to return (1-40) | -| `excerpts` | `Optional[dict]` | `None` | Excerpt settings (e.g., {'max_chars_per_result': 1500}) | -| `mode` | `Optional[str]` | `None` | Search mode: 'one-shot' for comprehensive results, 'agentic' for token-efficient results | -| `fetch_policy` | `Optional[dict]` | `None` | Policy for cached vs live content (e.g., {'max_age_seconds': 86400, 'timeout_seconds': 60}) | -| `api_key` | `Optional[SecretStr]` | `None` | API key (uses env var if not provided) | -| `base_url` | `str` | `"https://api.parallel.ai"` | API base URL | +| `objective` | `Optional[str]` | `None` | Natural-language description of research goal (≤5000 chars). | +| `search_queries` | `Optional[List[str]]` | `None` | 1-5 keyword queries (3-6 words each, ≤200 chars). Required by the GA `/v1` endpoint; if omitted, the call routes to the deprecated `/v1beta` endpoint with a `DeprecationWarning` (slated for removal in 0.4.0). Pair with an optional `objective` for best results. | +| `max_results` | `int` | `10` | Maximum results to return (1–40). | +| `excerpts` | `Optional[ExcerptSettings]` | `None` | Per-result excerpt-size cap. | +| `max_chars_total` | `Optional[int]` | `None` | Cap on total excerpt characters across all results. | +| `mode` | `Optional[Literal["basic", "advanced"]]` | `None` (API default `advanced`) | `basic` is lower-latency; `advanced` is higher quality with more retrieval and compression. Legacy values `fast`, `one-shot` (→ `basic`) and `agentic` (→ `advanced`) are accepted with a `DeprecationWarning`. | +| `source_policy` | `Optional[SourcePolicy]` | `None` | Domain include/exclude lists and freshness floor (`after_date`). | +| `fetch_policy` | `Optional[FetchPolicy]` | `None` | Cache vs live-fetch policy (e.g. `FetchPolicy(max_age_seconds=86400, timeout_seconds=60)`). | +| `location` | `Optional[str]` | `None` | ISO 3166-1 alpha-2 country code (e.g. `"us"`, `"gb"`). | +| `client_model` | `Optional[str]` | `None` | Identifier of the calling LLM, used for model-specific result optimizations. | +| `session_id` | `Optional[str]` | `None` | Shared id grouping related Search/Extract calls in one task. | +| `api_key` | `Optional[SecretStr]` | `None` | API key (uses `PARALLEL_API_KEY` env var if not provided). | +| `base_url` | `str` | `"https://api.parallel.ai"` | API base URL. | ### Search with Specific Queries @@ -247,31 +246,27 @@ result = search_tool.invoke({ ### Tool Usage in Agents -The search tool works seamlessly with LangChain agents: +Use the search tool with a tool-calling chat model (e.g. Anthropic Claude or OpenAI) and `create_agent`. Note that Parallel's own Chat API does not currently support tool calling, so use a different model class for the agent's LLM and use Parallel as a tool. ```python -from langchain.agents import create_openai_functions_agent, AgentExecutor -from langchain_core.prompts import ChatPromptTemplate - -# Create agent with search capabilities -tools = [search_tool] - -prompt = ChatPromptTemplate.from_messages([ - ("system", "You are a research assistant. Use the search tool to find current information."), - ("human", "{input}"), - ("placeholder", "{agent_scratchpad}"), -]) - -agent = create_openai_functions_agent(chat, tools, prompt) -agent_executor = AgentExecutor(agent=agent, tools=tools) +from langchain.agents import create_agent +from langchain_parallel import ParallelWebSearchTool, ParallelExtractTool + +agent = create_agent( + "anthropic:claude-haiku-4-5", + tools=[ParallelWebSearchTool(), ParallelExtractTool()], + system_prompt=( + "You are a research assistant. Use parallel_web_search to find " + "current information and parallel_extract to read specific pages." + ), +) -# Run the agent -result = agent_executor.invoke({ - "input": "What are the latest developments in artificial intelligence?" -}) -print(result["output"]) +result = agent.invoke({"messages": [("human", "Latest AI breakthroughs?")]}) +print(result["messages"][-1].content) ``` +See `docs/demo_agent.ipynb` for a full walkthrough. + ## Extract API The Extract API provides clean content extraction from web pages, returning structured markdown-formatted content optimized for LLM consumption. @@ -361,15 +356,18 @@ print(f"Content length: {len(result[0]['content'])} characters") | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `urls` | `List[str]` | Required | List of URLs to extract content from | -| `search_objective` | `Optional[str]` | `None` | Natural language objective to focus extraction | -| `search_queries` | `Optional[List[str]]` | `None` | Specific keyword queries to focus extraction | -| `excerpts` | `Union[bool, ExcerptSettings]` | `True` | Include relevant excerpts (focused on objective/queries if provided) | -| `full_content` | `Union[bool, FullContentSettings]` | `False` | Include full page content | -| `fetch_policy` | `Optional[FetchPolicy]` | `None` | Cache vs live content policy | -| `max_chars_per_extract` | `Optional[int]` | `None` | Maximum characters per extraction (tool-level setting) | -| `api_key` | `Optional[SecretStr]` | `None` | API key (uses env var if not provided) | -| `base_url` | `str` | `"https://api.parallel.ai"` | API base URL | +| `urls` | `List[str]` | Required | List of URLs to extract content from (up to 20 per request). | +| `search_objective` | `Optional[str]` | `None` | Natural language objective to focus extraction (≤5000 chars). | +| `search_queries` | `Optional[List[str]]` | `None` | Specific keyword queries to focus extraction. | +| `excerpts` | `Union[bool, ExcerptSettings]` | `True` | In v1 GA, excerpts are always returned; the bool is kept for backward compatibility, and `ExcerptSettings(max_chars_per_result=…)` controls per-result size. | +| `full_content` | `Union[bool, FullContentSettings]` | `False` | Include full page content in addition to excerpts. | +| `max_chars_total` | `Optional[int]` | `None` | Cap on total excerpt characters across all results. Does not affect `full_content`. | +| `fetch_policy` | `Optional[FetchPolicy]` | `None` | Cache vs live content policy. | +| `client_model` | `Optional[str]` | `None` | Identifier of the calling LLM, used for model-specific result optimizations. | +| `session_id` | `Optional[str]` | `None` | Shared id grouping related Search/Extract calls in one task. | +| `max_chars_per_extract` | `Optional[int]` | `None` | Tool-level default cap on `full_content` size; only applied when `full_content=True`. | +| `api_key` | `Optional[SecretStr]` | `None` | API key (uses `PARALLEL_API_KEY` env var if not provided). | +| `base_url` | `str` | `"https://api.parallel.ai"` | API base URL. | ### Error Handling @@ -500,11 +498,4 @@ This project is licensed under the MIT License - see the LICENSE file for detail ## Changelog -### v0.1.0 -- Initial release -- **Chat Models**: ChatParallelWeb with real-time web research -- **Search Tools**: ParallelWebSearchTool for direct API access -- **Extract Tools**: ParallelExtractTool for clean content extraction -- Streaming and async/await support -- Batch URL extraction with error handling -- Full LangChain ecosystem compatibility +See [`CHANGELOG.md`](./CHANGELOG.md) for the full version history. diff --git a/docs/chat.ipynb b/docs/chat.ipynb index 0bd924f..112e84b 100644 --- a/docs/chat.ipynb +++ b/docs/chat.ipynb @@ -16,12 +16,12 @@ "\n", "| Class | Package | Local | Serializable | JS support | Package downloads | Package latest |\n", "| :--- | :--- | :---: | :---: | :---: | :---: | :---: |\n", - "| [ChatParallelWeb](https://python.langchain.com/api_reference/parallel_web/chat_models/langchain_parallel.chat_models.ChatParallelWeb.html) | [langchain-parallel](https://python.langchain.com/api_reference/parallel_web/) | ❌ | ✅ | ❌ | ![PyPI - Downloads](https://img.shields.io/pypi/dm/langchain-parallel?style=flat-square&label=%20) | ![PyPI - Version](https://img.shields.io/pypi/v/langchain-parallel?style=flat-square&label=%20) |\n", + "| [ChatParallelWeb](https://python.langchain.com/api_reference/parallel_web/chat_models/langchain_parallel.chat_models.ChatParallelWeb.html) | [langchain-parallel](https://python.langchain.com/api_reference/parallel_web/) | \u274c | \u2705 | \u274c | ![PyPI - Downloads](https://img.shields.io/pypi/dm/langchain-parallel?style=flat-square&label=%20) | ![PyPI - Version](https://img.shields.io/pypi/v/langchain-parallel?style=flat-square&label=%20) |\n", "\n", "### Model features\n", "| [Tool calling](/docs/how_to/tool_calling) | [Structured output](/docs/how_to/structured_output/) | JSON mode | [Image input](/docs/how_to/multimodal_inputs/) | Audio input | Video input | [Token-level streaming](/docs/how_to/chat_streaming/) | Native async | [Token usage](/docs/how_to/chat_token_usage_tracking/) | [Logprobs](/docs/how_to/logprobs/) |\n", "| :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |\n", - "| ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | \n", + "| \u274c | \u274c | \u274c | \u274c | \u274c | \u274c | \u2705 | \u2705 | \u274c | \u274c | \n", "\n", "## Setup\n", "\n", @@ -83,24 +83,24 @@ "metadata": {}, "outputs": [], "source": [ - "from langchain_parallel import ChatParallelWeb\n", - "\n", - "llm = ChatParallelWeb(\n", - " model=\"speed\", # Default model with fast responses\n", - " temperature=0.7,\n", - " max_tokens=None,\n", + "from langchain_parallel import ChatParallel\n", + "\n", + "# `ChatParallel` is the canonical name in 0.3+ (the older `ChatParallelWeb`\n", + "# continues to work as an alias).\n", + "#\n", + "# Models:\n", + "# - \"speed\" (default): low-latency conversational answers, no citations.\n", + "# - \"lite\" / \"base\" / \"core\": research models with web access. They return\n", + "# source citations on `response_metadata[\"basis\"]` and support\n", + "# `with_structured_output()` via `response_format` JSON schema.\n", + "\n", + "llm = ChatParallel(\n", + " model=\"speed\",\n", " timeout=None,\n", " max_retries=2,\n", - " # api_key=\"your-api-key\" # Optional if set in environment\n", + " # api_key=\"your-api-key\" # Optional if PARALLEL_API_KEY is set\n", " # base_url=\"https://api.parallel.ai\" # Optional, uses default\n", - " # OpenAI-compatible parameters (ignored by Parallel but supported for compatibility)\n", - " # response_format={\"type\": \"json_object\"}, # Ignored\n", - " # tools=[...], # Ignored\n", - " # tool_choice=\"auto\", # Ignored\n", - " # top_p=1.0, # Ignored\n", - " # frequency_penalty=0.0, # Ignored\n", - " # presence_penalty=0.0, # Ignored\n", - ")" + ")\n" ] }, { @@ -180,6 +180,58 @@ ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Structured output (research models)\n", + "\n", + "On `lite`, `base`, and `core`, `ChatParallel.with_structured_output(...)` ", + "binds a JSON-schema `response_format` and returns a parsed pydantic ", + "object (or dict). On `speed` it raises a clear error since that model ", + "silently ignores `response_format`.\n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "from pydantic import BaseModel, Field\n", + "\n", + "class Founder(BaseModel):\n", + " name: str = Field(description=\"Full name of the founder\")\n", + " company: str = Field(description=\"Company they founded\")\n", + "\n", + "structured = ChatParallel(model=\"lite\").with_structured_output(Founder)\n", + "parsed = structured.invoke([(\"human\", \"Who founded SpaceX?\")])\n", + "parsed" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Citations (research models)\n", + "\n", + "Research models populate `AIMessage.response_metadata['basis']` with ", + "per-field citations, the model's reasoning, and a confidence label.\n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "cited = ChatParallel(model=\"lite\").invoke([\n", + " (\"human\", \"Who is the current CEO of OpenAI? One sentence.\")\n", + "])\n", + "print(cited.content)\n", + "print(\"\\nbasis:\", cited.response_metadata.get(\"basis\"))" + ] + }, { "cell_type": "markdown", "id": "d1ee55bc-ffc8-4cfa-801c-993953a08cfd", @@ -315,4 +367,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/docs/extract_tool.ipynb b/docs/extract_tool.ipynb index b3e94b3..aa4c411 100644 --- a/docs/extract_tool.ipynb +++ b/docs/extract_tool.ipynb @@ -17,7 +17,7 @@ "\n", "| Class | Package | Serializable | JS support | Package latest |\n", "| :--- | :--- | :---: | :---: | :---: |\n", - "| [ParallelExtractTool](https://python.langchain.com/api_reference/parallel_web/extract_tool/langchain_parallel.extract_tool.ParallelExtractTool.html) | [langchain-parallel](https://python.langchain.com/api_reference/parallel_web/) | ❌ | ❌ | ![PyPI - Version](https://img.shields.io/pypi/v/langchain-parallel?style=flat-square&label=%20) |\n", + "| [ParallelExtractTool](https://python.langchain.com/api_reference/parallel_web/extract_tool/langchain_parallel.extract_tool.ParallelExtractTool.html) | [langchain-parallel](https://python.langchain.com/api_reference/parallel_web/) | \u274c | \u274c | ![PyPI - Version](https://img.shields.io/pypi/v/langchain-parallel?style=flat-square&label=%20) |\n", "\n", "### Tool features\n", "\n", @@ -87,15 +87,16 @@ "source": [ "from langchain_parallel import ParallelExtractTool\n", "\n", - "# Basic instantiation - API key from environment\n", + "# Reads PARALLEL_API_KEY from the environment by default.\n", "tool = ParallelExtractTool()\n", "\n", - "# With explicit API key and custom settings\n", - "tool = ParallelExtractTool(\n", - " api_key=\"your-api-key\",\n", - " base_url=\"https://api.parallel.ai\", # default value\n", - " max_chars_per_extract=5000, # Limit content length\n", - ")" + "# To pass an explicit key, override the base URL, or cap the per-URL\n", + "# full_content size:\n", + "# tool = ParallelExtractTool(\n", + "# api_key=\"your-api-key\",\n", + "# base_url=\"https://api.parallel.ai\",\n", + "# max_chars_per_extract=5000,\n", + "# )\n" ] }, { @@ -317,72 +318,18 @@ }, { "cell_type": "markdown", - "id": "659f9fbd", "metadata": {}, "source": [ "## Chaining\n", "\n", - "We can use our tool in a chain by first binding it to a [tool-calling model](/docs/how_to/tool_calling/) and then calling it:\n", + "To use the tool from a tool-calling chat model, bind it to any LLM ", + "that supports tool calls (e.g. `ChatAnthropic`, `ChatOpenAI`) and ", + "drive an agent with `langchain.agents.create_agent`. Parallel's own ", + "`ChatParallel` does not support tool calling \u2014 use it as a research assistant inside a chain, or use the search/extract tools ", + "alongside another model.\n", "\n", - "import ChatModelTabs from \"@theme/ChatModelTabs\";\n", - "\n", - "" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "af3123ad", - "metadata": {}, - "outputs": [], - "source": [ - "# | output: false\n", - "# | echo: false\n", - "\n", - "# !pip install -qU langchain langchain-openai\n", - "from langchain.chat_models import init_chat_model\n", - "\n", - "llm = init_chat_model(model=\"gpt-4o\", model_provider=\"openai\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fdbf35b5", - "metadata": {}, - "outputs": [], - "source": [ - "from langchain_core.prompts import ChatPromptTemplate\n", - "from langchain_core.runnables import RunnableConfig, chain\n", - "\n", - "prompt = ChatPromptTemplate(\n", - " [\n", - " (\n", - " \"system\",\n", - " \"You are a helpful assistant that extracts and summarizes web content.\",\n", - " ),\n", - " (\"human\", \"{user_input}\"),\n", - " (\"placeholder\", \"{messages}\"),\n", - " ]\n", - ")\n", - "\n", - "# specifying tool_choice will force the model to call this tool.\n", - "llm_with_tools = llm.bind_tools([tool], tool_choice=tool.name)\n", - "\n", - "llm_chain = prompt | llm_with_tools\n", - "\n", - "\n", - "@chain\n", - "def tool_chain(user_input: str, config: RunnableConfig):\n", - " input_ = {\"user_input\": user_input}\n", - " ai_msg = llm_chain.invoke(input_, config=config)\n", - " tool_msgs = tool.batch(ai_msg.tool_calls, config=config)\n", - " return llm_chain.invoke({**input_, \"messages\": [ai_msg, *tool_msgs]}, config=config)\n", - "\n", - "\n", - "tool_chain.invoke(\n", - " \"Extract and summarize the content from https://en.wikipedia.org/wiki/Large_language_model\"\n", - ")" + "See [`docs/demo_agent.ipynb`](./demo_agent.ipynb) for a complete ", + "walkthrough using `create_agent` with `claude-haiku-4-5`.\n" ] }, { @@ -447,4 +394,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/docs/search_tool.ipynb b/docs/search_tool.ipynb index ce18e9c..617df05 100644 --- a/docs/search_tool.ipynb +++ b/docs/search_tool.ipynb @@ -9,7 +9,7 @@ "\n", "This notebook provides a quick overview for getting started with Parallel [search tool](/docs/integrations/tools/). For detailed documentation of all ParallelWebSearchTool features and configurations head to the [API reference](https://docs.parallel.ai/api-reference/).\n", "\n", - "The ParallelWebSearchTool provides access to Parallel's Search API, which streamlines the traditional search → scrape → extract pipeline into a single API call, returning structured, LLM-optimized results.\n", + "The ParallelWebSearchTool provides access to Parallel's Search API, which streamlines the traditional search \u2192 scrape \u2192 extract pipeline into a single API call, returning structured, LLM-optimized results.\n", "\n", "## Overview\n", "\n", @@ -17,7 +17,7 @@ "\n", "| Class | Package | Serializable | JS support | Package latest |\n", "| :--- | :--- | :---: | :---: | :---: |\n", - "| [ParallelWebSearchTool](https://python.langchain.com/api_reference/parallel_web/search_tool/langchain_parallel.search_tool.ParallelWebSearchTool.html) | [langchain-parallel](https://python.langchain.com/api_reference/parallel_web/) | ❌ | ❌ | ![PyPI - Version](https://img.shields.io/pypi/v/langchain-parallel?style=flat-square&label=%20) |\n", + "| [ParallelWebSearchTool](https://python.langchain.com/api_reference/parallel_web/search_tool/langchain_parallel.search_tool.ParallelWebSearchTool.html) | [langchain-parallel](https://python.langchain.com/api_reference/parallel_web/) | \u274c | \u274c | ![PyPI - Version](https://img.shields.io/pypi/v/langchain-parallel?style=flat-square&label=%20) |\n", "\n", "### Tool features\n", "\n", @@ -86,16 +86,17 @@ "metadata": {}, "outputs": [], "source": [ - "from langchain_parallel import ParallelWebSearchTool\n", + "from langchain_parallel import ParallelSearchTool\n", "\n", - "# Basic instantiation - API key from environment\n", - "tool = ParallelWebSearchTool()\n", + "# Reads PARALLEL_API_KEY from the environment by default.\n", + "# (The older `ParallelWebSearchTool` name continues to work as an alias.)\n", + "tool = ParallelSearchTool()\n", "\n", - "# With explicit API key and custom base URL\n", - "tool = ParallelWebSearchTool(\n", - " api_key=\"your-api-key\",\n", - " base_url=\"https://api.parallel.ai\", # default value\n", - ")" + "# To pass an explicit key or override the base URL:\n", + "# tool = ParallelSearchTool(\n", + "# api_key=\"your-api-key\",\n", + "# base_url=\"https://api.parallel.ai\",\n", + "# )\n" ] }, { @@ -117,17 +118,17 @@ "metadata": {}, "outputs": [], "source": [ - "# Using specific search queries with advanced options\n", + "# Using specific search queries with advanced options.\n", "result = tool.invoke(\n", " {\n", " \"search_queries\": [\n", - " \"AI breakthroughs 2024\",\n", + " \"AI breakthroughs 2026\",\n", " \"machine learning advances\",\n", " \"generative AI news\",\n", " ],\n", " \"max_results\": 8,\n", " \"excerpts\": {\"max_chars_per_result\": 2000},\n", - " \"mode\": \"one-shot\", # Use 'agentic' for token-efficient results\n", + " \"mode\": \"advanced\", # Higher quality; 'basic' is lower-latency\n", " \"source_policy\": {\n", " \"include_domains\": [\"arxiv.org\", \"nature.com\"],\n", " \"exclude_domains\": [\"reddit.com\", \"twitter.com\"],\n", @@ -137,11 +138,11 @@ " \"timeout_seconds\": 60,\n", " },\n", " \"include_metadata\": True,\n", - " \"timeout\": 120, # Custom timeout in seconds\n", " }\n", ")\n", "\n", - "print(result)" + "print(f\"Found {len(result.get('results', []))} results\")\n", + "print(f\"endpoint: {result['search_metadata']['endpoint']}\")\n" ] }, { @@ -151,40 +152,22 @@ "metadata": {}, "outputs": [], "source": [ - "# Using an objective (natural language) with metadata\n", + "# Pair an objective with search_queries to use the GA /v1 endpoint.\n", "result = tool.invoke(\n", " {\n", - " \"objective\": \"What are the latest developments in artificial intelligence in 2024?\",\n", + " \"search_queries\": [\n", + " \"AI developments 2026\",\n", + " \"artificial intelligence breakthroughs\",\n", + " ],\n", + " \"objective\": (\n", + " \"What are the latest developments in artificial intelligence?\"\n", + " ),\n", " \"max_results\": 5,\n", - " \"include_metadata\": True, # Include search timing and statistics\n", + " \"include_metadata\": True,\n", " }\n", ")\n", "\n", - "print(result)\n", - "\n", - "# Example response structure:\n", - "# {\n", - "# \"search_id\": \"search_abc123...\",\n", - "# \"results\": [\n", - "# {\n", - "# \"url\": \"https://example.com/ai-news\",\n", - "# \"title\": \"Latest AI Developments 2024\",\n", - "# \"excerpts\": [\n", - "# \"Recent breakthrough in transformer architectures...\",\n", - "# \"New applications in computer vision...\"\n", - "# ]\n", - "# }\n", - "# ],\n", - "# \"search_metadata\": {\n", - "# \"search_duration_seconds\": 4.123,\n", - "# \"search_timestamp\": \"2024-01-15T10:30:00\",\n", - "# \"max_results_requested\": 5,\n", - "# \"actual_results_returned\": 4,\n", - "# \"search_id\": \"search_abc123...\",\n", - "# \"query_count\": 1,\n", - "# \"source_policy_applied\": false\n", - "# }\n", - "# }" + "print(f\"Found {len(result.get('results', []))} results / endpoint={result['search_metadata']['endpoint']}\")\n" ] }, { @@ -207,9 +190,15 @@ "# This is usually generated by a model, but we'll create a tool call directly for demo purposes.\n", "model_generated_tool_call = {\n", " \"args\": {\n", + " \"search_queries\": [\n", + " \"climate change initiatives\",\n", + " \"global climate policy 2026\",\n", + " ],\n", " \"objective\": \"Find recent news about climate change initiatives\",\n", " \"max_results\": 3,\n", - " \"source_policy\": {\"include_domains\": [\"ipcc.ch\", \"unfccc.int\", \"nature.com\"]},\n", + " \"source_policy\": {\n", + " \"include_domains\": [\"ipcc.ch\", \"unfccc.int\", \"nature.com\"]\n", + " },\n", " \"include_metadata\": True,\n", " },\n", " \"id\": \"call_123\",\n", @@ -219,8 +208,7 @@ "\n", "result = tool.invoke(model_generated_tool_call)\n", "print(result)\n", - "print(f\"Tool name: {tool.name}\") # parallel_web_search\n", - "print(f\"Tool description: {tool.description}\")" + "print(f\"Tool name: {tool.name}\") # parallel_web_search\n" ] }, { @@ -243,16 +231,12 @@ "async def search_async():\n", " return await tool.ainvoke(\n", " {\n", + " \"search_queries\": [\"quantum computing breakthroughs\"],\n", " \"objective\": \"Latest quantum computing breakthroughs\",\n", " \"max_results\": 5,\n", " \"include_metadata\": True,\n", " }\n", - " )\n", - "\n", - "\n", - "# Run async search\n", - "result = await search_async()\n", - "print(result)" + " )\n" ] }, { @@ -296,7 +280,7 @@ " \"excerpts\": {\n", " \"max_chars_per_result\": 2500\n", " }, # Longer excerpts for detailed information\n", - " \"mode\": \"one-shot\", # Comprehensive results\n", + " \"mode\": \"advanced\", # Comprehensive results\n", " \"source_policy\": {\n", " \"include_domains\": [\"europa.eu\", \"iea.org\", \"irena.org\"],\n", " \"exclude_domains\": [\"wikipedia.org\", \"reddit.com\"],\n", @@ -320,67 +304,18 @@ }, { "cell_type": "markdown", - "id": "659f9fbd-6fcf-445f-aa8c-72d8e60154bd", "metadata": {}, "source": [ "## Chaining\n", "\n", - "We can use our tool in a chain by first binding it to a [tool-calling model](/docs/how_to/tool_calling/) and then calling it:\n", - "\n", - "import ChatModelTabs from \"@theme/ChatModelTabs\";\n", - "\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "id": "af3123ad-7a02-40e5-b58e-7d56e23e5830", - "metadata": {}, - "outputs": [], - "source": [ - "# | output: false\n", - "# | echo: false\n", - "\n", - "# !pip install -qU langchain langchain-openai\n", - "from langchain.chat_models import init_chat_model\n", - "\n", - "llm = init_chat_model(model=\"gpt-4o\", model_provider=\"openai\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fdbf35b5-3aaf-4947-9ec6-48c21533fb95", - "metadata": {}, - "outputs": [], - "source": [ - "from langchain_core.prompts import ChatPromptTemplate\n", - "from langchain_core.runnables import RunnableConfig, chain\n", - "\n", - "prompt = ChatPromptTemplate(\n", - " [\n", - " (\"system\", \"You are a helpful assistant.\"),\n", - " (\"human\", \"{user_input}\"),\n", - " (\"placeholder\", \"{messages}\"),\n", - " ]\n", - ")\n", - "\n", - "# specifying tool_choice will force the model to call this tool.\n", - "llm_with_tools = llm.bind_tools([tool], tool_choice=tool.name)\n", - "\n", - "llm_chain = prompt | llm_with_tools\n", - "\n", - "\n", - "@chain\n", - "def tool_chain(user_input: str, config: RunnableConfig):\n", - " input_ = {\"user_input\": user_input}\n", - " ai_msg = llm_chain.invoke(input_, config=config)\n", - " tool_msgs = tool.batch(ai_msg.tool_calls, config=config)\n", - " return llm_chain.invoke({**input_, \"messages\": [ai_msg, *tool_msgs]}, config=config)\n", - "\n", + "To use the tool from a tool-calling chat model, bind it to any LLM ", + "that supports tool calls (e.g. `ChatAnthropic`, `ChatOpenAI`) and ", + "drive an agent with `langchain.agents.create_agent`. Parallel's own ", + "`ChatParallel` does not support tool calling \u2014 use it as a research assistant inside a chain, or use the search/extract tools ", + "alongside another model.\n", "\n", - "tool_chain.invoke(\"What are the latest breakthrough discoveries in quantum computing?\")" + "See [`docs/demo_agent.ipynb`](./demo_agent.ipynb) for a complete ", + "walkthrough using `create_agent` with `claude-haiku-4-5`.\n" ] }, { @@ -455,4 +390,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/examples/chat_example.py b/examples/chat_example.py index c272ef1..7c69fa9 100644 --- a/examples/chat_example.py +++ b/examples/chat_example.py @@ -7,7 +7,7 @@ from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage -from langchain_parallel import ChatParallelWeb +from langchain_parallel import ChatParallel # Set your API key: export PARALLEL_API_KEY="your-api-key" @@ -16,12 +16,10 @@ def basic_example() -> None: """Basic synchronous chat example.""" print("=== Basic Chat Example ===") - # Initialize the chat model - chat = ChatParallelWeb( - model_name="speed", # Parallel's chat model - temperature=0.7, # Optional: temperature (ignored by Parallel) - max_tokens=None, # Optional: max tokens (ignored by Parallel) - ) + # Initialize the chat model. Models: "speed" (default, fast), + # "lite" / "base" / "core" (research models with web citations + # in `response_metadata["basis"]` and structured-output support). + chat = ChatParallel(model="speed") # Create messages messages = [ @@ -57,7 +55,7 @@ def streaming_example() -> None: """Streaming example for real-time responses.""" print("\n=== Streaming Chat Example ===") - chat = ChatParallelWeb() + chat = ChatParallel() messages = [ SystemMessage(content="You are a creative writing assistant."), @@ -86,7 +84,7 @@ async def async_example() -> None: """Asynchronous example.""" print("\n=== Async Chat Example ===") - chat = ChatParallelWeb() + chat = ChatParallel() messages = [ SystemMessage(content="You are a technology expert."), @@ -121,7 +119,7 @@ def conversation_example() -> None: """Example of maintaining conversation context.""" print("\n=== Conversation Example ===") - chat = ChatParallelWeb() + chat = ChatParallel() # Start with system message messages: list[BaseMessage] = [ diff --git a/examples/extract_tool_example.py b/examples/extract_tool_example.py index 6efafb0..f8c1df9 100644 --- a/examples/extract_tool_example.py +++ b/examples/extract_tool_example.py @@ -168,11 +168,11 @@ def agent_integration_example() -> None: """Example of using extract tool with an agent.""" print("\n=== Agent Integration Example ===") - from langchain_parallel import ChatParallelWeb + from langchain_parallel import ChatParallel # Initialize tools extract_tool = ParallelExtractTool(max_chars_per_extract=3000) - chat = ChatParallelWeb() + chat = ChatParallel() # Extract content print("\nExtracting content from URLs...") diff --git a/examples/search_example.py b/examples/search_example.py index f7b33e7..c91c1f7 100644 --- a/examples/search_example.py +++ b/examples/search_example.py @@ -6,7 +6,7 @@ import os from typing import Any -from langchain_parallel import ParallelWebSearchTool +from langchain_parallel import ParallelSearchTool, SourcePolicy # Set your API key: export PARALLEL_API_KEY="your-api-key" @@ -15,37 +15,37 @@ def basic_search_examples() -> None: """Basic search tool examples.""" print("=== Basic Search Examples ===") - # Initialize the search tool - search_tool = ParallelWebSearchTool() + search_tool = ParallelSearchTool() - # Example 1: Simple objective-based search - print("\nExample 1: Simple objective-based search") + # Example 1: Objective + search_queries (the recommended GA shape). + print("\nExample 1: Objective + search_queries") result = search_tool.invoke( { - "objective": ( - "What are the latest developments in artificial intelligence in 2024?" - ) - } + "search_queries": [ + "latest AI developments 2026", + "AI research breakthroughs", + ], + "objective": "What are the latest developments in artificial intelligence?", + "max_results": 5, + }, ) - print(f"Found {len(result.get('results', []))} results") display_results(result, max_results=2) display_metadata(result) - # Example 2: Multiple search queries + # Example 2: Multiple search queries (no objective). print("\nExample 2: Multiple search queries") result2 = search_tool.invoke( { "search_queries": [ - "AI developments 2024", + "AI developments 2026", "latest artificial intelligence news", - "machine learning breakthroughs 2024", + "machine learning breakthroughs", ], "max_results": 8, - "include_metadata": True, # Get timing info - } + "include_metadata": True, + }, ) - print(f"Found {len(result2.get('results', []))} results") display_results(result2, max_results=3) display_metadata(result2) @@ -55,47 +55,49 @@ def search_examples() -> None: """Search features examples.""" print("\n=== Search Examples ===") - search_tool = ParallelWebSearchTool() + search_tool = ParallelSearchTool() - # Example 3: Academic search with domain filtering and fetch policy + # Example 3: Academic search with domain filtering and fetch policy. print("\nExample 3: Academic search with domain filtering and fetch policy") result3 = search_tool.invoke( { + "search_queries": [ + "climate change research findings", + "global warming peer reviewed studies", + ], "objective": "Latest climate change research and findings", - "source_policy": { - "include_domains": ["nature.com", "science.org", "arxiv.org"], - "exclude_domains": ["reddit.com", "twitter.com", "facebook.com"], - }, + "source_policy": SourcePolicy( + include_domains=["nature.com", "science.org", "arxiv.org"], + exclude_domains=["reddit.com", "twitter.com", "facebook.com"], + ), "max_results": 5, - "excerpts": {"max_chars_per_result": 2000}, # Longer excerpts - "mode": "one-shot", # Comprehensive results + "excerpts": {"max_chars_per_result": 2000}, + "mode": "advanced", # Higher quality with more retrieval and compression. "fetch_policy": { - "max_age_seconds": 86400, # Cache content for 1 day - "timeout_seconds": 60, # 60 second timeout for live fetches + "max_age_seconds": 86400, # Cache content for 1 day. + "timeout_seconds": 60, }, "include_metadata": True, - } + }, ) - print("Academic sources search completed") display_results(result3, max_results=2, show_excerpts=True) display_metadata(result3) - # Example 4: Multiple topic news search with agentic mode - print("\nExample 4: Multiple topic news search with agentic mode") + # Example 4: Multiple-topic news search with the basic (low-latency) mode. + print("\nExample 4: Multiple topic news search (basic mode)") result4 = search_tool.invoke( { "search_queries": [ - "tech industry layoffs 2024", + "tech industry layoffs 2026", "startup funding trends", "AI company acquisitions", ], "max_results": 6, - "mode": "agentic", # Token-efficient, concise results + "mode": "basic", # Low-latency mode; pair with 2-3 high-quality queries. "include_metadata": True, - } + }, ) - print("Multiple query search completed") display_results(result4, max_results=3) display_metadata(result4) @@ -105,34 +107,44 @@ async def async_search_examples() -> None: """Async search examples.""" print("\n=== Async Search Examples ===") - search_tool = ParallelWebSearchTool() + search_tool = ParallelSearchTool() - # Example 5: Async search + # Example 5: Async search. print("\nExample 5: Async search execution") result5 = await search_tool.ainvoke( { + "search_queries": ["quantum computing breakthroughs"], "objective": "Latest developments in quantum computing", "max_results": 4, "include_metadata": True, - } + }, ) - print("Async search completed") display_results(result5, max_results=2) display_metadata(result5) - # Example 6: Parallel async searches + # Example 6: Parallel async searches. print("\nExample 6: Parallel async searches") tasks = [ search_tool.ainvoke( - {"objective": "artificial intelligence news", "max_results": 3} + { + "search_queries": ["artificial intelligence news"], + "max_results": 3, + }, + ), + search_tool.ainvoke( + { + "search_queries": ["machine learning research"], + "max_results": 3, + }, ), search_tool.ainvoke( - {"objective": "machine learning research", "max_results": 3} + { + "search_queries": ["robotics developments"], + "max_results": 3, + }, ), - search_tool.ainvoke({"objective": "robotics developments", "max_results": 3}), ] - results = await asyncio.gather(*tasks) for i, result in enumerate(results, 1): @@ -141,7 +153,10 @@ async def async_search_examples() -> None: def display_results( - result: dict[str, Any], *, max_results: int = 5, show_excerpts: bool = False + result: dict[str, Any], + *, + max_results: int = 5, + show_excerpts: bool = False, ) -> None: """Display search results in a formatted way.""" if "results" not in result: @@ -149,13 +164,10 @@ def display_results( print(f"Response keys: {list(result.keys())}") return - results = result["results"][:max_results] - - for i, res in enumerate(results, 1): + for i, res in enumerate(result["results"][:max_results], 1): print(f"\nResult {i}:") print(f" URL: {res.get('url', 'N/A')}") print(f" Title: {res.get('title', 'N/A')}") - excerpts = res.get("excerpts", []) if excerpts: print(f" Excerpts: {len(excerpts)} found") @@ -170,100 +182,92 @@ def display_metadata(result: dict[str, Any]) -> None: """Display search metadata if available.""" if "search_metadata" not in result: return - metadata = result["search_metadata"] print("\n Search Metadata:") + print(f" Endpoint: {metadata.get('endpoint', 'N/A')}") print(f" Duration: {metadata.get('search_duration_seconds', 'N/A')}s") - print( - f" Results: {metadata.get('actual_results_returned', 'N/A')}" - f"/{metadata.get('max_results_requested', 'N/A')}" - ) - - if metadata.get("query_count"): - print(f" Queries: {metadata['query_count']}") - - if metadata.get("source_policy_applied"): - if "included_domains" in metadata: - print(f" Included domains: {metadata['included_domains']}") - if "excluded_domains" in metadata: - print(f" Excluded domains: {metadata['excluded_domains']}") + print(f" Results: {metadata.get('actual_results_returned', 'N/A')}") def practical_use_cases() -> None: """Practical use case examples.""" print("\n=== Practical Use Cases ===") - search_tool = ParallelWebSearchTool() + search_tool = ParallelSearchTool() - # Use case 1: Research assistance + # Use case 1: Research assistance. print("\nUse Case 1: Research Assistant") research_result = search_tool.invoke( { - "objective": "Analysis of renewable energy adoption trends in 2024", - "source_policy": { - "include_domains": ["iea.org", "irena.org", "energy.gov", "nature.com"], - "exclude_domains": ["blog.com", "personal-site.com"], - }, + "search_queries": [ + "renewable energy adoption 2026", + "solar wind energy growth", + ], + "objective": "Analysis of renewable energy adoption trends", + "source_policy": SourcePolicy( + include_domains=["iea.org", "irena.org", "energy.gov", "nature.com"], + exclude_domains=["blog.com", "personal-site.com"], + ), "max_results": 10, "excerpts": {"max_chars_per_result": 2500}, "include_metadata": True, - } + }, ) - print("Research completed - energy analysis") print(f"Found {len(research_result.get('results', []))} authoritative sources") display_metadata(research_result) - # Use case 2: News monitoring + # Use case 2: News monitoring. print("\nUse Case 2: News Monitoring Dashboard") news_result = search_tool.invoke( { "search_queries": [ "tech industry news today", "AI company funding", - "cybersecurity breaches 2024", + "cybersecurity breaches 2026", "cloud computing trends", ], "max_results": 15, "include_metadata": True, - } + }, ) - print("News monitoring completed") print(f"Found {len(news_result.get('results', []))} relevant news items") display_metadata(news_result) - # Use case 3: Competitive analysis + # Use case 3: Competitive analysis. print("\nUse Case 3: Competitive Analysis") competitor_result = search_tool.invoke( { + "search_queries": [ + "tech company product launches", + "big tech strategic moves", + ], "objective": ( "Latest product launches and strategic moves by major tech companies" ), - "source_policy": { - "include_domains": [ + "source_policy": SourcePolicy( + include_domains=[ "techcrunch.com", "theverge.com", "wired.com", "ars-technica.com", ], - "exclude_domains": ["reddit.com", "twitter.com"], - }, + exclude_domains=["reddit.com", "twitter.com"], + ), "max_results": 12, "include_metadata": True, - } + }, ) - print("Competitive analysis completed") display_results(competitor_result, max_results=2) display_metadata(competitor_result) async def main() -> None: - """Main function demonstrating Parallel Web Search Tool usage.""" + """Main function demonstrating Parallel Search Tool usage.""" print("=== Parallel Search Examples ===") - # Check if API key is set if not os.getenv("PARALLEL_API_KEY"): print("Error: PARALLEL_API_KEY environment variable not set") print("Please set your API key: export PARALLEL_API_KEY='your-api-key'") @@ -272,31 +276,21 @@ async def main() -> None: print("API key found in environment") print("Starting search examples...") - # Run examples try: - # Basic examples basic_search_examples() - - # Search features search_examples() - - # Async examples await async_search_examples() - - # Practical use cases practical_use_cases() print("\n=== All examples completed successfully ===") print("\nKey features demonstrated:") - print(" - Basic objective and query-based searches") - print(" - Multi-query search capabilities") - print(" - Domain filtering with source policies") - print(" - Fetch policies for cache control") - print(" - Search modes: one-shot and agentic") - print(" - Async search execution") - print(" - Parallel search processing") + print(" - search_queries + objective (GA /v1 endpoint)") + print(" - Multi-query search") + print(" - Domain filtering with SourcePolicy") + print(" - FetchPolicy for cache control") + print(" - Search modes: basic (low-latency) and advanced (high-quality)") + print(" - Async + parallel execution") print(" - Metadata collection") - print(" - Practical use case implementations") except Exception as e: print(f"\nError during execution: {e}") @@ -325,5 +319,4 @@ def run_sync_examples() -> None: if __name__ == "__main__": - # Run async main asyncio.run(main()) diff --git a/langchain_parallel/__init__.py b/langchain_parallel/__init__.py index 45463ab..c3ade94 100644 --- a/langchain_parallel/__init__.py +++ b/langchain_parallel/__init__.py @@ -4,10 +4,11 @@ ExcerptSettings, FetchPolicy, FullContentSettings, + SourcePolicy, ) -from langchain_parallel.chat_models import ChatParallelWeb +from langchain_parallel.chat_models import ChatParallel, ChatParallelWeb from langchain_parallel.extract_tool import ParallelExtractTool -from langchain_parallel.search_tool import ParallelWebSearchTool +from langchain_parallel.search_tool import ParallelSearchTool, ParallelWebSearchTool try: __version__ = metadata.version(__package__ or __name__) @@ -17,11 +18,14 @@ del metadata # optional, avoids polluting the results of dir(__package__) __all__ = [ + "ChatParallel", "ChatParallelWeb", "ExcerptSettings", "FetchPolicy", "FullContentSettings", "ParallelExtractTool", + "ParallelSearchTool", "ParallelWebSearchTool", + "SourcePolicy", "__version__", ] diff --git a/langchain_parallel/_client.py b/langchain_parallel/_client.py index 949649f..691e461 100644 --- a/langchain_parallel/_client.py +++ b/langchain_parallel/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Optional, Union +from typing import Optional import openai from parallel import AsyncParallel, Parallel @@ -45,235 +45,11 @@ def get_async_openai_client(api_key: str, base_url: str) -> openai.AsyncOpenAI: return openai.AsyncOpenAI(api_key=api_key, base_url=base_url) -class ParallelSearchClient: - """Synchronous client for Parallel Search API using the Parallel SDK.""" +def get_parallel_client(api_key: str, base_url: str) -> Parallel: + """Returns a configured sync Parallel SDK client.""" + return Parallel(api_key=api_key, base_url=base_url) - def __init__( - self, - api_key: str, - base_url: str = "https://api.parallel.ai", - ): - self.api_key = api_key - self.base_url = base_url.rstrip("/") - # Initialize the Parallel SDK client - self.client = Parallel(api_key=api_key, base_url=base_url) - def search( - self, - objective: Optional[str] = None, - search_queries: Optional[list[str]] = None, - max_results: int = 10, - excerpts: Optional[dict[str, Any]] = None, - mode: Optional[str] = None, - source_policy: Optional[dict[str, Union[str, list[str]]]] = None, - fetch_policy: Optional[dict[str, Any]] = None, - timeout: Optional[float] = None, - ) -> dict[str, Any]: - """Perform a synchronous search using the Parallel Search API via SDK.""" - if not objective and not search_queries: - msg = "Either 'objective' or 'search_queries' must be provided" - raise ValueError(msg) - - # Use default timeout if not provided - if timeout is None: - timeout = 30.0 - - # Build kwargs, only including non-None values for optional params - kwargs: dict[str, Any] = { - "objective": objective, - "search_queries": search_queries, - "max_results": max_results, - "timeout": timeout, - } - if excerpts is not None: - kwargs["excerpts"] = excerpts - if mode is not None: - kwargs["mode"] = mode - if source_policy is not None: - kwargs["source_policy"] = source_policy - if fetch_policy is not None: - kwargs["fetch_policy"] = fetch_policy - - # Use the Parallel SDK's beta.search method - search_response = self.client.beta.search(**kwargs) - - # Convert the SDK response to a dictionary - return search_response.model_dump() - - -class AsyncParallelSearchClient: - """Asynchronous client for Parallel Search API using the Parallel SDK.""" - - def __init__( - self, - api_key: str, - base_url: str = "https://api.parallel.ai", - ): - self.api_key = api_key - self.base_url = base_url.rstrip("/") - # Initialize the Parallel SDK async client - self.client = AsyncParallel(api_key=api_key, base_url=base_url) - - async def search( - self, - objective: Optional[str] = None, - search_queries: Optional[list[str]] = None, - max_results: int = 10, - excerpts: Optional[dict[str, Any]] = None, - mode: Optional[str] = None, - source_policy: Optional[dict[str, Union[str, list[str]]]] = None, - fetch_policy: Optional[dict[str, Any]] = None, - timeout: Optional[float] = None, - ) -> dict[str, Any]: - """Perform an async search using the Parallel Search API via SDK.""" - if not objective and not search_queries: - msg = "Either 'objective' or 'search_queries' must be provided" - raise ValueError(msg) - - # Use default timeout if not provided - if timeout is None: - timeout = 30.0 - - # Build kwargs, only including non-None values for optional params - kwargs: dict[str, Any] = { - "objective": objective, - "search_queries": search_queries, - "max_results": max_results, - "timeout": timeout, - } - if excerpts is not None: - kwargs["excerpts"] = excerpts - if mode is not None: - kwargs["mode"] = mode - if source_policy is not None: - kwargs["source_policy"] = source_policy - if fetch_policy is not None: - kwargs["fetch_policy"] = fetch_policy - - # Use the Parallel SDK's beta.search method - search_response = await self.client.beta.search(**kwargs) - - # Convert the SDK response to a dictionary - return search_response.model_dump() - - -def get_search_client( - api_key: str, base_url: str = "https://api.parallel.ai" -) -> ParallelSearchClient: - """Returns a configured sync Parallel Search client.""" - return ParallelSearchClient(api_key, base_url) - - -def get_async_search_client( - api_key: str, base_url: str = "https://api.parallel.ai" -) -> AsyncParallelSearchClient: - """Returns a configured async Parallel Search client.""" - return AsyncParallelSearchClient(api_key, base_url) - - -class ParallelExtractClient: - """Synchronous client for Parallel Extract API using the Parallel SDK.""" - - def __init__( - self, - api_key: str, - base_url: str = "https://api.parallel.ai", - ): - self.api_key = api_key - self.base_url = base_url.rstrip("/") - # Initialize the Parallel SDK client - self.client = Parallel(api_key=api_key, base_url=base_url) - - def extract( - self, - urls: list[str], - objective: Optional[str] = None, - search_queries: Optional[list[str]] = None, - excerpts: Optional[Union[bool, dict[str, Any]]] = None, - full_content: Optional[Union[bool, dict[str, Any]]] = None, - fetch_policy: Optional[dict[str, Any]] = None, - timeout: Optional[float] = None, - ) -> dict[str, Any]: - """Perform a synchronous extract using the Parallel Extract API via SDK.""" - if not urls: - msg = "At least one URL must be provided" - raise ValueError(msg) - - # Use default timeout if not provided (5 seconds per URL) - if timeout is None: - timeout = 5.0 * len(urls) - - # Use the Parallel SDK's beta.extract method - extract_response = self.client.beta.extract( - urls=urls, - objective=objective, - search_queries=search_queries, - excerpts=excerpts, - full_content=full_content, - fetch_policy=fetch_policy, - timeout=timeout, - ) - - # Convert the SDK response to a dictionary - return extract_response.model_dump() - - -class AsyncParallelExtractClient: - """Asynchronous client for Parallel Extract API using the Parallel SDK.""" - - def __init__( - self, - api_key: str, - base_url: str = "https://api.parallel.ai", - ): - self.api_key = api_key - self.base_url = base_url.rstrip("/") - # Initialize the Parallel SDK async client - self.client = AsyncParallel(api_key=api_key, base_url=base_url) - - async def extract( - self, - urls: list[str], - objective: Optional[str] = None, - search_queries: Optional[list[str]] = None, - excerpts: Optional[Union[bool, dict[str, Any]]] = None, - full_content: Optional[Union[bool, dict[str, Any]]] = None, - fetch_policy: Optional[dict[str, Any]] = None, - timeout: Optional[float] = None, - ) -> dict[str, Any]: - """Perform an async extract using the Parallel Extract API via SDK.""" - if not urls: - msg = "At least one URL must be provided" - raise ValueError(msg) - - # Use default timeout if not provided (5 seconds per URL) - if timeout is None: - timeout = 5.0 * len(urls) - - # Use the Parallel SDK's beta.extract method - extract_response = await self.client.beta.extract( - urls=urls, - objective=objective, - search_queries=search_queries, - excerpts=excerpts, - full_content=full_content, - fetch_policy=fetch_policy, - timeout=timeout, - ) - - # Convert the SDK response to a dictionary - return extract_response.model_dump() - - -def get_extract_client( - api_key: str, base_url: str = "https://api.parallel.ai" -) -> ParallelExtractClient: - """Returns a configured sync Parallel Extract client.""" - return ParallelExtractClient(api_key, base_url) - - -def get_async_extract_client( - api_key: str, base_url: str = "https://api.parallel.ai" -) -> AsyncParallelExtractClient: - """Returns a configured async Parallel Extract client.""" - return AsyncParallelExtractClient(api_key, base_url) +def get_async_parallel_client(api_key: str, base_url: str) -> AsyncParallel: + """Returns a configured async Parallel SDK client.""" + return AsyncParallel(api_key=api_key, base_url=base_url) diff --git a/langchain_parallel/_types.py b/langchain_parallel/_types.py index e25bfa2..7bb2f18 100644 --- a/langchain_parallel/_types.py +++ b/langchain_parallel/_types.py @@ -58,3 +58,26 @@ class FetchPolicy(BaseModel): "fetch fails or times out. If true, returns an error instead." ), ) + + +class SourcePolicy(BaseModel): + """Domain allow/deny lists and freshness floor for web research.""" + + include_domains: Optional[list[str]] = Field( + default=None, + description=( + "If provided, only sources from these apex domains are returned. " + "Combined include + exclude lists are capped at 200 domains." + ), + ) + exclude_domains: Optional[list[str]] = Field( + default=None, + description="If provided, sources from these apex domains are excluded.", + ) + after_date: Optional[str] = Field( + default=None, + description=( + "ISO date (YYYY-MM-DD). Only return sources published on or after " + "this date." + ), + ) diff --git a/langchain_parallel/chat_models.py b/langchain_parallel/chat_models.py index eac915e..8441554 100644 --- a/langchain_parallel/chat_models.py +++ b/langchain_parallel/chat_models.py @@ -8,14 +8,14 @@ import contextlib from collections.abc import AsyncIterator, Iterator -from typing import Any, Optional, cast +from typing import Any, Literal, Optional, Union, cast import openai from langchain_core.callbacks import ( AsyncCallbackManagerForLLMRun, CallbackManagerForLLMRun, ) -from langchain_core.language_models import BaseChatModel +from langchain_core.language_models import BaseChatModel, LanguageModelInput from langchain_core.messages import ( AIMessage, AIMessageChunk, @@ -23,13 +23,23 @@ HumanMessage, SystemMessage, ) +from langchain_core.output_parsers import ( + JsonOutputParser, + PydanticOutputParser, +) from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult +from langchain_core.runnables import Runnable +from langchain_core.utils.function_calling import convert_to_json_schema +from langchain_core.utils.pydantic import is_basemodel_subclass from openai import AuthenticationError, RateLimitError -from pydantic import Field, SecretStr, model_validator +from pydantic import BaseModel, Field, SecretStr, model_validator from typing_extensions import Self from ._client import get_api_key, get_async_openai_client, get_openai_client +# Models that support response_format JSON schema. The `speed` model ignores it. +_STRUCTURED_OUTPUT_MODELS: frozenset[str] = frozenset({"lite", "base", "core"}) + def _convert_message_to_dict(message: BaseMessage) -> dict[str, Any]: """Convert a LangChain message to OpenAI message format.""" @@ -50,12 +60,31 @@ def _prepare_messages(messages: list[BaseMessage]) -> list[dict[str, Any]]: def _create_response_metadata(response: Any, choice: Any) -> dict[str, Any]: - """Create response metadata from API response.""" - return { - "model": getattr(response, "model", None), + """Create response metadata from API response. + + Uses LangChain 1.x standard keys (`model_name`, `finish_reason`, + `system_fingerprint`). Surfaces Parallel-specific fields (`basis`, + `interaction_id`) when present. + """ + metadata: dict[str, Any] = { + "model_name": getattr(response, "model", None), "finish_reason": getattr(choice, "finish_reason", None), "created": getattr(response, "created", None), } + system_fingerprint = getattr(response, "system_fingerprint", None) + if system_fingerprint is not None: + metadata["system_fingerprint"] = system_fingerprint + basis = getattr(response, "basis", None) + if basis: + metadata["basis"] = ( + [b.model_dump() if hasattr(b, "model_dump") else b for b in basis] + if isinstance(basis, list) + else basis + ) + interaction_id = getattr(response, "interaction_id", None) + if interaction_id is not None: + metadata["interaction_id"] = interaction_id + return metadata def _create_ai_message(content: str, response_metadata: dict[str, Any]) -> AIMessage: @@ -69,11 +98,22 @@ def _create_ai_message(content: str, response_metadata: dict[str, Any]) -> AIMes def _create_stream_response_metadata(chunk: Any, choice: Any) -> dict[str, Any]: """Create response metadata for streaming chunks.""" - response_metadata = {} + response_metadata: dict[str, Any] = {} if hasattr(choice, "finish_reason") and choice.finish_reason is not None: response_metadata["finish_reason"] = str(choice.finish_reason) if hasattr(chunk, "model"): - response_metadata["model"] = chunk.model + response_metadata["model_name"] = chunk.model + if getattr(chunk, "system_fingerprint", None) is not None: + response_metadata["system_fingerprint"] = chunk.system_fingerprint + if getattr(chunk, "interaction_id", None) is not None: + response_metadata["interaction_id"] = chunk.interaction_id + basis = getattr(chunk, "basis", None) + if basis: + response_metadata["basis"] = ( + [b.model_dump() if hasattr(b, "model_dump") else b for b in basis] + if isinstance(basis, list) + else basis + ) return response_metadata @@ -219,8 +259,17 @@ class ChatParallelWeb(BaseChatModel): """ - model: str = Field(default="speed", alias="model_name") - """The name of the model to use. Defaults to 'speed' for Parallel.""" + model: str = Field(default="speed") + """The name of the model to use. + + One of: + + - ``"speed"`` (default): low-latency conversational answers, no citations. + - ``"lite"`` / ``"base"`` / ``"core"``: research models with web access + that return source citations on ``response_metadata['basis']`` and + support ``response_format`` JSON schemas via + :meth:`with_structured_output`. + """ api_key: Optional[SecretStr] = Field(default=None) """Parallel API key. If not provided, will be read from @@ -275,6 +324,24 @@ class ChatParallelWeb(BaseChatModel): _client: Optional[openai.OpenAI] = None _async_client: Optional[openai.AsyncOpenAI] = None + @model_validator(mode="before") + @classmethod + def _accept_model_name_alias(cls, values: Any) -> Any: + """Accept ``model_name="..."`` as a back-compat alias for ``model="..."``. + + Pre-0.3.0 the field was declared as ``Field(alias="model_name")``, + meaning users had to pass ``model_name=`` and ``model=`` was silently + ignored. The alias was removed in 0.3.0 to fix that footgun; this + validator preserves callers that still use ``model_name=``. + """ + if ( + isinstance(values, dict) + and "model_name" in values + and "model" not in values + ): + values = {**values, "model": values.pop("model_name")} + return values + @model_validator(mode="after") def validate_environment(self) -> Self: """Validate that api key exists and initialize clients.""" @@ -339,7 +406,7 @@ def lc_secrets(self) -> dict[str, str]: @property def lc_attributes(self) -> dict[str, Any]: """Return attributes for LangChain serialization.""" - attributes: dict[str, Any] = {} + attributes: dict[str, Any] = {"model_name": self.model} if self.base_url: attributes["base_url"] = self.base_url return attributes @@ -377,7 +444,7 @@ def _process_non_stream_response(self, response: Any) -> ChatResult: choice = response.choices[0] content = choice.message.content or "" response_metadata = _create_response_metadata(response, choice) - response_metadata["model"] = response_metadata["model"] or self.model + response_metadata["model_name"] = response_metadata["model_name"] or self.model message = _create_ai_message(content, response_metadata) generation = ChatGeneration(message=message) @@ -437,6 +504,33 @@ async def _process_async_stream_chunk( return ChatGenerationChunk(message=chunk_message) + def _build_create_kwargs( + self, + messages: list[dict[str, Any]], + stop: Optional[list[str]], + *, + stream: bool, + extra: dict[str, Any], + ) -> dict[str, Any]: + """Build kwargs for the OpenAI ``chat.completions.create`` call. + + Per-call ``extra`` (typically populated by ``with_structured_output``) + wins over instance-level fields. + """ + create_kwargs: dict[str, Any] = { + "model": self.model, + "messages": cast(Any, messages), + "stream": stream, + "temperature": self.temperature, + "max_tokens": self.max_tokens, + "stop": stop, + } + if self.response_format is not None: + create_kwargs["response_format"] = self.response_format + # Per-call overrides from the runnable kwargs. Drop None values. + create_kwargs.update({k: v for k, v in extra.items() if v is not None}) + return create_kwargs + def _generate( self, messages: list[BaseMessage], @@ -449,12 +543,12 @@ def _generate( with self._handle_errors(): response = self.client.chat.completions.create( - model=self.model, - messages=cast(Any, openai_messages), - stream=False, - temperature=self.temperature, - max_tokens=self.max_tokens, - stop=stop, + **self._build_create_kwargs( + openai_messages, + stop, + stream=False, + extra=kwargs, + ), ) return self._process_non_stream_response(response) @@ -471,12 +565,12 @@ def _stream( with self._handle_errors(): stream = self.client.chat.completions.create( - model=self.model, - messages=cast(Any, openai_messages), - stream=True, - temperature=self.temperature, - max_tokens=self.max_tokens, - stop=stop, + **self._build_create_kwargs( + openai_messages, + stop, + stream=True, + extra=kwargs, + ), ) for chunk in stream: @@ -496,12 +590,12 @@ async def _agenerate( with self._handle_errors(): response = await self.async_client.chat.completions.create( - model=self.model, - messages=cast(Any, openai_messages), - stream=False, - temperature=self.temperature, - max_tokens=self.max_tokens, - stop=stop, + **self._build_create_kwargs( + openai_messages, + stop, + stream=False, + extra=kwargs, + ), ) return self._process_non_stream_response(response) @@ -518,12 +612,12 @@ async def _astream( with self._handle_errors(): stream = await self.async_client.chat.completions.create( - model=self.model, - messages=cast(Any, openai_messages), - stream=True, - temperature=self.temperature, - max_tokens=self.max_tokens, - stop=stop, + **self._build_create_kwargs( + openai_messages, + stop, + stream=True, + extra=kwargs, + ), ) async for chunk in stream: @@ -532,3 +626,116 @@ async def _astream( ) if chunk_result is not None: yield chunk_result + + def with_structured_output( + self, + schema: Optional[Union[dict[str, Any], type[BaseModel]]] = None, + *, + method: Literal["json_schema", "function_calling", "json_mode"] = "json_schema", + include_raw: bool = False, + strict: Optional[bool] = None, + **kwargs: Any, + ) -> Runnable[LanguageModelInput, Union[dict[str, Any], BaseModel]]: + """Return a Runnable that produces structured output. + + Parallel's research models (``lite``, ``base``, ``core``) accept the + OpenAI ``response_format`` parameter with a JSON schema. The ``speed`` + model silently ignores it; this method raises if you try to use it + on a non-supporting model so the failure is loud. + + Args: + schema: A pydantic v2 model class or a JSON schema dict. + method: ``"json_schema"`` (default) for strict-typed output, or + ``"json_mode"`` to ask the model for any valid JSON object. + ``"function_calling"`` is accepted for cross-provider + compatibility and is routed to ``"json_schema"`` since + Parallel's chat API does not support tool calling. + include_raw: If True, return ``{"raw": AIMessage, "parsed": ..., + "parsing_error": ...}`` instead of just the parsed value. + strict: Forwarded to the API's ``response_format`` JSON schema. + Defaults to True for pydantic schemas, None for raw dicts. + **kwargs: Reserved for forward compatibility; unused. + """ + if kwargs: + msg = f"Received unsupported kwargs: {sorted(kwargs)}" + raise ValueError(msg) + if self.model not in _STRUCTURED_OUTPUT_MODELS: + msg = ( + f"Structured output requires one of the research models " + f"({sorted(_STRUCTURED_OUTPUT_MODELS)}); the '{self.model}' " + f"model silently ignores response_format. Re-instantiate with " + f"`ChatParallelWeb(model='lite' | 'base' | 'core')`." + ) + raise ValueError(msg) + if method == "function_calling": + # Parallel chat doesn't support tool calling; route to json_schema + # since the user-visible result is equivalent. + method = "json_schema" + if method not in {"json_schema", "json_mode"}: + msg = ( + f"Unsupported method '{method}'. Use 'json_schema', " + f"'function_calling' (routed to json_schema), or 'json_mode'." + ) + raise ValueError(msg) + + if method == "json_mode": + # `json_mode` only enables JSON output without a schema constraint; + # if a schema is also passed, accept it for cross-provider compat + # but only use it for the parser, not for the API call. + response_format: dict[str, Any] = {"type": "json_object"} + schema_is_pydantic = ( + schema is not None + and isinstance(schema, type) + and is_basemodel_subclass(schema) + ) + output_parser: Runnable = ( + PydanticOutputParser(pydantic_object=schema) # type: ignore[arg-type] + if schema_is_pydantic + else JsonOutputParser() + ) + else: + if schema is None: + msg = "method='json_schema' requires a schema." + raise ValueError(msg) + is_pydantic = isinstance(schema, type) and is_basemodel_subclass(schema) + strict_value: Optional[bool] + if is_pydantic: + json_schema = convert_to_json_schema(schema) + output_parser = PydanticOutputParser(pydantic_object=schema) # type: ignore[arg-type] + strict_value = True if strict is None else strict + else: + json_schema = dict(schema) # type: ignore[arg-type] + output_parser = JsonOutputParser() + strict_value = strict + response_format = { + "type": "json_schema", + "json_schema": { + "name": json_schema.get("title", "output"), + "schema": json_schema, + }, + } + if strict_value is not None: + response_format["json_schema"]["strict"] = strict_value + + bound = self.bind(response_format=response_format) + if include_raw: + + def _parse_with_capture(raw: AIMessage) -> dict[str, Any]: + try: + return { + "raw": raw, + "parsed": output_parser.invoke(raw), + "parsing_error": None, + } + except Exception as e: + return {"raw": raw, "parsed": None, "parsing_error": e} + + return bound | _parse_with_capture + return bound | output_parser + + +#: Forward-compat alias for :class:`ChatParallelWeb`. +#: +#: Prefer ChatParallel in new code; ChatParallelWeb will continue to +#: work indefinitely as an alias for this class. +ChatParallel = ChatParallelWeb diff --git a/langchain_parallel/extract_tool.py b/langchain_parallel/extract_tool.py index 06d09f4..6404d45 100644 --- a/langchain_parallel/extract_tool.py +++ b/langchain_parallel/extract_tool.py @@ -2,6 +2,7 @@ from __future__ import annotations +import warnings from typing import Any, Optional, Union from langchain_core.callbacks import ( @@ -9,61 +10,142 @@ CallbackManagerForToolRun, ) from langchain_core.tools import BaseTool +from parallel import AsyncParallel, Parallel from pydantic import BaseModel, Field, SecretStr, model_validator -from ._client import get_api_key, get_async_extract_client, get_extract_client +from ._client import get_api_key, get_async_parallel_client, get_parallel_client from ._types import ExcerptSettings, FetchPolicy, FullContentSettings +def _coerce_full_content( + full_content: Union[bool, FullContentSettings, dict[str, Any]], + *, + tool_max_chars: Optional[int], +) -> Union[bool, dict[str, Any]]: + """Resolve the user-provided full_content arg + tool-level default. + + Precedence: an explicit FullContentSettings or dict wins over tool_max_chars, + which only applies when full_content was passed as a plain True/False. + """ + if isinstance(full_content, FullContentSettings): + return full_content.model_dump(exclude_none=True) + if isinstance(full_content, dict): + return {k: v for k, v in full_content.items() if v is not None} + if full_content is True and tool_max_chars is not None: + return {"max_chars_per_result": tool_max_chars} + return full_content + + +def _coerce_excerpts( + excerpts: Union[bool, ExcerptSettings, dict[str, Any], None], +) -> Optional[dict[str, Any]]: + """Resolve the legacy ``Union[bool, ExcerptSettings]`` excerpts arg. + + In v1 GA, excerpts are always returned and the API has no flag to disable + them — only their per-result size is configurable. We accept the legacy + boolean for backward compatibility: + + - ``None`` / ``True``: no excerpt-size override (API uses its default). + - ``False``: warn (the API can no longer disable excerpts) and treat as + no override. + - ``ExcerptSettings`` / ``dict``: pass through to advanced_settings. + """ + if excerpts is None or excerpts is True: + return None + if excerpts is False: + warnings.warn( + "excerpts=False is no longer supported — the GA Extract API " + "always returns excerpts. Use ExcerptSettings(max_chars_per_result=…) " + "to control per-result size.", + DeprecationWarning, + stacklevel=4, + ) + return None + if isinstance(excerpts, ExcerptSettings): + return excerpts.model_dump(exclude_none=True) + return {k: v for k, v in excerpts.items() if v is not None} + + +def _build_advanced_settings( + *, + excerpts_settings: Optional[dict[str, Any]], + full_content: Union[bool, dict[str, Any]], + fetch_policy: Optional[FetchPolicy], +) -> Optional[dict[str, Any]]: + """Pack the user-facing flat fields into the GA `advanced_settings` envelope.""" + settings: dict[str, Any] = {} + if excerpts_settings is not None: + settings["excerpt_settings"] = excerpts_settings + if fetch_policy is not None: + settings["fetch_policy"] = fetch_policy.model_dump(exclude_none=True) + # full_content goes through whether True/False/dict — the API treats False + # as "do not return full content" (default). + if full_content is not False: + settings["full_content"] = full_content + return settings or None + + class ParallelExtractInput(BaseModel): """Input schema for Parallel Extract Tool.""" - urls: list[str] = Field(description="List of URLs to extract content from") - + urls: list[str] = Field( + description="List of URLs to extract content from. Up to 20 per request.", + ) search_objective: Optional[str] = Field( default=None, description=( - "If provided, focuses extracted content on the specified search objective" + "Natural-language objective to focus extraction. Up to 5000 characters." ), ) - search_queries: Optional[list[str]] = Field( default=None, - description=( - "If provided, focuses extracted content on the specified keyword search " - "queries" - ), + description="Keyword queries to focus extracted content.", ) - excerpts: Union[bool, ExcerptSettings] = Field( default=True, description=( - "Include excerpts from each URL relevant to the search objective and " - "queries. Can be boolean or ExcerptSettings object." + "Include excerpts from each URL. In v1 GA, excerpts are always " + "returned; the boolean is kept for backward compatibility and " + "controls nothing on the wire. Pass an ExcerptSettings to control " + "per-result size (the API has no flag to disable excerpts in v1)." ), ) - full_content: Union[bool, FullContentSettings] = Field( default=False, description=( - "Include full content from each URL. Can be boolean or " - "FullContentSettings object." + "Include full page content in addition to excerpts. " + "Use FullContentSettings(max_chars_per_result=...) to cap size." + ), + ) + max_chars_total: Optional[int] = Field( + default=None, + description=( + "Upper bound on total characters of excerpts across all results. " + "Does not affect full_content." ), ) - fetch_policy: Optional[FetchPolicy] = Field( + default=None, + description="Policy for cached vs live content fetches.", + ) + client_model: Optional[str] = Field( default=None, description=( - "Fetch policy: determines when to return content from the cache " - "(faster) vs fetching live content (fresher)" + "Identifier of the calling LLM, used by the API for " + "model-specific result optimizations." + ), + ) + session_id: Optional[str] = Field( + default=None, + description=( + "Group related Search and Extract calls made by the same agent task " + "under a shared session id. The server returns one if not provided." ), ) - timeout: Optional[float] = Field( default=None, description=( - "Request timeout in seconds. If not specified, uses default of " - "5 seconds per URL." + "Request timeout in seconds. If not specified, uses the SDK default." ), ) @@ -71,8 +153,9 @@ class ParallelExtractInput(BaseModel): class ParallelExtractTool(BaseTool): """Parallel Extract Tool. - This tool extracts clean, structured content from web pages using the - Parallel Extract API. + Calls Parallel's Extract API to pull clean, structured content from web + pages. Returns a compact summary string the LLM sees and the full + structured response as a tool artifact. Setup: Install `langchain-parallel` and set environment variable @@ -90,52 +173,44 @@ class ParallelExtractTool(BaseTool): base_url: str Base URL for Parallel API. Defaults to "https://api.parallel.ai". max_chars_per_extract: Optional[int] - Maximum characters per extracted result. + Tool-wide default cap on full_content size (per URL). Only applied + when full_content is passed as ``True`` (a settings object always + wins). Instantiation: ```python from langchain_parallel import ParallelExtractTool - # Basic instantiation tool = ParallelExtractTool() - - # With custom API key and parameters - tool = ParallelExtractTool( - api_key="your-api-key", - max_chars_per_extract=5000 - ) ``` Invocation: ```python - # Extract content from URLs result = tool.invoke({ - "urls": [ - "https://example.com/article1", - "https://example.com/article2" - ] + "urls": ["https://en.wikipedia.org/wiki/Artificial_intelligence"], + "search_objective": "Main applications of AI", + "full_content": False, }) + for r in result: + print(r["url"], r.get("title")) + ``` - # Result is a list of dicts with url, title, and content - for item in result: - print(f"Title: {item['title']}") - print(f"URL: {item['url']}") - print(f"Content: {item['content'][:200]}...") + Async: + ```python + result = await tool.ainvoke({"urls": [...]}) ``` - Response Format: - Returns a list of dictionaries, each containing: - - url: The URL that was extracted - - title: Title of the webpage - - content: Full extracted content as markdown - - publish_date: Publish date if available (optional) + Response shape (``list[dict]``): + Each item carries `url`, `title`, optional `publish_date`, and + either `excerpts` (always present in v1) and/or `full_content`. + Errors carry `error_type` and `http_status_code`. """ name: str = "parallel_extract" description: str = ( - "Extract clean, structured content from web pages. " - "Input should be a list of URLs to extract content from. " - "Returns extracted content formatted as markdown." + "Extract clean, structured content from web pages using Parallel's " + "Extract API. Returns a list of per-URL records " + "(url, title, excerpts, optional full_content)." ) args_schema: type[BaseModel] = ParallelExtractInput @@ -146,108 +221,58 @@ class ParallelExtractTool(BaseTool): """Base URL for Parallel API.""" max_chars_per_extract: Optional[int] = None - """Maximum characters per extracted result.""" + """Tool-wide default cap on full_content size (per URL). + Only applied when ``full_content=True`` is passed. + """ - _client: Any = None - """Synchronous extract client (initialized after validation).""" + _client: Optional[Parallel] = None + """Synchronous Parallel SDK client (initialized after validation).""" - _async_client: Any = None - """Asynchronous extract client (initialized after validation).""" + _async_client: Optional[AsyncParallel] = None + """Asynchronous Parallel SDK client (initialized after validation).""" @model_validator(mode="after") def validate_environment(self) -> ParallelExtractTool: - """Validate the environment and initialize clients.""" - # Get API key from parameter or environment + """Validate the environment and initialize SDK clients.""" api_key_str = get_api_key( - self.api_key.get_secret_value() if self.api_key else None + self.api_key.get_secret_value() if self.api_key else None, ) - - # Initialize both sync and async clients once - self._client = get_extract_client(api_key_str, self.base_url) - self._async_client = get_async_extract_client(api_key_str, self.base_url) - + self._client = get_parallel_client(api_key_str, self.base_url) + self._async_client = get_async_parallel_client(api_key_str, self.base_url) return self - def _prepare_extract_params( + def _format_response( self, - excerpts: Union[bool, ExcerptSettings], - full_content: Union[bool, FullContentSettings], - fetch_policy: Optional[FetchPolicy], - ) -> tuple[Any, Any, Optional[dict[str, Any]]]: - """Prepare parameters for extract API call. - - Args: - excerpts: Include excerpts (boolean or ExcerptSettings) - full_content: Include full content (boolean or FullContentSettings) - fetch_policy: Optional fetch policy for cache vs live content - - Returns: - Tuple of (excerpts_param, full_content_param, fetch_policy_param) - """ - # Build full_content config - full_content_param = full_content - if self.max_chars_per_extract and isinstance(full_content, bool): - # Use tool-level config if full_content is just a boolean - full_content_param = {"max_chars_per_result": self.max_chars_per_extract} - elif isinstance(full_content, FullContentSettings): - full_content_param = full_content.model_dump(exclude_none=True) - - # Build excerpts config - excerpts_param = excerpts - if isinstance(excerpts, ExcerptSettings): - excerpts_param = excerpts.model_dump(exclude_none=True) - - # Build fetch_policy config - fetch_policy_param = None - if fetch_policy: - fetch_policy_param = fetch_policy.model_dump(exclude_none=True) - - return excerpts_param, full_content_param, fetch_policy_param - - def _format_extract_response( - self, extract_response: dict[str, Any] + extract_response: dict[str, Any], ) -> list[dict[str, Any]]: - """Format the extract API response. + """Format the extract API response into a per-URL list. - Args: - extract_response: Raw response from the extract API - - Returns: - List of formatted result dictionaries + Mirrors the v0.2 shape so existing consumers continue to work: + - "content" stays populated (full_content if present, else joined excerpts) + - error rows carry "error_type" and "http_status_code" """ - results = extract_response.get("results", []) - errors = extract_response.get("errors", []) + results = extract_response.get("results") or [] + errors = extract_response.get("errors") or [] - # Format results - formatted_results = [] + formatted: list[dict[str, Any]] = [] for result in results: - formatted_result = { + entry: dict[str, Any] = { "url": result.get("url"), "title": result.get("title"), } - - # Add excerpts if present - if "excerpts" in result and result["excerpts"] is not None: - formatted_result["excerpts"] = result["excerpts"] - # Combine excerpts into content field for backward compatibility - # Excerpts are a list of strings, join them with newlines - formatted_result["content"] = "\n\n".join(result["excerpts"]) - - # Add full_content if present and not None - # (overrides excerpts-based content) - if "full_content" in result and result["full_content"] is not None: - formatted_result["full_content"] = result["full_content"] - # For backward compatibility, also set as "content" - formatted_result["content"] = result["full_content"] - - # Add optional fields if present + excerpts = result.get("excerpts") + full_content = result.get("full_content") + if excerpts is not None: + entry["excerpts"] = excerpts + entry["content"] = "\n\n".join(excerpts) + if full_content is not None: + entry["full_content"] = full_content + entry["content"] = full_content if "publish_date" in result: - formatted_result["publish_date"] = result["publish_date"] - - formatted_results.append(formatted_result) + entry["publish_date"] = result["publish_date"] + formatted.append(entry) - # If there were errors, add them to the results with error info - formatted_results.extend( + formatted.extend( [ { "url": error.get("url"), @@ -257,10 +282,76 @@ def _format_extract_response( "http_status_code": error.get("http_status_code"), } for error in errors - ] + ], + ) + return formatted + + def _build_call_kwargs( + self, + *, + urls: list[str], + search_objective: Optional[str], + search_queries: Optional[list[str]], + excerpts: Union[bool, ExcerptSettings, dict[str, Any], None], + full_content: Union[bool, FullContentSettings, dict[str, Any]], + fetch_policy: Optional[FetchPolicy], + max_chars_total: Optional[int], + client_model: Optional[str], + session_id: Optional[str], + timeout: Optional[float], + ) -> dict[str, Any]: + """Resolve params into the GA `client.extract(...)` shape.""" + if not urls: + msg = "At least one URL must be provided." + raise ValueError(msg) + + full_content_resolved = _coerce_full_content( + full_content, + tool_max_chars=self.max_chars_per_extract, + ) + advanced_settings = _build_advanced_settings( + excerpts_settings=_coerce_excerpts(excerpts), + full_content=full_content_resolved, + fetch_policy=fetch_policy, ) - return formatted_results + kwargs: dict[str, Any] = {"urls": list(urls)} + if search_objective is not None: + kwargs["objective"] = search_objective + if search_queries is not None: + kwargs["search_queries"] = list(search_queries) + if max_chars_total is not None: + kwargs["max_chars_total"] = max_chars_total + if client_model is not None: + kwargs["client_model"] = client_model + if session_id is not None: + kwargs["session_id"] = session_id + if advanced_settings is not None: + kwargs["advanced_settings"] = advanced_settings + if timeout is not None: + kwargs["timeout"] = timeout + return kwargs + + @staticmethod + def _start_text(urls: list[str], *, async_: bool) -> str: + """Build the run-manager start-of-extraction log message.""" + prefix = ( + "Starting async content extraction from" + if async_ + else "Starting content extraction from" + ) + count = len(urls) + return f"{prefix} {count} URL{'' if count == 1 else 's'}\n" + + @staticmethod + def _completion_text(formatted: list[dict[str, Any]], *, async_: bool) -> str: + """Build the run-manager end-of-extraction log message.""" + prefix = "Async extraction completed" if async_ else "Extraction completed" + success = sum(1 for item in formatted if "error_type" not in item) + errors = len(formatted) - success + if errors: + return f"{prefix}: {success} succeeded, {errors} failed\n" + return f"{prefix}: {success} URL{'' if success == 1 else 's'} processed\n" def _run( self, @@ -269,83 +360,50 @@ def _run( search_queries: Optional[list[str]] = None, excerpts: Union[bool, ExcerptSettings] = True, full_content: Union[bool, FullContentSettings] = False, + max_chars_total: Optional[int] = None, fetch_policy: Optional[FetchPolicy] = None, + client_model: Optional[str] = None, + session_id: Optional[str] = None, timeout: Optional[float] = None, run_manager: Optional[CallbackManagerForToolRun] = None, ) -> list[dict[str, Any]]: - """Extract content from URLs. - - Args: - urls: List of URLs to extract content from - search_objective: Optional search objective to focus extraction - search_queries: Optional keyword search queries to focus extraction - excerpts: Include excerpts (boolean or ExcerptSettings) - full_content: Include full content (boolean or FullContentSettings) - fetch_policy: Optional fetch policy for cache vs live content - timeout: Request timeout in seconds (defaults to 5 seconds per URL) - run_manager: Callback manager for the tool run - - Returns: - List of dictionaries with extracted content - """ - # Notify callback manager about extraction start + """Extract content from URLs.""" + if self._client is None: + msg = "Parallel client not initialized." + raise RuntimeError(msg) + if run_manager: - url_count = len(urls) - url_desc = f"{url_count} URL{'s' if url_count != 1 else ''}" - run_manager.on_text( - f"Starting content extraction from {url_desc}\n", color="blue" - ) + run_manager.on_text(self._start_text(urls, async_=False), color="blue") + + kwargs = self._build_call_kwargs( + urls=urls, + search_objective=search_objective, + search_queries=search_queries, + excerpts=excerpts, + full_content=full_content, + fetch_policy=fetch_policy, + max_chars_total=max_chars_total, + client_model=client_model, + session_id=session_id, + timeout=timeout, + ) try: - # Prepare parameters for the extract API call - excerpts_param, full_content_param, fetch_policy_param = ( - self._prepare_extract_params(excerpts, full_content, fetch_policy) - ) - - # Notify about extraction execution - if run_manager: - run_manager.on_text("Executing extraction...\n", color="yellow") - - # Extract content from URLs using the pre-initialized client - extract_response = self._client.extract( - urls=urls, - objective=search_objective, - search_queries=search_queries, - excerpts=excerpts_param, - full_content=full_content_param, - fetch_policy=fetch_policy_param, - timeout=timeout, - ) - - # Format and return the response - result = self._format_extract_response(extract_response) - - # Notify callback manager about completion - if run_manager: - success_count = sum(1 for item in result if "error_type" not in item) - error_count = len(result) - success_count - if error_count > 0: - run_manager.on_text( - f"Extraction completed: {success_count} succeeded, " - f"{error_count} failed\n", - color="green", - ) - else: - url_text = "URL" if success_count == 1 else "URLs" - run_manager.on_text( - f"Extraction completed: {success_count} {url_text} processed\n", - color="green", - ) - - return result - + response_obj = self._client.extract(**kwargs) except Exception as e: - # Notify callback manager about error if run_manager: run_manager.on_text(f"Extraction failed: {e!s}\n", color="red") msg = f"Error calling Parallel Extract API: {e!s}" raise ValueError(msg) from e + formatted = self._format_response(response_obj.model_dump()) + if run_manager: + run_manager.on_text( + self._completion_text(formatted, async_=False), + color="green", + ) + return formatted + async def _arun( self, urls: list[str], @@ -353,84 +411,52 @@ async def _arun( search_queries: Optional[list[str]] = None, excerpts: Union[bool, ExcerptSettings] = True, full_content: Union[bool, FullContentSettings] = False, + max_chars_total: Optional[int] = None, fetch_policy: Optional[FetchPolicy] = None, + client_model: Optional[str] = None, + session_id: Optional[str] = None, timeout: Optional[float] = None, run_manager: Optional[AsyncCallbackManagerForToolRun] = None, ) -> list[dict[str, Any]]: - """Extract content from URLs asynchronously. - - Args: - urls: List of URLs to extract content from - search_objective: Optional search objective to focus extraction - search_queries: Optional keyword search queries to focus extraction - excerpts: Include excerpts (boolean or ExcerptSettings) - full_content: Include full content (boolean or FullContentSettings) - fetch_policy: Optional fetch policy for cache vs live content - timeout: Request timeout in seconds (defaults to 5 seconds per URL) - run_manager: Async callback manager for the tool run - - Returns: - List of dictionaries with extracted content - """ - # Notify callback manager about extraction start + """Async extract content from URLs.""" + if self._async_client is None: + msg = "Async Parallel client not initialized." + raise RuntimeError(msg) + if run_manager: - url_count = len(urls) - url_desc = f"{url_count} URL{'s' if url_count != 1 else ''}" await run_manager.on_text( - f"Starting async content extraction from {url_desc}\n", color="blue" - ) - - try: - # Prepare parameters for the extract API call - excerpts_param, full_content_param, fetch_policy_param = ( - self._prepare_extract_params(excerpts, full_content, fetch_policy) - ) - - # Notify about extraction execution - if run_manager: - await run_manager.on_text( - "Executing async extraction...\n", color="yellow" - ) - - # Extract content from URLs using the pre-initialized async client - extract_response = await self._async_client.extract( - urls=urls, - objective=search_objective, - search_queries=search_queries, - excerpts=excerpts_param, - full_content=full_content_param, - fetch_policy=fetch_policy_param, - timeout=timeout, + self._start_text(urls, async_=True), + color="blue", ) - # Format and return the response - result = self._format_extract_response(extract_response) - - # Notify callback manager about completion - if run_manager: - success_count = sum(1 for item in result if "error_type" not in item) - error_count = len(result) - success_count - if error_count > 0: - await run_manager.on_text( - f"Async extraction completed: {success_count} succeeded, " - f"{error_count} failed\n", - color="green", - ) - else: - url_text = "URL" if success_count == 1 else "URLs" - await run_manager.on_text( - f"Async extraction completed: {success_count} {url_text} " - f"processed\n", - color="green", - ) - - return result + kwargs = self._build_call_kwargs( + urls=urls, + search_objective=search_objective, + search_queries=search_queries, + excerpts=excerpts, + full_content=full_content, + fetch_policy=fetch_policy, + max_chars_total=max_chars_total, + client_model=client_model, + session_id=session_id, + timeout=timeout, + ) + try: + response_obj = await self._async_client.extract(**kwargs) except Exception as e: - # Notify callback manager about error if run_manager: await run_manager.on_text( - f"Async extraction failed: {e!s}\n", color="red" + f"Async extraction failed: {e!s}\n", + color="red", ) msg = f"Error calling Parallel Extract API: {e!s}" raise ValueError(msg) from e + + formatted = self._format_response(response_obj.model_dump()) + if run_manager: + await run_manager.on_text( + self._completion_text(formatted, async_=True), + color="green", + ) + return formatted diff --git a/langchain_parallel/search_tool.py b/langchain_parallel/search_tool.py index 1c78d11..85827d0 100644 --- a/langchain_parallel/search_tool.py +++ b/langchain_parallel/search_tool.py @@ -2,6 +2,7 @@ from __future__ import annotations +import warnings from datetime import datetime from typing import Any, Optional, Union @@ -10,10 +11,72 @@ CallbackManagerForToolRun, ) from langchain_core.tools import BaseTool +from parallel import AsyncParallel, Parallel from pydantic import BaseModel, Field, SecretStr, model_validator -from ._client import get_api_key, get_async_search_client, get_search_client -from ._types import ExcerptSettings, FetchPolicy +from ._client import get_api_key, get_async_parallel_client, get_parallel_client +from ._types import ExcerptSettings, FetchPolicy, SourcePolicy + +_LEGACY_MODE_MAP: dict[str, str] = { + "fast": "basic", + "one-shot": "basic", + "agentic": "advanced", +} + + +def _normalize_mode(mode: Optional[str]) -> Optional[str]: + """Translate legacy beta mode strings to the GA `basic` / `advanced` set.""" + if mode is None or mode in {"basic", "advanced"}: + return mode + if mode in _LEGACY_MODE_MAP: + new_mode = _LEGACY_MODE_MAP[mode] + warnings.warn( + f"mode='{mode}' is a legacy beta value; mapping to '{new_mode}'. " + f"Pass mode='{new_mode}' directly to silence this warning.", + DeprecationWarning, + stacklevel=3, + ) + return new_mode + msg = ( + f"Invalid mode '{mode}'. Expected one of: 'basic', 'advanced'. " + f"(Legacy values 'fast', 'one-shot', 'agentic' are accepted with a warning.)" + ) + raise ValueError(msg) + + +def _coerce_source_policy( + source_policy: Optional[Union[SourcePolicy, dict[str, Any]]], +) -> Optional[dict[str, Any]]: + """Accept a SourcePolicy model or a raw dict, return a dict for the SDK.""" + if source_policy is None: + return None + if isinstance(source_policy, SourcePolicy): + return source_policy.model_dump(exclude_none=True) + return {k: v for k, v in source_policy.items() if v is not None} + + +def _build_advanced_settings( + *, + excerpts: Optional[ExcerptSettings], + fetch_policy: Optional[FetchPolicy], + source_policy: Optional[Union[SourcePolicy, dict[str, Any]]], + max_results: Optional[int], + location: Optional[str], +) -> Optional[dict[str, Any]]: + """Pack the user-facing flat fields into the GA `advanced_settings` envelope.""" + settings: dict[str, Any] = {} + if excerpts is not None: + settings["excerpt_settings"] = excerpts.model_dump(exclude_none=True) + if fetch_policy is not None: + settings["fetch_policy"] = fetch_policy.model_dump(exclude_none=True) + sp = _coerce_source_policy(source_policy) + if sp: + settings["source_policy"] = sp + if max_results is not None: + settings["max_results"] = max_results + if location is not None: + settings["location"] = location + return settings or None class ParallelWebSearchInput(BaseModel): @@ -21,67 +84,106 @@ class ParallelWebSearchInput(BaseModel): objective: Optional[str] = Field( default=None, - description="Natural-language description of what the web research goal is. " - "Include any source or freshness guidance. Either this or search_queries " - "must be provided.", + description=( + "Natural-language description of the research goal. Up to 5000 " + "characters. Include any source or freshness guidance. Recommended " + "alongside `search_queries` for best results." + ), ) search_queries: Optional[list[str]] = Field( default=None, - description="Optional list of search queries to guide the search. " - "Maximum 5 queries, each up to 200 characters. Either this or objective " - "must be provided.", + description=( + "1-5 keyword search queries (3-6 words each, up to 200 characters). " + "Required by Parallel's GA endpoint; if omitted, the call routes " + "to the deprecated /v1beta endpoint with a DeprecationWarning. " + "Pair with an optional `objective` for best results." + ), ) max_results: int = Field( - default=10, description="Maximum number of search results to return (1 to 40)." + default=10, + description="Maximum number of search results to return (1 to 40).", ) excerpts: Optional[ExcerptSettings] = Field( default=None, description=( - "Optional excerpt settings for controlling excerpt length. " - "Example: ExcerptSettings(max_chars_per_result=1500)" + "Per-result excerpt-size settings. " + "Example: ExcerptSettings(max_chars_per_result=1500)." + ), + ) + max_chars_total: Optional[int] = Field( + default=None, + description=( + "Upper bound on the total characters of excerpts across all results. " + "Useful for capping context size when feeding results to an LLM." ), ) mode: Optional[str] = Field( default=None, description=( - "Search mode: 'one-shot' for comprehensive results with longer " - "excerpts, 'agentic' for concise, token-efficient results. " - "Defaults to 'one-shot'." + "Search mode: 'basic' for low-latency searches, 'advanced' (default) " + "for higher quality with more retrieval and compression. Legacy " + "values 'fast', 'one-shot' (-> 'basic') and 'agentic' (-> 'advanced') " + "are accepted with a deprecation warning." ), ) - source_policy: Optional[dict[str, Union[str, list[str]]]] = Field( + source_policy: Optional[Union[SourcePolicy, dict[str, Any]]] = Field( default=None, description=( - "Optional source policy with 'include_domains' and/or " - "'exclude_domains' lists. Example: " - "{'include_domains': ['wikipedia.org'], 'exclude_domains': ['reddit.com']}" + "Domain include/exclude lists and a freshness floor (after_date). " + "Example: SourcePolicy(include_domains=['nature.com'], " + "after_date='2024-01-01'). A raw dict is also accepted." ), ) fetch_policy: Optional[FetchPolicy] = Field( default=None, description=( - "Optional fetch policy to control when to return cached vs live " - "content. Example: FetchPolicy(max_age_seconds=86400, timeout_seconds=60)" + "Cache vs live-fetch policy. " + "Example: FetchPolicy(max_age_seconds=86400, timeout_seconds=60)." + ), + ) + location: Optional[str] = Field( + default=None, + description=( + "ISO 3166-1 alpha-2 country code (e.g., 'us', 'gb', 'de', 'jp') " + "to geo-target results. Unsupported values are ignored with a " + "warning by the API." + ), + ) + client_model: Optional[str] = Field( + default=None, + description=( + "Identifier of the calling LLM, used by the API for model-specific " + "result optimizations." + ), + ) + session_id: Optional[str] = Field( + default=None, + description=( + "Group related Search and Extract calls made by the same agent task " + "under a shared session id. The server returns one if not provided." ), ) include_metadata: bool = Field( default=True, - description="Whether to include metadata in the response " - "(search timing, result counts, etc.).", + description=( + "Whether to attach client-side timing/result metadata to the artifact." + ), ) timeout: Optional[int] = Field( default=None, - description="Request timeout in seconds. If not specified, uses default timeout.", # noqa: E501 + description=( + "Request timeout in seconds. If not specified, uses the SDK default." + ), ) class ParallelWebSearchTool(BaseTool): """Parallel Search tool with web research capabilities. - This tool provides access to Parallel's Search API, which streamlines - the traditional search → scrape → extract pipeline into a single API call. - Features include domain filtering, multiple processors, async support, - and metadata collection. + This tool calls Parallel's Search API, which streamlines the traditional + search -> scrape -> extract pipeline into a single API call. It supports + natural-language objectives, keyword queries, domain filters, two modes + (`basic`, `advanced`), location targeting, and async usage. Setup: Install `langchain-parallel` and set environment variable @@ -103,136 +205,76 @@ class ParallelWebSearchTool(BaseTool): ```python from langchain_parallel import ParallelWebSearchTool - # Basic instantiation tool = ParallelWebSearchTool() - - # With custom API key - tool = ParallelWebSearchTool(api_key="your-api-key") ``` - Basic Usage: + Invocation: ```python - # Simple objective-based search result = tool.invoke({ - "objective": "What are the latest developments in AI?" - }) - - # Query-based search with multiple queries - result = tool.invoke({ - "search_queries": [ - "latest AI developments 2024", - "machine learning breakthroughs", - "artificial intelligence news" - ], - "max_results": 10 + "objective": "Latest developments in AI agents", + "search_queries": ["AI agents 2026", "autonomous LLM systems"], + "mode": "advanced", + "max_results": 5, }) + print(result["search_id"], len(result["results"])) ``` - Domain filtering and advanced options: + Domain and freshness filters: ```python - # Domain filtering with fetch policy (using dict format) - result = tool.invoke({ - "objective": "Recent climate change research", - "source_policy": { - "include_domains": ["nature.com", "science.org"], - "exclude_domains": ["reddit.com", "twitter.com"] - }, - "max_results": 15, - "excerpts": {"max_chars_per_result": 2000}, # Auto-converted - "mode": "one-shot", # Use 'agentic' for token-efficient results - "fetch_policy": { # Auto-converted to FetchPolicy - "max_age_seconds": 86400, # 1 day cache - "timeout_seconds": 60 - }, - "include_metadata": True - }) - - # Or use the types directly - from langchain_parallel import ExcerptSettings, FetchPolicy + from langchain_parallel import SourcePolicy result = tool.invoke({ - "objective": "Recent climate change research", - "excerpts": ExcerptSettings(max_chars_per_result=2000), - "fetch_policy": FetchPolicy(max_age_seconds=86400, timeout_seconds=60), + "search_queries": ["climate research breakthroughs"], + "source_policy": SourcePolicy( + include_domains=["nature.com", "science.org"], + after_date="2025-01-01", + ), + "location": "us", }) ``` - Async Usage: + Async: ```python - import asyncio - - async def search_async(): - result = await tool.ainvoke({ - "objective": "Latest tech news" - }) - return result - - result = asyncio.run(search_async()) + result = await tool.ainvoke({"search_queries": ["..."]}) ``` - Response Format: + Response shape: ```python { - "search_id": "search_abc123...", + "search_id": "search_abc123", + "session_id": "sess_...", "results": [ - { - "url": "https://example.com/article", - "title": "Article Title", - "excerpts": [ - "Relevant excerpt from the page...", - "Another important section..." - ] - } + {"url": "...", "title": "...", "publish_date": "...", + "excerpts": ["..."]}, + ... ], - "search_metadata": { + "warnings": [...], + "usage": {...}, + "search_metadata": { # added by this tool when include_metadata=True "search_duration_seconds": 2.451, - "search_timestamp": "2024-01-15T10:30:00", - "max_results_requested": 10, - "actual_results_returned": 8, - "search_id": "search_abc123...", - "query_count": 3, - "queries_used": ["query1", "query2", "query3"], - "source_policy_applied": true, - "included_domains": ["nature.com"], - "excluded_domains": ["reddit.com"] + "search_timestamp": "2026-04-27T10:30:00", + "endpoint": "v1", # or "v1beta" if search_queries was omitted + "actual_results_returned": 5, } } ``` - Tool Calling Integration: - ```python - # When used with LangChain agents or chat models with tool calling - from langchain_core.messages import HumanMessage - from langchain_parallel import ChatParallelWeb - - chat = ChatParallelWeb() - chat_with_tools = chat.bind_tools([tool]) - - response = chat_with_tools.invoke([ - HumanMessage(content="Search for the latest AI research papers") - ]) - ``` - - Best Practices: - - Use specific objectives for better results - - Apply domain filtering for focused searches - - Include metadata for debugging and optimization """ name: str = "parallel_web_search" - """The name that is passed to the model when performing tool calling.""" + """The name passed to the model when performing tool calling.""" description: str = ( "Search the web using Parallel's Search API. " "Provides real-time web information with compressed, structured excerpts " - "optimized for LLM consumption. Supports domain filtering " - "and metadata. Specify either an objective " - "(natural language goal) or specific search queries for targeted results." + "optimized for LLM consumption. Supports natural-language objectives, " + "keyword queries, domain filtering, and geo-targeting. Returns the " + "structured search response as a dict." ) - """The description that is passed to the model when performing tool calling.""" + """The description passed to the model when performing tool calling.""" args_schema: type[BaseModel] = ParallelWebSearchInput - """The schema that is passed to the model when performing tool calling.""" + """The schema passed to the model when performing tool calling.""" api_key: Optional[SecretStr] = Field(default=None) """Parallel API key. If not provided, will be read from @@ -241,62 +283,163 @@ async def search_async(): base_url: str = Field(default="https://api.parallel.ai") """Base URL for Parallel API.""" - _client: Any = None - """Synchronous search client (initialized after validation).""" + _client: Optional[Parallel] = None + """Synchronous Parallel SDK client (initialized after validation).""" - _async_client: Any = None - """Asynchronous search client (initialized after validation).""" + _async_client: Optional[AsyncParallel] = None + """Asynchronous Parallel SDK client (initialized after validation).""" @model_validator(mode="after") def validate_environment(self) -> ParallelWebSearchTool: - """Validate the environment and initialize clients.""" - # Get API key from parameter or environment + """Validate the environment and initialize SDK clients.""" api_key_str = get_api_key( - self.api_key.get_secret_value() if self.api_key else None + self.api_key.get_secret_value() if self.api_key else None, ) - - # Initialize both sync and async clients once - self._client = get_search_client(api_key_str, self.base_url) - self._async_client = get_async_search_client(api_key_str, self.base_url) - + self._client = get_parallel_client(api_key_str, self.base_url) + self._async_client = get_async_parallel_client(api_key_str, self.base_url) return self - def _create_response_metadata( + def _build_metadata( self, + *, start_time: datetime, - search_params: dict[str, Any], + endpoint: str, response: dict[str, Any], - *, - include_metadata: bool, ) -> dict[str, Any]: - """Create response metadata.""" - if not include_metadata: - return {} - + """Build client-side timing/result metadata.""" end_time = datetime.now() - duration = (end_time - start_time).total_seconds() - - metadata = { - "search_duration_seconds": round(duration, 3), + return { + "search_duration_seconds": round( + (end_time - start_time).total_seconds(), + 3, + ), "search_timestamp": start_time.isoformat(), - "max_results_requested": search_params.get("max_results", 10), - "actual_results_returned": len(response.get("results", [])), - "search_id": response.get("search_id"), + "endpoint": endpoint, + "actual_results_returned": len(response.get("results") or []), } - if search_params.get("search_queries"): - metadata["query_count"] = len(search_params["search_queries"]) - metadata["queries_used"] = search_params["search_queries"] - - if search_params.get("source_policy"): - metadata["source_policy_applied"] = True - policy = search_params["source_policy"] - if "include_domains" in policy: - metadata["included_domains"] = policy["include_domains"] - if "exclude_domains" in policy: - metadata["excluded_domains"] = policy["exclude_domains"] - - return metadata + def _build_call_kwargs( + self, + *, + objective: Optional[str], + search_queries: Optional[list[str]], + mode: Optional[str], + max_chars_total: Optional[int], + client_model: Optional[str], + session_id: Optional[str], + excerpts: Optional[ExcerptSettings], + fetch_policy: Optional[FetchPolicy], + source_policy: Optional[Union[SourcePolicy, dict[str, Any]]], + max_results: int, + location: Optional[str], + timeout: Optional[int], + ) -> tuple[str, dict[str, Any]]: + """Resolve params + endpoint (`v1` GA vs `v1beta` legacy fallback). + + The v1beta path is deprecated and will be removed in 0.4.0; it exists + so 0.2.x callers passing only ``objective`` keep working through the + Parallel API beta sunset (~June 2026). + """ + if not objective and not search_queries: + msg = "Either 'objective' or 'search_queries' must be provided." + raise ValueError(msg) + + normalized_mode = _normalize_mode(mode) + + if not search_queries: + warnings.warn( + "Calling Parallel Search without `search_queries` routes to " + "the deprecated /v1beta endpoint and will be removed in " + "langchain-parallel 0.4.0. Pass `search_queries=[...]` (1-5 " + "keyword strings, 3-6 words each) to use the GA /v1 endpoint. " + "See https://docs.parallel.ai/search/search-migration-guide.", + DeprecationWarning, + stacklevel=4, + ) + beta_kwargs: dict[str, Any] = { + "objective": objective, + "max_results": max_results, + } + if excerpts is not None: + beta_kwargs["excerpts"] = excerpts.model_dump(exclude_none=True) + if fetch_policy is not None: + beta_kwargs["fetch_policy"] = fetch_policy.model_dump(exclude_none=True) + sp = _coerce_source_policy(source_policy) + if sp: + beta_kwargs["source_policy"] = sp + if normalized_mode is not None: + # v1beta speaks the legacy mode dialect. + beta_kwargs["mode"] = ( + "agentic" if normalized_mode == "advanced" else "one-shot" + ) + if client_model is not None: + beta_kwargs["client_model"] = client_model + if session_id is not None: + beta_kwargs["session_id"] = session_id + if location is not None: + beta_kwargs["location"] = location + if timeout is not None: + beta_kwargs["timeout"] = timeout + return "v1beta", beta_kwargs + + advanced_settings = _build_advanced_settings( + excerpts=excerpts, + fetch_policy=fetch_policy, + source_policy=source_policy, + max_results=max_results, + location=location, + ) + kwargs: dict[str, Any] = {"search_queries": list(search_queries)} + if objective is not None: + kwargs["objective"] = objective + if normalized_mode is not None: + kwargs["mode"] = normalized_mode + if max_chars_total is not None: + kwargs["max_chars_total"] = max_chars_total + if client_model is not None: + kwargs["client_model"] = client_model + if session_id is not None: + kwargs["session_id"] = session_id + if advanced_settings is not None: + kwargs["advanced_settings"] = advanced_settings + if timeout is not None: + kwargs["timeout"] = timeout + return "v1", kwargs + + def _finalize_response( + self, + response_obj: Any, + *, + start_time: datetime, + endpoint: str, + include_metadata: bool, + ) -> dict[str, Any]: + """Convert SDK response to dict and attach client-side metadata.""" + response: dict[str, Any] = response_obj.model_dump() + if include_metadata: + response["search_metadata"] = self._build_metadata( + start_time=start_time, + endpoint=endpoint, + response=response, + ) + return response + + @staticmethod + def _start_text( + objective: Optional[str], search_queries: Optional[list[str]], *, async_: bool + ) -> str: + """Build the run-manager start-of-search log message.""" + prefix = "Starting async web search" if async_ else "Starting web search" + query_desc = objective or f"{len(search_queries or [])} search queries" + return f"{prefix}: {query_desc}\n" + + @staticmethod + def _completion_text(response: dict[str, Any], *, async_: bool) -> str: + """Build the run-manager end-of-search log message.""" + prefix = "Async search completed" if async_ else "Search completed" + count = len(response.get("results") or []) + duration = response.get("search_metadata", {}).get("search_duration_seconds", 0) + return f"{prefix}: {count} results in {duration}s\n" def _run( self, @@ -304,193 +447,146 @@ def _run( search_queries: Optional[list[str]] = None, max_results: int = 10, excerpts: Optional[ExcerptSettings] = None, + max_chars_total: Optional[int] = None, mode: Optional[str] = None, - source_policy: Optional[dict[str, Union[str, list[str]]]] = None, + source_policy: Optional[Union[SourcePolicy, dict[str, Any]]] = None, fetch_policy: Optional[FetchPolicy] = None, + location: Optional[str] = None, + client_model: Optional[str] = None, + session_id: Optional[str] = None, *, include_metadata: bool = True, timeout: Optional[int] = None, run_manager: Optional[CallbackManagerForToolRun] = None, ) -> dict[str, Any]: - """Execute the search using Parallel's Search API. - - Args: - objective: Natural-language description of the research goal - search_queries: List of specific search queries - max_results: Maximum number of results (1-40) - excerpts: Optional ExcerptSettings for controlling excerpt length - mode: Search mode ('one-shot' or 'agentic') - source_policy: Optional source policy for domain filtering - fetch_policy: Optional FetchPolicy for cache vs live content - include_metadata: Whether to include metadata - timeout: Request timeout in seconds - run_manager: Callback manager for the tool run - - Returns: - Dictionary containing search results with metadata - """ - start_time = datetime.now() + """Execute the search using Parallel's Search API.""" + if self._client is None: + msg = "Parallel client not initialized." + raise RuntimeError(msg) - # Notify callback manager about search start + start_time = datetime.now() if run_manager: - query_desc = objective or f"{len(search_queries or [])} search queries" - run_manager.on_text(f"Starting web search: {query_desc}\n", color="blue") + run_manager.on_text( + self._start_text(objective, search_queries, async_=False), + color="blue", + ) - # Convert ExcerptSettings and FetchPolicy to dict if provided - excerpts_dict = excerpts.model_dump(exclude_none=True) if excerpts else None - fetch_policy_dict = ( - fetch_policy.model_dump(exclude_none=True) if fetch_policy else None + endpoint, kwargs = self._build_call_kwargs( + objective=objective, + search_queries=search_queries, + mode=mode, + max_chars_total=max_chars_total, + client_model=client_model, + session_id=session_id, + excerpts=excerpts, + fetch_policy=fetch_policy, + source_policy=source_policy, + max_results=max_results, + location=location, + timeout=timeout, ) - search_params = { - "objective": objective, - "search_queries": search_queries, - "max_results": max_results, - "excerpts": excerpts_dict, - "mode": mode, - "source_policy": source_policy, - "fetch_policy": fetch_policy_dict, - } - try: - # Notify about search execution - if run_manager: - run_manager.on_text("Executing search...\n", color="yellow") - - # Perform search using pre-initialized client - response = self._client.search( - objective=objective, - search_queries=search_queries, - max_results=max_results, - excerpts=excerpts_dict, - mode=mode, - source_policy=source_policy, - fetch_policy=fetch_policy_dict, - timeout=timeout, - ) - - # Create metadata - metadata = self._create_response_metadata( - start_time, search_params, response, include_metadata=include_metadata + response_obj: Any = ( + self._client.search(**kwargs) + if endpoint == "v1" + else self._client.beta.search(**kwargs) ) - if metadata: - response["search_metadata"] = metadata - - # Notify callback manager about completion - if run_manager: - result_count = len(response.get("results", [])) - duration = metadata.get("search_duration_seconds", 0) if metadata else 0 - run_manager.on_text( - f"Search completed: {result_count} results in {duration}s\n", - color="green", - ) - - return response - except Exception as e: - # Notify callback manager about error if run_manager: run_manager.on_text(f"Search failed: {e!s}\n", color="red") msg = f"Error calling Parallel Search API: {e!s}" raise ValueError(msg) from e + response = self._finalize_response( + response_obj, + start_time=start_time, + endpoint=endpoint, + include_metadata=include_metadata, + ) + if run_manager: + run_manager.on_text( + self._completion_text(response, async_=False), + color="green", + ) + return response + async def _arun( self, objective: Optional[str] = None, search_queries: Optional[list[str]] = None, max_results: int = 10, excerpts: Optional[ExcerptSettings] = None, + max_chars_total: Optional[int] = None, mode: Optional[str] = None, - source_policy: Optional[dict[str, Union[str, list[str]]]] = None, + source_policy: Optional[Union[SourcePolicy, dict[str, Any]]] = None, fetch_policy: Optional[FetchPolicy] = None, + location: Optional[str] = None, + client_model: Optional[str] = None, + session_id: Optional[str] = None, *, include_metadata: bool = True, timeout: Optional[int] = None, run_manager: Optional[AsyncCallbackManagerForToolRun] = None, ) -> dict[str, Any]: - """Async execute the search using Parallel's Search API. - - Args: - objective: Natural-language description of the research goal - search_queries: List of specific search queries - max_results: Maximum number of results (1-40) - excerpts: Optional ExcerptSettings for controlling excerpt length - mode: Search mode ('one-shot' or 'agentic') - source_policy: Optional source policy for domain filtering - fetch_policy: Optional FetchPolicy for cache vs live content - include_metadata: Whether to include metadata - timeout: Request timeout in seconds - run_manager: Async callback manager for the tool run - - Returns: - Dictionary containing search results with metadata - """ - start_time = datetime.now() + """Async execute the search using Parallel's Search API.""" + if self._async_client is None: + msg = "Async Parallel client not initialized." + raise RuntimeError(msg) - # Notify callback manager about search start + start_time = datetime.now() if run_manager: - query_desc = objective or f"{len(search_queries or [])} search queries" await run_manager.on_text( - f"Starting async web search: {query_desc}\n", color="blue" + self._start_text(objective, search_queries, async_=True), + color="blue", ) - # Convert ExcerptSettings and FetchPolicy to dict if provided - excerpts_dict = excerpts.model_dump(exclude_none=True) if excerpts else None - fetch_policy_dict = ( - fetch_policy.model_dump(exclude_none=True) if fetch_policy else None + endpoint, kwargs = self._build_call_kwargs( + objective=objective, + search_queries=search_queries, + mode=mode, + max_chars_total=max_chars_total, + client_model=client_model, + session_id=session_id, + excerpts=excerpts, + fetch_policy=fetch_policy, + source_policy=source_policy, + max_results=max_results, + location=location, + timeout=timeout, ) - search_params = { - "objective": objective, - "search_queries": search_queries, - "max_results": max_results, - "excerpts": excerpts_dict, - "mode": mode, - "source_policy": source_policy, - "fetch_policy": fetch_policy_dict, - } - try: - # Notify about search execution + response_obj = ( + await self._async_client.search(**kwargs) + if endpoint == "v1" + else await self._async_client.beta.search(**kwargs) + ) + except Exception as e: if run_manager: await run_manager.on_text( - "Executing async search...\n", - color="yellow", + f"Async search failed: {e!s}\n", + color="red", ) + msg = f"Error calling Parallel Search API: {e!s}" + raise ValueError(msg) from e - # Use the pre-initialized async client for better performance - response = await self._async_client.search( - objective=objective, - search_queries=search_queries, - max_results=max_results, - excerpts=excerpts_dict, - mode=mode, - source_policy=source_policy, - fetch_policy=fetch_policy_dict, - timeout=timeout, - ) - - # Create metadata - metadata = self._create_response_metadata( - start_time, search_params, response, include_metadata=include_metadata + response = self._finalize_response( + response_obj, + start_time=start_time, + endpoint=endpoint, + include_metadata=include_metadata, + ) + if run_manager: + await run_manager.on_text( + self._completion_text(response, async_=True), + color="green", ) - if metadata: - response["search_metadata"] = metadata - - # Notify callback manager about completion - if run_manager: - result_count = len(response.get("results", [])) - duration = metadata.get("search_duration_seconds", 0) if metadata else 0 - await run_manager.on_text( - f"Async search completed: {result_count} results in {duration}s\n", - color="green", - ) + return response - return response - except Exception as e: - # Notify callback manager about error - if run_manager: - await run_manager.on_text(f"Async search failed: {e!s}\n", color="red") - msg = f"Error calling Parallel Search API: {e!s}" - raise ValueError(msg) from e +#: Forward-compat alias for :class:`ParallelWebSearchTool`. +#: +#: Prefer ParallelSearchTool in new code; ParallelWebSearchTool will +#: continue to work indefinitely as an alias for this class. +ParallelSearchTool = ParallelWebSearchTool diff --git a/poetry.lock b/poetry.lock index 129c934..5020db4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1191,14 +1191,14 @@ files = [ [[package]] name = "parallel-web" -version = "0.3.3" +version = "0.5.1" description = "The official Python library for the Parallel API" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "parallel_web-0.3.3-py3-none-any.whl", hash = "sha256:730187f8754c81bbdb7f3d06c5c44b6df25d665366c051f34409ab04aad69570"}, - {file = "parallel_web-0.3.3.tar.gz", hash = "sha256:31a33ae094182887d731390468d816a52dc76a7f462f8e00b91477098cb00e50"}, + {file = "parallel_web-0.5.1-py3-none-any.whl", hash = "sha256:7db65556a362d44ae864b5e4881a239e96377bcefbf931616d9c3b80a6124c21"}, + {file = "parallel_web-0.5.1.tar.gz", hash = "sha256:e967f3bd1833c73db30ea11aa49f5b3248c10342af1fa768a4a290ff8f4301f6"}, ] [package.dependencies] @@ -1207,7 +1207,7 @@ distro = ">=1.7.0,<2" httpx = ">=0.23.0,<1" pydantic = ">=1.9.0,<3" sniffio = "*" -typing-extensions = ">=4.10,<5" +typing-extensions = ">=4.14,<5" [package.extras] aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"] @@ -2566,4 +2566,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "85dcc91af918c74e6c832182b6a6d130a51c6da49bb4c3b586ed4976a6edc72b" +content-hash = "1f2192a6249d32118c66b8ab9f78754e93a50cde422fef85e96d70e19cdc6053" diff --git a/pyproject.toml b/pyproject.toml index c8d1bb7..5836e53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,13 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "langchain-parallel" -version = "0.2.0" +version = "0.3.0" description = "A LangChain integration for Parallel Web AI services, including Chat and Search." authors = ["Parallel Team "] readme = "README.md" repository = "https://github.com/parallel-web/langchain-parallel" license = "MIT" +include = ["langchain_parallel/py.typed"] [tool.mypy] disallow_untyped_defs = true @@ -45,7 +46,7 @@ langchain-core = ">=1.1.0,<2.0.0" openai = "^1.88.0" pydantic = "^2.11.7" httpx = ">=0.28.1,<1.0.0" -parallel-web = "^0.3.3" +parallel-web = "^0.5.1" [tool.ruff] target-version = "py310" @@ -117,7 +118,11 @@ convention = "google" ] "examples/*.py" = ["T201"] # Allow print statements in examples "docs/*.ipynb" = ["T201", "E501"] # Allow print statements and long lines in documentation -"scripts/*.py" = ["SIM105", "S110"] # Allow try-except-pass in scripts +"scripts/*.py" = [ + "SIM105", # Allow try-except-pass in scripts + "S110", + "T201", # Allow print statements in scripts +] "langchain_parallel/extract_tool.py" = [ "FBT001", # Boolean-typed positional argument (matches Extract API design) "FBT002", # Boolean default positional argument (matches Extract API design) diff --git a/scripts/check_imports.py b/scripts/check_imports.py index b141be6..87777df 100644 --- a/scripts/check_imports.py +++ b/scripts/check_imports.py @@ -63,8 +63,8 @@ def load_module_with_deps(file: str, loaded_modules: set[str] | None = None) -> load_module_with_deps(file, loaded_modules) except Exception: has_failure = True - print(file) # noqa: T201 + print(file) traceback.print_exc() - print() # noqa: T201 + print() sys.exit(1 if has_failure else 0) diff --git a/scripts/run_notebooks.py b/scripts/run_notebooks.py new file mode 100644 index 0000000..1bb1a7f --- /dev/null +++ b/scripts/run_notebooks.py @@ -0,0 +1,97 @@ +"""Headless executor for the docs/*.ipynb notebooks. + +Run from the repo root with PARALLEL_API_KEY set: + + poetry run python scripts/run_notebooks.py + +Skips cells that need user interaction (`%pip install`, `getpass.getpass`) +so the rest can run end-to-end against the real Parallel API. Useful as a +pre-release smoke test that the published examples still work. + +Pass paths to limit which notebooks run: + + poetry run python scripts/run_notebooks.py docs/chat.ipynb +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +import nbformat +from nbclient import NotebookClient +from nbclient.exceptions import CellExecutionError + +REPO_ROOT = Path(__file__).resolve().parent.parent +DOCS = REPO_ROOT / "docs" +DEFAULT_NOTEBOOKS = [ + DOCS / "chat.ipynb", + DOCS / "search_tool.ipynb", + DOCS / "extract_tool.ipynb", +] + + +def _is_interactive_cell(source: str) -> bool: + """Skip cells that block on user input or shell-out to install.""" + stripped = source.lstrip() + return stripped.startswith(("%pip", "!pip")) or "getpass.getpass" in source + + +def run_notebook(path: Path, *, timeout: int = 180) -> bool: + """Execute a notebook in-place and report whether it succeeded.""" + nb = nbformat.read(path, as_version=4) + + keep = [] + for cell in nb.cells: + if cell.cell_type == "code" and _is_interactive_cell( + "".join(cell.get("source", [])), + ): + continue + keep.append(cell) + nb.cells = keep + + client = NotebookClient(nb, timeout=timeout, kernel_name="python3") + try: + client.execute() + except CellExecutionError as e: + print(f"FAIL: {path.name}") + # Tail of the traceback is what matters; full message is huge. + print(str(e)[-2000:]) + return False + print(f"OK: {path.name}") + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "notebooks", + nargs="*", + type=Path, + help="Notebook paths (defaults to docs/*.ipynb)", + ) + parser.add_argument( + "--timeout", + type=int, + default=180, + help="Per-cell timeout in seconds (default 180)", + ) + args = parser.parse_args() + + if not os.environ.get("PARALLEL_API_KEY"): + print( + "PARALLEL_API_KEY is not set; notebooks that hit the API will fail.", + file=sys.stderr, + ) + + notebooks = args.notebooks or DEFAULT_NOTEBOOKS + ok = True + for nb_path in notebooks: + ok &= run_notebook(nb_path.resolve(), timeout=args.timeout) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/integration_tests/test_extract_tool.py b/tests/integration_tests/test_extract_tool.py index 6ff626b..48c072c 100644 --- a/tests/integration_tests/test_extract_tool.py +++ b/tests/integration_tests/test_extract_tool.py @@ -1,5 +1,7 @@ """Integration tests for Parallel Extract Tool.""" +from __future__ import annotations + import os import pytest @@ -27,7 +29,7 @@ def test_extract_single_url(self, api_key: str) -> None: { "urls": ["https://en.wikipedia.org/wiki/Artificial_intelligence"], "full_content": True, - } + }, ) assert len(result) == 1 @@ -52,7 +54,6 @@ def test_extract_multiple_urls(self, api_key: str) -> None: for item in result: assert "url" in item assert "content" in item - # Content may be empty for some pages, so just check it exists def test_extract_with_search_objective(self, api_key: str) -> None: """Test extraction with search objective to focus content.""" @@ -62,19 +63,16 @@ def test_extract_with_search_objective(self, api_key: str) -> None: { "urls": ["https://en.wikipedia.org/wiki/Artificial_intelligence"], "search_objective": "What are the main applications of AI?", - "excerpts": True, "full_content": False, - } + }, ) assert len(result) == 1 assert ( result[0]["url"] == "https://en.wikipedia.org/wiki/Artificial_intelligence" ) - # Should have excerpts focused on the objective assert "excerpts" in result[0] assert isinstance(result[0]["excerpts"], list) - # Content should be populated from excerpts assert len(result[0]["content"]) > 0 def test_extract_with_search_queries(self, api_key: str) -> None: @@ -83,16 +81,12 @@ def test_extract_with_search_queries(self, api_key: str) -> None: result = tool.invoke( { - "urls": [ - "https://en.wikipedia.org/wiki/Machine_learning", - ], + "urls": ["https://en.wikipedia.org/wiki/Machine_learning"], "search_queries": ["neural networks", "training algorithms"], - "excerpts": True, - } + }, ) assert len(result) == 1 - # Should have excerpts focused on the queries assert "excerpts" in result[0] assert isinstance(result[0]["excerpts"], list) assert len(result[0]["excerpts"]) > 0 @@ -105,49 +99,43 @@ def test_extract_with_max_chars(self, api_key: str) -> None: { "urls": ["https://en.wikipedia.org/wiki/Python_(programming_language)"], "full_content": True, - } + }, ) assert len(result) == 1 - # Note: The API currently returns up to 100k characters for full_content - # regardless of max_characters setting. This test verifies the tool - # correctly passes the parameter to the API. assert len(result[0]["content"]) > 0 assert result[0]["title"] is not None - def test_extract_metadata_fields(self, api_key: str) -> None: - """Test that metadata fields are properly populated.""" + def test_extract_excerpts_metadata_round_trip(self, api_key: str) -> None: + """Excerpts and publish_date round-trip through `_format_response`.""" tool = ParallelExtractTool(api_key=api_key) result = tool.invoke( - {"urls": ["https://en.wikipedia.org/wiki/Machine_learning"]} + { + "urls": ["https://en.wikipedia.org/wiki/Machine_learning"], + "search_objective": "Define machine learning", + }, ) assert len(result) > 0 - item = result[0] - assert "url" in item - assert "title" in item - assert "content" in item - # Other metadata fields may or may not be present depending on the source + assert "excerpts" in item + assert isinstance(item["excerpts"], list) def test_extract_invalid_url(self, api_key: str) -> None: """Test extraction handles invalid URLs gracefully.""" tool = ParallelExtractTool(api_key=api_key) - # The API handles invalid URLs gracefully by returning error info result = tool.invoke( { "urls": ["https://this-domain-does-not-exist-12345.com/"], "full_content": True, - "timeout": 30.0, # Reasonable timeout - } + "timeout": 30.0, + }, ) - # Should return a result with error information assert len(result) == 1 assert result[0]["url"] == "https://this-domain-does-not-exist-12345.com/" - # Should have error information in content or error_type assert "Error" in result[0]["content"] or "error_type" in result[0] def test_extract_mixed_valid_invalid_urls(self, api_key: str) -> None: @@ -161,11 +149,10 @@ def test_extract_mixed_valid_invalid_urls(self, api_key: str) -> None: "https://this-domain-does-not-exist-12345.com/", ], "full_content": True, - } + }, ) assert len(result) == 2 - # First URL should have content assert len(result[0]["content"]) > 0 or len(result[1]["content"]) > 0 @pytest.mark.asyncio @@ -177,7 +164,7 @@ async def test_extract_async(self, api_key: str) -> None: { "urls": ["https://en.wikipedia.org/wiki/Artificial_intelligence"], "full_content": True, - } + }, ) assert len(result) == 1 @@ -194,27 +181,8 @@ def test_extract_with_long_content(self, api_key: str) -> None: { "urls": ["https://en.wikipedia.org/wiki/History_of_the_United_States"], "full_content": True, - } + }, ) assert len(result) == 1 - # Long articles should have substantial content assert len(result[0]["content"]) > 1000 - - def test_extract_different_content_types(self, api_key: str) -> None: - """Test extraction from different types of web pages.""" - tool = ParallelExtractTool(api_key=api_key) - - # Test various content types - urls = [ - "https://www.wikipedia.org/", # Homepage - "https://en.wikipedia.org/wiki/Main_Page", # Wiki page - ] - - result = tool.invoke({"urls": urls}) - - assert len(result) == 2 - # All should return some result (even if empty content) - for item in result: - assert "url" in item - assert "content" in item diff --git a/tests/integration_tests/test_search_tool.py b/tests/integration_tests/test_search_tool.py index 8556b26..f6fc1c9 100644 --- a/tests/integration_tests/test_search_tool.py +++ b/tests/integration_tests/test_search_tool.py @@ -23,6 +23,13 @@ def tool_invoke_params_example(self) -> dict: have {"name", "id", "args"} keys. """ return { - "objective": "What are the latest developments in AI?", + "search_queries": [ + "latest AI developments", + "AI breakthroughs 2026", + ], + "objective": "Latest developments in AI", "max_results": 3, } + # Note: passing only `objective` (no search_queries) also works in + # 0.3.x but routes to /v1beta with a DeprecationWarning. Prefer the + # GA shape above; the fallback will be removed in 0.4.0. diff --git a/tests/unit_tests/__snapshots__/test_chat_models.ambr b/tests/unit_tests/__snapshots__/test_chat_models.ambr index 4927bb6..057e0bd 100644 --- a/tests/unit_tests/__snapshots__/test_chat_models.ambr +++ b/tests/unit_tests/__snapshots__/test_chat_models.ambr @@ -16,10 +16,33 @@ }), 'base_url': 'https://api.parallel.ai', 'max_retries': 2, - 'max_tokens': 100, 'model': 'speed', - 'temperature': 0.0, - 'timeout': 60.0, + 'model_name': 'speed', + }), + 'lc': 1, + 'name': 'ChatParallelWeb', + 'type': 'constructor', + }) +# --- +# name: TestChatParallelWebUnitLite.test_serdes[serialized] + dict({ + 'id': list([ + 'langchain_parallel', + 'chat_models', + 'ChatParallelWeb', + ]), + 'kwargs': dict({ + 'api_key': dict({ + 'id': list([ + 'PARALLEL_API_KEY', + ]), + 'lc': 1, + 'type': 'secret', + }), + 'base_url': 'https://api.parallel.ai', + 'max_retries': 2, + 'model': 'lite', + 'model_name': 'lite', }), 'lc': 1, 'name': 'ChatParallelWeb', diff --git a/tests/unit_tests/test_chat_models.py b/tests/unit_tests/test_chat_models.py index fc5a7b8..34d08a1 100644 --- a/tests/unit_tests/test_chat_models.py +++ b/tests/unit_tests/test_chat_models.py @@ -2,10 +2,19 @@ from __future__ import annotations +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from langchain_core.messages import AIMessage +from langchain_core.runnables import RunnableSequence from langchain_tests.unit_tests import ChatModelUnitTests +from pydantic import BaseModel, SecretStr from langchain_parallel.chat_models import ChatParallelWeb +_TEST_KEY = SecretStr("test") + class TestChatParallelWebUnit(ChatModelUnitTests): @property @@ -20,6 +29,11 @@ def chat_model_params(self) -> dict: "api_key": "test-api-key", } + @property + def standard_chat_model_params(self) -> dict: + """Parallel ignores most OpenAI sampling params; keep tests honest.""" + return {} + # Configure capabilities based on Parallel's Chat API features @property def has_tool_calling(self) -> bool: @@ -86,8 +100,15 @@ def supports_image_tool_message(self) -> bool: @property def structured_output_kwargs(self) -> dict: - """Additional kwargs for with_structured_output.""" - return {"method": "function_calling"} + """Additional kwargs for with_structured_output. + + Parallel research models (`lite`, `base`, `core`) accept + ``response_format`` JSON schemas; ``function_calling`` is not + supported. The base class doesn't enable structured output + (see :attr:`has_structured_output`); subclasses that flip the + flag should default to ``method='json_schema'``. + """ + return {"method": "json_schema"} @property def supported_usage_metadata_details(self) -> dict: @@ -124,3 +145,189 @@ def init_from_env_params(self) -> tuple[dict, dict, dict]: "api_key": "test-env-api-key", }, ) + + +class TestChatParallelWebUnitLite(TestChatParallelWebUnit): + """Unit tests parametrized for the `lite` research model. + + `lite` (and `base`/`core`) accept ``response_format`` JSON schema, so the + structured-output capability flag is True for those models. + """ + + @property + def chat_model_params(self) -> dict: + return {"model": "lite", "api_key": "test-api-key"} + + @property + def has_structured_output(self) -> bool: + return True + + @property + def structured_output_kwargs(self) -> dict: + # Parallel research models use json_schema, not function_calling. + return {"method": "json_schema"} + + +class _Founder(BaseModel): + name: str + company: str + + +class TestChatParallelWebDirect: + """Direct unit tests for behaviors the standard suite doesn't cover.""" + + def test_model_kwarg_actually_sets_model(self) -> None: + """`ChatParallelWeb(model='lite')` selects 'lite' (regression test).""" + chat = ChatParallelWeb(model="lite", api_key=_TEST_KEY) + assert chat.model == "lite" + + def test_model_name_alias_back_compat(self) -> None: + """`ChatParallelWeb(model_name='lite')` still works via the validator shim.""" + # Pre-0.3.0 callers used `model_name=`; the validator maps it back + # to `model=`. `model_name` isn't a real field, so silence the type + # checker on this back-compat call. + chat = ChatParallelWeb(model_name="lite", api_key=_TEST_KEY) # type: ignore[call-arg] + assert chat.model == "lite" + + def test_lc_attributes_exposes_model_name(self) -> None: + """`lc_attributes` surfaces the model under the LangChain-standard key.""" + chat = ChatParallelWeb(model="core", api_key=_TEST_KEY) + assert chat.lc_attributes["model_name"] == "core" + + def test_response_metadata_surfaces_basis_and_interaction_id(self) -> None: + """Basis / interaction_id / system_fingerprint round-trip on AIMessage.""" + chat = ChatParallelWeb(model="lite", api_key=_TEST_KEY) + choice = SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace(content="Elon Musk founded SpaceX."), + ) + response = SimpleNamespace( + choices=[choice], + model="lite", + created=1700000000, + system_fingerprint="fp-1", + interaction_id="int-1", + basis=[ + SimpleNamespace( + model_dump=lambda: {"field": "answer", "citations": []}, + ), + ], + ) + result = chat._process_non_stream_response(response) + msg = result.generations[0].message + assert isinstance(msg, AIMessage) + assert msg.response_metadata["model_name"] == "lite" + assert msg.response_metadata["finish_reason"] == "stop" + assert msg.response_metadata["system_fingerprint"] == "fp-1" + assert msg.response_metadata["interaction_id"] == "int-1" + assert msg.response_metadata["basis"] == [ + {"field": "answer", "citations": []}, + ] + + def test_with_structured_output_rejects_speed(self) -> None: + """Speed silently ignores response_format; raise to make this loud.""" + chat = ChatParallelWeb(model="speed", api_key=_TEST_KEY) + with pytest.raises(ValueError, match="research models"): + chat.with_structured_output(_Founder) + + def test_with_structured_output_binds_response_format(self) -> None: + """Binding a pydantic schema produces a json_schema response_format.""" + chat = ChatParallelWeb(model="lite", api_key=_TEST_KEY) + runnable = chat.with_structured_output(_Founder) + assert isinstance(runnable, RunnableSequence) + bound = runnable.first + rf = bound.kwargs["response_format"] # type: ignore[attr-defined] + assert rf["type"] == "json_schema" + assert rf["json_schema"]["name"] == "_Founder" + assert rf["json_schema"]["strict"] is True + assert "name" in rf["json_schema"]["schema"]["properties"] + + def test_with_structured_output_function_calling_routes_to_json_schema( + self, + ) -> None: + """method='function_calling' is routed to json_schema for compat. + + Parallel chat doesn't actually support tool calling; we accept + ``function_calling`` for cross-provider compatibility and produce a + json_schema response_format under the hood. + """ + chat = ChatParallelWeb(model="lite", api_key=_TEST_KEY) + runnable = chat.with_structured_output(_Founder, method="function_calling") + assert isinstance(runnable, RunnableSequence) + bound = runnable.first + assert bound.kwargs["response_format"]["type"] == "json_schema" # type: ignore[attr-defined] + + def test_with_structured_output_json_mode(self) -> None: + """method='json_mode' produces a json_object response_format.""" + chat = ChatParallelWeb(model="lite", api_key=_TEST_KEY) + runnable = chat.with_structured_output(method="json_mode") + assert isinstance(runnable, RunnableSequence) + bound = runnable.first + assert bound.kwargs["response_format"] == {"type": "json_object"} # type: ignore[attr-defined] + + def test_with_structured_output_include_raw_failure_capture(self) -> None: + """include_raw=True populates parsing_error on parse failure.""" + from langchain_core.runnables import RunnableLambda + + chat = ChatParallelWeb(model="lite", api_key=_TEST_KEY) + runnable = chat.with_structured_output(_Founder, include_raw=True) + # The capture lambda is the last step; pull it out and exercise directly + # so we don't need a live API call. + assert isinstance(runnable, RunnableSequence) + capture = next( + step.func for step in runnable.steps if isinstance(step, RunnableLambda) + ) + result = capture(AIMessage(content="not json")) + assert isinstance(result["raw"], AIMessage) + assert result["parsed"] is None + assert result["parsing_error"] is not None + + def test_with_structured_output_include_raw_success(self) -> None: + """include_raw=True wraps the parsed pydantic object.""" + from langchain_core.runnables import RunnableLambda + + chat = ChatParallelWeb(model="lite", api_key=_TEST_KEY) + runnable = chat.with_structured_output(_Founder, include_raw=True) + assert isinstance(runnable, RunnableSequence) + capture = next( + step.func for step in runnable.steps if isinstance(step, RunnableLambda) + ) + result = capture( + AIMessage(content='{"name": "Elon Musk", "company": "SpaceX"}'), + ) + assert isinstance(result["parsed"], _Founder) + assert result["parsed"].name == "Elon Musk" + assert result["parsing_error"] is None + + def test_chat_parallel_is_alias_of_chat_parallel_web(self) -> None: + """``ChatParallel`` is the new canonical name; old name still works.""" + from langchain_parallel import ChatParallel, ChatParallelWeb + + assert ChatParallel is ChatParallelWeb + assert ChatParallel(model="lite", api_key=_TEST_KEY).model == "lite" + + def test_response_metadata_stream_chunk_includes_basis(self) -> None: + """Streaming chunks expose basis on the last chunk.""" + chat = ChatParallelWeb(model="lite", api_key=_TEST_KEY) + chunk = SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + delta=SimpleNamespace(content="."), + ), + ], + model="lite", + interaction_id="int-2", + basis=[ + SimpleNamespace( + model_dump=lambda: {"field": "answer", "citations": []}, + ), + ], + system_fingerprint=None, + ) + out = chat._process_stream_chunk(chunk, run_manager=Mock()) + assert out is not None + meta = out.message.response_metadata + assert meta["model_name"] == "lite" + assert meta["interaction_id"] == "int-2" + assert meta["basis"] == [{"field": "answer", "citations": []}] diff --git a/tests/unit_tests/test_extract_tool.py b/tests/unit_tests/test_extract_tool.py index e895b34..887b9f7 100644 --- a/tests/unit_tests/test_extract_tool.py +++ b/tests/unit_tests/test_extract_tool.py @@ -1,12 +1,21 @@ """Unit tests for Parallel Extract Tool.""" +from __future__ import annotations + +from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch import pytest +from langchain_parallel._types import ExcerptSettings, FetchPolicy, FullContentSettings from langchain_parallel.extract_tool import ParallelExtractTool +def _make_response(payload: dict) -> SimpleNamespace: + """Build a mock SDK response with .model_dump().""" + return SimpleNamespace(model_dump=lambda: dict(payload)) + + class TestParallelExtractTool: """Test cases for ParallelExtractTool.""" @@ -25,240 +34,444 @@ def test_extract_tool_initialization_with_params(self) -> None: with patch( "langchain_parallel.extract_tool.get_api_key", return_value="test-key" ): - tool = ParallelExtractTool( - max_chars_per_extract=3000, - ) + tool = ParallelExtractTool(max_chars_per_extract=3000) assert tool.max_chars_per_extract == 3000 - @patch("langchain_parallel.extract_tool.get_extract_client") - def test_extract_single_url(self, mock_get_extract_client: Mock) -> None: - """Test extracting content from a single URL.""" - # Mock the extract client - mock_client = Mock() - mock_client.extract.return_value = { - "extract_id": "extract-123", - "results": [ - { - "url": "https://example.com", - "title": "Test Article", - "full_content": "This is the extracted content.", - "publish_date": "2024-01-01", - } - ], - "errors": [], - } - mock_get_extract_client.return_value = mock_client + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + def test_extract_single_url( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """Single URL hits the GA endpoint and returns a list of dicts.""" + sync_client = Mock() + sync_client.extract.return_value = _make_response( + { + "extract_id": "extract-1", + "results": [ + { + "url": "https://example.com", + "title": "Test Article", + "full_content": "This is the extracted content.", + "publish_date": "2024-01-01", + }, + ], + "errors": [], + }, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() with patch( "langchain_parallel.extract_tool.get_api_key", return_value="test-key" ): tool = ParallelExtractTool() - result = tool.invoke({"urls": ["https://example.com"]}) + result = tool._run(urls=["https://example.com"], full_content=True) + sync_client.extract.assert_called_once() + sync_client.beta.extract.assert_not_called() + assert isinstance(result, list) assert len(result) == 1 assert result[0]["url"] == "https://example.com" assert result[0]["title"] == "Test Article" assert result[0]["content"] == "This is the extracted content." assert result[0]["publish_date"] == "2024-01-01" - @patch("langchain_parallel.extract_tool.get_extract_client") - def test_extract_multiple_urls(self, mock_get_extract_client: Mock) -> None: + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + def test_extract_multiple_urls( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: """Test extraction with multiple URLs.""" - mock_client = Mock() - mock_client.extract.return_value = { - "extract_id": "extract-123", - "results": [ - { - "url": "https://example1.com", - "title": "Article 1", - "full_content": "Content 1", - }, - { - "url": "https://example2.com", - "title": "Article 2", - "full_content": "Content 2", - }, - ], - "errors": [], - } - mock_get_extract_client.return_value = mock_client + sync_client = Mock() + sync_client.extract.return_value = _make_response( + { + "extract_id": "extract-1", + "results": [ + { + "url": "https://example1.com", + "title": "Article 1", + "full_content": "Content 1", + }, + { + "url": "https://example2.com", + "title": "Article 2", + "full_content": "Content 2", + }, + ], + "errors": [], + }, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() with patch( "langchain_parallel.extract_tool.get_api_key", return_value="test-key" ): tool = ParallelExtractTool() - result = tool.invoke( - {"urls": ["https://example1.com", "https://example2.com"]} + result = tool._run( + urls=["https://example1.com", "https://example2.com"], + full_content=True, ) - - assert len(result) == 2 - assert result[0]["content"] == "Content 1" - assert result[1]["content"] == "Content 2" - - @patch("langchain_parallel.extract_tool.get_extract_client") - def test_extract_with_errors(self, mock_get_extract_client: Mock) -> None: + assert [r["content"] for r in result] == ["Content 1", "Content 2"] + + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + def test_extract_with_errors( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: """Test extraction handles errors gracefully.""" - mock_client = Mock() - mock_client.extract.return_value = { - "extract_id": "extract-123", - "results": [ - { - "url": "https://example1.com", - "title": "Article 1", - "full_content": "Content 1", - } - ], - "errors": [ - { - "url": "https://example2.com", - "error_type": "http_error", - "http_status_code": 404, - "content": None, - } - ], - } - mock_get_extract_client.return_value = mock_client + sync_client = Mock() + sync_client.extract.return_value = _make_response( + { + "extract_id": "extract-1", + "results": [ + { + "url": "https://example1.com", + "title": "Article 1", + "full_content": "Content 1", + }, + ], + "errors": [ + { + "url": "https://example2.com", + "error_type": "http_error", + "http_status_code": 404, + "content": None, + }, + ], + }, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() with patch( "langchain_parallel.extract_tool.get_api_key", return_value="test-key" ): tool = ParallelExtractTool() - result = tool.invoke( - {"urls": ["https://example1.com", "https://example2.com"]} + result = tool._run( + urls=["https://example1.com", "https://example2.com"], + full_content=True, ) - assert len(result) == 2 assert result[0]["content"] == "Content 1" - assert result[1]["url"] == "https://example2.com" - assert "Error: http_error" in result[1]["content"] assert result[1]["error_type"] == "http_error" assert result[1]["http_status_code"] == 404 + assert "Error: http_error" in result[1]["content"] - @patch("langchain_parallel.extract_tool.get_extract_client") - def test_extract_with_max_chars(self, mock_get_extract_client: Mock) -> None: - """Test extraction with max_chars_per_extract limit.""" - mock_client = Mock() - mock_client.extract.return_value = { - "extract_id": "extract-123", - "results": [ - { - "url": "https://example.com", - "title": "Test", - "full_content": "Short content", - } - ], - "errors": [], - } - mock_get_extract_client.return_value = mock_client + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + def test_full_content_precedence_tool_level_default( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """Tool-level max_chars_per_extract applies when full_content=True.""" + sync_client = Mock() + sync_client.extract.return_value = _make_response( + { + "extract_id": "extract-1", + "results": [ + { + "url": "https://example.com", + "title": "Test", + "full_content": "Short", + }, + ], + "errors": [], + }, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() with patch( "langchain_parallel.extract_tool.get_api_key", return_value="test-key" ): tool = ParallelExtractTool(max_chars_per_extract=5000) - tool.invoke({"urls": ["https://example.com"]}) + tool._run(urls=["https://example.com"], full_content=True) + kwargs = sync_client.extract.call_args.kwargs + assert kwargs["advanced_settings"]["full_content"] == { + "max_chars_per_result": 5000, + } - # Verify extract was called with full_content config - call_kwargs = mock_client.extract.call_args[1] - assert call_kwargs["full_content"] == {"max_chars_per_result": 5000} + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + def test_full_content_precedence_explicit_settings_wins( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """Explicit FullContentSettings beats the tool-level cap.""" + sync_client = Mock() + sync_client.extract.return_value = _make_response( + {"extract_id": "e", "results": [], "errors": []}, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() - @patch("langchain_parallel.extract_tool.get_extract_client") - def test_extract_handles_api_error(self, mock_get_extract_client: Mock) -> None: - """Test extract tool handles API errors gracefully.""" - mock_client = Mock() - mock_client.extract.side_effect = Exception("API Error") - mock_get_extract_client.return_value = mock_client + with patch( + "langchain_parallel.extract_tool.get_api_key", return_value="test-key" + ): + tool = ParallelExtractTool(max_chars_per_extract=5000) + tool._run( + urls=["https://example.com"], + full_content=FullContentSettings(max_chars_per_result=200), + ) + kwargs = sync_client.extract.call_args.kwargs + assert kwargs["advanced_settings"]["full_content"] == { + "max_chars_per_result": 200, + } + + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + def test_full_content_false_omits_key( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """full_content=False produces no full_content key in advanced_settings.""" + sync_client = Mock() + sync_client.extract.return_value = _make_response( + {"extract_id": "e", "results": [], "errors": []}, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() with patch( "langchain_parallel.extract_tool.get_api_key", return_value="test-key" ): tool = ParallelExtractTool() + tool._run(urls=["https://example.com"], full_content=False) + kwargs = sync_client.extract.call_args.kwargs + advanced = kwargs.get("advanced_settings") or {} + assert "full_content" not in advanced + + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + def test_excerpts_bool_true_is_no_op( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """Backward compat: excerpts=True (the default) adds no excerpt_settings.""" + sync_client = Mock() + sync_client.extract.return_value = _make_response( + {"extract_id": "e", "results": [], "errors": []}, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() + with patch( + "langchain_parallel.extract_tool.get_api_key", return_value="test-key" + ): + tool = ParallelExtractTool() + tool._run(urls=["https://example.com"], excerpts=True) + advanced = sync_client.extract.call_args.kwargs.get("advanced_settings") + # No advanced settings at all when only the bool default is set. + assert advanced is None + + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + def test_excerpts_bool_false_warns( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """excerpts=False emits a DeprecationWarning (v1 always returns excerpts).""" + sync_client = Mock() + sync_client.extract.return_value = _make_response( + {"extract_id": "e", "results": [], "errors": []}, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() + + with patch( + "langchain_parallel.extract_tool.get_api_key", return_value="test-key" + ): + tool = ParallelExtractTool() + with pytest.warns(DeprecationWarning, match="always returns excerpts"): + tool._run(urls=["https://example.com"], excerpts=False) + + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + def test_advanced_settings_envelope( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """ExcerptSettings + FetchPolicy + full_content nest into advanced_settings.""" + sync_client = Mock() + sync_client.extract.return_value = _make_response( + {"extract_id": "e", "results": [], "errors": []}, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() + + with patch( + "langchain_parallel.extract_tool.get_api_key", return_value="test-key" + ): + tool = ParallelExtractTool() + tool._run( + urls=["https://example.com"], + excerpts=ExcerptSettings(max_chars_per_result=2000), + full_content=FullContentSettings(max_chars_per_result=8000), + fetch_policy=FetchPolicy(max_age_seconds=86400), + ) + kwargs = sync_client.extract.call_args.kwargs + assert kwargs["advanced_settings"] == { + "excerpt_settings": {"max_chars_per_result": 2000}, + "fetch_policy": { + "max_age_seconds": 86400, + "disable_cache_fallback": False, + }, + "full_content": {"max_chars_per_result": 8000}, + } + + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + def test_top_level_passthrough_fields( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """`max_chars_total`, `client_model`, `session_id` flow through verbatim.""" + sync_client = Mock() + sync_client.extract.return_value = _make_response( + {"extract_id": "e", "results": [], "errors": []}, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() + + with patch( + "langchain_parallel.extract_tool.get_api_key", return_value="test-key" + ): + tool = ParallelExtractTool() + tool._run( + urls=["https://example.com"], + max_chars_total=42_000, + client_model="claude-opus-4-7", + session_id="sess-1", + ) + kwargs = sync_client.extract.call_args.kwargs + assert kwargs["max_chars_total"] == 42_000 + assert kwargs["client_model"] == "claude-opus-4-7" + assert kwargs["session_id"] == "sess-1" + + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + def test_extract_handles_api_error( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """Test extract tool wraps API errors as ValueError.""" + sync_client = Mock() + sync_client.extract.side_effect = Exception("API Error") + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() + + with patch( + "langchain_parallel.extract_tool.get_api_key", return_value="test-key" + ): + tool = ParallelExtractTool() with pytest.raises( ValueError, match="Error calling Parallel Extract API: API Error" ): - tool.invoke({"urls": ["https://example.com"]}) + tool._run(urls=["https://example.com"]) - @patch("langchain_parallel.extract_tool.get_async_extract_client") - @patch("langchain_parallel.extract_tool.get_extract_client") + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") @pytest.mark.asyncio async def test_extract_async_functionality( - self, mock_get_extract_client: Mock, mock_get_async_extract_client: Mock + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, ) -> None: - """Test async extraction functionality.""" - # Mock sync client (needed for initialization) - mock_sync_client = Mock() - mock_get_extract_client.return_value = mock_sync_client - - # Mock async client - mock_async_client = Mock() - mock_async_client.extract = AsyncMock( - return_value={ - "extract_id": "extract-123", - "results": [ - { - "url": "https://example.com", - "title": "Test Article", - "full_content": "Async content", - } - ], - "errors": [], - } + """Async path uses async client.""" + async_client = Mock() + async_client.extract = AsyncMock( + return_value=_make_response( + { + "extract_id": "extract-1", + "results": [ + { + "url": "https://example.com", + "title": "Async", + "full_content": "Async content", + }, + ], + "errors": [], + }, + ), ) - mock_get_async_extract_client.return_value = mock_async_client + mock_async_factory.return_value = async_client + mock_sync_factory.return_value = Mock() with patch( "langchain_parallel.extract_tool.get_api_key", return_value="test-key" ): tool = ParallelExtractTool() - result = await tool.ainvoke({"urls": ["https://example.com"]}) - + result = await tool._arun(urls=["https://example.com"]) + assert isinstance(result, list) assert len(result) == 1 assert result[0]["content"] == "Async content" - @patch("langchain_parallel.extract_tool.get_extract_client") - def test_extract_metadata_fields(self, mock_get_extract_client: Mock) -> None: - """Test that all metadata fields are properly extracted.""" - mock_client = Mock() - mock_client.extract.return_value = { - "extract_id": "extract-123", - "results": [ - { - "url": "https://example.com", - "title": "Test Article", - "full_content": "Content", - "publish_date": "2024-01-01", - } - ], - "errors": [], - } - mock_get_extract_client.return_value = mock_client + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + @pytest.mark.asyncio + async def test_extract_async_handles_api_error( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """Async API exceptions are wrapped as ValueError.""" + async_client = Mock() + async_client.extract = AsyncMock(side_effect=Exception("Async API Error")) + mock_async_factory.return_value = async_client + mock_sync_factory.return_value = Mock() with patch( "langchain_parallel.extract_tool.get_api_key", return_value="test-key" ): tool = ParallelExtractTool() - result = tool.invoke({"urls": ["https://example.com"]}) - - assert result[0]["url"] == "https://example.com" - assert result[0]["title"] == "Test Article" - assert result[0]["content"] == "Content" - assert result[0].get("publish_date") == "2024-01-01" - - @patch("langchain_parallel.extract_tool.get_extract_client") - def test_extract_empty_results(self, mock_get_extract_client: Mock) -> None: + with pytest.raises( + ValueError, + match="Error calling Parallel Extract API: Async API Error", + ): + await tool._arun(urls=["https://example.com"]) + + @patch("langchain_parallel.extract_tool.get_parallel_client") + @patch("langchain_parallel.extract_tool.get_async_parallel_client") + def test_extract_empty_results( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: """Test extract tool handles empty results.""" - mock_client = Mock() - mock_client.extract.return_value = { - "extract_id": "extract-123", - "results": [], - "errors": [], - } - mock_get_extract_client.return_value = mock_client + sync_client = Mock() + sync_client.extract.return_value = _make_response( + {"extract_id": "extract-1", "results": [], "errors": []}, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() with patch( "langchain_parallel.extract_tool.get_api_key", return_value="test-key" ): tool = ParallelExtractTool() - result = tool.invoke({"urls": ["https://example.com"]}) + result = tool._run(urls=["https://example.com"]) + assert result == [] - assert len(result) == 0 + def test_extract_empty_urls_raises(self) -> None: + """urls=[] raises ValueError.""" + with patch( + "langchain_parallel.extract_tool.get_api_key", return_value="test-key" + ): + tool = ParallelExtractTool() + with pytest.raises(ValueError, match="At least one URL"): + tool._run(urls=[]) diff --git a/tests/unit_tests/test_search_tool.py b/tests/unit_tests/test_search_tool.py index 9a2e8d3..86441be 100644 --- a/tests/unit_tests/test_search_tool.py +++ b/tests/unit_tests/test_search_tool.py @@ -1,10 +1,19 @@ """Unit tests for Parallel Search functionality.""" +from __future__ import annotations + +from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch import pytest -from langchain_parallel.search_tool import ParallelWebSearchTool +from langchain_parallel._types import ExcerptSettings, FetchPolicy, SourcePolicy +from langchain_parallel.search_tool import ParallelWebSearchTool, _normalize_mode + + +def _make_response(payload: dict) -> SimpleNamespace: + """Build a mock SDK response with .model_dump().""" + return SimpleNamespace(model_dump=lambda: dict(payload)) class TestParallelWebSearchTool: @@ -17,95 +26,325 @@ def test_tool_initialization(self) -> None: ): tool = ParallelWebSearchTool() assert tool.name == "parallel_web_search" - assert "Search the web using Parallel" in tool.description - - @patch("langchain_parallel.search_tool.get_search_client") - def test_tool_successful_search(self, mock_get_client: Mock) -> None: - """Test successful search execution.""" - # Mock the search client - mock_client = Mock() - mock_client.search.return_value = { - "search_id": "test-123", - "results": [ - { - "url": "https://example.com", - "title": "Test Result", - "excerpts": ["Test excerpt"], - } - ], - } - mock_get_client.return_value = mock_client + assert "Search the web" in tool.description + + @patch("langchain_parallel.search_tool.get_parallel_client") + @patch("langchain_parallel.search_tool.get_async_parallel_client") + def test_run_uses_v1_endpoint_when_search_queries_provided( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """search_queries triggers the GA endpoint and returns a dict.""" + sync_client = Mock() + sync_client.search.return_value = _make_response( + { + "search_id": "search-1", + "results": [ + { + "url": "https://example.com", + "title": "Test", + "excerpts": ["snippet"], + }, + ], + }, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() with patch( "langchain_parallel.search_tool.get_api_key", return_value="test-key" ): tool = ParallelWebSearchTool() - result = tool._run(objective="test search") + result = tool._run( + search_queries=["query 1"], + max_results=3, + mode="advanced", + ) + sync_client.search.assert_called_once() + sync_client.beta.search.assert_not_called() + kwargs = sync_client.search.call_args.kwargs + assert kwargs["search_queries"] == ["query 1"] + assert kwargs["mode"] == "advanced" + assert kwargs["advanced_settings"] == {"max_results": 3} + assert isinstance(result, dict) + assert result["search_id"] == "search-1" + assert result["search_metadata"]["endpoint"] == "v1" - assert result["search_id"] == "test-123" - assert len(result["results"]) == 1 - assert result["results"][0]["title"] == "Test Result" + @patch("langchain_parallel.search_tool.get_parallel_client") + @patch("langchain_parallel.search_tool.get_async_parallel_client") + def test_run_falls_back_to_beta_when_objective_only( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """Objective-only routes to /v1beta with a DeprecationWarning. - @patch("langchain_parallel.search_tool.get_search_client") - def test_tool_handles_api_error(self, mock_get_client: Mock) -> None: - """Test tool handles API errors gracefully.""" - # Mock the search client to raise an exception - mock_client = Mock() - mock_client.search.side_effect = Exception("API Error") - mock_get_client.return_value = mock_client + This is a deprecated path slated for removal in 0.4.0; it exists so + 0.2.x callers passing only ``objective`` keep working. + """ + sync_client = Mock() + sync_client.beta.search.return_value = _make_response( + {"search_id": "beta-1", "results": []}, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() with patch( "langchain_parallel.search_tool.get_api_key", return_value="test-key" ): tool = ParallelWebSearchTool() + with pytest.warns(DeprecationWarning, match="0.4.0"): + result = tool._run( + objective="What is AI?", + mode="advanced", + source_policy={"include_domains": ["wikipedia.org"]}, + ) + sync_client.beta.search.assert_called_once() + sync_client.search.assert_not_called() + beta_kwargs = sync_client.beta.search.call_args.kwargs + # advanced -> agentic on the legacy endpoint + assert beta_kwargs["mode"] == "agentic" + assert beta_kwargs["source_policy"] == { + "include_domains": ["wikipedia.org"], + } + assert result["search_metadata"]["endpoint"] == "v1beta" - with pytest.raises( - ValueError, match="Error calling Parallel Search API: API Error" - ): - tool._run(objective="test search") - - @patch("langchain_parallel.search_tool.get_search_client") - def test_metadata_collection(self, mock_get_client: Mock) -> None: - """Test metadata collection.""" - mock_client = Mock() - mock_client.search.return_value = { - "search_id": "test-123", - "results": [{"url": "https://example.com", "title": "Test"}], - } - mock_get_client.return_value = mock_client + def test_run_raises_when_neither_objective_nor_queries(self) -> None: + """At least one of objective or search_queries must be provided.""" + with patch( + "langchain_parallel.search_tool.get_api_key", return_value="test-key" + ): + tool = ParallelWebSearchTool() + with pytest.raises(ValueError, match="objective.*search_queries.*provided"): + tool._run() + + @patch("langchain_parallel.search_tool.get_parallel_client") + @patch("langchain_parallel.search_tool.get_async_parallel_client") + def test_run_translates_legacy_mode( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """Legacy mode strings are mapped with a DeprecationWarning.""" + sync_client = Mock() + sync_client.search.return_value = _make_response( + {"search_id": "s", "results": []}, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() with patch( "langchain_parallel.search_tool.get_api_key", return_value="test-key" ): tool = ParallelWebSearchTool() - result = tool._run( - search_queries=["query1", "query2"], - include_metadata=True, + with pytest.warns(DeprecationWarning, match="legacy beta value"): + tool._run(search_queries=["q"], mode="agentic") + assert sync_client.search.call_args.kwargs["mode"] == "advanced" + + @patch("langchain_parallel.search_tool.get_parallel_client") + @patch("langchain_parallel.search_tool.get_async_parallel_client") + def test_advanced_settings_envelope_pydantic( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """Pydantic models pack into `advanced_settings` correctly.""" + sync_client = Mock() + sync_client.search.return_value = _make_response( + {"search_id": "s", "results": []}, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() + + with patch( + "langchain_parallel.search_tool.get_api_key", return_value="test-key" + ): + tool = ParallelWebSearchTool() + tool._run( + search_queries=["q"], + excerpts=ExcerptSettings(max_chars_per_result=1500), + fetch_policy=FetchPolicy(max_age_seconds=86400), + source_policy=SourcePolicy( + include_domains=["nature.com"], + after_date="2025-01-01", + ), + location="us", + max_results=15, ) + kwargs = sync_client.search.call_args.kwargs + assert kwargs["advanced_settings"] == { + "excerpt_settings": {"max_chars_per_result": 1500}, + "fetch_policy": { + "max_age_seconds": 86400, + "disable_cache_fallback": False, + }, + "source_policy": { + "include_domains": ["nature.com"], + "after_date": "2025-01-01", + }, + "max_results": 15, + "location": "us", + } + + @patch("langchain_parallel.search_tool.get_parallel_client") + @patch("langchain_parallel.search_tool.get_async_parallel_client") + def test_advanced_settings_envelope_dict( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """Raw-dict source_policy is accepted alongside the pydantic model.""" + sync_client = Mock() + sync_client.search.return_value = _make_response( + {"search_id": "s", "results": []}, + ) + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() - assert "search_metadata" in result - metadata = result["search_metadata"] - assert "search_duration_seconds" in metadata - assert "query_count" in metadata - assert metadata["query_count"] == 2 - - @patch("langchain_parallel.search_tool.get_async_search_client") - async def test_async_functionality(self, mock_get_async_client: Mock) -> None: - """Test async search functionality.""" - mock_client = Mock() - mock_client.search = AsyncMock( - return_value={ - "search_id": "async-test-123", - "results": [{"url": "https://example.com", "title": "Async Test"}], + with patch( + "langchain_parallel.search_tool.get_api_key", return_value="test-key" + ): + tool = ParallelWebSearchTool() + tool._run( + search_queries=["q"], + source_policy={"include_domains": ["nature.com"]}, + location="us", + max_results=15, + ) + kwargs = sync_client.search.call_args.kwargs + assert kwargs["advanced_settings"] == { + "source_policy": {"include_domains": ["nature.com"]}, + "max_results": 15, + "location": "us", } + + @patch("langchain_parallel.search_tool.get_parallel_client") + @patch("langchain_parallel.search_tool.get_async_parallel_client") + def test_top_level_passthrough_fields( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """`max_chars_total`, `client_model`, `session_id` flow through verbatim.""" + sync_client = Mock() + sync_client.search.return_value = _make_response( + {"search_id": "s", "results": []}, ) - mock_get_async_client.return_value = mock_client + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() + + with patch( + "langchain_parallel.search_tool.get_api_key", return_value="test-key" + ): + tool = ParallelWebSearchTool() + tool._run( + search_queries=["q"], + max_chars_total=42_000, + client_model="claude-opus-4-7", + session_id="sess-1", + ) + kwargs = sync_client.search.call_args.kwargs + assert kwargs["max_chars_total"] == 42_000 + assert kwargs["client_model"] == "claude-opus-4-7" + assert kwargs["session_id"] == "sess-1" + + @patch("langchain_parallel.search_tool.get_parallel_client") + @patch("langchain_parallel.search_tool.get_async_parallel_client") + def test_run_handles_api_error( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """API exceptions are wrapped as ValueError.""" + sync_client = Mock() + sync_client.search.side_effect = Exception("API Error") + mock_sync_factory.return_value = sync_client + mock_async_factory.return_value = Mock() with patch( "langchain_parallel.search_tool.get_api_key", return_value="test-key" ): tool = ParallelWebSearchTool() - result = await tool._arun(objective="test async search") + with pytest.raises( + ValueError, + match="Error calling Parallel Search API: API Error", + ): + tool._run(search_queries=["q"]) + + @patch("langchain_parallel.search_tool.get_parallel_client") + @patch("langchain_parallel.search_tool.get_async_parallel_client") + async def test_async_functionality( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """Async path uses the async client.""" + async_client = Mock() + async_client.search = AsyncMock( + return_value=_make_response( + { + "search_id": "async-1", + "results": [{"url": "https://example.com", "title": "Async"}], + }, + ), + ) + mock_async_factory.return_value = async_client + mock_sync_factory.return_value = Mock() + + with patch( + "langchain_parallel.search_tool.get_api_key", return_value="test-key" + ): + tool = ParallelWebSearchTool() + result = await tool._arun(search_queries=["q"]) + assert isinstance(result, dict) + assert result["search_id"] == "async-1" + + @patch("langchain_parallel.search_tool.get_parallel_client") + @patch("langchain_parallel.search_tool.get_async_parallel_client") + async def test_async_handles_api_error( + self, + mock_async_factory: Mock, + mock_sync_factory: Mock, + ) -> None: + """Async API exceptions are wrapped as ValueError.""" + async_client = Mock() + async_client.search = AsyncMock(side_effect=Exception("Async API Error")) + mock_async_factory.return_value = async_client + mock_sync_factory.return_value = Mock() + + with patch( + "langchain_parallel.search_tool.get_api_key", return_value="test-key" + ): + tool = ParallelWebSearchTool() + with pytest.raises( + ValueError, + match="Error calling Parallel Search API: Async API Error", + ): + await tool._arun(search_queries=["q"]) + + +def test_parallel_search_tool_is_alias_of_parallel_web_search_tool() -> None: + """``ParallelSearchTool`` is the new canonical name; old name still works.""" + from langchain_parallel import ParallelSearchTool, ParallelWebSearchTool + + assert ParallelSearchTool is ParallelWebSearchTool + + +class TestNormalizeMode: + def test_passthrough(self) -> None: + assert _normalize_mode("basic") == "basic" + assert _normalize_mode("advanced") == "advanced" + assert _normalize_mode(None) is None + + def test_legacy(self) -> None: + with pytest.warns(DeprecationWarning): + assert _normalize_mode("one-shot") == "basic" + with pytest.warns(DeprecationWarning): + assert _normalize_mode("agentic") == "advanced" + with pytest.warns(DeprecationWarning): + assert _normalize_mode("fast") == "basic" - assert result["search_id"] == "async-test-123" - assert len(result["results"]) == 1 + def test_invalid(self) -> None: + with pytest.raises(ValueError, match="Invalid mode"): + _normalize_mode("nonsense")