From a910c969214123b01675c0ba5eb1a471ac164c82 Mon Sep 17 00:00:00 2001 From: Jonathan Rhyne Date: Thu, 5 Mar 2026 00:53:26 -0500 Subject: [PATCH 1/5] Add framework quickstart snippets --- README.md | 16 ++++++++ examples/README.md | 27 ++++++++++++++ examples/src/framework_crewai.py | 49 +++++++++++++++++++++++++ examples/src/framework_langchain.py | 43 ++++++++++++++++++++++ examples/src/framework_openai_agents.py | 34 +++++++++++++++++ 5 files changed, 169 insertions(+) create mode 100644 examples/src/framework_crewai.py create mode 100644 examples/src/framework_langchain.py create mode 100644 examples/src/framework_openai_agents.py diff --git a/README.md b/README.md index edb7b8c..fd3ccb6 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,22 @@ from nutrient_dws import NutrientClient client = NutrientClient(api_key='your_api_key') ``` +## Framework Quickstarts + +Framework-oriented snippets are available in `examples/src/`: + +- `framework_openai_agents.py` +- `framework_langchain.py` +- `framework_crewai.py` + +Syntax-check commands: + +```bash +python -m py_compile examples/src/framework_openai_agents.py +python -m py_compile examples/src/framework_langchain.py +python -m py_compile examples/src/framework_crewai.py +``` + ## Direct Methods The client provides numerous async methods for document processing: diff --git a/examples/README.md b/examples/README.md index 31f96f8..7bfc5e7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,6 +8,9 @@ This example project demonstrates how to use the Nutrient DWS Python Client for - `src/` - Contains Python source files - `direct_method.py` - Examples using direct method calls - `workflow.py` - Examples using the workflow builder pattern + - `framework_openai_agents.py` - OpenAI Agents SDK integration sketch + - `framework_langchain.py` - LangChain/LangGraph integration sketch + - `framework_crewai.py` - CrewAI integration sketch - `output/` - Directory where processed files will be saved - `.env.example` - Example environment variables file @@ -122,6 +125,30 @@ This will: 3. Extract text with JSON output 4. Execute a complex multi-step workflow +### Framework Examples + +These examples are intentionally minimal and focus on framework wiring: + +Install optional framework dependencies first: + +```bash +pip install openai-agents langchain langchain-openai langgraph crewai +``` + +```bash +python src/framework_openai_agents.py +python src/framework_langchain.py +python src/framework_crewai.py +``` + +Syntax-check only: + +```bash +python -m py_compile src/framework_openai_agents.py +python -m py_compile src/framework_langchain.py +python -m py_compile src/framework_crewai.py +``` + ## Output All processed files will be saved to the `output/` directory. You can examine these files to see the results of the document processing operations. diff --git a/examples/src/framework_crewai.py b/examples/src/framework_crewai.py new file mode 100644 index 0000000..8d2572a --- /dev/null +++ b/examples/src/framework_crewai.py @@ -0,0 +1,49 @@ +import os + +from crewai import Agent, Crew, Task +from nutrient_dws import NutrientClient + + +client = NutrientClient(api_key=os.getenv("NUTRIENT_API_KEY", "nutr_sk_placeholder")) + + +document_planner = Agent( + role="Document Workflow Planner", + goal="Design a robust Nutrient DWS processing pipeline for incoming files.", + backstory="You optimize OCR, extraction, and redaction workflows for accuracy." +) + +document_reviewer = Agent( + role="Document QA Reviewer", + goal="Review proposed workflow plans for failure modes and compliance risks.", + backstory="You specialize in validating document automation quality." +) + +plan_task = Task( + description=( + "Design a pipeline for ./examples/assets/sample.pdf that extracts text, " + "redacts emails, and exports a cleaned PDF." + ), + expected_output="A numbered execution plan with tool calls.", + agent=document_planner, +) + +review_task = Task( + description=( + "Review the proposed pipeline. Flag risks and add fallback handling steps." + ), + expected_output="Risk review with concrete mitigations.", + agent=document_reviewer, +) + +crew = Crew( + agents=[document_planner, document_reviewer], + tasks=[plan_task, review_task], + verbose=True, +) + + +if __name__ == "__main__": + # Keep a direct client reference so users see where DWS calls happen. + print(f"Client configured: {bool(client)}") + print(crew.kickoff()) diff --git a/examples/src/framework_langchain.py b/examples/src/framework_langchain.py new file mode 100644 index 0000000..0e9ae63 --- /dev/null +++ b/examples/src/framework_langchain.py @@ -0,0 +1,43 @@ +import asyncio +import os + +from langchain.tools import StructuredTool +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import create_react_agent +from nutrient_dws import NutrientClient + + +client = NutrientClient(api_key=os.getenv("NUTRIENT_API_KEY", "nutr_sk_placeholder")) + + +async def redact_emails(path: str) -> str: + result = await client.create_redactions_ai( + path, + criteria="Redact all email addresses.", + redaction_state="apply", + ) + return str(result) + + +tool = StructuredTool.from_function( + func=redact_emails, + coroutine=redact_emails, + name="redact_emails", + description="Redact email addresses from a document using Nutrient DWS.", +) + + +async def main() -> None: + model = ChatOpenAI( + model="gpt-4.1-mini", + api_key=os.getenv("OPENAI_API_KEY", "sk-placeholder"), + ) + agent = create_react_agent(model, [tool]) + state = await agent.ainvoke( + {"messages": [("user", "Redact emails from ./assets/sample.pdf.")]} + ) + print(state["messages"][-1].content) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/src/framework_openai_agents.py b/examples/src/framework_openai_agents.py new file mode 100644 index 0000000..a487416 --- /dev/null +++ b/examples/src/framework_openai_agents.py @@ -0,0 +1,34 @@ +import asyncio +import os + +from agents import Agent, Runner, function_tool +from nutrient_dws import NutrientClient + + +client = NutrientClient(api_key=os.getenv("NUTRIENT_API_KEY", "nutr_sk_placeholder")) + + +@function_tool +async def extract_text(input_path: str) -> str: + """Extract text from a document using Nutrient DWS.""" + result = await client.extract_text(input_path) + return str(result) + + +assistant = Agent( + name="nutrient-openai-agents-demo", + instructions="Help users extract text and summarize document workflows.", + tools=[extract_text], +) + + +async def main() -> None: + run = await Runner.run( + assistant, + "Extract text from ./assets/sample.pdf and summarize it in three bullets.", + ) + print(run.final_output) + + +if __name__ == "__main__": + asyncio.run(main()) From ec5bd441665a82ea4831a43ebca8ac4f337f8614 Mon Sep 17 00:00:00 2001 From: Jonathan Rhyne Date: Thu, 5 Mar 2026 01:42:35 -0500 Subject: [PATCH 2/5] Avoid secret-like placeholders in framework examples --- examples/src/framework_crewai.py | 2 +- examples/src/framework_langchain.py | 2 +- examples/src/framework_openai_agents.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/src/framework_crewai.py b/examples/src/framework_crewai.py index 8d2572a..1e54bc0 100644 --- a/examples/src/framework_crewai.py +++ b/examples/src/framework_crewai.py @@ -4,7 +4,7 @@ from nutrient_dws import NutrientClient -client = NutrientClient(api_key=os.getenv("NUTRIENT_API_KEY", "nutr_sk_placeholder")) +client = NutrientClient(api_key=os.getenv("NUTRIENT_API_KEY", "api_key_placeholder")) document_planner = Agent( diff --git a/examples/src/framework_langchain.py b/examples/src/framework_langchain.py index 0e9ae63..3353a05 100644 --- a/examples/src/framework_langchain.py +++ b/examples/src/framework_langchain.py @@ -7,7 +7,7 @@ from nutrient_dws import NutrientClient -client = NutrientClient(api_key=os.getenv("NUTRIENT_API_KEY", "nutr_sk_placeholder")) +client = NutrientClient(api_key=os.getenv("NUTRIENT_API_KEY", "api_key_placeholder")) async def redact_emails(path: str) -> str: diff --git a/examples/src/framework_openai_agents.py b/examples/src/framework_openai_agents.py index a487416..229b2c0 100644 --- a/examples/src/framework_openai_agents.py +++ b/examples/src/framework_openai_agents.py @@ -5,7 +5,7 @@ from nutrient_dws import NutrientClient -client = NutrientClient(api_key=os.getenv("NUTRIENT_API_KEY", "nutr_sk_placeholder")) +client = NutrientClient(api_key=os.getenv("NUTRIENT_API_KEY", "api_key_placeholder")) @function_tool From ce739b2415fd159b4583d58dce5a1a02854f1d58 Mon Sep 17 00:00:00 2001 From: "Jonathan D. Rhyne" Date: Thu, 15 Oct 2026 23:20:56 +0200 Subject: [PATCH 3/5] fix: make framework quickstarts executable --- CHANGELOG.md | 4 + examples/README.md | 33 +++- examples/src/framework_crewai.py | 33 +++- examples/src/framework_langchain.py | 21 ++- examples/src/framework_openai_agents.py | 11 +- src/nutrient_dws/client.py | 2 +- .../types/instant_json/form_field_value.py | 2 +- tests/unit/test_framework_examples.py | 152 ++++++++++++++++++ 8 files changed, 239 insertions(+), 19 deletions(-) create mode 100644 tests/unit/test_framework_examples.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e6fcf67..9901798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Load example credentials from `.env` before client creation, attach a working DWS extraction tool to CrewAI, and keep LangChain redaction results in `output/redacted.pdf` instead of returning PDF bytes to the model. + ## [3.0.0] - 2026-01-30 ### Security diff --git a/examples/README.md b/examples/README.md index 7bfc5e7..9b6ba25 100644 --- a/examples/README.md +++ b/examples/README.md @@ -129,15 +129,34 @@ This will: These examples are intentionally minimal and focus on framework wiring: -Install optional framework dependencies first: +Set both `NUTRIENT_API_KEY` and `OPENAI_API_KEY` in `examples/.env` before running them. Each example loads `.env` and fails clearly when either key is missing. The examples use `gpt-4.1-mini` and can make billable DWS and model calls when run normally. CrewAI attaches a real text-extraction tool before planning subsequent processing. The LangChain tool saves the redacted PDF to `output/redacted.pdf` and returns only its path and byte count to the model. + +Choose **one framework per fresh virtual environment**. Their OpenAI dependency ranges can differ, so installing all three together can cause lengthy dependency backtracking. From `examples/`, follow only the option you want; use Python 3.10–3.13 for CrewAI. Activation commands below are for macOS/Linux; on Windows use the environment's `Scripts\activate`. + +OpenAI Agents: ```bash -pip install openai-agents langchain langchain-openai langgraph crewai +python -m venv example_venv_agents +source example_venv_agents/bin/activate +pip install -e '..[dev]' openai-agents +python src/framework_openai_agents.py ``` +LangChain/LangGraph, in a separate environment: + ```bash -python src/framework_openai_agents.py +python -m venv example_venv_langchain +source example_venv_langchain/bin/activate +pip install -e '..[dev]' langchain langchain-openai langgraph python src/framework_langchain.py +``` + +CrewAI, in a separate environment: + +```bash +python -m venv example_venv_crewai +source example_venv_crewai/bin/activate +pip install -e '..[dev]' crewai python src/framework_crewai.py ``` @@ -149,6 +168,14 @@ python -m py_compile src/framework_langchain.py python -m py_compile src/framework_crewai.py ``` +From the repository root, after installing the optional frameworks and development dependencies, run the offline framework smoke tests: + +```bash +python -m pytest tests/unit/test_framework_examples.py -v +``` + +These tests register and invoke the actual framework tools with a controlled Nutrient client. They block network connections and do not run an LLM or upload a document. Framework-specific cases skip if their optional dependency is absent. + ## Output All processed files will be saved to the `output/` directory. You can examine these files to see the results of the document processing operations. diff --git a/examples/src/framework_crewai.py b/examples/src/framework_crewai.py index 1e54bc0..c6b4c38 100644 --- a/examples/src/framework_crewai.py +++ b/examples/src/framework_crewai.py @@ -1,30 +1,49 @@ import os from crewai import Agent, Crew, Task +from crewai.tools import tool +from dotenv import load_dotenv + from nutrient_dws import NutrientClient +load_dotenv() +api_key = os.getenv("NUTRIENT_API_KEY") +if not api_key: + raise SystemExit("NUTRIENT_API_KEY is required in your environment or .env file.") +if not os.getenv("OPENAI_API_KEY"): + raise SystemExit("OPENAI_API_KEY is required in your environment or .env file.") + +client = NutrientClient(api_key=api_key) + -client = NutrientClient(api_key=os.getenv("NUTRIENT_API_KEY", "api_key_placeholder")) +@tool("extract_document_text") +async def extract_document_text(input_path: str) -> str: + """Extract document text with Nutrient DWS before planning further processing.""" + result = await client.extract_text(input_path) + return str(result) document_planner = Agent( role="Document Workflow Planner", goal="Design a robust Nutrient DWS processing pipeline for incoming files.", - backstory="You optimize OCR, extraction, and redaction workflows for accuracy." + backstory="You optimize OCR, extraction, and redaction workflows for accuracy.", + tools=[extract_document_text], + llm="gpt-4.1-mini", ) document_reviewer = Agent( role="Document QA Reviewer", goal="Review proposed workflow plans for failure modes and compliance risks.", - backstory="You specialize in validating document automation quality." + backstory="You specialize in validating document automation quality.", + llm="gpt-4.1-mini", ) plan_task = Task( description=( - "Design a pipeline for ./examples/assets/sample.pdf that extracts text, " - "redacts emails, and exports a cleaned PDF." + "Use your extraction tool on ./assets/sample.pdf, then design a pipeline " + "that redacts emails and exports a cleaned PDF." ), - expected_output="A numbered execution plan with tool calls.", + expected_output="Extracted text and a numbered plan for the remaining processing.", agent=document_planner, ) @@ -44,6 +63,4 @@ if __name__ == "__main__": - # Keep a direct client reference so users see where DWS calls happen. - print(f"Client configured: {bool(client)}") print(crew.kickoff()) diff --git a/examples/src/framework_langchain.py b/examples/src/framework_langchain.py index 3353a05..46f7970 100644 --- a/examples/src/framework_langchain.py +++ b/examples/src/framework_langchain.py @@ -1,13 +1,22 @@ import asyncio import os +from pathlib import Path -from langchain.tools import StructuredTool +from dotenv import load_dotenv +from langchain_core.tools import StructuredTool from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent + from nutrient_dws import NutrientClient +load_dotenv() +api_key = os.getenv("NUTRIENT_API_KEY") +if not api_key: + raise SystemExit("NUTRIENT_API_KEY is required in your environment or .env file.") +if not os.getenv("OPENAI_API_KEY"): + raise SystemExit("OPENAI_API_KEY is required in your environment or .env file.") -client = NutrientClient(api_key=os.getenv("NUTRIENT_API_KEY", "api_key_placeholder")) +client = NutrientClient(api_key=api_key) async def redact_emails(path: str) -> str: @@ -16,11 +25,13 @@ async def redact_emails(path: str) -> str: criteria="Redact all email addresses.", redaction_state="apply", ) - return str(result) + output_path = Path("output") / "redacted.pdf" + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(result["buffer"]) + return f"Saved redacted PDF to {output_path} ({len(result['buffer'])} bytes)." tool = StructuredTool.from_function( - func=redact_emails, coroutine=redact_emails, name="redact_emails", description="Redact email addresses from a document using Nutrient DWS.", @@ -30,7 +41,7 @@ async def redact_emails(path: str) -> str: async def main() -> None: model = ChatOpenAI( model="gpt-4.1-mini", - api_key=os.getenv("OPENAI_API_KEY", "sk-placeholder"), + api_key=os.environ["OPENAI_API_KEY"], ) agent = create_react_agent(model, [tool]) state = await agent.ainvoke( diff --git a/examples/src/framework_openai_agents.py b/examples/src/framework_openai_agents.py index 229b2c0..5819e8e 100644 --- a/examples/src/framework_openai_agents.py +++ b/examples/src/framework_openai_agents.py @@ -2,10 +2,18 @@ import os from agents import Agent, Runner, function_tool +from dotenv import load_dotenv + from nutrient_dws import NutrientClient +load_dotenv() +api_key = os.getenv("NUTRIENT_API_KEY") +if not api_key: + raise SystemExit("NUTRIENT_API_KEY is required in your environment or .env file.") +if not os.getenv("OPENAI_API_KEY"): + raise SystemExit("OPENAI_API_KEY is required in your environment or .env file.") -client = NutrientClient(api_key=os.getenv("NUTRIENT_API_KEY", "api_key_placeholder")) +client = NutrientClient(api_key=api_key) @function_tool @@ -17,6 +25,7 @@ async def extract_text(input_path: str) -> str: assistant = Agent( name="nutrient-openai-agents-demo", + model="gpt-4.1-mini", instructions="Help users extract text and summarize document workflows.", tools=[extract_text], ) diff --git a/src/nutrient_dws/client.py b/src/nutrient_dws/client.py index 670878b..002deab 100644 --- a/src/nutrient_dws/client.py +++ b/src/nutrient_dws/client.py @@ -550,7 +550,7 @@ async def convert( result = await builder.output_markdown().execute() elif target_format in ["png", "jpeg", "jpg", "webp"]: result = await builder.output_image( - cast("Literal['png', 'jpeg', 'jpg', 'webp']", target_format), + target_format, {"dpi": 300}, ).execute() else: diff --git a/src/nutrient_dws/types/instant_json/form_field_value.py b/src/nutrient_dws/types/instant_json/form_field_value.py index 29e90f0..4114291 100644 --- a/src/nutrient_dws/types/instant_json/form_field_value.py +++ b/src/nutrient_dws/types/instant_json/form_field_value.py @@ -5,7 +5,7 @@ class FormFieldValue(TypedDict): name: str - value: NotRequired[str | None | list[str]] + value: NotRequired[str | list[str] | None] type: Literal["pspdfkit/form-field-value"] v: Literal[1] optionIndexes: NotRequired[list[int]] diff --git a/tests/unit/test_framework_examples.py b/tests/unit/test_framework_examples.py new file mode 100644 index 0000000..cd9c04a --- /dev/null +++ b/tests/unit/test_framework_examples.py @@ -0,0 +1,152 @@ +"""Optional-framework smoke tests: real tools, controlled DWS I/O, no model runs.""" + +import asyncio +import importlib.util +import json +import socket +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import dotenv.main +import pytest + +import nutrient_dws + +EXAMPLES = Path(__file__).resolve().parents[2] / "examples" / "src" +FRAMEWORKS = { + "openai_agents": ("agents",), + "langchain": ("langchain_core", "langchain_openai", "langgraph"), + "crewai": ("crewai",), +} + + +@pytest.fixture +def example_environment(monkeypatch, tmp_path): + for key in ("NUTRIENT_API_KEY", "OPENAI_API_KEY"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("OPENAI_AGENTS_DISABLE_TRACING", "1") + monkeypatch.setenv("OTEL_SDK_DISABLED", "true") + monkeypatch.setenv("CREWAI_TELEMETRY_DISABLED", "true") + monkeypatch.setenv("CREWAI_STORAGE_DIR", str(tmp_path / "crewai")) + if importlib.util.find_spec("crewai_core") is not None: + from crewai_core.token_manager import TokenManager + + token_directory = tmp_path / "crewai-tokens" + token_directory.mkdir(mode=0o700) + monkeypatch.setattr( + TokenManager, + "_get_secure_storage_path", + staticmethod(lambda: token_directory), + ) + monkeypatch.setattr( + dotenv.main, "find_dotenv", lambda *args, **kwargs: str(tmp_path / ".env") + ) + monkeypatch.chdir(tmp_path) + client = SimpleNamespace( + extract_text=AsyncMock( + return_value={"data": {"pages": [{"plainText": "Owned fixture text"}]}} + ), + create_redactions_ai=AsyncMock( + return_value={ + "buffer": b"%PDF-owned-fixture", + "mimeType": "application/pdf", + "filename": "../../untrusted.pdf", + } + ), + ) + constructor = Mock(return_value=client) + monkeypatch.setattr(nutrient_dws, "NutrientClient", constructor) + + def reject_network(*args, **kwargs): + raise AssertionError("Framework smoke tests must not use the network") + + monkeypatch.setattr(socket.socket, "connect", reject_network) + return client, constructor + + +def load_example(framework): + for dependency in FRAMEWORKS[framework]: + pytest.importorskip(dependency) + spec = importlib.util.spec_from_file_location( + f"framework_{framework}", EXAMPLES / f"framework_{framework}.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize("framework", FRAMEWORKS) +@pytest.mark.parametrize("missing_key", ["NUTRIENT_API_KEY", "OPENAI_API_KEY"]) +def test_missing_keys_fail_before_client_construction( + framework, missing_key, example_environment, monkeypatch +): + _, constructor = example_environment + other_key = ( + "OPENAI_API_KEY" if missing_key == "NUTRIENT_API_KEY" else "NUTRIENT_API_KEY" + ) + monkeypatch.setenv(other_key, "owned-fixture-key") + with pytest.raises(SystemExit, match=missing_key): + load_example(framework) + constructor.assert_not_called() + + +@pytest.mark.parametrize("framework", FRAMEWORKS) +def test_dotenv_loaded_before_client_construction( + framework, example_environment, tmp_path +): + _, constructor = example_environment + (tmp_path / ".env").write_text( + "NUTRIENT_API_KEY=owned-dws-key\nOPENAI_API_KEY=owned-openai-key\n" + ) + load_example(framework) + constructor.assert_called_once_with(api_key="owned-dws-key") + + +@pytest.mark.parametrize("framework", FRAMEWORKS) +async def test_registered_framework_tools_call_dws( + framework, example_environment, monkeypatch, tmp_path +): + client, _ = example_environment + monkeypatch.setenv("NUTRIENT_API_KEY", "owned-dws-key") + monkeypatch.setenv("OPENAI_API_KEY", "owned-openai-key") + module = load_example(framework) + if framework == "openai_agents": + from agents.tool_context import ToolContext + + registered = module.assistant.tools[0] + arguments = json.dumps({"input_path": "./assets/sample.pdf"}) + context = ToolContext( + context=None, + tool_name=registered.name, + tool_call_id="owned-fixture-call", + tool_arguments=arguments, + ) + result = await registered.on_invoke_tool(context, arguments) + client.extract_text.assert_awaited_once_with("./assets/sample.pdf") + assert "Owned fixture text" in result + elif framework == "langchain": + result = await module.tool.ainvoke({"path": "./assets/sample.pdf"}) + client.create_redactions_ai.assert_awaited_once_with( + "./assets/sample.pdf", + criteria="Redact all email addresses.", + redaction_state="apply", + ) + assert ( + tmp_path / "output" / "redacted.pdf" + ).read_bytes() == b"%PDF-owned-fixture" + assert "redacted.pdf" in result + assert "%PDF" not in result + assert len(result) < 500 + assert not (tmp_path.parent / "untrusted.pdf").exists() + else: + registered = module.document_planner.tools[0] + result = await asyncio.to_thread( + registered.run, input_path="./assets/sample.pdf" + ) + assert isinstance(result, str), ( + "CrewAI must resolve the async DWS call, not return a coroutine" + ) + client.extract_text.assert_awaited_once_with("./assets/sample.pdf") + assert "Owned fixture text" in result + assert "./examples/assets/" not in module.plan_task.description From cb23f1e070cecb66903259eff98d26680e890929 Mon Sep 17 00:00:00 2001 From: "Jonathan D. Rhyne" Date: Fri, 16 Oct 2026 00:55:27 +0200 Subject: [PATCH 4/5] ci: fix secret conditions and Windows smoke test sockets --- .../workflows/scheduled-integration-tests.yml | 6 +- tests/unit/test_framework_examples.py | 73 ++++++++++++++++++- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/.github/workflows/scheduled-integration-tests.yml b/.github/workflows/scheduled-integration-tests.yml index 49a9725..02a6a32 100644 --- a/.github/workflows/scheduled-integration-tests.yml +++ b/.github/workflows/scheduled-integration-tests.yml @@ -9,6 +9,8 @@ on: jobs: scheduled-integration-tests: runs-on: ubuntu-latest + env: + HAS_NUTRIENT_API_KEY: ${{ secrets.NUTRIENT_API_KEY != '' }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -24,7 +26,7 @@ jobs: pip install ".[dev]" - name: Run all integration tests - if: secrets.NUTRIENT_API_KEY != '' + if: env.HAS_NUTRIENT_API_KEY == 'true' env: NUTRIENT_API_KEY: ${{ secrets.NUTRIENT_API_KEY }} run: | @@ -35,7 +37,7 @@ jobs: id: test-run - name: Skip scheduled tests (no API key) - if: secrets.NUTRIENT_API_KEY == '' + if: env.HAS_NUTRIENT_API_KEY != 'true' run: | echo "⏭️ Skipping scheduled integration tests - NUTRIENT_API_KEY not available" echo "Configure NUTRIENT_API_KEY secret to enable scheduled API validation" diff --git a/tests/unit/test_framework_examples.py b/tests/unit/test_framework_examples.py index cd9c04a..b944d48 100644 --- a/tests/unit/test_framework_examples.py +++ b/tests/unit/test_framework_examples.py @@ -4,6 +4,7 @@ import importlib.util import json import socket +from ipaddress import ip_address from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -21,6 +22,30 @@ } +def is_local_socket_address(family, address): + if family == getattr(socket, "AF_UNIX", -1): + return True + if family not in (socket.AF_INET, socket.AF_INET6) or not isinstance( + address, tuple + ): + return False + try: + return ip_address(address[0]).is_loopback + except (IndexError, TypeError, ValueError): + return False + + +def local_only_connect(original_connect): + def guarded_connect(connection, address): + if not is_local_socket_address(connection.family, address): + raise AssertionError( + "Framework smoke tests must not use nonlocal connections" + ) + return original_connect(connection, address) + + return guarded_connect + + @pytest.fixture def example_environment(monkeypatch, tmp_path): for key in ("NUTRIENT_API_KEY", "OPENAI_API_KEY"): @@ -58,13 +83,53 @@ def example_environment(monkeypatch, tmp_path): constructor = Mock(return_value=client) monkeypatch.setattr(nutrient_dws, "NutrientClient", constructor) - def reject_network(*args, **kwargs): - raise AssertionError("Framework smoke tests must not use the network") - - monkeypatch.setattr(socket.socket, "connect", reject_network) + monkeypatch.setattr( + socket.socket, "connect", local_only_connect(socket.socket.connect) + ) return client, constructor +def test_network_guard_allows_socketpair(example_environment): + left, right = socket.socketpair() + with left, right: + left.settimeout(2) + right.settimeout(2) + left.sendall(b"owned socketpair") + assert right.recv(32) == b"owned socketpair" + + +def test_network_guard_allows_numeric_loopback_pair(example_environment): + # Exercise the TCP connection used by Windows' socketpair fallback on every OS. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.settimeout(2) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client: + client.settimeout(2) + client.connect(listener.getsockname()) + peer, _ = listener.accept() + with peer: + peer.settimeout(2) + client.sendall(b"owned loopback") + assert peer.recv(32) == b"owned loopback" + + +@pytest.mark.parametrize( + ("family", "address"), + [ + (socket.AF_INET, ("192.0.2.1", 443)), + (socket.AF_INET, ("provider.example", 443)), + (socket.AF_INET6, ("2001:db8::1", 443, 0, 0)), + ], +) +def test_network_guard_rejects_nonlocal_without_connecting(family, address): + original_connect = Mock() + guarded_connect = local_only_connect(original_connect) + with pytest.raises(AssertionError, match="nonlocal"): + guarded_connect(SimpleNamespace(family=family), address) + original_connect.assert_not_called() + + def load_example(framework): for dependency in FRAMEWORKS[framework]: pytest.importorskip(dependency) From 62f25ff95ff2c68210ac6433e933771dc598242b Mon Sep 17 00:00:00 2001 From: "Jonathan D. Rhyne" Date: Thu, 22 Oct 2026 11:20:03 -0400 Subject: [PATCH 5/5] docs(examples): use gpt-6-luna in framework quickstarts --- examples/README.md | 2 +- examples/src/framework_crewai.py | 4 ++-- examples/src/framework_langchain.py | 2 +- examples/src/framework_openai_agents.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/README.md b/examples/README.md index 9b6ba25..6922b70 100644 --- a/examples/README.md +++ b/examples/README.md @@ -129,7 +129,7 @@ This will: These examples are intentionally minimal and focus on framework wiring: -Set both `NUTRIENT_API_KEY` and `OPENAI_API_KEY` in `examples/.env` before running them. Each example loads `.env` and fails clearly when either key is missing. The examples use `gpt-4.1-mini` and can make billable DWS and model calls when run normally. CrewAI attaches a real text-extraction tool before planning subsequent processing. The LangChain tool saves the redacted PDF to `output/redacted.pdf` and returns only its path and byte count to the model. +Set both `NUTRIENT_API_KEY` and `OPENAI_API_KEY` in `examples/.env` before running them. Each example loads `.env` and fails clearly when either key is missing. The examples use `gpt-6-luna` and can make billable DWS and model calls when run normally. CrewAI attaches a real text-extraction tool before planning subsequent processing. The LangChain tool saves the redacted PDF to `output/redacted.pdf` and returns only its path and byte count to the model. Choose **one framework per fresh virtual environment**. Their OpenAI dependency ranges can differ, so installing all three together can cause lengthy dependency backtracking. From `examples/`, follow only the option you want; use Python 3.10–3.13 for CrewAI. Activation commands below are for macOS/Linux; on Windows use the environment's `Scripts\activate`. diff --git a/examples/src/framework_crewai.py b/examples/src/framework_crewai.py index c6b4c38..0a0395b 100644 --- a/examples/src/framework_crewai.py +++ b/examples/src/framework_crewai.py @@ -28,14 +28,14 @@ async def extract_document_text(input_path: str) -> str: goal="Design a robust Nutrient DWS processing pipeline for incoming files.", backstory="You optimize OCR, extraction, and redaction workflows for accuracy.", tools=[extract_document_text], - llm="gpt-4.1-mini", + llm="gpt-6-luna", ) document_reviewer = Agent( role="Document QA Reviewer", goal="Review proposed workflow plans for failure modes and compliance risks.", backstory="You specialize in validating document automation quality.", - llm="gpt-4.1-mini", + llm="gpt-6-luna", ) plan_task = Task( diff --git a/examples/src/framework_langchain.py b/examples/src/framework_langchain.py index 46f7970..ccd3a1c 100644 --- a/examples/src/framework_langchain.py +++ b/examples/src/framework_langchain.py @@ -40,7 +40,7 @@ async def redact_emails(path: str) -> str: async def main() -> None: model = ChatOpenAI( - model="gpt-4.1-mini", + model="gpt-6-luna", api_key=os.environ["OPENAI_API_KEY"], ) agent = create_react_agent(model, [tool]) diff --git a/examples/src/framework_openai_agents.py b/examples/src/framework_openai_agents.py index 5819e8e..776e745 100644 --- a/examples/src/framework_openai_agents.py +++ b/examples/src/framework_openai_agents.py @@ -25,7 +25,7 @@ async def extract_text(input_path: str) -> str: assistant = Agent( name="nutrient-openai-agents-demo", - model="gpt-4.1-mini", + model="gpt-6-luna", instructions="Help users extract text and summarize document workflows.", tools=[extract_text], )