From e26d09701c1adfb521136c52460de5b833235cf8 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Sun, 5 Apr 2026 12:00:27 +0200 Subject: [PATCH 01/54] Add unit tests for LLM message handling and batch completion routing --- tests/test_llms_unit.py | 80 +++++++++++++++++++++++++++++++ tests/test_runner_llm_messages.py | 47 ++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 tests/test_llms_unit.py create mode 100644 tests/test_runner_llm_messages.py diff --git a/tests/test_llms_unit.py b/tests/test_llms_unit.py new file mode 100644 index 0000000..a6e5674 --- /dev/null +++ b/tests/test_llms_unit.py @@ -0,0 +1,80 @@ +import datafast.llms as llms_module +from datafast.llms import OpenRouterProvider + + +class _DummyMessage: + def __init__(self, content: str) -> None: + self.content = content + + +class _DummyChoice: + def __init__(self, content: str) -> None: + self.message = _DummyMessage(content) + + +class _DummyResponse: + def __init__(self, content: str) -> None: + self.choices = [_DummyChoice(content)] + + +def test_openrouter_single_messages_use_completion(monkeypatch): + monkeypatch.setattr(llms_module, "load_env_once", lambda: None) + monkeypatch.setattr( + llms_module, + "maybe_configure_langfuse_tracing", + lambda load_env=False: False, + ) + + calls = {"completion": 0, "batch_completion": 0} + + def fake_completion(**kwargs): + calls["completion"] += 1 + assert kwargs["messages"] == [{"role": "user", "content": "ping"}] + return _DummyResponse("ok") + + def fake_batch_completion(**kwargs): + calls["batch_completion"] += 1 + raise AssertionError("single-message requests should not use batch_completion") + + monkeypatch.setattr(llms_module.litellm, "completion", fake_completion) + monkeypatch.setattr(llms_module.litellm, "batch_completion", fake_batch_completion) + + provider = OpenRouterProvider(model_id="demo-model", api_key="test-key") + + response = provider.generate(messages=[{"role": "user", "content": "ping"}]) + + assert response == "ok" + assert calls == {"completion": 1, "batch_completion": 0} + + +def test_openrouter_batch_messages_use_batch_completion(monkeypatch): + monkeypatch.setattr(llms_module, "load_env_once", lambda: None) + monkeypatch.setattr( + llms_module, + "maybe_configure_langfuse_tracing", + lambda load_env=False: False, + ) + + calls = {"completion": 0, "batch_completion": 0} + + def fake_completion(**kwargs): + calls["completion"] += 1 + raise AssertionError("batched requests should not use completion") + + def fake_batch_completion(**kwargs): + calls["batch_completion"] += 1 + assert len(kwargs["messages"]) == 2 + return [_DummyResponse("first"), _DummyResponse("second")] + + monkeypatch.setattr(llms_module.litellm, "completion", fake_completion) + monkeypatch.setattr(llms_module.litellm, "batch_completion", fake_batch_completion) + + provider = OpenRouterProvider(model_id="demo-model", api_key="test-key") + + response = provider.generate(messages=[ + [{"role": "user", "content": "one"}], + [{"role": "user", "content": "two"}], + ]) + + assert response == ["first", "second"] + assert calls == {"completion": 0, "batch_completion": 1} diff --git a/tests/test_runner_llm_messages.py b/tests/test_runner_llm_messages.py new file mode 100644 index 0000000..d870093 --- /dev/null +++ b/tests/test_runner_llm_messages.py @@ -0,0 +1,47 @@ +from datafast import LLMStep, ListSink, Source + + +def test_runner_passes_llm_messages_by_keyword(): + class FakeModel: + provider_name = "fake" + model_id = "fake-model" + + def __init__(self) -> None: + self.calls: list[dict] = [] + + def generate( + self, + prompt=None, + messages=None, + metadata=None, + response_format=None, + ): + self.calls.append({ + "prompt": prompt, + "messages": messages, + "metadata": metadata, + }) + return "done" + + model = FakeModel() + sink = ListSink() + + pipeline = ( + Source.list([{"topic": "robotics"}]) + >> LLMStep( + prompt="Write one short line about {topic}.", + input_columns=["topic"], + output_column="result", + model=model, + ).as_step("generate_copy") + >> sink + ) + + output = pipeline.run() + + assert output == [{"topic": "robotics", "result": "done", "_model": "fake-model"}] + assert len(model.calls) == 1 + assert model.calls[0]["prompt"] is None + assert model.calls[0]["messages"] == [ + {"role": "user", "content": "Write one short line about robotics."} + ] From 2aa9ebf1e21672582d33d66f7ae4700a2c931714 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Sun, 5 Apr 2026 12:00:36 +0200 Subject: [PATCH 02/54] Remove openspec/ from gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 5f96167..3c8264e 100644 --- a/.gitignore +++ b/.gitignore @@ -186,5 +186,4 @@ secrets.env examples/checkpoints/ examples/outputs/ -.codex/ -openspec/ \ No newline at end of file +.codex/ \ No newline at end of file From 45c83befa8c4ae04623270398417e4b77d2e7858 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Sun, 5 Apr 2026 12:01:23 +0200 Subject: [PATCH 03/54] Fix OpenRouter batch completion routing and add persona generation cookbook documentation --- datafast/core/runner.py | 2 +- datafast/llms.py | 19 +++++--- docs/cookbook/assets/index.md | 30 ++++++++++++ docs/cookbook/index.md | 14 ++++++ docs/cookbook/persona_generation.md | 74 +++++++++++++++++++++++++++++ mkdocs.yml | 3 ++ 6 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 docs/cookbook/assets/index.md create mode 100644 docs/cookbook/index.md create mode 100644 docs/cookbook/persona_generation.md diff --git a/datafast/core/runner.py b/datafast/core/runner.py index 0a28ba2..3497605 100644 --- a/datafast/core/runner.py +++ b/datafast/core/runner.py @@ -233,7 +233,7 @@ def _execute_llm_step( try: result = model.generate( - call.messages, + messages=call.messages, metadata=build_trace_metadata( model=model, component="pipeline.step", diff --git a/datafast/llms.py b/datafast/llms.py index 092346a..754e8b8 100644 --- a/datafast/llms.py +++ b/datafast/llms.py @@ -18,7 +18,6 @@ # LiteLLM import litellm from litellm.exceptions import RateLimitError -from litellm.utils import ModelResponse # Internal imports from .llm_utils import get_messages @@ -292,17 +291,23 @@ def generate( if response_format is not None: completion_params["response_format"] = response_format - # Call LiteLLM completion with batch messages - retry on rate limit + # Call LiteLLM completion with retry on rate limit. + # OpenRouter accepts single message requests via completion(), but + # rejects the same payload when wrapped in batch_completion(). max_retries = 3 retry_delay = 5 # Start with 5 seconds response = None - + for attempt in range(max_retries): try: - response: list[ModelResponse] = litellm.batch_completion( - **completion_params) + if len(batch_to_send) == 1: + response = [litellm.completion( + **{**completion_params, "messages": batch_to_send[0]} + )] + else: + response = litellm.batch_completion(**completion_params) break # Success, exit retry loop - except RateLimitError as e: + except RateLimitError: if attempt < max_retries - 1: wait_time = retry_delay * (2 ** attempt) # Exponential backoff logger.warning( @@ -316,7 +321,7 @@ def generate( f"Provider: {self.provider_name} | Model: {self.model_id}" ) raise - + if response is None: raise RuntimeError("Failed to get response after retries") diff --git a/docs/cookbook/assets/index.md b/docs/cookbook/assets/index.md new file mode 100644 index 0000000..9063979 --- /dev/null +++ b/docs/cookbook/assets/index.md @@ -0,0 +1,30 @@ +# Persona Cookbook Assets + +This note records the supporting assets used by the persona-generation cookbook. + +## Dataset Selection + +- Dataset: `xsum` +- Split: `validation` +- Text field: `document` +- Summary field kept for inspection: `summary` +- Selection rule: keep documents whose whitespace-tokenized word counts are between `300` and `500` +- Cap: use the first `5` matching records + +This keeps the cookbook deterministic and small while still using a well-known Hugging Face corpus with article lengths that fit the demonstration. + +`GEM/xsum` was the original candidate, but the current `datasets` stack in this repo no longer supports dataset-script based loading for that asset. The script therefore uses the scriptless `xsum` dataset, which exposes the same `document` and summary-style fields needed for the cookbook. + +## Prompt Assets + +| Asset | Provenance | Purpose | +| --- | --- | --- | +| [text_to_persona.txt](text_to_persona.txt) | `paper-aligned` | Infer one specific persona from a source text | +| [persona_to_persona.txt](persona_to_persona.txt) | `paper-aligned` | Expand a persona through one close relationship | +| [persona_to_user_prompt.txt](persona_to_user_prompt.txt) | `repository-derived` | Generate a representative user prompt from a persona | + +## Provenance Notes + +- The Persona Hub paper describes `Text-to-Persona` and `Persona-to-Persona`, but it explicitly says the prompts shown in figures are simplified rather than the exact experiment strings. +- The `persona_to_user_prompt` asset is derived from the repository prompt family for instruction generation and adapted to return JSON fields that fit DataFast. +- The cookbook does not reuse Persona Hub code. It reimplements the workflow with DataFast primitives. diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md new file mode 100644 index 0000000..03fc75b --- /dev/null +++ b/docs/cookbook/index.md @@ -0,0 +1,14 @@ +# Cookbook + +Cookbooks connect a runnable script to a documentation walkthrough. + +The Python script is the source of truth. Each cookbook page explains: + +- where the executable example lives +- what inputs it uses +- which prompt assets it depends on +- where it writes its output artifacts + +## Available Cookbooks + +- [Persona Generation](persona_generation.md): infer personas from real source texts, expand them through relationships, and generate representative user prompts with DataFast. diff --git a/docs/cookbook/persona_generation.md b/docs/cookbook/persona_generation.md new file mode 100644 index 0000000..b35e00a --- /dev/null +++ b/docs/cookbook/persona_generation.md @@ -0,0 +1,74 @@ +# Persona Generation + +This cookbook shows how to implement a Persona Hub-inspired workflow with DataFast without reusing Persona Hub code. + +## Runnable Source + +- Script: `examples/scripts/43_cookbook_persona_generation.py` +- Prompt assets: [asset index](assets/index.md) +- Output artifact: `examples/outputs/43_persona_cookbook.jsonl` + +## What The Script Does + +The pipeline is intentionally small: + +1. Load `xsum` articles from the `validation` split. +2. Keep only the first `5` documents whose word counts fall between `300` and `500`. +3. Infer one likely persona from each article with a `Text-to-Persona` prompt. +4. Expand that persona into a closely related persona with a `Persona-to-Persona` prompt. +5. Generate one representative user prompt for the related persona. + +```text +GEM/xsum article + | + v +Text-to-Persona + | + v +Persona-to-Persona + | + v +Representative user prompt +``` + +## Run + +Prerequisites: + +- `OPENROUTER_API_KEY` is set +- the project environment has the base dependencies from `pyproject.toml` +- the script uses OpenRouter model `nvidia/nemotron-3-super-120b-a12b` + +Example: + +```bash +.venv/bin/python examples/scripts/43_cookbook_persona_generation.py +``` + +## Prompt Summary + +The cookbook keeps full prompts in asset files rather than embedding them here. + +- [Text-to-Persona prompt](assets/text_to_persona.txt): a paper-aligned adaptation that infers one specific persona from a source text. +- [Persona-to-Persona prompt](assets/persona_to_persona.txt): a paper-aligned adaptation that expands a persona through one close relationship. +- [Persona-to-User-Prompt prompt](assets/persona_to_user_prompt.txt): a repository-derived prompt that asks for one realistic user request from the generated persona. + +## Research Basis + +The Persona Hub paper introduces `Text-to-Persona` and `Persona-to-Persona` as scalable persona-construction methods from web text. It also states that the prompts shown in the paper figures are simplified rather than the exact strings used in experiments, so this cookbook treats those persona-construction prompts as paper-aligned adaptations rather than verbatim reproductions. + +For downstream prompt generation, the repository publishes a prompt family for persona-conditioned instruction generation. This cookbook adapts that idea to a DataFast JSON workflow and keeps the full asset path visible in the [asset index](assets/index.md). + +## Output Shape + +The JSONL output keeps the fields that matter for inspection: + +- `summary` +- `document` +- `word_count` +- `persona` +- `persona_basis` +- `relationship_type` +- `related_persona` +- `user_prompt` +- `prompt_basis` diff --git a/mkdocs.yml b/mkdocs.yml index 87e795a..d9b4d6a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,9 @@ nav: - LLM Steps: guides/llm_steps.md - Checkpointing: guides/checkpointing.md - Langfuse Tracing: guides/langfuse_tracing.md + - Cookbook: + - cookbook/index.md + - Persona Generation: cookbook/persona_generation.md - Providers: llms.md - Models: models.md - API: api.md From 69c1638937a9e3e626471bfd6bf01b8e06eb4e2c Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Sun, 5 Apr 2026 12:01:35 +0200 Subject: [PATCH 04/54] adding prompts --- docs/cookbook/assets/persona_to_persona.txt | 11 +++++++++++ docs/cookbook/assets/persona_to_user_prompt.txt | 11 +++++++++++ docs/cookbook/assets/text_to_persona.txt | 11 +++++++++++ 3 files changed, 33 insertions(+) create mode 100644 docs/cookbook/assets/persona_to_persona.txt create mode 100644 docs/cookbook/assets/persona_to_user_prompt.txt create mode 100644 docs/cookbook/assets/text_to_persona.txt diff --git a/docs/cookbook/assets/persona_to_persona.txt b/docs/cookbook/assets/persona_to_persona.txt new file mode 100644 index 0000000..ace5c54 --- /dev/null +++ b/docs/cookbook/assets/persona_to_persona.txt @@ -0,0 +1,11 @@ +Given the following persona, infer one other specific persona who is in a close relationship with them. + +Persona: +{persona} + +Requirements: +1. Use one clear relationship such as patient-caregiver, coworker, family member, teacher-student, or client-service provider. +2. Choose a related persona that adds a meaningfully different perspective. +3. Keep the related persona realistic and specific. +4. Return only one relationship expansion. + diff --git a/docs/cookbook/assets/persona_to_user_prompt.txt b/docs/cookbook/assets/persona_to_user_prompt.txt new file mode 100644 index 0000000..260ed53 --- /dev/null +++ b/docs/cookbook/assets/persona_to_user_prompt.txt @@ -0,0 +1,11 @@ +Guess a prompt that the following persona may ask an LLM to do. + +Persona: +{related_persona} + +Requirements: +1. The user prompt should be informative and specific. +2. The request should sound like something this persona would genuinely ask. +3. Keep it to a single prompt, not a conversation. +4. Do not mention that the persona was inferred from another text. + diff --git a/docs/cookbook/assets/text_to_persona.txt b/docs/cookbook/assets/text_to_persona.txt new file mode 100644 index 0000000..d71d2d7 --- /dev/null +++ b/docs/cookbook/assets/text_to_persona.txt @@ -0,0 +1,11 @@ +Infer one specific persona who is likely to read, write, or strongly engage with the following source text. + +Source text: +{document} + +Requirements: +1. Return a single persona, not a group. +2. Make the persona specific and fine-grained rather than generic. +3. Ground the persona in signals from the text such as domain, expertise, context, or likely motivation. +4. Do not quote the source text in the persona field. + From d42d8be22ea97fef3bbc37951cf9bc668c2cdc2a Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Sun, 5 Apr 2026 12:01:53 +0200 Subject: [PATCH 05/54] Adding openspec artifacts --- .../scripts/43_cookbook_persona_generation.py | 82 +++++++++++ .../add-persona-cookbook/.openspec.yaml | 2 + .../changes/add-persona-cookbook/design.md | 139 ++++++++++++++++++ .../changes/add-persona-cookbook/proposal.md | 28 ++++ .../specs/docs-cookbook/spec.md | 15 ++ .../specs/persona-generation-cookbook/spec.md | 29 ++++ .../changes/add-persona-cookbook/tasks.md | 19 +++ openspec/config.yaml | 20 +++ 8 files changed, 334 insertions(+) create mode 100644 examples/scripts/43_cookbook_persona_generation.py create mode 100644 openspec/changes/add-persona-cookbook/.openspec.yaml create mode 100644 openspec/changes/add-persona-cookbook/design.md create mode 100644 openspec/changes/add-persona-cookbook/proposal.md create mode 100644 openspec/changes/add-persona-cookbook/specs/docs-cookbook/spec.md create mode 100644 openspec/changes/add-persona-cookbook/specs/persona-generation-cookbook/spec.md create mode 100644 openspec/changes/add-persona-cookbook/tasks.md create mode 100644 openspec/config.yaml diff --git a/examples/scripts/43_cookbook_persona_generation.py b/examples/scripts/43_cookbook_persona_generation.py new file mode 100644 index 0000000..9f381d7 --- /dev/null +++ b/examples/scripts/43_cookbook_persona_generation.py @@ -0,0 +1,82 @@ +"""Persona-generation cookbook: XSum article -> personas -> user prompts. + +Demonstrates: Source.huggingface, Map, Filter, Sample, JSON-mode LLMSteps, +and prompt assets stored under docs/cookbook/assets. + +Requires: +- OPENROUTER_API_KEY +- network access to Hugging Face and OpenRouter +""" + +from datafast import Filter, LLMStep, Map, Sample, Sink, Source, openrouter + +import litellm + +litellm.suppress_debug_info = True + + +MODEL_ID = "nvidia/nemotron-3-super-120b-a12b" +OUTPUT_PATH = "examples/outputs/43_persona_cookbook.jsonl" +TEXT_TO_PERSONA_PROMPT = "docs/cookbook/assets/text_to_persona.txt" +PERSONA_TO_PERSONA_PROMPT = "docs/cookbook/assets/persona_to_persona.txt" +PERSONA_TO_USER_PROMPT = "docs/cookbook/assets/persona_to_user_prompt.txt" + + +def add_word_count(record: dict) -> dict: + return {**record, "word_count": len(record["document"].split())} + + +def keep_output_fields(record: dict) -> dict: + return { + "summary": record["summary"], + "document": record["document"], + "word_count": record["word_count"], + "persona": record["persona"], + "persona_basis": record["persona_basis"], + "relationship_type": record["relationship_type"], + "related_persona": record["related_persona"], + "user_prompt": record["user_prompt"], + "prompt_basis": record["prompt_basis"], + } + + +model = openrouter(MODEL_ID, temperature=0.7) + +pipeline = ( + Source.huggingface( + "xsum", + split="validation", + columns=["document", "summary"], + ) + >> Map(add_word_count).as_step("add_word_count") + >> Filter(fn=lambda r: 300 <= r["word_count"] <= 500).as_step("filter_word_count") + >> Sample(n=5, strategy="first").as_step("take_first_five") + >> LLMStep( + prompt=TEXT_TO_PERSONA_PROMPT, + input_columns=["document"], + output_columns=["persona", "persona_basis"], + model=model, + parse_mode="json", + on_parse_error="raise", + ).as_step("text_to_persona") + >> LLMStep( + prompt=PERSONA_TO_PERSONA_PROMPT, + input_columns=["persona"], + output_columns=["relationship_type", "related_persona"], + model=model, + parse_mode="json", + on_parse_error="raise", + ).as_step("persona_to_persona") + >> LLMStep( + prompt=PERSONA_TO_USER_PROMPT, + input_columns=["related_persona"], + output_columns=["user_prompt", "prompt_basis"], + model=model, + parse_mode="json", + on_parse_error="raise", + ).as_step("persona_to_user_prompt") + >> Map(keep_output_fields).as_step("keep_output_fields") + >> Sink.jsonl(OUTPUT_PATH) +) + +records = pipeline.run(batch_size=1) diff --git a/openspec/changes/add-persona-cookbook/.openspec.yaml b/openspec/changes/add-persona-cookbook/.openspec.yaml new file mode 100644 index 0000000..c551aea --- /dev/null +++ b/openspec/changes/add-persona-cookbook/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-05 diff --git a/openspec/changes/add-persona-cookbook/design.md b/openspec/changes/add-persona-cookbook/design.md new file mode 100644 index 0000000..c712d75 --- /dev/null +++ b/openspec/changes/add-persona-cookbook/design.md @@ -0,0 +1,139 @@ +## Context + +DataFast already separates runnable examples (`examples/scripts/`) from rendered documentation (`docs/`), but it does not yet have a cookbook layer that ties a real script to a narrative, site-facing walkthrough. This change introduces that pattern and uses a Persona Hub-inspired persona-generation example as the first cookbook because the repo already exposes the core primitives needed for it: `Source`/`Seed`, `LLMStep`, and `Sink`. + +The research constraint is important. Persona Hub publishes the high-level methods for persona construction in the paper, but the paper also states that figure prompts are simplified rather than the exact experiment strings. The repository publishes exact prompt templates for persona-conditioned synthesis tasks such as instruction generation, but not a full canonical `Text-to-Persona` implementation prompt. The design therefore needs an explicit provenance model so DataFast can be faithful to the method without overstating prompt fidelity. + +## Goals / Non-Goals + +**Goals:** +- Add a reusable cookbook pattern that connects docs navigation, a runnable script, and an explanatory Markdown page. +- Implement a first cookbook that demonstrates Persona Hub-inspired persona creation with DataFast, centered on `Text-to-Persona` and `Persona-to-Persona`. +- Preserve provenance by separating paper-aligned prompts, repository-derived templates, and DataFast-specific prompt adaptations. +- Keep the example practically runnable with a bounded sample size and explicit provider prerequisites. + +**Non-Goals:** +- Reproducing Persona Hub’s full billion-persona data pipeline or dataset scale. +- Reusing or vendoring Persona Hub code. +- Adding new public DataFast APIs solely for this cookbook. +- Building an automated docs-code sync system beyond what is needed for the first cookbook. + +## Decisions + +### 1. Keep executable source in `examples/scripts/` and renderable narrative in `docs/cookbook/` + +The runnable example will follow the repo’s existing convention and live under `examples/scripts/`. The new cookbook section will live under `docs/cookbook/`, with `mkdocs.yml` updated to expose it in navigation. + +Why: +- The repo already teaches runnable examples from `examples/scripts/`. +- Keeping executable code there avoids turning `docs/` into a mixed content area full of Python files and generated artifacts. +- The cookbook page can still act as the canonical reader-facing entry point while pointing to the authoritative script. + +Alternative considered: +- Store the script directly under `docs/cookbook/`. Rejected because it breaks current example organization and makes docs content noisier to maintain. + +### 2. Use prompt assets with explicit provenance labels + +Prompt text used by the cookbook will be stored as dedicated assets instead of being embedded only inside Python strings. Each prompt asset will be labeled as one of: +- paper-aligned adaptation +- repository-derived template +- DataFast-specific adaptation + +Why: +- The paper gives simplified prompt forms for persona creation, not exact experiment strings. +- The repository does publish exact downstream templates such as instruction synthesis. +- External prompt assets make it easier to show provenance in both the script and the cookbook page without duplicating large prompt blocks. + +Alternative considered: +- Inline all prompts in the script. Rejected because provenance becomes harder to audit and the doc page is more likely to drift from the executable source. + +### 3. Model the cookbook as a small multi-stage DataFast pipeline + +The first cookbook script will be structured as a bounded pipeline with three stages: +- source text to inferred persona (`Text-to-Persona`) +- persona to related persona (`Persona-to-Persona`) +- downstream persona-conditioned generation via a representative user-prompt generation step inspired by the Persona Hub repository template + +Outputs should be written to JSONL so the script leaves inspectable artifacts after execution. + +Why: +- This demonstrates both persona construction methods from the paper and one concrete persona-driven synthesis pattern from the repository. +- JSONL outputs fit existing repo conventions and are easy to inspect in docs. +- A multi-stage pipeline shows DataFast’s composition model better than a single prompt call. + +Alternative considered: +- Stop after the first persona-generation stage. Rejected because it under-explains the value of generated personas and misses the strongest link to the repository prompt templates. + +### 4. Use `xsum` validation articles as the seed corpus + +The cookbook will draw source texts from Hugging Face `xsum`, using the `validation` split and selecting up to the first five documents whose lengths fall between 300 and 500 words. + +Why: +- `xsum` is a well-known Hugging Face dataset with concise full articles that fit the desired demonstration size well. +- The `validation` split gives a stable, reproducible sample source for the cookbook. +- Limiting the selection to at most five articles keeps the run small enough for a cookbook while still showing multiple persona inferences. +- It works with the current `datasets>=3.0` stack in this repo, whereas `GEM/xsum` now depends on a dataset-script loading path that is no longer supported here. + +Alternative considered: +- Use `GEM/xsum`. Rejected during implementation because the current `datasets` dependency no longer supports the required dataset script. + +### 5. Favor structured outputs where practical + +The script should ask the model for structured fields where that improves inspectability, for example: +- inferred persona description +- relationship type used for expansion +- related persona description +- downstream user prompt or artifact + +Why: +- Structured output is easier to verify manually. +- It produces cleaner JSONL for later cookbook rendering. +- It reduces ambiguity when comparing prompt variants. + +Alternative considered: +- Use only free-form text outputs. Rejected because the resulting artifacts are harder to reuse in documentation. + +### 6. Use OpenRouter as the documented execution path + +The cookbook will document and smoke-test an OpenRouter-based execution path using `nvidia/nemotron-3-super-120b-a12b` as the default model id. + +Why: +- The user selected OpenRouter as the desired provider. +- It matches existing repo examples and keeps the cookbook aligned with current usage patterns. +- A single concrete provider path reduces ambiguity in the first cookbook. + +Alternative considered: +- Keep the smoke-run path provider-agnostic. Rejected because it weakens reproducibility for the initial cookbook. + +### 7. Require explicit runtime prerequisites instead of CI-level execution guarantees + +The cookbook will be documented as a real runnable script that requires a configured LLM provider. The implementation should keep sample sizes small and output paths explicit, but it will not require new test infrastructure or a fake public provider abstraction. + +Why: +- The repo’s current examples already assume a configured provider. +- No public fake-provider path exists for full end-to-end example execution. +- This keeps the first cookbook lightweight and consistent with existing example ergonomics. + +Alternative considered: +- Introduce a fake provider or snapshot-based offline harness only for cookbook validation. Rejected for this change because it expands scope beyond the user request. + +## Risks / Trade-offs + +- [Prompt fidelity ambiguity] → Label prompts by provenance and explicitly state that the paper’s displayed persona-creation prompts are simplified, not exact experiment strings. +- [Docs/example drift] → Make the Python script the implementation source of truth and have the cookbook page reference concrete script and prompt asset paths. +- [Networked example friction] → Keep runs bounded, document provider prerequisites clearly, and use a small default sample size for manual execution. +- [Cookbook scope creep] → Limit v1 to a single persona-generation cookbook and a minimal cookbook section in navigation. + +## Migration Plan + +1. Add the cookbook docs section and navigation entry. +2. Add prompt/reference assets with provenance notes. +3. Implement the runnable persona-generation script using `xsum` validation articles, capped at the first five 300 to 500 word documents, and write outputs to a stable example output path. +4. Author the cookbook Markdown page that explains the workflow and references the script outputs. +5. Run a bounded manual smoke test with OpenRouter and capture the expected invocation in the docs. + +Rollback is straightforward because the change is additive: remove the cookbook nav entry and the newly added docs/example files. + +## Open Questions + +- Whether the word-count filter should use a simple whitespace tokenization or a slightly stricter normalization rule before selecting the five `xsum` documents. diff --git a/openspec/changes/add-persona-cookbook/proposal.md b/openspec/changes/add-persona-cookbook/proposal.md new file mode 100644 index 0000000..7b394d2 --- /dev/null +++ b/openspec/changes/add-persona-cookbook/proposal.md @@ -0,0 +1,28 @@ +## Why + +DataFast has runnable examples and narrative docs, but it does not yet have a cookbook area that connects a real executable script to a documentation-page walkthrough. A persona-generation cookbook is a strong first entry because it showcases a realistic synthetic-data workflow and lets DataFast demonstrate a paper-aligned reimplementation of Persona Hub ideas without copying their code. + +## What Changes + +- Add a cookbook section under `docs/` and expose it in the MkDocs navigation. +- Establish a cookbook pattern where the executable Python script is authored first and the documentation page is built from that runnable example. +- Add the first cookbook for persona generation, implemented with DataFast primitives and explicitly inspired by Persona Hub’s `Text-to-Persona`, `Persona-to-Persona`, and persona-conditioned prompting ideas. +- Document the research basis of the cookbook, including which prompt patterns come from the paper or repository and which parts are DataFast-specific adaptations. +- Keep the implementation independent from Persona Hub code: reuse methodology and prompt logic where appropriate, but do not vendor or call their code. + +## Capabilities + +### New Capabilities +- `docs-cookbook`: Provide a cookbook area in the documentation for executable, real-world DataFast examples that can later render cleanly on the docs site. +- `persona-generation-cookbook`: Provide a first cookbook that explores persona generation with DataFast using Persona Hub-inspired methods, prompts, and workflow notes. + +### Modified Capabilities + +- None. + +## Impact + +- Affected docs and site navigation in `docs/` and `mkdocs.yml`. +- New runnable cookbook source files and supporting prompt/reference material. +- No public DataFast API changes are required. +- Requires explicit handling of provider prerequisites and provenance notes because the Persona Hub paper states that figure prompts are simplified rather than exact experiment strings. diff --git a/openspec/changes/add-persona-cookbook/specs/docs-cookbook/spec.md b/openspec/changes/add-persona-cookbook/specs/docs-cookbook/spec.md new file mode 100644 index 0000000..a2731cb --- /dev/null +++ b/openspec/changes/add-persona-cookbook/specs/docs-cookbook/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Cookbook navigation +The documentation site SHALL expose a Cookbook section in the MkDocs navigation, and each cookbook entry SHALL resolve to a Markdown page under `docs/`. + +#### Scenario: Cookbook section appears in navigation +- **WHEN** the documentation site configuration is loaded +- **THEN** the navigation includes a Cookbook section with an entry for the persona-generation cookbook + +### Requirement: Cookbook pages identify runnable source +Each cookbook page SHALL identify the authoritative executable source file, the runtime prerequisites, and the expected output location for the example it documents. + +#### Scenario: Reader opens a cookbook page +- **WHEN** a reader opens the persona-generation cookbook page +- **THEN** the page shows the script path, required provider configuration, and the output artifact path needed to reproduce the example diff --git a/openspec/changes/add-persona-cookbook/specs/persona-generation-cookbook/spec.md b/openspec/changes/add-persona-cookbook/specs/persona-generation-cookbook/spec.md new file mode 100644 index 0000000..b1dd4c6 --- /dev/null +++ b/openspec/changes/add-persona-cookbook/specs/persona-generation-cookbook/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Persona Hub-inspired persona workflow +The persona-generation cookbook SHALL implement a runnable DataFast workflow that explores Persona Hub-inspired `Text-to-Persona` and `Persona-to-Persona` methods from bounded sample inputs drawn from Hugging Face `xsum`. + +#### Scenario: Script generates personas from source texts +- **WHEN** a user runs the cookbook script with a configured OpenRouter model +- **THEN** the script selects up to the first five documents from the `validation` split whose lengths are between 300 and 500 words and produces output records that include personas inferred from source text and personas expanded from prior personas + +### Requirement: Prompt provenance is explicit +The cookbook SHALL distinguish between paper-aligned persona-generation prompts, repository-derived downstream prompt templates, and DataFast-specific prompt adaptations, and it SHALL NOT claim verbatim reproduction where the source material does not publish exact prompt strings. + +#### Scenario: Reader inspects prompt usage +- **WHEN** a reader reviews the cookbook code or documentation +- **THEN** each prompt used in the workflow is labeled by provenance and any paper-derived persona prompt is described as an adaptation rather than an exact reproduction + +### Requirement: Cookbook demonstrates downstream persona usage +The cookbook SHALL include at least one downstream persona-conditioned generation step implemented with DataFast to show how generated personas can drive later synthetic-data creation. + +#### Scenario: Script reaches downstream synthesis +- **WHEN** the cookbook script completes its final stage +- **THEN** the outputs include at least one artifact generated from a persona-conditioned prompt, such as a representative user request + +### Requirement: Standalone execution is documented and bounded +The cookbook SHALL be runnable as a standalone Python script with a bounded execution path suitable for manual verification. + +#### Scenario: User performs a smoke run +- **WHEN** a user executes the documented smoke-run command +- **THEN** the script uses OpenRouter with model id `nvidia/nemotron-3-super-120b-a12b`, processes only the documented bounded sample size, and writes inspectable output artifacts without requiring repo code changes diff --git a/openspec/changes/add-persona-cookbook/tasks.md b/openspec/changes/add-persona-cookbook/tasks.md new file mode 100644 index 0000000..181e453 --- /dev/null +++ b/openspec/changes/add-persona-cookbook/tasks.md @@ -0,0 +1,19 @@ +## 1. Cookbook scaffolding + +- [x] 1.1 Add the Cookbook section to `mkdocs.yml` and create the base `docs/cookbook/` pages needed for navigation. +- [x] 1.2 Add prompt/reference assets for the persona cookbook and label each asset with its provenance (`paper-aligned`, `repository-derived`, or `DataFast adaptation`). +- [x] 1.3 Add a dataset-selection note for `xsum` `validation`, including the 300 to 500 word filter and first-five cap used by the example. + +## 2. Runnable persona example + +- [x] 2.1 Implement the standalone persona-generation script in `examples/scripts/` with `xsum` `validation` inputs, a 300 to 500 word filter, a first-five cap, and JSONL outputs. +- [x] 2.2 Add the `Text-to-Persona` and `Persona-to-Persona` stages using DataFast primitives and structured outputs where practical. +- [x] 2.3 Add a downstream persona-conditioned user-prompt generation stage that demonstrates how the generated personas drive a later synthetic-data step. +- [x] 2.4 Perform a bounded smoke run with OpenRouter and confirm the documented command produces inspectable output artifacts. + +## 3. Cookbook documentation + +- [x] 3.1 Write the persona-generation cookbook page under `docs/cookbook/` with prerequisites, script path, run command, output path, and explanation of the workflow. +- [x] 3.2 Document the research basis and prompt provenance, including the limitation that Persona Hub’s paper shows simplified persona-creation prompts rather than exact experiment strings. +- [x] 3.3 Summarize the key prompts in the cookbook page and link to the prompt asset files instead of embedding full prompt text inline. +- [x] 3.4 Review the cookbook navigation and page content to ensure the docs site can expose the new section cleanly. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..392946c --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours From 8a19840982fd94943d22c805a87c8193af4ec394 Mon Sep 17 00:00:00 2001 From: Patrick Date: Sun, 5 Apr 2026 12:45:04 +0200 Subject: [PATCH 06/54] Update persona cookbook to use Mistral AI, expand to 20 samples, and publish to Hugging Face Hub --- docs/cookbook/assets/index.md | 4 +- docs/cookbook/assets/persona_to_persona.txt | 2 +- docs/cookbook/persona_generation.md | 14 ++- .../scripts/43_cookbook_persona_generation.py | 112 +++++++++++------- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/docs-cookbook/spec.md | 0 .../specs/persona-generation-cookbook/spec.md | 0 .../2026-04-05-add-persona-cookbook}/tasks.md | 0 .../.openspec.yaml | 2 + .../design.md | 92 ++++++++++++++ .../proposal.md | 27 +++++ .../specs/docs-cookbook/spec.md | 8 ++ .../specs/persona-generation-cookbook/spec.md | 24 ++++ .../tasks.md | 15 +++ openspec/specs/docs-cookbook/spec.md | 17 +++ .../specs/persona-generation-cookbook/spec.md | 38 ++++++ 18 files changed, 306 insertions(+), 49 deletions(-) rename openspec/changes/{add-persona-cookbook => archive/2026-04-05-add-persona-cookbook}/.openspec.yaml (100%) rename openspec/changes/{add-persona-cookbook => archive/2026-04-05-add-persona-cookbook}/design.md (100%) rename openspec/changes/{add-persona-cookbook => archive/2026-04-05-add-persona-cookbook}/proposal.md (100%) rename openspec/changes/{add-persona-cookbook => archive/2026-04-05-add-persona-cookbook}/specs/docs-cookbook/spec.md (100%) rename openspec/changes/{add-persona-cookbook => archive/2026-04-05-add-persona-cookbook}/specs/persona-generation-cookbook/spec.md (100%) rename openspec/changes/{add-persona-cookbook => archive/2026-04-05-add-persona-cookbook}/tasks.md (100%) create mode 100644 openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/design.md create mode 100644 openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/proposal.md create mode 100644 openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/docs-cookbook/spec.md create mode 100644 openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/persona-generation-cookbook/spec.md create mode 100644 openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/tasks.md create mode 100644 openspec/specs/docs-cookbook/spec.md create mode 100644 openspec/specs/persona-generation-cookbook/spec.md diff --git a/docs/cookbook/assets/index.md b/docs/cookbook/assets/index.md index 9063979..8eacc96 100644 --- a/docs/cookbook/assets/index.md +++ b/docs/cookbook/assets/index.md @@ -9,9 +9,9 @@ This note records the supporting assets used by the persona-generation cookbook. - Text field: `document` - Summary field kept for inspection: `summary` - Selection rule: keep documents whose whitespace-tokenized word counts are between `300` and `500` -- Cap: use the first `5` matching records +- Cap: use the first `20` matching records -This keeps the cookbook deterministic and small while still using a well-known Hugging Face corpus with article lengths that fit the demonstration. +This keeps the cookbook deterministic and bounded while still using a well-known Hugging Face corpus with article lengths that fit the demonstration. `GEM/xsum` was the original candidate, but the current `datasets` stack in this repo no longer supports dataset-script based loading for that asset. The script therefore uses the scriptless `xsum` dataset, which exposes the same `document` and summary-style fields needed for the cookbook. diff --git a/docs/cookbook/assets/persona_to_persona.txt b/docs/cookbook/assets/persona_to_persona.txt index ace5c54..2913cfa 100644 --- a/docs/cookbook/assets/persona_to_persona.txt +++ b/docs/cookbook/assets/persona_to_persona.txt @@ -4,7 +4,7 @@ Persona: {persona} Requirements: -1. Use one clear relationship such as patient-caregiver, coworker, family member, teacher-student, or client-service provider. +1. Use one clear relationship. 2. Choose a related persona that adds a meaningfully different perspective. 3. Keep the related persona realistic and specific. 4. Return only one relationship expansion. diff --git a/docs/cookbook/persona_generation.md b/docs/cookbook/persona_generation.md index b35e00a..cd9a81c 100644 --- a/docs/cookbook/persona_generation.md +++ b/docs/cookbook/persona_generation.md @@ -7,16 +7,18 @@ This cookbook shows how to implement a Persona Hub-inspired workflow with DataFa - Script: `examples/scripts/43_cookbook_persona_generation.py` - Prompt assets: [asset index](assets/index.md) - Output artifact: `examples/outputs/43_persona_cookbook.jsonl` +- Dataset publication target: the Hugging Face dataset repo in `PERSONA_COOKBOOK_HF_REPO_ID` ## What The Script Does The pipeline is intentionally small: 1. Load `xsum` articles from the `validation` split. -2. Keep only the first `5` documents whose word counts fall between `300` and `500`. +2. Keep only the first `20` documents whose word counts fall between `300` and `500`. 3. Infer one likely persona from each article with a `Text-to-Persona` prompt. 4. Expand that persona into a closely related persona with a `Persona-to-Persona` prompt. 5. Generate one representative user prompt for the related persona. +6. Write the final records to local JSONL and publish the same rows to Hugging Face Hub. ```text GEM/xsum article @@ -35,9 +37,11 @@ Representative user prompt Prerequisites: -- `OPENROUTER_API_KEY` is set +- `MISTRAL_API_KEY` is set +- `PERSONA_COOKBOOK_HF_REPO_ID` points at the target Hugging Face dataset repo, for example `your-name/persona-cookbook-43` +- Hugging Face authentication is available through `HF_TOKEN` or a cached `huggingface_hub` login - the project environment has the base dependencies from `pyproject.toml` -- the script uses OpenRouter model `nvidia/nemotron-3-super-120b-a12b` +- the script uses the Mistral model `mistral-small-2603` Example: @@ -45,6 +49,8 @@ Example: .venv/bin/python examples/scripts/43_cookbook_persona_generation.py ``` +By default the dataset push is private. Set `PERSONA_COOKBOOK_HF_PRIVATE=false` if you want the published dataset to be public. + ## Prompt Summary The cookbook keeps full prompts in asset files rather than embedding them here. @@ -61,7 +67,7 @@ For downstream prompt generation, the repository publishes a prompt family for p ## Output Shape -The JSONL output keeps the fields that matter for inspection: +The local JSONL output and the published Hugging Face dataset keep the same fields for inspection: - `summary` - `document` diff --git a/examples/scripts/43_cookbook_persona_generation.py b/examples/scripts/43_cookbook_persona_generation.py index 9f381d7..4304c8f 100644 --- a/examples/scripts/43_cookbook_persona_generation.py +++ b/examples/scripts/43_cookbook_persona_generation.py @@ -4,19 +4,26 @@ and prompt assets stored under docs/cookbook/assets. Requires: -- OPENROUTER_API_KEY -- network access to Hugging Face and OpenRouter +- MISTRAL_API_KEY +- PERSONA_COOKBOOK_HF_REPO_ID +- Hugging Face authentication via HF_TOKEN or a cached `huggingface_hub` login +- network access to Hugging Face and Mistral AI """ -from datafast import Filter, LLMStep, Map, Sample, Sink, Source, openrouter +import os + +from datafast import Filter, LLMStep, Map, Sample, Sink, Source, mistral import litellm litellm.suppress_debug_info = True -MODEL_ID = "nvidia/nemotron-3-super-120b-a12b" +MODEL_ID = "mistral-small-2603" +SAMPLE_SIZE = 2 OUTPUT_PATH = "examples/outputs/43_persona_cookbook.jsonl" +HF_REPO_ID_ENV = "PERSONA_COOKBOOK_HF_REPO_ID" +HF_PRIVATE_ENV = "PERSONA_COOKBOOK_HF_PRIVATE" TEXT_TO_PERSONA_PROMPT = "docs/cookbook/assets/text_to_persona.txt" PERSONA_TO_PERSONA_PROMPT = "docs/cookbook/assets/persona_to_persona.txt" PERSONA_TO_USER_PROMPT = "docs/cookbook/assets/persona_to_user_prompt.txt" @@ -40,43 +47,64 @@ def keep_output_fields(record: dict) -> dict: } -model = openrouter(MODEL_ID, temperature=0.7) +def build_pipeline(): + model = mistral(MODEL_ID, temperature=0.7) + + return ( + Source.huggingface( + "xsum", + split="validation", + columns=["document", "summary"], + ) + >> Map(add_word_count).as_step("add_word_count") + >> Filter(fn=lambda r: 300 <= r["word_count"] <= 500).as_step("filter_word_count") + >> Sample(n=SAMPLE_SIZE, strategy="first").as_step("take_first_twenty") + >> LLMStep( + prompt=TEXT_TO_PERSONA_PROMPT, + input_columns=["document"], + output_columns=["persona", "persona_basis"], + model=model, + parse_mode="json", + on_parse_error="raise", + ).as_step("text_to_persona") + >> LLMStep( + prompt=PERSONA_TO_PERSONA_PROMPT, + input_columns=["persona"], + output_columns=["relationship_type", "related_persona"], + model=model, + parse_mode="json", + on_parse_error="raise", + ).as_step("persona_to_persona") + >> LLMStep( + prompt=PERSONA_TO_USER_PROMPT, + input_columns=["related_persona"], + output_columns=["user_prompt", "prompt_basis"], + model=model, + parse_mode="json", + on_parse_error="raise", + ).as_step("persona_to_user_prompt") + >> Map(keep_output_fields).as_step("keep_output_fields") + >> Sink.jsonl(OUTPUT_PATH) + ) + + +def push_records_to_hub(records: list[dict]) -> None: + repo_id = "patrickfleith/datafast-persona-cookbook" + private = False -pipeline = ( - Source.huggingface( - "xsum", - split="validation", - columns=["document", "summary"], + list( + Sink.hub( + repo_id=repo_id, + private=private, + commit_message=f"Publish cookbook 43 persona dataset with {MODEL_ID}", + ).process(records) ) - >> Map(add_word_count).as_step("add_word_count") - >> Filter(fn=lambda r: 300 <= r["word_count"] <= 500).as_step("filter_word_count") - >> Sample(n=5, strategy="first").as_step("take_first_five") - >> LLMStep( - prompt=TEXT_TO_PERSONA_PROMPT, - input_columns=["document"], - output_columns=["persona", "persona_basis"], - model=model, - parse_mode="json", - on_parse_error="raise", - ).as_step("text_to_persona") - >> LLMStep( - prompt=PERSONA_TO_PERSONA_PROMPT, - input_columns=["persona"], - output_columns=["relationship_type", "related_persona"], - model=model, - parse_mode="json", - on_parse_error="raise", - ).as_step("persona_to_persona") - >> LLMStep( - prompt=PERSONA_TO_USER_PROMPT, - input_columns=["related_persona"], - output_columns=["user_prompt", "prompt_basis"], - model=model, - parse_mode="json", - on_parse_error="raise", - ).as_step("persona_to_user_prompt") - >> Map(keep_output_fields).as_step("keep_output_fields") - >> Sink.jsonl(OUTPUT_PATH) -) - -records = pipeline.run(batch_size=1) + + +def main() -> None: + records = build_pipeline().run(batch_size=1) + push_records_to_hub(records) + + +if __name__ == "__main__": + main() diff --git a/openspec/changes/add-persona-cookbook/.openspec.yaml b/openspec/changes/archive/2026-04-05-add-persona-cookbook/.openspec.yaml similarity index 100% rename from openspec/changes/add-persona-cookbook/.openspec.yaml rename to openspec/changes/archive/2026-04-05-add-persona-cookbook/.openspec.yaml diff --git a/openspec/changes/add-persona-cookbook/design.md b/openspec/changes/archive/2026-04-05-add-persona-cookbook/design.md similarity index 100% rename from openspec/changes/add-persona-cookbook/design.md rename to openspec/changes/archive/2026-04-05-add-persona-cookbook/design.md diff --git a/openspec/changes/add-persona-cookbook/proposal.md b/openspec/changes/archive/2026-04-05-add-persona-cookbook/proposal.md similarity index 100% rename from openspec/changes/add-persona-cookbook/proposal.md rename to openspec/changes/archive/2026-04-05-add-persona-cookbook/proposal.md diff --git a/openspec/changes/add-persona-cookbook/specs/docs-cookbook/spec.md b/openspec/changes/archive/2026-04-05-add-persona-cookbook/specs/docs-cookbook/spec.md similarity index 100% rename from openspec/changes/add-persona-cookbook/specs/docs-cookbook/spec.md rename to openspec/changes/archive/2026-04-05-add-persona-cookbook/specs/docs-cookbook/spec.md diff --git a/openspec/changes/add-persona-cookbook/specs/persona-generation-cookbook/spec.md b/openspec/changes/archive/2026-04-05-add-persona-cookbook/specs/persona-generation-cookbook/spec.md similarity index 100% rename from openspec/changes/add-persona-cookbook/specs/persona-generation-cookbook/spec.md rename to openspec/changes/archive/2026-04-05-add-persona-cookbook/specs/persona-generation-cookbook/spec.md diff --git a/openspec/changes/add-persona-cookbook/tasks.md b/openspec/changes/archive/2026-04-05-add-persona-cookbook/tasks.md similarity index 100% rename from openspec/changes/add-persona-cookbook/tasks.md rename to openspec/changes/archive/2026-04-05-add-persona-cookbook/tasks.md diff --git a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/.openspec.yaml b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/.openspec.yaml new file mode 100644 index 0000000..c551aea --- /dev/null +++ b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-05 diff --git a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/design.md b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/design.md new file mode 100644 index 0000000..35ea2a3 --- /dev/null +++ b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/design.md @@ -0,0 +1,92 @@ +## Context + +Cookbook 43 already provides a runnable persona-generation example and a matching docs page, but the current implementation is tuned for a small OpenRouter smoke run and stops at a local JSONL artifact. The requested update changes three user-visible behaviors at once: the documented LLM path moves to DataFast's native Mistral provider, the bounded run expands from five records to twenty, and the final synthetic records must be publishable to Hugging Face Hub. + +This change stays within the existing cookbook pattern. The repo already exposes `mistral(...)` for provider construction and `Sink.hub(...)` for dataset publication, so the main design work is deciding how Cookbook 43 will compose those existing primitives without losing the bounded, inspectable nature of the example. + +## Goals / Non-Goals + +**Goals:** +- Update Cookbook 43 so its documented execution path uses Mistral AI instead of OpenRouter. +- Expand the bounded synthetic-data run from 5 to 20 filtered `xsum` validation records. +- Preserve a local inspectable output artifact while also publishing the final records to Hugging Face Hub. +- Document the exact runtime prerequisites needed for both generation and publishing. + +**Non-Goals:** +- Changing the Persona Hub-inspired prompt assets or provenance labels beyond what is needed for provider and publication updates. +- Generalizing every cookbook to support automatic dataset publication. +- Adding new public DataFast sink APIs or a new provider abstraction. +- Turning the cookbook into an unbounded production pipeline. + +## Decisions + +### 1. Use DataFast's native Mistral provider helper as the cookbook default + +Cookbook 43 will switch from `openrouter(...)` to `mistral(...)` and document `MISTRAL_API_KEY` as the required LLM credential. The documented model path will be pinned to `mistral-small-2603`. + +Why: +- The repo already ships and tests a first-party Mistral provider. +- This makes the cookbook align with a provider that DataFast owns directly instead of routing through OpenRouter. +- Reusing the helper keeps the example consistent with existing provider ergonomics. + +Alternative considered: +- Keep the script provider-agnostic. Rejected because the user asked for a concrete switch to Mistral AI and the docs need a reproducible path. + +### 2. Keep the `xsum` validation workflow and raise only the bounded sample cap + +The cookbook will continue to source records from `xsum` `validation`, apply the existing 300 to 500 word filter, and use `Sample(..., strategy=\"first\")` for deterministic selection. Only the cap changes, from 5 to 20. + +Why: +- This preserves the original example's reproducibility and keeps the change narrowly focused on requested scale. +- Twenty records is still small enough for cookbook verification while being materially more representative than five. +- Holding the dataset and filter constant isolates the effect of the provider and publication changes. + +Alternative considered: +- Switch to random sampling or a different dataset. Rejected because it adds unnecessary variability and moves the change away from Cookbook 43's current behavior. + +### 3. Publish the final synthetic records to Hugging Face Hub while retaining local JSONL output + +The final transformed records should remain inspectable in `examples/outputs/43_persona_cookbook.jsonl`, and the same final schema should also be pushed to Hugging Face Hub through `Sink.hub(...)`. The cookbook should treat local JSONL as the audit artifact and the Hub dataset as the publication artifact. + +The Hub destination should be runtime-configurable instead of hard-coded, using the standard `HF_TOKEN` plus a dataset repo identifier supplied through configuration that the script can read without requiring source edits. + +Why: +- The user asked to push the resulting dataset, but cookbook readers still need a simple local file they can inspect immediately. +- `Sink.hub(...)` already implements dataset creation and push semantics, so the example can stay within existing APIs. +- A configurable repo id avoids baking a personal or temporary dataset namespace into the repository. + +Alternative considered: +- Replace the JSONL sink entirely with a Hub-only push. Rejected because it weakens local inspectability and makes failure analysis harder. + +### 4. Expand the cookbook docs to describe both generation and publication prerequisites + +`docs/cookbook/persona_generation.md` should document the authoritative script, prompt assets, local output path, Mistral credential requirements, Hugging Face publication requirements, and the configured dataset target. The page should also update its workflow summary from five samples to twenty and explain that the final records are published after generation. + +Why: +- The current docs spec already treats the cookbook page as the reader-facing source of truth. +- Provider and publication configuration are both runtime prerequisites, not hidden implementation details. +- Cookbook readers need to know where artifacts end up locally and remotely. + +Alternative considered: +- Document only the script change and leave Hub publication discoverable from code. Rejected because it would violate the cookbook pattern of making execution requirements explicit. + +## Risks / Trade-offs + +- [Higher run cost and latency from 20 samples] → Keep deterministic first-20 sampling and preserve the cookbook's bounded scope. +- [Publishing requires user-specific dataset configuration] → Use standard Hugging Face credentials and a runtime-configurable repo id rather than a hard-coded namespace. +- [Local and remote outputs can drift] → Push the same final record shape that is written to JSONL and keep the field-selection step immediately before both sinks. +- [Hub push failures can obscure generation failures] → Preserve local JSONL output so generation artifacts remain inspectable even if publication fails. + +## Migration Plan + +1. Update the Cookbook 43 script to construct a Mistral provider, keep the existing `xsum` filter, and raise the bounded sample cap to 20. +2. Add publication configuration and wire the final record stream to both local JSONL output and a Hugging Face Hub sink. +3. Revise the cookbook docs to describe Mistral setup, Hugging Face publication requirements, the 20-sample behavior, and the local plus remote outputs. +4. Run a bounded manual verification path that confirms the documented configuration and output locations are coherent. + +Rollback is straightforward: revert the script and docs to the previous OpenRouter plus JSONL-only flow and remove the publication configuration path. + +## Open Questions + +- Which repo-level configuration mechanism should hold the Hugging Face dataset repo id for the example: a dedicated environment variable, a top-of-script constant, or a small CLI argument surface? +- Whether the published dataset should remain private by default or be documented as user-selectable at runtime. diff --git a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/proposal.md b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/proposal.md new file mode 100644 index 0000000..a2dccf2 --- /dev/null +++ b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/proposal.md @@ -0,0 +1,27 @@ +## Why + +Cookbook 43 currently demonstrates persona-conditioned synthetic data generation with OpenRouter and a five-record sample cap, which is too narrow for the larger synthetic-data workflow the repo now wants to showcase. The next revision should reflect a first-party Mistral AI execution path, produce a more representative 20-sample output set, and document publishing the resulting dataset to Hugging Face Hub. + +## What Changes + +- Update `examples/scripts/43_cookbook_persona_generation.py` to use the repo's Mistral provider path instead of OpenRouter. +- Expand the bounded sample size from the first `5` eligible `xsum` validation records to the first `20`. +- Extend the cookbook pipeline so it can publish the generated synthetic dataset to Hugging Face Hub after local generation succeeds. +- Revise the cookbook documentation to describe the Mistral runtime prerequisites, larger sample count, local output artifact, and Hugging Face dataset destination. +- Preserve the existing Persona Hub-inspired prompt provenance model while adapting the execution and publication flow. + +## Capabilities + +### New Capabilities +- None. + +### Modified Capabilities +- `docs-cookbook`: Change cookbook-page requirements so a published cookbook can document both its local output artifact and its Hugging Face dataset publication target. +- `persona-generation-cookbook`: Change the cookbook workflow requirements to use Mistral AI, generate 20 bounded samples, and publish the resulting synthetic dataset to Hugging Face Hub. + +## Impact + +- Affected runnable example in `examples/scripts/43_cookbook_persona_generation.py`. +- Affected cookbook documentation in `docs/cookbook/persona_generation.md` and supporting asset references. +- Requires Mistral runtime configuration via `MISTRAL_API_KEY` and dataset publication credentials via `HF_TOKEN`. +- Uses existing DataFast Hub sink support for dataset publishing; no new public API surface is required. diff --git a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/docs-cookbook/spec.md b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/docs-cookbook/spec.md new file mode 100644 index 0000000..422fc4e --- /dev/null +++ b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/docs-cookbook/spec.md @@ -0,0 +1,8 @@ +## MODIFIED Requirements + +### Requirement: Cookbook pages identify runnable source +Each cookbook page SHALL identify the authoritative executable source file, the runtime prerequisites, the expected local output location, and any Hugging Face dataset publication target for the example it documents. + +#### Scenario: Reader opens a cookbook page +- **WHEN** a reader opens the persona-generation cookbook page +- **THEN** the page shows the script path, required Mistral and Hugging Face configuration, the local output artifact path, and the dataset publication target needed to reproduce the example diff --git a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/persona-generation-cookbook/spec.md b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/persona-generation-cookbook/spec.md new file mode 100644 index 0000000..5494f14 --- /dev/null +++ b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/persona-generation-cookbook/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Persona Hub-inspired persona workflow +The persona-generation cookbook SHALL implement a runnable DataFast workflow that explores Persona Hub-inspired `Text-to-Persona` and `Persona-to-Persona` methods from bounded sample inputs drawn from Hugging Face `xsum`, using Mistral AI as the documented LLM provider path. + +#### Scenario: Script generates personas from source texts +- **WHEN** a user runs the cookbook script with a configured Mistral model +- **THEN** the script selects up to the first twenty documents from the `validation` split whose lengths are between 300 and 500 words and produces output records that include personas inferred from source text and personas expanded from prior personas + +### Requirement: Standalone execution is documented and bounded +The cookbook SHALL be runnable as a standalone Python script with a bounded execution path suitable for manual verification and dataset publication. + +#### Scenario: User performs a smoke run +- **WHEN** a user executes the documented smoke-run command with configured Mistral and Hugging Face credentials +- **THEN** the script uses Mistral model id `mistral-small-2603`, processes only the documented bounded sample size, writes inspectable output artifacts, and publishes the resulting dataset without requiring repo code changes + +## ADDED Requirements + +### Requirement: Cookbook publishes the final synthetic dataset +The persona-generation cookbook SHALL push the final synthetic records to a configured Hugging Face Hub dataset after local generation completes successfully. + +#### Scenario: Script publishes generated records +- **WHEN** the cookbook script reaches its final sink stage with a configured Hugging Face dataset repo and token +- **THEN** it pushes the same final record fields that are written locally to the configured dataset repository diff --git a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/tasks.md b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/tasks.md new file mode 100644 index 0000000..8e13311 --- /dev/null +++ b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/tasks.md @@ -0,0 +1,15 @@ +## 1. Cookbook Script + +- [x] 1.1 Replace the OpenRouter provider setup in `examples/scripts/43_cookbook_persona_generation.py` with the DataFast Mistral provider path and the required runtime configuration. +- [x] 1.2 Raise the bounded sample selection from the first 5 eligible `xsum` validation records to the first 20 while preserving the existing word-count filter and output schema. +- [x] 1.3 Add a final Hugging Face Hub publication step that pushes the generated synthetic records after local output generation succeeds. + +## 2. Cookbook Documentation + +- [x] 2.1 Update `docs/cookbook/persona_generation.md` to document the Mistral prerequisites, 20-sample behavior, local output artifact, and Hugging Face dataset publication target. +- [x] 2.2 Revise supporting cookbook notes or asset references that still describe the OpenRouter path or five-sample limit. + +## 3. Verification + +- [x] 3.1 Perform a bounded verification of the updated script path and confirm the documented Mistral plus Hugging Face configuration is coherent. +- [x] 3.2 Verify the cookbook page and script describe the same local record fields and remote publication behavior. diff --git a/openspec/specs/docs-cookbook/spec.md b/openspec/specs/docs-cookbook/spec.md new file mode 100644 index 0000000..53e5c22 --- /dev/null +++ b/openspec/specs/docs-cookbook/spec.md @@ -0,0 +1,17 @@ +## Purpose +The Cookbook section documents runnable examples in the DataFast docs site and points readers to the authoritative source, prerequisites, and expected outputs for each example. +## Requirements +### Requirement: Cookbook navigation +The documentation site SHALL expose a Cookbook section in the MkDocs navigation, and each cookbook entry SHALL resolve to a Markdown page under `docs/`. + +#### Scenario: Cookbook section appears in navigation +- **WHEN** the documentation site configuration is loaded +- **THEN** the navigation includes a Cookbook section with an entry for the persona-generation cookbook + +### Requirement: Cookbook pages identify runnable source +Each cookbook page SHALL identify the authoritative executable source file, the runtime prerequisites, the expected local output location, and any Hugging Face dataset publication target for the example it documents. + +#### Scenario: Reader opens a cookbook page +- **WHEN** a reader opens the persona-generation cookbook page +- **THEN** the page shows the script path, required Mistral and Hugging Face configuration, the local output artifact path, and the dataset publication target needed to reproduce the example + diff --git a/openspec/specs/persona-generation-cookbook/spec.md b/openspec/specs/persona-generation-cookbook/spec.md new file mode 100644 index 0000000..0efd33a --- /dev/null +++ b/openspec/specs/persona-generation-cookbook/spec.md @@ -0,0 +1,38 @@ +## Purpose +The persona-generation cookbook documents a bounded, runnable DataFast workflow inspired by Persona Hub research and shows how generated personas can drive later synthetic-data creation. +## Requirements +### Requirement: Persona Hub-inspired persona workflow +The persona-generation cookbook SHALL implement a runnable DataFast workflow that explores Persona Hub-inspired `Text-to-Persona` and `Persona-to-Persona` methods from bounded sample inputs drawn from Hugging Face `xsum`, using Mistral AI as the documented LLM provider path. + +#### Scenario: Script generates personas from source texts +- **WHEN** a user runs the cookbook script with a configured Mistral model +- **THEN** the script selects up to the first twenty documents from the `validation` split whose lengths are between 300 and 500 words and produces output records that include personas inferred from source text and personas expanded from prior personas + +### Requirement: Prompt provenance is explicit +The cookbook SHALL distinguish between paper-aligned persona-generation prompts, repository-derived downstream prompt templates, and DataFast-specific prompt adaptations, and it SHALL NOT claim verbatim reproduction where the source material does not publish exact prompt strings. + +#### Scenario: Reader inspects prompt usage +- **WHEN** a reader reviews the cookbook code or documentation +- **THEN** each prompt used in the workflow is labeled by provenance and any paper-derived persona prompt is described as an adaptation rather than an exact reproduction + +### Requirement: Cookbook demonstrates downstream persona usage +The cookbook SHALL include at least one downstream persona-conditioned generation step implemented with DataFast to show how generated personas can drive later synthetic-data creation. + +#### Scenario: Script reaches downstream synthesis +- **WHEN** the cookbook script completes its final stage +- **THEN** the outputs include at least one artifact generated from a persona-conditioned prompt, such as a representative user request + +### Requirement: Standalone execution is documented and bounded +The cookbook SHALL be runnable as a standalone Python script with a bounded execution path suitable for manual verification and dataset publication. + +#### Scenario: User performs a smoke run +- **WHEN** a user executes the documented smoke-run command with configured Mistral and Hugging Face credentials +- **THEN** the script uses Mistral model id `mistral-small-2603`, processes only the documented bounded sample size, writes inspectable output artifacts, and publishes the resulting dataset without requiring repo code changes + +### Requirement: Cookbook publishes the final synthetic dataset +The persona-generation cookbook SHALL push the final synthetic records to a configured Hugging Face Hub dataset after local generation completes successfully. + +#### Scenario: Script publishes generated records +- **WHEN** the cookbook script reaches its final sink stage with a configured Hugging Face dataset repo and token +- **THEN** it pushes the same final record fields that are written locally to the configured dataset repository + From 2f079069bc07cffd8679516676de912b8eea936e Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 16 Apr 2026 10:22:26 +0200 Subject: [PATCH 07/54] modified prompts --- docs/cookbook/assets/persona_to_persona.txt | 11 ----------- docs/cookbook/assets/persona_to_persona_v1.txt | 11 +++++++++++ docs/cookbook/assets/persona_to_persona_v2.txt | 14 ++++++++++++++ docs/cookbook/assets/persona_to_persona_v3.txt | 16 ++++++++++++++++ ...prompt.txt => persona_to_user_prompt_v1.txt} | 2 -- .../assets/persona_to_user_prompt_v2.txt | 15 +++++++++++++++ .../assets/persona_to_user_prompt_v3.txt | 11 +++++++++++ docs/cookbook/assets/text_to_persona.txt | 11 ----------- docs/cookbook/assets/text_to_persona_v1.txt | 17 +++++++++++++++++ docs/cookbook/assets/text_to_persona_v2.txt | 16 ++++++++++++++++ docs/cookbook/assets/text_to_persona_v3.txt | 17 +++++++++++++++++ 11 files changed, 117 insertions(+), 24 deletions(-) delete mode 100644 docs/cookbook/assets/persona_to_persona.txt create mode 100644 docs/cookbook/assets/persona_to_persona_v1.txt create mode 100644 docs/cookbook/assets/persona_to_persona_v2.txt create mode 100644 docs/cookbook/assets/persona_to_persona_v3.txt rename docs/cookbook/assets/{persona_to_user_prompt.txt => persona_to_user_prompt_v1.txt} (81%) create mode 100644 docs/cookbook/assets/persona_to_user_prompt_v2.txt create mode 100644 docs/cookbook/assets/persona_to_user_prompt_v3.txt delete mode 100644 docs/cookbook/assets/text_to_persona.txt create mode 100644 docs/cookbook/assets/text_to_persona_v1.txt create mode 100644 docs/cookbook/assets/text_to_persona_v2.txt create mode 100644 docs/cookbook/assets/text_to_persona_v3.txt diff --git a/docs/cookbook/assets/persona_to_persona.txt b/docs/cookbook/assets/persona_to_persona.txt deleted file mode 100644 index 2913cfa..0000000 --- a/docs/cookbook/assets/persona_to_persona.txt +++ /dev/null @@ -1,11 +0,0 @@ -Given the following persona, infer one other specific persona who is in a close relationship with them. - -Persona: -{persona} - -Requirements: -1. Use one clear relationship. -2. Choose a related persona that adds a meaningfully different perspective. -3. Keep the related persona realistic and specific. -4. Return only one relationship expansion. - diff --git a/docs/cookbook/assets/persona_to_persona_v1.txt b/docs/cookbook/assets/persona_to_persona_v1.txt new file mode 100644 index 0000000..eabb6d6 --- /dev/null +++ b/docs/cookbook/assets/persona_to_persona_v1.txt @@ -0,0 +1,11 @@ +Given the following persona, infer one other specific persona who is in a close relationship with them. + +Persona: +{persona_description} + +Requirements: +1. Use one clear relationship such as family member, colleague, friend, or neighbor, coach, teacher, married partner. +2. Choose a related persona that adds a meaningfully different life perspective but is still likely to be in close contact with the original persona. +3. Keep the related persona realistic and specific. +4. Don't talk about the orginal person in the description of the related persona, as it should be self-contained description. +5. The related persona must be {related_life_stage}. Do not state a precise age, just reflect this life stage naturally. diff --git a/docs/cookbook/assets/persona_to_persona_v2.txt b/docs/cookbook/assets/persona_to_persona_v2.txt new file mode 100644 index 0000000..b4e4adf --- /dev/null +++ b/docs/cookbook/assets/persona_to_persona_v2.txt @@ -0,0 +1,14 @@ +Think of a person who regularly interacts with the following persona in a meaningful way. + +Rules: +- Do not mention the original persona in the description of the related persona. +- Do not mention the relationship between the two personas in the description, only in the relationship_type +- Pick a single, concrete relationship type such as mentor-mentee, colleague, neighbor, supervisor-report, or service provider-client +- The related person should bring a distinctly different viewpoint or expertise, and some uniqueness. +- Keep the description realistic and standalone without mentionning with the original persona. +- The related persona must be {related_life_stage}. Do not state a precise age, just reflect this life stage naturally. + +Original Persona: +{persona_description} + +Now generate a related persona. \ No newline at end of file diff --git a/docs/cookbook/assets/persona_to_persona_v3.txt b/docs/cookbook/assets/persona_to_persona_v3.txt new file mode 100644 index 0000000..9652161 --- /dev/null +++ b/docs/cookbook/assets/persona_to_persona_v3.txt @@ -0,0 +1,16 @@ +Here is the description of someone: + +{persona_description} + + +Come up with one other description of an individual who could be part of this persona's life. +We want the description to be detailed but super concise (max 2 sentences) and vivid. +But we want to have the a standalone description of that new persona without mentioning the original persona or a reason in the description. + +Requirements: +1. Define a clear interpersonal link such as friend, advisor, competitor, family member, or collaborator. +2. The new persona should offer a complementary or contrasting perspective. +3. Make the related persona vivid and believable, avoid generic archetypes. +4. Describe the relation in relationship_type field, not in the description. +5. The related persona must be {related_life_stage}. Do not state a precise age, just reflect this life stage naturally. + diff --git a/docs/cookbook/assets/persona_to_user_prompt.txt b/docs/cookbook/assets/persona_to_user_prompt_v1.txt similarity index 81% rename from docs/cookbook/assets/persona_to_user_prompt.txt rename to docs/cookbook/assets/persona_to_user_prompt_v1.txt index 260ed53..949fbcc 100644 --- a/docs/cookbook/assets/persona_to_user_prompt.txt +++ b/docs/cookbook/assets/persona_to_user_prompt_v1.txt @@ -7,5 +7,3 @@ Requirements: 1. The user prompt should be informative and specific. 2. The request should sound like something this persona would genuinely ask. 3. Keep it to a single prompt, not a conversation. -4. Do not mention that the persona was inferred from another text. - diff --git a/docs/cookbook/assets/persona_to_user_prompt_v2.txt b/docs/cookbook/assets/persona_to_user_prompt_v2.txt new file mode 100644 index 0000000..ad87aa3 --- /dev/null +++ b/docs/cookbook/assets/persona_to_user_prompt_v2.txt @@ -0,0 +1,15 @@ + + +Imagine the following person: + + +{related_persona} + + +The person is sitting down to use an AI assistant. What single, specific request could they possibly type? + +Requirements: +1. The prompt must be detailed and self-contained. +2. It should reflect this persona's unique knowledge, needs, or curiosity. +3. Output exactly one prompt, not a multi-turn dialogue. + diff --git a/docs/cookbook/assets/persona_to_user_prompt_v3.txt b/docs/cookbook/assets/persona_to_user_prompt_v3.txt new file mode 100644 index 0000000..45ef9a4 --- /dev/null +++ b/docs/cookbook/assets/persona_to_user_prompt_v3.txt @@ -0,0 +1,11 @@ +What is one realistic question or task that the persona described below would ask a large language model? + +Requirements: +1. Provide a single standalone prompt, not a series of follow-ups. +2. Make the request specific enough that the answer would genuinely help this persona. +3. The wording should feel natural — as if the persona typed it themselves. + +Persona: +{related_persona} + +Now come up with the prompt from that user. \ No newline at end of file diff --git a/docs/cookbook/assets/text_to_persona.txt b/docs/cookbook/assets/text_to_persona.txt deleted file mode 100644 index d71d2d7..0000000 --- a/docs/cookbook/assets/text_to_persona.txt +++ /dev/null @@ -1,11 +0,0 @@ -Infer one specific persona who is likely to read, write, or strongly engage with the following source text. - -Source text: -{document} - -Requirements: -1. Return a single persona, not a group. -2. Make the persona specific and fine-grained rather than generic. -3. Ground the persona in signals from the text such as domain, expertise, context, or likely motivation. -4. Do not quote the source text in the persona field. - diff --git a/docs/cookbook/assets/text_to_persona_v1.txt b/docs/cookbook/assets/text_to_persona_v1.txt new file mode 100644 index 0000000..cd09909 --- /dev/null +++ b/docs/cookbook/assets/text_to_persona_v1.txt @@ -0,0 +1,17 @@ +Infer one specific persona who is likely to read text. + +Source text: +{document} + +Requirements: +1. Return a single persona, not a group. +2. Make the persona specific and fine-grained rather than generic. +3. Ground the persona in signals from the text such as domain, expertise, context, or likely motivation. +4. Do not quote the source text in the persona field. +5. Only write 1 or 2 sentences maximum. +6. The persona is not the subject of the text, but rather someone who would be reading it. +7. Do not refer to the source text, article, or its content in the persona description. The persona must be self-contained. +8. The persona must be {life_stage}. Do not mention a precise age, just reflect this life stage naturally. + +Now figure out a persona description who would be reading this text. + diff --git a/docs/cookbook/assets/text_to_persona_v2.txt b/docs/cookbook/assets/text_to_persona_v2.txt new file mode 100644 index 0000000..294577d --- /dev/null +++ b/docs/cookbook/assets/text_to_persona_v2.txt @@ -0,0 +1,16 @@ + +{document} + + +Identify one precise individual who would naturally encounter or write the . + +Requirements: +1. Describe exactly one person. +2. Be as specific as possible: mention plausible occupation and/or life situation. +3. Derive the persona strictly from cues in the text such as topic, jargon, tone, or implied audience as a potential writter / reader of this text. +4. Do not copy or paraphrase the source text in the persona field. +5. Only return 1 or 2 sentences maximum. +6. The described person is not the subject of the text, but rather someone who would be encountering or writing such text as part of their life. +7. Do not reference the source text, article, or its content in the persona description. The persona must stand on its own. +8. The persona must be {life_stage}. Do not state a precise age, just reflect this life stage naturally. + diff --git a/docs/cookbook/assets/text_to_persona_v3.txt b/docs/cookbook/assets/text_to_persona_v3.txt new file mode 100644 index 0000000..3ccb077 --- /dev/null +++ b/docs/cookbook/assets/text_to_persona_v3.txt @@ -0,0 +1,17 @@ +You are a persona inference assistant. + +Based on the text content below, imagine one real person who would be interested in searching about the topic from this content. + +Rules: +- Output a single, concrete persona rather than a broad demographic. +- Include details like professional background, interests, or situational context that make the persona feel authentic. +- Don't mention the person search or information retrieval action in the persona description, just describe the persona which could explain their interest in the topic. +- Keep it super short and concise. +- Do not mention or refer to the source text, article, or its content in the persona description. The persona must be self-contained. +- The persona must be {life_stage}. Do not state a precise age, just reflect this life stage naturally. + +Source text: +{document} + + + From c3b28ae3de1c7ee331a50a497c8b01ce00aef87e Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 16 Apr 2026 10:23:15 +0200 Subject: [PATCH 08/54] Update persona cookbook to use OpenRouter with Nemotron model, expand to 100 samples, add life stage assignments, use multiple prompt variants, and push to Hugging Face Hub --- .../scripts/43_cookbook_persona_generation.py | 112 +++++++++++------- 1 file changed, 67 insertions(+), 45 deletions(-) diff --git a/examples/scripts/43_cookbook_persona_generation.py b/examples/scripts/43_cookbook_persona_generation.py index 4304c8f..20c36c8 100644 --- a/examples/scripts/43_cookbook_persona_generation.py +++ b/examples/scripts/43_cookbook_persona_generation.py @@ -10,45 +10,72 @@ - network access to Hugging Face and Mistral AI """ -import os +import random -from datafast import Filter, LLMStep, Map, Sample, Sink, Source, mistral +from dotenv import load_dotenv + +from datafast import Filter, LLMStep, Map, Sample, Sink, Source, openrouter import litellm +load_dotenv() + litellm.suppress_debug_info = True -MODEL_ID = "mistral-small-2603" -SAMPLE_SIZE = 2 +MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:nitro" OUTPUT_PATH = "examples/outputs/43_persona_cookbook.jsonl" -HF_REPO_ID_ENV = "PERSONA_COOKBOOK_HF_REPO_ID" -HF_PRIVATE_ENV = "PERSONA_COOKBOOK_HF_PRIVATE" -TEXT_TO_PERSONA_PROMPT = "docs/cookbook/assets/text_to_persona.txt" -PERSONA_TO_PERSONA_PROMPT = "docs/cookbook/assets/persona_to_persona.txt" -PERSONA_TO_USER_PROMPT = "docs/cookbook/assets/persona_to_user_prompt.txt" +HF_REPO_ID = "patrickfleith/new-persona-cookbook-dataset" +TEXT_TO_PERSONA_PROMPTS = [ + "docs/cookbook/assets/text_to_persona_v1.txt", + "docs/cookbook/assets/text_to_persona_v2.txt", + "docs/cookbook/assets/text_to_persona_v3.txt", +] +PERSONA_TO_PERSONA_PROMPTS = [ + "docs/cookbook/assets/persona_to_persona_v1.txt", + "docs/cookbook/assets/persona_to_persona_v2.txt", + "docs/cookbook/assets/persona_to_persona_v3.txt", +] +# PERSONA_TO_USER_PROMPTS = [ +# "docs/cookbook/assets/persona_to_user_prompt_v2.txt", +# "docs/cookbook/assets/persona_to_user_prompt_v3.txt", +# ] +LIFE_STAGES = [ + "a teenager", + "a young adult", + "an adult (30s/40s)", + "a middle-aged person (in their 50s/60s)", + "a senior person (in their 70s/80s)", +] def add_word_count(record: dict) -> dict: return {**record, "word_count": len(record["document"].split())} +def assign_life_stage(record: dict) -> dict: + return {**record, "life_stage": random.choice(LIFE_STAGES)} + + +def assign_related_life_stage(record: dict) -> dict: + return {**record, "related_life_stage": random.choice(LIFE_STAGES)} + + def keep_output_fields(record: dict) -> dict: return { "summary": record["summary"], "document": record["document"], "word_count": record["word_count"], - "persona": record["persona"], - "persona_basis": record["persona_basis"], + "life_stage": record["life_stage"], + "persona_description": record["persona_description"], "relationship_type": record["relationship_type"], - "related_persona": record["related_persona"], - "user_prompt": record["user_prompt"], - "prompt_basis": record["prompt_basis"], + "related_life_stage": record["related_life_stage"], + "related_persona_description": record["related_persona_description"], } def build_pipeline(): - model = mistral(MODEL_ID, temperature=0.7) + model = openrouter(MODEL_ID, temperature=0.7) return ( Source.huggingface( @@ -56,36 +83,31 @@ def build_pipeline(): split="validation", columns=["document", "summary"], ) - >> Map(add_word_count).as_step("add_word_count") - >> Filter(fn=lambda r: 300 <= r["word_count"] <= 500).as_step("filter_word_count") - >> Sample(n=SAMPLE_SIZE, strategy="first").as_step("take_first_twenty") - >> LLMStep( - prompt=TEXT_TO_PERSONA_PROMPT, - input_columns=["document"], - output_columns=["persona", "persona_basis"], - model=model, - parse_mode="json", - on_parse_error="raise", - ).as_step("text_to_persona") - >> LLMStep( - prompt=PERSONA_TO_PERSONA_PROMPT, - input_columns=["persona"], - output_columns=["relationship_type", "related_persona"], - model=model, - parse_mode="json", - on_parse_error="raise", - ).as_step("persona_to_persona") - >> LLMStep( - prompt=PERSONA_TO_USER_PROMPT, - input_columns=["related_persona"], - output_columns=["user_prompt", "prompt_basis"], - model=model, - parse_mode="json", - on_parse_error="raise", - ).as_step("persona_to_user_prompt") - >> Map(keep_output_fields).as_step("keep_output_fields") - >> Sink.jsonl(OUTPUT_PATH) - ) + >> Map(add_word_count).as_step("add_word_count") + >> Filter(fn=lambda r: 300 <= r["word_count"] <= 500).as_step("filter_word_count") + >> Sample(n=100, strategy="first").as_step("take_first_100") + >> Map(assign_life_stage).as_step("assign_life_stage") + >> LLMStep( + prompt=Sample(TEXT_TO_PERSONA_PROMPTS, n=1), + input_columns=["document", "life_stage"], + output_columns=["persona_description"], + model=model, + parse_mode="json", + on_parse_error="raise", + ).as_step("text_to_persona") + >> Map(assign_related_life_stage).as_step("assign_related_life_stage") + >> LLMStep( + prompt=Sample(PERSONA_TO_PERSONA_PROMPTS, n=1), + input_columns=["persona_description", "related_life_stage"], + output_columns=["relationship_type", "related_persona_description"], + model=model, + parse_mode="json", + on_parse_error="raise", + ).as_step("persona_to_persona") + >> Map(keep_output_fields).as_step("keep_output_fields") + >> Sink.jsonl(OUTPUT_PATH) + >> Sink.hub(HF_REPO_ID, private=True) +) def push_records_to_hub(records: list[dict]) -> None: From 6912ba2552795967995801268b813dcd75dac23e Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 16 Apr 2026 10:25:35 +0200 Subject: [PATCH 09/54] Update persona cookbook documentation to reflect prompt variant randomization, reduced sample size, and simplified pipeline structure --- docs/cookbook/assets/index.md | 52 ++++++++++------- docs/cookbook/index.md | 2 +- docs/cookbook/persona_generation.md | 91 ++++++++++++----------------- 3 files changed, 71 insertions(+), 74 deletions(-) diff --git a/docs/cookbook/assets/index.md b/docs/cookbook/assets/index.md index 8eacc96..b57ae2e 100644 --- a/docs/cookbook/assets/index.md +++ b/docs/cookbook/assets/index.md @@ -1,30 +1,42 @@ # Persona Cookbook Assets -This note records the supporting assets used by the persona-generation cookbook. +Prompt files and dataset details used by the persona-generation cookbook. -## Dataset Selection +## Dataset -- Dataset: `xsum` -- Split: `validation` -- Text field: `document` -- Summary field kept for inspection: `summary` -- Selection rule: keep documents whose whitespace-tokenized word counts are between `300` and `500` -- Cap: use the first `20` matching records +- **Source:** `xsum` (Hugging Face), `validation` split +- **Fields used:** `document`, `summary` +- **Filter:** 300–500 words, first 5 matches -This keeps the cookbook deterministic and bounded while still using a well-known Hugging Face corpus with article lengths that fit the demonstration. +## Prompt Variants -`GEM/xsum` was the original candidate, but the current `datasets` stack in this repo no longer supports dataset-script based loading for that asset. The script therefore uses the scriptless `xsum` dataset, which exposes the same `document` and summary-style fields needed for the cookbook. +Each LLM step picks one prompt at random per record. Multiple variants add diversity. -## Prompt Assets +### Text-to-Persona -| Asset | Provenance | Purpose | -| --- | --- | --- | -| [text_to_persona.txt](text_to_persona.txt) | `paper-aligned` | Infer one specific persona from a source text | -| [persona_to_persona.txt](persona_to_persona.txt) | `paper-aligned` | Expand a persona through one close relationship | -| [persona_to_user_prompt.txt](persona_to_user_prompt.txt) | `repository-derived` | Generate a representative user prompt from a persona | +| File | Style | +| --- | --- | +| [text_to_persona_v1.txt](text_to_persona_v1.txt) | Direct inference of a reader persona | +| [text_to_persona_v2.txt](text_to_persona_v2.txt) | XML-tagged source text, writer/reader framing | +| [text_to_persona_v3.txt](text_to_persona_v3.txt) | System-role preamble, search-interest angle | -## Provenance Notes +### Persona-to-Persona -- The Persona Hub paper describes `Text-to-Persona` and `Persona-to-Persona`, but it explicitly says the prompts shown in figures are simplified rather than the exact experiment strings. -- The `persona_to_user_prompt` asset is derived from the repository prompt family for instruction generation and adapted to return JSON fields that fit DataFast. -- The cookbook does not reuse Persona Hub code. It reimplements the workflow with DataFast primitives. +| File | Style | +| --- | --- | +| [persona_to_persona_v1.txt](persona_to_persona_v1.txt) | Close relationship, standalone description | +| [persona_to_persona_v2.txt](persona_to_persona_v2.txt) | Rule-list format, explicit separation of description and relationship | +| [persona_to_persona_v3.txt](persona_to_persona_v3.txt) | XML-tagged input, concise vivid output | + +### Persona-to-User-Prompt (not in current pipeline) + +| File | Style | +| --- | --- | +| [persona_to_user_prompt_v2.txt](persona_to_user_prompt_v2.txt) | XML-tagged person, AI assistant framing | +| [persona_to_user_prompt_v3.txt](persona_to_user_prompt_v3.txt) | Requirements-first ordering | + +## Provenance + +- Text-to-Persona and Persona-to-Persona prompts are paper-aligned adaptations. The Persona Hub paper states its published prompts are simplified, not exact. +- User-prompt variants are derived from the repository's instruction-generation prompt family. +- No Persona Hub code is reused. The workflow is built with DataFast primitives. diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md index 03fc75b..e97413b 100644 --- a/docs/cookbook/index.md +++ b/docs/cookbook/index.md @@ -11,4 +11,4 @@ The Python script is the source of truth. Each cookbook page explains: ## Available Cookbooks -- [Persona Generation](persona_generation.md): infer personas from real source texts, expand them through relationships, and generate representative user prompts with DataFast. +- [Persona Generation](persona_generation.md): infer personas from real articles and expand them through relationships using randomized prompt variants. diff --git a/docs/cookbook/persona_generation.md b/docs/cookbook/persona_generation.md index cd9a81c..ee842b4 100644 --- a/docs/cookbook/persona_generation.md +++ b/docs/cookbook/persona_generation.md @@ -1,80 +1,65 @@ # Persona Generation -This cookbook shows how to implement a Persona Hub-inspired workflow with DataFast without reusing Persona Hub code. +Build personas from real articles and expand them through relationships. Inspired by the Persona Hub paper, implemented entirely with DataFast. -## Runnable Source +## Source -- Script: `examples/scripts/43_cookbook_persona_generation.py` -- Prompt assets: [asset index](assets/index.md) -- Output artifact: `examples/outputs/43_persona_cookbook.jsonl` -- Dataset publication target: the Hugging Face dataset repo in `PERSONA_COOKBOOK_HF_REPO_ID` +- **Script:** `examples/scripts/43_cookbook_persona_generation.py` +- **Prompt assets:** [asset index](assets/index.md) +- **Output:** pushed to a private Hugging Face Hub dataset -## What The Script Does +## Pipeline -The pipeline is intentionally small: +1. Load `xsum` articles (`validation` split). +2. Filter to documents between 300 and 500 words. Keep the first 5. +3. **Text-to-Persona** — infer one persona from each article. +4. **Persona-to-Persona** — expand that persona into a related individual. +5. Push results to Hugging Face Hub. -1. Load `xsum` articles from the `validation` split. -2. Keep only the first `20` documents whose word counts fall between `300` and `500`. -3. Infer one likely persona from each article with a `Text-to-Persona` prompt. -4. Expand that persona into a closely related persona with a `Persona-to-Persona` prompt. -5. Generate one representative user prompt for the related persona. -6. Write the final records to local JSONL and publish the same rows to Hugging Face Hub. +Each LLM step randomly picks one prompt variant per record using `Sample(prompts, n=1)`. This adds diversity across generations. ```text -GEM/xsum article - | - v -Text-to-Persona - | - v -Persona-to-Persona - | - v -Representative user prompt +xsum article + │ + ▼ +Text-to-Persona (random prompt from 3 variants) + │ + ▼ +Persona-to-Persona (random prompt from 3 variants) + │ + ▼ +Hugging Face Hub ``` ## Run Prerequisites: -- `MISTRAL_API_KEY` is set -- `PERSONA_COOKBOOK_HF_REPO_ID` points at the target Hugging Face dataset repo, for example `your-name/persona-cookbook-43` -- Hugging Face authentication is available through `HF_TOKEN` or a cached `huggingface_hub` login -- the project environment has the base dependencies from `pyproject.toml` -- the script uses the Mistral model `mistral-small-2603` - -Example: +- `OPENROUTER_API_KEY` and `HF_TOKEN` set in a `.env` file +- Base dependencies from `pyproject.toml` installed ```bash -.venv/bin/python examples/scripts/43_cookbook_persona_generation.py +python examples/scripts/43_cookbook_persona_generation.py ``` -By default the dataset push is private. Set `PERSONA_COOKBOOK_HF_PRIVATE=false` if you want the published dataset to be public. +## Prompt Variants -## Prompt Summary +Each step draws from multiple prompt files stored under `docs/cookbook/assets/`. See the [asset index](assets/index.md) for the full list. -The cookbook keeps full prompts in asset files rather than embedding them here. +- **Text-to-Persona:** 3 variants (`text_to_persona_v1.txt`, `v2`, `v3`) +- **Persona-to-Persona:** 3 variants (`persona_to_persona_v1.txt`, `v2`, `v3`) -- [Text-to-Persona prompt](assets/text_to_persona.txt): a paper-aligned adaptation that infers one specific persona from a source text. -- [Persona-to-Persona prompt](assets/persona_to_persona.txt): a paper-aligned adaptation that expands a persona through one close relationship. -- [Persona-to-User-Prompt prompt](assets/persona_to_user_prompt.txt): a repository-derived prompt that asks for one realistic user request from the generated persona. +Additional prompt variants for user-prompt generation are available (`persona_to_user_prompt_v2.txt`, `v3`) but not used in the current pipeline. ## Research Basis -The Persona Hub paper introduces `Text-to-Persona` and `Persona-to-Persona` as scalable persona-construction methods from web text. It also states that the prompts shown in the paper figures are simplified rather than the exact strings used in experiments, so this cookbook treats those persona-construction prompts as paper-aligned adaptations rather than verbatim reproductions. - -For downstream prompt generation, the repository publishes a prompt family for persona-conditioned instruction generation. This cookbook adapts that idea to a DataFast JSON workflow and keeps the full asset path visible in the [asset index](assets/index.md). - -## Output Shape +The Persona Hub paper introduces Text-to-Persona and Persona-to-Persona as scalable methods for building personas from web text. The paper states that its published prompts are simplified, not the exact experiment strings. This cookbook treats them as paper-aligned adaptations. It does not reuse any Persona Hub code. -The local JSONL output and the published Hugging Face dataset keep the same fields for inspection: +## Output Fields -- `summary` -- `document` -- `word_count` -- `persona` -- `persona_basis` -- `relationship_type` -- `related_persona` -- `user_prompt` -- `prompt_basis` +- `summary` — original article summary +- `document` — source article text +- `word_count` — whitespace token count +- `persona_description` — inferred persona +- `relationship_type` — link between the two personas +- `related_persona_description` — the expanded related persona From f285b40e632f2f46173fd79f277e2e44798fa4cf Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 17 Apr 2026 08:26:03 +0200 Subject: [PATCH 10/54] Add .agents/ to gitignore and remove archived persona cookbook openspec artifacts --- .gitignore | 3 +- .../.openspec.yaml | 2 - .../2026-04-05-add-persona-cookbook/design.md | 139 ------------------ .../proposal.md | 28 ---- .../specs/docs-cookbook/spec.md | 15 -- .../specs/persona-generation-cookbook/spec.md | 29 ---- .../2026-04-05-add-persona-cookbook/tasks.md | 19 --- .../.openspec.yaml | 2 - .../design.md | 92 ------------ .../proposal.md | 27 ---- .../specs/docs-cookbook/spec.md | 8 - .../specs/persona-generation-cookbook/spec.md | 24 --- .../tasks.md | 15 -- openspec/config.yaml | 20 --- openspec/specs/docs-cookbook/spec.md | 17 --- .../specs/persona-generation-cookbook/spec.md | 38 ----- 16 files changed, 2 insertions(+), 476 deletions(-) delete mode 100644 openspec/changes/archive/2026-04-05-add-persona-cookbook/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-04-05-add-persona-cookbook/design.md delete mode 100644 openspec/changes/archive/2026-04-05-add-persona-cookbook/proposal.md delete mode 100644 openspec/changes/archive/2026-04-05-add-persona-cookbook/specs/docs-cookbook/spec.md delete mode 100644 openspec/changes/archive/2026-04-05-add-persona-cookbook/specs/persona-generation-cookbook/spec.md delete mode 100644 openspec/changes/archive/2026-04-05-add-persona-cookbook/tasks.md delete mode 100644 openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/design.md delete mode 100644 openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/proposal.md delete mode 100644 openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/docs-cookbook/spec.md delete mode 100644 openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/persona-generation-cookbook/spec.md delete mode 100644 openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/tasks.md delete mode 100644 openspec/config.yaml delete mode 100644 openspec/specs/docs-cookbook/spec.md delete mode 100644 openspec/specs/persona-generation-cookbook/spec.md diff --git a/.gitignore b/.gitignore index 3c8264e..819ffa5 100644 --- a/.gitignore +++ b/.gitignore @@ -186,4 +186,5 @@ secrets.env examples/checkpoints/ examples/outputs/ -.codex/ \ No newline at end of file +.codex/ +.agents/ \ No newline at end of file diff --git a/openspec/changes/archive/2026-04-05-add-persona-cookbook/.openspec.yaml b/openspec/changes/archive/2026-04-05-add-persona-cookbook/.openspec.yaml deleted file mode 100644 index c551aea..0000000 --- a/openspec/changes/archive/2026-04-05-add-persona-cookbook/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-05 diff --git a/openspec/changes/archive/2026-04-05-add-persona-cookbook/design.md b/openspec/changes/archive/2026-04-05-add-persona-cookbook/design.md deleted file mode 100644 index c712d75..0000000 --- a/openspec/changes/archive/2026-04-05-add-persona-cookbook/design.md +++ /dev/null @@ -1,139 +0,0 @@ -## Context - -DataFast already separates runnable examples (`examples/scripts/`) from rendered documentation (`docs/`), but it does not yet have a cookbook layer that ties a real script to a narrative, site-facing walkthrough. This change introduces that pattern and uses a Persona Hub-inspired persona-generation example as the first cookbook because the repo already exposes the core primitives needed for it: `Source`/`Seed`, `LLMStep`, and `Sink`. - -The research constraint is important. Persona Hub publishes the high-level methods for persona construction in the paper, but the paper also states that figure prompts are simplified rather than the exact experiment strings. The repository publishes exact prompt templates for persona-conditioned synthesis tasks such as instruction generation, but not a full canonical `Text-to-Persona` implementation prompt. The design therefore needs an explicit provenance model so DataFast can be faithful to the method without overstating prompt fidelity. - -## Goals / Non-Goals - -**Goals:** -- Add a reusable cookbook pattern that connects docs navigation, a runnable script, and an explanatory Markdown page. -- Implement a first cookbook that demonstrates Persona Hub-inspired persona creation with DataFast, centered on `Text-to-Persona` and `Persona-to-Persona`. -- Preserve provenance by separating paper-aligned prompts, repository-derived templates, and DataFast-specific prompt adaptations. -- Keep the example practically runnable with a bounded sample size and explicit provider prerequisites. - -**Non-Goals:** -- Reproducing Persona Hub’s full billion-persona data pipeline or dataset scale. -- Reusing or vendoring Persona Hub code. -- Adding new public DataFast APIs solely for this cookbook. -- Building an automated docs-code sync system beyond what is needed for the first cookbook. - -## Decisions - -### 1. Keep executable source in `examples/scripts/` and renderable narrative in `docs/cookbook/` - -The runnable example will follow the repo’s existing convention and live under `examples/scripts/`. The new cookbook section will live under `docs/cookbook/`, with `mkdocs.yml` updated to expose it in navigation. - -Why: -- The repo already teaches runnable examples from `examples/scripts/`. -- Keeping executable code there avoids turning `docs/` into a mixed content area full of Python files and generated artifacts. -- The cookbook page can still act as the canonical reader-facing entry point while pointing to the authoritative script. - -Alternative considered: -- Store the script directly under `docs/cookbook/`. Rejected because it breaks current example organization and makes docs content noisier to maintain. - -### 2. Use prompt assets with explicit provenance labels - -Prompt text used by the cookbook will be stored as dedicated assets instead of being embedded only inside Python strings. Each prompt asset will be labeled as one of: -- paper-aligned adaptation -- repository-derived template -- DataFast-specific adaptation - -Why: -- The paper gives simplified prompt forms for persona creation, not exact experiment strings. -- The repository does publish exact downstream templates such as instruction synthesis. -- External prompt assets make it easier to show provenance in both the script and the cookbook page without duplicating large prompt blocks. - -Alternative considered: -- Inline all prompts in the script. Rejected because provenance becomes harder to audit and the doc page is more likely to drift from the executable source. - -### 3. Model the cookbook as a small multi-stage DataFast pipeline - -The first cookbook script will be structured as a bounded pipeline with three stages: -- source text to inferred persona (`Text-to-Persona`) -- persona to related persona (`Persona-to-Persona`) -- downstream persona-conditioned generation via a representative user-prompt generation step inspired by the Persona Hub repository template - -Outputs should be written to JSONL so the script leaves inspectable artifacts after execution. - -Why: -- This demonstrates both persona construction methods from the paper and one concrete persona-driven synthesis pattern from the repository. -- JSONL outputs fit existing repo conventions and are easy to inspect in docs. -- A multi-stage pipeline shows DataFast’s composition model better than a single prompt call. - -Alternative considered: -- Stop after the first persona-generation stage. Rejected because it under-explains the value of generated personas and misses the strongest link to the repository prompt templates. - -### 4. Use `xsum` validation articles as the seed corpus - -The cookbook will draw source texts from Hugging Face `xsum`, using the `validation` split and selecting up to the first five documents whose lengths fall between 300 and 500 words. - -Why: -- `xsum` is a well-known Hugging Face dataset with concise full articles that fit the desired demonstration size well. -- The `validation` split gives a stable, reproducible sample source for the cookbook. -- Limiting the selection to at most five articles keeps the run small enough for a cookbook while still showing multiple persona inferences. -- It works with the current `datasets>=3.0` stack in this repo, whereas `GEM/xsum` now depends on a dataset-script loading path that is no longer supported here. - -Alternative considered: -- Use `GEM/xsum`. Rejected during implementation because the current `datasets` dependency no longer supports the required dataset script. - -### 5. Favor structured outputs where practical - -The script should ask the model for structured fields where that improves inspectability, for example: -- inferred persona description -- relationship type used for expansion -- related persona description -- downstream user prompt or artifact - -Why: -- Structured output is easier to verify manually. -- It produces cleaner JSONL for later cookbook rendering. -- It reduces ambiguity when comparing prompt variants. - -Alternative considered: -- Use only free-form text outputs. Rejected because the resulting artifacts are harder to reuse in documentation. - -### 6. Use OpenRouter as the documented execution path - -The cookbook will document and smoke-test an OpenRouter-based execution path using `nvidia/nemotron-3-super-120b-a12b` as the default model id. - -Why: -- The user selected OpenRouter as the desired provider. -- It matches existing repo examples and keeps the cookbook aligned with current usage patterns. -- A single concrete provider path reduces ambiguity in the first cookbook. - -Alternative considered: -- Keep the smoke-run path provider-agnostic. Rejected because it weakens reproducibility for the initial cookbook. - -### 7. Require explicit runtime prerequisites instead of CI-level execution guarantees - -The cookbook will be documented as a real runnable script that requires a configured LLM provider. The implementation should keep sample sizes small and output paths explicit, but it will not require new test infrastructure or a fake public provider abstraction. - -Why: -- The repo’s current examples already assume a configured provider. -- No public fake-provider path exists for full end-to-end example execution. -- This keeps the first cookbook lightweight and consistent with existing example ergonomics. - -Alternative considered: -- Introduce a fake provider or snapshot-based offline harness only for cookbook validation. Rejected for this change because it expands scope beyond the user request. - -## Risks / Trade-offs - -- [Prompt fidelity ambiguity] → Label prompts by provenance and explicitly state that the paper’s displayed persona-creation prompts are simplified, not exact experiment strings. -- [Docs/example drift] → Make the Python script the implementation source of truth and have the cookbook page reference concrete script and prompt asset paths. -- [Networked example friction] → Keep runs bounded, document provider prerequisites clearly, and use a small default sample size for manual execution. -- [Cookbook scope creep] → Limit v1 to a single persona-generation cookbook and a minimal cookbook section in navigation. - -## Migration Plan - -1. Add the cookbook docs section and navigation entry. -2. Add prompt/reference assets with provenance notes. -3. Implement the runnable persona-generation script using `xsum` validation articles, capped at the first five 300 to 500 word documents, and write outputs to a stable example output path. -4. Author the cookbook Markdown page that explains the workflow and references the script outputs. -5. Run a bounded manual smoke test with OpenRouter and capture the expected invocation in the docs. - -Rollback is straightforward because the change is additive: remove the cookbook nav entry and the newly added docs/example files. - -## Open Questions - -- Whether the word-count filter should use a simple whitespace tokenization or a slightly stricter normalization rule before selecting the five `xsum` documents. diff --git a/openspec/changes/archive/2026-04-05-add-persona-cookbook/proposal.md b/openspec/changes/archive/2026-04-05-add-persona-cookbook/proposal.md deleted file mode 100644 index 7b394d2..0000000 --- a/openspec/changes/archive/2026-04-05-add-persona-cookbook/proposal.md +++ /dev/null @@ -1,28 +0,0 @@ -## Why - -DataFast has runnable examples and narrative docs, but it does not yet have a cookbook area that connects a real executable script to a documentation-page walkthrough. A persona-generation cookbook is a strong first entry because it showcases a realistic synthetic-data workflow and lets DataFast demonstrate a paper-aligned reimplementation of Persona Hub ideas without copying their code. - -## What Changes - -- Add a cookbook section under `docs/` and expose it in the MkDocs navigation. -- Establish a cookbook pattern where the executable Python script is authored first and the documentation page is built from that runnable example. -- Add the first cookbook for persona generation, implemented with DataFast primitives and explicitly inspired by Persona Hub’s `Text-to-Persona`, `Persona-to-Persona`, and persona-conditioned prompting ideas. -- Document the research basis of the cookbook, including which prompt patterns come from the paper or repository and which parts are DataFast-specific adaptations. -- Keep the implementation independent from Persona Hub code: reuse methodology and prompt logic where appropriate, but do not vendor or call their code. - -## Capabilities - -### New Capabilities -- `docs-cookbook`: Provide a cookbook area in the documentation for executable, real-world DataFast examples that can later render cleanly on the docs site. -- `persona-generation-cookbook`: Provide a first cookbook that explores persona generation with DataFast using Persona Hub-inspired methods, prompts, and workflow notes. - -### Modified Capabilities - -- None. - -## Impact - -- Affected docs and site navigation in `docs/` and `mkdocs.yml`. -- New runnable cookbook source files and supporting prompt/reference material. -- No public DataFast API changes are required. -- Requires explicit handling of provider prerequisites and provenance notes because the Persona Hub paper states that figure prompts are simplified rather than exact experiment strings. diff --git a/openspec/changes/archive/2026-04-05-add-persona-cookbook/specs/docs-cookbook/spec.md b/openspec/changes/archive/2026-04-05-add-persona-cookbook/specs/docs-cookbook/spec.md deleted file mode 100644 index a2731cb..0000000 --- a/openspec/changes/archive/2026-04-05-add-persona-cookbook/specs/docs-cookbook/spec.md +++ /dev/null @@ -1,15 +0,0 @@ -## ADDED Requirements - -### Requirement: Cookbook navigation -The documentation site SHALL expose a Cookbook section in the MkDocs navigation, and each cookbook entry SHALL resolve to a Markdown page under `docs/`. - -#### Scenario: Cookbook section appears in navigation -- **WHEN** the documentation site configuration is loaded -- **THEN** the navigation includes a Cookbook section with an entry for the persona-generation cookbook - -### Requirement: Cookbook pages identify runnable source -Each cookbook page SHALL identify the authoritative executable source file, the runtime prerequisites, and the expected output location for the example it documents. - -#### Scenario: Reader opens a cookbook page -- **WHEN** a reader opens the persona-generation cookbook page -- **THEN** the page shows the script path, required provider configuration, and the output artifact path needed to reproduce the example diff --git a/openspec/changes/archive/2026-04-05-add-persona-cookbook/specs/persona-generation-cookbook/spec.md b/openspec/changes/archive/2026-04-05-add-persona-cookbook/specs/persona-generation-cookbook/spec.md deleted file mode 100644 index b1dd4c6..0000000 --- a/openspec/changes/archive/2026-04-05-add-persona-cookbook/specs/persona-generation-cookbook/spec.md +++ /dev/null @@ -1,29 +0,0 @@ -## ADDED Requirements - -### Requirement: Persona Hub-inspired persona workflow -The persona-generation cookbook SHALL implement a runnable DataFast workflow that explores Persona Hub-inspired `Text-to-Persona` and `Persona-to-Persona` methods from bounded sample inputs drawn from Hugging Face `xsum`. - -#### Scenario: Script generates personas from source texts -- **WHEN** a user runs the cookbook script with a configured OpenRouter model -- **THEN** the script selects up to the first five documents from the `validation` split whose lengths are between 300 and 500 words and produces output records that include personas inferred from source text and personas expanded from prior personas - -### Requirement: Prompt provenance is explicit -The cookbook SHALL distinguish between paper-aligned persona-generation prompts, repository-derived downstream prompt templates, and DataFast-specific prompt adaptations, and it SHALL NOT claim verbatim reproduction where the source material does not publish exact prompt strings. - -#### Scenario: Reader inspects prompt usage -- **WHEN** a reader reviews the cookbook code or documentation -- **THEN** each prompt used in the workflow is labeled by provenance and any paper-derived persona prompt is described as an adaptation rather than an exact reproduction - -### Requirement: Cookbook demonstrates downstream persona usage -The cookbook SHALL include at least one downstream persona-conditioned generation step implemented with DataFast to show how generated personas can drive later synthetic-data creation. - -#### Scenario: Script reaches downstream synthesis -- **WHEN** the cookbook script completes its final stage -- **THEN** the outputs include at least one artifact generated from a persona-conditioned prompt, such as a representative user request - -### Requirement: Standalone execution is documented and bounded -The cookbook SHALL be runnable as a standalone Python script with a bounded execution path suitable for manual verification. - -#### Scenario: User performs a smoke run -- **WHEN** a user executes the documented smoke-run command -- **THEN** the script uses OpenRouter with model id `nvidia/nemotron-3-super-120b-a12b`, processes only the documented bounded sample size, and writes inspectable output artifacts without requiring repo code changes diff --git a/openspec/changes/archive/2026-04-05-add-persona-cookbook/tasks.md b/openspec/changes/archive/2026-04-05-add-persona-cookbook/tasks.md deleted file mode 100644 index 181e453..0000000 --- a/openspec/changes/archive/2026-04-05-add-persona-cookbook/tasks.md +++ /dev/null @@ -1,19 +0,0 @@ -## 1. Cookbook scaffolding - -- [x] 1.1 Add the Cookbook section to `mkdocs.yml` and create the base `docs/cookbook/` pages needed for navigation. -- [x] 1.2 Add prompt/reference assets for the persona cookbook and label each asset with its provenance (`paper-aligned`, `repository-derived`, or `DataFast adaptation`). -- [x] 1.3 Add a dataset-selection note for `xsum` `validation`, including the 300 to 500 word filter and first-five cap used by the example. - -## 2. Runnable persona example - -- [x] 2.1 Implement the standalone persona-generation script in `examples/scripts/` with `xsum` `validation` inputs, a 300 to 500 word filter, a first-five cap, and JSONL outputs. -- [x] 2.2 Add the `Text-to-Persona` and `Persona-to-Persona` stages using DataFast primitives and structured outputs where practical. -- [x] 2.3 Add a downstream persona-conditioned user-prompt generation stage that demonstrates how the generated personas drive a later synthetic-data step. -- [x] 2.4 Perform a bounded smoke run with OpenRouter and confirm the documented command produces inspectable output artifacts. - -## 3. Cookbook documentation - -- [x] 3.1 Write the persona-generation cookbook page under `docs/cookbook/` with prerequisites, script path, run command, output path, and explanation of the workflow. -- [x] 3.2 Document the research basis and prompt provenance, including the limitation that Persona Hub’s paper shows simplified persona-creation prompts rather than exact experiment strings. -- [x] 3.3 Summarize the key prompts in the cookbook page and link to the prompt asset files instead of embedding full prompt text inline. -- [x] 3.4 Review the cookbook navigation and page content to ensure the docs site can expose the new section cleanly. diff --git a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/.openspec.yaml b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/.openspec.yaml deleted file mode 100644 index c551aea..0000000 --- a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-05 diff --git a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/design.md b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/design.md deleted file mode 100644 index 35ea2a3..0000000 --- a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/design.md +++ /dev/null @@ -1,92 +0,0 @@ -## Context - -Cookbook 43 already provides a runnable persona-generation example and a matching docs page, but the current implementation is tuned for a small OpenRouter smoke run and stops at a local JSONL artifact. The requested update changes three user-visible behaviors at once: the documented LLM path moves to DataFast's native Mistral provider, the bounded run expands from five records to twenty, and the final synthetic records must be publishable to Hugging Face Hub. - -This change stays within the existing cookbook pattern. The repo already exposes `mistral(...)` for provider construction and `Sink.hub(...)` for dataset publication, so the main design work is deciding how Cookbook 43 will compose those existing primitives without losing the bounded, inspectable nature of the example. - -## Goals / Non-Goals - -**Goals:** -- Update Cookbook 43 so its documented execution path uses Mistral AI instead of OpenRouter. -- Expand the bounded synthetic-data run from 5 to 20 filtered `xsum` validation records. -- Preserve a local inspectable output artifact while also publishing the final records to Hugging Face Hub. -- Document the exact runtime prerequisites needed for both generation and publishing. - -**Non-Goals:** -- Changing the Persona Hub-inspired prompt assets or provenance labels beyond what is needed for provider and publication updates. -- Generalizing every cookbook to support automatic dataset publication. -- Adding new public DataFast sink APIs or a new provider abstraction. -- Turning the cookbook into an unbounded production pipeline. - -## Decisions - -### 1. Use DataFast's native Mistral provider helper as the cookbook default - -Cookbook 43 will switch from `openrouter(...)` to `mistral(...)` and document `MISTRAL_API_KEY` as the required LLM credential. The documented model path will be pinned to `mistral-small-2603`. - -Why: -- The repo already ships and tests a first-party Mistral provider. -- This makes the cookbook align with a provider that DataFast owns directly instead of routing through OpenRouter. -- Reusing the helper keeps the example consistent with existing provider ergonomics. - -Alternative considered: -- Keep the script provider-agnostic. Rejected because the user asked for a concrete switch to Mistral AI and the docs need a reproducible path. - -### 2. Keep the `xsum` validation workflow and raise only the bounded sample cap - -The cookbook will continue to source records from `xsum` `validation`, apply the existing 300 to 500 word filter, and use `Sample(..., strategy=\"first\")` for deterministic selection. Only the cap changes, from 5 to 20. - -Why: -- This preserves the original example's reproducibility and keeps the change narrowly focused on requested scale. -- Twenty records is still small enough for cookbook verification while being materially more representative than five. -- Holding the dataset and filter constant isolates the effect of the provider and publication changes. - -Alternative considered: -- Switch to random sampling or a different dataset. Rejected because it adds unnecessary variability and moves the change away from Cookbook 43's current behavior. - -### 3. Publish the final synthetic records to Hugging Face Hub while retaining local JSONL output - -The final transformed records should remain inspectable in `examples/outputs/43_persona_cookbook.jsonl`, and the same final schema should also be pushed to Hugging Face Hub through `Sink.hub(...)`. The cookbook should treat local JSONL as the audit artifact and the Hub dataset as the publication artifact. - -The Hub destination should be runtime-configurable instead of hard-coded, using the standard `HF_TOKEN` plus a dataset repo identifier supplied through configuration that the script can read without requiring source edits. - -Why: -- The user asked to push the resulting dataset, but cookbook readers still need a simple local file they can inspect immediately. -- `Sink.hub(...)` already implements dataset creation and push semantics, so the example can stay within existing APIs. -- A configurable repo id avoids baking a personal or temporary dataset namespace into the repository. - -Alternative considered: -- Replace the JSONL sink entirely with a Hub-only push. Rejected because it weakens local inspectability and makes failure analysis harder. - -### 4. Expand the cookbook docs to describe both generation and publication prerequisites - -`docs/cookbook/persona_generation.md` should document the authoritative script, prompt assets, local output path, Mistral credential requirements, Hugging Face publication requirements, and the configured dataset target. The page should also update its workflow summary from five samples to twenty and explain that the final records are published after generation. - -Why: -- The current docs spec already treats the cookbook page as the reader-facing source of truth. -- Provider and publication configuration are both runtime prerequisites, not hidden implementation details. -- Cookbook readers need to know where artifacts end up locally and remotely. - -Alternative considered: -- Document only the script change and leave Hub publication discoverable from code. Rejected because it would violate the cookbook pattern of making execution requirements explicit. - -## Risks / Trade-offs - -- [Higher run cost and latency from 20 samples] → Keep deterministic first-20 sampling and preserve the cookbook's bounded scope. -- [Publishing requires user-specific dataset configuration] → Use standard Hugging Face credentials and a runtime-configurable repo id rather than a hard-coded namespace. -- [Local and remote outputs can drift] → Push the same final record shape that is written to JSONL and keep the field-selection step immediately before both sinks. -- [Hub push failures can obscure generation failures] → Preserve local JSONL output so generation artifacts remain inspectable even if publication fails. - -## Migration Plan - -1. Update the Cookbook 43 script to construct a Mistral provider, keep the existing `xsum` filter, and raise the bounded sample cap to 20. -2. Add publication configuration and wire the final record stream to both local JSONL output and a Hugging Face Hub sink. -3. Revise the cookbook docs to describe Mistral setup, Hugging Face publication requirements, the 20-sample behavior, and the local plus remote outputs. -4. Run a bounded manual verification path that confirms the documented configuration and output locations are coherent. - -Rollback is straightforward: revert the script and docs to the previous OpenRouter plus JSONL-only flow and remove the publication configuration path. - -## Open Questions - -- Which repo-level configuration mechanism should hold the Hugging Face dataset repo id for the example: a dedicated environment variable, a top-of-script constant, or a small CLI argument surface? -- Whether the published dataset should remain private by default or be documented as user-selectable at runtime. diff --git a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/proposal.md b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/proposal.md deleted file mode 100644 index a2dccf2..0000000 --- a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/proposal.md +++ /dev/null @@ -1,27 +0,0 @@ -## Why - -Cookbook 43 currently demonstrates persona-conditioned synthetic data generation with OpenRouter and a five-record sample cap, which is too narrow for the larger synthetic-data workflow the repo now wants to showcase. The next revision should reflect a first-party Mistral AI execution path, produce a more representative 20-sample output set, and document publishing the resulting dataset to Hugging Face Hub. - -## What Changes - -- Update `examples/scripts/43_cookbook_persona_generation.py` to use the repo's Mistral provider path instead of OpenRouter. -- Expand the bounded sample size from the first `5` eligible `xsum` validation records to the first `20`. -- Extend the cookbook pipeline so it can publish the generated synthetic dataset to Hugging Face Hub after local generation succeeds. -- Revise the cookbook documentation to describe the Mistral runtime prerequisites, larger sample count, local output artifact, and Hugging Face dataset destination. -- Preserve the existing Persona Hub-inspired prompt provenance model while adapting the execution and publication flow. - -## Capabilities - -### New Capabilities -- None. - -### Modified Capabilities -- `docs-cookbook`: Change cookbook-page requirements so a published cookbook can document both its local output artifact and its Hugging Face dataset publication target. -- `persona-generation-cookbook`: Change the cookbook workflow requirements to use Mistral AI, generate 20 bounded samples, and publish the resulting synthetic dataset to Hugging Face Hub. - -## Impact - -- Affected runnable example in `examples/scripts/43_cookbook_persona_generation.py`. -- Affected cookbook documentation in `docs/cookbook/persona_generation.md` and supporting asset references. -- Requires Mistral runtime configuration via `MISTRAL_API_KEY` and dataset publication credentials via `HF_TOKEN`. -- Uses existing DataFast Hub sink support for dataset publishing; no new public API surface is required. diff --git a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/docs-cookbook/spec.md b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/docs-cookbook/spec.md deleted file mode 100644 index 422fc4e..0000000 --- a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/docs-cookbook/spec.md +++ /dev/null @@ -1,8 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Cookbook pages identify runnable source -Each cookbook page SHALL identify the authoritative executable source file, the runtime prerequisites, the expected local output location, and any Hugging Face dataset publication target for the example it documents. - -#### Scenario: Reader opens a cookbook page -- **WHEN** a reader opens the persona-generation cookbook page -- **THEN** the page shows the script path, required Mistral and Hugging Face configuration, the local output artifact path, and the dataset publication target needed to reproduce the example diff --git a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/persona-generation-cookbook/spec.md b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/persona-generation-cookbook/spec.md deleted file mode 100644 index 5494f14..0000000 --- a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/specs/persona-generation-cookbook/spec.md +++ /dev/null @@ -1,24 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Persona Hub-inspired persona workflow -The persona-generation cookbook SHALL implement a runnable DataFast workflow that explores Persona Hub-inspired `Text-to-Persona` and `Persona-to-Persona` methods from bounded sample inputs drawn from Hugging Face `xsum`, using Mistral AI as the documented LLM provider path. - -#### Scenario: Script generates personas from source texts -- **WHEN** a user runs the cookbook script with a configured Mistral model -- **THEN** the script selects up to the first twenty documents from the `validation` split whose lengths are between 300 and 500 words and produces output records that include personas inferred from source text and personas expanded from prior personas - -### Requirement: Standalone execution is documented and bounded -The cookbook SHALL be runnable as a standalone Python script with a bounded execution path suitable for manual verification and dataset publication. - -#### Scenario: User performs a smoke run -- **WHEN** a user executes the documented smoke-run command with configured Mistral and Hugging Face credentials -- **THEN** the script uses Mistral model id `mistral-small-2603`, processes only the documented bounded sample size, writes inspectable output artifacts, and publishes the resulting dataset without requiring repo code changes - -## ADDED Requirements - -### Requirement: Cookbook publishes the final synthetic dataset -The persona-generation cookbook SHALL push the final synthetic records to a configured Hugging Face Hub dataset after local generation completes successfully. - -#### Scenario: Script publishes generated records -- **WHEN** the cookbook script reaches its final sink stage with a configured Hugging Face dataset repo and token -- **THEN** it pushes the same final record fields that are written locally to the configured dataset repository diff --git a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/tasks.md b/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/tasks.md deleted file mode 100644 index 8e13311..0000000 --- a/openspec/changes/archive/2026-04-05-update-cookbook-43-mistral-and-hub-publish/tasks.md +++ /dev/null @@ -1,15 +0,0 @@ -## 1. Cookbook Script - -- [x] 1.1 Replace the OpenRouter provider setup in `examples/scripts/43_cookbook_persona_generation.py` with the DataFast Mistral provider path and the required runtime configuration. -- [x] 1.2 Raise the bounded sample selection from the first 5 eligible `xsum` validation records to the first 20 while preserving the existing word-count filter and output schema. -- [x] 1.3 Add a final Hugging Face Hub publication step that pushes the generated synthetic records after local output generation succeeds. - -## 2. Cookbook Documentation - -- [x] 2.1 Update `docs/cookbook/persona_generation.md` to document the Mistral prerequisites, 20-sample behavior, local output artifact, and Hugging Face dataset publication target. -- [x] 2.2 Revise supporting cookbook notes or asset references that still describe the OpenRouter path or five-sample limit. - -## 3. Verification - -- [x] 3.1 Perform a bounded verification of the updated script path and confirm the documented Mistral plus Hugging Face configuration is coherent. -- [x] 3.2 Verify the cookbook page and script describe the same local record fields and remote publication behavior. diff --git a/openspec/config.yaml b/openspec/config.yaml deleted file mode 100644 index 392946c..0000000 --- a/openspec/config.yaml +++ /dev/null @@ -1,20 +0,0 @@ -schema: spec-driven - -# Project context (optional) -# This is shown to AI when creating artifacts. -# Add your tech stack, conventions, style guides, domain knowledge, etc. -# Example: -# context: | -# Tech stack: TypeScript, React, Node.js -# We use conventional commits -# Domain: e-commerce platform - -# Per-artifact rules (optional) -# Add custom rules for specific artifacts. -# Example: -# rules: -# proposal: -# - Keep proposals under 500 words -# - Always include a "Non-goals" section -# tasks: -# - Break tasks into chunks of max 2 hours diff --git a/openspec/specs/docs-cookbook/spec.md b/openspec/specs/docs-cookbook/spec.md deleted file mode 100644 index 53e5c22..0000000 --- a/openspec/specs/docs-cookbook/spec.md +++ /dev/null @@ -1,17 +0,0 @@ -## Purpose -The Cookbook section documents runnable examples in the DataFast docs site and points readers to the authoritative source, prerequisites, and expected outputs for each example. -## Requirements -### Requirement: Cookbook navigation -The documentation site SHALL expose a Cookbook section in the MkDocs navigation, and each cookbook entry SHALL resolve to a Markdown page under `docs/`. - -#### Scenario: Cookbook section appears in navigation -- **WHEN** the documentation site configuration is loaded -- **THEN** the navigation includes a Cookbook section with an entry for the persona-generation cookbook - -### Requirement: Cookbook pages identify runnable source -Each cookbook page SHALL identify the authoritative executable source file, the runtime prerequisites, the expected local output location, and any Hugging Face dataset publication target for the example it documents. - -#### Scenario: Reader opens a cookbook page -- **WHEN** a reader opens the persona-generation cookbook page -- **THEN** the page shows the script path, required Mistral and Hugging Face configuration, the local output artifact path, and the dataset publication target needed to reproduce the example - diff --git a/openspec/specs/persona-generation-cookbook/spec.md b/openspec/specs/persona-generation-cookbook/spec.md deleted file mode 100644 index 0efd33a..0000000 --- a/openspec/specs/persona-generation-cookbook/spec.md +++ /dev/null @@ -1,38 +0,0 @@ -## Purpose -The persona-generation cookbook documents a bounded, runnable DataFast workflow inspired by Persona Hub research and shows how generated personas can drive later synthetic-data creation. -## Requirements -### Requirement: Persona Hub-inspired persona workflow -The persona-generation cookbook SHALL implement a runnable DataFast workflow that explores Persona Hub-inspired `Text-to-Persona` and `Persona-to-Persona` methods from bounded sample inputs drawn from Hugging Face `xsum`, using Mistral AI as the documented LLM provider path. - -#### Scenario: Script generates personas from source texts -- **WHEN** a user runs the cookbook script with a configured Mistral model -- **THEN** the script selects up to the first twenty documents from the `validation` split whose lengths are between 300 and 500 words and produces output records that include personas inferred from source text and personas expanded from prior personas - -### Requirement: Prompt provenance is explicit -The cookbook SHALL distinguish between paper-aligned persona-generation prompts, repository-derived downstream prompt templates, and DataFast-specific prompt adaptations, and it SHALL NOT claim verbatim reproduction where the source material does not publish exact prompt strings. - -#### Scenario: Reader inspects prompt usage -- **WHEN** a reader reviews the cookbook code or documentation -- **THEN** each prompt used in the workflow is labeled by provenance and any paper-derived persona prompt is described as an adaptation rather than an exact reproduction - -### Requirement: Cookbook demonstrates downstream persona usage -The cookbook SHALL include at least one downstream persona-conditioned generation step implemented with DataFast to show how generated personas can drive later synthetic-data creation. - -#### Scenario: Script reaches downstream synthesis -- **WHEN** the cookbook script completes its final stage -- **THEN** the outputs include at least one artifact generated from a persona-conditioned prompt, such as a representative user request - -### Requirement: Standalone execution is documented and bounded -The cookbook SHALL be runnable as a standalone Python script with a bounded execution path suitable for manual verification and dataset publication. - -#### Scenario: User performs a smoke run -- **WHEN** a user executes the documented smoke-run command with configured Mistral and Hugging Face credentials -- **THEN** the script uses Mistral model id `mistral-small-2603`, processes only the documented bounded sample size, writes inspectable output artifacts, and publishes the resulting dataset without requiring repo code changes - -### Requirement: Cookbook publishes the final synthetic dataset -The persona-generation cookbook SHALL push the final synthetic records to a configured Hugging Face Hub dataset after local generation completes successfully. - -#### Scenario: Script publishes generated records -- **WHEN** the cookbook script reaches its final sink stage with a configured Hugging Face dataset repo and token -- **THEN** it pushes the same final record fields that are written locally to the configured dataset repository - From d62a2f3102b9dc59a97f39be56d82931cdd7d8cf Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Wed, 27 May 2026 14:46:17 +0200 Subject: [PATCH 11/54] Align persona cookbook docs with script --- docs/cookbook/assets/index.md | 11 ++++--- docs/cookbook/persona_generation.md | 31 ++++++++++++++----- .../scripts/43_cookbook_persona_generation.py | 7 ++--- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/docs/cookbook/assets/index.md b/docs/cookbook/assets/index.md index b57ae2e..ce8966f 100644 --- a/docs/cookbook/assets/index.md +++ b/docs/cookbook/assets/index.md @@ -6,11 +6,13 @@ Prompt files and dataset details used by the persona-generation cookbook. - **Source:** `xsum` (Hugging Face), `validation` split - **Fields used:** `document`, `summary` -- **Filter:** 300–500 words, first 5 matches +- **Filter:** 300–500 words, first 100 matches +- **Local output:** `examples/outputs/43_persona_cookbook.jsonl` +- **Hub output:** set `HF_REPO_ID` and the `repo_id` in `push_records_to_hub()` to repos under your own Hugging Face username or organization ## Prompt Variants -Each LLM step picks one prompt at random per record. Multiple variants add diversity. +Each LLM step picks one prompt at random per record. The script also assigns random `life_stage` and `related_life_stage` values before the corresponding LLM steps. Multiple variants add diversity. ### Text-to-Persona @@ -32,8 +34,9 @@ Each LLM step picks one prompt at random per record. Multiple variants add diver | File | Style | | --- | --- | -| [persona_to_user_prompt_v2.txt](persona_to_user_prompt_v2.txt) | XML-tagged person, AI assistant framing | -| [persona_to_user_prompt_v3.txt](persona_to_user_prompt_v3.txt) | Requirements-first ordering | +| [persona_to_user_prompt_v1.txt](persona_to_user_prompt_v1.txt) | Minimal direct instruction: takes a persona description and asks for one plausible LLM request | +| [persona_to_user_prompt_v2.txt](persona_to_user_prompt_v2.txt) | XML-delimited persona input with assistant-oriented wording for cleaner prompt templating | +| [persona_to_user_prompt_v3.txt](persona_to_user_prompt_v3.txt) | Constraint-first format that emphasizes specificity, realism, and single-turn output | ## Provenance diff --git a/docs/cookbook/persona_generation.md b/docs/cookbook/persona_generation.md index ee842b4..638627b 100644 --- a/docs/cookbook/persona_generation.md +++ b/docs/cookbook/persona_generation.md @@ -6,15 +6,18 @@ Build personas from real articles and expand them through relationships. Inspire - **Script:** `examples/scripts/43_cookbook_persona_generation.py` - **Prompt assets:** [asset index](assets/index.md) -- **Output:** pushed to a private Hugging Face Hub dataset +- **Local output:** `examples/outputs/43_persona_cookbook.jsonl` +- **Hub output:** pushed to the Hugging Face Hub repo IDs configured in the script ## Pipeline 1. Load `xsum` articles (`validation` split). -2. Filter to documents between 300 and 500 words. Keep the first 5. -3. **Text-to-Persona** — infer one persona from each article. -4. **Persona-to-Persona** — expand that persona into a related individual. -5. Push results to Hugging Face Hub. +2. Filter to documents between 300 and 500 words. Keep the first 100 matches. +3. Assign a random life stage to the source persona. +4. **Text-to-Persona** — infer one persona from each article and life stage. +5. Assign a random life stage to the related persona. +6. **Persona-to-Persona** — expand that persona into a related individual. +7. Keep the final output fields, write JSONL, and push results to Hugging Face Hub. Each LLM step randomly picks one prompt variant per record using `Sample(prompts, n=1)`. This adds diversity across generations. @@ -22,9 +25,15 @@ Each LLM step randomly picks one prompt variant per record using `Sample(prompts xsum article │ ▼ +life_stage (random from configured stages) + │ + ▼ Text-to-Persona (random prompt from 3 variants) │ ▼ +related_life_stage (random from configured stages) + │ + ▼ Persona-to-Persona (random prompt from 3 variants) │ ▼ @@ -35,9 +44,15 @@ Hugging Face Hub Prerequisites: -- `OPENROUTER_API_KEY` and `HF_TOKEN` set in a `.env` file +- `OPENROUTER_API_KEY` set in a `.env` file +- Hugging Face authentication via `HF_TOKEN` in `.env` or a cached `huggingface_hub` login - Base dependencies from `pyproject.toml` installed +Before running, replace the example Hugging Face namespaces in the script with your own username or organization: + +- `HF_REPO_ID = "/new-persona-cookbook-dataset"` controls the private pipeline sink. +- `repo_id = "/datafast-persona-cookbook"` inside `push_records_to_hub()` controls the public publish step. + ```bash python examples/scripts/43_cookbook_persona_generation.py ``` @@ -49,7 +64,7 @@ Each step draws from multiple prompt files stored under `docs/cookbook/assets/`. - **Text-to-Persona:** 3 variants (`text_to_persona_v1.txt`, `v2`, `v3`) - **Persona-to-Persona:** 3 variants (`persona_to_persona_v1.txt`, `v2`, `v3`) -Additional prompt variants for user-prompt generation are available (`persona_to_user_prompt_v2.txt`, `v3`) but not used in the current pipeline. +Additional prompt variants for user-prompt generation are available (`persona_to_user_prompt_v1.txt`, `v2`, `v3`) but not used in the current pipeline. ## Research Basis @@ -60,6 +75,8 @@ The Persona Hub paper introduces Text-to-Persona and Persona-to-Persona as scala - `summary` — original article summary - `document` — source article text - `word_count` — whitespace token count +- `life_stage` — randomly selected life stage for the inferred persona - `persona_description` — inferred persona - `relationship_type` — link between the two personas +- `related_life_stage` — randomly selected life stage for the expanded persona - `related_persona_description` — the expanded related persona diff --git a/examples/scripts/43_cookbook_persona_generation.py b/examples/scripts/43_cookbook_persona_generation.py index 20c36c8..2c5c8c3 100644 --- a/examples/scripts/43_cookbook_persona_generation.py +++ b/examples/scripts/43_cookbook_persona_generation.py @@ -1,13 +1,12 @@ -"""Persona-generation cookbook: XSum article -> personas -> user prompts. +"""Persona-generation cookbook: XSum article -> personas -> related personas. Demonstrates: Source.huggingface, Map, Filter, Sample, JSON-mode LLMSteps, and prompt assets stored under docs/cookbook/assets. Requires: -- MISTRAL_API_KEY -- PERSONA_COOKBOOK_HF_REPO_ID +- OPENROUTER_API_KEY - Hugging Face authentication via HF_TOKEN or a cached `huggingface_hub` login -- network access to Hugging Face and Mistral AI +- network access to Hugging Face and OpenRouter """ import random From 776282072fd3274693992abbf4e94875a587b651 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Wed, 10 Jun 2026 21:48:01 +0200 Subject: [PATCH 12/54] Improve persona cookbook resumability --- docs/cookbook/assets/index.md | 7 +++++-- docs/cookbook/persona_generation.md | 14 +++++++++++--- examples/scripts/43_cookbook_persona_generation.py | 13 +++++++++++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/cookbook/assets/index.md b/docs/cookbook/assets/index.md index ce8966f..b4a04c9 100644 --- a/docs/cookbook/assets/index.md +++ b/docs/cookbook/assets/index.md @@ -5,11 +5,14 @@ Prompt files and dataset details used by the persona-generation cookbook. ## Dataset - **Source:** `xsum` (Hugging Face), `validation` split -- **Fields used:** `document`, `summary` +- **Fields used:** `id`, `document`, `summary` - **Filter:** 300–500 words, first 100 matches - **Local output:** `examples/outputs/43_persona_cookbook.jsonl` +- **Checkpoints:** `examples/checkpoints/43_persona_cookbook` - **Hub output:** set `HF_REPO_ID` and the `repo_id` in `push_records_to_hub()` to repos under your own Hugging Face username or organization +The example keeps first-match sampling for reproducibility. For local JSONL corpora with metadata such as `document_filename`, stratified sampling is usually a better fit. + ## Prompt Variants Each LLM step picks one prompt at random per record. The script also assigns random `life_stage` and `related_life_stage` values before the corresponding LLM steps. Multiple variants add diversity. @@ -42,4 +45,4 @@ Each LLM step picks one prompt at random per record. The script also assigns ran - Text-to-Persona and Persona-to-Persona prompts are paper-aligned adaptations. The Persona Hub paper states its published prompts are simplified, not exact. - User-prompt variants are derived from the repository's instruction-generation prompt family. -- No Persona Hub code is reused. The workflow is built with DataFast primitives. +- No Persona Hub code is reused. The workflow is built with datafast primitives. diff --git a/docs/cookbook/persona_generation.md b/docs/cookbook/persona_generation.md index 638627b..3f26d6f 100644 --- a/docs/cookbook/persona_generation.md +++ b/docs/cookbook/persona_generation.md @@ -1,26 +1,29 @@ # Persona Generation -Build personas from real articles and expand them through relationships. Inspired by the Persona Hub paper, implemented entirely with DataFast. +Build personas from real articles and expand them through relationships. Inspired by the Persona Hub paper, implemented entirely with datafast. ## Source - **Script:** `examples/scripts/43_cookbook_persona_generation.py` - **Prompt assets:** [asset index](assets/index.md) - **Local output:** `examples/outputs/43_persona_cookbook.jsonl` +- **Checkpoints:** `examples/checkpoints/43_persona_cookbook` - **Hub output:** pushed to the Hugging Face Hub repo IDs configured in the script ## Pipeline -1. Load `xsum` articles (`validation` split). +1. Load `xsum` articles (`validation` split), preserving the dataset `id`. 2. Filter to documents between 300 and 500 words. Keep the first 100 matches. 3. Assign a random life stage to the source persona. 4. **Text-to-Persona** — infer one persona from each article and life stage. 5. Assign a random life stage to the related persona. 6. **Persona-to-Persona** — expand that persona into a related individual. -7. Keep the final output fields, write JSONL, and push results to Hugging Face Hub. +7. Keep the final output fields, write JSONL, checkpoint progress, and push results to Hugging Face Hub. Each LLM step randomly picks one prompt variant per record using `Sample(prompts, n=1)`. This adds diversity across generations. +The cookbook keeps `Sample(n=100, strategy="first")` so runs are deterministic and easy to compare. For local corpora with source metadata, use stratified sampling, for example `Sample(n=250, strategy="stratified", by="document_filename")`, to avoid over-representing one source file. + ```text xsum article │ @@ -57,6 +60,10 @@ Before running, replace the example Hugging Face namespaces in the script with y python examples/scripts/43_cookbook_persona_generation.py ``` +The run uses `checkpoint_dir` and `resume=True`, which is useful for paid or rate-limited LLM calls. If a run is interrupted, re-run the same command to continue from the saved checkpoints. + +The main example reads from Hugging Face. For a local JSONL corpus, replace `Source.huggingface(...)` with `Source.file(...)` and map your text column to `document` before `add_word_count`. + ## Prompt Variants Each step draws from multiple prompt files stored under `docs/cookbook/assets/`. See the [asset index](assets/index.md) for the full list. @@ -73,6 +80,7 @@ The Persona Hub paper introduces Text-to-Persona and Persona-to-Persona as scala ## Output Fields - `summary` — original article summary +- `id` — original XSum record identifier - `document` — source article text - `word_count` — whitespace token count - `life_stage` — randomly selected life stage for the inferred persona diff --git a/examples/scripts/43_cookbook_persona_generation.py b/examples/scripts/43_cookbook_persona_generation.py index 2c5c8c3..a9bfc67 100644 --- a/examples/scripts/43_cookbook_persona_generation.py +++ b/examples/scripts/43_cookbook_persona_generation.py @@ -24,6 +24,7 @@ MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:nitro" OUTPUT_PATH = "examples/outputs/43_persona_cookbook.jsonl" +CHECKPOINT_DIR = "examples/checkpoints/43_persona_cookbook" HF_REPO_ID = "patrickfleith/new-persona-cookbook-dataset" TEXT_TO_PERSONA_PROMPTS = [ "docs/cookbook/assets/text_to_persona_v1.txt", @@ -62,6 +63,7 @@ def assign_related_life_stage(record: dict) -> dict: def keep_output_fields(record: dict) -> dict: return { + "id": record["id"], "summary": record["summary"], "document": record["document"], "word_count": record["word_count"], @@ -80,8 +82,11 @@ def build_pipeline(): Source.huggingface( "xsum", split="validation", - columns=["document", "summary"], + columns=["id", "document", "summary"], ) + # For a local JSONL corpus, replace the Hugging Face source with something + # like Source.file("data/articles.jsonl") and map your text field to + # "document" before add_word_count. >> Map(add_word_count).as_step("add_word_count") >> Filter(fn=lambda r: 300 <= r["word_count"] <= 500).as_step("filter_word_count") >> Sample(n=100, strategy="first").as_step("take_first_100") @@ -123,7 +128,11 @@ def push_records_to_hub(records: list[dict]) -> None: def main() -> None: - records = build_pipeline().run(batch_size=1) + records = build_pipeline().run( + batch_size=1, + checkpoint_dir=CHECKPOINT_DIR, + resume=True, + ) push_records_to_hub(records) From 0e8ebd4f464d885e15e0618aa031b298559af31c Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Wed, 10 Jun 2026 22:16:57 +0200 Subject: [PATCH 13/54] Remove user-prompt assets from persona cookbook --- .gitignore | 2 +- docs/cookbook/assets/index.md | 9 --------- .../cookbook/assets/persona_to_user_prompt_v1.txt | 9 --------- .../cookbook/assets/persona_to_user_prompt_v2.txt | 15 --------------- .../cookbook/assets/persona_to_user_prompt_v3.txt | 11 ----------- docs/cookbook/persona_generation.md | 2 -- .../scripts/43_cookbook_persona_generation.py | 4 ---- 7 files changed, 1 insertion(+), 51 deletions(-) delete mode 100644 docs/cookbook/assets/persona_to_user_prompt_v1.txt delete mode 100644 docs/cookbook/assets/persona_to_user_prompt_v2.txt delete mode 100644 docs/cookbook/assets/persona_to_user_prompt_v3.txt diff --git a/.gitignore b/.gitignore index 819ffa5..aa49fe5 100644 --- a/.gitignore +++ b/.gitignore @@ -103,7 +103,7 @@ ipython_config.py # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. -#uv.lock +uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. diff --git a/docs/cookbook/assets/index.md b/docs/cookbook/assets/index.md index b4a04c9..6daea6d 100644 --- a/docs/cookbook/assets/index.md +++ b/docs/cookbook/assets/index.md @@ -33,16 +33,7 @@ Each LLM step picks one prompt at random per record. The script also assigns ran | [persona_to_persona_v2.txt](persona_to_persona_v2.txt) | Rule-list format, explicit separation of description and relationship | | [persona_to_persona_v3.txt](persona_to_persona_v3.txt) | XML-tagged input, concise vivid output | -### Persona-to-User-Prompt (not in current pipeline) - -| File | Style | -| --- | --- | -| [persona_to_user_prompt_v1.txt](persona_to_user_prompt_v1.txt) | Minimal direct instruction: takes a persona description and asks for one plausible LLM request | -| [persona_to_user_prompt_v2.txt](persona_to_user_prompt_v2.txt) | XML-delimited persona input with assistant-oriented wording for cleaner prompt templating | -| [persona_to_user_prompt_v3.txt](persona_to_user_prompt_v3.txt) | Constraint-first format that emphasizes specificity, realism, and single-turn output | - ## Provenance - Text-to-Persona and Persona-to-Persona prompts are paper-aligned adaptations. The Persona Hub paper states its published prompts are simplified, not exact. -- User-prompt variants are derived from the repository's instruction-generation prompt family. - No Persona Hub code is reused. The workflow is built with datafast primitives. diff --git a/docs/cookbook/assets/persona_to_user_prompt_v1.txt b/docs/cookbook/assets/persona_to_user_prompt_v1.txt deleted file mode 100644 index 949fbcc..0000000 --- a/docs/cookbook/assets/persona_to_user_prompt_v1.txt +++ /dev/null @@ -1,9 +0,0 @@ -Guess a prompt that the following persona may ask an LLM to do. - -Persona: -{related_persona} - -Requirements: -1. The user prompt should be informative and specific. -2. The request should sound like something this persona would genuinely ask. -3. Keep it to a single prompt, not a conversation. diff --git a/docs/cookbook/assets/persona_to_user_prompt_v2.txt b/docs/cookbook/assets/persona_to_user_prompt_v2.txt deleted file mode 100644 index ad87aa3..0000000 --- a/docs/cookbook/assets/persona_to_user_prompt_v2.txt +++ /dev/null @@ -1,15 +0,0 @@ - - -Imagine the following person: - - -{related_persona} - - -The person is sitting down to use an AI assistant. What single, specific request could they possibly type? - -Requirements: -1. The prompt must be detailed and self-contained. -2. It should reflect this persona's unique knowledge, needs, or curiosity. -3. Output exactly one prompt, not a multi-turn dialogue. - diff --git a/docs/cookbook/assets/persona_to_user_prompt_v3.txt b/docs/cookbook/assets/persona_to_user_prompt_v3.txt deleted file mode 100644 index 45ef9a4..0000000 --- a/docs/cookbook/assets/persona_to_user_prompt_v3.txt +++ /dev/null @@ -1,11 +0,0 @@ -What is one realistic question or task that the persona described below would ask a large language model? - -Requirements: -1. Provide a single standalone prompt, not a series of follow-ups. -2. Make the request specific enough that the answer would genuinely help this persona. -3. The wording should feel natural — as if the persona typed it themselves. - -Persona: -{related_persona} - -Now come up with the prompt from that user. \ No newline at end of file diff --git a/docs/cookbook/persona_generation.md b/docs/cookbook/persona_generation.md index 3f26d6f..72ff7b5 100644 --- a/docs/cookbook/persona_generation.md +++ b/docs/cookbook/persona_generation.md @@ -71,8 +71,6 @@ Each step draws from multiple prompt files stored under `docs/cookbook/assets/`. - **Text-to-Persona:** 3 variants (`text_to_persona_v1.txt`, `v2`, `v3`) - **Persona-to-Persona:** 3 variants (`persona_to_persona_v1.txt`, `v2`, `v3`) -Additional prompt variants for user-prompt generation are available (`persona_to_user_prompt_v1.txt`, `v2`, `v3`) but not used in the current pipeline. - ## Research Basis The Persona Hub paper introduces Text-to-Persona and Persona-to-Persona as scalable methods for building personas from web text. The paper states that its published prompts are simplified, not the exact experiment strings. This cookbook treats them as paper-aligned adaptations. It does not reuse any Persona Hub code. diff --git a/examples/scripts/43_cookbook_persona_generation.py b/examples/scripts/43_cookbook_persona_generation.py index a9bfc67..6c8fefc 100644 --- a/examples/scripts/43_cookbook_persona_generation.py +++ b/examples/scripts/43_cookbook_persona_generation.py @@ -36,10 +36,6 @@ "docs/cookbook/assets/persona_to_persona_v2.txt", "docs/cookbook/assets/persona_to_persona_v3.txt", ] -# PERSONA_TO_USER_PROMPTS = [ -# "docs/cookbook/assets/persona_to_user_prompt_v2.txt", -# "docs/cookbook/assets/persona_to_user_prompt_v3.txt", -# ] LIFE_STAGES = [ "a teenager", "a young adult", From 973a23fe004eb9f3eacbc19d7aa212fd9c551ed5 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 11 Jun 2026 07:48:57 +0200 Subject: [PATCH 14/54] Add explicit UUID support for generated records --- README.md | 4 +- datafast/__init__.py | 3 +- datafast/transforms/__init__.py | 4 +- datafast/transforms/data_ops.py | 29 ++++ docs/api.md | 1 + docs/cookbook/assets/index.md | 35 ++++- .../cookbook/assets/space_text_generation.txt | 1 + docs/cookbook/index.md | 1 + docs/cookbook/persona_generation.md | 5 +- docs/cookbook/space_text_generation.md | 103 +++++++++++++ docs/guides/building_pipelines.md | 4 +- .../scripts/43_cookbook_persona_generation.py | 9 +- .../44_cookbook_space_text_generation.py | 143 ++++++++++++++++++ mkdocs.yml | 1 + tests/test_add_uuid.py | 78 ++++++++++ tests/test_public_api.py | 2 + 16 files changed, 404 insertions(+), 19 deletions(-) create mode 100644 docs/cookbook/assets/space_text_generation.txt create mode 100644 docs/cookbook/space_text_generation.md create mode 100644 examples/scripts/44_cookbook_space_text_generation.py create mode 100644 tests/test_add_uuid.py diff --git a/README.md b/README.md index 6521a97..38b2deb 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Datafast is a python library for synthetic data generation using llms. The old dataset-class API has been removed. The canonical package is `datafast`, and the primary model is: - create records with `Source` or `Seed` -- transform them with composable steps +- transform them with composable steps such as `AddUUID`, `Map`, and `Filter` - call LLMs with `LLMStep`, `Classify`, `Score`, `Compare`, `Rewrite`, or `Extract` - persist results with `Sink` @@ -53,7 +53,7 @@ pipeline.run(batch_size=4) - `Source`: load records from Python lists, files, or Hugging Face datasets - `Seed`: generate record combinations declaratively -- `Map`, `FlatMap`, `Filter`, `Group`, `Pair`, `Concat`, `Join`: data operations +- `AddUUID`, `Map`, `FlatMap`, `Filter`, `Group`, `Pair`, `Concat`, `Join`: data operations - `LLMStep`: free-form generation - `Classify`, `Score`, `Compare`, `Rewrite`, `Extract`: higher-level LLM transforms - `Branch` and `JoinBranches`: multi-path pipelines diff --git a/datafast/__init__.py b/datafast/__init__.py index 19bd452..6576370 100644 --- a/datafast/__init__.py +++ b/datafast/__init__.py @@ -31,7 +31,7 @@ is_langfuse_tracing_enabled, ) from datafast.transforms.branch import Branch, JoinBranches -from datafast.transforms.data_ops import Map, FlatMap, Filter, Group, Pair, Concat, Join +from datafast.transforms.data_ops import AddUUID, Map, FlatMap, Filter, Group, Pair, Concat, Join from datafast.transforms.llm_eval import Classify, Score, Compare from datafast.transforms.llm_extract import Extract from datafast.transforms.llm_step import LLMStep @@ -64,6 +64,7 @@ def get_version() -> str: "Seed", "SeedDimension", "Sample", + "AddUUID", "Map", "FlatMap", "Filter", diff --git a/datafast/transforms/__init__.py b/datafast/transforms/__init__.py index 025ea3f..f7a88d2 100644 --- a/datafast/transforms/__init__.py +++ b/datafast/transforms/__init__.py @@ -1,7 +1,7 @@ """Transform steps for datafast v2.""" from datafast.transforms.sample import Sample -from datafast.transforms.data_ops import Map, FlatMap, Filter, Group, Pair, Concat, Join +from datafast.transforms.data_ops import AddUUID, Map, FlatMap, Filter, Group, Pair, Concat, Join from datafast.transforms.llm_step import LLMStep from datafast.transforms.llm_eval import Classify, Score, Compare from datafast.transforms.llm_transform import Rewrite @@ -9,7 +9,7 @@ from datafast.transforms.branch import Branch, JoinBranches __all__ = [ - "Sample", "Map", "FlatMap", "Filter", "Group", "Pair", "Concat", "Join", + "Sample", "AddUUID", "Map", "FlatMap", "Filter", "Group", "Pair", "Concat", "Join", "LLMStep", "Classify", "Score", "Compare", "Rewrite", "Extract", "Branch", "JoinBranches", ] diff --git a/datafast/transforms/data_ops.py b/datafast/transforms/data_ops.py index 3887460..fafb5cf 100644 --- a/datafast/transforms/data_ops.py +++ b/datafast/transforms/data_ops.py @@ -3,6 +3,7 @@ import itertools import random import re +import uuid from collections import defaultdict from collections.abc import Callable, Iterable from typing import Any @@ -62,6 +63,34 @@ def process(self, records: Iterable[Record]) -> Iterable[Record]: yield from self._fn(record) +class AddUUID(Step): + """Add a UUID field to each record.""" + + def __init__(self, column: str = "id", overwrite: bool = False) -> None: + """ + Initialize an AddUUID step. + + Args: + column: Field name to write the UUID into. + overwrite: If True, replace existing values in the target column. + + Examples: + >>> AddUUID() + >>> AddUUID(column="example_id", overwrite=True) + """ + super().__init__() + self._column = column + self._overwrite = overwrite + + def process(self, records: Iterable[Record]) -> Iterable[Record]: + """Add UUIDs while preserving all other fields.""" + for record in records: + if self._column in record and not self._overwrite: + yield record + else: + yield {**record, self._column: str(uuid.uuid4())} + + class Filter(Step): """Keep or drop records based on conditions.""" diff --git a/docs/api.md b/docs/api.md index edef161..45857e2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -36,6 +36,7 @@ from datafast import Source, LLMStep, Sink, openrouter ## Data Operations - `Sample` +- `AddUUID` - `Map` - `FlatMap` - `Filter` diff --git a/docs/cookbook/assets/index.md b/docs/cookbook/assets/index.md index 6daea6d..55b9836 100644 --- a/docs/cookbook/assets/index.md +++ b/docs/cookbook/assets/index.md @@ -1,8 +1,10 @@ -# Persona Cookbook Assets +# Cookbook Assets -Prompt files and dataset details used by the persona-generation cookbook. +Prompt files and dataset details used by cookbook examples. -## Dataset +## Persona Generation + +### Dataset - **Source:** `xsum` (Hugging Face), `validation` split - **Fields used:** `id`, `document`, `summary` @@ -13,11 +15,11 @@ Prompt files and dataset details used by the persona-generation cookbook. The example keeps first-match sampling for reproducibility. For local JSONL corpora with metadata such as `document_filename`, stratified sampling is usually a better fit. -## Prompt Variants +### Prompt Variants Each LLM step picks one prompt at random per record. The script also assigns random `life_stage` and `related_life_stage` values before the corresponding LLM steps. Multiple variants add diversity. -### Text-to-Persona +#### Text-to-Persona | File | Style | | --- | --- | @@ -25,7 +27,7 @@ Each LLM step picks one prompt at random per record. The script also assigns ran | [text_to_persona_v2.txt](text_to_persona_v2.txt) | XML-tagged source text, writer/reader framing | | [text_to_persona_v3.txt](text_to_persona_v3.txt) | System-role preamble, search-interest angle | -### Persona-to-Persona +#### Persona-to-Persona | File | Style | | --- | --- | @@ -33,7 +35,26 @@ Each LLM step picks one prompt at random per record. The script also assigns ran | [persona_to_persona_v2.txt](persona_to_persona_v2.txt) | Rule-list format, explicit separation of description and relationship | | [persona_to_persona_v3.txt](persona_to_persona_v3.txt) | XML-tagged input, concise vivid output | -## Provenance +### Provenance - Text-to-Persona and Persona-to-Persona prompts are paper-aligned adaptations. The Persona Hub paper states its published prompts are simplified, not exact. - No Persona Hub code is reused. The workflow is built with datafast primitives. + +## Space Engineering Text Generation + +### Dataset + +- **Source:** seed dimensions created with `Seed.product` +- **Dimensions:** document type, topic, expertise level, and language +- **Local output:** `examples/outputs/44_space_text_generation_cookbook.jsonl` +- **Checkpoints:** `examples/checkpoints/44_space_text_generation_cookbook` +- **Hub output:** optional, controlled by `DATAFAST_PUSH_TO_HUB=1` + +### Prompt + +The text-generation cookbook uses one compact prompt and relies on seed +dimensions for variation. + +| File | Style | +| --- | --- | +| [space_text_generation.txt](space_text_generation.txt) | Minimal variable-driven request | diff --git a/docs/cookbook/assets/space_text_generation.txt b/docs/cookbook/assets/space_text_generation.txt new file mode 100644 index 0000000..ca5af4b --- /dev/null +++ b/docs/cookbook/assets/space_text_generation.txt @@ -0,0 +1 @@ +Write one {document_type} excerpt about {topic} for {expertise_level} in {language_name}. diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md index e97413b..89b092f 100644 --- a/docs/cookbook/index.md +++ b/docs/cookbook/index.md @@ -12,3 +12,4 @@ The Python script is the source of truth. Each cookbook page explains: ## Available Cookbooks - [Persona Generation](persona_generation.md): infer personas from real articles and expand them through relationships using randomized prompt variants. +- [Space Engineering Text Generation](space_text_generation.md): generate a raw multilingual technical text corpus from seed dimensions. diff --git a/docs/cookbook/persona_generation.md b/docs/cookbook/persona_generation.md index 72ff7b5..f314a39 100644 --- a/docs/cookbook/persona_generation.md +++ b/docs/cookbook/persona_generation.md @@ -18,7 +18,7 @@ Build personas from real articles and expand them through relationships. Inspire 4. **Text-to-Persona** — infer one persona from each article and life stage. 5. Assign a random life stage to the related persona. 6. **Persona-to-Persona** — expand that persona into a related individual. -7. Keep the final output fields, write JSONL, checkpoint progress, and push results to Hugging Face Hub. +7. Keep the final output fields, add a row UUID, write JSONL, checkpoint progress, and push results to Hugging Face Hub. Each LLM step randomly picks one prompt variant per record using `Sample(prompts, n=1)`. This adds diversity across generations. @@ -77,8 +77,9 @@ The Persona Hub paper introduces Text-to-Persona and Persona-to-Persona as scala ## Output Fields +- `id` — generated row UUID +- `source_id` — original XSum record identifier - `summary` — original article summary -- `id` — original XSum record identifier - `document` — source article text - `word_count` — whitespace token count - `life_stage` — randomly selected life stage for the inferred persona diff --git a/docs/cookbook/space_text_generation.md b/docs/cookbook/space_text_generation.md new file mode 100644 index 0000000..92c55dc --- /dev/null +++ b/docs/cookbook/space_text_generation.md @@ -0,0 +1,103 @@ +# Space Engineering Text Generation + +Build a raw technical text corpus across document types, topics, expertise levels, +languages, and model choices. + +## Source + +- **Script:** `examples/scripts/44_cookbook_space_text_generation.py` +- **Prompt assets:** [asset index](assets/index.md) +- **Local output:** `examples/outputs/44_space_text_generation_cookbook.jsonl` +- **Checkpoints:** `examples/checkpoints/44_space_text_generation_cookbook` +- **Hub output:** optional, controlled by `DATAFAST_PUSH_TO_HUB=1` + +## Pipeline + +1. Create a seed grid with `Seed.product`. +2. Cross document types, topics, and expertise levels explicitly. +3. Generate one section per seed and language with `LLMStep`. +4. Let the prompt variables drive the corpus variation. +5. Parse `title` and `text` from JSON mode. +6. Keep publication fields, add a row UUID, write JSONL, checkpoint progress, + and optionally push to Hugging Face Hub. + +The default model is `nvidia/nemotron-3-super-120b-a12b:nitro` through +OpenRouter. + +```text +document_type x topic x expertise_level + | + v +LLMStep language expansion: English and French + | + v +JSON fields: title, text + | + v +examples/outputs/44_space_text_generation_cookbook.jsonl +``` + +## Row Count + +The default script generates: + +```text +3 document types x 8 topics x 3 expertise levels x 2 languages +x 1 generated output x 1 model = 144 rows +``` + +To use several models, add provider IDs to `MODEL_IDS`. `LLMStep` will run each +seed-language combination through every model and the row count will multiply by +the number of models. + +## Run + +Prerequisites: + +- `OPENROUTER_API_KEY` set in a `.env` file +- Base dependencies from `pyproject.toml` installed +- Hugging Face authentication only if publishing + +```bash +python examples/scripts/44_cookbook_space_text_generation.py +``` + +To publish, replace `HF_REPO_ID` in the script with a repository under your own +Hugging Face username or organization, then run: + +```bash +DATAFAST_PUSH_TO_HUB=1 python examples/scripts/44_cookbook_space_text_generation.py +``` + +The run uses `checkpoint_dir` and `resume=True`. If generation is interrupted, +run the command again to continue from saved checkpoints. + +## Prompt + +The script uses one compact prompt file: + +```text +Write one {document_type} excerpt about {topic} for {expertise_level} in {language_name}. +``` + +## Generation Controls + +- `MODEL_IDS` controls which models generate each record. +- `LANGUAGES` controls language expansion and writes the emitted language code to + the `language` field. +- `NUM_OUTPUTS` controls how many generated rows are created for each + seed, language, and model combination. +- `PROMPT_PATH` controls the prompt file used for generation. +- `SEED` controls deterministic dataset splitting when publishing. +- `HF_REPO_ID` controls the optional Hugging Face Hub destination. + +## Output Fields + +- `id` - generated row UUID +- `document_type` - requested document style +- `topic` - space engineering topic +- `expertise_level` - intended reader level +- `language` - language code emitted by `LLMStep` +- `model` - model ID emitted by `LLMStep` +- `title` - generated section title +- `text` - generated corpus text diff --git a/docs/guides/building_pipelines.md b/docs/guides/building_pipelines.md index 64aaaf2..b755410 100644 --- a/docs/guides/building_pipelines.md +++ b/docs/guides/building_pipelines.md @@ -3,11 +3,12 @@ ## Minimal Pipeline ```python -from datafast import Map, Sink, Source +from datafast import AddUUID, Map, Sink, Source pipeline = ( Source.list([{"text": "hello"}]) >> Map(lambda r: {**r, "length": len(r["text"])}) + >> AddUUID() >> Sink.list() ) @@ -38,6 +39,7 @@ seed = Seed.product( ## Core Data Operations +- `AddUUID`: add a UUID field to each record - `Map`: one record in, one record out - `FlatMap`: one record in, many records out - `Filter`: keep or drop records diff --git a/examples/scripts/43_cookbook_persona_generation.py b/examples/scripts/43_cookbook_persona_generation.py index 6c8fefc..ac4f718 100644 --- a/examples/scripts/43_cookbook_persona_generation.py +++ b/examples/scripts/43_cookbook_persona_generation.py @@ -13,7 +13,7 @@ from dotenv import load_dotenv -from datafast import Filter, LLMStep, Map, Sample, Sink, Source, openrouter +from datafast import AddUUID, Filter, LLMStep, Map, Sample, Sink, Source, openrouter import litellm @@ -59,7 +59,7 @@ def assign_related_life_stage(record: dict) -> dict: def keep_output_fields(record: dict) -> dict: return { - "id": record["id"], + "source_id": record["id"], "summary": record["summary"], "document": record["document"], "word_count": record["word_count"], @@ -85,7 +85,7 @@ def build_pipeline(): # "document" before add_word_count. >> Map(add_word_count).as_step("add_word_count") >> Filter(fn=lambda r: 300 <= r["word_count"] <= 500).as_step("filter_word_count") - >> Sample(n=100, strategy="first").as_step("take_first_100") + >> Sample(n=10, strategy="first").as_step("take_first_100") >> Map(assign_life_stage).as_step("assign_life_stage") >> LLMStep( prompt=Sample(TEXT_TO_PERSONA_PROMPTS, n=1), @@ -105,6 +105,7 @@ def build_pipeline(): on_parse_error="raise", ).as_step("persona_to_persona") >> Map(keep_output_fields).as_step("keep_output_fields") + >> AddUUID(column="id", overwrite=True).as_step("add_uuid") >> Sink.jsonl(OUTPUT_PATH) >> Sink.hub(HF_REPO_ID, private=True) ) @@ -127,7 +128,7 @@ def main() -> None: records = build_pipeline().run( batch_size=1, checkpoint_dir=CHECKPOINT_DIR, - resume=True, + resume=False, ) push_records_to_hub(records) diff --git a/examples/scripts/44_cookbook_space_text_generation.py b/examples/scripts/44_cookbook_space_text_generation.py new file mode 100644 index 0000000..6c5d2cb --- /dev/null +++ b/examples/scripts/44_cookbook_space_text_generation.py @@ -0,0 +1,143 @@ +"""Space text-generation cookbook: seed grid -> technical text corpus. + +Demonstrates: Seed.product, LLMStep JSON mode, multi-language generation, +num_outputs, checkpointing, JSONL output, and optional Hub push. + +Requires: +- OPENROUTER_API_KEY +- Hugging Face authentication only if DATAFAST_PUSH_TO_HUB=1 +- network access to OpenRouter, and to Hugging Face when publishing +""" + +from __future__ import annotations + +import os + +import litellm +from dotenv import load_dotenv + +from datafast import AddUUID, LLMStep, Map, Seed, Sink, openrouter + +load_dotenv() +litellm.suppress_debug_info = True + + +SEED = 20250304 +MODEL_IDS = ["nvidia/nemotron-3-super-120b-a12b:nitro"] +OUTPUT_PATH = "examples/outputs/44_space_text_generation_cookbook.jsonl" +CHECKPOINT_DIR = "examples/checkpoints/44_space_text_generation_cookbook" +HF_REPO_ID = "patrickfleith/datafast-space-text-generation-cookbook" +NUM_OUTPUTS = 1 +PROMPT_PATH = "docs/cookbook/assets/space_text_generation.txt" + +DOCUMENT_TYPES = [ + "space engineering textbook", + "spacecraft design justification document", + "personal blog of a space engineer", +] + +TOPICS = [ + "Microgravity", + "Vacuum", + "Heavy Ions", + "Thermal Extremes", + "Atomic Oxygen", + "Debris Impact", + "Electrostatic Charging", + "Propellant Boil-off", +] + +EXPERTISE_LEVELS = [ + "executives", + "senior engineers", + "PhD candidates", +] + +LANGUAGES = { + "en": "English", + "fr": "French", +} + + +def make_models(): + return [openrouter(model_id, temperature=0.7) for model_id in MODEL_IDS] + + +def expected_row_count(model_count: int | None = None) -> int: + """Return the number of rows this configuration should generate.""" + model_total = len(MODEL_IDS) if model_count is None else model_count + return ( + len(DOCUMENT_TYPES) + * len(TOPICS) + * len(EXPERTISE_LEVELS) + * len(LANGUAGES) + * NUM_OUTPUTS + * model_total + ) + + +def finalize_record(record: dict) -> dict: + """Keep the columns meant for publication.""" + return { + "document_type": record["document_type"], + "topic": record["topic"], + "expertise_level": record["expertise_level"], + "language": record.get("_language", ""), + "model": record.get("_model", ""), + "title": record["title"], + "text": record["text"], + } + + +def build_pipeline(): + return ( + Seed.product( + Seed.values("document_type", DOCUMENT_TYPES), + Seed.values("topic", TOPICS), + Seed.values("expertise_level", EXPERTISE_LEVELS), + ).as_step("seed_space_text_grid") + >> LLMStep( + prompt=PROMPT_PATH, + input_columns=["document_type", "topic", "expertise_level"], + output_columns=["title", "text"], + parse_mode="json", + model=make_models(), + language=LANGUAGES, + num_outputs=NUM_OUTPUTS, + on_parse_error="raise", + ).as_step("generate_space_text") + >> Map(finalize_record).as_step("finalize_record") + >> AddUUID(column="id", overwrite=True).as_step("add_uuid") + >> Sink.jsonl(OUTPUT_PATH) + ) + + +def push_records_to_hub(records: list[dict]) -> None: + list( + Sink.hub( + repo_id=HF_REPO_ID, + private=True, + train_size=0.8, + seed=SEED, + shuffle=True, + commit_message=f"Publish cookbook 44 text dataset with {', '.join(MODEL_IDS)}", + ).process(records) + ) + + +def main() -> None: + print(f"Expected rows: {expected_row_count()}") + records = build_pipeline().run( + batch_size=4, + checkpoint_dir=CHECKPOINT_DIR, + resume=True, + ) + + if os.getenv("DATAFAST_PUSH_TO_HUB") == "1": + push_records_to_hub(records) + + print(f"Wrote {len(records)} records to {OUTPUT_PATH}") + + +if __name__ == "__main__": + main() diff --git a/mkdocs.yml b/mkdocs.yml index d9b4d6a..80de161 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - Cookbook: - cookbook/index.md - Persona Generation: cookbook/persona_generation.md + - Space Engineering Text Generation: cookbook/space_text_generation.md - Providers: llms.md - Models: models.md - API: api.md diff --git a/tests/test_add_uuid.py b/tests/test_add_uuid.py new file mode 100644 index 0000000..e89f837 --- /dev/null +++ b/tests/test_add_uuid.py @@ -0,0 +1,78 @@ +import uuid + +from datafast import AddUUID, LLMStep, Sink, Source + + +def assert_valid_uuid(value: str) -> None: + parsed = uuid.UUID(value) + assert str(parsed) == value + + +def test_add_uuid_adds_id_when_missing(): + records = list(AddUUID().process([{"text": "hello"}])) + + assert records[0]["text"] == "hello" + assert_valid_uuid(records[0]["id"]) + + +def test_add_uuid_preserves_existing_id_by_default(): + records = list(AddUUID().process([{"id": "source-1", "text": "hello"}])) + + assert records == [{"id": "source-1", "text": "hello"}] + + +def test_add_uuid_overwrites_existing_id_when_requested(): + records = list( + AddUUID(overwrite=True).process([{"id": "source-1", "text": "hello"}]) + ) + + assert records[0]["text"] == "hello" + assert records[0]["id"] != "source-1" + assert_valid_uuid(records[0]["id"]) + + +def test_add_uuid_generates_distinct_ids_for_multiple_records(): + records = list(AddUUID().process([{"text": "a"}, {"text": "b"}])) + ids = [record["id"] for record in records] + + assert len(set(ids)) == 2 + for value in ids: + assert_valid_uuid(value) + + +def test_add_uuid_supports_custom_column_name(): + records = list(AddUUID(column="example_id").process([{"text": "hello"}])) + + assert "id" not in records[0] + assert_valid_uuid(records[0]["example_id"]) + + +def test_add_uuid_assigns_unique_ids_to_llm_num_outputs_pipeline(): + class FakeModel: + model_id = "fake-model" + provider_name = "fake" + + def generate(self, messages, metadata=None): + return '{"title": "Generated", "text": "Body"}' + + pipeline = ( + Source.list([{"topic": "vacuum"}]) + >> LLMStep( + prompt="Write about {topic}.", + input_columns=["topic"], + output_columns=["title", "text"], + parse_mode="json", + model=FakeModel(), + num_outputs=2, + ) + >> AddUUID() + >> Sink.list() + ) + + records = pipeline.run() + ids = [record["id"] for record in records] + + assert len(records) == 2 + assert len(set(ids)) == 2 + for value in ids: + assert_valid_uuid(value) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 7eaf787..ac56477 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1,4 +1,5 @@ from datafast import ( + AddUUID, Branch, Classify, Compare, @@ -70,6 +71,7 @@ def test_factory_exports_are_available(monkeypatch): assert Sink is not None assert Seed is not None assert Sample is not None + assert AddUUID is not None assert Map is not None assert FlatMap is not None assert Filter is not None From 1e49ed360abbd3eb44ffa7cf336afbc713917b64 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 11 Jun 2026 15:03:30 +0200 Subject: [PATCH 15/54] Add text classification cookbook --- docs/cookbook/assets/index.md | 20 ++ .../assets/text_classification_generation.txt | 13 ++ docs/cookbook/index.md | 1 + docs/cookbook/text_classification.md | 119 ++++++++++++ .../45_cookbook_text_classification.py | 176 ++++++++++++++++++ mkdocs.yml | 1 + 6 files changed, 330 insertions(+) create mode 100644 docs/cookbook/assets/text_classification_generation.txt create mode 100644 docs/cookbook/text_classification.md create mode 100644 examples/scripts/45_cookbook_text_classification.py diff --git a/docs/cookbook/assets/index.md b/docs/cookbook/assets/index.md index 55b9836..7f69923 100644 --- a/docs/cookbook/assets/index.md +++ b/docs/cookbook/assets/index.md @@ -2,6 +2,26 @@ Prompt files and dataset details used by cookbook examples. +## Text Classification + +### Dataset + +- **Source:** seed dimensions created with `Seed.product` +- **Dimensions:** label, trail type, style, language, and model +- **Local output:** `examples/outputs/45_text_classification_cookbook.jsonl` +- **Checkpoints:** `examples/checkpoints/45_text_classification_cookbook` +- **Hub output:** optional, controlled by `DATAFAST_PUSH_TO_HUB=1` + +This cookbook models variation directly as seed dimensions so the label, trail +type, style, language, and model are all explicit in the +pipeline. + +### Prompt + +| File | Style | +| --- | --- | +| [text_classification_generation.txt](text_classification_generation.txt) | One short trail report per call, with label, trail type, style, and language injected | + ## Persona Generation ### Dataset diff --git a/docs/cookbook/assets/text_classification_generation.txt b/docs/cookbook/assets/text_classification_generation.txt new file mode 100644 index 0000000..7712183 --- /dev/null +++ b/docs/cookbook/assets/text_classification_generation.txt @@ -0,0 +1,13 @@ +Write one realistic hiker report in {language_name}. + +Target category: {label_name} +Category definition: {label_description} + +Constraints: +- The report must clearly match the target category. +- The setting must be a {trail_type}. +- The writing style must be {style}. +- Keep it to 1 or 2 sentences. +- Do not mention the category name directly. +- Do not use bullets, numbering, or explanations. +- Make the report concrete and varied. diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md index 89b092f..1b745ec 100644 --- a/docs/cookbook/index.md +++ b/docs/cookbook/index.md @@ -11,5 +11,6 @@ The Python script is the source of truth. Each cookbook page explains: ## Available Cookbooks +- [Text Classification](text_classification.md): generate a multilingual trail-conditions classification dataset from explicit seed dimensions. - [Persona Generation](persona_generation.md): infer personas from real articles and expand them through relationships using randomized prompt variants. - [Space Engineering Text Generation](space_text_generation.md): generate a raw multilingual technical text corpus from seed dimensions. diff --git a/docs/cookbook/text_classification.md b/docs/cookbook/text_classification.md new file mode 100644 index 0000000..ede9d26 --- /dev/null +++ b/docs/cookbook/text_classification.md @@ -0,0 +1,119 @@ +# Text Classification + +Build a multilingual trail-conditions classification dataset with `datafast`. + +## Source + +- **Script:** `examples/scripts/45_cookbook_text_classification.py` +- **Prompt assets:** [asset index](assets/index.md) +- **Local output:** `examples/outputs/45_text_classification_cookbook.jsonl` +- **Checkpoints:** `examples/checkpoints/45_text_classification_cookbook` +- **Hub output:** optional, controlled by `DATAFAST_PUSH_TO_HUB=1` + +## Use Case + +This cookbook generates short hiker reports across four trail-condition labels +so teams can monitor trail quality and surface issues quickly. + +The default setup is: + +- multi-class: 4 trail-condition labels +- multi-lingual: English and French +- multi-model: two generation models by default +- publishable: optional push to Hugging Face Hub + +## Pipeline + +1. Create a seed grid from labels, trail types, and writing styles. +2. Generate one short hiker report for each seed across all configured models + and languages. +3. Keep the label and prompt-variation provenance in flat output columns. +4. Add a UUID, write JSONL locally, and optionally push to Hugging Face Hub. + +Variation is modeled explicitly through `Seed.product(...)`, which keeps the +generation axes inspectable and easy to count. + +```text +label x trail_type x style + | + v +LLMStep language expansion: English and French + | + v +LLMStep model expansion + | + v +examples/outputs/45_text_classification_cookbook.jsonl +``` + +## Row Count + +The default script generates: + +```text +4 labels x 3 trail types x 2 styles x 2 languages +x 2 models = 96 rows +``` + +Each extra model in `MODEL_IDS` multiplies the total row count. + +## Run + +Prerequisites: + +- `OPENROUTER_API_KEY` set in a `.env` file +- Base dependencies from `pyproject.toml` installed +- Hugging Face authentication only if publishing + +```bash +python examples/scripts/45_cookbook_text_classification.py +``` + +To publish, replace `HF_REPO_ID` in the script with a repository under your own +Hugging Face username or organization, then run: + +```bash +DATAFAST_PUSH_TO_HUB=1 python examples/scripts/45_cookbook_text_classification.py +``` + +The run uses `checkpoint_dir` and `resume=True`. If generation is interrupted, +run the command again to continue from saved checkpoints. + +If you want to use provider-specific clients directly, replace `make_models()` +with providers such as `openai(...)` or `anthropic(...)`. The default setup +uses multiple OpenRouter-backed models so it works with one API key. + +## Prompt + +The cookbook uses one prompt file and drives diversity through seed dimensions: + +```text +Write one realistic hiker report in {language_name}. +``` + +See [text_classification_generation.txt](assets/text_classification_generation.txt) +for the full prompt. + +## Generation Controls + +- `LABELS` defines the target classes and their prompt descriptions. +- `TRAIL_TYPES` controls the trail settings used in generation. +- `STYLES` controls the voice and format of each report. +- `LANGUAGES` controls language expansion. +- `MODEL_IDS` controls which models generate records. +- `HF_REPO_ID` controls the optional Hugging Face Hub destination. + +If you want an extra quality-control pass, add a downstream `Classify` and +`Filter` stage to verify that generated reports match their intended label. + +## Output Fields + +- `id` - generated row UUID +- `label` - target trail-condition label +- `label_description` - human-readable label definition used in the prompt +- `label_source` - fixed to `synthetic` +- `trail_type` - prompt expansion axis for the trail setting +- `style` - prompt expansion axis for the report style +- `language` - language code emitted by `LLMStep` +- `model` - model ID emitted by `LLMStep` +- `text` - generated hiker report diff --git a/examples/scripts/45_cookbook_text_classification.py b/examples/scripts/45_cookbook_text_classification.py new file mode 100644 index 0000000..2a215ad --- /dev/null +++ b/examples/scripts/45_cookbook_text_classification.py @@ -0,0 +1,176 @@ +"""Text-classification cookbook: seed grid -> multilingual trail reports. + +Demonstrates: Seed.product, prompt expansion via seed dimensions, multi-model +generation, multi-language generation, checkpointing, JSONL output, and +optional Hugging Face Hub publishing. + +Requires: +- OPENROUTER_API_KEY +- Hugging Face authentication only if DATAFAST_PUSH_TO_HUB=1 +- network access to OpenRouter, and to Hugging Face when publishing +""" + +from __future__ import annotations + +import os + +import litellm +from dotenv import load_dotenv + +from datafast import AddUUID, LLMStep, Map, Seed, SeedDimension, Sink, openrouter + +load_dotenv() +litellm.suppress_debug_info = True + + +SEED = 20250611 +MODEL_IDS = [ + "nvidia/nemotron-3-super-120b-a12b:nitro", + "mistralai/ministral-14b-2512", +] +OUTPUT_PATH = "examples/outputs/45_text_classification_cookbook.jsonl" +CHECKPOINT_DIR = "examples/checkpoints/45_text_classification_cookbook" +HF_REPO_ID = "patrickfleith/datafast-text-classification-cookbook" +PROMPT_PATH = "docs/cookbook/assets/text_classification_generation.txt" + +LABELS = [ + { + "label_name": "trail_obstruction", + "label_description": ( + "The trail is partially or fully blocked by obstacles such as " + "fallen trees, landslides, snow, flooding, erosion, or dense " + "vegetation." + ), + }, + { + "label_name": "infrastructure_issues", + "label_description": ( + "The report is about damaged or missing bridges, signs, stairs, " + "handrails, markers, boardwalks, or similar trail infrastructure." + ), + }, + { + "label_name": "hazards", + "label_description": ( + "The trail has immediate safety risks such as slippery surfaces, " + "dangerous crossings, unstable terrain, wildlife threats, or " + "other hazardous conditions." + ), + }, + { + "label_name": "positive_conditions", + "label_description": ( + "The report highlights clear, safe, enjoyable trail conditions " + "such as good maintenance, solid infrastructure, clear signage, " + "or scenic features." + ), + }, +] + +TRAIL_TYPES = [ + "mountain trail", + "coastal path", + "forest walk", +] + +STYLES = [ + "a brief social media post", + "a hiking review", +] + +LANGUAGES = { + "en": "English", + "fr": "French", +} + + +def make_models(): + return [openrouter(model_id, temperature=0.8) for model_id in MODEL_IDS] + + +def make_label_dimension() -> SeedDimension: + return SeedDimension( + columns=["label_name", "label_description"], + values=LABELS, + ) + + +def expected_row_count(model_count: int | None = None) -> int: + """Return the number of rows this configuration should generate.""" + model_total = len(MODEL_IDS) if model_count is None else model_count + return ( + len(LABELS) + * len(TRAIL_TYPES) + * len(STYLES) + * len(LANGUAGES) + * model_total + ) + + +def finalize_record(record: dict) -> dict: + """Keep the publication fields and flatten generation metadata.""" + return { + "label": record["label_name"], + "label_description": record["label_description"], + "label_source": "synthetic", + "trail_type": record["trail_type"], + "style": record["style"], + "language": record.get("_language", ""), + "model": record.get("_model", ""), + "text": record["text"], + } + + +def build_pipeline(): + return ( + Seed.product( + make_label_dimension(), + Seed.values("trail_type", TRAIL_TYPES), + Seed.values("style", STYLES), + ).as_step("seed_trail_report_grid") + >> LLMStep( + prompt=PROMPT_PATH, + input_columns=["label_name", "label_description", "trail_type", "style"], + output_column="text", + parse_mode="text", + model=make_models(), + language=LANGUAGES, + ).as_step("generate_trail_reports") + >> Map(finalize_record).as_step("finalize_record") + >> AddUUID(column="id", overwrite=True).as_step("add_uuid") + >> Sink.jsonl(OUTPUT_PATH) + ) + + +def push_records_to_hub(records: list[dict]) -> None: + list( + Sink.hub( + repo_id=HF_REPO_ID, + private=True, + train_size=0.8, + seed=SEED, + shuffle=True, + commit_message=( + "Publish cookbook 45 classification dataset with " + f"{', '.join(MODEL_IDS)}" + ), + ).process(records) + ) + + +def main() -> None: + print(f"Expected rows: {expected_row_count()}") + records = build_pipeline().run( + batch_size=4, + checkpoint_dir=CHECKPOINT_DIR, + resume=True, + ) + + if os.getenv("DATAFAST_PUSH_TO_HUB") == "1": + push_records_to_hub(records) + + print(f"Wrote {len(records)} records to {OUTPUT_PATH}") + + +if __name__ == "__main__": + main() diff --git a/mkdocs.yml b/mkdocs.yml index 80de161..131400c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - Langfuse Tracing: guides/langfuse_tracing.md - Cookbook: - cookbook/index.md + - Text Classification: cookbook/text_classification.md - Persona Generation: cookbook/persona_generation.md - Space Engineering Text Generation: cookbook/space_text_generation.md - Providers: llms.md From f8261370031aefc33a704fde8ea06c16018e1742 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 11 Jun 2026 15:21:33 +0200 Subject: [PATCH 16/54] Simplify text classification cookbook --- .../assets/text_classification_generation.txt | 2 +- docs/cookbook/text_classification.md | 9 +- .../45_cookbook_text_classification.py | 120 ++++++++---------- 3 files changed, 56 insertions(+), 75 deletions(-) diff --git a/docs/cookbook/assets/text_classification_generation.txt b/docs/cookbook/assets/text_classification_generation.txt index 7712183..24d28c0 100644 --- a/docs/cookbook/assets/text_classification_generation.txt +++ b/docs/cookbook/assets/text_classification_generation.txt @@ -1,6 +1,6 @@ Write one realistic hiker report in {language_name}. -Target category: {label_name} +Target category: {label} Category definition: {label_description} Constraints: diff --git a/docs/cookbook/text_classification.md b/docs/cookbook/text_classification.md index ede9d26..819523d 100644 --- a/docs/cookbook/text_classification.md +++ b/docs/cookbook/text_classification.md @@ -79,9 +79,10 @@ DATAFAST_PUSH_TO_HUB=1 python examples/scripts/45_cookbook_text_classification.p The run uses `checkpoint_dir` and `resume=True`. If generation is interrupted, run the command again to continue from saved checkpoints. -If you want to use provider-specific clients directly, replace `make_models()` -with providers such as `openai(...)` or `anthropic(...)`. The default setup -uses multiple OpenRouter-backed models so it works with one API key. +If you want to use provider-specific clients directly, replace `MODEL_IDS` or +the `model=MODELS` argument in `LLMStep` with providers such as `openai(...)` +or `anthropic(...)`. The default setup uses multiple OpenRouter-backed models +so it works with one API key. ## Prompt @@ -110,8 +111,6 @@ If you want an extra quality-control pass, add a downstream `Classify` and - `id` - generated row UUID - `label` - target trail-condition label -- `label_description` - human-readable label definition used in the prompt -- `label_source` - fixed to `synthetic` - `trail_type` - prompt expansion axis for the trail setting - `style` - prompt expansion axis for the report style - `language` - language code emitted by `LLMStep` diff --git a/examples/scripts/45_cookbook_text_classification.py b/examples/scripts/45_cookbook_text_classification.py index 2a215ad..496233b 100644 --- a/examples/scripts/45_cookbook_text_classification.py +++ b/examples/scripts/45_cookbook_text_classification.py @@ -35,7 +35,7 @@ LABELS = [ { - "label_name": "trail_obstruction", + "label": "trail_obstruction", "label_description": ( "The trail is partially or fully blocked by obstacles such as " "fallen trees, landslides, snow, flooding, erosion, or dense " @@ -43,14 +43,14 @@ ), }, { - "label_name": "infrastructure_issues", + "label": "infrastructure_issues", "label_description": ( "The report is about damaged or missing bridges, signs, stairs, " "handrails, markers, boardwalks, or similar trail infrastructure." ), }, { - "label_name": "hazards", + "label": "hazards", "label_description": ( "The trail has immediate safety risks such as slippery surfaces, " "dangerous crossings, unstable terrain, wildlife threats, or " @@ -58,7 +58,7 @@ ), }, { - "label_name": "positive_conditions", + "label": "positive_conditions", "label_description": ( "The report highlights clear, safe, enjoyable trail conditions " "such as good maintenance, solid infrastructure, clear signage, " @@ -83,36 +83,20 @@ "fr": "French", } +MODELS = [openrouter(model_id, temperature=0.8) for model_id in MODEL_IDS] +EXPECTED_ROWS = ( + len(LABELS) + * len(TRAIL_TYPES) + * len(STYLES) + * len(LANGUAGES) + * len(MODELS) +) -def make_models(): - return [openrouter(model_id, temperature=0.8) for model_id in MODEL_IDS] - -def make_label_dimension() -> SeedDimension: - return SeedDimension( - columns=["label_name", "label_description"], - values=LABELS, - ) - - -def expected_row_count(model_count: int | None = None) -> int: - """Return the number of rows this configuration should generate.""" - model_total = len(MODEL_IDS) if model_count is None else model_count - return ( - len(LABELS) - * len(TRAIL_TYPES) - * len(STYLES) - * len(LANGUAGES) - * model_total - ) - - -def finalize_record(record: dict) -> dict: - """Keep the publication fields and flatten generation metadata.""" +def keep_output_fields(record: dict) -> dict: + """Keep only the fields meant for publication.""" return { - "label": record["label_name"], - "label_description": record["label_description"], - "label_source": "synthetic", + "label": record["label"], "trail_type": record["trail_type"], "style": record["style"], "language": record.get("_language", ""), @@ -121,53 +105,51 @@ def finalize_record(record: dict) -> dict: } -def build_pipeline(): - return ( - Seed.product( - make_label_dimension(), - Seed.values("trail_type", TRAIL_TYPES), - Seed.values("style", STYLES), - ).as_step("seed_trail_report_grid") - >> LLMStep( - prompt=PROMPT_PATH, - input_columns=["label_name", "label_description", "trail_type", "style"], - output_column="text", - parse_mode="text", - model=make_models(), - language=LANGUAGES, - ).as_step("generate_trail_reports") - >> Map(finalize_record).as_step("finalize_record") - >> AddUUID(column="id", overwrite=True).as_step("add_uuid") - >> Sink.jsonl(OUTPUT_PATH) - ) - - -def push_records_to_hub(records: list[dict]) -> None: - list( - Sink.hub( - repo_id=HF_REPO_ID, - private=True, - train_size=0.8, - seed=SEED, - shuffle=True, - commit_message=( - "Publish cookbook 45 classification dataset with " - f"{', '.join(MODEL_IDS)}" - ), - ).process(records) - ) +pipeline = ( + Seed.product( + SeedDimension( + columns=["label", "label_description"], + values=LABELS, + ), + Seed.values("trail_type", TRAIL_TYPES), + Seed.values("style", STYLES), + ).as_step("seed_trail_report_grid") + >> LLMStep( + prompt=PROMPT_PATH, + input_columns=["label", "label_description", "trail_type", "style"], + output_column="text", + parse_mode="text", + model=MODELS, + language=LANGUAGES, + ).as_step("generate_trail_reports") + >> Map(keep_output_fields).as_step("keep_output_fields") + >> AddUUID(column="id", overwrite=True).as_step("add_uuid") + >> Sink.jsonl(OUTPUT_PATH) +) def main() -> None: - print(f"Expected rows: {expected_row_count()}") - records = build_pipeline().run( + print(f"Expected rows: {EXPECTED_ROWS}") + records = pipeline.run( batch_size=4, checkpoint_dir=CHECKPOINT_DIR, resume=True, ) if os.getenv("DATAFAST_PUSH_TO_HUB") == "1": - push_records_to_hub(records) + list( + Sink.hub( + repo_id=HF_REPO_ID, + private=True, + train_size=0.8, + seed=SEED, + shuffle=True, + commit_message=( + "Publish cookbook 45 classification dataset with " + f"{', '.join(MODEL_IDS)}" + ), + ).process(records) + ) print(f"Wrote {len(records)} records to {OUTPUT_PATH}") From e59363b8bdd1ce36f8bbcd0506f46e4cd49819ef Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 11 Jun 2026 15:33:50 +0200 Subject: [PATCH 17/54] change default to public dataset --- examples/scripts/45_cookbook_text_classification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/scripts/45_cookbook_text_classification.py b/examples/scripts/45_cookbook_text_classification.py index 496233b..01b7e5a 100644 --- a/examples/scripts/45_cookbook_text_classification.py +++ b/examples/scripts/45_cookbook_text_classification.py @@ -140,7 +140,7 @@ def main() -> None: list( Sink.hub( repo_id=HF_REPO_ID, - private=True, + private=False, train_size=0.8, seed=SEED, shuffle=True, From 174386381bf41b9cd91dce6d147ba4026ac8f5d1 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 11 Jun 2026 19:36:06 +0200 Subject: [PATCH 18/54] Refine trail comment prompt wording --- docs/cookbook/assets/index.md | 2 +- .../assets/text_classification_generation.txt | 9 ++++++--- docs/cookbook/text_classification.md | 14 +++++++------- .../scripts/45_cookbook_text_classification.py | 2 +- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/cookbook/assets/index.md b/docs/cookbook/assets/index.md index 7f69923..65896be 100644 --- a/docs/cookbook/assets/index.md +++ b/docs/cookbook/assets/index.md @@ -20,7 +20,7 @@ pipeline. | File | Style | | --- | --- | -| [text_classification_generation.txt](text_classification_generation.txt) | One short trail report per call, with label, trail type, style, and language injected | +| [text_classification_generation.txt](text_classification_generation.txt) | One short trail comment per call, with label, trail type, style, and language injected | ## Persona Generation diff --git a/docs/cookbook/assets/text_classification_generation.txt b/docs/cookbook/assets/text_classification_generation.txt index 24d28c0..85dc0f1 100644 --- a/docs/cookbook/assets/text_classification_generation.txt +++ b/docs/cookbook/assets/text_classification_generation.txt @@ -1,13 +1,16 @@ -Write one realistic hiker report in {language_name}. +Write one realistic trail comment in {language_name} that sounds like something +an actual hiker would write after being on the trail. Target category: {label} Category definition: {label_description} Constraints: -- The report must clearly match the target category. +- The comment must clearly match the target category. - The setting must be a {trail_type}. - The writing style must be {style}. - Keep it to 1 or 2 sentences. +- Make it sound first-hand, natural, and slightly informal when appropriate. +- Do not sound like an official report, safety bulletin, or structured form. - Do not mention the category name directly. - Do not use bullets, numbering, or explanations. -- Make the report concrete and varied. +- Make the comment concrete and varied. diff --git a/docs/cookbook/text_classification.md b/docs/cookbook/text_classification.md index 819523d..dd36422 100644 --- a/docs/cookbook/text_classification.md +++ b/docs/cookbook/text_classification.md @@ -12,7 +12,7 @@ Build a multilingual trail-conditions classification dataset with `datafast`. ## Use Case -This cookbook generates short hiker reports across four trail-condition labels +This cookbook generates short trail comments across four trail-condition labels so teams can monitor trail quality and surface issues quickly. The default setup is: @@ -25,7 +25,7 @@ The default setup is: ## Pipeline 1. Create a seed grid from labels, trail types, and writing styles. -2. Generate one short hiker report for each seed across all configured models +2. Generate one short trail comment for each seed across all configured models and languages. 3. Keep the label and prompt-variation provenance in flat output columns. 4. Add a UUID, write JSONL locally, and optionally push to Hugging Face Hub. @@ -89,7 +89,7 @@ so it works with one API key. The cookbook uses one prompt file and drives diversity through seed dimensions: ```text -Write one realistic hiker report in {language_name}. +Write one realistic trail comment in {language_name}. ``` See [text_classification_generation.txt](assets/text_classification_generation.txt) @@ -99,20 +99,20 @@ for the full prompt. - `LABELS` defines the target classes and their prompt descriptions. - `TRAIL_TYPES` controls the trail settings used in generation. -- `STYLES` controls the voice and format of each report. +- `STYLES` controls the voice and format of each comment. - `LANGUAGES` controls language expansion. - `MODEL_IDS` controls which models generate records. - `HF_REPO_ID` controls the optional Hugging Face Hub destination. If you want an extra quality-control pass, add a downstream `Classify` and -`Filter` stage to verify that generated reports match their intended label. +`Filter` stage to verify that generated comments match their intended label. ## Output Fields - `id` - generated row UUID - `label` - target trail-condition label - `trail_type` - prompt expansion axis for the trail setting -- `style` - prompt expansion axis for the report style +- `style` - prompt expansion axis for the comment style - `language` - language code emitted by `LLMStep` - `model` - model ID emitted by `LLMStep` -- `text` - generated hiker report +- `text` - generated trail comment diff --git a/examples/scripts/45_cookbook_text_classification.py b/examples/scripts/45_cookbook_text_classification.py index 01b7e5a..9dce7af 100644 --- a/examples/scripts/45_cookbook_text_classification.py +++ b/examples/scripts/45_cookbook_text_classification.py @@ -1,4 +1,4 @@ -"""Text-classification cookbook: seed grid -> multilingual trail reports. +"""Text-classification cookbook: seed grid -> multilingual trail comments. Demonstrates: Seed.product, prompt expansion via seed dimensions, multi-model generation, multi-language generation, checkpointing, JSONL output, and From 684f3ea8788f16b484a2ec931e749fe26a1fa9f9 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Fri, 12 Jun 2026 07:56:42 +0200 Subject: [PATCH 19/54] Adding LLM Providers requirements --- llm_provider_requirements.md | 259 +++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 llm_provider_requirements.md diff --git a/llm_provider_requirements.md b/llm_provider_requirements.md new file mode 100644 index 0000000..df82303 --- /dev/null +++ b/llm_provider_requirements.md @@ -0,0 +1,259 @@ +# LLM Provider Requirements (Draft) + +## Goal + +Design a clean model-provider layer for `datafast/llms.py` with one stable Datafast API, while resolving actual support per target model or deployment. + +The key design rule is: + +- The public API should provide a uniform core model. +- The public API should also provide ergonomic provider-specific entry points. +- Capabilities should be resolved per target: provider + endpoint + model + optional self-hosted server behavior. + +## Core Design Principles + +- Keep a small common config surface for normal usage. +- Do not assume all models under one provider support the same parameters. +- Do not silently pass unsupported parameters unless that behavior is explicitly enabled. +- Preserve provider or server defaults when the user does not override them. +- Separate Datafast-level config from provider-specific request mapping. + +## Common Datafast Config + +Every target should support these common fields when applicable: + +- `model_id` +- `temperature` +- `rpm_limit` +- `timeout` + +Optional fields, only sent when supported: + +- `max_completion_tokens` +- `thinking` +- `reasoning_effort` +- `api_key` +- `api_base_url` +- retry limit +- `unsupported_params` + +`unsupported_params` should control how Datafast handles user-specified parameters that are known to be unsupported by the resolved target. + +- `fail`: raise a clear error before sending the request +- `warn`: omit the unsupported parameter and emit a warning +- `quiet`: omit the unsupported parameter silently + +Default: + +- `unsupported_params="warn"` + +## Public API Ergonomics + +The public API should expose provider-specific entry points such as: + +- `openai(...)` +- `anthropic(...)` +- `openrouter(...)` +- `mistral(...)` +- `ollama(...)` + +Requirements: + +- Provider-specific entry points should be the primary ergonomic API for users. +- They should make provider choice explicit and easy to read in pipelines. +- They should expose sensible provider-specific defaults and validation. +- They should share the same common config surface where possible. +- They may expose provider-specific options when needed, without forcing those options into every provider API. +- They should remain thin wrappers over a shared internal target/config system. +- Core execution behavior such as retries, batching, capability resolution, caching, and parsing should not live separately in each provider wrapper. + +## Capability Resolution + +Requirements should be defined around resolved target capabilities, not provider classes alone. + +That means: + +- OpenAI-compatible transport does not imply OpenAI-equivalent features. +- OpenRouter support is model-specific, not just provider-specific. +- Local servers such as Ollama, vLLM, and `llama.cpp` may expose different controls even when they look OpenAI-compatible. +- Local servers may emulate an endpoint shape without matching the full upstream semantics. +- When support is unknown, the safe default is to omit optional params rather than optimistically send them. + +The design should allow: + +- capability mapping per model or deployment +- endpoint-mode resolution per target, especially chat completions vs Responses API +- provider-specific parameter aliases +- explicit escape hatches for provider-specific params +- controlled dropping of unsupported params when intentionally enabled + +Unsupported-parameter handling should be explicit and user-configurable through `unsupported_params`. + +- The policy should apply to Datafast-known unsupported parameters for the resolved target. +- The default behavior should be `warn`. +- `quiet` should be allowed for users who intentionally want best-effort portability. +- `fail` should be available for users who want strict validation. + +Some targets may work best through `completion()` and others through `responses()`. The public Datafast API should not force users to care about that distinction, but the internal adapter layer should. + +Requirements should also allow target-level compatibility notes such as: + +- chat endpoint requires a compatible chat template +- a parameter is accepted but ignored +- an endpoint is available but implemented as an internal translation layer + +## Request / Response Model + +Datafast should expose one request model that supports: + +- single request +- concurrent batch requests +- prompt input +- message input +- structured output via Pydantic + +The execution layer should support both: + +- native same-target batching for many inputs to one resolved model/deployment when available +- fallback concurrency when native batching is unavailable + +If native batching is unavailable and Datafast falls back to parallel single requests, the user should be warned that a fallback execution path is being used. + +The message model should support both: + +- simple text messages +- typed multimodal content parts + +Supported content parts should include a common shape for: + +- text +- image +- audio +- video +- file +- document + +This keeps the public API compatible with multimodal-capable chat models without forcing separate provider APIs for each modality. + +Content parts should also be able to carry optional stable media IDs / UUIDs for targets that can reuse multimodal processing across requests. + +## Multimodal Requirements + +- Multimodal input support must be capability-aware per target. +- A model that supports text-only should still work with the same public call shape. +- A model that supports image, audio, video, document, or file inputs should accept typed content parts in `messages`. +- The design should also allow non-text outputs when supported, especially image-generation-capable chat models. +- Structured output and multimodal input should coexist when the target supports both. +- The design should support targets that expose multimodal and reasoning features primarily through the Responses API. +- The design should not assume all local backends support the same modalities. For example, support for image, audio, video, and document inputs may differ substantially between vLLM and `llama.cpp`. +- The design should allow target-specific media options when needed, without polluting the common API surface. + +## Reliability and Execution + +Every LLM call should have a standard execution policy: + +- bounded retries +- exponential backoff +- jitter +- retryable vs non-retryable error handling +- consistent timeout handling +- client-side RPM throttling + +Batch execution should: + +- preserve input order +- apply the same retry and timeout rules as single requests +- use native same-target batching when available +- fall back to controlled concurrency when native batching is unavailable +- warn the user when fallback concurrency is used instead of native batching + +## Endpoint Mode Requirements + +The design should explicitly allow multiple endpoint modes behind one public API. + +- Some targets should be called through chat completions. +- Some targets should be called through the Responses API. +- Endpoint choice should be resolved per target capability, not hardcoded per provider class. +- Responses API support matters for targets that expose reasoning, multimodal I/O, image generation, or session continuity through that endpoint. +- When the Responses API is used, the design should allow carrying forward response-session state such as `previous_response_id` when needed. +- The requirements should not assume that every Responses API implementation is native. A local backend may expose `/v1/responses` by translating it into another internal request shape. + +## Caching Requirements + +Caching should be part of the design, but not assumed to behave the same across targets. + +The requirements should distinguish: + +- provider-native prompt caching +- gateway or routing-layer caching +- local server prefix / KV caching +- optional client-side result caching + +Key requirements: + +- caching must be explicit and correctness-preserving +- cache behavior must be capability-aware per target +- cache keys or cache hints must account for model, endpoint, relevant generation params, and multimodal inputs +- provider-specific caching controls should be supported through the mapping layer or escape hatch +- the public API should not promise identical cache semantics across OpenAI, Anthropic, Mistral, OpenRouter, Ollama, vLLM, and `llama.cpp` + +The requirements should also distinguish between: + +- provider-side prompt caching semantics +- prefix / KV-cache reuse for repeated prompt prefixes +- multimodal preprocessing cache reuse keyed by stable media identity + +In particular, local backends may expose caching mainly as performance-oriented KV reuse rather than provider-managed prompt caching. That should be modeled explicitly. + +## What To Keep From The Current Design + +The current `llms.py` points to a few good design directions that should remain in the requirements: + +- one stable API for single and batch calls +- first-class structured output +- proactive client-side rate limiting +- standard retry behavior +- graceful fallback when a target lacks native batching +- support for local backends without requiring an API key +- tracing / metadata hooks on every request + +## Recommended Direction + +The optimal design is: + +- provider-specific public factories as thin entry points +- one common Datafast request/config model +- one target capability layer +- one shared execution layer for retries, throttling, batching, caching, and parsing +- thin internal provider adapters that only map Datafast requests into target-specific LiteLLM calls + +The capability layer should be able to describe at least: + +- supported endpoint modes +- supported modalities +- structured-output mechanism +- cache mechanism type +- chat-template or prompt-format requirements +- parameter caveats such as unsupported, ignored, translated, or model-dependent + +This keeps the user-facing API simple while allowing model-specific behavior where it actually belongs. + +## References + +- LiteLLM provider-specific params: +- LiteLLM drop unsupported params: +- LiteLLM retries / fallbacks: +- LiteLLM batching: +- LiteLLM Responses API: +- LiteLLM structured output / JSON mode: +- LiteLLM reasoning content: +- LiteLLM vision: +- LiteLLM audio: +- LiteLLM document understanding: +- LiteLLM image generation in chat: +- vLLM online serving: +- vLLM structured outputs: +- vLLM automatic prefix caching: +- vLLM multimodal inputs: +- llama.cpp server: +- llama.cpp multimodal: From 3b0460095e76442809ff05bfb73ee38b5ded5d06 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Fri, 12 Jun 2026 07:56:55 +0200 Subject: [PATCH 20/54] Adding LLM Providers Test Plan --- llm_provider_test_plan.md | 266 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 llm_provider_test_plan.md diff --git a/llm_provider_test_plan.md b/llm_provider_test_plan.md new file mode 100644 index 0000000..5ee5372 --- /dev/null +++ b/llm_provider_test_plan.md @@ -0,0 +1,266 @@ +# LLM Provider Test Plan (Draft) + +## Goal + +Test the provider redesign without exploding the matrix. + +Main idea: + +- Test shared behavior once at the common layer. +- Test only provider/model deltas at the capability layer. +- Run a meaningful live suite against selected real models. +- Keep the live suite maintainable through a small curated model catalog. +- Defer multimodal live coverage until after the first stable text-first provider test suite is in place. +- Defer caching coverage until after the first stable text-first provider test suite is in place. + +## Test Layers + +| Layer | Purpose | Typical tools | +|---|---|---| +| Unit / contract | Validate request normalization, capability resolution, retry logic, batching decisions, parsing, caching decisions | mocked LiteLLM / fake adapters | +| Adapter tests | Verify mapping from Datafast request to LiteLLM request per endpoint mode | mocked `completion()`, `batch_completion()`, `responses()` | +| Live acceptance | Verify selected real models are safe for Datafast users | live API / local server | + +## Marker Strategy + +Recommended markers: + +- `live`: any test hitting a real provider endpoint +- `multimodal`: reserved for later image / audio / document / video coverage +- `ollama`: real Ollama backend +- `vllm`: real vLLM backend +- `llamacpp`: real `llama.cpp` backend + +Suggested usage: + +- default CI: mocked tests only +- provider CI / pre-release: `-m live` +- targeted local runs: `-m "live and ollama"` / `-m "live and vllm"` / `-m "live and llamacpp"` + +## Matrix Reduction Strategy + +- Do not test every feature against every provider. +- Run a compact acceptance suite against a curated list of selected models. +- Choose one representative provider/model endpoint per endpoint mode for mocked tests. +- Choose one representative provider/model endpoint per modality for deeper live tests. +- For each provider, test only what is different from the shared contract. +- Keep local-backend tests separate from hosted-provider smoke tests. + +## Selected Model Catalog + +Maintain one curated list of current supported / recommended test targets per provider. + +This catalog should not aim to include every available model. It should be a curated test surface for capability coverage and user confidence, not a registry of all provider inventory. + +Each catalog entry should record at least: + +- provider +- model_id +- endpoint mode +- hosted vs local +- expected modalities +- expected structured-output support +- expected reasoning / thinking support +- expected batching behavior +- expected cache mechanism type +- test markers to apply, such as `live`, `multimodal`, `ollama`, `vllm`, `llamacpp` + +Design goal: + +- adding a new model should usually mean adding one catalog entry +- most live tests should parametrize over that catalog +- provider/model-specific regressions should be captured as capability expectations in the catalog + +Models are good candidates for the catalog when they are: + +- recommended to Datafast users +- used in docs or examples +- representative of a distinct capability shape +- newly added and worth validating before being treated as supported +- known to be tricky or historically unstable + +Models are usually not good candidates when they: + +- do not add meaningful new capability coverage +- are deprecated or not intended for ongoing support +- are only one of many near-identical variants from the same provider + +## Live Acceptance Suite + +These should run against the curated selected-model catalog. + +| ID | Test | +|---|---| +| L01 | Basic text generation works for every selected live model | +| L02 | Structured output works for every selected live model that claims support | +| L03 | Batch request works for every selected live model using the expected execution path, and emits a warning if fallback batching is used | +| L04 | Common params such as `timeout` and `temperature` are accepted or handled according to capability expectations | +| L05 | Declared unsupported params follow `unsupported_params` policy as expected for that model | +| L06 | Endpoint mode matches expectation: chat completions vs Responses API | +| L07 | Provider-specific factory entry point works for that model | +| L08 | Metadata / tracing path does not break live requests | + +For local backends, include: + +| ID | Test | +|---|---| +| L09 | `api_base_url` path works | +| L10 | no-API-key path works where expected | + +## Core Contract Tests + +These should run with mocks only. + +| ID | Test | +|---|---| +| C01 | Factory functions such as `openai(...)`, `openrouter(...)`, `ollama(...)` create the expected internal target/config shape | +| C02 | Single prompt returns a single result | +| C03 | Batch prompts return ordered list results | +| C04 | `messages` input works for single request | +| C05 | Batched `messages` input works and preserves order | +| C06 | Reject `prompt=None` and `messages=None` | +| C07 | Reject providing both `prompt` and `messages` | +| C08 | Structured output with Pydantic parses successfully | +| C09 | Structured output surfaces a clear validation error on invalid JSON / schema mismatch | +| C10 | Text responses are normalized consistently | +| C11 | Metadata / tracing payload is attached to requests | + +## Capability Layer Tests + +These should validate the resolved target rules. + +| ID | Test | +|---|---| +| K01 | Supported params are forwarded for a target that allows them | +| K02 | Unsupported params are omitted by default when capability is unknown | +| K03 | `unsupported_params="warn"` omits unsupported params and emits a warning | +| K04 | `unsupported_params="fail"` raises a clear error before request dispatch | +| K05 | `unsupported_params="quiet"` omits unsupported params without warning | +| K06 | Provider-specific aliases map correctly to the internal common config | +| K07 | `thinking=False` suppresses `reasoning_effort` | +| K08 | `thinking=True` with no explicit `reasoning_effort` uses target default | +| K09 | Endpoint mode resolves correctly: chat completions vs Responses API | +| K10 | Capability notes such as "accepted but ignored" or "translated internally" are represented correctly | +| K11 | OpenAI-compatible target is not assumed to support all OpenAI features | +| K12 | Local target requiring a chat template is flagged correctly | + +## Adapter Tests + +These verify the LiteLLM call shape. + +| ID | Test | +|---|---| +| A01 | Chat-completions target calls `litellm.completion()` for single input | +| A02 | Native same-target batch calls `litellm.batch_completion()` when supported | +| A03 | If native batching is unavailable, batch input is executed via bounded parallel single requests, preserves ordered batch outputs, and emits a user warning | +| A04 | Responses target calls `litellm.responses()` | +| A05 | Responses target forwards `previous_response_id` when present | +| A06 | Structured output maps to the correct LiteLLM field per endpoint mode | +| A07 | Provider-specific extra params pass only through the escape hatch | +| A08 | `api_base_url` and optional `api_key` are passed correctly for local / self-hosted targets | + +## Reliability Tests + +| ID | Test | +|---|---| +| R01 | Retryable error triggers bounded retries | +| R02 | Non-retryable error fails immediately | +| R03 | Backoff grows across retries | +| R04 | Jitter is applied within the expected range | +| R05 | Timeout is forwarded and timeout failure is surfaced clearly | +| R06 | Client-side `rpm_limit` throttles before provider error | +| R07 | Batch retry behavior preserves output ordering | + +## Multimodal Tests + +Multimodal coverage should come later. + +For the first rollout: + +- keep multimodal tests out of the required live acceptance suite +- allow a small number of mocked multimodal contract tests if useful +- add real multimodal coverage only after the text-first live suite is stable + +| ID | Test | +|---|---| +| M01 | Text-only message content remains supported | +| M02 | Image content part is accepted for a target with image input support | +| M03 | Audio content part is accepted for a target with audio input support | +| M04 | Video content part is accepted for a target with video input support | +| M05 | File / document content part is accepted for a target with document support | +| M06 | Unsupported modality is rejected clearly for a text-only target | +| M07 | Mixed text + image multimodal message preserves part order | +| M08 | Stable media ID / UUID is forwarded when provided | +| M09 | Non-text output path is selected correctly for image-generation-capable chat target | + +## Caching Tests + +Caching coverage should come later. + +For the first rollout: + +- keep caching tests out of the required live acceptance suite +- allow mocked cache-resolution tests if useful +- add real cache-behavior coverage only after the text-first live suite is stable + +| ID | Test | +|---|---| +| H01 | Cache mode resolves correctly for provider-native prompt caching | +| H02 | Cache mode resolves correctly for local prefix / KV caching | +| H03 | Cache key / cache hint changes when model changes | +| H04 | Cache key / cache hint changes when relevant generation params change | +| H05 | Cache key / cache hint changes when multimodal input identity changes | +| H06 | Stable media identity enables multimodal reuse hint when supported | +| H07 | Public API does not claim cache hit semantics that the target cannot guarantee | + +## Provider / Model Delta Live Tests + +Add only when a selected model has behavior that differs meaningfully from the common suite. + +| ID | Example | +|---|---| +| D01 | Responses-only reasoning model | +| D02 | OpenRouter model with provider-specific capability caveat | +| D03 | vLLM deployment with structured-output expectations | +| D04 | `llama.cpp` target with chat-template requirement | +| D05 | model with unusual unsupported-param behavior expectations | +| D06 | multimodal model with image input support | +| D07 | cache-relevant local backend behavior | + +## Extended Live Scenarios + +These are later-phase tests, not required for the initial rollout. + +| ID | Target | Test | +|---|---|---| +| E01 | Multimodal hosted model | text + image input | +| E02 | Audio or document-capable model | real multimodal request | +| E03 | Structured-output target | real Pydantic schema validation | +| E04 | Provider with prompt caching | repeated request with cache-relevant setup | +| E05 | vLLM | prefix-cache-friendly repeated prompt | +| E06 | local multimodal target | document or image input if supported | +| E07 | Responses target | `previous_response_id` continuation | +| E08 | selected-model sweep | run the full acceptance suite across the full catalog | + +## New Model Onboarding + +When a new model comes out: + +1. Add it to the selected-model catalog with expected capabilities. +2. Run the shared live acceptance suite against it. +3. Add a provider/model delta test only if it differs from the standard expectations. +4. Add an extended live scenario only if it adds meaningful new capability coverage. + +## Suggested Priorities + +- Phase 1: `C*`, `K*`, `A*`, `R*` +- Phase 2: selected-model `L*` live suite +- Phase 3: `M*`, `H*`, `D*` +- Phase 4: `E*` + +## Success Criteria + +- Shared behavior is covered mostly by fast mocked tests. +- The curated live suite gives confidence against real provider endpoints. +- Provider/model-specific logic is tested as deltas, not full re-runs of the whole matrix. +- Adding a new model is mostly a catalog update plus, if needed, one delta test. From 20c8896efa6bf990e33a09413c41e21ab244cc68 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Tue, 16 Jun 2026 17:48:57 +0200 Subject: [PATCH 21/54] updating LLM provider test plan --- llm_provider_test_plan.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/llm_provider_test_plan.md b/llm_provider_test_plan.md index 5ee5372..71bad93 100644 --- a/llm_provider_test_plan.md +++ b/llm_provider_test_plan.md @@ -85,6 +85,25 @@ Models are usually not good candidates when they: - are deprecated or not intended for ongoing support - are only one of many near-identical variants from the same provider +### Current Catalog Decisions + +Current agreed shortlist as of June 2026: + +- OpenAI: `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano` +- Anthropic: `claude-sonnet-4-6`, `claude-haiku-4-5` +- Gemini: `gemini-2.5-pro`, `gemini-3.5-flash`, `gemini-3.1-flash-lite` +- Mistral hosted: `mistral-medium-3-5`, `mistral-large-2512`, `mistral-small-2603` +- Mistral local / self-hosted: `ministral-14b-2512`, `ministral-8b-2512`, `ministral-3b-2512` + +Current exclusions / constraints: + +- Exclude Anthropic `claude-fable-5` and `claude-opus-4-8` due to cost. +- Exclude Gemini `gemini-2.5-flash`. +- Keep the catalog curated for capability coverage, not exhaustive by provider inventory. +- Keep hosted Mistral and local Mistral entries separate in the catalog. +- Treat local-server capability expectations as backend-specific, especially for `vLLM`, `llama.cpp`, and other OpenAI-compatible servers. +- If a compact local Mistral subset is needed later, start with `ministral-8b-2512` and `ministral-3b-2512`. + ## Live Acceptance Suite These should run against the curated selected-model catalog. From 8c4c9839f8b67191abe1655d78c843ce9cb87dff Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Tue, 16 Jun 2026 17:51:45 +0200 Subject: [PATCH 22/54] fix llms provider --- README.md | 1 + datafast/__init__.py | 4 + datafast/core/runner.py | 231 +++- datafast/llm/__init__.py | 28 + datafast/llm/capabilities.py | 273 +++++ datafast/llm/provider.py | 1465 +++++++++++++++++++++++++- datafast/llm/types.py | 151 +++ datafast/llms.py | 922 +--------------- datafast/transforms/llm_eval.py | 6 +- datafast/transforms/llm_extract.py | 2 +- datafast/transforms/llm_step.py | 2 +- datafast/transforms/llm_transform.py | 2 +- examples/providers/README.md | 8 + 13 files changed, 2122 insertions(+), 973 deletions(-) create mode 100644 datafast/llm/capabilities.py create mode 100644 datafast/llm/types.py create mode 100644 examples/providers/README.md diff --git a/README.md b/README.md index 38b2deb..90503e5 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ configure_langfuse_tracing() - `datafast/`: canonical source package - `examples/scripts/`: runnable pipeline examples +- `examples/providers/`: direct provider usage examples - `docs/`: pipeline-first documentation - `datafast_new_design_document.md`: retained design reference diff --git a/datafast/__init__.py b/datafast/__init__.py index 6576370..5ffcae8 100644 --- a/datafast/__init__.py +++ b/datafast/__init__.py @@ -15,12 +15,14 @@ MistralProvider, OpenRouterProvider, OllamaProvider, + OpenAICompatibleProvider, openai, anthropic, gemini, mistral, openrouter, ollama, + openai_compatible, ) from datafast.logger_config import configure_logger from datafast.sinks.sink import Sink, JSONLSink, CSVSink, ListSink, ParquetSink, HubSink @@ -93,12 +95,14 @@ def get_version() -> str: "MistralProvider", "OpenRouterProvider", "OllamaProvider", + "OpenAICompatibleProvider", "openai", "anthropic", "gemini", "mistral", "openrouter", "ollama", + "openai_compatible", "configure_logger", "configure_langfuse_tracing", "get_version", diff --git a/datafast/core/runner.py b/datafast/core/runner.py index 3497605..c7d280f 100644 --- a/datafast/core/runner.py +++ b/datafast/core/runner.py @@ -3,6 +3,7 @@ import time import uuid from collections import defaultdict +from dataclasses import dataclass from typing import TYPE_CHECKING from loguru import logger @@ -19,7 +20,6 @@ if TYPE_CHECKING: from datafast.core.step import Pipeline, Step - from datafast.llm.provider import LLMProvider from datafast.transforms.llm_step import LLMStep @@ -29,6 +29,12 @@ def chunked(iterable: list, size: int): yield iterable[i : i + size] +@dataclass +class _LLMBatchStats: + generated: int = 0 + errors: int = 0 + + class Runner: """ Execution engine for pipelines. @@ -218,77 +224,206 @@ def _execute_llm_step( if self._checkpoint_mgr and not skip_call_ids: self._checkpoint_mgr.clear_step_file(step_index, step_name) - completed_in_batch = 0 + completed_since_checkpoint = 0 errors = 0 generated_total = len(skip_call_ids) for batch in chunked(calls, self.config.batch_size): batch_start = time.perf_counter() - batch_generated = 0 - batch_model_id = batch[0].model_id if batch else "unknown" - - for call in batch: - model = models_map[call.model_id] - batch_model_id = call.model_id - - try: - result = model.generate( - messages=call.messages, - metadata=build_trace_metadata( - model=model, - component="pipeline.step", - trace_name=f"datafast.{step_name}", - session_id=self._trace_session_id, - step_name=step_name, - step_type=step.__class__.__name__, - record_index=call.record_index, - prompt_index=call.prompt_index, - output_index=call.output_index, - language_code=call.language_code or None, - call_id=call.call_id, - ), - ) - output_record = step.apply_result(call, result, model) - output_records.append(output_record) - progress.completed_call_ids.append(call.call_id) - completed_in_batch += 1 - batch_generated += 1 - generated_total += 1 - - # Append record immediately to JSONL - if self._checkpoint_mgr: - self._checkpoint_mgr.append_record( - step_index, step_name, output_record - ) - - except Exception as e: - errors += 1 - logger.warning( - f"LLM call failed | Model: {call.model_id} | " - f"Call: {call.call_id} | Error: {e}" - ) + batch_model_id = ( + batch[0].model_id + if len({call.model_id for call in batch}) == 1 + else "mixed" + ) + stats = self._execute_llm_batch( + step=step, + step_name=step_name, + step_index=step_index, + batch=batch, + models_map=models_map, + progress=progress, + output_records=output_records, + ) + completed_since_checkpoint += stats.generated + errors += stats.errors + generated_total += stats.generated batch_duration = time.perf_counter() - batch_start logger.info( - f"Generated {batch_generated} samples (total: {generated_total}) | " + f"Generated {stats.generated} samples (total: {generated_total}) | " f"model: {batch_model_id} | duration: {batch_duration:.2f}s" ) if ( self._checkpoint_mgr and manifest - and completed_in_batch >= self.config.checkpoint_every + and completed_since_checkpoint >= self.config.checkpoint_every ): self._checkpoint_mgr.save_llm_progress( step_index, step_name, progress, output_records ) - completed_in_batch = 0 + completed_since_checkpoint = 0 logger.info( f"LLMStep complete: {len(output_records)} outputs, {errors} errors" ) return output_records + def _execute_llm_batch( + self, + *, + step: "LLMStep", + step_name: str, + step_index: int, + batch: list[LLMCall], + models_map: dict[str, object], + progress: LLMStepProgress, + output_records: list[Record], + ) -> _LLMBatchStats: + """Execute and apply one runner batch, preserving input order.""" + batch_results, errors = self._collect_llm_batch_results( + step=step, + step_name=step_name, + batch=batch, + models_map=models_map, + ) + stats = self._apply_llm_batch_results( + step=step, + step_name=step_name, + step_index=step_index, + batch=batch, + batch_results=batch_results, + models_map=models_map, + progress=progress, + output_records=output_records, + ) + stats.errors += errors + return stats + + def _collect_llm_batch_results( + self, + *, + step: "LLMStep", + step_name: str, + batch: list[LLMCall], + models_map: dict[str, object], + ) -> tuple[list[object | None], int]: + batch_results: list[object | None] = [None] * len(batch) + errors = 0 + grouped_indexes: dict[str, list[int]] = defaultdict(list) + + for index, call in enumerate(batch): + grouped_indexes[call.model_id].append(index) + + for model_id, indexes in grouped_indexes.items(): + group_calls = [batch[index] for index in indexes] + model = models_map[model_id] + try: + group_results = self._generate_llm_group( + step=step, + step_name=step_name, + model=model, + group_calls=group_calls, + ) + except Exception as e: + errors += len(group_calls) + self._log_llm_failures(group_calls, e) + continue + + for result_index, result in zip(indexes, group_results): + batch_results[result_index] = result + + return batch_results, errors + + def _generate_llm_group( + self, + *, + step: "LLMStep", + step_name: str, + model: object, + group_calls: list[LLMCall], + ) -> list[object]: + group_metadata = [ + self._build_llm_call_metadata(step, step_name, call, model) + for call in group_calls + ] + if hasattr(model, "generate_batch"): + return list( + model.generate_batch( # type: ignore[attr-defined] + [call.messages for call in group_calls], + metadata=group_metadata, + ) + ) + return [ + model.generate( # type: ignore[attr-defined] + messages=call.messages, + metadata=metadata, + ) + for call, metadata in zip(group_calls, group_metadata) + ] + + def _apply_llm_batch_results( + self, + *, + step: "LLMStep", + step_name: str, + step_index: int, + batch: list[LLMCall], + batch_results: list[object | None], + models_map: dict[str, object], + progress: LLMStepProgress, + output_records: list[Record], + ) -> _LLMBatchStats: + stats = _LLMBatchStats() + + for call, result in zip(batch, batch_results): + if result is None: + continue + try: + output_record = step.apply_result(call, result, models_map[call.model_id]) + output_records.append(output_record) + progress.completed_call_ids.append(call.call_id) + stats.generated += 1 + + if self._checkpoint_mgr: + self._checkpoint_mgr.append_record( + step_index, step_name, output_record + ) + except Exception as e: + stats.errors += 1 + self._log_llm_failures([call], e) + + return stats + + def _build_llm_call_metadata( + self, + step: "LLMStep", + step_name: str, + call: LLMCall, + model: object, + ) -> dict[str, object]: + return build_trace_metadata( + model=model, + component="pipeline.step", + trace_name=f"datafast.{step_name}", + session_id=self._trace_session_id, + step_name=step_name, + step_type=step.__class__.__name__, + record_index=call.record_index, + prompt_index=call.prompt_index, + output_index=call.output_index, + language_code=call.language_code or None, + call_id=call.call_id, + ) + + @staticmethod + def _log_llm_failures(calls: list[LLMCall], error: Exception) -> None: + for call in calls: + logger.warning( + f"LLM call failed | Model: {call.model_id} | " + f"Call: {call.call_id} | Error: {error}" + ) + def _order_calls(self, calls: list[LLMCall]) -> list[LLMCall]: """Order calls according to execution strategy.""" strategy = self.config.llm_strategy diff --git a/datafast/llm/__init__.py b/datafast/llm/__init__.py index 725ece6..eba520d 100644 --- a/datafast/llm/__init__.py +++ b/datafast/llm/__init__.py @@ -8,12 +8,27 @@ MistralProvider, OpenRouterProvider, OllamaProvider, + OpenAICompatibleProvider, openai, anthropic, gemini, mistral, openrouter, ollama, + openai_compatible, +) +from datafast.llm.types import ( + BatchMode, + CacheMode, + ContentPart, + EndpointMode, + Modality, + NormalizedResponse, + RetryPolicy, + StructuredOutputMode, + TargetCapabilities, + TargetConfig, + UnsupportedParamsPolicy, ) from datafast.llm.parsing import ( OutputParser, @@ -30,12 +45,25 @@ "MistralProvider", "OpenRouterProvider", "OllamaProvider", + "OpenAICompatibleProvider", "openai", "anthropic", "gemini", "mistral", "openrouter", "ollama", + "openai_compatible", + "BatchMode", + "CacheMode", + "ContentPart", + "EndpointMode", + "Modality", + "NormalizedResponse", + "RetryPolicy", + "StructuredOutputMode", + "TargetCapabilities", + "TargetConfig", + "UnsupportedParamsPolicy", "OutputParser", "TextParser", "JSONParser", diff --git a/datafast/llm/capabilities.py b/datafast/llm/capabilities.py new file mode 100644 index 0000000..14d6b26 --- /dev/null +++ b/datafast/llm/capabilities.py @@ -0,0 +1,273 @@ +"""Capability resolution for Datafast LLM targets.""" + +from __future__ import annotations + +from datafast.llm.types import ( + BatchMode, + CacheMode, + EndpointMode, + Modality, + StructuredOutputMode, + TargetCapabilities, +) + + +COMMON_CHAT_PARAMS = frozenset({ + "temperature", + "max_completion_tokens", + "timeout", +}) + +SAMPLING_CHAT_PARAMS = frozenset({ + "top_p", + "frequency_penalty", +}) + +REASONING_PARAMS = frozenset({ + "thinking", + "reasoning_effort", +}) + +RESPONSES_PARAMS = frozenset({ + "temperature", + "max_completion_tokens", + "timeout", + "thinking", + "reasoning_effort", + "previous_response_id", +}) + + +HOSTED_CHAT = TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT}), + default_endpoint_mode=EndpointMode.CHAT, + supported_params=COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS, + structured_output=StructuredOutputMode.JSON_SCHEMA, + batch_mode=BatchMode.LITELLM_BATCH, + cache_mode=CacheMode.PROVIDER_PROMPT, +) + + +OPENAI_RESPONSES = TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT, EndpointMode.RESPONSES}), + default_endpoint_mode=EndpointMode.RESPONSES, + supported_params=RESPONSES_PARAMS | SAMPLING_CHAT_PARAMS, + structured_output=StructuredOutputMode.JSON_SCHEMA, + batch_mode=BatchMode.FALLBACK_CONCURRENCY, + cache_mode=CacheMode.PROVIDER_PROMPT, + supports_reasoning=True, +) + + +OPENAI_CHAT = TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT, EndpointMode.RESPONSES}), + default_endpoint_mode=EndpointMode.CHAT, + supported_params=COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS, + structured_output=StructuredOutputMode.JSON_SCHEMA, + batch_mode=BatchMode.LITELLM_BATCH, + cache_mode=CacheMode.PROVIDER_PROMPT, +) + + +ANTHROPIC_CHAT = TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT}), + default_endpoint_mode=EndpointMode.CHAT, + supported_params=COMMON_CHAT_PARAMS | REASONING_PARAMS, + structured_output=StructuredOutputMode.JSON_SCHEMA, + batch_mode=BatchMode.LITELLM_BATCH, + cache_mode=CacheMode.PROVIDER_PROMPT, + supports_reasoning=True, + supports_thinking=True, +) + + +OPENROUTER_CHAT = TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT}), + default_endpoint_mode=EndpointMode.CHAT, + supported_params=COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS, + modalities=frozenset({Modality.TEXT, Modality.IMAGE}), + structured_output=StructuredOutputMode.JSON_SCHEMA, + batch_mode=BatchMode.LITELLM_BATCH, + cache_mode=CacheMode.ROUTER, + notes=( + "OpenRouter capabilities remain model and routed-provider dependent.", + "Reasoning controls are omitted by default; pass provider_params for " + "model-specific OpenRouter/LiteLLM escape hatches.", + ), +) + + +OLLAMA_CHAT = TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT}), + default_endpoint_mode=EndpointMode.CHAT, + supported_params=COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS, + structured_output=StructuredOutputMode.JSON_OBJECT, + batch_mode=BatchMode.FALLBACK_CONCURRENCY, + cache_mode=CacheMode.LOCAL_KV, + no_api_key=True, + notes=("Structured output uses Ollama JSON mode plus Datafast validation.",), +) + + +VLLM_CHAT = TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT, EndpointMode.RESPONSES}), + default_endpoint_mode=EndpointMode.CHAT, + supported_params=COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS, + modalities=frozenset({Modality.TEXT, Modality.IMAGE, Modality.VIDEO}), + structured_output=StructuredOutputMode.JSON_SCHEMA, + batch_mode=BatchMode.FALLBACK_CONCURRENCY, + cache_mode=CacheMode.LOCAL_KV, + no_api_key=True, + requires_chat_template=True, + notes=( + "vLLM exposes OpenAI-compatible chat and Responses endpoints, but " + "feature coverage remains model and server-version dependent.", + "Multimodal support depends on the served model; stable media UUIDs " + "can be passed with ContentPart.media_id.", + ), +) + + +LLAMACPP_CHAT = TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT}), + default_endpoint_mode=EndpointMode.CHAT, + supported_params=COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS, + modalities=frozenset({ + Modality.TEXT, + Modality.IMAGE, + Modality.AUDIO, + Modality.VIDEO, + Modality.FILE, + }), + structured_output=StructuredOutputMode.JSON_SCHEMA, + batch_mode=BatchMode.FALLBACK_CONCURRENCY, + cache_mode=CacheMode.LOCAL_KV, + no_api_key=True, + requires_chat_template=True, + notes=( + "llama.cpp server is OpenAI-compatible for chat, with JSON schema " + "support through response_format.", + "Multimodal inputs and reasoning controls are model and build dependent; " + "use provider_params for llama.cpp-specific extra_body fields.", + ), +) + + +OPENAI_COMPATIBLE_CHAT = TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT, EndpointMode.RESPONSES}), + default_endpoint_mode=EndpointMode.CHAT, + supported_params=frozenset({"timeout"}), + structured_output=StructuredOutputMode.PROMPTED_JSON, + batch_mode=BatchMode.FALLBACK_CONCURRENCY, + cache_mode=CacheMode.LOCAL_KV, + no_api_key=True, + requires_chat_template=True, + notes=("OpenAI-compatible transport does not imply OpenAI feature support.",), +) + + +_CATALOG: dict[tuple[str, str], TargetCapabilities] = { + ("openai", "gpt-5.5"): OPENAI_RESPONSES, + ("openai", "gpt-5.4"): OPENAI_RESPONSES, + ("openai", "gpt-5.4-mini"): OPENAI_RESPONSES, + ("openai", "gpt-5.4-nano"): OPENAI_RESPONSES, + ("anthropic", "claude-sonnet-4-6"): ANTHROPIC_CHAT, + ("anthropic", "claude-haiku-4-5"): ANTHROPIC_CHAT, + ("gemini", "gemini-2.5-pro"): HOSTED_CHAT, + ("gemini", "gemini-3.5-flash"): HOSTED_CHAT, + ("gemini", "gemini-3.1-flash-lite"): HOSTED_CHAT, + ("mistral", "mistral-medium-3-5"): HOSTED_CHAT, + ("mistral", "mistral-large-2512"): HOSTED_CHAT, + ("mistral", "mistral-small-2603"): HOSTED_CHAT, + ("mistral", "ministral-14b-2512"): OPENAI_COMPATIBLE_CHAT, + ("mistral", "ministral-8b-2512"): OPENAI_COMPATIBLE_CHAT, + ("mistral", "ministral-3b-2512"): OPENAI_COMPATIBLE_CHAT, +} + +_PROVIDER_DEFAULTS: dict[str, TargetCapabilities] = { + "anthropic": ANTHROPIC_CHAT, + "gemini": HOSTED_CHAT, + "llamacpp": LLAMACPP_CHAT, + "mistral": HOSTED_CHAT, + "ollama": OLLAMA_CHAT, + "openrouter": OPENROUTER_CHAT, + "vllm": VLLM_CHAT, +} + +_OPENAI_COMPATIBLE_PROVIDERS = frozenset({ + "openai_compatible", +}) + + +def resolve_capabilities( + provider: str, + model_id: str, + *, + api_base_url: str | None = None, + explicit: TargetCapabilities | None = None, +) -> TargetCapabilities: + """Resolve target capabilities with conservative defaults.""" + if explicit is not None: + return explicit + + normalized_provider = provider.lower() + normalized_model = model_id.lower() + + catalog_match = _CATALOG.get((normalized_provider, normalized_model)) + if catalog_match is not None: + return catalog_match + + if normalized_provider == "openai": + return _resolve_openai_capabilities(normalized_model) + + provider_default = _PROVIDER_DEFAULTS.get(normalized_provider) + if provider_default is not None: + return provider_default + + if normalized_provider in _OPENAI_COMPATIBLE_PROVIDERS: + return OPENAI_COMPATIBLE_CHAT + + if api_base_url: + return OPENAI_COMPATIBLE_CHAT + + return _unknown_capabilities() + + +def _resolve_openai_capabilities(model_id: str) -> TargetCapabilities: + if _looks_like_openai_reasoning_model(model_id): + return OPENAI_RESPONSES + return OPENAI_CHAT + + +def _looks_like_openai_reasoning_model(model_id: str) -> bool: + return ( + model_id.startswith("gpt-5") + or model_id.startswith("o1") + or model_id.startswith("o3") + or model_id.startswith("o4") + ) + + +def _unknown_capabilities() -> TargetCapabilities: + return TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT}), + default_endpoint_mode=EndpointMode.CHAT, + supported_params=frozenset({"timeout"}), + structured_output=StructuredOutputMode.PROMPTED_JSON, + batch_mode=BatchMode.FALLBACK_CONCURRENCY, + notes=("Unknown target; optional Datafast parameters are omitted by default.",), + ) + + +__all__ = [ + "ANTHROPIC_CHAT", + "HOSTED_CHAT", + "LLAMACPP_CHAT", + "OLLAMA_CHAT", + "OPENAI_CHAT", + "OPENAI_COMPATIBLE_CHAT", + "OPENAI_RESPONSES", + "OPENROUTER_CHAT", + "VLLM_CHAT", + "resolve_capabilities", +] diff --git a/datafast/llm/provider.py b/datafast/llm/provider.py index 4768b24..4ef0ef8 100644 --- a/datafast/llm/provider.py +++ b/datafast/llm/provider.py @@ -1,52 +1,1463 @@ -"""Provider exports for the pipeline-first datafast API.""" - -from datafast.llms import ( - LLMProvider, - OpenAIProvider, - AnthropicProvider, - GeminiProvider, - MistralProvider, - OpenRouterProvider, - OllamaProvider, +"""Capability-aware LLM providers for Datafast.""" + +from __future__ import annotations + +import copy +import os +import random +import time +import traceback +import warnings +from concurrent.futures import ThreadPoolExecutor +from threading import Lock +from typing import Any, TypeVar + +from loguru import logger +from pydantic import BaseModel + +import litellm +from litellm import exceptions as litellm_exceptions + +from datafast.llm.capabilities import resolve_capabilities +from datafast.llm.types import ( + BatchMode, + ContentPart, + EndpointMode, + Message, + Messages, + Modality, + NormalizedRequest, + NormalizedResponse, + RetryPolicy, + StructuredOutputMode, + TargetCapabilities, + TargetConfig, + UnsupportedParamsPolicy, +) +from datafast.tracing import ( + build_trace_metadata, + load_env_once, + maybe_configure_langfuse_tracing, ) -def openai(model_id: str = "gpt-5-mini-2025-08-07", **kwargs) -> OpenAIProvider: - """Create an OpenAI provider instance.""" +T = TypeVar("T", bound=BaseModel) + +JSON_INSTRUCTIONS = ( + "\nReturn only valid JSON. Do not include markdown fences. Use double quotes " + "for keys and string values, escape internal newlines, and avoid trailing commas." +) + + +class LLMProvider: + """One Datafast provider target resolved to LiteLLM request adapters.""" + + def __init__( + self, + provider: str, + model_id: str, + *, + litellm_provider: str, + env_key_name: str | None, + endpoint_mode: str | EndpointMode = EndpointMode.AUTO, + temperature: float | None = None, + max_completion_tokens: int | None = None, + max_tokens: int | None = None, + thinking: bool | None = None, + reasoning_effort: str | None = None, + rpm_limit: int | None = None, + timeout: float | None = None, + api_key: str | None = None, + api_base_url: str | None = None, + api_base: str | None = None, + retry_limit: int | None = None, + retry_policy: RetryPolicy | None = None, + unsupported_params: str | UnsupportedParamsPolicy = UnsupportedParamsPolicy.WARN, + provider_params: dict[str, Any] | None = None, + max_concurrent: int = 4, + capabilities: TargetCapabilities | None = None, + **extra_provider_params: Any, + ) -> None: + if max_completion_tokens is None and max_tokens is not None: + max_completion_tokens = max_tokens + if api_base_url is None: + api_base_url = api_base + + merged_provider_params = dict(provider_params or {}) + merged_provider_params.update(extra_provider_params) + + if retry_policy is None: + retry_policy = RetryPolicy( + max_retries=retry_limit if retry_limit is not None else 3 + ) + + unsupported_policy = _coerce_unsupported_policy(unsupported_params) + + self.config = TargetConfig( + provider=provider, + model_id=model_id, + litellm_provider=litellm_provider, + env_key_name=env_key_name, + endpoint_mode=_coerce_endpoint_mode(endpoint_mode), + temperature=temperature, + max_completion_tokens=max_completion_tokens, + thinking=thinking, + reasoning_effort=reasoning_effort, + rpm_limit=rpm_limit, + timeout=timeout, + api_key=api_key, + api_base_url=api_base_url, + retry_policy=retry_policy, + unsupported_params=unsupported_policy, + provider_params=merged_provider_params, + max_concurrent=max_concurrent, + ) + self.capabilities = resolve_capabilities( + provider, + model_id, + api_base_url=api_base_url, + explicit=capabilities, + ) + self.endpoint_mode = self._resolve_endpoint_mode(self.config.endpoint_mode) + + self.provider_name = provider + self.model_id = model_id + self.env_key_name = env_key_name + self.api_key = api_key or (os.getenv(env_key_name) if env_key_name else None) + self.api_base_url = api_base_url + self.temperature = temperature + self.max_completion_tokens = max_completion_tokens + self.reasoning_effort = reasoning_effort + self.rpm_limit = rpm_limit + self.timeout = timeout + self.unsupported_params = unsupported_policy.value + + self._request_timestamps: list[float] = [] + self._rate_lock = Lock() + self._sleep = time.sleep + self._configured_common_params = { + name + for name, value in { + "temperature": temperature, + "max_completion_tokens": max_completion_tokens, + "thinking": thinking, + "reasoning_effort": reasoning_effort, + "timeout": timeout, + }.items() + if value is not None + } + + load_env_once() + maybe_configure_langfuse_tracing(load_env=False) + logger.info( + "Initialized {} | Model: {} | Endpoint: {}", + self.provider_name, + self.model_id, + self.endpoint_mode.value, + ) + + def generate( + self, + prompt: str | list[str] | None = None, + messages: Messages | list[Messages] | None = None, + response_format: type[T] | None = None, + metadata: dict[str, Any] | None = None, + previous_response_id: str | None = None, + ) -> str | list[str] | T | list[T]: + """Generate a single response or ordered batch of responses.""" + requests, single_input = self._normalize_inputs( + prompt=prompt, + messages=messages, + metadata=metadata, + previous_response_id=previous_response_id, + response_format=response_format, + ) + try: + results = self._generate_requests(requests, response_format=response_format) + except ValueError: + raise + except Exception as exc: + error_trace = traceback.format_exc() + logger.error( + "Generation failed | Provider: {} | Model: {} | Error: {}", + self.provider_name, + self.model_id, + exc, + ) + raise RuntimeError( + f"Error generating response with {self.provider_name}:\n{error_trace}" + ) from exc + + if single_input: + return results[0] + return results + + def generate_batch( + self, + messages: list[Messages], + *, + response_format: type[T] | None = None, + metadata: list[dict[str, Any] | None] | dict[str, Any] | None = None, + previous_response_ids: list[str | None] | None = None, + ) -> list[str] | list[T]: + """Generate an ordered batch from pre-built message lists.""" + if not messages: + return [] + + metadata_items = _normalize_metadata(metadata, len(messages)) + previous_ids = previous_response_ids or [None] * len(messages) + if len(previous_ids) != len(messages): + raise ValueError("previous_response_ids length must match messages length") + + requests = [ + NormalizedRequest( + messages=self._prepare_messages( + item, + response_format=response_format, + ), + metadata=metadata_items[index], + previous_response_id=previous_ids[index], + ) + for index, item in enumerate(messages) + ] + return self._generate_requests(requests, response_format=response_format) + + def generate_response( + self, + prompt: str | list[str] | None = None, + messages: Messages | list[Messages] | None = None, + metadata: dict[str, Any] | None = None, + previous_response_id: str | None = None, + ) -> NormalizedResponse | list[NormalizedResponse]: + """Generate response metadata, including LiteLLM reasoning fields when present.""" + requests, single_input = self._normalize_inputs( + prompt=prompt, + messages=messages, + metadata=metadata, + previous_response_id=previous_response_id, + response_format=None, + ) + responses = self._generate_normalized_responses( + requests, + response_format=None, + ) + if single_input: + return responses[0] + return responses + + def generate_batch_response( + self, + messages: list[Messages], + *, + metadata: list[dict[str, Any] | None] | dict[str, Any] | None = None, + previous_response_ids: list[str | None] | None = None, + ) -> list[NormalizedResponse]: + """Generate ordered batch responses with metadata preserved.""" + if not messages: + return [] + + metadata_items = _normalize_metadata(metadata, len(messages)) + previous_ids = previous_response_ids or [None] * len(messages) + if len(previous_ids) != len(messages): + raise ValueError("previous_response_ids length must match messages length") + + requests = [ + NormalizedRequest( + messages=self._prepare_messages(item, response_format=None), + metadata=metadata_items[index], + previous_response_id=previous_ids[index], + ) + for index, item in enumerate(messages) + ] + return self._generate_normalized_responses(requests, response_format=None) + + def _generate_requests( + self, + requests: list[NormalizedRequest], + *, + response_format: type[T] | None, + ) -> list[str] | list[T]: + responses = self._generate_normalized_responses( + requests, + response_format=response_format, + ) + return [ + self._parse_response(response, response_format=response_format) + for response in responses + ] + + def _generate_normalized_responses( + self, + requests: list[NormalizedRequest], + *, + response_format: type[T] | None, + ) -> list[NormalizedResponse]: + if not requests: + return [] + + if len(requests) == 1: + return [self._execute_single(requests[0], response_format=response_format)] + + if ( + self.endpoint_mode == EndpointMode.CHAT + and self.capabilities.batch_mode == BatchMode.LITELLM_BATCH + ): + return self._execute_litellm_batch( + requests, + response_format=response_format, + ) + + warnings.warn( + ( + f"{self.provider_name}/{self.model_id} does not expose native " + "same-target batching for this endpoint. Falling back to bounded " + "parallel single requests." + ), + UserWarning, + stacklevel=2, + ) + with ThreadPoolExecutor( + max_workers=max(1, min(self.config.max_concurrent, len(requests))) + ) as executor: + responses = list( + executor.map( + lambda request: self._execute_single( + request, + response_format=response_format, + ), + requests, + ) + ) + return responses + + def _execute_single( + self, + request: NormalizedRequest, + *, + response_format: type[T] | None, + ) -> NormalizedResponse: + if self.endpoint_mode == EndpointMode.RESPONSES: + params = self._build_responses_params(request, response_format) + response = self._call_litellm( + litellm.responses, + params, + request_count=1, + ) + return NormalizedResponse( + text=_extract_responses_text(response), + raw=response, + reasoning_content=_extract_responses_reasoning(response), + images=_extract_responses_images(response), + audio=_extract_responses_audio(response), + output_items=_extract_responses_output_items(response), + ) + + params = self._build_chat_params(request, response_format) + response = self._call_litellm( + litellm.completion, + params, + request_count=1, + ) + return NormalizedResponse( + text=_extract_chat_text(response), + raw=response, + reasoning_content=_extract_chat_reasoning_content(response), + thinking_blocks=_extract_chat_thinking_blocks(response), + images=_extract_chat_images(response), + audio=_extract_chat_audio(response), + ) + + def _execute_litellm_batch( + self, + requests: list[NormalizedRequest], + *, + response_format: type[T] | None, + ) -> list[NormalizedResponse]: + params = self._build_chat_params( + NormalizedRequest( + messages=[], + metadata=_combine_batch_metadata(requests), + ), + response_format, + ) + params["messages"] = [request.messages for request in requests] + response = self._call_litellm( + litellm.batch_completion, + params, + request_count=len(requests), + ) + if not isinstance(response, list): + response = list(response) + + normalized: list[NormalizedResponse] = [] + for index, item in enumerate(response): + if isinstance(item, Exception): + raise RuntimeError(f"Batch item {index} failed: {item}") from item + normalized.append( + NormalizedResponse( + text=_extract_chat_text(item), + raw=item, + reasoning_content=_extract_chat_reasoning_content(item), + thinking_blocks=_extract_chat_thinking_blocks(item), + images=_extract_chat_images(item), + audio=_extract_chat_audio(item), + ) + ) + return normalized + + def _build_chat_params( + self, + request: NormalizedRequest, + response_format: type[T] | None, + ) -> dict[str, Any]: + params: dict[str, Any] = { + "model": self._get_model_string(), + "messages": request.messages, + "metadata": self._build_request_metadata(request.metadata), + } + if request.previous_response_id is not None: + self._add_supported_param( + params, + "previous_response_id", + request.previous_response_id, + endpoint=EndpointMode.CHAT, + ) + self._add_transport_params(params, endpoint=EndpointMode.CHAT) + self._add_common_generation_params(params, endpoint=EndpointMode.CHAT) + self._add_chat_structured_output(params, response_format) + params.update(self.config.provider_params) + return _without_none(params) + + def _build_responses_params( + self, + request: NormalizedRequest, + response_format: type[T] | None, + ) -> dict[str, Any]: + params: dict[str, Any] = { + "model": self._get_model_string(), + "input": request.messages, + "metadata": self._build_request_metadata(request.metadata), + } + if request.previous_response_id is not None: + self._add_supported_param( + params, + "previous_response_id", + request.previous_response_id, + endpoint=EndpointMode.RESPONSES, + ) + self._add_transport_params(params, endpoint=EndpointMode.RESPONSES) + self._add_common_generation_params(params, endpoint=EndpointMode.RESPONSES) + self._add_responses_structured_output(params, response_format) + params.update(self.config.provider_params) + return _without_none(params) + + def _add_common_generation_params( + self, + params: dict[str, Any], + *, + endpoint: EndpointMode, + ) -> None: + self._add_supported_param( + params, + "temperature", + self.config.temperature, + endpoint=endpoint, + ) + + token_param = ( + "max_output_tokens" + if endpoint == EndpointMode.RESPONSES + else "max_completion_tokens" + ) + self._add_supported_param( + params, + "max_completion_tokens", + self.config.max_completion_tokens, + endpoint=endpoint, + target_name=token_param, + ) + + if self.config.thinking is False: + return + + effort = self.config.reasoning_effort + if effort is None and self.config.thinking is True: + effort = "low" + + if endpoint == EndpointMode.RESPONSES and effort is not None: + self._add_supported_param( + params, + "reasoning_effort", + {"effort": effort}, + endpoint=endpoint, + target_name="reasoning", + ) + return + + self._add_supported_param( + params, + "reasoning_effort", + effort, + endpoint=endpoint, + ) + + def _add_chat_structured_output( + self, + params: dict[str, Any], + response_format: type[T] | None, + ) -> None: + if response_format is None: + return + + mode = self.capabilities.structured_output + if mode == StructuredOutputMode.JSON_SCHEMA: + params["response_format"] = response_format + elif mode == StructuredOutputMode.JSON_OBJECT: + params["response_format"] = {"type": "json_object"} + if self.provider_name == "ollama": + params["format"] = "json" + elif mode == StructuredOutputMode.PROMPTED_JSON: + warnings.warn( + ( + f"{self.provider_name}/{self.model_id} has no declared native " + "schema support. Using prompted JSON plus Pydantic validation." + ), + UserWarning, + stacklevel=3, + ) + else: + raise ValueError( + f"{self.provider_name}/{self.model_id} does not support structured output" + ) + + def _add_responses_structured_output( + self, + params: dict[str, Any], + response_format: type[T] | None, + ) -> None: + if response_format is None: + return + + if self.capabilities.structured_output != StructuredOutputMode.JSON_SCHEMA: + raise ValueError( + f"{self.provider_name}/{self.model_id} does not support native " + "Responses structured output" + ) + params["text_format"] = response_format + + def _add_transport_params( + self, + params: dict[str, Any], + *, + endpoint: EndpointMode, + ) -> None: + if self.config.timeout is not None: + self._add_supported_param( + params, + "timeout", + self.config.timeout, + endpoint=endpoint, + ) + if self.api_base_url is not None: + params["api_base"] = self.api_base_url + if self.api_key is not None: + params["api_key"] = self.api_key + elif self.env_key_name and not self.capabilities.no_api_key: + env_key = os.getenv(self.env_key_name) + if env_key: + params["api_key"] = env_key + else: + raise ValueError( + f"{self.env_key_name} environment variable not set. " + "Set it or provide api_key when initializing the provider." + ) + + def _add_supported_param( + self, + params: dict[str, Any], + source_name: str, + value: Any, + *, + endpoint: EndpointMode, + target_name: str | None = None, + ) -> None: + if value is None: + return + + if source_name not in self.capabilities.supported_params: + if ( + source_name in self._configured_common_params + or source_name == "previous_response_id" + or ( + source_name == "reasoning_effort" + and self.config.thinking is True + ) + ): + self._handle_unsupported_param(source_name) + return + + if source_name == "reasoning_effort" and not self.capabilities.supports_reasoning: + self._handle_unsupported_param(source_name) + return + + if endpoint == EndpointMode.RESPONSES and not self.capabilities.supports_endpoint( + EndpointMode.RESPONSES + ): + self._handle_unsupported_param(source_name) + return + + params[target_name or source_name] = value + + def _handle_unsupported_param(self, name: str) -> None: + message = ( + f"Parameter '{name}' is not supported by resolved target " + f"{self.provider_name}/{self.model_id} and will be omitted." + ) + if self.config.unsupported_params == UnsupportedParamsPolicy.FAIL: + raise ValueError(message) + if self.config.unsupported_params == UnsupportedParamsPolicy.WARN: + warnings.warn(message, UserWarning, stacklevel=3) + + def _normalize_inputs( + self, + *, + prompt: str | list[str] | None, + messages: Messages | list[Messages] | None, + metadata: dict[str, Any] | None, + previous_response_id: str | None, + response_format: type[T] | None, + ) -> tuple[list[NormalizedRequest], bool]: + if prompt is None and messages is None: + raise ValueError("Either prompt or messages must be provided") + if prompt is not None and messages is not None: + raise ValueError("Provide either prompt or messages, not both") + + single_input = False + batch_messages: list[Messages] + + if prompt is not None: + if isinstance(prompt, str): + batch_messages = [[{"role": "user", "content": prompt}]] + single_input = True + elif isinstance(prompt, list) and all(isinstance(item, str) for item in prompt): + if not prompt: + raise ValueError("prompt list cannot be empty") + batch_messages = [ + [{"role": "user", "content": item}] + for item in prompt + ] + else: + raise ValueError("prompt must be a string or list of strings") + elif _is_single_messages(messages): + batch_messages = [messages] # type: ignore[list-item] + single_input = True + elif _is_batch_messages(messages): + batch_messages = messages # type: ignore[assignment] + if not batch_messages: + raise ValueError("messages cannot be empty") + else: + raise ValueError("Invalid messages format") + + return ( + [ + NormalizedRequest( + messages=self._prepare_messages( + item, + response_format=response_format, + ), + metadata=metadata, + previous_response_id=previous_response_id, + ) + for item in batch_messages + ], + single_input, + ) + + def _prepare_messages( + self, + messages: Messages, + *, + response_format: type[T] | None, + ) -> Messages: + if not messages: + raise ValueError("messages cannot be empty") + + normalized = [_normalize_message(message) for message in copy.deepcopy(messages)] + self._validate_modalities(normalized) + + if response_format is not None and self.capabilities.structured_output in { + StructuredOutputMode.JSON_OBJECT, + StructuredOutputMode.PROMPTED_JSON, + }: + _append_json_instructions(normalized) + + return normalized + + def _validate_modalities(self, messages: Messages) -> None: + supported = self.capabilities.modalities + for message in messages: + content = message.get("content") + if not isinstance(content, list): + continue + for part in content: + modality = _modality_for_part(part) + if modality not in supported: + raise ValueError( + f"Modality '{modality.value}' is not supported by " + f"{self.provider_name}/{self.model_id}" + ) + + def _resolve_endpoint_mode(self, endpoint_mode: EndpointMode) -> EndpointMode: + if endpoint_mode == EndpointMode.AUTO: + return self.capabilities.default_endpoint_mode + if not self.capabilities.supports_endpoint(endpoint_mode): + raise ValueError( + f"{self.provider_name}/{self.model_id} does not support " + f"endpoint_mode='{endpoint_mode.value}'" + ) + return endpoint_mode + + def _call_litellm(self, func, params: dict[str, Any], *, request_count: int) -> Any: + try: + return self._call_with_retries( + lambda: func(**params), + request_count=request_count, + ) + except Exception as exc: + if not self._should_retry_with_drop_params(exc, params): + raise + + retry_params = dict(params) + retry_params["drop_params"] = True + if self.config.unsupported_params == UnsupportedParamsPolicy.WARN: + warnings.warn( + ( + "LiteLLM rejected one or more request parameters as " + "unsupported. Retrying once with drop_params=True because " + f"unsupported_params='{self.config.unsupported_params.value}'." + ), + UserWarning, + stacklevel=3, + ) + return self._call_with_retries( + lambda: func(**retry_params), + request_count=request_count, + ) + + def _should_retry_with_drop_params( + self, + exc: Exception, + params: dict[str, Any], + ) -> bool: + if self.config.unsupported_params == UnsupportedParamsPolicy.FAIL: + return False + if params.get("drop_params") is True: + return False + return _is_unsupported_params_error(exc) + + def _call_with_retries(self, func, *, request_count: int) -> Any: + retry_policy = self.config.retry_policy + attempts = max(1, retry_policy.max_retries) + + for attempt in range(attempts): + self._respect_rate_limit(request_count) + try: + response = func() + self._record_requests(request_count) + return response + except Exception as exc: + if attempt >= attempts - 1 or not _is_retryable_error(exc): + raise + delay = min( + retry_policy.max_delay, + retry_policy.base_delay * (2 ** attempt), + ) + if retry_policy.jitter > 0: + delay += random.uniform(0, delay * retry_policy.jitter) + logger.warning( + "Retryable LLM error | Provider: {} | Model: {} | " + "Attempt: {}/{} | Waiting: {:.2f}s | Error: {}", + self.provider_name, + self.model_id, + attempt + 1, + attempts, + delay, + exc, + ) + self._sleep(delay) + + raise RuntimeError("unreachable retry state") + + def _respect_rate_limit(self, request_count: int = 1) -> None: + if self.config.rpm_limit is None: + return + + with self._rate_lock: + now = time.monotonic() + self._request_timestamps = [ + timestamp + for timestamp in self._request_timestamps + if now - timestamp < 60 + ] + + while len(self._request_timestamps) + request_count > self.config.rpm_limit: + earliest = self._request_timestamps[0] + sleep_time = max(0.0, 60 - (now - earliest)) + if sleep_time > 0: + logger.warning( + "Rate limit reached | Provider: {} | Model: {} | " + "Waiting {:.2f}s", + self.provider_name, + self.model_id, + sleep_time, + ) + self._sleep(sleep_time) + now = time.monotonic() + self._request_timestamps = [ + timestamp + for timestamp in self._request_timestamps + if now - timestamp < 60 + ] + + def _record_requests(self, request_count: int = 1) -> None: + if self.config.rpm_limit is None: + return + with self._rate_lock: + now = time.monotonic() + self._request_timestamps.extend([now] * request_count) + + def _parse_response( + self, + response: NormalizedResponse, + *, + response_format: type[T] | None, + ) -> str | T: + if response_format is None: + return response.text.strip() if response.text else response.text + + parsed = getattr(response.raw, "output_parsed", None) + if parsed is not None: + return parsed + + content = self._strip_code_fences(response.text) + try: + return response_format.model_validate_json(content) + except Exception as validation_error: + content_preview = ( + content[:200] + "..." if len(content) > 200 else content + ) + raise ValueError( + f"Failed to parse JSON response into {response_format.__name__}.\n" + f"Validation error: {validation_error}\n" + f"Content received (first 200 chars):\n{content_preview}" + ) from validation_error + + def _build_request_metadata( + self, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + return build_trace_metadata( + model=self, + component="provider.generate", + trace_name=f"datafast.{self.provider_name}", + metadata=metadata, + ) + + def _get_model_string(self) -> str: + prefix = f"{self.config.litellm_provider}/" + if self.model_id.startswith(prefix): + return self.model_id + return f"{prefix}{self.model_id}" + + @staticmethod + def _strip_code_fences(content: str) -> str: + if not content: + return content + + content = content.strip() + if content.startswith("```"): + first_newline = content.find("\n") + content = content[first_newline + 1 :] if first_newline != -1 else content[3:] + if content.endswith("```"): + content = content[:-3] + return content.strip() + + +class OpenAIProvider(LLMProvider): + def __init__(self, model_id: str = "gpt-5.5", **kwargs: Any) -> None: + super().__init__( + "openai", + model_id, + litellm_provider="openai", + env_key_name="OPENAI_API_KEY", + **kwargs, + ) + + +class AnthropicProvider(LLMProvider): + def __init__(self, model_id: str = "claude-haiku-4-5", **kwargs: Any) -> None: + super().__init__( + "anthropic", + model_id, + litellm_provider="anthropic", + env_key_name="ANTHROPIC_API_KEY", + **kwargs, + ) + + +class GeminiProvider(LLMProvider): + def __init__(self, model_id: str = "gemini-3.1-flash-lite", **kwargs: Any) -> None: + super().__init__( + "gemini", + model_id, + litellm_provider="gemini", + env_key_name="GEMINI_API_KEY", + **kwargs, + ) + + +class MistralProvider(LLMProvider): + def __init__(self, model_id: str = "mistral-small-2603", **kwargs: Any) -> None: + super().__init__( + "mistral", + model_id, + litellm_provider="mistral", + env_key_name="MISTRAL_API_KEY", + **kwargs, + ) + + +class OpenRouterProvider(LLMProvider): + def __init__(self, model_id: str = "openai/gpt-5.4-mini", **kwargs: Any) -> None: + super().__init__( + "openrouter", + model_id, + litellm_provider="openrouter", + env_key_name="OPENROUTER_API_KEY", + **kwargs, + ) + + +class OllamaProvider(LLMProvider): + def __init__(self, model_id: str = "gemma3:4b", **kwargs: Any) -> None: + super().__init__( + "ollama", + model_id, + litellm_provider="ollama_chat", + env_key_name=None, + **kwargs, + ) + + +class OpenAICompatibleProvider(LLMProvider): + def __init__( + self, + model_id: str, + *, + provider: str = "openai_compatible", + litellm_provider: str = "openai", + env_key_name: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__( + provider, + model_id, + litellm_provider=litellm_provider, + env_key_name=env_key_name, + **kwargs, + ) + + +def openai(model_id: str = "gpt-5.5", **kwargs: Any) -> OpenAIProvider: return OpenAIProvider(model_id=model_id, **kwargs) -def anthropic( - model_id: str = "claude-haiku-4-5-20251001", - **kwargs, -) -> AnthropicProvider: - """Create an Anthropic provider instance.""" +def anthropic(model_id: str = "claude-haiku-4-5", **kwargs: Any) -> AnthropicProvider: return AnthropicProvider(model_id=model_id, **kwargs) -def gemini(model_id: str = "gemini-2.0-flash", **kwargs) -> GeminiProvider: - """Create a Gemini provider instance.""" +def gemini(model_id: str = "gemini-3.1-flash-lite", **kwargs: Any) -> GeminiProvider: return GeminiProvider(model_id=model_id, **kwargs) -def mistral(model_id: str = "mistral-small-latest", **kwargs) -> MistralProvider: - """Create a Mistral provider instance.""" +def mistral(model_id: str = "mistral-small-2603", **kwargs: Any) -> MistralProvider: return MistralProvider(model_id=model_id, **kwargs) def openrouter( - model_id: str = "openai/gpt-5-mini", - **kwargs, + model_id: str = "openai/gpt-5.4-mini", + **kwargs: Any, ) -> OpenRouterProvider: - """Create an OpenRouter provider instance.""" return OpenRouterProvider(model_id=model_id, **kwargs) -def ollama(model_id: str = "gemma3:4b", **kwargs) -> OllamaProvider: - """Create an Ollama provider instance.""" +def ollama(model_id: str = "gemma3:4b", **kwargs: Any) -> OllamaProvider: return OllamaProvider(model_id=model_id, **kwargs) +def openai_compatible( + model_id: str, + *, + api_base_url: str | None = None, + backend: str = "openai_compatible", + **kwargs: Any, +) -> OpenAICompatibleProvider: + provider = _normalize_openai_compatible_backend(backend) + return OpenAICompatibleProvider( + model_id=model_id, + provider=provider, + api_base_url=api_base_url, + **kwargs, + ) + + +def _normalize_openai_compatible_backend(value: str) -> str: + normalized = value.strip().lower().replace("-", "_") + aliases = { + "openai-compatible": "openai_compatible", + "openai_compatible": "openai_compatible", + "llama.cpp": "llamacpp", + "llama_cpp": "llamacpp", + "llamacpp": "llamacpp", + "vllm": "vllm", + } + try: + return aliases[normalized] + except KeyError as exc: + valid = ", ".join(sorted(set(aliases.values()))) + raise ValueError( + f"Unsupported OpenAI-compatible backend '{value}'. Choose: {valid}" + ) from exc + + +def _coerce_endpoint_mode(value: str | EndpointMode) -> EndpointMode: + if isinstance(value, EndpointMode): + return value + try: + return EndpointMode(value) + except ValueError as exc: + raise ValueError("endpoint_mode must be 'auto', 'chat', or 'responses'") from exc + + +def _coerce_unsupported_policy( + value: str | UnsupportedParamsPolicy, +) -> UnsupportedParamsPolicy: + if isinstance(value, UnsupportedParamsPolicy): + return value + try: + return UnsupportedParamsPolicy(value) + except ValueError as exc: + raise ValueError("unsupported_params must be 'fail', 'warn', or 'quiet'") from exc + + +def _normalize_metadata( + metadata: list[dict[str, Any] | None] | dict[str, Any] | None, + expected_length: int, +) -> list[dict[str, Any] | None]: + if isinstance(metadata, list): + if len(metadata) != expected_length: + raise ValueError("metadata length must match messages length") + return metadata + return [metadata] * expected_length + + +def _combine_batch_metadata(requests: list[NormalizedRequest]) -> dict[str, Any]: + metadata_items = [request.metadata for request in requests] + return { + "datafast_batch_size": len(requests), + "datafast_batch_metadata": metadata_items, + } + + +def _is_single_messages(value: Any) -> bool: + return isinstance(value, list) and bool(value) and isinstance(value[0], dict) + + +def _is_batch_messages(value: Any) -> bool: + return isinstance(value, list) and bool(value) and isinstance(value[0], list) + + +def _normalize_message(message: Message) -> Message: + if not isinstance(message, dict): + raise ValueError("Each message must be a dictionary") + + normalized = dict(message) + content = normalized.get("content") + if isinstance(content, list): + normalized["content"] = [_normalize_content_part(part) for part in content] + elif content is not None and not isinstance(content, str): + raise ValueError("message content must be a string, list of parts, or None") + return normalized + + +def _normalize_content_part(part: Any) -> dict[str, Any]: + part = _content_part_to_dict(part) + part_type = part.get("type") + + normalizers = { + "text": _normalize_text_part, + "image": _normalize_image_part, + "audio": _normalize_audio_part, + "video": _normalize_video_part, + "file": _normalize_file_part, + "document": _normalize_file_part, + } + if part_type in {"image_url", "input_audio", "video_url"}: + return _without_none(part) + if part_type in normalizers: + return normalizers[part_type](part) + return part + + +def _content_part_to_dict(part: Any) -> dict[str, Any]: + if isinstance(part, ContentPart): + part = { + "type": part.type, + "text": part.text, + "url": part.url, + "data": part.data, + "media_type": part.media_type, + "media_id": part.media_id, + **part.provider_options, + } + + if not isinstance(part, dict): + raise ValueError("content parts must be dictionaries or ContentPart objects") + return part + + +def _normalize_text_part(part: dict[str, Any]) -> dict[str, Any]: + return _without_none({"type": "text", "text": part.get("text")}) + + +def _normalize_image_part(part: dict[str, Any]) -> dict[str, Any]: + image_url: dict[str, Any] = {"url": part.get("url") or part.get("data")} + if part.get("format") or part.get("media_type"): + image_url["format"] = part.get("format") or part.get("media_type") + if part.get("detail"): + image_url["detail"] = part["detail"] + normalized = {"type": "image_url", "image_url": _without_none(image_url)} + if part.get("media_id"): + normalized["uuid"] = part["media_id"] + return normalized + + +def _normalize_audio_part(part: dict[str, Any]) -> dict[str, Any]: + return { + "type": "input_audio", + "input_audio": _without_none({ + "data": part.get("data"), + "format": part.get("format") or part.get("media_type") or "wav", + }), + } + + +def _normalize_video_part(part: dict[str, Any]) -> dict[str, Any]: + normalized = {"type": "video_url", "video_url": {"url": part.get("url")}} + if part.get("media_id"): + normalized["uuid"] = part["media_id"] + return normalized + + +def _normalize_file_part(part: dict[str, Any]) -> dict[str, Any]: + if isinstance(part.get("file"), dict): + file_payload = part["file"] + elif part.get("data"): + file_payload = {"file_data": part.get("data")} + else: + file_payload = {"file_id": part.get("url")} + return {"type": "file", "file": _without_none(file_payload)} + + +def _modality_for_part(part: dict[str, Any]) -> Modality: + part_type = part.get("type") + if part_type == "text": + return Modality.TEXT + if part_type in {"image", "image_url"}: + return Modality.IMAGE + if part_type in {"audio", "input_audio"}: + return Modality.AUDIO + if part_type in {"video", "video_url"}: + return Modality.VIDEO + if part_type == "file": + return Modality.FILE + if part_type == "document": + return Modality.DOCUMENT + return Modality.TEXT + + +def _append_json_instructions(messages: Messages) -> None: + for message in reversed(messages): + if message.get("role") != "user": + continue + content = message.get("content") + if isinstance(content, str): + message["content"] = content + JSON_INSTRUCTIONS + return + if isinstance(content, list): + for part in reversed(content): + if part.get("type") == "text" and isinstance(part.get("text"), str): + part["text"] = part["text"] + JSON_INSTRUCTIONS + return + messages.append({"role": "user", "content": JSON_INSTRUCTIONS.strip()}) + + +def _extract_chat_text(response: Any) -> str: + choice = _get_first_choice(response) + if choice is None: + raise RuntimeError( + f"Unexpected chat response from LiteLLM: {type(response).__name__}" + ) + + message = _get_attr_or_key(choice, "message") + if message is None: + text = _get_attr_or_key(choice, "text") + return "" if text is None else str(text) + + content = _get_attr_or_key(message, "content") + return _content_to_text(content) + + +def _extract_chat_reasoning_content(response: Any) -> str | None: + message = _extract_chat_message(response) + if message is None: + return None + + reasoning_content = _get_attr_or_key(message, "reasoning_content") + if reasoning_content is None: + reasoning_content = _get_attr_or_key(message, "reasoning") + if reasoning_content is None: + return None + if isinstance(reasoning_content, list): + return _content_to_text(reasoning_content).strip() or None + return str(reasoning_content).strip() or None + + +def _extract_chat_thinking_blocks(response: Any) -> list[dict[str, Any]]: + message = _extract_chat_message(response) + if message is None: + return [] + + blocks = _get_attr_or_key(message, "thinking_blocks") + if not blocks: + return [] + if not isinstance(blocks, list): + blocks = [blocks] + return [_normalize_mapping_block(block) for block in blocks] + + +def _extract_chat_images(response: Any) -> list[dict[str, Any]]: + message = _extract_chat_message(response) + if message is None: + return [] + + images = _get_attr_or_key(message, "images") + collected = list(_normalize_optional_list(images)) + + content = _get_attr_or_key(message, "content") + for part in _normalize_optional_list(content): + part_type = _get_attr_or_key(part, "type") + if part_type in {"image", "image_url", "output_image"}: + collected.append(part) + + return [_normalize_mapping_block(image) for image in collected] + + +def _extract_chat_audio(response: Any) -> dict[str, Any] | None: + message = _extract_chat_message(response) + if message is None: + return None + + audio = _get_attr_or_key(message, "audio") + if audio: + return _normalize_mapping_block(audio) + + content = _get_attr_or_key(message, "content") + for part in _normalize_optional_list(content): + part_type = _get_attr_or_key(part, "type") + if part_type in {"audio", "output_audio"}: + return _normalize_mapping_block(part) + return None + + +def _extract_chat_message(response: Any) -> Any: + choice = _get_first_choice(response) + if choice is None: + return None + return _get_attr_or_key(choice, "message") + + +def _extract_responses_text(response: Any) -> str: + output_text = _get_attr_or_key(response, "output_text") + if output_text: + return str(output_text) + + output = _normalize_optional_list(_get_attr_or_key(response, "output")) + texts: list[str] = [] + for item in output: + content = _get_attr_or_key(item, "content") or [] + if isinstance(content, str): + texts.append(content) + continue + for part in _normalize_optional_list(content): + part_type = _get_attr_or_key(part, "type") + if part_type in {"output_text", "text"}: + text = _get_attr_or_key(part, "text") + if text is not None: + texts.append(str(text)) + if texts: + return "".join(texts) + if output: + return "" + raise RuntimeError( + f"Unexpected Responses API response from LiteLLM: {type(response).__name__}" + ) + + +def _extract_responses_reasoning(response: Any) -> str | None: + reasoning_content = _get_attr_or_key(response, "reasoning_content") + if reasoning_content: + return str(reasoning_content).strip() or None + + output = _normalize_optional_list(_get_attr_or_key(response, "output")) + texts: list[str] = [] + for item in output: + item_type = _get_attr_or_key(item, "type") + if item_type != "reasoning": + continue + + for field_name in ("text", "content"): + value = _get_attr_or_key(item, field_name) + if value: + texts.append(_content_to_text(value)) + + summary = _get_attr_or_key(item, "summary") or [] + if isinstance(summary, str): + texts.append(summary) + continue + for part in _normalize_optional_list(summary): + text = _get_attr_or_key(part, "text") or _get_attr_or_key(part, "content") + if text: + texts.append(_content_to_text(text)) + + joined = "\n".join(text.strip() for text in texts if text and text.strip()) + return joined or None + + +def _extract_responses_output_items(response: Any) -> list[dict[str, Any]]: + output = _get_attr_or_key(response, "output") or [] + return [_normalize_mapping_block(item) for item in _normalize_optional_list(output)] + + +def _extract_responses_images(response: Any) -> list[dict[str, Any]]: + images: list[Any] = [] + for item in _normalize_optional_list(_get_attr_or_key(response, "output")): + item_type = _get_attr_or_key(item, "type") + if item_type in {"image", "output_image", "image_generation_call"}: + images.append(item) + for part in _normalize_optional_list(_get_attr_or_key(item, "content")): + part_type = _get_attr_or_key(part, "type") + if part_type in {"image", "image_url", "output_image"}: + images.append(part) + return [_normalize_mapping_block(image) for image in images] + + +def _extract_responses_audio(response: Any) -> dict[str, Any] | None: + for item in _normalize_optional_list(_get_attr_or_key(response, "output")): + item_type = _get_attr_or_key(item, "type") + if item_type in {"audio", "output_audio"}: + return _normalize_mapping_block(item) + for part in _normalize_optional_list(_get_attr_or_key(item, "content")): + part_type = _get_attr_or_key(part, "type") + if part_type in {"audio", "output_audio"}: + return _normalize_mapping_block(part) + return None + + +def _get_first_choice(response: Any) -> Any: + choices = _get_attr_or_key(response, "choices") + if not choices: + return None + return choices[0] + + +def _content_to_text(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + texts = [] + for part in content: + text = _get_attr_or_key(part, "text") + if text is not None: + texts.append(str(text)) + return "".join(texts) + return str(content) + + +def _get_attr_or_key(value: Any, name: str) -> Any: + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _normalize_optional_list(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, str): + return [] + if isinstance(value, list): + return value + return [value] + + +def _normalize_mapping_block(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return dict(value) + + if hasattr(value, "model_dump"): + dumped = value.model_dump() + if isinstance(dumped, dict): + return dumped + + if hasattr(value, "dict"): + dumped = value.dict() + if isinstance(dumped, dict): + return dumped + + result: dict[str, Any] = {} + for name in ("type", "text", "thinking", "content", "signature"): + attr = getattr(value, name, None) + if attr is not None: + result[name] = attr + if result: + return result + return {"content": str(value)} + + +def _without_none(values: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in values.items() if value is not None} + + +def _is_retryable_error(exc: Exception) -> bool: + retryable_types = ( + litellm_exceptions.RateLimitError, + litellm_exceptions.APIConnectionError, + litellm_exceptions.Timeout, + litellm_exceptions.InternalServerError, + litellm_exceptions.ServiceUnavailableError, + ) + return isinstance(exc, retryable_types) + + +def _is_unsupported_params_error(exc: Exception) -> bool: + unsupported_type = getattr(litellm_exceptions, "UnsupportedParamsError", None) + if unsupported_type is not None and isinstance(exc, unsupported_type): + return True + return exc.__class__.__name__ == "UnsupportedParamsError" + + __all__ = [ "LLMProvider", "OpenAIProvider", @@ -55,10 +1466,12 @@ def ollama(model_id: str = "gemma3:4b", **kwargs) -> OllamaProvider: "MistralProvider", "OpenRouterProvider", "OllamaProvider", + "OpenAICompatibleProvider", "openai", "anthropic", "gemini", "mistral", "openrouter", "ollama", + "openai_compatible", ] diff --git a/datafast/llm/types.py b/datafast/llm/types.py new file mode 100644 index 0000000..c8f260b --- /dev/null +++ b/datafast/llm/types.py @@ -0,0 +1,151 @@ +"""Shared types for Datafast LLM provider targets.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Literal + + +Message = dict[str, Any] +Messages = list[Message] + +ContentPartType = Literal["text", "image", "audio", "video", "file", "document"] + + +class EndpointMode(str, Enum): + AUTO = "auto" + CHAT = "chat" + RESPONSES = "responses" + + +class UnsupportedParamsPolicy(str, Enum): + FAIL = "fail" + WARN = "warn" + QUIET = "quiet" + + +class StructuredOutputMode(str, Enum): + NONE = "none" + PROMPTED_JSON = "prompted_json" + JSON_OBJECT = "json_object" + JSON_SCHEMA = "json_schema" + + +class BatchMode(str, Enum): + NONE = "none" + LITELLM_BATCH = "litellm_batch" + FALLBACK_CONCURRENCY = "fallback_concurrency" + + +class CacheMode(str, Enum): + NONE = "none" + PROVIDER_PROMPT = "provider_prompt" + ROUTER = "router" + LOCAL_KV = "local_kv" + CLIENT_RESULT = "client_result" + + +class Modality(str, Enum): + TEXT = "text" + IMAGE = "image" + AUDIO = "audio" + VIDEO = "video" + FILE = "file" + DOCUMENT = "document" + + +@dataclass(frozen=True) +class RetryPolicy: + max_retries: int = 3 + base_delay: float = 1.0 + max_delay: float = 30.0 + jitter: float = 0.25 + + +@dataclass(frozen=True) +class TargetCapabilities: + endpoint_modes: frozenset[EndpointMode] + default_endpoint_mode: EndpointMode + supported_params: frozenset[str] = frozenset() + modalities: frozenset[Modality] = frozenset({Modality.TEXT}) + structured_output: StructuredOutputMode = StructuredOutputMode.PROMPTED_JSON + batch_mode: BatchMode = BatchMode.FALLBACK_CONCURRENCY + cache_mode: CacheMode = CacheMode.NONE + supports_reasoning: bool = False + supports_thinking: bool = False + no_api_key: bool = False + requires_chat_template: bool = False + notes: tuple[str, ...] = () + + def supports_endpoint(self, endpoint_mode: EndpointMode) -> bool: + return endpoint_mode in self.endpoint_modes + + +@dataclass(frozen=True) +class TargetConfig: + provider: str + model_id: str + litellm_provider: str + env_key_name: str | None + endpoint_mode: EndpointMode = EndpointMode.AUTO + temperature: float | None = None + max_completion_tokens: int | None = None + thinking: bool | None = None + reasoning_effort: str | None = None + rpm_limit: int | None = None + timeout: float | None = None + api_key: str | None = None + api_base_url: str | None = None + retry_policy: RetryPolicy = field(default_factory=RetryPolicy) + unsupported_params: UnsupportedParamsPolicy = UnsupportedParamsPolicy.WARN + provider_params: dict[str, Any] = field(default_factory=dict) + max_concurrent: int = 4 + + +@dataclass(frozen=True) +class NormalizedRequest: + messages: Messages + metadata: dict[str, Any] | None = None + previous_response_id: str | None = None + + +@dataclass(frozen=True) +class NormalizedResponse: + text: str + raw: Any + reasoning_content: str | None = None + thinking_blocks: list[dict[str, Any]] = field(default_factory=list) + images: list[dict[str, Any]] = field(default_factory=list) + audio: dict[str, Any] | None = None + output_items: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass(frozen=True) +class ContentPart: + type: ContentPartType + text: str | None = None + url: str | None = None + data: str | None = None + media_type: str | None = None + media_id: str | None = None + provider_options: dict[str, Any] = field(default_factory=dict) + + +__all__ = [ + "BatchMode", + "CacheMode", + "ContentPart", + "ContentPartType", + "EndpointMode", + "Message", + "Messages", + "Modality", + "NormalizedRequest", + "NormalizedResponse", + "RetryPolicy", + "StructuredOutputMode", + "TargetCapabilities", + "TargetConfig", + "UnsupportedParamsPolicy", +] diff --git a/datafast/llms.py b/datafast/llms.py index 754e8b8..3478e30 100644 --- a/datafast/llms.py +++ b/datafast/llms.py @@ -1,897 +1,28 @@ -"""LLM providers for datafast using LiteLLM. +"""Compatibility exports for Datafast LLM providers. -This module provides classes for different LLM providers (OpenAI, Anthropic, Gemini, Mistral) -with a unified interface using LiteLLM under the hood. +The implementation lives in :mod:`datafast.llm.provider`. """ -from typing import Any, Type, TypeVar -from abc import ABC, abstractmethod -import os -import time -import traceback -import warnings -from loguru import logger - -# Pydantic -from pydantic import BaseModel - -# LiteLLM -import litellm -from litellm.exceptions import RateLimitError - -# Internal imports -from .llm_utils import get_messages -from .tracing import ( - build_trace_metadata, - load_env_once, - maybe_configure_langfuse_tracing, +from datafast.llm.provider import ( + LLMProvider, + AnthropicProvider, + GeminiProvider, + MistralProvider, + OllamaProvider, + OpenAICompatibleProvider, + OpenAIProvider, + OpenRouterProvider, + anthropic, + gemini, + mistral, + ollama, + openai, + openai_compatible, + openrouter, ) +from datafast.tracing import load_env_once, maybe_configure_langfuse_tracing -# Type aliases for Python 3.10+ -Message = dict[str, str] -Messages = list[Message] -T = TypeVar('T', bound=BaseModel) - - -class LLMProvider(ABC): - """Abstract base class for LLM providers.""" - - def __init__( - self, - model_id: str, - api_key: str | None = None, - temperature: float | None = None, - max_completion_tokens: int | None = None, - top_p: float | None = None, - frequency_penalty: float | None = None, - rpm_limit: int | None = None, - timeout: int | None = None, - ): - """Initialize the LLM provider with common parameters. - - Args: - model_id: The model identifier - api_key: API key (if None, will get from environment) - temperature: The sampling temperature to be used, between 0 and 2. Higher values like 0.8 produce more random outputs, while lower values like 0.2 make outputs more focused and deterministic - max_completion_tokens: An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens. - top_p: Nucleus sampling parameter (0.0 to 1.0) - frequency_penalty: Penalty for token frequency (-2.0 to 2.0) - """ - self.model_id = model_id - load_env_once() - maybe_configure_langfuse_tracing(load_env=False) - self.api_key = api_key or self._get_api_key() - - # Set generation parameters - self.temperature = temperature - self.max_completion_tokens = max_completion_tokens - self.top_p = top_p - self.frequency_penalty = frequency_penalty - - # Rate limiting - self.rpm_limit = rpm_limit - self._request_timestamps: list[float] = [] - - # timeout - self.timeout = timeout - - # Configure environment with API key if needed - self._configure_env() - # Log successful initialization - logger.info(f"Initialized {self.provider_name} | Model: {self.model_id}") - - def _build_request_metadata( - self, - metadata: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Build default tracing metadata for provider-level calls.""" - return build_trace_metadata( - model=self, - component="provider.generate", - trace_name=f"datafast.{self.provider_name}", - metadata=metadata, - ) - - @property - @abstractmethod - def provider_name(self) -> str: - """Return the provider name used by LiteLLM.""" - pass - - @property - @abstractmethod - def env_key_name(self) -> str: - """Return the environment variable name for API key.""" - pass - - def _get_api_key(self) -> str: - """Get API key from environment variables.""" - api_key = os.getenv(self.env_key_name) - if not api_key: - logger.error( - f"Missing API key | Set {self.env_key_name} environment variable" - ) - raise ValueError( - f"{self.env_key_name} environment variable not set. " - f"Please set it or provide an API key when initializing the provider." - ) - return api_key - - def _configure_env(self) -> None: - """Configure environment variables for API key.""" - if self.api_key: - os.environ[self.env_key_name] = self.api_key - - def _get_model_string(self) -> str: - """Get the full model string for LiteLLM.""" - return f"{self.provider_name}/{self.model_id}" - - def _respect_rate_limit(self) -> None: - """Block execution to ensure we do not exceed the rpm_limit.""" - if self.rpm_limit is None: - return - current = time.monotonic() - # Keep only timestamps within the last minute - self._request_timestamps = [ - ts for ts in self._request_timestamps if current - ts < 60] - - # Be more conservative - wait if we're at 90% of the limit - conservative_limit = max(1, int(self.rpm_limit * 0.9)) - - if len(self._request_timestamps) < conservative_limit: - return - - # Need to wait until the earliest request is outside the 60-second window - earliest = self._request_timestamps[0] - # Add a 2s margin to avoid accidental rate limit exceedance - sleep_time = 62 - (current - earliest) - if sleep_time > 0: - logger.warning( - f"Rate limit approaching | Requests: {len(self._request_timestamps)}/{self.rpm_limit} | " - f"Waiting {sleep_time:.1f}s" - ) - time.sleep(sleep_time) - # Clean up old timestamps after waiting - current = time.monotonic() - self._request_timestamps = [ - ts for ts in self._request_timestamps if current - ts < 60] - - @staticmethod - def _strip_code_fences(content: str) -> str: - """Strip markdown code fences from content if present. - - Args: - content: The content string that may contain code fences - - Returns: - Content with code fences removed - """ - if not content: - return content - - content = content.strip() - - # Check for code fences with optional language identifier - if content.startswith('```'): - # Find the end of the first line (language identifier) - first_newline = content.find('\n') - if first_newline != -1: - content = content[first_newline + 1:] - else: - # No newline after opening fence, remove just the fence - content = content[3:] - - # Remove closing fence - if content.endswith('```'): - content = content[:-3] - - return content.strip() - - def generate( - self, - prompt: str | list[str] | None = None, - messages: list[Messages] | Messages | None = None, - response_format: Type[T] | None = None, - metadata: dict[str, Any] | None = None, - ) -> str | list[str] | T | list[T]: - """ - Generate responses from the LLM using single or batch inference. - - Args: - prompt: Single text prompt (str) or list of text prompts for batch processing - messages: Single message list or list of message lists for batch processing - response_format: Optional Pydantic model class for structured output - metadata: Optional LiteLLM metadata for tracing / observability - - Returns: - Single string/model or list of strings/models depending on input type. - - Raises: - ValueError: If neither prompt nor messages is provided, or if both are provided. - RuntimeError: If there's an error during generation. - """ - # Validate inputs - if prompt is None and messages is None: - raise ValueError("Either prompts or messages must be provided") - if prompt is not None and messages is not None: - raise ValueError("Provide either prompts or messages, not both") - - # Determine if this is a single input or batch input - single_input = False - batch_prompts = None - batch_messages = None - - if prompt is not None: - if isinstance(prompt, str): - # Single prompt - convert to batch - batch_prompts = [prompt] - single_input = True - elif isinstance(prompt, list): - # Already a list of prompts - batch_prompts = prompt - single_input = False - else: - raise ValueError("prompt must be a string or list of strings") - - if messages is not None: - if isinstance(messages, list) and len(messages) > 0: - # Check if it's a single message list or batch - if isinstance(messages[0], dict): - # Single message list - convert to batch - batch_messages = [messages] - single_input = True - elif isinstance(messages[0], list): - # Already a batch of message lists - batch_messages = messages - single_input = False - else: - raise ValueError("Invalid messages format") - else: - raise ValueError("messages cannot be empty") - - try: - # Append JSON formatting instructions if response_format is provided - json_instructions = ( - "\nReturn only valid JSON. To do so, don't include ```json ``` markdown " - "or code fences around the JSON. Use double quotes for all keys and values. " - "Escape internal quotes and newlines (use \\n). Do not include trailing commas." - ) - - # Convert batch prompts to messages if needed - batch_to_send = [] - if batch_prompts is not None: - for one_prompt in batch_prompts: - # Append JSON instructions to prompt if response_format is provided - modified_prompt = one_prompt + json_instructions if response_format is not None else one_prompt - batch_to_send.append(get_messages(modified_prompt)) - else: - batch_to_send = batch_messages - # Append JSON instructions to the last user message if response_format is provided - if response_format is not None: - for message_list in batch_to_send: - for msg in reversed(message_list): - if msg.get("role") == "user": - msg["content"] += json_instructions - break - - # Enforce rate limit per batch - self._respect_rate_limit() - - # Prepare completion parameters for batch - completion_params = { - "model": self._get_model_string(), - "messages": batch_to_send, - "temperature": self.temperature, - "max_tokens": self.max_completion_tokens, - "top_p": self.top_p, - "frequency_penalty": self.frequency_penalty, - "timeout": self.timeout, - "metadata": self._build_request_metadata(metadata), - } - if response_format is not None: - completion_params["response_format"] = response_format - - # Call LiteLLM completion with retry on rate limit. - # OpenRouter accepts single message requests via completion(), but - # rejects the same payload when wrapped in batch_completion(). - max_retries = 3 - retry_delay = 5 # Start with 5 seconds - response = None - - for attempt in range(max_retries): - try: - if len(batch_to_send) == 1: - response = [litellm.completion( - **{**completion_params, "messages": batch_to_send[0]} - )] - else: - response = litellm.batch_completion(**completion_params) - break # Success, exit retry loop - except RateLimitError: - if attempt < max_retries - 1: - wait_time = retry_delay * (2 ** attempt) # Exponential backoff - logger.warning( - f"Rate limit hit | Provider: {self.provider_name} | Model: {self.model_id} | " - f"Attempt {attempt + 1}/{max_retries} | Waiting {wait_time}s before retry" - ) - time.sleep(wait_time) - else: - logger.error( - f"Rate limit exceeded after {max_retries} attempts | " - f"Provider: {self.provider_name} | Model: {self.model_id}" - ) - raise - - if response is None: - raise RuntimeError("Failed to get response after retries") - - # Record timestamp for rate limiting (one timestamp per batch item) - if self.rpm_limit is not None: - current_time = time.monotonic() - for _ in range(len(batch_to_send)): - self._request_timestamps.append(current_time) - - # Extract content from each response - results = [] - for idx, one_response in enumerate(response): - if isinstance(one_response, Exception): - if isinstance(one_response, RateLimitError): - logger.warning( - "Rate limit error in batch item | Provider: %s | Model: %s | Item: %d", - self.provider_name, - self.model_id, - idx, - ) - raise RuntimeError( - f"Batch item {idx} failed during generation: {one_response}" - ) from one_response - - if not getattr(one_response, "choices", None): - raise RuntimeError( - f"Unexpected response type from LiteLLM batch completion at item {idx}: {type(one_response).__name__}" - ) - - content = one_response.choices[0].message.content - - if response_format is not None: - # Strip code fences before validation - content = self._strip_code_fences(content) - try: - results.append( - response_format.model_validate_json(content)) - except Exception as validation_error: - # Show the content that failed to parse for debugging - content_preview = content[:200] + "..." if len(content) > 200 else content - logger.warning( - f"JSON parsing failed, skipping response | " - f"Model: {self.model_id} | " - f"Format: {response_format.__name__} | " - f"Content preview: {content_preview}" - ) - raise ValueError( - f"Failed to parse JSON response into {response_format.__name__}.\n" - f"Validation error: {validation_error}\n" - f"Content received (first 200 chars):\n{content_preview}" - ) from validation_error - else: - # Strip leading/trailing whitespace for text responses - results.append(content.strip() if content else content) - - # Return single result for backward compatibility - if single_input and len(results) == 1: - return results[0] - return results - - except Exception as e: - error_trace = traceback.format_exc() - logger.error( - f"Generation failed | Provider: {self.provider_name} | " - f"Model: {self.model_id} | Error: {str(e)}" - ) - raise RuntimeError( - f"Error generating batch response with {self.provider_name}:\n{error_trace}" - ) - - -class OpenAIProvider(LLMProvider): - """OpenAI provider using litellm.responses endpoint. - - Note: This provider uses the new responses endpoint which has different - parameter support compared to the standard completion endpoint: - - temperature, top_p, and frequency_penalty are not supported - - Uses text_format instead of response_format - - Supports reasoning parameter for controlling reasoning effort - - Does not support batch operations (will process sequentially with warning) - """ - - @property - def provider_name(self) -> str: - return "openai" - - @property - def env_key_name(self) -> str: - return "OPENAI_API_KEY" - - def __init__( - self, - model_id: str = "gpt-5-mini-2025-08-07", - api_key: str | None = None, - max_completion_tokens: int | None = None, - reasoning_effort: str = "low", - temperature: float | None = None, - top_p: float | None = None, - frequency_penalty: float | None = None, - timeout: int | None = None, - ): - """Initialize the OpenAI provider. - - Args: - model_id: The model ID (defaults to gpt-5-mini) - api_key: API key (if None, will get from environment) - max_completion_tokens: An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens. - reasoning_effort: Reasoning effort level - "low", "medium", or "high" (defaults to "low") - temperature: DEPRECATED - Not supported by responses endpoint - top_p: DEPRECATED - Not supported by responses endpoint - frequency_penalty: DEPRECATED - Not supported by responses endpoint - timeout: Request timeout in seconds - """ - # Warn about deprecated parameters - if temperature is not None: - warnings.warn( - "temperature parameter is not supported by OpenAI responses endpoint and will be ignored", - UserWarning, - stacklevel=2 - ) - if top_p is not None: - warnings.warn( - "top_p parameter is not supported by OpenAI responses endpoint and will be ignored", - UserWarning, - stacklevel=2 - ) - if frequency_penalty is not None: - warnings.warn( - "frequency_penalty parameter is not supported by OpenAI responses endpoint and will be ignored", - UserWarning, - stacklevel=2 - ) - - # Store reasoning effort - self.reasoning_effort = reasoning_effort - - # Call parent init with None for unsupported params - super().__init__( - model_id=model_id, - api_key=api_key, - temperature=None, - max_completion_tokens=max_completion_tokens, - top_p=None, - frequency_penalty=None, - timeout=timeout, - ) - - def generate( - self, - prompt: str | list[str] | None = None, - messages: list[Messages] | Messages | None = None, - response_format: Type[T] | None = None, - metadata: dict[str, Any] | None = None, - ) -> str | list[str] | T | list[T]: - """ - Generate responses from the LLM using the responses endpoint. - - Note: Batch operations are processed sequentially as the responses endpoint - does not support native batching. - - Args: - prompt: Single text prompt (str) or list of text prompts for batch processing - messages: Single message list or list of message lists for batch processing - response_format: Optional Pydantic model class for structured output - metadata: Optional LiteLLM metadata for tracing / observability - - Returns: - Single string/model or list of strings/models depending on input type. - - Raises: - ValueError: If neither prompt nor messages is provided, or if both are provided. - RuntimeError: If there's an error during generation. - """ - # Validate inputs - if prompt is None and messages is None: - raise ValueError("Either prompts or messages must be provided") - if prompt is not None and messages is not None: - raise ValueError("Provide either prompts or messages, not both") - - # Determine if this is a single input or batch input - single_input = False - batch_prompts = None - batch_messages = None - - if prompt is not None: - if isinstance(prompt, str): - # Single prompt - convert to batch - batch_prompts = [prompt] - single_input = True - elif isinstance(prompt, list): - # Already a list of prompts - batch_prompts = prompt - single_input = False - else: - raise ValueError("prompt must be a string or list of strings") - - if messages is not None: - if isinstance(messages, list) and len(messages) > 0: - # Check if it's a single message list or batch - if isinstance(messages[0], dict): - # Single message list - convert to batch - batch_messages = [messages] - single_input = True - elif isinstance(messages[0], list): - # Already a batch of message lists - batch_messages = messages - single_input = False - else: - raise ValueError("Invalid messages format") - else: - raise ValueError("messages cannot be empty") - - try: - # Convert batch prompts to messages if needed - batch_to_send = [] - if batch_prompts is not None: - for one_prompt in batch_prompts: - batch_to_send.append([{"role": "user", "content": one_prompt}]) - else: - batch_to_send = batch_messages - - # Warn if batch processing is being used - if len(batch_to_send) > 1: - warnings.warn( - f"OpenAI responses endpoint does not support batch operations. " - f"Processing {len(batch_to_send)} requests sequentially.", - UserWarning, - stacklevel=2 - ) - - # Process each request sequentially - results = [] - for message_list in batch_to_send: - # Enforce rate limit per request - self._respect_rate_limit() - - # Prepare completion parameters - completion_params = { - "model": self._get_model_string(), - "input": message_list, - "reasoning": {"effort": self.reasoning_effort}, - "metadata": self._build_request_metadata(metadata), - } - - # Add max_output_tokens if specified - if self.max_completion_tokens is not None: - completion_params["max_output_tokens"] = self.max_completion_tokens - - # Add text_format if response_format is provided - if response_format is not None: - completion_params["text_format"] = response_format - - # Call LiteLLM responses endpoint - response = litellm.responses(**completion_params) - - # Record timestamp for rate limiting - if self.rpm_limit is not None: - self._request_timestamps.append(time.monotonic()) - - # Extract content from response - # Response structure: response.output[1].content[0].text - content = response.output[1].content[0].text - - if response_format is not None: - # Strip code fences before validation - content = self._strip_code_fences(content) - try: - results.append(response_format.model_validate_json(content)) - except Exception as validation_error: - # Show the content that failed to parse for debugging - content_preview = content[:200] + "..." if len(content) > 200 else content - logger.warning( - f"JSON parsing failed, skipping response | " - f"Model: {self.model_id} | " - f"Format: {response_format.__name__} | " - f"Content preview: {content_preview}" - ) - raise ValueError( - f"Failed to parse JSON response into {response_format.__name__}.\n" - f"Validation error: {validation_error}\n" - f"Content received (first 200 chars):\n{content_preview}" - ) from validation_error - else: - # Strip leading/trailing whitespace for text responses - results.append(content.strip() if content else content) - - # Return single result for backward compatibility - if single_input and len(results) == 1: - return results[0] - return results - - except Exception as e: - error_trace = traceback.format_exc() - logger.error( - f"Generation failed | Provider: {self.provider_name} | " - f"Model: {self.model_id} | Error: {str(e)}" - ) - raise RuntimeError( - f"Error generating response with {self.provider_name}:\n{error_trace}" - ) - - -class AnthropicProvider(LLMProvider): - """Anthropic provider using litellm.""" - - @property - def provider_name(self) -> str: - return "anthropic" - - @property - def env_key_name(self) -> str: - return "ANTHROPIC_API_KEY" - - def __init__( - self, - model_id: str = "claude-haiku-4-5-20251001", - api_key: str | None = None, - temperature: float | None = None, - max_completion_tokens: int | None = None, - timeout: int | None = None, - # top_p: float | None = None, # Not properly supported by anthropic models 4.5 - # frequency_penalty: float | None = None, # Not supported by anthropic models 4.5 - ): - """Initialize the Anthropic provider. - - Args: - model_id: The model ID (defaults to claude-haiku-4-5-20251001) - api_key: API key (if None, will get from environment) - temperature: Temperature for generation (0.0 to 1.0) - max_completion_tokens: Maximum tokens to generate - timeout: Request timeout in seconds - top_p: Nucleus sampling parameter (0.0 to 1.0) - """ - super().__init__( - model_id=model_id, - api_key=api_key, - temperature=temperature, - max_completion_tokens=max_completion_tokens, - timeout=timeout, - ) - - -class GeminiProvider(LLMProvider): - """Google Gemini provider using litellm.""" - - @property - def provider_name(self) -> str: - return "gemini" - - @property - def env_key_name(self) -> str: - return "GEMINI_API_KEY" - - def __init__( - self, - model_id: str = "gemini-2.0-flash", - api_key: str | None = None, - temperature: float | None = None, - max_completion_tokens: int | None = None, - top_p: float | None = None, - frequency_penalty: float | None = None, - rpm_limit: int | None = None, - timeout: int | None = None, - ): - """Initialize the Gemini provider. - - Args: - model_id: The model ID (defaults to gemini-2.0-flash) - api_key: API key (if None, will get from environment) - temperature: Temperature for generation (0.0 to 1.0) - max_completion_tokens: Maximum tokens to generate - top_p: Nucleus sampling parameter (0.0 to 1.0) - frequency_penalty: Penalty for token frequency (-2.0 to 2.0) - timeout: Request timeout in seconds - """ - super().__init__( - model_id=model_id, - api_key=api_key, - temperature=temperature, - max_completion_tokens=max_completion_tokens, - top_p=top_p, - frequency_penalty=frequency_penalty, - rpm_limit=rpm_limit, - timeout=timeout, - ) - - -class OllamaProvider(LLMProvider): - """Ollama provider using litellm. - - Note: Ollama typically doesn't require an API key as it's usually run locally. - """ - - @property - def provider_name(self) -> str: - return "ollama_chat" - - @property - def env_key_name(self) -> str: - return "OLLAMA_API_BASE" - - def _get_api_key(self) -> str: - """Override to handle Ollama not requiring an API key. - - Returns an empty string since Ollama typically doesn't need an API key. - OLLAMA_API_BASE can be used to set a custom base URL. - """ - return "" - - def __init__( - self, - model_id: str = "gemma3:4b", - temperature: float | None = None, - max_completion_tokens: int | None = None, - top_p: float | None = None, - frequency_penalty: float | None = None, - api_base: str | None = None, - rpm_limit: int | None = None, - timeout: int | None = None, - ): - """Initialize the Ollama provider. - - Args: - model_id: The model ID (defaults to llama3) - temperature: Temperature for generation (0.0 to 1.0) - max_completion_tokens: Maximum tokens to generate - top_p: Nucleus sampling parameter (0.0 to 1.0) - frequency_penalty: Penalty for token frequency (-2.0 to 2.0) - api_base: Base URL for Ollama API (e.g., "http://localhost:11434") - timeout: Request timeout in seconds - """ - # Set API base URL if provided - if api_base: - os.environ["OLLAMA_API_BASE"] = api_base - - super().__init__( - model_id=model_id, - api_key="", # Pass empty string since parent class requires this parameter - temperature=temperature, - max_completion_tokens=max_completion_tokens, - top_p=top_p, - frequency_penalty=frequency_penalty, - rpm_limit=rpm_limit, - timeout=timeout, - ) - - -class OpenRouterProvider(LLMProvider): - """OpenRouter provider using litellm""" - - @property - def provider_name(self) -> str: - return "openrouter" - - @property - def env_key_name(self) -> str: - return "OPENROUTER_API_KEY" - - def __init__( - self, - model_id: str = "openai/gpt-5-mini", # for default model - api_key: str | None = None, - temperature: float | None = None, - max_completion_tokens: int | None = None, - top_p: float | None = None, - frequency_penalty: float | None = None, - timeout: int | None = None, - ): - """Initialize the OpenRouter provider. - - Args: - model_id: The model ID (defaults to openai/gpt-5-mini) - api_key: API key (if None, will get from environment) - temperature: Temperature for generation (0.0 to 1.0) - max_completion_tokens: Maximum tokens to generate - top_p: Nucleus sampling parameter (0.0 to 1.0) - frequency_penalty: Penalty for token frequency (-2.0 to 2.0) - timeout: Request timeout in seconds - """ - super().__init__( - model_id = model_id, - api_key = api_key, - temperature = temperature, - max_completion_tokens = max_completion_tokens, - top_p = top_p, - frequency_penalty = frequency_penalty, - timeout = timeout, - ) - - -class MistralProvider(LLMProvider): - """Mistral AI provider using litellm.""" - - @property - def provider_name(self) -> str: - return "mistral" - - @property - def env_key_name(self) -> str: - return "MISTRAL_API_KEY" - - def __init__( - self, - model_id: str = "mistral-small-latest", - api_key: str | None = None, - temperature: float | None = None, - max_completion_tokens: int | None = None, - top_p: float | None = None, - frequency_penalty: float | None = None, - rpm_limit: int | None = None, - timeout: int | None = None, - ): - """Initialize the Mistral provider. - - Args: - model_id: The model ID (defaults to mistral-small-latest) - api_key: API key (if None, will get from MISTRAL_API_KEY env var) - temperature: Temperature for generation (0.0 to 1.0) - max_completion_tokens: Maximum tokens to generate - top_p: Nucleus sampling parameter (0.0 to 1.0) - frequency_penalty: Penalty for token frequency (-2.0 to 2.0) - rpm_limit: Requests per minute limit for rate limiting - timeout: Request timeout in seconds - """ - super().__init__( - model_id=model_id, - api_key=api_key, - temperature=temperature, - max_completion_tokens=max_completion_tokens, - top_p=top_p, - frequency_penalty=frequency_penalty, - rpm_limit=rpm_limit, - timeout=timeout, - ) - - -def openai(model_id: str = "gpt-5-mini-2025-08-07", **kwargs) -> OpenAIProvider: - """Create an OpenAI provider instance.""" - return OpenAIProvider(model_id=model_id, **kwargs) - - -def anthropic( - model_id: str = "claude-haiku-4-5-20251001", - **kwargs, -) -> AnthropicProvider: - """Create an Anthropic provider instance.""" - return AnthropicProvider(model_id=model_id, **kwargs) - - -def gemini(model_id: str = "gemini-2.0-flash", **kwargs) -> GeminiProvider: - """Create a Gemini provider instance.""" - return GeminiProvider(model_id=model_id, **kwargs) - - -def ollama(model_id: str = "gemma3:4b", **kwargs) -> OllamaProvider: - """Create an Ollama provider instance.""" - return OllamaProvider(model_id=model_id, **kwargs) - - -def openrouter( - model_id: str = "openai/gpt-5-mini", - **kwargs, -) -> OpenRouterProvider: - """Create an OpenRouter provider instance.""" - return OpenRouterProvider(model_id=model_id, **kwargs) - - -def mistral(model_id: str = "mistral-small-latest", **kwargs) -> MistralProvider: - """Create a Mistral provider instance.""" - return MistralProvider(model_id=model_id, **kwargs) +import litellm __all__ = [ @@ -899,13 +30,18 @@ def mistral(model_id: str = "mistral-small-latest", **kwargs) -> MistralProvider "OpenAIProvider", "AnthropicProvider", "GeminiProvider", - "OllamaProvider", - "OpenRouterProvider", "MistralProvider", + "OpenRouterProvider", + "OllamaProvider", + "OpenAICompatibleProvider", "openai", "anthropic", "gemini", - "ollama", - "openrouter", "mistral", + "openrouter", + "ollama", + "openai_compatible", + "litellm", + "load_env_once", + "maybe_configure_langfuse_tracing", ] diff --git a/datafast/transforms/llm_eval.py b/datafast/transforms/llm_eval.py index b0ea320..6fb3e78 100644 --- a/datafast/transforms/llm_eval.py +++ b/datafast/transforms/llm_eval.py @@ -366,7 +366,7 @@ def _process_llm(self, records: Iterable[Record]) -> Iterable[Record]: try: messages = self._build_messages(record) raw = model.generate( - messages, + messages=messages, metadata=build_trace_metadata( model=model, component="step.process", @@ -657,7 +657,7 @@ def _process_llm(self, records: Iterable[Record]) -> Iterable[Record]: try: messages = self._build_messages(record) raw = model.generate( - messages, + messages=messages, metadata=build_trace_metadata( model=model, component="step.process", @@ -1011,7 +1011,7 @@ def _process_llm(self, records: Iterable[Record]) -> Iterable[Record]: try: messages = self._build_messages(record) raw = model.generate( - messages, + messages=messages, metadata=build_trace_metadata( model=model, component="step.process", diff --git a/datafast/transforms/llm_extract.py b/datafast/transforms/llm_extract.py index aa8161d..9d3e095 100644 --- a/datafast/transforms/llm_extract.py +++ b/datafast/transforms/llm_extract.py @@ -418,7 +418,7 @@ def _process_llm(self, records: Iterable[Record]) -> Iterable[Record]: try: messages = self._build_messages(record) raw = model.generate( - messages, + messages=messages, metadata=build_trace_metadata( model=model, component="step.process", diff --git a/datafast/transforms/llm_step.py b/datafast/transforms/llm_step.py index d2aae42..ad1a8fb 100644 --- a/datafast/transforms/llm_step.py +++ b/datafast/transforms/llm_step.py @@ -384,7 +384,7 @@ def process(self, records: Iterable[Record]) -> Iterable[Record]: messages = self._build_messages(prompt_template, context) raw_output = model.generate( - messages, + messages=messages, metadata=build_trace_metadata( model=model, component="step.process", diff --git a/datafast/transforms/llm_transform.py b/datafast/transforms/llm_transform.py index 8901a03..105ce65 100644 --- a/datafast/transforms/llm_transform.py +++ b/datafast/transforms/llm_transform.py @@ -298,7 +298,7 @@ def process(self, records: Iterable[Record]) -> Iterable[Record]: try: messages = self._build_messages(record) raw = model.generate( - messages, + messages=messages, metadata=build_trace_metadata( model=model, component="step.process", diff --git a/examples/providers/README.md b/examples/providers/README.md new file mode 100644 index 0000000..5c9e4c7 --- /dev/null +++ b/examples/providers/README.md @@ -0,0 +1,8 @@ +# Provider Examples + +This folder contains direct, provider-focused examples. + +- `openrouter/`: simple OpenRouter calls with `model.generate(...)` + +These scripts are intentionally separate from `examples/scripts/`, which focuses on +pipeline usage. From 58b676541a99b2574efd425c2335429682c68ffd Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Tue, 16 Jun 2026 17:52:03 +0200 Subject: [PATCH 23/54] llm tests --- pytest.ini | 5 + tests/conftest.py | 20 ++ tests/test_llm_provider_contract.py | 503 ++++++++++++++++++++++++++++ tests/test_llms_unit.py | 37 +- 4 files changed, 560 insertions(+), 5 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_llm_provider_contract.py diff --git a/pytest.ini b/pytest.ini index 798f789..042626f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,11 @@ [pytest] markers = integration: marks tests that require API connectivity (deselect with '-m "not integration"') + live: marks tests that hit a real provider endpoint + multimodal: marks tests that exercise multimodal provider behavior + ollama: marks tests that require a real Ollama backend + vllm: marks tests that require a real vLLM backend + llamacpp: marks tests that require a real llama.cpp backend slow: marks tests that are slow to run # Other pytest configurations diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..961d50b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +import pytest + + +def pytest_addoption(parser): + parser.addoption( + "--run-live", + action="store_true", + default=False, + help="run tests marked live or integration", + ) + + +def pytest_collection_modifyitems(config, items): + if config.getoption("--run-live"): + return + + skip_live = pytest.mark.skip(reason="requires --run-live") + for item in items: + if "live" in item.keywords or "integration" in item.keywords: + item.add_marker(skip_live) diff --git a/tests/test_llm_provider_contract.py b/tests/test_llm_provider_contract.py new file mode 100644 index 0000000..8928e5e --- /dev/null +++ b/tests/test_llm_provider_contract.py @@ -0,0 +1,503 @@ +import pytest +from pydantic import BaseModel + +import datafast.llm.provider as provider_module +from datafast import LLMStep, ListSink, Source +from datafast.llm import ( + ContentPart, + EndpointMode, + Modality, + OpenAIProvider, + OpenRouterProvider, + openai, + openai_compatible, +) + + +class SimpleSchema(BaseModel): + answer: str + + +class _DummyMessage: + def __init__( + self, + content, + reasoning_content=None, + thinking_blocks=None, + images=None, + audio=None, + ): + self.content = content + self.reasoning_content = reasoning_content + self.thinking_blocks = thinking_blocks + self.images = images + self.audio = audio + + +class _DummyChoice: + def __init__( + self, + content, + reasoning_content=None, + thinking_blocks=None, + images=None, + audio=None, + ): + self.message = _DummyMessage( + content, + reasoning_content, + thinking_blocks, + images, + audio, + ) + + +class _DummyChatResponse: + def __init__( + self, + content, + reasoning_content=None, + thinking_blocks=None, + images=None, + audio=None, + ): + self.choices = [ + _DummyChoice( + content, + reasoning_content, + thinking_blocks, + images, + audio, + ) + ] + + +class _DummyResponsesResponse: + def __init__(self, output_text=None, output=None, reasoning_content=None): + self.output_text = output_text + self.output = output + self.reasoning_content = reasoning_content + + +@pytest.fixture(autouse=True) +def _disable_provider_side_effects(monkeypatch): + monkeypatch.setattr(provider_module, "load_env_once", lambda: None) + monkeypatch.setattr( + provider_module, + "maybe_configure_langfuse_tracing", + lambda load_env=False: False, + ) + + +def test_factories_resolve_expected_targets(): + hosted = openai(api_key="test-key") + local = openai_compatible( + "ministral-8b-2512", + api_base_url="http://localhost:8000/v1", + ) + + assert hosted.provider_name == "openai" + assert hosted.endpoint_mode == EndpointMode.RESPONSES + assert hosted._get_model_string() == "openai/gpt-5.5" + + assert local.provider_name == "openai_compatible" + assert local.endpoint_mode == EndpointMode.CHAT + assert local.api_base_url == "http://localhost:8000/v1" + + +def test_openai_compatible_backend_profiles_are_distinct(): + generic = openai_compatible( + "local-model", + api_base_url="http://localhost:8000/v1", + ) + vllm = openai_compatible( + "local-model", + api_base_url="http://localhost:8000/v1", + backend="vllm", + ) + llamacpp = openai_compatible( + "local-model", + api_base_url="http://localhost:8080/v1", + backend="llamacpp", + ) + + assert generic.provider_name == "openai_compatible" + assert generic.capabilities.modalities == frozenset({Modality.TEXT}) + + assert vllm.provider_name == "vllm" + assert vllm.capabilities.supports_endpoint(EndpointMode.RESPONSES) + assert Modality.IMAGE in vllm.capabilities.modalities + assert Modality.VIDEO in vllm.capabilities.modalities + + assert llamacpp.provider_name == "llamacpp" + assert Modality.AUDIO in llamacpp.capabilities.modalities + assert Modality.FILE in llamacpp.capabilities.modalities + + +def test_input_validation_rejects_missing_or_ambiguous_inputs(): + provider = OpenRouterProvider(model_id="demo-model", api_key="test-key") + + with pytest.raises(ValueError, match="Either prompt or messages"): + provider.generate() + + with pytest.raises(ValueError, match="either prompt or messages"): + provider.generate(prompt="hello", messages=[{"role": "user", "content": "hi"}]) + + +def test_unsupported_params_warn_and_omit(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = openai_compatible( + "local-model", + api_base_url="http://localhost:8000/v1", + temperature=0.7, + ) + + with pytest.warns(UserWarning, match="temperature"): + assert provider.generate(prompt="ping") == "ok" + + assert "temperature" not in captured + assert captured["api_base"] == "http://localhost:8000/v1" + + +def test_unsupported_params_fail_before_dispatch(monkeypatch): + def fake_completion(**kwargs): + raise AssertionError("request should not be dispatched") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = openai_compatible( + "local-model", + api_base_url="http://localhost:8000/v1", + temperature=0.7, + unsupported_params="fail", + ) + + with pytest.raises(ValueError, match="temperature"): + provider.generate(prompt="ping") + + +def test_chat_endpoint_warns_and_omits_previous_response_id(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = OpenRouterProvider(model_id="demo-model", api_key="test-key") + + with pytest.warns(UserWarning, match="previous_response_id"): + assert provider.generate(prompt="ping", previous_response_id="resp_old") == "ok" + + assert "previous_response_id" not in captured + + +def test_openrouter_thinking_warns_and_omits_reasoning_param(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = OpenRouterProvider( + model_id="nvidia/nemotron-3-super-120b-a12b:nitro", + api_key="test-key", + thinking=True, + ) + + with pytest.warns(UserWarning, match="reasoning_effort"): + assert provider.generate(prompt="ping") == "ok" + + assert "reasoning_effort" not in captured + assert "reasoning" not in captured + + +def test_provider_params_escape_hatch_is_forwarded(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = openai_compatible( + "local-model", + api_base_url="http://localhost:8000/v1", + provider_params={"extra_body": {"backend_hint": "vllm"}}, + ) + + assert provider.generate(prompt="ping") == "ok" + assert captured["extra_body"] == {"backend_hint": "vllm"} + + +def test_content_parts_normalize_multimodal_and_document_shapes(): + vllm = openai_compatible( + "local-model", + api_base_url="http://localhost:8000/v1", + backend="vllm", + ) + prepared = vllm._prepare_messages( + [ + { + "role": "user", + "content": [ + ContentPart(type="text", text="What is in this image?"), + ContentPart( + type="image", + url="https://example.com/image.png", + media_id="img-123", + ), + ContentPart( + type="video", + url="https://example.com/video.mp4", + media_id="vid-123", + ), + ], + } + ], + response_format=None, + ) + + assert prepared[0]["content"] == [ + {"type": "text", "text": "What is in this image?"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png"}, + "uuid": "img-123", + }, + { + "type": "video_url", + "video_url": {"url": "https://example.com/video.mp4"}, + "uuid": "vid-123", + }, + ] + + llamacpp = openai_compatible( + "local-model", + api_base_url="http://localhost:8080/v1", + backend="llamacpp", + ) + prepared = llamacpp._prepare_messages( + [ + { + "role": "user", + "content": [ + ContentPart( + type="document", + data="data:application/pdf;base64,abc", + media_type="application/pdf", + ), + ], + } + ], + response_format=None, + ) + + assert prepared[0]["content"] == [ + { + "type": "file", + "file": {"file_data": "data:application/pdf;base64,abc"}, + } + ] + + +def test_litellm_unsupported_params_can_retry_with_drop_params(monkeypatch): + unsupported_error = type("UnsupportedParamsError", (Exception,), {}) + calls = [] + + def fake_completion(**kwargs): + calls.append(kwargs) + if len(calls) == 1: + raise unsupported_error("bad param") + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = OpenRouterProvider(model_id="demo-model", api_key="test-key") + + with pytest.warns(UserWarning, match="drop_params=True"): + assert provider.generate(prompt="ping") == "ok" + + assert calls[0].get("drop_params") is None + assert calls[1]["drop_params"] is True + + +def test_generate_response_preserves_litellm_reasoning_metadata(monkeypatch): + monkeypatch.setattr( + provider_module.litellm, + "completion", + lambda **kwargs: _DummyChatResponse( + "final answer", + reasoning_content="internal summary", + thinking_blocks=[ + { + "type": "thinking", + "thinking": "visible thinking block", + "signature": "sig", + } + ], + images=[{"type": "image", "url": "https://example.com/out.png"}], + audio={"id": "audio-1", "expires_at": 123}, + ), + ) + + provider = OpenRouterProvider(model_id="demo-model", api_key="test-key") + response = provider.generate_response(prompt="ping") + + assert response.text == "final answer" + assert response.reasoning_content == "internal summary" + assert response.thinking_blocks == [ + { + "type": "thinking", + "thinking": "visible thinking block", + "signature": "sig", + } + ] + assert response.images == [ + {"type": "image", "url": "https://example.com/out.png"} + ] + assert response.audio == {"id": "audio-1", "expires_at": 123} + + +def test_responses_full_response_preserves_output_items_and_media(monkeypatch): + output = [ + {"type": "reasoning", "summary": [{"text": "short rationale"}]}, + {"type": "image_generation_call", "result": "base64-image"}, + { + "type": "message", + "content": [{"type": "output_text", "text": "Here is the image."}], + }, + ] + + monkeypatch.setattr( + provider_module.litellm, + "responses", + lambda **kwargs: _DummyResponsesResponse(output=output), + ) + + provider = OpenAIProvider(model_id="gpt-5.5", api_key="test-key") + response = provider.generate_response(prompt="make an image") + + assert response.text == "Here is the image." + assert response.reasoning_content == "short rationale" + assert response.images == [ + {"type": "image_generation_call", "result": "base64-image"} + ] + assert response.output_items == output + + +def test_responses_endpoint_maps_reasoning_state_and_structured_output(monkeypatch): + captured = {} + + def fake_responses(**kwargs): + captured.update(kwargs) + return _DummyResponsesResponse('{"answer": "Paris"}') + + monkeypatch.setattr(provider_module.litellm, "responses", fake_responses) + + provider = OpenAIProvider( + model_id="gpt-5.5", + api_key="test-key", + thinking=True, + max_completion_tokens=64, + ) + + result = provider.generate( + messages=[{"role": "user", "content": "capital?"}], + response_format=SimpleSchema, + previous_response_id="resp_previous", + metadata={"purpose": "test"}, + ) + + assert result == SimpleSchema(answer="Paris") + assert captured["model"] == "openai/gpt-5.5" + assert captured["previous_response_id"] == "resp_previous" + assert captured["reasoning"] == {"effort": "low"} + assert captured["max_output_tokens"] == 64 + assert captured["text_format"] is SimpleSchema + assert captured["metadata"]["purpose"] == "test" + + +def test_fallback_batching_preserves_order(monkeypatch): + calls = [] + + def fake_completion(**kwargs): + calls.append(kwargs["messages"][0]["content"]) + return _DummyChatResponse(f"reply:{kwargs['messages'][0]['content']}") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = openai_compatible( + "local-model", + api_base_url="http://localhost:8000/v1", + max_concurrent=1, + ) + + with pytest.warns(UserWarning, match="Falling back"): + result = provider.generate(prompt=["one", "two", "three"]) + + assert result == ["reply:one", "reply:two", "reply:three"] + assert calls == ["one", "two", "three"] + + +def test_structured_output_validation_error_is_clear(monkeypatch): + monkeypatch.setattr( + provider_module.litellm, + "completion", + lambda **kwargs: _DummyChatResponse("not json"), + ) + + provider = OpenRouterProvider(model_id="demo-model", api_key="test-key") + + with pytest.raises(ValueError, match="Failed to parse JSON response"): + provider.generate(prompt="answer in json", response_format=SimpleSchema) + + +def test_runner_dispatches_same_model_batches_through_generate_batch(): + class FakeBatchModel: + provider_name = "fake" + model_id = "fake-model" + + def __init__(self): + self.batches = [] + + def generate_batch(self, messages, metadata=None, response_format=None): + self.batches.append({"messages": messages, "metadata": metadata}) + return ["first", "second"] + + model = FakeBatchModel() + sink = ListSink() + pipeline = ( + Source.list([{"topic": "alpha"}, {"topic": "beta"}]) + >> LLMStep( + prompt="Write about {topic}.", + input_columns=["topic"], + output_column="result", + model=model, + ) + >> sink + ) + + output = pipeline.run(batch_size=2) + + assert output == [ + {"topic": "alpha", "result": "first", "_model": "fake-model"}, + {"topic": "beta", "result": "second", "_model": "fake-model"}, + ] + assert len(model.batches) == 1 + assert [batch[0]["content"] for batch in model.batches[0]["messages"]] == [ + "Write about alpha.", + "Write about beta.", + ] + assert len(model.batches[0]["metadata"]) == 2 diff --git a/tests/test_llms_unit.py b/tests/test_llms_unit.py index a6e5674..1c67a6d 100644 --- a/tests/test_llms_unit.py +++ b/tests/test_llms_unit.py @@ -3,18 +3,20 @@ class _DummyMessage: - def __init__(self, content: str) -> None: + def __init__(self, content: str, **extra: object) -> None: self.content = content + for key, value in extra.items(): + setattr(self, key, value) class _DummyChoice: - def __init__(self, content: str) -> None: - self.message = _DummyMessage(content) + def __init__(self, content: str, **extra: object) -> None: + self.message = _DummyMessage(content, **extra) class _DummyResponse: - def __init__(self, content: str) -> None: - self.choices = [_DummyChoice(content)] + def __init__(self, content: str, **extra: object) -> None: + self.choices = [_DummyChoice(content, **extra)] def test_openrouter_single_messages_use_completion(monkeypatch): @@ -78,3 +80,28 @@ def fake_batch_completion(**kwargs): assert response == ["first", "second"] assert calls == {"completion": 0, "batch_completion": 1} + + +def test_openrouter_generate_response_reads_reasoning_field(monkeypatch): + monkeypatch.setattr(llms_module, "load_env_once", lambda: None) + monkeypatch.setattr( + llms_module, + "maybe_configure_langfuse_tracing", + lambda load_env=False: False, + ) + + monkeypatch.setattr( + llms_module.litellm, + "completion", + lambda **kwargs: _DummyResponse( + "final answer", + reasoning="hidden chain of thought summary", + ), + ) + + provider = OpenRouterProvider(model_id="demo-model", api_key="test-key") + + response = provider.generate_response(prompt="solve this") + + assert response.text == "final answer" + assert response.reasoning_content == "hidden chain of thought summary" From b518730f3285fc1687249ebe9648d5044a132de5 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Tue, 16 Jun 2026 17:52:16 +0200 Subject: [PATCH 24/54] utility function --- datafast/llm_utils.py | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/datafast/llm_utils.py b/datafast/llm_utils.py index 18890cd..9aa2fb3 100644 --- a/datafast/llm_utils.py +++ b/datafast/llm_utils.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +from collections.abc import Sequence + + def get_messages(prompt: str, system_message: str = "You are a helpful assistant.") -> list[dict[str, str]]: """Convert a single prompt into a message list format expected by LLM APIs. @@ -12,3 +17,48 @@ def get_messages(prompt: str, system_message: str = "You are a helpful assistant {"role": "system", "content": system_message}, {"role": "user", "content": prompt}, ] + + +def format_generated_responses( + prompts: str | Sequence[str], + responses: str | Sequence[str], +) -> str: + """Return a readable string for one or many prompt/response pairs.""" + prompt_items = [prompts] if isinstance(prompts, str) else list(prompts) + response_items = [responses] if isinstance(responses, str) else list(responses) + + if len(prompt_items) != len(response_items): + raise ValueError("prompts and responses must have the same length") + + sections = [ + _format_response_section(prompt, response, index, total=len(prompt_items)) + for index, (prompt, response) in enumerate( + zip(prompt_items, response_items, strict=True), + start=1, + ) + ] + return "\n\n".join(sections) + + +def _format_response_section( + prompt: str, + response: str, + index: int, + *, + total: int, +) -> str: + lines = [] + if total > 1: + lines.append(f"Example {index}") + lines.extend( + [ + "Prompt", + "------", + prompt, + "", + "Response", + "--------", + response, + ] + ) + return "\n".join(lines) From 4507d7c199cf8a0681bf97d9dab05a6b9200ff69 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Tue, 16 Jun 2026 17:52:26 +0200 Subject: [PATCH 25/54] script simple prompt test --- .../providers/openrouter/01_simple_prompt.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 examples/providers/openrouter/01_simple_prompt.py diff --git a/examples/providers/openrouter/01_simple_prompt.py b/examples/providers/openrouter/01_simple_prompt.py new file mode 100644 index 0000000..fdfb279 --- /dev/null +++ b/examples/providers/openrouter/01_simple_prompt.py @@ -0,0 +1,22 @@ +"""Minimal OpenRouter example with a single prompt.""" + +from dotenv import load_dotenv + +from datafast import openrouter +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "openai/gpt-5.4-mini" +PROMPT = "Write one sentence explaining what OpenRouter is." + + +def main() -> None: + load_dotenv() + + model = openrouter(MODEL_ID, temperature=0) + response = model.generate(prompt=PROMPT) + print(format_generated_responses(PROMPT, response)) + + +if __name__ == "__main__": + main() From 6e17cf34aa0a5475077da2f44a44a4ee6d3b6708 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Tue, 16 Jun 2026 17:52:35 +0200 Subject: [PATCH 26/54] example with batch prompts --- .../providers/openrouter/02_batch_prompts.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 examples/providers/openrouter/02_batch_prompts.py diff --git a/examples/providers/openrouter/02_batch_prompts.py b/examples/providers/openrouter/02_batch_prompts.py new file mode 100644 index 0000000..765b219 --- /dev/null +++ b/examples/providers/openrouter/02_batch_prompts.py @@ -0,0 +1,26 @@ +"""Minimal OpenRouter example with a batch of prompts.""" + +from dotenv import load_dotenv + +from datafast import openrouter +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "openai/gpt-5.4-mini" +PROMPTS = [ + "Give a one-sentence definition of synthetic data.", + "Give a one-sentence definition of retrieval-augmented generation.", + "Give a one-sentence definition of tool calling.", +] + + +def main() -> None: + load_dotenv() + + model = openrouter(MODEL_ID, temperature=0) + responses = model.generate(prompt=PROMPTS) + print(format_generated_responses(PROMPTS, responses)) + + +if __name__ == "__main__": + main() From b5fcbeabf5145ae1d72a916f72512b85212c2971 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Tue, 16 Jun 2026 17:52:47 +0200 Subject: [PATCH 27/54] messages with system prompt --- .../03_messages_with_system_prompt.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 examples/providers/openrouter/03_messages_with_system_prompt.py diff --git a/examples/providers/openrouter/03_messages_with_system_prompt.py b/examples/providers/openrouter/03_messages_with_system_prompt.py new file mode 100644 index 0000000..546e63f --- /dev/null +++ b/examples/providers/openrouter/03_messages_with_system_prompt.py @@ -0,0 +1,31 @@ +"""OpenRouter example using explicit chat messages.""" + +from dotenv import load_dotenv + +from datafast import openrouter +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "openai/gpt-5.4-mini" +MESSAGES = [ + { + "role": "system", + "content": "You are a concise technical assistant. Answer in exactly two bullets.", + }, + { + "role": "user", + "content": "Explain why teams use an LLM router.", + }, +] + + +def main() -> None: + load_dotenv() + + model = openrouter(MODEL_ID, temperature=0) + response = model.generate(messages=MESSAGES) + print(format_generated_responses(MESSAGES[-1]["content"], response)) + + +if __name__ == "__main__": + main() From 9804cc900b43e076cea11b9b885f111e9a7d898e Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Tue, 16 Jun 2026 17:53:02 +0200 Subject: [PATCH 28/54] structured output example --- .../openrouter/04_structured_output.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 examples/providers/openrouter/04_structured_output.py diff --git a/examples/providers/openrouter/04_structured_output.py b/examples/providers/openrouter/04_structured_output.py new file mode 100644 index 0000000..1c2bd61 --- /dev/null +++ b/examples/providers/openrouter/04_structured_output.py @@ -0,0 +1,28 @@ +"""OpenRouter example with structured output validation.""" + +from dotenv import load_dotenv +from pydantic import BaseModel + +from datafast import openrouter + + +MODEL_ID = "openai/gpt-5.4-mini" +PROMPT = "Return a JSON object describing OpenRouter in two short sentences." + + +class ProviderSummary(BaseModel): + name: str + summary: str + best_for: str + + +def main() -> None: + load_dotenv() + + model = openrouter(MODEL_ID, temperature=0) + response = model.generate(prompt=PROMPT, response_format=ProviderSummary) + print(response.model_dump_json(indent=2)) + + +if __name__ == "__main__": + main() From 3fa2fb0d62b7d44111db4e9af2eaed86563e6277 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Tue, 16 Jun 2026 17:53:15 +0200 Subject: [PATCH 29/54] example with batch of messages --- .../providers/openrouter/05_batch_messages.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 examples/providers/openrouter/05_batch_messages.py diff --git a/examples/providers/openrouter/05_batch_messages.py b/examples/providers/openrouter/05_batch_messages.py new file mode 100644 index 0000000..25b42d7 --- /dev/null +++ b/examples/providers/openrouter/05_batch_messages.py @@ -0,0 +1,44 @@ +"""OpenRouter example with a batch of message lists.""" + +from dotenv import load_dotenv + +from datafast import openrouter +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "openai/gpt-5.4-mini" +BATCH_MESSAGES = [ + [ + { + "role": "system", + "content": "You answer for engineers in one sentence.", + }, + { + "role": "user", + "content": "What is prompt caching?", + }, + ], + [ + { + "role": "system", + "content": "You answer for engineers in one sentence.", + }, + { + "role": "user", + "content": "What is structured output?", + }, + ], +] + + +def main() -> None: + load_dotenv() + + model = openrouter(MODEL_ID, temperature=0) + responses = model.generate(messages=BATCH_MESSAGES) + prompts = [messages[-1]["content"] for messages in BATCH_MESSAGES] + print(format_generated_responses(prompts, responses)) + + +if __name__ == "__main__": + main() From f1ed04534a6efab257ec535650ffcbaf548922fa Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 25 Jun 2026 12:53:28 +0200 Subject: [PATCH 30/54] adding botocore --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 95e84f9..1735b56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "litellm", "gradio", "loguru", + "botocore", ] [project.optional-dependencies] From 60338611b3d295b5a624b4bb518673920316fb25 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 25 Jun 2026 12:53:49 +0200 Subject: [PATCH 31/54] making batch messages example more interesting --- examples/providers/openrouter/05_batch_messages.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/examples/providers/openrouter/05_batch_messages.py b/examples/providers/openrouter/05_batch_messages.py index 25b42d7..f996689 100644 --- a/examples/providers/openrouter/05_batch_messages.py +++ b/examples/providers/openrouter/05_batch_messages.py @@ -19,10 +19,6 @@ }, ], [ - { - "role": "system", - "content": "You answer for engineers in one sentence.", - }, { "role": "user", "content": "What is structured output?", From 11ba2eb1f74870b05f6beb9f7274328b1a5a3d54 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 2 Jul 2026 17:14:56 +0200 Subject: [PATCH 32/54] silencing annoying litellm providers warning --- datafast/llm/provider.py | 10 ++++++++++ tests/test_llm_provider_contract.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/datafast/llm/provider.py b/datafast/llm/provider.py index 4ef0ef8..458f638 100644 --- a/datafast/llm/provider.py +++ b/datafast/llm/provider.py @@ -47,6 +47,15 @@ "\nReturn only valid JSON. Do not include markdown fences. Use double quotes " "for keys and string values, escape internal newlines, and avoid trailing commas." ) +LITELLM_SUPPRESS_DEBUG_ENV = "DATAFAST_LITELLM_SUPPRESS_DEBUG_INFO" + + +def _configure_litellm_debug_output() -> None: + """Suppress LiteLLM provider help text unless explicitly opted out.""" + setting = os.getenv(LITELLM_SUPPRESS_DEBUG_ENV, "1").strip().lower() + if setting in {"0", "false", "no", "off"}: + return + litellm.suppress_debug_info = True class LLMProvider: @@ -147,6 +156,7 @@ def __init__( if value is not None } + _configure_litellm_debug_output() load_env_once() maybe_configure_langfuse_tracing(load_env=False) logger.info( diff --git a/tests/test_llm_provider_contract.py b/tests/test_llm_provider_contract.py index 8928e5e..5f0080a 100644 --- a/tests/test_llm_provider_contract.py +++ b/tests/test_llm_provider_contract.py @@ -105,6 +105,24 @@ def test_factories_resolve_expected_targets(): assert local.api_base_url == "http://localhost:8000/v1" +def test_provider_suppresses_litellm_debug_info_by_default(monkeypatch): + monkeypatch.delenv(provider_module.LITELLM_SUPPRESS_DEBUG_ENV, raising=False) + monkeypatch.setattr(provider_module.litellm, "suppress_debug_info", False) + + provider_module.OpenRouterProvider(model_id="demo-model", api_key="test-key") + + assert provider_module.litellm.suppress_debug_info is True + + +def test_provider_allows_litellm_debug_opt_out(monkeypatch): + monkeypatch.setenv(provider_module.LITELLM_SUPPRESS_DEBUG_ENV, "0") + monkeypatch.setattr(provider_module.litellm, "suppress_debug_info", False) + + provider_module.OpenRouterProvider(model_id="demo-model", api_key="test-key") + + assert provider_module.litellm.suppress_debug_info is False + + def test_openai_compatible_backend_profiles_are_distinct(): generic = openai_compatible( "local-model", From 3259102a7d49a18c47f2b29d5fa602e4aec96b6e Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 2 Jul 2026 17:15:16 +0200 Subject: [PATCH 33/54] update docs --- examples/providers/openrouter/README.md | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 examples/providers/openrouter/README.md diff --git a/examples/providers/openrouter/README.md b/examples/providers/openrouter/README.md new file mode 100644 index 0000000..20efcf1 --- /dev/null +++ b/examples/providers/openrouter/README.md @@ -0,0 +1,34 @@ +# OpenRouter Examples + +Requirements: + +- `OPENROUTER_API_KEY` set in your environment or `.env` + +Notes: + +- Datafast suppresses LiteLLM's provider help banner by default for cleaner example + output. +- Set `DATAFAST_LITELLM_SUPPRESS_DEBUG_INFO=0` if you want LiteLLM to print that + extra provider/debug information while troubleshooting. + +Run: + +```bash +.venv/bin/python examples/providers/openrouter/01_simple_prompt.py +.venv/bin/python examples/providers/openrouter/02_batch_prompts.py +.venv/bin/python examples/providers/openrouter/03_messages_with_system_prompt.py +.venv/bin/python examples/providers/openrouter/04_structured_output.py +.venv/bin/python examples/providers/openrouter/05_batch_messages.py +.venv/bin/python examples/providers/openrouter/06_generation_metadata.py +.venv/bin/python examples/providers/openrouter/08_structured_batch.py +``` + +Files: + +- `01_simple_prompt.py`: one prompt, one response +- `02_batch_prompts.py`: a list of prompts sent through one `generate(...)` call +- `03_messages_with_system_prompt.py`: chat messages with a system instruction +- `04_structured_output.py`: validated Pydantic output +- `05_batch_messages.py`: a batch of independent message lists +- `06_generation_metadata.py`: `generate_response(...)` and normalized metadata +- `08_structured_batch.py`: batched structured responses From d7af6f38ed35f16e3a3f0db7e20bae95cf925da8 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 2 Jul 2026 17:15:37 +0200 Subject: [PATCH 34/54] Clarified settings for DATAFAST_LITELLM_SUPPRESS_DEBUG_INFO --- docs/llms.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/llms.md b/docs/llms.md index 8f5e448..bf761e8 100644 --- a/docs/llms.md +++ b/docs/llms.md @@ -50,9 +50,16 @@ pipeline = ( - `MISTRAL_API_KEY` - `OPENROUTER_API_KEY` - `OLLAMA_API_BASE` +- `DATAFAST_LITELLM_SUPPRESS_DEBUG_INFO` Ollama typically does not require an API key and instead uses the local API base. +`DATAFAST_LITELLM_SUPPRESS_DEBUG_INFO` defaults to enabled. Datafast sets +LiteLLM's `suppress_debug_info` flag when a provider is created so example runs do +not print LiteLLM provider help text such as the OpenRouter provider list banner. +Set `DATAFAST_LITELLM_SUPPRESS_DEBUG_INFO=0` if you want LiteLLM's debug/help +output back while troubleshooting. + ## Optional Langfuse Tracing Install the optional extra: From c78f750faf3975474787664c286c6d06ef7f4fea Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 2 Jul 2026 17:15:51 +0200 Subject: [PATCH 35/54] OpenRouter example returning normalized response metadata --- .../openrouter/06_generation_metadata.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 examples/providers/openrouter/06_generation_metadata.py diff --git a/examples/providers/openrouter/06_generation_metadata.py b/examples/providers/openrouter/06_generation_metadata.py new file mode 100644 index 0000000..6481aa0 --- /dev/null +++ b/examples/providers/openrouter/06_generation_metadata.py @@ -0,0 +1,60 @@ +"""OpenRouter example returning normalized response metadata.""" + +from dotenv import load_dotenv + +from datafast import openrouter + + +# MODEL_ID = "openai/gpt-5.4-mini" +MODEL_ID = "google/gemma-4-31b-it:nitro" +PROMPT = ( + "A train travels 60 miles per hour for 2.5 hours. " + "Work it out carefully, then give the final answer in one short sentence." +) + + +def main() -> None: + load_dotenv() + + model = openrouter( + MODEL_ID, + temperature=0.7, + provider_params={ + "extra_body": { + "reasoning": { + "effort": "high", + "exclude": False, + } + } + }, + ) + response = model.generate_response(prompt=PROMPT) + usage = getattr(response.raw, "usage", None) + completion_details = getattr(usage, "completion_tokens_details", None) + reasoning_tokens = ( + getattr(completion_details, "reasoning_tokens", None) + if completion_details is not None + else None + ) + + print("Text") + print("----") + print(response.text.strip()) + print() + print("Metadata") + print("--------") + print(f"reasoning_content: {bool(response.reasoning_content)}") + print(f"reasoning_tokens: {reasoning_tokens}") + print(f"thinking_blocks: {len(response.thinking_blocks)}") + print(f"images: {len(response.images)}") + print(f"audio: {bool(response.audio)}") + print(f"output_items: {len(response.output_items)}") + if response.reasoning_content: + print() + print("Reasoning") + print("---------") + print(response.reasoning_content) + + +if __name__ == "__main__": + main() From 9c1dbf5bc3aac03520853a54e30de30e7405ac1b Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Thu, 2 Jul 2026 17:16:08 +0200 Subject: [PATCH 36/54] OpenRouter example with batched structured responses --- .../openrouter/07_structured_batch.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 examples/providers/openrouter/07_structured_batch.py diff --git a/examples/providers/openrouter/07_structured_batch.py b/examples/providers/openrouter/07_structured_batch.py new file mode 100644 index 0000000..3d01e00 --- /dev/null +++ b/examples/providers/openrouter/07_structured_batch.py @@ -0,0 +1,36 @@ +"""OpenRouter example with batched structured responses.""" + +from dotenv import load_dotenv +from pydantic import BaseModel + +from datafast import openrouter + + +# MODEL_ID = "openai/gpt-5.4-mini" +MODEL_ID = "google/gemma-4-26b-a4b-it:nitro" +PROMPTS = [ + "Return JSON for Python with fields language, category, and one_sentence_use_case.", + "Return JSON for Rust with fields language, category, and one_sentence_use_case.", + "Return JSON for SQL with fields language, category, and one_sentence_use_case.", +] + + +class LanguageCard(BaseModel): + language: str + category: str + one_sentence_use_case: str + + +def main() -> None: + load_dotenv() + + model = openrouter(MODEL_ID, temperature=0) + responses = model.generate(prompt=PROMPTS, response_format=LanguageCard) + + for response in responses: + print(response.model_dump_json(indent=2)) + print() + + +if __name__ == "__main__": + main() From 3caa0dd36cb839cb25646ec55cb0a84d151cb5c8 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Fri, 3 Jul 2026 00:38:52 +0200 Subject: [PATCH 37/54] example llm script for unsupported params --- .../08_unsupported_params_policies.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 examples/providers/openrouter/08_unsupported_params_policies.py diff --git a/examples/providers/openrouter/08_unsupported_params_policies.py b/examples/providers/openrouter/08_unsupported_params_policies.py new file mode 100644 index 0000000..6a6d0b5 --- /dev/null +++ b/examples/providers/openrouter/08_unsupported_params_policies.py @@ -0,0 +1,51 @@ +"""OpenRouter example showing unsupported parameter policies.""" + +from __future__ import annotations + +import warnings + +from dotenv import load_dotenv + +from datafast import openrouter + + +MODEL_ID = "openai/gpt-5-mini" +PROMPT = "Explain OpenRouter in one short sentence." +REASONING_EFFORT = "high" + + +def run_case(policy: str) -> None: + model = openrouter( + MODEL_ID, + temperature=0, + reasoning_effort=REASONING_EFFORT, + unsupported_params=policy, + ) + + print(f"Policy: {policy}") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + try: + response = model.generate(prompt=PROMPT) + except ValueError as exc: + print(f"status: error") + print(f"detail: {exc}") + else: + print("status: ok") + print(f"text: {response.strip()}") + + print(f"warnings: {len(caught)}") + for warning in caught: + print(f"- {warning.message}") + print() + + +def main() -> None: + load_dotenv() + + for policy in ("warn", "quiet", "fail"): + run_case(policy) + + +if __name__ == "__main__": + main() From 7c3a37d0a74ccd69107fa75b9839d906871b320e Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Fri, 3 Jul 2026 00:39:05 +0200 Subject: [PATCH 38/54] example llm with multimodal input --- .../openrouter/09_multimodal_image_input.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 examples/providers/openrouter/09_multimodal_image_input.py diff --git a/examples/providers/openrouter/09_multimodal_image_input.py b/examples/providers/openrouter/09_multimodal_image_input.py new file mode 100644 index 0000000..ed1d3c3 --- /dev/null +++ b/examples/providers/openrouter/09_multimodal_image_input.py @@ -0,0 +1,41 @@ +"""OpenRouter example with text plus image input.""" + +from dotenv import load_dotenv + +from datafast import openrouter +from datafast.llm import ContentPart + + +# Swap this for any OpenRouter model on your account that supports image input. +MODEL_ID = "openai/gpt-5-mini" +IMAGE_URL = ( + "https://upload.wikimedia.org/wikipedia/commons/4/40/Portrait_of_a_father.jpg" +) +MESSAGES = [ + { + "role": "user", + "content": [ + ContentPart( + type="text", + text="Describe this image in two short bullet points.", + ), + ContentPart( + type="image", + url=IMAGE_URL, + media_id="boardwalk-demo-image", + ), + ], + } +] + + +def main() -> None: + load_dotenv() + + model = openrouter(MODEL_ID, temperature=0) + response = model.generate(messages=MESSAGES) + print(response.strip()) + + +if __name__ == "__main__": + main() From a992de94b84ed3b78b2fcc65de07c3c1dc98f675 Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Fri, 3 Jul 2026 00:39:15 +0200 Subject: [PATCH 39/54] raw vs normalized inputs --- .../10_raw_vs_normalized_response.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 examples/providers/openrouter/10_raw_vs_normalized_response.py diff --git a/examples/providers/openrouter/10_raw_vs_normalized_response.py b/examples/providers/openrouter/10_raw_vs_normalized_response.py new file mode 100644 index 0000000..1d8a490 --- /dev/null +++ b/examples/providers/openrouter/10_raw_vs_normalized_response.py @@ -0,0 +1,94 @@ +"""OpenRouter example comparing normalized fields with raw payload fields.""" + +from __future__ import annotations + +from dotenv import load_dotenv + +from datafast import openrouter + + +MODEL_ID = "google/gemma-4-31b-it:nitro" +PROMPT = ( + "A train travels 60 miles per hour for 2.5 hours. " + "Work it out carefully, then give the final answer in one short sentence." +) + + +def _get_attr_or_key(value, name: str): + if value is None: + return None + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _first_choice_message(raw_response): + choices = _get_attr_or_key(raw_response, "choices") or [] + if not choices: + return None + return _get_attr_or_key(choices[0], "message") + + +def main() -> None: + load_dotenv() + + model = openrouter( + MODEL_ID, + temperature=0, + provider_params={ + "extra_body": { + "reasoning": { + "effort": "high", + "exclude": False, + } + } + }, + ) + response = model.generate_response(prompt=PROMPT) + + usage = getattr(response.raw, "usage", None) + message = _first_choice_message(response.raw) + choices = getattr(response.raw, "choices", None) + output = getattr(response.raw, "output", None) + completion_details = getattr(usage, "completion_tokens_details", None) + reasoning_tokens = ( + getattr(completion_details, "reasoning_tokens", None) + if completion_details is not None + else None + ) + raw_text = _get_attr_or_key(message, "content") + raw_reasoning = _get_attr_or_key(message, "reasoning_content") + + print("Comparison") + print("----------") + print(f"normalized.text: {response.text.strip()!r}") + print(f"raw choices[0].message.content: {raw_text!r}") + print(f"reasoning_content: {bool(response.reasoning_content)}") + print(f"raw reasoning_content: {bool(raw_reasoning)}") + print(f"reasoning_tokens: {reasoning_tokens}") + print(f"output_items: {len(response.output_items)}") + print(f"raw output present: {output is not None}") + if response.reasoning_content: + print() + print("Normalized reasoning") + print("--------------------") + print(response.reasoning_content) + if raw_reasoning: + print() + print("Raw reasoning") + print("-------------") + print(raw_reasoning) + print() + print("Raw") + print("---") + print(f"raw_type: {type(response.raw).__name__}") + print(f"has_usage: {usage is not None}") + print(f"has_choices: {choices is not None}") + print(f"has_output: {output is not None}") + if usage is not None: + print(f"prompt_tokens: {getattr(usage, 'prompt_tokens', None)}") + print(f"completion_tokens: {getattr(usage, 'completion_tokens', None)}") + + +if __name__ == "__main__": + main() From 745567890e4f7a4360cd4fa431741479ba8db86e Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Fri, 3 Jul 2026 00:39:39 +0200 Subject: [PATCH 40/54] example provider script for timeout and RPM --- .../openrouter/11_timeout_and_rate_limit.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 examples/providers/openrouter/11_timeout_and_rate_limit.py diff --git a/examples/providers/openrouter/11_timeout_and_rate_limit.py b/examples/providers/openrouter/11_timeout_and_rate_limit.py new file mode 100644 index 0000000..5f01054 --- /dev/null +++ b/examples/providers/openrouter/11_timeout_and_rate_limit.py @@ -0,0 +1,73 @@ +"""OpenRouter example showing timeout and rpm_limit across multiple requests.""" + +import time + +from dotenv import load_dotenv + +from datafast import openrouter + + +MODEL_ID = "google/gemma-4-31b-it:nitro" +TIMEOUT_SECONDS = 30 +RPM_LIMIT = 2 +PROMPTS = [ + "Reply with exactly: request one acknowledged.", + "Reply with exactly: request two acknowledged.", + "Reply with exactly: request three acknowledged.", +] + + +def main() -> None: + load_dotenv() + + model = openrouter( + MODEL_ID, + temperature=0, + timeout=TIMEOUT_SECONDS, + rpm_limit=RPM_LIMIT, + ) + + print("Config") + print("------") + print(f"model: {MODEL_ID}") + print(f"timeout: {TIMEOUT_SECONDS}s") + print(f"rpm_limit: {RPM_LIMIT}") + print() + print( + "This script sends three separate requests through one provider instance." + ) + print( + "With rpm_limit=2, the third request should pause for roughly one minute " + "before Datafast sends it." + ) + print() + + started = time.monotonic() + for index, prompt in enumerate(PROMPTS, start=1): + request_started = time.monotonic() + response = model.generate(prompt=prompt) + request_elapsed = time.monotonic() - request_started + + print(f"Request {index}") + print(f"prompt: {prompt}") + print(f"response: {response}") + print(f"call_elapsed_seconds: {request_elapsed:.2f}") + print() + + elapsed = time.monotonic() - started + + print("Notes") + print("-----") + print(f"total_elapsed_seconds: {elapsed:.2f}") + print( + "Datafast forwards timeout on each request and enforces rpm_limit on the " + "provider instance before the next request is sent." + ) + print( + "Requests 1 and 2 should complete normally. Request 3 should be the one " + "that clearly shows client-side throttling." + ) + + +if __name__ == "__main__": + main() From c97bbae331ba458a40951862dfecc04558a71fcc Mon Sep 17 00:00:00 2001 From: Patrick Fleith Date: Fri, 3 Jul 2026 00:45:31 +0200 Subject: [PATCH 41/54] README updated --- examples/providers/openrouter/README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/providers/openrouter/README.md b/examples/providers/openrouter/README.md index 20efcf1..e31d8f2 100644 --- a/examples/providers/openrouter/README.md +++ b/examples/providers/openrouter/README.md @@ -20,7 +20,11 @@ Run: .venv/bin/python examples/providers/openrouter/04_structured_output.py .venv/bin/python examples/providers/openrouter/05_batch_messages.py .venv/bin/python examples/providers/openrouter/06_generation_metadata.py -.venv/bin/python examples/providers/openrouter/08_structured_batch.py +.venv/bin/python examples/providers/openrouter/07_structured_batch.py +.venv/bin/python examples/providers/openrouter/08_unsupported_params_policies.py +.venv/bin/python examples/providers/openrouter/09_multimodal_image_input.py +.venv/bin/python examples/providers/openrouter/10_raw_vs_normalized_response.py +.venv/bin/python examples/providers/openrouter/11_timeout_and_rate_limit.py ``` Files: @@ -31,4 +35,8 @@ Files: - `04_structured_output.py`: validated Pydantic output - `05_batch_messages.py`: a batch of independent message lists - `06_generation_metadata.py`: `generate_response(...)` and normalized metadata -- `08_structured_batch.py`: batched structured responses +- `07_structured_batch.py`: batched structured responses +- `08_unsupported_params_policies.py`: `warn`, `quiet`, and `fail` handling for an unsupported parameter +- `09_multimodal_image_input.py`: text plus image input using `ContentPart` +- `10_raw_vs_normalized_response.py`: compare normalized fields with the underlying raw payload fields +- `11_timeout_and_rate_limit.py`: three separate requests through one provider instance, with `rpm_limit=2` so the third request shows client-side throttling From f4d293b55508d79580c37161fd4ec54e3dbb6ea2 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 13 Jul 2026 19:00:06 +0200 Subject: [PATCH 42/54] chore: add agent workflow instructions and skills - AGENTS.md/CLAUDE.md describe how agents work in this repo - add commit, decide, glossary, log, and write-manual skills --- .claude/skills/commit/SKILL.md | 84 ++++++++++++++++++++++++++++ .claude/skills/decide/SKILL.md | 80 ++++++++++++++++++++++++++ .claude/skills/glossary/SKILL.md | 43 ++++++++++++++ .claude/skills/log/SKILL.md | 67 ++++++++++++++++++++++ .claude/skills/write-manual/SKILL.md | 80 ++++++++++++++++++++++++++ AGENTS.md | 42 ++++++++++++++ CLAUDE.md | 1 + 7 files changed, 397 insertions(+) create mode 100644 .claude/skills/commit/SKILL.md create mode 100644 .claude/skills/decide/SKILL.md create mode 100644 .claude/skills/glossary/SKILL.md create mode 100644 .claude/skills/log/SKILL.md create mode 100644 .claude/skills/write-manual/SKILL.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md new file mode 100644 index 0000000..c3f2921 --- /dev/null +++ b/.claude/skills/commit/SKILL.md @@ -0,0 +1,84 @@ +--- +name: commit +description: Turn uncommitted work into a logically ordered sequence of atomic conventional commits. Surveys the whole working tree, groups changes, orders them foundation-first, shows a plan, then commits by path. Use when the user says "commit this", "commit my work", "split this into commits", or "clean up my changes into commits" +disable-model-invocation: true +--- + +# commit + +Take the current uncommitted work and commit it as a clean, ordered sequence of atomic commits — each self-consistent, each a conventional-commit. + +## 1. Survey + +Run these to see everything (not just what's staged): + +```bash +git status --short +git diff # unstaged +git diff --staged # staged +``` + +Read the actual changes. List untracked files too (`git status --short` shows them as `??`). + +If the user named a subset to commit (specific files, or "just the auth work"), scope everything below to that subset and leave the rest untouched. Otherwise consider the whole tree. + +## 2. Group into atomic commits + +Cluster the changes into the smallest units that each stand on their own (one logical change per commit). Split unrelated work apart; keep a change and its direct test/doc together. + +**Flag unfinished work.** While reading the diff, watch for changes that look incomplete or not meant to ship — e.g. `TODO`/`FIXME`/`XXX` added, commented-out or debug code (`print`, `console.log`, `dbg!`), stubs / empty function bodies / `pass` / `NotImplementedError`, half-edited files, failing or `.skip`ped tests, or a reference with no definition. List each one and **ask** whether to include it, hold it back (leave unstaged), or fix it first. Don't silently commit it. + +## 3. Order foundation-first + +Sequence commits so each builds on the last: + +1. Dependencies / config / build setup +2. Shared utilities, types, refactors that others depend on +3. Features / fixes that use them +4. Tests +5. Docs + +## 4. Show the plan, wait for approval + +Print the proposed sequence and stop: + +``` +1. feat(auth): add password reset endpoint + files: src/auth/reset.ts, src/routes.ts +2. test(auth): cover password reset flow + files: test/auth/reset.test.ts +``` + +Do not create any commit until the user approves. Adjust on feedback. + +## 5. Commit sequentially + +For each commit, stage that group **by path** then commit: + +```bash +git add +git commit -m "(): " -m "- detail" -m "- detail" +``` + +- Stage by explicit paths only. Do **not** use `git add -p`/`git add -i` (interactive, unsupported here); if two logically separate changes live in the same file and can't be split by path, note it in the plan and commit them together. +- After each commit, verify with `git status` that the right files landed. + +## Commit format + +``` +(): +``` +Add a bullet body (`-m` per bullet) only when the change has multiple parts. + +Types: `feat` (new feature), `fix` (bug fix), `refactor`, `perf`, `test`, `docs`, `build`, `ci`, `chore`. Scope is optional, lowercase, the affected area. Description: imperative, lowercase, no trailing period. + +## Rules + +- **Plan before committing.** Never commit without showing the sequence first. +- **Subset is fine.** Commit only the changes the user asked for; leave everything else exactly as it was. +- **Never commit unfinished work silently.** If something looks incomplete, ask first. +- **Clean messages.** No agent attribution footer — write them as a human would. +- **Never push.** Only `git push` if the user explicitly asks. +- **Don't commit junk.** If a file looks like a secret, credential, or large build artifact, leave it unstaged and flag it. +- **Branch safety.** If on the default branch (`main`/`master`), say so and offer to create a branch before committing. +- **Respect hooks.** If a pre-commit hook rejects or rewrites a commit, stop and report — don't loop or bypass with `--no-verify` unless asked. diff --git a/.claude/skills/decide/SKILL.md b/.claude/skills/decide/SKILL.md new file mode 100644 index 0000000..af0930f --- /dev/null +++ b/.claude/skills/decide/SKILL.md @@ -0,0 +1,80 @@ +--- +name: decide +description: Record decisions in docs-agents/DECISIONS.md — add a TBD (open question), log a decided decision, or revise/revoke an existing one. Use when the user says "decide", "we decided", "log a decision", "that's TBD", or wants to change/revoke a past decision +disable-model-invocation: true +--- + +# decide + +Maintains `docs-agents/DECISIONS.md`. Keep entries terse. + +## Locate the file + +Edit `docs-agents/DECISIONS.md` in the current project. If it doesn't exist, create it with this structure: + +```markdown +# Decisions + +## TBD + +- **** — options/context in one line. + +## Decided + +### DEC-001 — title + +Decided by: Author | Author with Claude Code | Claude Code +Date: YYYY-MM-DD HH:MM + +**Decision:** 1–2 sentences (what was decided). + +**Rationale:** 1–2 sentences (why). +``` + +## Modes + +**1. Add a TBD** (open question requiring a decision to move forward, not yet decided). +Not to be confused with an open question from `docs-agents/QUESTIONS.md` which do not related to decision. +Append to the `## TBD` list: +``` +- **** — options/context in one line. +``` + +**2. Log a decided decision** +- Find the highest `DEC-NNN` in `## Decided`; new id = highest + 1, zero-padded to 3 digits. +- Prepend the new entry to the top of `## Decided` (newest first). +- Get the timestamp by running `date "+%Y-%m-%d %H:%M"`. +- If it resolves a `## TBD` item, remove that TBD line. +``` +### DEC-NNN — + +Decided by: Author | Author with Agent | Agent +Date: YYYY-MM-DD HH:MM + +**Decision:** 1–2 sentences (what was decided). + +**Rationale:** 1–2 sentences (why). +``` +Set "Decided by" to who actually decided (ask if unclear; default `Author with Agent`). +`Author` means the person using the skill. +`Agent` means the coding agent harness (Claude Code / Codex). + +**3. Revise a decision** +Locate the `DEC-NNN`. Do NOT rewrite history silently — append a revision note under the existing entry: +``` +**Revised (YYYY-MM-DD HH:MM):** what changed and why. Supersedes original decision above. +``` +Then move the whole entry to the top of `## Decided`. Keep its original id — the number is a stable reference, not a sort key, so recently-touched decisions surface first even if ids end up out of numeric order. + +**4. Revoke a decision** +Prefix the title with `~~REVOKED~~` and append: +``` +**Revoked (YYYY-MM-DD HH:MM):** reason. No longer in effect. +``` +Then move the whole entry to the top of `## Decided` (same rule as revise) — a revocation is important activity, so surface the "no longer in effect" warning rather than leaving it buried. + +## Rules + +- Never renumber existing DEC ids. +- Always stamp real timestamps via `date`, never guess. +- Keep it skimmable — 1–2 sentences per field. diff --git a/.claude/skills/glossary/SKILL.md b/.claude/skills/glossary/SKILL.md new file mode 100644 index 0000000..52646f4 --- /dev/null +++ b/.claude/skills/glossary/SKILL.md @@ -0,0 +1,43 @@ +--- +name: glossary +description: Manage docs-agents/GLOSSARY.md — add or refine a canonical term, extract terms from the current conversation, or audit for conflicts and duplicates. Use when the user says "define this term", "add to the glossary", "build a glossary", "what do we call this", or wants to harden terminology +disable-model-invocation: true +--- + +# glossary + +Maintains `docs-agents/GLOSSARY.md` — the single source of truth for what terms mean in this project. Reference doc: opinionated, tight, skimmable. No implementation details or code. + +## Locate the file + +Edit `docs-agents/GLOSSARY.md`. If it doesn't exist, create it with this structure: + +```markdown +# Glossary + +**<Term>** — one or two sentence definition in this project's context. _avoid:_ <synonyms to not use> +``` + +Format per term: a bold term, an em-dash, one or two sentences saying what it **is** (not what it does), and an optional `_avoid:_` list of synonyms to kill. One term per line. Keep alphabetical, or group under `##` subheadings only when natural clusters clearly emerge. + +## Modes + +**1. Add a term** +Append (or insert alphabetically) one line in the format above. Pick the best word as canonical; list rejected synonyms under `_avoid:_`. + +**2. Refine a term** +Locate the existing line and tighten its definition or update its `_avoid:_` list in place. Don't duplicate the term. + +**3. Extract from this conversation** +Scan the discussion for project/domain-specific nouns and concepts. For each: pick a canonical term, write a one or two sentence definition, and note synonyms actually used in the conversation under `_avoid:_`. Add all new terms; refine any already present. + +**4. Audit** +Read the whole file and report (inline, to the user): duplicate/near-duplicate terms, definitions longer than two sentences, generic programming terms that don't belong, and any term used two ways. Propose fixes; apply on confirmation. + +## Rules + +- **Be opinionated.** One canonical term per concept; everything else goes under `_avoid:_`. +- **One or two sentences, define what it IS.** Cut anything longer. +- **Project-specific only.** Skip generic programming concepts (array, endpoint, timeout) unless they carry special meaning here. +- **Flag conflicts, don't silently overwrite.** If a new term clashes with an existing definition, surface it to the user and let them resolve it. +- **No implementation details.** This is a glossary, not a spec or scratchpad. diff --git a/.claude/skills/log/SKILL.md b/.claude/skills/log/SKILL.md new file mode 100644 index 0000000..15ca1fe --- /dev/null +++ b/.claude/skills/log/SKILL.md @@ -0,0 +1,67 @@ +--- +name: log +description: Quickly add one entry to the right doc — a task, idea, stack choice, concern, or open question — in the correct format. Triggered by "log"/"note"/"capture"/"add"/"jot down" + the kind, e.g. "log this task", "note this idea down", "capture a concern", "add TODO", "we're using X for Y — put it in the stack", "open question:". Addition only. +disable-model-invocation: true +--- + +# log + +Append a single new entry to the correct doc under `docs-agents/`, in that doc's format. This skill only **adds** — it never edits, reorders, or removes existing entries. Decisions and glossary terms are out of scope (use `decide` / `glossary`). + +## Route by what the user is logging + +Match on the **kind** the user names, however they phrase it (log / note / capture / add / jot down / write down / remember, or just naming the thing): + +| Kind & example phrasings | File | What to do | +| ------------------------------------------------------------------------------ | -------------------------- | ------------------------------------- | +| **task** — "log this task", "add a TODO", "remind me to…", "we still need to…" | `docs-agents/TASKS.md` | checkbox item at top of **To do** | +| **idea** — "log this idea", "note this down", "what if we…", "idea:" | `docs-agents/IDEAS.md` | new `IDEA-NNN` at the top | +| **stack** — "we're using X for Y", "put X in the stack", "we went with X" | `docs-agents/STACK.md` | add a `- **Category:** choice` bullet | +| **concern** — "log this concern", "I'm worried that…", "flag that…", "risk:" | `docs-agents/CONCERNS.md` | new `CON-NNN` at the top | +| **question** — "log this question", "open question:", "we need to figure out…" | `docs-agents/QUESTIONS.md` | new `Q-NNN` at the top | + +**Newest goes on top.** Insert each new entry above the existing ones, not at the bottom. (Stack is the exception — it's grouped by category, not by recency.) + +If the target file doesn't exist, create it with just its `# Heading` before adding the entry. For ID'd docs, next id = highest existing NNN + 1, zero-padded to 3. + +## Formats + +**Task** — `docs-agents/TASKS.md`, insert at the top of the `## To do` section: +``` +- [ ] <task> <!-- optional (context) --> +``` +The file is organized `## To do` / `## Done`. Create those sections if missing; leave completed items alone (they belong under `## Done`). + +**Idea** — `docs-agents/IDEAS.md`, insert at top of the list (newest first): +``` +## IDEA-NNN — <one-line title> + +Brief note (10–50 words), or bullets. +``` + +**Stack** — `docs-agents/STACK.md`, add a bullet in its logical spot (grouped by category, parentheses only if extra detail helps): +``` +- **<Category>:** <choice> (<detail>) +``` +If that category already exists with a different value, don't overwrite — flag the conflict and ask. + +**Concern** — `docs-agents/CONCERNS.md`, insert at top (newest first): +``` +## CON-NNN — <one-line title> + +What it is and why it matters (10–50 words). Ref: <url, if any> +``` + +**Question** — `docs-agents/QUESTIONS.md`, insert at top (newest first): +``` +## Q-NNN — <the question> — `open` + +**Answer:** <fill once answered; set status to `answered`> +``` + +## Rules + +- **One entry per invocation**, inserted at the top — no rewriting the rest of the file. +- **Keep it tight.** Fit the format; don't pad. Rephrase the user's note only enough to be clear. +- **Never guess an id** — read the file, find the highest, increment. +- **Ambiguous target?** If it's unclear which doc the user means, ask before writing. diff --git a/.claude/skills/write-manual/SKILL.md b/.claude/skills/write-manual/SKILL.md new file mode 100644 index 0000000..73476b1 --- /dev/null +++ b/.claude/skills/write-manual/SKILL.md @@ -0,0 +1,80 @@ +--- +name: write-manual +description: Write or update docs-agents/SUM.md — the Software User Manual — from the codebase. Mode 1 (generate) builds a full, detailed SUM from scratch by inventorying every user-facing surface so nothing is missed. Mode 2 (update) revises the SUM against a PR/diff, removing what's obsolete, editing what changed, and adding what's new — precisely and concisely. Triggered by "write the manual", "generate the user manual", "document how to use this", or "update the manual for this PR". +disable-model-invocation: true +--- + +# write-manual + +Maintains `docs-agents/SUM.md` — the end-user manual: how to install and use the software correctly. Follows the template in `templates/docs-agents/SUM.md`: a **Contents** map up top, then 9 sections (§6–8 optional). This is a **user** manual, not a design doc — no internals, architecture, or rationale. + +## The Contents block + +A SUM is long, so `docs-agents/SUM.md` opens with a `## Contents` map — the first thing an AI tool (Claude Code, Codex) or human reads to jump to the right passage. Build it from the actual headings (never guess), and **rebuild it after every change so it always matches the document** (see Mode 1 Step C and Mode 2 step 4). + +- One entry per `##` section, and one **nested** sub-entry per `### Feature: <name>` subsection under §5, in document order. +- Each entry is a heading anchor link + ` — ` + a one-line "what's here". For a feature, the one-liner is what the feature does. +- Anchor = heading text lowercased, punctuation dropped, spaces → hyphens. E.g. `## 6. Configuration & Security` → `#6-configuration--security`; `### Feature: Export data` → `#feature-export-data`. +- List only sections/features that actually exist — omit entries for dropped optional sections (§6–8). Never list the Contents block itself. + +## Before writing (both modes) + +1. **Read the template** `templates/docs-agents/SUM.md` for the target structure. +2. **Read `docs-agents/GLOSSARY.md`** — use its canonical terms verbatim; never invent a synonym for something the glossary already names. +3. **Skim sibling docs for context, not for copying:** `docs-agents/PRD.md` (what & who it's for), `docs-agents/features/*` (feature intent), `README.md`, `docs-agents/CHANGELOG.md`. The SUM describes *how to use*, the PRD/FRD describe *what & why* — don't leak the latter in. + +## Mode 1 — Generate a full SUM from the codebase + +Goal: a complete manual with **no blind spots**. Blind spots come from documenting only the obvious entry point and missing the rest. Prevent that by **inventorying every user-facing surface first**, then writing. + +**Step A — Inventory (do this before writing a word).** Sweep the codebase and list every surface a user touches: + +| Surface | Where to look | +| --- | --- | +| Install / build | `package.json` scripts, `pyproject.toml`, `Dockerfile`, `Makefile`, install docs | +| Entry points | `bin/`, `main`, `cmd/`, `__main__`, CLI framework registrations | +| Commands / subcommands | arg-parser definitions (argparse/click/commander/cobra…) | +| API / endpoints | route definitions, OpenAPI/proto specs, exported public functions | +| Config & env vars | config schema, `.env.example`, settings files, defaults | +| Required files / services / credentials | DB, external APIs, secrets, tokens the software needs to run | +| Features | `docs-agents/features/*` and the code that implements them | +| Error/diagnostic messages | raised errors, exit codes, log strings a user would see | +| Prerequisites & platforms | supported OS/runtime, version constraints, dependencies | + +Reconcile the inventory against the glossary and features docs. **Report the inventory to the user before drafting** if the surface is large — cheap way to catch a missing area early. + +**Step B — Write.** Fill the template top to bottom. Map each inventory item to its section: +- Prereqs/platforms/required services → **§3 Requirements** +- Install + config + a verify-it-worked check → **§4 Installation** +- Every command/endpoint/feature → **§5 Usage** (one `### Feature: <name>` subsection each: what it does, how to use, example, options) +- Config keys, secrets/access, secure setup → **§6 Configuration & Security** +- Common errors → cause → fix, logs, recovery → **§7 Troubleshooting** +- Full command/config/env/error tables → **§8 Reference** + +Every user-facing surface from Step A lands in exactly one section, or is deliberately dropped with a reason. Drop §6–8 only if genuinely empty. + +**Step C — Build the Contents block.** Once the body is final, generate the `## Contents` map from the headings you actually wrote (per "The Contents block" above). This is the last step so the map matches the finished document. + +## Mode 2 — Update the SUM for a PR + +Goal: bring the SUM back in sync with reality **precisely and concisely** — touch only what the change touched. This is a surgical edit, not a rewrite. + +1. **Get the diff.** `git diff <base>...HEAD` (or the PR range). Identify which user-facing surfaces changed — reuse the Step A inventory lenses, but scoped to the diff. +2. **Classify each SUM impact and act:** + - **Remove** — a feature/command/flag/option the diff deleted → cut its lines (and its `### Feature:` subsection, reference row, error entry). + - **Modify** — behavior, syntax, defaults, or steps changed → edit that passage in place; keep glossary terms. + - **Add** — a new user-facing surface with no coverage → add the minimal section/`### Feature:` subsection/row needed. +3. **Leave untouched** anything the diff didn't affect. No reflowing, reordering, or "while I'm here" cleanups. +4. **Refresh the Contents block** — mandatory, not a "while I'm here" cleanup. If you added, removed, or renamed a section or `### Feature:` subsection, add/cut/rename its Contents entry; if a feature's purpose changed, update its one-liner. If no heading changed, leave Contents as-is. +5. **Report** a short changelist to the user: what you removed / modified / added and why (note any Contents change). + +## Rules + +- **User-facing only.** How to install, configure, and use it. No architecture, no design rationale, no code internals. +- **No blind spots (Mode 1):** inventory before drafting; every surface is covered or consciously dropped. +- **Surgical (Mode 2):** change only what the diff changed; don't rewrite stable sections. +- **Contents never goes stale.** After any edit, the `## Contents` map must match the document's headings exactly (right entries, right order, working anchors, one feature per `### Feature:` subsection). Regenerate it — this is required even in surgical Mode 2. +- **Glossary is law.** Use canonical terms; if the code reveals a user-facing concept the glossary is missing, flag it (suggest running `glossary`), don't coin a term silently. +- **Concise.** Fit the template; every example should be copy-pasteable. Cut prose that doesn't help a user act. +- **Verify examples exist.** Don't invent commands, flags, or endpoints — cite ones that are actually in the code. +- **Omit empty optional sections.** §6–8 carry `*(optional)*`; delete rather than pad. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bc11c34 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# AGENTS.md + +Instructions for AI coding agents (Claude Code, Codex, and any tool that reads +`AGENTS.md`) working in this repo. Claude loads it via `@AGENTS.md` in +`CLAUDE.md`; other tools read this file directly. It is loaded every session — +this is the whole mechanism, so keep it compact and current. + +## How to work here + +- **What this is:** `datafast` — a pipeline-first Python library for synthetic data generation with LLMs, for developers and ML engineers building datasets. +- Before acting, consult the relevant project doc below. +- Keep changes minimal and in the style of the surrounding code. +- **Tests:** `pytest` is not on PATH — run via `.venv/bin/pytest`. Integration/live tests hit real providers; deselect with `-m "not integration and not live"`. + +## Project docs + +Project docs live in `docs-agents/`. Consult the relevant one before acting. If a doc +doesn't exist yet, don't fabricate one — create it with the skill noted below. + +| Doc | Path | What it captures | Skill | +|--------------|--------------------------|-----------------------------------------|----------------| +| PRD | docs-agents/PRD.md | Product intent, users, requirements. | edit | +| Roadmap | docs-agents/ROADMAP.md | Shipped / in progress / next / later. | edit | +| Changelog | docs-agents/CHANGELOG.md | Released changes, per version. | edit | +| Manual (SUM) | docs-agents/SUM.md | Install + usage guide for end users. | `write-manual` | +| Decisions | docs-agents/DECISIONS.md | Pending (TBD) and settled decisions. | `decide` | +| Glossary | docs-agents/GLOSSARY.md | Canonical term definitions. | `glossary` | +| Stack | docs-agents/STACK.md | Frameworks, libraries, tools. | `log` | +| Tasks | docs-agents/TASKS.md | Repo-level todo dump. | `log` | +| Ideas | docs-agents/IDEAS.md | Captured ideas / possibilities. | `log` | +| Concerns | docs-agents/CONCERNS.md | Risks and concerns to investigate. | `log` | +| Questions | docs-agents/QUESTIONS.md | Open questions + answers once resolved. | `log` | + +## Capabilities + +Skills are auto-discovered from `.claude/skills/`. Currently available: + +- `commit` — turn uncommitted work into atomic conventional commits. +- `log` — append an entry to STACK, TASKS, IDEAS, CONCERNS, or QUESTIONS. +- `decide` — record and manage decisions in DECISIONS. +- `glossary` — add and refine canonical terms in GLOSSARY. +- `write-manual` — write or update the SUM from the codebase. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From f5d3dc20ff5b659bebda6cc78c22dbfb3fd5412c Mon Sep 17 00:00:00 2001 From: Patrick <pfleith.pro@gmail.com> Date: Mon, 13 Jul 2026 19:00:14 +0200 Subject: [PATCH 43/54] docs: add agent-facing project docs - PRD, roadmap, changelog, decisions, stack, and tasks under docs-agents/ --- docs-agents/CHANGELOG.md | 13 +++++++++++++ docs-agents/DECISIONS.md | 27 +++++++++++++++++++++++++++ docs-agents/PRD.md | 21 +++++++++++++++++++++ docs-agents/ROADMAP.md | 23 +++++++++++++++++++++++ docs-agents/STACK.md | 17 +++++++++++++++++ docs-agents/TASKS.md | 18 ++++++++++++++++++ 6 files changed, 119 insertions(+) create mode 100644 docs-agents/CHANGELOG.md create mode 100644 docs-agents/DECISIONS.md create mode 100644 docs-agents/PRD.md create mode 100644 docs-agents/ROADMAP.md create mode 100644 docs-agents/STACK.md create mode 100644 docs-agents/TASKS.md diff --git a/docs-agents/CHANGELOG.md b/docs-agents/CHANGELOG.md new file mode 100644 index 0000000..afd9175 --- /dev/null +++ b/docs-agents/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## [Unreleased] + +### Added + +### Changed + +### Fixed + +## [0.1.0] — YYYY-MM-DD + +- Initial release. diff --git a/docs-agents/DECISIONS.md b/docs-agents/DECISIONS.md new file mode 100644 index 0000000..6c0b4d9 --- /dev/null +++ b/docs-agents/DECISIONS.md @@ -0,0 +1,27 @@ +# Decisions + +## TBD + +- **Shortname — <pending decision>** — options/context in one line. + +## Decided + + +### DEC-002 — More decisions + +Decided by: Claude Code +Date: 2026-10-14 12:56 + +**Decision**: We have decided to mae more deicions. + +**Rationale**: More is better. + + +### DEC-001 — title to be replaced + +Decided by: Author | Author with Claude Code | Claude Code +Date: YYYY-MM-DD HH:MM + +**Decision:** 1–2 sentences (what was decided). + +**Rationale:** 1–2 sentences (why). diff --git a/docs-agents/PRD.md b/docs-agents/PRD.md new file mode 100644 index 0000000..4b00769 --- /dev/null +++ b/docs-agents/PRD.md @@ -0,0 +1,21 @@ +# PRD — <Product/Project> + +## Intent & motivation + +What we're building and why, in a few lines. + +## Business context + +Why now; who benefits; how success is measured. + +## Users & needs + +- As a <user type>, I need <need> so that <outcome>. + +## High-level requirements + +- **R1:** <requirement> — touches <features/systems>. + +## System-level requirements + +Cross-cutting requirements that link features/components. diff --git a/docs-agents/ROADMAP.md b/docs-agents/ROADMAP.md new file mode 100644 index 0000000..83dd3ed --- /dev/null +++ b/docs-agents/ROADMAP.md @@ -0,0 +1,23 @@ +# Roadmap + +## Shipped + +- <feature> + +## In progress + +- <feature> + +## Next up + +- <feature> + +## Later / long term + +- <theme or feature> + +## Improvements & tech debt + +Non-feature work: rework, refactor, performance, cleanup. + +- <item> diff --git a/docs-agents/STACK.md b/docs-agents/STACK.md new file mode 100644 index 0000000..8835ad8 --- /dev/null +++ b/docs-agents/STACK.md @@ -0,0 +1,17 @@ +# Stack + +One line per choice; parentheses only if extra detail is needed. + +- **Language:** Python (≥3.10, tested through 3.13) +- **Packaging:** setuptools + wheel; published to PyPI as `datafast` +- **Dependency manager:** uv (`uv.lock`) +- **LLM calling:** LiteLLM (unified gateway); provider SDKs `openai`, `anthropic`, `google-generativeai`; `botocore` for AWS +- **Structured outputs:** Instructor + Pydantic +- **Data:** Hugging Face `datasets` +- **UI:** Gradio +- **Logging:** Loguru +- **LLM observability:** Langfuse (optional extra, via LiteLLM) +- **Config:** python-dotenv (`.env`) +- **Tests:** pytest (run via `.venv/bin/pytest`) +- **Lint/format:** Ruff (line length 88) +- **Docs:** MkDocs + Material theme diff --git a/docs-agents/TASKS.md b/docs-agents/TASKS.md new file mode 100644 index 0000000..9e4821f --- /dev/null +++ b/docs-agents/TASKS.md @@ -0,0 +1,18 @@ +# Tasks + +<!-- Newest to do on top. Check off in place; move to Done when complete. --> + +## To do + +- [ ] Make sure the roadmap clearly outlines the release of the new version of datafast +- [ ] Make a roadmap +- [ ] Review the documentation and identify missing blocks before publishing the new version of datafast +- [ ] <task> <!-- optional (context) --> + +## Done + +- [X] Try out the OpenAI example scripts <!-- examples/providers/openai --> +- [X] Try out the Mistral example scripts <!-- examples/providers/mistral --> +- [X] Try out the Anthropic example scripts <!-- examples/providers/anthropic --> +- [X] Try out the Ollama example script <!-- examples/providers/ollama --> + From 40a1fa92c05331a533600dde36bace486e716486 Mon Sep 17 00:00:00 2001 From: Patrick <pfleith.pro@gmail.com> Date: Mon, 13 Jul 2026 19:00:24 +0200 Subject: [PATCH 44/54] feat(llm): add reasoning and multimodal provider support - add reasoning profiles for Mistral (magistral) and Ollama thinking models, mapping reasoning_effort with an allowlist escape hatch where LiteLLM needs it - add per-provider capability resolvers and modality metadata (Gemini audio/video, Ollama image) - convert chat messages to Responses API input items and build data URIs for raw base64 media - fix rate-limit reservation, retry counting, and per-item batch retries; treat empty API keys as unset - cover reasoning resolution/forwarding and isolate provider tests from env side effects --- datafast/llm/capabilities.py | 136 +++++++++- datafast/llm/provider.py | 369 +++++++++++++++++++++------- datafast/llm/types.py | 2 + tests/test_llm_provider_contract.py | 166 ++++++++++++- tests/test_llms_unit.py | 36 ++- tests/test_mistral.py | 4 +- 6 files changed, 589 insertions(+), 124 deletions(-) diff --git a/datafast/llm/capabilities.py b/datafast/llm/capabilities.py index 14d6b26..10e7df6 100644 --- a/datafast/llm/capabilities.py +++ b/datafast/llm/capabilities.py @@ -28,8 +28,10 @@ "reasoning_effort", }) +# previous_response_id is a Responses-API concept; reasoning models reject +# sampling controls such as temperature/top_p, so they are excluded here and +# surface through the unsupported_params policy instead. RESPONSES_PARAMS = frozenset({ - "temperature", "max_completion_tokens", "timeout", "thinking", @@ -42,6 +44,44 @@ endpoint_modes=frozenset({EndpointMode.CHAT}), default_endpoint_mode=EndpointMode.CHAT, supported_params=COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS, + modalities=frozenset({Modality.TEXT, Modality.IMAGE, Modality.FILE}), + structured_output=StructuredOutputMode.JSON_SCHEMA, + batch_mode=BatchMode.LITELLM_BATCH, + cache_mode=CacheMode.PROVIDER_PROMPT, +) + + +MISTRAL_REASONING_CHAT = TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT}), + default_endpoint_mode=EndpointMode.CHAT, + supported_params=( + COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS | frozenset({"reasoning_effort"}) + ), + modalities=frozenset({Modality.TEXT, Modality.IMAGE, Modality.FILE}), + structured_output=StructuredOutputMode.JSON_SCHEMA, + batch_mode=BatchMode.LITELLM_BATCH, + cache_mode=CacheMode.PROVIDER_PROMPT, + supports_reasoning=True, + reasoning_requires_allowlist=True, + notes=( + "Reasoning is opt-in via reasoning_effort. Magistral models enable it " + "natively; mistral-medium/small accept it server-side but LiteLLM only " + "forwards it through the allowed_openai_params escape hatch.", + ), +) + + +GEMINI_CHAT = TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT}), + default_endpoint_mode=EndpointMode.CHAT, + supported_params=COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS, + modalities=frozenset({ + Modality.TEXT, + Modality.IMAGE, + Modality.AUDIO, + Modality.VIDEO, + Modality.FILE, + }), structured_output=StructuredOutputMode.JSON_SCHEMA, batch_mode=BatchMode.LITELLM_BATCH, cache_mode=CacheMode.PROVIDER_PROMPT, @@ -51,7 +91,8 @@ OPENAI_RESPONSES = TargetCapabilities( endpoint_modes=frozenset({EndpointMode.CHAT, EndpointMode.RESPONSES}), default_endpoint_mode=EndpointMode.RESPONSES, - supported_params=RESPONSES_PARAMS | SAMPLING_CHAT_PARAMS, + supported_params=RESPONSES_PARAMS, + modalities=frozenset({Modality.TEXT, Modality.IMAGE, Modality.FILE}), structured_output=StructuredOutputMode.JSON_SCHEMA, batch_mode=BatchMode.FALLBACK_CONCURRENCY, cache_mode=CacheMode.PROVIDER_PROMPT, @@ -63,6 +104,7 @@ endpoint_modes=frozenset({EndpointMode.CHAT, EndpointMode.RESPONSES}), default_endpoint_mode=EndpointMode.CHAT, supported_params=COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS, + modalities=frozenset({Modality.TEXT, Modality.IMAGE, Modality.FILE}), structured_output=StructuredOutputMode.JSON_SCHEMA, batch_mode=BatchMode.LITELLM_BATCH, cache_mode=CacheMode.PROVIDER_PROMPT, @@ -73,6 +115,7 @@ endpoint_modes=frozenset({EndpointMode.CHAT}), default_endpoint_mode=EndpointMode.CHAT, supported_params=COMMON_CHAT_PARAMS | REASONING_PARAMS, + modalities=frozenset({Modality.TEXT, Modality.IMAGE, Modality.FILE}), structured_output=StructuredOutputMode.JSON_SCHEMA, batch_mode=BatchMode.LITELLM_BATCH, cache_mode=CacheMode.PROVIDER_PROMPT, @@ -101,11 +144,39 @@ endpoint_modes=frozenset({EndpointMode.CHAT}), default_endpoint_mode=EndpointMode.CHAT, supported_params=COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS, - structured_output=StructuredOutputMode.JSON_OBJECT, + modalities=frozenset({Modality.TEXT, Modality.IMAGE}), + structured_output=StructuredOutputMode.JSON_SCHEMA, batch_mode=BatchMode.FALLBACK_CONCURRENCY, cache_mode=CacheMode.LOCAL_KV, no_api_key=True, - notes=("Structured output uses Ollama JSON mode plus Datafast validation.",), + notes=( + "Structured output uses Ollama schema-constrained decoding via " + "LiteLLM's response_format translation, plus Datafast validation.", + "Image input requires a vision-capable Ollama model (e.g. gemma3, " + "gemma4, llama3.2-vision); text-only models will reject it server-side.", + ), +) + + +OLLAMA_REASONING_CHAT = TargetCapabilities( + endpoint_modes=frozenset({EndpointMode.CHAT}), + default_endpoint_mode=EndpointMode.CHAT, + supported_params=( + COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS | frozenset({"reasoning_effort"}) + ), + modalities=frozenset({Modality.TEXT, Modality.IMAGE}), + structured_output=StructuredOutputMode.JSON_SCHEMA, + batch_mode=BatchMode.FALLBACK_CONCURRENCY, + cache_mode=CacheMode.LOCAL_KV, + no_api_key=True, + supports_reasoning=True, + notes=( + "Thinking-capable models (deepseek-r1, qwen3, gpt-oss, magistral) accept " + "reasoning via thinking/reasoning_effort; LiteLLM maps it onto Ollama's " + "think parameter and normalizes the trace into reasoning_content.", + "gpt-oss honors the effort level (low/medium/high); other thinking models " + "treat any level as on/off.", + ), ) @@ -117,6 +188,7 @@ structured_output=StructuredOutputMode.JSON_SCHEMA, batch_mode=BatchMode.FALLBACK_CONCURRENCY, cache_mode=CacheMode.LOCAL_KV, + supports_media_uuid=True, no_api_key=True, requires_chat_template=True, notes=( @@ -173,12 +245,12 @@ ("openai", "gpt-5.4-nano"): OPENAI_RESPONSES, ("anthropic", "claude-sonnet-4-6"): ANTHROPIC_CHAT, ("anthropic", "claude-haiku-4-5"): ANTHROPIC_CHAT, - ("gemini", "gemini-2.5-pro"): HOSTED_CHAT, - ("gemini", "gemini-3.5-flash"): HOSTED_CHAT, - ("gemini", "gemini-3.1-flash-lite"): HOSTED_CHAT, - ("mistral", "mistral-medium-3-5"): HOSTED_CHAT, + ("gemini", "gemini-2.5-pro"): GEMINI_CHAT, + ("gemini", "gemini-3.5-flash"): GEMINI_CHAT, + ("gemini", "gemini-3.1-flash-lite"): GEMINI_CHAT, + ("mistral", "mistral-medium-3-5"): MISTRAL_REASONING_CHAT, ("mistral", "mistral-large-2512"): HOSTED_CHAT, - ("mistral", "mistral-small-2603"): HOSTED_CHAT, + ("mistral", "mistral-small-2603"): MISTRAL_REASONING_CHAT, ("mistral", "ministral-14b-2512"): OPENAI_COMPATIBLE_CHAT, ("mistral", "ministral-8b-2512"): OPENAI_COMPATIBLE_CHAT, ("mistral", "ministral-3b-2512"): OPENAI_COMPATIBLE_CHAT, @@ -186,10 +258,8 @@ _PROVIDER_DEFAULTS: dict[str, TargetCapabilities] = { "anthropic": ANTHROPIC_CHAT, - "gemini": HOSTED_CHAT, + "gemini": GEMINI_CHAT, "llamacpp": LLAMACPP_CHAT, - "mistral": HOSTED_CHAT, - "ollama": OLLAMA_CHAT, "openrouter": OPENROUTER_CHAT, "vllm": VLLM_CHAT, } @@ -220,6 +290,12 @@ def resolve_capabilities( if normalized_provider == "openai": return _resolve_openai_capabilities(normalized_model) + if normalized_provider == "mistral": + return _resolve_mistral_capabilities(normalized_model) + + if normalized_provider == "ollama": + return _resolve_ollama_capabilities(normalized_model) + provider_default = _PROVIDER_DEFAULTS.get(normalized_provider) if provider_default is not None: return provider_default @@ -239,6 +315,39 @@ def _resolve_openai_capabilities(model_id: str) -> TargetCapabilities: return OPENAI_CHAT +def _resolve_mistral_capabilities(model_id: str) -> TargetCapabilities: + # Magistral is Mistral's reasoning family; LiteLLM enables reasoning_effort + # for any model whose id contains "magistral". Everything else falls back to + # the standard hosted-chat profile. + if "magistral" in model_id: + return MISTRAL_REASONING_CHAT + return HOSTED_CHAT + + +# Ollama model families that accept the `think` parameter (LiteLLM maps +# reasoning_effort onto it). "-thinking" also matches models an author has +# explicitly tagged as a thinking variant, e.g. lfm2.5-thinking. Models outside +# this set reject reasoning. +_OLLAMA_REASONING_MODELS = ( + "deepseek-r1", + "deepseek-v3.1", + "qwen3", + "qwq", + "gpt-oss", + "magistral", + "gemma4", + "-thinking", +) + + +def _resolve_ollama_capabilities(model_id: str) -> TargetCapabilities: + # Only thinking-capable families accept a reasoning control; every other + # Ollama model keeps the plain chat profile. + if any(family in model_id for family in _OLLAMA_REASONING_MODELS): + return OLLAMA_REASONING_CHAT + return OLLAMA_CHAT + + def _looks_like_openai_reasoning_model(model_id: str) -> bool: return ( model_id.startswith("gpt-5") @@ -261,9 +370,12 @@ def _unknown_capabilities() -> TargetCapabilities: __all__ = [ "ANTHROPIC_CHAT", + "GEMINI_CHAT", "HOSTED_CHAT", "LLAMACPP_CHAT", + "MISTRAL_REASONING_CHAT", "OLLAMA_CHAT", + "OLLAMA_REASONING_CHAT", "OPENAI_CHAT", "OPENAI_COMPATIBLE_CHAT", "OPENAI_RESPONSES", diff --git a/datafast/llm/provider.py b/datafast/llm/provider.py index 458f638..e1ee67e 100644 --- a/datafast/llm/provider.py +++ b/datafast/llm/provider.py @@ -132,7 +132,9 @@ def __init__( self.provider_name = provider self.model_id = model_id self.env_key_name = env_key_name - self.api_key = api_key or (os.getenv(env_key_name) if env_key_name else None) + env_api_key = os.getenv(env_key_name) if env_key_name else None + # `or None` so an empty env var is treated as unset, not sent as "". + self.api_key = api_key or env_api_key or None self.api_base_url = api_base_url self.temperature = temperature self.max_completion_tokens = max_completion_tokens @@ -182,22 +184,7 @@ def generate( previous_response_id=previous_response_id, response_format=response_format, ) - try: - results = self._generate_requests(requests, response_format=response_format) - except ValueError: - raise - except Exception as exc: - error_trace = traceback.format_exc() - logger.error( - "Generation failed | Provider: {} | Model: {} | Error: {}", - self.provider_name, - self.model_id, - exc, - ) - raise RuntimeError( - f"Error generating response with {self.provider_name}:\n{error_trace}" - ) from exc - + results = self._generate_requests(requests, response_format=response_format) if single_input: return results[0] return results @@ -215,7 +202,11 @@ def generate_batch( return [] metadata_items = _normalize_metadata(metadata, len(messages)) - previous_ids = previous_response_ids or [None] * len(messages) + previous_ids = ( + previous_response_ids + if previous_response_ids is not None + else [None] * len(messages) + ) if len(previous_ids) != len(messages): raise ValueError("previous_response_ids length must match messages length") @@ -247,7 +238,7 @@ def generate_response( previous_response_id=previous_response_id, response_format=None, ) - responses = self._generate_normalized_responses( + responses = self._execute_normalized( requests, response_format=None, ) @@ -267,7 +258,11 @@ def generate_batch_response( return [] metadata_items = _normalize_metadata(metadata, len(messages)) - previous_ids = previous_response_ids or [None] * len(messages) + previous_ids = ( + previous_response_ids + if previous_response_ids is not None + else [None] * len(messages) + ) if len(previous_ids) != len(messages): raise ValueError("previous_response_ids length must match messages length") @@ -279,7 +274,7 @@ def generate_batch_response( ) for index, item in enumerate(messages) ] - return self._generate_normalized_responses(requests, response_format=None) + return self._execute_normalized(requests, response_format=None) def _generate_requests( self, @@ -287,7 +282,7 @@ def _generate_requests( *, response_format: type[T] | None, ) -> list[str] | list[T]: - responses = self._generate_normalized_responses( + responses = self._execute_normalized( requests, response_format=response_format, ) @@ -296,6 +291,34 @@ def _generate_requests( for response in responses ] + def _execute_normalized( + self, + requests: list[NormalizedRequest], + *, + response_format: type[T] | None, + ) -> list[NormalizedResponse]: + """Run requests with the shared error contract: ValueError passes + through (validation/policy), everything else is logged and wrapped in + RuntimeError.""" + try: + return self._generate_normalized_responses( + requests, + response_format=response_format, + ) + except ValueError: + raise + except Exception as exc: + error_trace = traceback.format_exc() + logger.error( + "Generation failed | Provider: {} | Model: {} | Error: {}", + self.provider_name, + self.model_id, + exc, + ) + raise RuntimeError( + f"Error generating response with {self.provider_name}:\n{error_trace}" + ) from exc + def _generate_normalized_responses( self, requests: list[NormalizedRequest], @@ -383,6 +406,11 @@ def _execute_litellm_batch( *, response_format: type[T] | None, ) -> list[NormalizedResponse]: + if any(request.previous_response_id is not None for request in requests): + # batch_completion shares one param set across items, so + # per-request response ids cannot be carried on this path. + self._handle_unsupported_param("previous_response_id") + params = self._build_chat_params( NormalizedRequest( messages=[], @@ -391,6 +419,9 @@ def _execute_litellm_batch( response_format, ) params["messages"] = [request.messages for request in requests] + params["max_workers"] = max( + 1, min(self.config.max_concurrent, len(requests)) + ) response = self._call_litellm( litellm.batch_completion, params, @@ -402,7 +433,18 @@ def _execute_litellm_batch( normalized: list[NormalizedResponse] = [] for index, item in enumerate(response): if isinstance(item, Exception): - raise RuntimeError(f"Batch item {index} failed: {item}") from item + # batch_completion returns per-item exceptions instead of + # raising, which bypasses the retry policy; re-run the item + # through the single-request path so retries/backoff apply. + normalized.append( + self._retry_failed_batch_item( + requests[index], + index=index, + error=item, + response_format=response_format, + ) + ) + continue normalized.append( NormalizedResponse( text=_extract_chat_text(item), @@ -415,6 +457,29 @@ def _execute_litellm_batch( ) return normalized + def _retry_failed_batch_item( + self, + request: NormalizedRequest, + *, + index: int, + error: Exception, + response_format: type[T] | None, + ) -> NormalizedResponse: + if not ( + _is_retryable_error(error) + or ( + self.config.unsupported_params != UnsupportedParamsPolicy.FAIL + and _is_unsupported_params_error(error) + ) + ): + raise RuntimeError(f"Batch item {index} failed: {error}") from error + try: + return self._execute_single(request, response_format=response_format) + except Exception as retry_error: + raise RuntimeError( + f"Batch item {index} failed after retries: {retry_error}" + ) from retry_error + def _build_chat_params( self, request: NormalizedRequest, @@ -426,16 +491,14 @@ def _build_chat_params( "metadata": self._build_request_metadata(request.metadata), } if request.previous_response_id is not None: - self._add_supported_param( - params, - "previous_response_id", - request.previous_response_id, - endpoint=EndpointMode.CHAT, - ) + # previous_response_id is a Responses-API concept; chat completions + # endpoints reject it regardless of what the target supports. + self._handle_unsupported_param("previous_response_id") self._add_transport_params(params, endpoint=EndpointMode.CHAT) self._add_common_generation_params(params, endpoint=EndpointMode.CHAT) self._add_chat_structured_output(params, response_format) params.update(self.config.provider_params) + self._apply_reasoning_allowlist(params) return _without_none(params) def _build_responses_params( @@ -445,8 +508,11 @@ def _build_responses_params( ) -> dict[str, Any]: params: dict[str, Any] = { "model": self._get_model_string(), - "input": request.messages, - "metadata": self._build_request_metadata(request.metadata), + "input": _to_responses_input(request.messages), + # For the Responses API, "metadata" is a wire parameter forwarded + # to the provider (OpenAI requires string values); trace metadata + # goes through LiteLLM's logging-only litellm_metadata instead. + "litellm_metadata": self._build_request_metadata(request.metadata), } if request.previous_response_id is not None: self._add_supported_param( @@ -511,6 +577,23 @@ def _add_common_generation_params( endpoint=endpoint, ) + def _apply_reasoning_allowlist(self, params: dict[str, Any]) -> None: + """Force reasoning_effort past LiteLLM's per-model param filter. + + Some targets (e.g. Mistral's mistral-medium/small) accept reasoning_effort + server-side, but the installed LiteLLM only recognises it for a subset of + models and would otherwise drop it. allowed_openai_params tells LiteLLM to + forward the parameter anyway. + """ + if not self.capabilities.reasoning_requires_allowlist: + return + if "reasoning_effort" not in params: + return + allowed = list(params.get("allowed_openai_params") or []) + if "reasoning_effort" not in allowed: + allowed.append("reasoning_effort") + params["allowed_openai_params"] = allowed + def _add_chat_structured_output( self, params: dict[str, Any], @@ -524,8 +607,6 @@ def _add_chat_structured_output( params["response_format"] = response_format elif mode == StructuredOutputMode.JSON_OBJECT: params["response_format"] = {"type": "json_object"} - if self.provider_name == "ollama": - params["format"] = "json" elif mode == StructuredOutputMode.PROMPTED_JSON: warnings.warn( ( @@ -570,9 +651,15 @@ def _add_transport_params( ) if self.api_base_url is not None: params["api_base"] = self.api_base_url - if self.api_key is not None: + # no_api_key only waives auth for self-hosted targets (custom base + # URL); hosted endpoints still require a key even if the resolved + # capability profile is a keyless local one. + api_key_optional = self.capabilities.no_api_key and ( + self.api_base_url is not None or self.env_key_name is None + ) + if self.api_key: params["api_key"] = self.api_key - elif self.env_key_name and not self.capabilities.no_api_key: + elif self.env_key_name and not api_key_optional: env_key = os.getenv(self.env_key_name) if env_key: params["api_key"] = env_key @@ -692,7 +779,13 @@ def _prepare_messages( if not messages: raise ValueError("messages cannot be empty") - normalized = [_normalize_message(message) for message in copy.deepcopy(messages)] + normalized = [ + _normalize_message( + message, + include_media_uuid=self.capabilities.supports_media_uuid, + ) + for message in copy.deepcopy(messages) + ] self._validate_modalities(normalized) if response_format is not None and self.capabilities.structured_output in { @@ -767,14 +860,13 @@ def _should_retry_with_drop_params( def _call_with_retries(self, func, *, request_count: int) -> Any: retry_policy = self.config.retry_policy - attempts = max(1, retry_policy.max_retries) + # max_retries counts retries after the initial attempt. + attempts = max(1, retry_policy.max_retries + 1) for attempt in range(attempts): self._respect_rate_limit(request_count) try: - response = func() - self._record_requests(request_count) - return response + return func() except Exception as exc: if attempt >= attempts - 1 or not _is_retryable_error(exc): raise @@ -802,39 +894,38 @@ def _respect_rate_limit(self, request_count: int = 1) -> None: if self.config.rpm_limit is None: return - with self._rate_lock: - now = time.monotonic() - self._request_timestamps = [ - timestamp - for timestamp in self._request_timestamps - if now - timestamp < 60 - ] + # A request block larger than the whole budget can only run once the + # window is empty; it is still recorded in full so later requests wait. + needed = min(request_count, self.config.rpm_limit) - while len(self._request_timestamps) + request_count > self.config.rpm_limit: - earliest = self._request_timestamps[0] - sleep_time = max(0.0, 60 - (now - earliest)) - if sleep_time > 0: - logger.warning( - "Rate limit reached | Provider: {} | Model: {} | " - "Waiting {:.2f}s", - self.provider_name, - self.model_id, - sleep_time, - ) - self._sleep(sleep_time) + while True: + with self._rate_lock: now = time.monotonic() self._request_timestamps = [ timestamp for timestamp in self._request_timestamps if now - timestamp < 60 ] + if len(self._request_timestamps) + needed <= self.config.rpm_limit: + # Reserve before dispatch so concurrent workers and failed + # attempts count against the budget. + self._request_timestamps.extend([now] * request_count) + return + earliest = self._request_timestamps[0] + # +1s margin so we never release exactly on the boundary, + # where the provider's server-side window may still count + # the expiring request. + sleep_time = max(0.0, 60 - (now - earliest)) + 1.0 - def _record_requests(self, request_count: int = 1) -> None: - if self.config.rpm_limit is None: - return - with self._rate_lock: - now = time.monotonic() - self._request_timestamps.extend([now] * request_count) + if sleep_time > 0: + logger.warning( + "Rate limit reached | Provider: {} | Model: {} | " + "Waiting {:.2f}s", + self.provider_name, + self.model_id, + sleep_time, + ) + self._sleep(sleep_time) def _parse_response( self, @@ -1087,35 +1178,42 @@ def _is_batch_messages(value: Any) -> bool: return isinstance(value, list) and bool(value) and isinstance(value[0], list) -def _normalize_message(message: Message) -> Message: +def _normalize_message(message: Message, *, include_media_uuid: bool = False) -> Message: if not isinstance(message, dict): raise ValueError("Each message must be a dictionary") normalized = dict(message) content = normalized.get("content") if isinstance(content, list): - normalized["content"] = [_normalize_content_part(part) for part in content] + normalized["content"] = [ + _normalize_content_part(part, include_media_uuid=include_media_uuid) + for part in content + ] elif content is not None and not isinstance(content, str): raise ValueError("message content must be a string, list of parts, or None") return normalized -def _normalize_content_part(part: Any) -> dict[str, Any]: +def _normalize_content_part( + part: Any, + *, + include_media_uuid: bool = False, +) -> dict[str, Any]: part = _content_part_to_dict(part) part_type = part.get("type") - normalizers = { - "text": _normalize_text_part, - "image": _normalize_image_part, - "audio": _normalize_audio_part, - "video": _normalize_video_part, - "file": _normalize_file_part, - "document": _normalize_file_part, - } if part_type in {"image_url", "input_audio", "video_url"}: return _without_none(part) - if part_type in normalizers: - return normalizers[part_type](part) + if part_type == "text": + return _normalize_text_part(part) + if part_type == "image": + return _normalize_image_part(part, include_media_uuid=include_media_uuid) + if part_type == "audio": + return _normalize_audio_part(part) + if part_type == "video": + return _normalize_video_part(part, include_media_uuid=include_media_uuid) + if part_type in {"file", "document"}: + return _normalize_file_part(part) return part @@ -1140,35 +1238,76 @@ def _normalize_text_part(part: dict[str, Any]) -> dict[str, Any]: return _without_none({"type": "text", "text": part.get("text")}) -def _normalize_image_part(part: dict[str, Any]) -> dict[str, Any]: - image_url: dict[str, Any] = {"url": part.get("url") or part.get("data")} +def _normalize_image_part( + part: dict[str, Any], + *, + include_media_uuid: bool = False, +) -> dict[str, Any]: + image_url: dict[str, Any] = { + "url": _media_url_from_part(part, kind="image"), + } if part.get("format") or part.get("media_type"): image_url["format"] = part.get("format") or part.get("media_type") if part.get("detail"): image_url["detail"] = part["detail"] normalized = {"type": "image_url", "image_url": _without_none(image_url)} - if part.get("media_id"): + if include_media_uuid and part.get("media_id"): normalized["uuid"] = part["media_id"] return normalized def _normalize_audio_part(part: dict[str, Any]) -> dict[str, Any]: + data = part.get("data") + if not data: + raise ValueError( + "audio content parts require base64 'data'; URL-only audio input " + "is not supported by chat audio APIs" + ) + audio_format = part.get("format") or part.get("media_type") or "wav" + # Accept MIME types ("audio/wav") but send the bare format ("wav"). + if "/" in audio_format: + audio_format = audio_format.split("/", 1)[1] return { "type": "input_audio", - "input_audio": _without_none({ - "data": part.get("data"), - "format": part.get("format") or part.get("media_type") or "wav", - }), + "input_audio": {"data": data, "format": audio_format}, } -def _normalize_video_part(part: dict[str, Any]) -> dict[str, Any]: - normalized = {"type": "video_url", "video_url": {"url": part.get("url")}} - if part.get("media_id"): +def _normalize_video_part( + part: dict[str, Any], + *, + include_media_uuid: bool = False, +) -> dict[str, Any]: + normalized = { + "type": "video_url", + "video_url": {"url": _media_url_from_part(part, kind="video")}, + } + if include_media_uuid and part.get("media_id"): normalized["uuid"] = part["media_id"] return normalized +def _media_url_from_part(part: dict[str, Any], *, kind: str) -> str: + url = part.get("url") + if url: + return url + + data = part.get("data") + if data: + if data.startswith("data:"): + return data + media_type = part.get("media_type") or part.get("format") + if not media_type: + raise ValueError( + f"{kind} content parts with raw base64 'data' need 'media_type' " + "(e.g. 'image/png') to build a data URI, or pass a full " + "'data:' URI directly" + ) + return f"data:{media_type};base64,{data}" + + raise ValueError(f"{kind} content parts require either 'url' or 'data'") + + def _normalize_file_part(part: dict[str, Any]) -> dict[str, Any]: if isinstance(part.get("file"), dict): file_payload = part["file"] @@ -1179,6 +1318,60 @@ def _normalize_file_part(part: dict[str, Any]) -> dict[str, Any]: return {"type": "file", "file": _without_none(file_payload)} +def _to_responses_input(messages: Messages) -> Messages: + """Convert chat-normalized messages into Responses API input items. + + The Responses API rejects chat-completions part types: it expects + input_text/input_image/input_file for user input and output_text for + assistant history. String content passes through unchanged. + """ + converted: Messages = [] + for message in messages: + content = message.get("content") + if not isinstance(content, list): + converted.append(message) + continue + + text_type = ( + "output_text" if message.get("role") == "assistant" else "input_text" + ) + parts = [_to_responses_part(part, text_type=text_type) for part in content] + new_message = dict(message) + new_message["content"] = parts + converted.append(new_message) + return converted + + +def _to_responses_part(part: dict[str, Any], *, text_type: str) -> dict[str, Any]: + part_type = part.get("type") + + if part_type == "text": + return {"type": text_type, "text": part.get("text")} + + if part_type == "image_url": + image_url = part.get("image_url") + url = image_url.get("url") if isinstance(image_url, dict) else image_url + new_part: dict[str, Any] = {"type": "input_image", "image_url": url} + if isinstance(image_url, dict) and image_url.get("detail"): + new_part["detail"] = image_url["detail"] + if part.get("uuid"): + new_part["uuid"] = part["uuid"] + return new_part + + if part_type == "file": + payload = dict(part.get("file") or {}) + new_part = {"type": "input_file"} + file_id = payload.pop("file_id", None) + if isinstance(file_id, str) and file_id.startswith(("http://", "https://")): + new_part["file_url"] = file_id + elif file_id is not None: + new_part["file_id"] = file_id + new_part.update(payload) + return new_part + + return part + + def _modality_for_part(part: dict[str, Any]) -> Modality: part_type = part.get("type") if part_type == "text": diff --git a/datafast/llm/types.py b/datafast/llm/types.py index c8f260b..94db6c7 100644 --- a/datafast/llm/types.py +++ b/datafast/llm/types.py @@ -74,6 +74,8 @@ class TargetCapabilities: cache_mode: CacheMode = CacheMode.NONE supports_reasoning: bool = False supports_thinking: bool = False + reasoning_requires_allowlist: bool = False + supports_media_uuid: bool = False no_api_key: bool = False requires_chat_template: bool = False notes: tuple[str, ...] = () diff --git a/tests/test_llm_provider_contract.py b/tests/test_llm_provider_contract.py index 5f0080a..b7c786f 100644 --- a/tests/test_llm_provider_contract.py +++ b/tests/test_llm_provider_contract.py @@ -7,11 +7,14 @@ ContentPart, EndpointMode, Modality, + MistralProvider, + OllamaProvider, OpenAIProvider, OpenRouterProvider, openai, openai_compatible, ) +from datafast.llm.capabilities import resolve_capabilities class SimpleSchema(BaseModel): @@ -240,6 +243,163 @@ def fake_completion(**kwargs): assert "reasoning" not in captured +def test_mistral_reasoning_capability_resolution(): + # Reasoning-capable Mistral targets: magistral family plus the documented + # mistral-medium/small snapshots. + for model_id in ( + "mistral-medium-3-5", + "mistral-small-2603", + "magistral-medium-2509", + "magistral-small-latest", + ): + caps = resolve_capabilities("mistral", model_id) + assert caps.supports_reasoning is True + assert "reasoning_effort" in caps.supported_params + assert caps.reasoning_requires_allowlist is True + + # Non-reasoning Mistral targets keep the plain hosted-chat profile. + for model_id in ("mistral-large-2512", "mistral-tiny"): + caps = resolve_capabilities("mistral", model_id) + assert caps.supports_reasoning is False + assert "reasoning_effort" not in caps.supported_params + + +def test_mistral_reasoning_effort_is_forwarded_with_allowlist(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok", reasoning_content="chain of thought") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = MistralProvider( + model_id="mistral-medium-3-5", + api_key="test-key", + reasoning_effort="high", + ) + + response = provider.generate_response(prompt="think it through") + + assert captured["reasoning_effort"] == "high" + # LiteLLM only forwards reasoning_effort for a subset of Mistral models; the + # allowlist forces it through for the rest. + assert "reasoning_effort" in captured["allowed_openai_params"] + assert response.reasoning_content == "chain of thought" + + +def test_mistral_without_reasoning_effort_stays_plain(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = MistralProvider(model_id="mistral-medium-3-5", api_key="test-key") + + assert provider.generate(prompt="ping") == "ok" + assert "reasoning_effort" not in captured + assert "allowed_openai_params" not in captured + + +def test_mistral_non_reasoning_model_warns_and_omits_reasoning_effort(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = MistralProvider( + model_id="mistral-large-2512", + api_key="test-key", + reasoning_effort="high", + ) + + with pytest.warns(UserWarning, match="reasoning_effort"): + assert provider.generate(prompt="ping") == "ok" + + assert "reasoning_effort" not in captured + assert "allowed_openai_params" not in captured + + +def test_ollama_reasoning_capability_resolution(): + # Thinking-capable Ollama families gain a mapped reasoning control; models + # tagged "-thinking" (e.g. lfm2.5-thinking) match too. + for model_id in ( + "deepseek-r1:8b", + "qwen3:8b", + "gpt-oss:20b", + "magistral:latest", + "gemma4:12b", + "lfm2.5-thinking:1.2b", + ): + caps = resolve_capabilities("ollama", model_id) + assert caps.supports_reasoning is True + assert "reasoning_effort" in caps.supported_params + + # Other Ollama models keep the plain chat profile. + for model_id in ("gemma3:4b", "llama3.2"): + caps = resolve_capabilities("ollama", model_id) + assert caps.supports_reasoning is False + assert "reasoning_effort" not in caps.supported_params + + +def test_ollama_reasoning_effort_is_forwarded(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok", reasoning_content="chain of thought") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = OllamaProvider(model_id="deepseek-r1:8b", reasoning_effort="high") + + response = provider.generate_response(prompt="think it through") + + assert captured["reasoning_effort"] == "high" + # LiteLLM maps reasoning_effort onto Ollama's think param natively, so no + # allowlist escape hatch is needed. + assert "allowed_openai_params" not in captured + assert response.reasoning_content == "chain of thought" + + +def test_ollama_thinking_true_defaults_to_low_effort(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = OllamaProvider(model_id="qwen3:8b", thinking=True) + + assert provider.generate(prompt="ping") == "ok" + assert captured["reasoning_effort"] == "low" + + +def test_ollama_non_reasoning_model_warns_and_omits_reasoning_effort(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = OllamaProvider(model_id="gemma3:4b", reasoning_effort="high") + + with pytest.warns(UserWarning, match="reasoning_effort"): + assert provider.generate(prompt="ping") == "ok" + + assert "reasoning_effort" not in captured + + def test_provider_params_escape_hatch_is_forwarded(monkeypatch): captured = {} @@ -444,7 +604,11 @@ def fake_responses(**kwargs): assert captured["reasoning"] == {"effort": "low"} assert captured["max_output_tokens"] == 64 assert captured["text_format"] is SimpleSchema - assert captured["metadata"]["purpose"] == "test" + # Trace metadata must stay off the Responses wire (OpenAI requires + # string-only metadata); it rides LiteLLM's logging-only kwarg instead. + assert "metadata" not in captured + assert captured["litellm_metadata"]["purpose"] == "test" + assert captured["input"] == [{"role": "user", "content": "capital?"}] def test_fallback_batching_preserves_order(monkeypatch): diff --git a/tests/test_llms_unit.py b/tests/test_llms_unit.py index 1c67a6d..a7408ca 100644 --- a/tests/test_llms_unit.py +++ b/tests/test_llms_unit.py @@ -1,7 +1,22 @@ +import pytest + +import datafast.llm.provider as provider_module import datafast.llms as llms_module from datafast.llms import OpenRouterProvider +@pytest.fixture(autouse=True) +def _disable_provider_side_effects(monkeypatch): + # Patch the bindings LLMProvider.__init__ actually calls (imported into + # datafast.llm.provider); patching datafast.llms would be a no-op. + monkeypatch.setattr(provider_module, "load_env_once", lambda: None) + monkeypatch.setattr( + provider_module, + "maybe_configure_langfuse_tracing", + lambda load_env=False: False, + ) + + class _DummyMessage: def __init__(self, content: str, **extra: object) -> None: self.content = content @@ -20,13 +35,6 @@ def __init__(self, content: str, **extra: object) -> None: def test_openrouter_single_messages_use_completion(monkeypatch): - monkeypatch.setattr(llms_module, "load_env_once", lambda: None) - monkeypatch.setattr( - llms_module, - "maybe_configure_langfuse_tracing", - lambda load_env=False: False, - ) - calls = {"completion": 0, "batch_completion": 0} def fake_completion(**kwargs): @@ -50,13 +58,6 @@ def fake_batch_completion(**kwargs): def test_openrouter_batch_messages_use_batch_completion(monkeypatch): - monkeypatch.setattr(llms_module, "load_env_once", lambda: None) - monkeypatch.setattr( - llms_module, - "maybe_configure_langfuse_tracing", - lambda load_env=False: False, - ) - calls = {"completion": 0, "batch_completion": 0} def fake_completion(**kwargs): @@ -83,13 +84,6 @@ def fake_batch_completion(**kwargs): def test_openrouter_generate_response_reads_reasoning_field(monkeypatch): - monkeypatch.setattr(llms_module, "load_env_once", lambda: None) - monkeypatch.setattr( - llms_module, - "maybe_configure_langfuse_tracing", - lambda load_env=False: False, - ) - monkeypatch.setattr( llms_module.litellm, "completion", diff --git a/tests/test_mistral.py b/tests/test_mistral.py index 3e4bce4..9b8a713 100644 --- a/tests/test_mistral.py +++ b/tests/test_mistral.py @@ -553,10 +553,10 @@ def test_batch_with_all_parameters(self): def test_batch_validation_errors(self): provider = MistralProvider() - with pytest.raises(ValueError, match="Either prompts or messages must be provided"): + with pytest.raises(ValueError, match="Either prompt or messages must be provided"): provider.generate() - with pytest.raises(ValueError, match="Provide either prompts or messages, not both"): + with pytest.raises(ValueError, match="Provide either prompt or messages, not both"): provider.generate( prompt=["test"], messages=[[{"role": "user", "content": "test"}]] From a3d4031ffd3832b7a79b3e8ab3d7e14f0ee5f29a Mon Sep 17 00:00:00 2001 From: Patrick <pfleith.pro@gmail.com> Date: Mon, 13 Jul 2026 19:00:33 +0200 Subject: [PATCH 45/54] docs(examples): add openai provider example suite - 11 runnable scripts plus README covering prompts, batching, structured output, metadata, multimodal, and rate limiting --- examples/providers/openai/01_simple_prompt.py | 22 ++++++ examples/providers/openai/02_batch_prompts.py | 26 +++++++ .../openai/03_messages_with_system_prompt.py | 31 ++++++++ .../providers/openai/04_structured_output.py | 28 +++++++ .../providers/openai/05_batch_messages.py | 40 ++++++++++ .../openai/06_generation_metadata.py | 53 +++++++++++++ .../providers/openai/07_structured_batch.py | 35 +++++++++ .../openai/08_unsupported_params_policies.py | 54 +++++++++++++ .../openai/09_multimodal_image_input.py | 49 ++++++++++++ .../openai/10_raw_vs_normalized_response.py | 57 ++++++++++++++ .../openai/11_timeout_and_rate_limit.py | 72 ++++++++++++++++++ examples/providers/openai/README.md | 45 +++++++++++ examples/providers/openai/sample_lion.jpg | Bin 0 -> 18489 bytes 13 files changed, 512 insertions(+) create mode 100644 examples/providers/openai/01_simple_prompt.py create mode 100644 examples/providers/openai/02_batch_prompts.py create mode 100644 examples/providers/openai/03_messages_with_system_prompt.py create mode 100644 examples/providers/openai/04_structured_output.py create mode 100644 examples/providers/openai/05_batch_messages.py create mode 100644 examples/providers/openai/06_generation_metadata.py create mode 100644 examples/providers/openai/07_structured_batch.py create mode 100644 examples/providers/openai/08_unsupported_params_policies.py create mode 100644 examples/providers/openai/09_multimodal_image_input.py create mode 100644 examples/providers/openai/10_raw_vs_normalized_response.py create mode 100644 examples/providers/openai/11_timeout_and_rate_limit.py create mode 100644 examples/providers/openai/README.md create mode 100644 examples/providers/openai/sample_lion.jpg diff --git a/examples/providers/openai/01_simple_prompt.py b/examples/providers/openai/01_simple_prompt.py new file mode 100644 index 0000000..ea92051 --- /dev/null +++ b/examples/providers/openai/01_simple_prompt.py @@ -0,0 +1,22 @@ +"""Minimal OpenAI example with a single prompt.""" + +from dotenv import load_dotenv + +from datafast import openai +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "gpt-5.4-mini" +PROMPT = "Write one sentence explaining what OpenAI is." + + +def main() -> None: + load_dotenv() + + model = openai(MODEL_ID) + response = model.generate(prompt=PROMPT) + print(format_generated_responses(PROMPT, response)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/openai/02_batch_prompts.py b/examples/providers/openai/02_batch_prompts.py new file mode 100644 index 0000000..876c40f --- /dev/null +++ b/examples/providers/openai/02_batch_prompts.py @@ -0,0 +1,26 @@ +"""Minimal OpenAI example with a batch of prompts.""" + +from dotenv import load_dotenv + +from datafast import openai +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "gpt-5.4-mini" +PROMPTS = [ + "Give a one-sentence definition of synthetic data.", + "Give a one-sentence definition of retrieval-augmented generation.", + "Give a one-sentence definition of tool calling.", +] + + +def main() -> None: + load_dotenv() + + model = openai(MODEL_ID) + responses = model.generate(prompt=PROMPTS) + print(format_generated_responses(PROMPTS, responses)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/openai/03_messages_with_system_prompt.py b/examples/providers/openai/03_messages_with_system_prompt.py new file mode 100644 index 0000000..281fdd4 --- /dev/null +++ b/examples/providers/openai/03_messages_with_system_prompt.py @@ -0,0 +1,31 @@ +"""OpenAI example using explicit chat messages.""" + +from dotenv import load_dotenv + +from datafast import openai +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "gpt-5.4-mini" +MESSAGES = [ + { + "role": "system", + "content": "You are a concise technical assistant. Answer in exactly two bullets.", + }, + { + "role": "user", + "content": "Explain why teams use OpenAI models for structured data generation.", + }, +] + + +def main() -> None: + load_dotenv() + + model = openai(MODEL_ID) + response = model.generate(messages=MESSAGES) + print(format_generated_responses(MESSAGES[-1]["content"], response)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/openai/04_structured_output.py b/examples/providers/openai/04_structured_output.py new file mode 100644 index 0000000..4fe06c2 --- /dev/null +++ b/examples/providers/openai/04_structured_output.py @@ -0,0 +1,28 @@ +"""OpenAI example with structured output validation.""" + +from dotenv import load_dotenv +from pydantic import BaseModel + +from datafast import openai + + +MODEL_ID = "gpt-5.4-mini" +PROMPT = "Return a JSON object describing OpenAI in two short sentences." + + +class ProviderSummary(BaseModel): + name: str + summary: str + best_for: str + + +def main() -> None: + load_dotenv() + + model = openai(MODEL_ID) + response = model.generate(prompt=PROMPT, response_format=ProviderSummary) + print(response.model_dump_json(indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/openai/05_batch_messages.py b/examples/providers/openai/05_batch_messages.py new file mode 100644 index 0000000..f57e5bc --- /dev/null +++ b/examples/providers/openai/05_batch_messages.py @@ -0,0 +1,40 @@ +"""OpenAI example with a batch of message lists.""" + +from dotenv import load_dotenv + +from datafast import openai +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "gpt-5.4-mini" +BATCH_MESSAGES = [ + [ + { + "role": "system", + "content": "You answer for engineers in one sentence.", + }, + { + "role": "user", + "content": "What is prompt caching?", + }, + ], + [ + { + "role": "user", + "content": "What is structured output?", + }, + ], +] + + +def main() -> None: + load_dotenv() + + model = openai(MODEL_ID) + responses = model.generate(messages=BATCH_MESSAGES) + prompts = [messages[-1]["content"] for messages in BATCH_MESSAGES] + print(format_generated_responses(prompts, responses)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/openai/06_generation_metadata.py b/examples/providers/openai/06_generation_metadata.py new file mode 100644 index 0000000..bc73ecd --- /dev/null +++ b/examples/providers/openai/06_generation_metadata.py @@ -0,0 +1,53 @@ +"""OpenAI example returning normalized response metadata.""" + +from dotenv import load_dotenv + +from datafast import openai + + +MODEL_ID = "gpt-5.4" +PROMPT = ( + "A train travels 60 miles per hour for 2.5 hours. " + "Work it out carefully, then give the final answer in one short sentence." +) + + +def main() -> None: + load_dotenv() + + # gpt-5 models run on the Responses API; asking for a reasoning summary + # surfaces reasoning_content alongside the reasoning token count. + model = openai( + MODEL_ID, + provider_params={"reasoning": {"effort": "high", "summary": "auto"}}, + ) + response = model.generate_response(prompt=PROMPT) + usage = getattr(response.raw, "usage", None) + completion_details = getattr(usage, "completion_tokens_details", None) + reasoning_tokens = ( + getattr(completion_details, "reasoning_tokens", None) + if completion_details is not None + else None + ) + + print("Text") + print("----") + print(response.text.strip()) + print() + print("Metadata") + print("--------") + print(f"reasoning_content: {bool(response.reasoning_content)}") + print(f"reasoning_tokens: {reasoning_tokens}") + print(f"thinking_blocks: {len(response.thinking_blocks)}") + print(f"images: {len(response.images)}") + print(f"audio: {bool(response.audio)}") + print(f"output_items: {len(response.output_items)}") + if response.reasoning_content: + print() + print("Reasoning") + print("---------") + print(response.reasoning_content) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/openai/07_structured_batch.py b/examples/providers/openai/07_structured_batch.py new file mode 100644 index 0000000..7cea8f3 --- /dev/null +++ b/examples/providers/openai/07_structured_batch.py @@ -0,0 +1,35 @@ +"""OpenAI example with batched structured responses.""" + +from dotenv import load_dotenv +from pydantic import BaseModel + +from datafast import openai + + +MODEL_ID = "gpt-5.4-mini" +PROMPTS = [ + "Return JSON for Python with fields language, category, and one_sentence_use_case.", + "Return JSON for Rust with fields language, category, and one_sentence_use_case.", + "Return JSON for SQL with fields language, category, and one_sentence_use_case.", +] + + +class LanguageCard(BaseModel): + language: str + category: str + one_sentence_use_case: str + + +def main() -> None: + load_dotenv() + + model = openai(MODEL_ID) + responses = model.generate(prompt=PROMPTS, response_format=LanguageCard) + + for response in responses: + print(response.model_dump_json(indent=2)) + print() + + +if __name__ == "__main__": + main() diff --git a/examples/providers/openai/08_unsupported_params_policies.py b/examples/providers/openai/08_unsupported_params_policies.py new file mode 100644 index 0000000..3ccf673 --- /dev/null +++ b/examples/providers/openai/08_unsupported_params_policies.py @@ -0,0 +1,54 @@ +"""OpenAI example showing unsupported parameter policies. + +OpenAI's gpt-5 models are reasoning models served on the Responses API, which +rejects sampling controls such as ``temperature``. Datafast omits it and routes +the decision through the warn/quiet/fail policy. +""" + +from __future__ import annotations + +import warnings + +from dotenv import load_dotenv + +from datafast import openai + + +MODEL_ID = "gpt-5.4-mini" +PROMPT = "Explain OpenAI in one short sentence." + + +def run_case(policy: str) -> None: + model = openai( + MODEL_ID, + temperature=0.5, + unsupported_params=policy, + ) + + print(f"Policy: {policy}") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + try: + response = model.generate(prompt=PROMPT) + except ValueError as exc: + print(f"status: error") + print(f"detail: {exc}") + else: + print("status: ok") + print(f"text: {response.strip()}") + + print(f"warnings: {len(caught)}") + for warning in caught: + print(f"- {warning.message}") + print() + + +def main() -> None: + load_dotenv() + + for policy in ("warn", "quiet", "fail"): + run_case(policy) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/openai/09_multimodal_image_input.py b/examples/providers/openai/09_multimodal_image_input.py new file mode 100644 index 0000000..4939543 --- /dev/null +++ b/examples/providers/openai/09_multimodal_image_input.py @@ -0,0 +1,49 @@ +"""OpenAI example with text plus image input. + +The image ships with this example and is sent as base64 bytes via +``ContentPart(data=...)``, so the request does not depend on OpenAI being able +to fetch a remote URL. +""" + +import base64 +from pathlib import Path + +from dotenv import load_dotenv + +from datafast import openai +from datafast.llm import ContentPart + + +# Swap this for any OpenAI model that supports image input. +MODEL_ID = "gpt-5.4-mini" +IMAGE_PATH = Path(__file__).parent / "sample_lion.jpg" + + +def main() -> None: + load_dotenv() + + image_b64 = base64.standard_b64encode(IMAGE_PATH.read_bytes()).decode("ascii") + messages = [ + { + "role": "user", + "content": [ + ContentPart( + type="text", + text="Describe this image in two short bullet points.", + ), + ContentPart( + type="image", + data=image_b64, + media_type="image/jpeg", + ), + ], + } + ] + + model = openai(MODEL_ID) + response = model.generate(messages=messages) + print(response.strip()) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/openai/10_raw_vs_normalized_response.py b/examples/providers/openai/10_raw_vs_normalized_response.py new file mode 100644 index 0000000..e694900 --- /dev/null +++ b/examples/providers/openai/10_raw_vs_normalized_response.py @@ -0,0 +1,57 @@ +"""OpenAI example comparing normalized fields with raw payload fields. + +gpt-5 models run on the Responses API, so the raw payload exposes ``output`` +items and ``output_text`` rather than the chat-completions ``choices`` array. +""" + +from __future__ import annotations + +from dotenv import load_dotenv + +from datafast import openai + + +MODEL_ID = "gpt-5.4" +PROMPT = ( + "A train travels 60 miles per hour for 2.5 hours. " + "Work it out carefully, then give the final answer in one short sentence." +) + + +def main() -> None: + load_dotenv() + + model = openai( + MODEL_ID, + provider_params={"reasoning": {"effort": "high", "summary": "auto"}}, + ) + response = model.generate_response(prompt=PROMPT) + + usage = getattr(response.raw, "usage", None) + output = getattr(response.raw, "output", None) + raw_text = getattr(response.raw, "output_text", None) + + print("Comparison") + print("----------") + print(f"normalized.text: {response.text.strip()!r}") + print(f"raw output_text: {raw_text!r}") + print(f"reasoning_content: {bool(response.reasoning_content)}") + print(f"output_items: {len(response.output_items)}") + if response.reasoning_content: + print() + print("Normalized reasoning") + print("--------------------") + print(response.reasoning_content) + print() + print("Raw") + print("---") + print(f"raw_type: {type(response.raw).__name__}") + print(f"has_usage: {usage is not None}") + print(f"has_output: {output is not None}") + if usage is not None: + print(f"input_tokens: {getattr(usage, 'input_tokens', None)}") + print(f"output_tokens: {getattr(usage, 'output_tokens', None)}") + + +if __name__ == "__main__": + main() diff --git a/examples/providers/openai/11_timeout_and_rate_limit.py b/examples/providers/openai/11_timeout_and_rate_limit.py new file mode 100644 index 0000000..650abf7 --- /dev/null +++ b/examples/providers/openai/11_timeout_and_rate_limit.py @@ -0,0 +1,72 @@ +"""OpenAI example showing timeout and rpm_limit across multiple requests.""" + +import time + +from dotenv import load_dotenv + +from datafast import openai + + +MODEL_ID = "gpt-5.4-mini" +TIMEOUT_SECONDS = 30 +RPM_LIMIT = 2 +PROMPTS = [ + "Reply with exactly: request one acknowledged.", + "Reply with exactly: request two acknowledged.", + "Reply with exactly: request three acknowledged.", +] + + +def main() -> None: + load_dotenv() + + model = openai( + MODEL_ID, + timeout=TIMEOUT_SECONDS, + rpm_limit=RPM_LIMIT, + ) + + print("Config") + print("------") + print(f"model: {MODEL_ID}") + print(f"timeout: {TIMEOUT_SECONDS}s") + print(f"rpm_limit: {RPM_LIMIT}") + print() + print( + "This script sends three separate requests through one provider instance." + ) + print( + "With rpm_limit=2, the third request should pause for roughly one minute " + "before Datafast sends it." + ) + print() + + started = time.monotonic() + for index, prompt in enumerate(PROMPTS, start=1): + request_started = time.monotonic() + response = model.generate(prompt=prompt) + request_elapsed = time.monotonic() - request_started + + print(f"Request {index}") + print(f"prompt: {prompt}") + print(f"response: {response}") + print(f"call_elapsed_seconds: {request_elapsed:.2f}") + print() + + elapsed = time.monotonic() - started + + print("Notes") + print("-----") + print(f"total_elapsed_seconds: {elapsed:.2f}") + print( + "Datafast forwards timeout on each request and enforces rpm_limit on the " + "provider instance before the next request is sent." + ) + print( + "Requests 1 and 2 should complete normally. Request 3 should be the one " + "that clearly shows client-side throttling." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/openai/README.md b/examples/providers/openai/README.md new file mode 100644 index 0000000..8062a35 --- /dev/null +++ b/examples/providers/openai/README.md @@ -0,0 +1,45 @@ +# OpenAI Examples + +Requirements: + +- `OPENAI_API_KEY` set in your environment or `.env` + +Notes: + +- OpenAI's `gpt-5` models are reasoning models served on the Responses API. They + do not accept `temperature`, so these examples leave it unset (example 08 shows + how the unsupported-parameter policy handles it). +- Datafast suppresses LiteLLM's provider help banner by default for cleaner example + output. +- Set `DATAFAST_LITELLM_SUPPRESS_DEBUG_INFO=0` if you want LiteLLM to print that + extra provider/debug information while troubleshooting. + +Run: + +```bash +.venv/bin/python examples/providers/openai/01_simple_prompt.py +.venv/bin/python examples/providers/openai/02_batch_prompts.py +.venv/bin/python examples/providers/openai/03_messages_with_system_prompt.py +.venv/bin/python examples/providers/openai/04_structured_output.py +.venv/bin/python examples/providers/openai/05_batch_messages.py +.venv/bin/python examples/providers/openai/06_generation_metadata.py +.venv/bin/python examples/providers/openai/07_structured_batch.py +.venv/bin/python examples/providers/openai/08_unsupported_params_policies.py +.venv/bin/python examples/providers/openai/09_multimodal_image_input.py +.venv/bin/python examples/providers/openai/10_raw_vs_normalized_response.py +.venv/bin/python examples/providers/openai/11_timeout_and_rate_limit.py +``` + +Files: + +- `01_simple_prompt.py`: one prompt, one response +- `02_batch_prompts.py`: a list of prompts sent through one `generate(...)` call +- `03_messages_with_system_prompt.py`: chat messages with a system instruction +- `04_structured_output.py`: validated Pydantic output +- `05_batch_messages.py`: a batch of independent message lists +- `06_generation_metadata.py`: `generate_response(...)` and normalized metadata with Responses-API reasoning +- `07_structured_batch.py`: batched structured responses +- `08_unsupported_params_policies.py`: `warn`, `quiet`, and `fail` handling for an unsupported parameter (`temperature`) +- `09_multimodal_image_input.py`: text plus image input using `ContentPart`, sending the bundled `sample_lion.jpg` as base64 bytes +- `10_raw_vs_normalized_response.py`: compare normalized fields with the underlying raw Responses payload +- `11_timeout_and_rate_limit.py`: three separate requests through one provider instance, with `rpm_limit=2` so the third request shows client-side throttling diff --git a/examples/providers/openai/sample_lion.jpg b/examples/providers/openai/sample_lion.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e9bf9f5d0816d6201b4862088dc74476249a6a70 GIT binary patch literal 18489 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 XW@0xO9xwB(t-OQ@=H<To<3In|#VQ85 literal 0 HcmV?d00001 From 5f74861a2653ee05c7822ed0aa96eea121aae4c5 Mon Sep 17 00:00:00 2001 From: Patrick <pfleith.pro@gmail.com> Date: Mon, 13 Jul 2026 19:00:33 +0200 Subject: [PATCH 46/54] docs(examples): add anthropic provider example suite - 11 runnable scripts plus README, including reasoning and multimodal image input --- .../providers/anthropic/01_simple_prompt.py | 22 ++++++ .../providers/anthropic/02_batch_prompts.py | 26 +++++++ .../03_messages_with_system_prompt.py | 31 ++++++++ .../anthropic/04_structured_output.py | 28 +++++++ .../providers/anthropic/05_batch_messages.py | 40 ++++++++++ .../anthropic/06_generation_metadata.py | 50 ++++++++++++ .../anthropic/07_structured_batch.py | 35 +++++++++ .../08_unsupported_params_policies.py | 53 +++++++++++++ .../anthropic/09_multimodal_image_input.py | 50 ++++++++++++ .../10_raw_vs_normalized_response.py | 70 +++++++++++++++++ .../anthropic/11_timeout_and_rate_limit.py | 73 ++++++++++++++++++ examples/providers/anthropic/README.md | 42 ++++++++++ examples/providers/anthropic/sample_ant.jpg | Bin 0 -> 15642 bytes 13 files changed, 520 insertions(+) create mode 100644 examples/providers/anthropic/01_simple_prompt.py create mode 100644 examples/providers/anthropic/02_batch_prompts.py create mode 100644 examples/providers/anthropic/03_messages_with_system_prompt.py create mode 100644 examples/providers/anthropic/04_structured_output.py create mode 100644 examples/providers/anthropic/05_batch_messages.py create mode 100644 examples/providers/anthropic/06_generation_metadata.py create mode 100644 examples/providers/anthropic/07_structured_batch.py create mode 100644 examples/providers/anthropic/08_unsupported_params_policies.py create mode 100644 examples/providers/anthropic/09_multimodal_image_input.py create mode 100644 examples/providers/anthropic/10_raw_vs_normalized_response.py create mode 100644 examples/providers/anthropic/11_timeout_and_rate_limit.py create mode 100644 examples/providers/anthropic/README.md create mode 100644 examples/providers/anthropic/sample_ant.jpg diff --git a/examples/providers/anthropic/01_simple_prompt.py b/examples/providers/anthropic/01_simple_prompt.py new file mode 100644 index 0000000..268c617 --- /dev/null +++ b/examples/providers/anthropic/01_simple_prompt.py @@ -0,0 +1,22 @@ +"""Minimal Anthropic example with a single prompt.""" + +from dotenv import load_dotenv + +from datafast import anthropic +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "claude-haiku-4-5" +PROMPT = "Write one sentence explaining what Anthropic is." + + +def main() -> None: + load_dotenv() + + model = anthropic(MODEL_ID, temperature=0) + response = model.generate(prompt=PROMPT) + print(format_generated_responses(PROMPT, response)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/anthropic/02_batch_prompts.py b/examples/providers/anthropic/02_batch_prompts.py new file mode 100644 index 0000000..626abc3 --- /dev/null +++ b/examples/providers/anthropic/02_batch_prompts.py @@ -0,0 +1,26 @@ +"""Minimal Anthropic example with a batch of prompts.""" + +from dotenv import load_dotenv + +from datafast import anthropic +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "claude-haiku-4-5" +PROMPTS = [ + "Give a one-sentence definition of synthetic data.", + "Give a one-sentence definition of retrieval-augmented generation.", + "Give a one-sentence definition of tool calling.", +] + + +def main() -> None: + load_dotenv() + + model = anthropic(MODEL_ID, temperature=0) + responses = model.generate(prompt=PROMPTS) + print(format_generated_responses(PROMPTS, responses)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/anthropic/03_messages_with_system_prompt.py b/examples/providers/anthropic/03_messages_with_system_prompt.py new file mode 100644 index 0000000..b4cbb45 --- /dev/null +++ b/examples/providers/anthropic/03_messages_with_system_prompt.py @@ -0,0 +1,31 @@ +"""Anthropic example using explicit chat messages.""" + +from dotenv import load_dotenv + +from datafast import anthropic +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "claude-haiku-4-5" +MESSAGES = [ + { + "role": "system", + "content": "You are a concise technical assistant. Answer in exactly two bullets.", + }, + { + "role": "user", + "content": "Explain why teams use Claude for structured data generation.", + }, +] + + +def main() -> None: + load_dotenv() + + model = anthropic(MODEL_ID, temperature=0) + response = model.generate(messages=MESSAGES) + print(format_generated_responses(MESSAGES[-1]["content"], response)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/anthropic/04_structured_output.py b/examples/providers/anthropic/04_structured_output.py new file mode 100644 index 0000000..ee02a6a --- /dev/null +++ b/examples/providers/anthropic/04_structured_output.py @@ -0,0 +1,28 @@ +"""Anthropic example with structured output validation.""" + +from dotenv import load_dotenv +from pydantic import BaseModel + +from datafast import anthropic + + +MODEL_ID = "claude-haiku-4-5" +PROMPT = "Return a JSON object describing Anthropic in two short sentences." + + +class ProviderSummary(BaseModel): + name: str + summary: str + best_for: str + + +def main() -> None: + load_dotenv() + + model = anthropic(MODEL_ID, temperature=0) + response = model.generate(prompt=PROMPT, response_format=ProviderSummary) + print(response.model_dump_json(indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/anthropic/05_batch_messages.py b/examples/providers/anthropic/05_batch_messages.py new file mode 100644 index 0000000..b06cc37 --- /dev/null +++ b/examples/providers/anthropic/05_batch_messages.py @@ -0,0 +1,40 @@ +"""Anthropic example with a batch of message lists.""" + +from dotenv import load_dotenv + +from datafast import anthropic +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "claude-haiku-4-5" +BATCH_MESSAGES = [ + [ + { + "role": "system", + "content": "You answer for engineers in one sentence.", + }, + { + "role": "user", + "content": "What is prompt caching?", + }, + ], + [ + { + "role": "user", + "content": "What is structured output?", + }, + ], +] + + +def main() -> None: + load_dotenv() + + model = anthropic(MODEL_ID, temperature=0) + responses = model.generate(messages=BATCH_MESSAGES) + prompts = [messages[-1]["content"] for messages in BATCH_MESSAGES] + print(format_generated_responses(prompts, responses)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/anthropic/06_generation_metadata.py b/examples/providers/anthropic/06_generation_metadata.py new file mode 100644 index 0000000..f3f01a7 --- /dev/null +++ b/examples/providers/anthropic/06_generation_metadata.py @@ -0,0 +1,50 @@ +"""Anthropic example returning normalized response metadata.""" + +from dotenv import load_dotenv + +from datafast import anthropic + + +MODEL_ID = "claude-sonnet-4-6" +PROMPT = ( + "A train travels 60 miles per hour for 2.5 hours. " + "Work it out carefully, then give the final answer in one short sentence." +) + + +def main() -> None: + load_dotenv() + + # Extended thinking is enabled natively; temperature is left unset because + # Anthropic requires the default temperature while thinking is on. + model = anthropic(MODEL_ID, thinking=True) + response = model.generate_response(prompt=PROMPT) + usage = getattr(response.raw, "usage", None) + completion_details = getattr(usage, "completion_tokens_details", None) + reasoning_tokens = ( + getattr(completion_details, "reasoning_tokens", None) + if completion_details is not None + else None + ) + + print("Text") + print("----") + print(response.text.strip()) + print() + print("Metadata") + print("--------") + print(f"reasoning_content: {bool(response.reasoning_content)}") + print(f"reasoning_tokens: {reasoning_tokens}") + print(f"thinking_blocks: {len(response.thinking_blocks)}") + print(f"images: {len(response.images)}") + print(f"audio: {bool(response.audio)}") + print(f"output_items: {len(response.output_items)}") + if response.reasoning_content: + print() + print("Reasoning") + print("---------") + print(response.reasoning_content) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/anthropic/07_structured_batch.py b/examples/providers/anthropic/07_structured_batch.py new file mode 100644 index 0000000..22680b1 --- /dev/null +++ b/examples/providers/anthropic/07_structured_batch.py @@ -0,0 +1,35 @@ +"""Anthropic example with batched structured responses.""" + +from dotenv import load_dotenv +from pydantic import BaseModel + +from datafast import anthropic + + +MODEL_ID = "claude-haiku-4-5" +PROMPTS = [ + "Return JSON for Python with fields language, category, and one_sentence_use_case.", + "Return JSON for Rust with fields language, category, and one_sentence_use_case.", + "Return JSON for SQL with fields language, category, and one_sentence_use_case.", +] + + +class LanguageCard(BaseModel): + language: str + category: str + one_sentence_use_case: str + + +def main() -> None: + load_dotenv() + + model = anthropic(MODEL_ID, temperature=0) + responses = model.generate(prompt=PROMPTS, response_format=LanguageCard) + + for response in responses: + print(response.model_dump_json(indent=2)) + print() + + +if __name__ == "__main__": + main() diff --git a/examples/providers/anthropic/08_unsupported_params_policies.py b/examples/providers/anthropic/08_unsupported_params_policies.py new file mode 100644 index 0000000..d167c80 --- /dev/null +++ b/examples/providers/anthropic/08_unsupported_params_policies.py @@ -0,0 +1,53 @@ +"""Anthropic example showing unsupported parameter policies. + +``previous_response_id`` is a Responses-API concept. Anthropic runs on the chat +endpoint, so it is unsupported and flows through the warn/quiet/fail policy. +""" + +from __future__ import annotations + +import warnings + +from dotenv import load_dotenv + +from datafast import anthropic + + +MODEL_ID = "claude-haiku-4-5" +PROMPT = "Explain Anthropic in one short sentence." + + +def run_case(policy: str) -> None: + model = anthropic( + MODEL_ID, + temperature=0, + unsupported_params=policy, + ) + + print(f"Policy: {policy}") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + try: + response = model.generate(prompt=PROMPT, previous_response_id="resp_demo") + except ValueError as exc: + print(f"status: error") + print(f"detail: {exc}") + else: + print("status: ok") + print(f"text: {response.strip()}") + + print(f"warnings: {len(caught)}") + for warning in caught: + print(f"- {warning.message}") + print() + + +def main() -> None: + load_dotenv() + + for policy in ("warn", "quiet", "fail"): + run_case(policy) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/anthropic/09_multimodal_image_input.py b/examples/providers/anthropic/09_multimodal_image_input.py new file mode 100644 index 0000000..c4f3eba --- /dev/null +++ b/examples/providers/anthropic/09_multimodal_image_input.py @@ -0,0 +1,50 @@ +"""Anthropic example with text plus image input. + +The image ships with this example and is sent as base64 bytes via +``ContentPart(data=...)``. Anthropic's server-side URL fetcher cannot reach +every host, so passing bytes directly is the portable way to feed Claude an +image. +""" + +import base64 +from pathlib import Path + +from dotenv import load_dotenv + +from datafast import anthropic +from datafast.llm import ContentPart + + +# Swap this for any Claude model that supports image input. +MODEL_ID = "claude-sonnet-4-6" +IMAGE_PATH = Path(__file__).parent / "sample_ant.jpg" + + +def main() -> None: + load_dotenv() + + image_b64 = base64.standard_b64encode(IMAGE_PATH.read_bytes()).decode("ascii") + messages = [ + { + "role": "user", + "content": [ + ContentPart( + type="text", + text="Describe this image in two short bullet points.", + ), + ContentPart( + type="image", + data=image_b64, + media_type="image/jpeg", + ), + ], + } + ] + + model = anthropic(MODEL_ID, temperature=0) + response = model.generate(messages=messages) + print(response.strip()) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/anthropic/10_raw_vs_normalized_response.py b/examples/providers/anthropic/10_raw_vs_normalized_response.py new file mode 100644 index 0000000..980a717 --- /dev/null +++ b/examples/providers/anthropic/10_raw_vs_normalized_response.py @@ -0,0 +1,70 @@ +"""Anthropic example comparing normalized fields with raw payload fields.""" + +from __future__ import annotations + +from dotenv import load_dotenv + +from datafast import anthropic + + +MODEL_ID = "claude-sonnet-4-6" +PROMPT = ( + "A train travels 60 miles per hour for 2.5 hours. " + "Work it out carefully, then give the final answer in one short sentence." +) + + +def _get_attr_or_key(value, name: str): + if value is None: + return None + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _first_choice_message(raw_response): + choices = _get_attr_or_key(raw_response, "choices") or [] + if not choices: + return None + return _get_attr_or_key(choices[0], "message") + + +def main() -> None: + load_dotenv() + + # Extended thinking is enabled natively; temperature is left unset because + # Anthropic requires the default temperature while thinking is on. + model = anthropic(MODEL_ID, thinking=True) + response = model.generate_response(prompt=PROMPT) + + usage = getattr(response.raw, "usage", None) + message = _first_choice_message(response.raw) + choices = getattr(response.raw, "choices", None) + raw_text = _get_attr_or_key(message, "content") + raw_reasoning = _get_attr_or_key(message, "reasoning_content") + + print("Comparison") + print("----------") + print(f"normalized.text: {response.text.strip()!r}") + print(f"raw choices[0].message.content: {raw_text!r}") + print(f"reasoning_content: {bool(response.reasoning_content)}") + print(f"raw reasoning_content: {bool(raw_reasoning)}") + print(f"thinking_blocks: {len(response.thinking_blocks)}") + if response.reasoning_content: + print() + print("Normalized reasoning") + print("--------------------") + print(response.reasoning_content) + print() + print("Raw") + print("---") + print(f"raw_type: {type(response.raw).__name__}") + print(f"has_usage: {usage is not None}") + print(f"has_choices: {choices is not None}") + if usage is not None: + print(f"prompt_tokens: {getattr(usage, 'prompt_tokens', None)}") + print(f"completion_tokens: {getattr(usage, 'completion_tokens', None)}") + + +if __name__ == "__main__": + main() diff --git a/examples/providers/anthropic/11_timeout_and_rate_limit.py b/examples/providers/anthropic/11_timeout_and_rate_limit.py new file mode 100644 index 0000000..a8c798d --- /dev/null +++ b/examples/providers/anthropic/11_timeout_and_rate_limit.py @@ -0,0 +1,73 @@ +"""Anthropic example showing timeout and rpm_limit across multiple requests.""" + +import time + +from dotenv import load_dotenv + +from datafast import anthropic + + +MODEL_ID = "claude-haiku-4-5" +TIMEOUT_SECONDS = 30 +RPM_LIMIT = 2 +PROMPTS = [ + "Reply with exactly: request one acknowledged.", + "Reply with exactly: request two acknowledged.", + "Reply with exactly: request three acknowledged.", +] + + +def main() -> None: + load_dotenv() + + model = anthropic( + MODEL_ID, + temperature=0, + timeout=TIMEOUT_SECONDS, + rpm_limit=RPM_LIMIT, + ) + + print("Config") + print("------") + print(f"model: {MODEL_ID}") + print(f"timeout: {TIMEOUT_SECONDS}s") + print(f"rpm_limit: {RPM_LIMIT}") + print() + print( + "This script sends three separate requests through one provider instance." + ) + print( + "With rpm_limit=2, the third request should pause for roughly one minute " + "before Datafast sends it." + ) + print() + + started = time.monotonic() + for index, prompt in enumerate(PROMPTS, start=1): + request_started = time.monotonic() + response = model.generate(prompt=prompt) + request_elapsed = time.monotonic() - request_started + + print(f"Request {index}") + print(f"prompt: {prompt}") + print(f"response: {response}") + print(f"call_elapsed_seconds: {request_elapsed:.2f}") + print() + + elapsed = time.monotonic() - started + + print("Notes") + print("-----") + print(f"total_elapsed_seconds: {elapsed:.2f}") + print( + "Datafast forwards timeout on each request and enforces rpm_limit on the " + "provider instance before the next request is sent." + ) + print( + "Requests 1 and 2 should complete normally. Request 3 should be the one " + "that clearly shows client-side throttling." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/anthropic/README.md b/examples/providers/anthropic/README.md new file mode 100644 index 0000000..6be2d86 --- /dev/null +++ b/examples/providers/anthropic/README.md @@ -0,0 +1,42 @@ +# Anthropic Examples + +Requirements: + +- `ANTHROPIC_API_KEY` set in your environment or `.env` + +Notes: + +- Datafast suppresses LiteLLM's provider help banner by default for cleaner example + output. +- Set `DATAFAST_LITELLM_SUPPRESS_DEBUG_INFO=0` if you want LiteLLM to print that + extra provider/debug information while troubleshooting. + +Run: + +```bash +.venv/bin/python examples/providers/anthropic/01_simple_prompt.py +.venv/bin/python examples/providers/anthropic/02_batch_prompts.py +.venv/bin/python examples/providers/anthropic/03_messages_with_system_prompt.py +.venv/bin/python examples/providers/anthropic/04_structured_output.py +.venv/bin/python examples/providers/anthropic/05_batch_messages.py +.venv/bin/python examples/providers/anthropic/06_generation_metadata.py +.venv/bin/python examples/providers/anthropic/07_structured_batch.py +.venv/bin/python examples/providers/anthropic/08_unsupported_params_policies.py +.venv/bin/python examples/providers/anthropic/09_multimodal_image_input.py +.venv/bin/python examples/providers/anthropic/10_raw_vs_normalized_response.py +.venv/bin/python examples/providers/anthropic/11_timeout_and_rate_limit.py +``` + +Files: + +- `01_simple_prompt.py`: one prompt, one response +- `02_batch_prompts.py`: a list of prompts sent through one `generate(...)` call +- `03_messages_with_system_prompt.py`: chat messages with a system instruction +- `04_structured_output.py`: validated Pydantic output +- `05_batch_messages.py`: a batch of independent message lists +- `06_generation_metadata.py`: `generate_response(...)` and normalized metadata with native extended thinking +- `07_structured_batch.py`: batched structured responses +- `08_unsupported_params_policies.py`: `warn`, `quiet`, and `fail` handling for an unsupported parameter +- `09_multimodal_image_input.py`: text plus image input using `ContentPart`, sending the bundled `sample_ant.jpg` as base64 bytes +- `10_raw_vs_normalized_response.py`: compare normalized fields with the underlying raw payload fields +- `11_timeout_and_rate_limit.py`: three separate requests through one provider instance, with `rpm_limit=2` so the third request shows client-side throttling diff --git a/examples/providers/anthropic/sample_ant.jpg b/examples/providers/anthropic/sample_ant.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c94955a142d264d7938819e55ffee5a42c602f4a GIT binary patch literal 15642 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 St+W`r=Jh5KV$c7*`#%5(LS(K0 literal 0 HcmV?d00001 From 7894876766126d7e303425777951fd5167629eb2 Mon Sep 17 00:00:00 2001 From: Patrick <pfleith.pro@gmail.com> Date: Mon, 13 Jul 2026 19:00:34 +0200 Subject: [PATCH 47/54] docs(examples): add mistral provider example suite - 11 runnable scripts plus README, including magistral reasoning via reasoning_effort --- .../providers/mistral/01_simple_prompt.py | 22 ++++++ .../providers/mistral/02_batch_prompts.py | 26 +++++++ .../mistral/03_messages_with_system_prompt.py | 31 ++++++++ .../providers/mistral/04_structured_output.py | 28 +++++++ .../providers/mistral/05_batch_messages.py | 40 ++++++++++ .../mistral/06_generation_metadata.py | 54 +++++++++++++ .../providers/mistral/07_structured_batch.py | 35 +++++++++ .../mistral/08_unsupported_params_policies.py | 55 +++++++++++++ .../mistral/09_multimodal_image_input.py | 49 ++++++++++++ .../mistral/10_raw_vs_normalized_response.py | 61 +++++++++++++++ .../mistral/11_timeout_and_rate_limit.py | 73 ++++++++++++++++++ examples/providers/mistral/README.md | 42 ++++++++++ examples/providers/mistral/sample_tower.jpg | Bin 0 -> 112385 bytes 13 files changed, 516 insertions(+) create mode 100644 examples/providers/mistral/01_simple_prompt.py create mode 100644 examples/providers/mistral/02_batch_prompts.py create mode 100644 examples/providers/mistral/03_messages_with_system_prompt.py create mode 100644 examples/providers/mistral/04_structured_output.py create mode 100644 examples/providers/mistral/05_batch_messages.py create mode 100644 examples/providers/mistral/06_generation_metadata.py create mode 100644 examples/providers/mistral/07_structured_batch.py create mode 100644 examples/providers/mistral/08_unsupported_params_policies.py create mode 100644 examples/providers/mistral/09_multimodal_image_input.py create mode 100644 examples/providers/mistral/10_raw_vs_normalized_response.py create mode 100644 examples/providers/mistral/11_timeout_and_rate_limit.py create mode 100644 examples/providers/mistral/README.md create mode 100644 examples/providers/mistral/sample_tower.jpg diff --git a/examples/providers/mistral/01_simple_prompt.py b/examples/providers/mistral/01_simple_prompt.py new file mode 100644 index 0000000..1032f05 --- /dev/null +++ b/examples/providers/mistral/01_simple_prompt.py @@ -0,0 +1,22 @@ +"""Minimal Mistral example with a single prompt.""" + +from dotenv import load_dotenv + +from datafast import mistral +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "mistral-small-2603" +PROMPT = "Write one sentence explaining what Mistral AI is." + + +def main() -> None: + load_dotenv() + + model = mistral(MODEL_ID, temperature=0) + response = model.generate(prompt=PROMPT) + print(format_generated_responses(PROMPT, response)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/mistral/02_batch_prompts.py b/examples/providers/mistral/02_batch_prompts.py new file mode 100644 index 0000000..6709ce1 --- /dev/null +++ b/examples/providers/mistral/02_batch_prompts.py @@ -0,0 +1,26 @@ +"""Minimal Mistral example with a batch of prompts.""" + +from dotenv import load_dotenv + +from datafast import mistral +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "mistral-small-2603" +PROMPTS = [ + "Give a one-sentence definition of synthetic data.", + "Give a one-sentence definition of retrieval-augmented generation.", + "Give a one-sentence definition of tool calling.", +] + + +def main() -> None: + load_dotenv() + + model = mistral(MODEL_ID, temperature=0) + responses = model.generate(prompt=PROMPTS) + print(format_generated_responses(PROMPTS, responses)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/mistral/03_messages_with_system_prompt.py b/examples/providers/mistral/03_messages_with_system_prompt.py new file mode 100644 index 0000000..c7e64c9 --- /dev/null +++ b/examples/providers/mistral/03_messages_with_system_prompt.py @@ -0,0 +1,31 @@ +"""Mistral example using explicit chat messages.""" + +from dotenv import load_dotenv + +from datafast import mistral +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "mistral-small-2603" +MESSAGES = [ + { + "role": "system", + "content": "You are a concise technical assistant. Answer in exactly two bullets.", + }, + { + "role": "user", + "content": "Explain why teams use Mistral for structured data generation.", + }, +] + + +def main() -> None: + load_dotenv() + + model = mistral(MODEL_ID, temperature=0) + response = model.generate(messages=MESSAGES) + print(format_generated_responses(MESSAGES[-1]["content"], response)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/mistral/04_structured_output.py b/examples/providers/mistral/04_structured_output.py new file mode 100644 index 0000000..2baeb9e --- /dev/null +++ b/examples/providers/mistral/04_structured_output.py @@ -0,0 +1,28 @@ +"""Mistral example with structured output validation.""" + +from dotenv import load_dotenv +from pydantic import BaseModel + +from datafast import mistral + + +MODEL_ID = "mistral-small-2603" +PROMPT = "Return a JSON object describing Mistral AI in two short sentences." + + +class ProviderSummary(BaseModel): + name: str + summary: str + best_for: str + + +def main() -> None: + load_dotenv() + + model = mistral(MODEL_ID, temperature=0) + response = model.generate(prompt=PROMPT, response_format=ProviderSummary) + print(response.model_dump_json(indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/mistral/05_batch_messages.py b/examples/providers/mistral/05_batch_messages.py new file mode 100644 index 0000000..8d9b887 --- /dev/null +++ b/examples/providers/mistral/05_batch_messages.py @@ -0,0 +1,40 @@ +"""Mistral example with a batch of message lists.""" + +from dotenv import load_dotenv + +from datafast import mistral +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "mistral-small-2603" +BATCH_MESSAGES = [ + [ + { + "role": "system", + "content": "You answer for engineers in one sentence.", + }, + { + "role": "user", + "content": "What is prompt caching?", + }, + ], + [ + { + "role": "user", + "content": "What is structured output?", + }, + ], +] + + +def main() -> None: + load_dotenv() + + model = mistral(MODEL_ID, temperature=0) + responses = model.generate(messages=BATCH_MESSAGES) + prompts = [messages[-1]["content"] for messages in BATCH_MESSAGES] + print(format_generated_responses(prompts, responses)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/mistral/06_generation_metadata.py b/examples/providers/mistral/06_generation_metadata.py new file mode 100644 index 0000000..1c6d885 --- /dev/null +++ b/examples/providers/mistral/06_generation_metadata.py @@ -0,0 +1,54 @@ +"""Mistral example returning normalized response metadata. + +Reasoning is opt-in on Mistral: mistral-medium-3-5 and mistral-small (and the +magistral family) support a `reasoning_effort` parameter. With +reasoning_effort="high" the API returns a thinking chunk (the reasoning trace) +before the final text, which Datafast normalizes into `reasoning_content`. This +example requests reasoning so those fields populate, showing the full metadata +surface that Datafast exposes uniformly across providers. +""" + +from dotenv import load_dotenv + +from datafast import mistral + + +MODEL_ID = "mistral-medium-3-5" +PROMPT = ( + "A train travels 60 miles per hour for 2.5 hours. " + "Work it out carefully, then give the final answer in one short sentence." +) + + +def main() -> None: + load_dotenv() + + # Reasoning mode samples, so avoid temperature=0 (Mistral rejects greedy + # sampling while reasoning); a small positive temperature keeps it focused. + model = mistral(MODEL_ID, temperature=0.3, reasoning_effort="high") + response = model.generate_response(prompt=PROMPT) + usage = getattr(response.raw, "usage", None) + completion_details = getattr(usage, "completion_tokens_details", None) + reasoning_tokens = ( + getattr(completion_details, "reasoning_tokens", None) + if completion_details is not None + else None + ) + + print("Text") + print("----") + print(response.text.strip()) + print() + print("Metadata") + print("--------") + reasoning = response.reasoning_content or "" + print(f"reasoning_content: {bool(reasoning)} ({len(reasoning)} chars)") + print(f"reasoning_tokens: {reasoning_tokens}") + print(f"thinking_blocks: {len(response.thinking_blocks)}") + print(f"images: {len(response.images)}") + print(f"audio: {bool(response.audio)}") + print(f"output_items: {len(response.output_items)}") + + +if __name__ == "__main__": + main() diff --git a/examples/providers/mistral/07_structured_batch.py b/examples/providers/mistral/07_structured_batch.py new file mode 100644 index 0000000..ad420c3 --- /dev/null +++ b/examples/providers/mistral/07_structured_batch.py @@ -0,0 +1,35 @@ +"""Mistral example with batched structured responses.""" + +from dotenv import load_dotenv +from pydantic import BaseModel + +from datafast import mistral + + +MODEL_ID = "mistral-small-2603" +PROMPTS = [ + "Return JSON for Python with fields language, category, and one_sentence_use_case.", + "Return JSON for Rust with fields language, category, and one_sentence_use_case.", + "Return JSON for SQL with fields language, category, and one_sentence_use_case.", +] + + +class LanguageCard(BaseModel): + language: str + category: str + one_sentence_use_case: str + + +def main() -> None: + load_dotenv() + + model = mistral(MODEL_ID, temperature=0) + responses = model.generate(prompt=PROMPTS, response_format=LanguageCard) + + for response in responses: + print(response.model_dump_json(indent=2)) + print() + + +if __name__ == "__main__": + main() diff --git a/examples/providers/mistral/08_unsupported_params_policies.py b/examples/providers/mistral/08_unsupported_params_policies.py new file mode 100644 index 0000000..b270558 --- /dev/null +++ b/examples/providers/mistral/08_unsupported_params_policies.py @@ -0,0 +1,55 @@ +"""Mistral example showing unsupported parameter policies. + +Mistral chat models do not expose a reasoning-effort control, so +``reasoning_effort`` is unsupported and flows through the warn/quiet/fail policy. +""" + +from __future__ import annotations + +import warnings + +from dotenv import load_dotenv + +from datafast import mistral + + +MODEL_ID = "mistral-small-2603" +PROMPT = "Explain Mistral AI in one short sentence." +REASONING_EFFORT = "high" + + +def run_case(policy: str) -> None: + model = mistral( + MODEL_ID, + temperature=0, + reasoning_effort=REASONING_EFFORT, + unsupported_params=policy, + ) + + print(f"Policy: {policy}") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + try: + response = model.generate(prompt=PROMPT) + except ValueError as exc: + print(f"status: error") + print(f"detail: {exc}") + else: + print("status: ok") + print(f"text: {response.strip()}") + + print(f"warnings: {len(caught)}") + for warning in caught: + print(f"- {warning.message}") + print() + + +def main() -> None: + load_dotenv() + + for policy in ("warn", "quiet", "fail"): + run_case(policy) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/mistral/09_multimodal_image_input.py b/examples/providers/mistral/09_multimodal_image_input.py new file mode 100644 index 0000000..64dba2e --- /dev/null +++ b/examples/providers/mistral/09_multimodal_image_input.py @@ -0,0 +1,49 @@ +"""Mistral example with text plus image input. + +The image ships with this example and is sent as base64 bytes via +``ContentPart(data=...)``, so the request does not depend on Mistral being able +to fetch a remote URL. +""" + +import base64 +from pathlib import Path + +from dotenv import load_dotenv + +from datafast import mistral +from datafast.llm import ContentPart + + +# Swap this for any Mistral model that supports image input. +MODEL_ID = "mistral-medium-3-5" +IMAGE_PATH = Path(__file__).parent / "sample_tower.jpg" + + +def main() -> None: + load_dotenv() + + image_b64 = base64.standard_b64encode(IMAGE_PATH.read_bytes()).decode("ascii") + messages = [ + { + "role": "user", + "content": [ + ContentPart( + type="text", + text="Describe this image in two short bullet points.", + ), + ContentPart( + type="image", + data=image_b64, + media_type="image/jpeg", + ), + ], + } + ] + + model = mistral(MODEL_ID, temperature=0) + response = model.generate(messages=messages) + print(response.strip()) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/mistral/10_raw_vs_normalized_response.py b/examples/providers/mistral/10_raw_vs_normalized_response.py new file mode 100644 index 0000000..7f42b66 --- /dev/null +++ b/examples/providers/mistral/10_raw_vs_normalized_response.py @@ -0,0 +1,61 @@ +"""Mistral example comparing normalized fields with raw payload fields.""" + +from __future__ import annotations + +from dotenv import load_dotenv + +from datafast import mistral + + +MODEL_ID = "mistral-medium-3-5" +PROMPT = ( + "A train travels 60 miles per hour for 2.5 hours. " + "Work it out carefully, then give the final answer in one short sentence." +) + + +def _get_attr_or_key(value, name: str): + if value is None: + return None + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _first_choice_message(raw_response): + choices = _get_attr_or_key(raw_response, "choices") or [] + if not choices: + return None + return _get_attr_or_key(choices[0], "message") + + +def main() -> None: + load_dotenv() + + model = mistral(MODEL_ID, temperature=0) + response = model.generate_response(prompt=PROMPT) + + usage = getattr(response.raw, "usage", None) + message = _first_choice_message(response.raw) + choices = getattr(response.raw, "choices", None) + raw_text = _get_attr_or_key(message, "content") + + print("Comparison") + print("----------") + print(f"normalized.text: {response.text.strip()!r}") + print(f"raw choices[0].message.content: {raw_text!r}") + print(f"reasoning_content: {bool(response.reasoning_content)}") + print(f"thinking_blocks: {len(response.thinking_blocks)}") + print() + print("Raw") + print("---") + print(f"raw_type: {type(response.raw).__name__}") + print(f"has_usage: {usage is not None}") + print(f"has_choices: {choices is not None}") + if usage is not None: + print(f"prompt_tokens: {getattr(usage, 'prompt_tokens', None)}") + print(f"completion_tokens: {getattr(usage, 'completion_tokens', None)}") + + +if __name__ == "__main__": + main() diff --git a/examples/providers/mistral/11_timeout_and_rate_limit.py b/examples/providers/mistral/11_timeout_and_rate_limit.py new file mode 100644 index 0000000..565e919 --- /dev/null +++ b/examples/providers/mistral/11_timeout_and_rate_limit.py @@ -0,0 +1,73 @@ +"""Mistral example showing timeout and rpm_limit across multiple requests.""" + +import time + +from dotenv import load_dotenv + +from datafast import mistral + + +MODEL_ID = "mistral-small-2603" +TIMEOUT_SECONDS = 30 +RPM_LIMIT = 2 +PROMPTS = [ + "Reply with exactly: request one acknowledged.", + "Reply with exactly: request two acknowledged.", + "Reply with exactly: request three acknowledged.", +] + + +def main() -> None: + load_dotenv() + + model = mistral( + MODEL_ID, + temperature=0, + timeout=TIMEOUT_SECONDS, + rpm_limit=RPM_LIMIT, + ) + + print("Config") + print("------") + print(f"model: {MODEL_ID}") + print(f"timeout: {TIMEOUT_SECONDS}s") + print(f"rpm_limit: {RPM_LIMIT}") + print() + print( + "This script sends three separate requests through one provider instance." + ) + print( + "With rpm_limit=2, the third request should pause for roughly one minute " + "before Datafast sends it." + ) + print() + + started = time.monotonic() + for index, prompt in enumerate(PROMPTS, start=1): + request_started = time.monotonic() + response = model.generate(prompt=prompt) + request_elapsed = time.monotonic() - request_started + + print(f"Request {index}") + print(f"prompt: {prompt}") + print(f"response: {response}") + print(f"call_elapsed_seconds: {request_elapsed:.2f}") + print() + + elapsed = time.monotonic() - started + + print("Notes") + print("-----") + print(f"total_elapsed_seconds: {elapsed:.2f}") + print( + "Datafast forwards timeout on each request and enforces rpm_limit on the " + "provider instance before the next request is sent." + ) + print( + "Requests 1 and 2 should complete normally. Request 3 should be the one " + "that clearly shows client-side throttling." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/mistral/README.md b/examples/providers/mistral/README.md new file mode 100644 index 0000000..08c566d --- /dev/null +++ b/examples/providers/mistral/README.md @@ -0,0 +1,42 @@ +# Mistral Examples + +Requirements: + +- `MISTRAL_API_KEY` set in your environment or `.env` + +Notes: + +- Datafast suppresses LiteLLM's provider help banner by default for cleaner example + output. +- Set `DATAFAST_LITELLM_SUPPRESS_DEBUG_INFO=0` if you want LiteLLM to print that + extra provider/debug information while troubleshooting. + +Run: + +```bash +.venv/bin/python examples/providers/mistral/01_simple_prompt.py +.venv/bin/python examples/providers/mistral/02_batch_prompts.py +.venv/bin/python examples/providers/mistral/03_messages_with_system_prompt.py +.venv/bin/python examples/providers/mistral/04_structured_output.py +.venv/bin/python examples/providers/mistral/05_batch_messages.py +.venv/bin/python examples/providers/mistral/06_generation_metadata.py +.venv/bin/python examples/providers/mistral/07_structured_batch.py +.venv/bin/python examples/providers/mistral/08_unsupported_params_policies.py +.venv/bin/python examples/providers/mistral/09_multimodal_image_input.py +.venv/bin/python examples/providers/mistral/10_raw_vs_normalized_response.py +.venv/bin/python examples/providers/mistral/11_timeout_and_rate_limit.py +``` + +Files: + +- `01_simple_prompt.py`: one prompt, one response +- `02_batch_prompts.py`: a list of prompts sent through one `generate(...)` call +- `03_messages_with_system_prompt.py`: chat messages with a system instruction +- `04_structured_output.py`: validated Pydantic output +- `05_batch_messages.py`: a batch of independent message lists +- `06_generation_metadata.py`: `generate_response(...)` and the normalized metadata surface, requesting reasoning via `reasoning_effort="high"` so `reasoning_content` is populated +- `07_structured_batch.py`: batched structured responses +- `08_unsupported_params_policies.py`: `warn`, `quiet`, and `fail` handling for an unsupported parameter +- `09_multimodal_image_input.py`: text plus image input using `ContentPart`, sending the bundled `sample_tower.jpg` as base64 bytes +- `10_raw_vs_normalized_response.py`: compare normalized fields with the underlying raw payload fields +- `11_timeout_and_rate_limit.py`: three separate requests through one provider instance, with `rpm_limit=2` so the third request shows client-side throttling diff --git a/examples/providers/mistral/sample_tower.jpg b/examples/providers/mistral/sample_tower.jpg new file mode 100644 index 0000000000000000000000000000000000000000..aec3fa94eedf13f6e0c4ef56e4f669a3dba59fd8 GIT binary patch literal 112385 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�*bJc;@ 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 F|JhBH+pYir literal 0 HcmV?d00001 From 9f41b0ad79630a73570f612f7f71bde77e06e041 Mon Sep 17 00:00:00 2001 From: Patrick <pfleith.pro@gmail.com> Date: Mon, 13 Jul 2026 19:00:42 +0200 Subject: [PATCH 48/54] docs(examples): add ollama provider example suite - 11 runnable scripts plus README for local Ollama, with first-class reasoning on thinking-capable models - list the new openai/anthropic/mistral/ollama suites in the providers README --- examples/providers/README.md | 4 + examples/providers/ollama/01_simple_prompt.py | 26 ++++++ examples/providers/ollama/02_batch_prompts.py | 31 +++++++ .../ollama/03_messages_with_system_prompt.py | 31 +++++++ .../providers/ollama/04_structured_output.py | 33 ++++++++ .../providers/ollama/05_batch_messages.py | 44 ++++++++++ .../ollama/06_generation_metadata.py | 46 ++++++++++ .../providers/ollama/07_structured_batch.py | 39 +++++++++ .../ollama/08_unsupported_params_policies.py | 57 +++++++++++++ .../ollama/09_multimodal_image_input.py | 49 +++++++++++ .../ollama/10_raw_vs_normalized_response.py | 74 ++++++++++++++++ .../ollama/11_timeout_and_rate_limit.py | 79 ++++++++++++++++++ examples/providers/ollama/README.md | 60 +++++++++++++ .../providers/ollama/sample_sunflower.jpg | Bin 0 -> 39288 bytes 14 files changed, 573 insertions(+) create mode 100644 examples/providers/ollama/01_simple_prompt.py create mode 100644 examples/providers/ollama/02_batch_prompts.py create mode 100644 examples/providers/ollama/03_messages_with_system_prompt.py create mode 100644 examples/providers/ollama/04_structured_output.py create mode 100644 examples/providers/ollama/05_batch_messages.py create mode 100644 examples/providers/ollama/06_generation_metadata.py create mode 100644 examples/providers/ollama/07_structured_batch.py create mode 100644 examples/providers/ollama/08_unsupported_params_policies.py create mode 100644 examples/providers/ollama/09_multimodal_image_input.py create mode 100644 examples/providers/ollama/10_raw_vs_normalized_response.py create mode 100644 examples/providers/ollama/11_timeout_and_rate_limit.py create mode 100644 examples/providers/ollama/README.md create mode 100644 examples/providers/ollama/sample_sunflower.jpg diff --git a/examples/providers/README.md b/examples/providers/README.md index 5c9e4c7..0acd3eb 100644 --- a/examples/providers/README.md +++ b/examples/providers/README.md @@ -3,6 +3,10 @@ This folder contains direct, provider-focused examples. - `openrouter/`: simple OpenRouter calls with `model.generate(...)` +- `anthropic/`: simple Anthropic calls with `model.generate(...)` +- `openai/`: simple OpenAI calls with `model.generate(...)` +- `mistral/`: simple Mistral calls with `model.generate(...)` +- `ollama/`: simple local Ollama calls with `model.generate(...)` These scripts are intentionally separate from `examples/scripts/`, which focuses on pipeline usage. diff --git a/examples/providers/ollama/01_simple_prompt.py b/examples/providers/ollama/01_simple_prompt.py new file mode 100644 index 0000000..53400a9 --- /dev/null +++ b/examples/providers/ollama/01_simple_prompt.py @@ -0,0 +1,26 @@ +"""Minimal Ollama example with a single prompt. + +Ollama runs locally and needs no API key. Make sure the Ollama server is +running and the model is pulled: `ollama pull gemma4:12b`. +""" + +from dotenv import load_dotenv + +from datafast import ollama +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "gemma4:12b" +PROMPT = "Write one sentence explaining what a local LLM is." + + +def main() -> None: + load_dotenv() + + model = ollama(MODEL_ID, temperature=0) + response = model.generate(prompt=PROMPT) + print(format_generated_responses(PROMPT, response)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/ollama/02_batch_prompts.py b/examples/providers/ollama/02_batch_prompts.py new file mode 100644 index 0000000..1b29ae7 --- /dev/null +++ b/examples/providers/ollama/02_batch_prompts.py @@ -0,0 +1,31 @@ +"""Ollama example with a batch of prompts. + +Ollama has no native batch endpoint, so Datafast falls back to bounded parallel +single requests and emits a UserWarning. On a small machine keep concurrency low +(`max_concurrent=1`) so you don't run several 12B generations at once. +""" + +from dotenv import load_dotenv + +from datafast import ollama +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "gemma4:12b" +PROMPTS = [ + "Give a one-sentence definition of synthetic data.", + "Give a one-sentence definition of retrieval-augmented generation.", + "Give a one-sentence definition of tool calling.", +] + + +def main() -> None: + load_dotenv() + + model = ollama(MODEL_ID, temperature=0, max_concurrent=1) + responses = model.generate(prompt=PROMPTS) + print(format_generated_responses(PROMPTS, responses)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/ollama/03_messages_with_system_prompt.py b/examples/providers/ollama/03_messages_with_system_prompt.py new file mode 100644 index 0000000..c8d45cd --- /dev/null +++ b/examples/providers/ollama/03_messages_with_system_prompt.py @@ -0,0 +1,31 @@ +"""Ollama example using explicit chat messages.""" + +from dotenv import load_dotenv + +from datafast import ollama +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "gemma4:12b" +MESSAGES = [ + { + "role": "system", + "content": "You are a concise technical assistant. Answer in exactly two bullets.", + }, + { + "role": "user", + "content": "Explain why teams run local models with Ollama for data generation.", + }, +] + + +def main() -> None: + load_dotenv() + + model = ollama(MODEL_ID, temperature=0) + response = model.generate(messages=MESSAGES) + print(format_generated_responses(MESSAGES[-1]["content"], response)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/ollama/04_structured_output.py b/examples/providers/ollama/04_structured_output.py new file mode 100644 index 0000000..5b33752 --- /dev/null +++ b/examples/providers/ollama/04_structured_output.py @@ -0,0 +1,33 @@ +"""Ollama example with structured output validation. + +Ollama supports schema-constrained decoding, so Datafast passes the Pydantic +schema through as the response format and validates the result. Constrained +decoding is what makes even a local model reliably return valid JSON. +""" + +from dotenv import load_dotenv +from pydantic import BaseModel + +from datafast import ollama + + +MODEL_ID = "gemma4:12b" +PROMPT = "Return a JSON object describing the Ollama runtime in two short sentences." + + +class ProviderSummary(BaseModel): + name: str + summary: str + best_for: str + + +def main() -> None: + load_dotenv() + + model = ollama(MODEL_ID, temperature=0) + response = model.generate(prompt=PROMPT, response_format=ProviderSummary) + print(response.model_dump_json(indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/ollama/05_batch_messages.py b/examples/providers/ollama/05_batch_messages.py new file mode 100644 index 0000000..9e5fc07 --- /dev/null +++ b/examples/providers/ollama/05_batch_messages.py @@ -0,0 +1,44 @@ +"""Ollama example with a batch of message lists. + +Same as the batch-prompts example, this uses Datafast's bounded-concurrency +fallback (Ollama has no native batch endpoint) and emits a UserWarning. +""" + +from dotenv import load_dotenv + +from datafast import ollama +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "gemma4:12b" +BATCH_MESSAGES = [ + [ + { + "role": "system", + "content": "You answer for engineers in one sentence.", + }, + { + "role": "user", + "content": "What is prompt caching?", + }, + ], + [ + { + "role": "user", + "content": "What is structured output?", + }, + ], +] + + +def main() -> None: + load_dotenv() + + model = ollama(MODEL_ID, temperature=0, max_concurrent=1) + responses = model.generate(messages=BATCH_MESSAGES) + prompts = [messages[-1]["content"] for messages in BATCH_MESSAGES] + print(format_generated_responses(prompts, responses)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/ollama/06_generation_metadata.py b/examples/providers/ollama/06_generation_metadata.py new file mode 100644 index 0000000..3104973 --- /dev/null +++ b/examples/providers/ollama/06_generation_metadata.py @@ -0,0 +1,46 @@ +"""Ollama example returning normalized response metadata. + +Reasoning is a first-class control for thinking-capable Ollama models +(gemma4, deepseek-r1, qwen3, gpt-oss, ...). Datafast forwards `thinking` and +`reasoning_effort` to Ollama's `think` parameter (LiteLLM handles the mapping) +and normalizes the thinking trace into `reasoning_content`. For gpt-oss the +effort level (low/medium/high) tunes the trace; other thinking models treat any +level as on/off. Models without a thinking mode have no reasoning control (see +the unsupported-params example). +""" + +from dotenv import load_dotenv + +from datafast import ollama + + +MODEL_ID = "gemma4:12b" +PROMPT = ( + "A train travels 60 miles per hour for 2.5 hours. " + "Work it out carefully, then give the final answer in one short sentence." +) + + +def print_metadata(title: str, response) -> None: + print(title) + print("-" * len(title)) + print(f"text: {response.text.strip()}") + print(f"reasoning_content: {bool(response.reasoning_content)}") + if response.reasoning_content: + print(f"reasoning_preview: {response.reasoning_content[:200]}") + print(f"thinking_blocks: {len(response.thinking_blocks)}") + print(f"images: {len(response.images)}") + print(f"audio: {bool(response.audio)}") + print(f"output_items: {len(response.output_items)}") + print() + + +def main() -> None: + load_dotenv() + + model = ollama(MODEL_ID, temperature=0, reasoning_effort="high") + print_metadata("Reasoning enabled", model.generate_response(prompt=PROMPT)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/ollama/07_structured_batch.py b/examples/providers/ollama/07_structured_batch.py new file mode 100644 index 0000000..0fafdd9 --- /dev/null +++ b/examples/providers/ollama/07_structured_batch.py @@ -0,0 +1,39 @@ +"""Ollama example with batched structured responses. + +Combines schema-constrained decoding with the bounded-concurrency batch fallback. +Kept at `max_concurrent=1` so a small machine runs the generations one at a time. +""" + +from dotenv import load_dotenv +from pydantic import BaseModel + +from datafast import ollama + + +MODEL_ID = "gemma4:12b" +PROMPTS = [ + "Return JSON for Python with fields language, category, and one_sentence_use_case.", + "Return JSON for Rust with fields language, category, and one_sentence_use_case.", + "Return JSON for SQL with fields language, category, and one_sentence_use_case.", +] + + +class LanguageCard(BaseModel): + language: str + category: str + one_sentence_use_case: str + + +def main() -> None: + load_dotenv() + + model = ollama(MODEL_ID, temperature=0, max_concurrent=1) + responses = model.generate(prompt=PROMPTS, response_format=LanguageCard) + + for response in responses: + print(response.model_dump_json(indent=2)) + print() + + +if __name__ == "__main__": + main() diff --git a/examples/providers/ollama/08_unsupported_params_policies.py b/examples/providers/ollama/08_unsupported_params_policies.py new file mode 100644 index 0000000..064ac64 --- /dev/null +++ b/examples/providers/ollama/08_unsupported_params_policies.py @@ -0,0 +1,57 @@ +"""Ollama example showing unsupported parameter policies. + +`ministral-3:3b` is not a thinking-capable model, so its profile does not map a +`reasoning_effort` control and the parameter flows through the warn/quiet/fail +policy. Thinking models (gemma4, deepseek-r1, qwen3, gpt-oss, ...) map it as a +first-class reasoning control instead — see the metadata example. +""" + +from __future__ import annotations + +import warnings + +from dotenv import load_dotenv + +from datafast import ollama + + +MODEL_ID = "ministral-3:3b" +PROMPT = "Explain what Ollama is in one short sentence." +REASONING_EFFORT = "high" + + +def run_case(policy: str) -> None: + model = ollama( + MODEL_ID, + temperature=0, + reasoning_effort=REASONING_EFFORT, + unsupported_params=policy, + ) + + print(f"Policy: {policy}") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + try: + response = model.generate(prompt=PROMPT) + except ValueError as exc: + print("status: error") + print(f"detail: {exc}") + else: + print("status: ok") + print(f"text: {response.strip()}") + + print(f"warnings: {len(caught)}") + for warning in caught: + print(f"- {warning.message}") + print() + + +def main() -> None: + load_dotenv() + + for policy in ("warn", "quiet", "fail"): + run_case(policy) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/ollama/09_multimodal_image_input.py b/examples/providers/ollama/09_multimodal_image_input.py new file mode 100644 index 0000000..d63c410 --- /dev/null +++ b/examples/providers/ollama/09_multimodal_image_input.py @@ -0,0 +1,49 @@ +"""Ollama example with text plus image input. + +Requires a vision-capable Ollama model (gemma4 is multimodal). The image ships +with this example and is sent as base64 bytes via ``ContentPart(data=...)``, so +the request does not depend on Ollama being able to fetch a remote URL. +""" + +import base64 +from pathlib import Path + +from dotenv import load_dotenv + +from datafast import ollama +from datafast.llm import ContentPart + + +# Swap this for any vision-capable Ollama model (e.g. gemma3, llama3.2-vision). +MODEL_ID = "gemma4:12b" +IMAGE_PATH = Path(__file__).parent / "sample_sunflower.jpg" + + +def main() -> None: + load_dotenv() + + image_b64 = base64.standard_b64encode(IMAGE_PATH.read_bytes()).decode("ascii") + messages = [ + { + "role": "user", + "content": [ + ContentPart( + type="text", + text="Describe this image in two short bullet points.", + ), + ContentPart( + type="image", + data=image_b64, + media_type="image/jpeg", + ), + ], + } + ] + + model = ollama(MODEL_ID, temperature=0) + response = model.generate(messages=messages) + print(response.strip()) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/ollama/10_raw_vs_normalized_response.py b/examples/providers/ollama/10_raw_vs_normalized_response.py new file mode 100644 index 0000000..b188381 --- /dev/null +++ b/examples/providers/ollama/10_raw_vs_normalized_response.py @@ -0,0 +1,74 @@ +"""Ollama example comparing normalized fields with raw payload fields. + +The raw payload from a local Ollama call carries timing/token counters that make +it easy to estimate tokens per second, which matters a lot when running a 12B +model on modest hardware. +""" + +from __future__ import annotations + +from dotenv import load_dotenv + +from datafast import ollama + + +MODEL_ID = "gemma4:12b" +PROMPT = ( + "A train travels 60 miles per hour for 2.5 hours. " + "Work it out carefully, then give the final answer in one short sentence." +) + + +def _get_attr_or_key(value, name: str): + if value is None: + return None + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _first_choice_message(raw_response): + choices = _get_attr_or_key(raw_response, "choices") or [] + if not choices: + return None + return _get_attr_or_key(choices[0], "message") + + +def main() -> None: + load_dotenv() + + model = ollama(MODEL_ID, temperature=0) + response = model.generate_response(prompt=PROMPT) + + usage = getattr(response.raw, "usage", None) + message = _first_choice_message(response.raw) + choices = getattr(response.raw, "choices", None) + raw_text = _get_attr_or_key(message, "content") + + print("Comparison") + print("----------") + print(f"normalized.text: {response.text.strip()!r}") + print(f"raw choices[0].message.content: {raw_text!r}") + print(f"reasoning_content: {bool(response.reasoning_content)}") + print(f"thinking_blocks: {len(response.thinking_blocks)}") + print() + print("Raw") + print("---") + print(f"raw_type: {type(response.raw).__name__}") + print(f"has_usage: {usage is not None}") + print(f"has_choices: {choices is not None}") + if usage is not None: + print(f"prompt_tokens: {getattr(usage, 'prompt_tokens', None)}") + print(f"completion_tokens: {getattr(usage, 'completion_tokens', None)}") + + # Ollama-specific timings surface on the raw response or its hidden params + # (in nanoseconds). Print them when the installed LiteLLM version exposes them. + hidden = getattr(response.raw, "_hidden_params", None) or {} + for field in ("eval_count", "eval_duration", "total_duration"): + value = _get_attr_or_key(response.raw, field) or _get_attr_or_key(hidden, field) + if value is not None: + print(f"{field}: {value}") + + +if __name__ == "__main__": + main() diff --git a/examples/providers/ollama/11_timeout_and_rate_limit.py b/examples/providers/ollama/11_timeout_and_rate_limit.py new file mode 100644 index 0000000..2327c04 --- /dev/null +++ b/examples/providers/ollama/11_timeout_and_rate_limit.py @@ -0,0 +1,79 @@ +"""Ollama example showing timeout and rpm_limit across multiple requests. + +`timeout` matters most on the first call, where Ollama loads the model into +memory (a 12B model can take a while to warm up). `rpm_limit` is a client-side +throttle enforced by Datafast; a local Ollama server has no rate limit of its +own, but the control behaves the same as it does for hosted providers. +""" + +import time + +from dotenv import load_dotenv + +from datafast import ollama + + +MODEL_ID = "gemma4:12b" +TIMEOUT_SECONDS = 120 +RPM_LIMIT = 2 +PROMPTS = [ + "Reply with exactly: request one acknowledged.", + "Reply with exactly: request two acknowledged.", + "Reply with exactly: request three acknowledged.", +] + + +def main() -> None: + load_dotenv() + + model = ollama( + MODEL_ID, + temperature=0, + timeout=TIMEOUT_SECONDS, + rpm_limit=RPM_LIMIT, + ) + + print("Config") + print("------") + print(f"model: {MODEL_ID}") + print(f"timeout: {TIMEOUT_SECONDS}s") + print(f"rpm_limit: {RPM_LIMIT}") + print() + print( + "This script sends three separate requests through one provider instance." + ) + print( + "With rpm_limit=2, the third request should pause for roughly one minute " + "before Datafast sends it." + ) + print() + + started = time.monotonic() + for index, prompt in enumerate(PROMPTS, start=1): + request_started = time.monotonic() + response = model.generate(prompt=prompt) + request_elapsed = time.monotonic() - request_started + + print(f"Request {index}") + print(f"prompt: {prompt}") + print(f"response: {response}") + print(f"call_elapsed_seconds: {request_elapsed:.2f}") + print() + + elapsed = time.monotonic() - started + + print("Notes") + print("-----") + print(f"total_elapsed_seconds: {elapsed:.2f}") + print( + "Datafast forwards timeout on each request and enforces rpm_limit on the " + "provider instance before the next request is sent." + ) + print( + "Requests 1 and 2 should complete normally. Request 3 should be the one " + "that clearly shows client-side throttling." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/ollama/README.md b/examples/providers/ollama/README.md new file mode 100644 index 0000000..4e2d8d4 --- /dev/null +++ b/examples/providers/ollama/README.md @@ -0,0 +1,60 @@ +# Ollama Examples + +Requirements: + +- [Ollama](https://ollama.com) installed and its server running (defaults to + `http://localhost:11434`) +- The model pulled locally: `ollama pull gemma4:12b` (~7.6 GB, multimodal). + Example `08` needs a non-reasoning model to show unsupported-param handling — + `ollama pull ministral-3:3b` for it. +- No API key — Ollama runs locally + +Notes: + +- `gemma4:12b` is a capable multimodal reasoning model but is heavy for a small + machine. If it is too slow or too large, the same scripts work unchanged + against a lighter model such as `gemma3:4b` — just change `MODEL_ID`. +- Google's recommended sampling for Gemma is `temperature=1.0`, `top_p=0.95`, + `top_k=64`. The examples use `temperature=0` for stable, repeatable output. + `top_k` is not one of Datafast's mapped parameters, so pass it through the + escape hatch when you want it: `ollama(MODEL_ID, provider_params={"top_k": 64})`. +- Datafast's Ollama profile is deliberately conservative (chat endpoint, text + + image). Thinking-capable models (gemma4, deepseek-r1, qwen3, gpt-oss, ...) get + a mapped reasoning control via `thinking` / `reasoning_effort`. Features the + profile does not map — `top_k`, thinking on models it doesn't recognize — are + reachable via `provider_params`. +- To offload the model to a beefier machine, point at a remote host: + `ollama(MODEL_ID, api_base="http://<host>:11434")`. +- Datafast suppresses LiteLLM's provider help banner by default for cleaner + example output. Set `DATAFAST_LITELLM_SUPPRESS_DEBUG_INFO=0` to see it while + troubleshooting. + +Run: + +```bash +.venv/bin/python examples/providers/ollama/01_simple_prompt.py +.venv/bin/python examples/providers/ollama/02_batch_prompts.py +.venv/bin/python examples/providers/ollama/03_messages_with_system_prompt.py +.venv/bin/python examples/providers/ollama/04_structured_output.py +.venv/bin/python examples/providers/ollama/05_batch_messages.py +.venv/bin/python examples/providers/ollama/06_generation_metadata.py +.venv/bin/python examples/providers/ollama/07_structured_batch.py +.venv/bin/python examples/providers/ollama/08_unsupported_params_policies.py +.venv/bin/python examples/providers/ollama/09_multimodal_image_input.py +.venv/bin/python examples/providers/ollama/10_raw_vs_normalized_response.py +.venv/bin/python examples/providers/ollama/11_timeout_and_rate_limit.py +``` + +Files: + +- `01_simple_prompt.py`: one prompt, one response +- `02_batch_prompts.py`: a list of prompts through one `generate(...)` call; Ollama has no native batch, so Datafast falls back to bounded concurrency (`max_concurrent=1`) +- `03_messages_with_system_prompt.py`: chat messages with a system instruction +- `04_structured_output.py`: validated Pydantic output via schema-constrained decoding +- `05_batch_messages.py`: a batch of independent message lists (bounded-concurrency fallback) +- `06_generation_metadata.py`: `generate_response(...)` metadata for a thinking model, with first-class reasoning via `reasoning_effort="high"` normalized into `reasoning_content` +- `07_structured_batch.py`: batched structured responses +- `08_unsupported_params_policies.py`: `warn`, `quiet`, and `fail` handling for `reasoning_effort` on a non-reasoning model (`ministral-3:3b`), which does not map it +- `09_multimodal_image_input.py`: text plus image input using `ContentPart`, sending the bundled `sample_sunflower.jpg` as base64 bytes (needs a vision-capable model) +- `10_raw_vs_normalized_response.py`: compare normalized fields with the raw payload, including Ollama's timing/token counters +- `11_timeout_and_rate_limit.py`: three separate requests through one provider instance, with `rpm_limit=2` so the third request shows client-side throttling diff --git a/examples/providers/ollama/sample_sunflower.jpg b/examples/providers/ollama/sample_sunflower.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1c02c87007df65e3e397ac764fe5fad52a28c34d GIT binary patch literal 39288 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≻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 literal 0 HcmV?d00001 From 14efda95006cf4c2a06ec0e74a6e5e30c7109960 Mon Sep 17 00:00:00 2001 From: Patrick <pfleith.pro@gmail.com> Date: Mon, 13 Jul 2026 19:00:42 +0200 Subject: [PATCH 49/54] docs(examples): send bundled image in openrouter multimodal example - ship sample_sunflower.jpg and send it as base64 so the request no longer depends on a routed provider fetching a remote URL --- .../openrouter/09_multimodal_image_input.py | 50 ++++++++++-------- examples/providers/openrouter/README.md | 2 +- .../providers/openrouter/sample_sunflower.jpg | Bin 0 -> 39288 bytes 3 files changed, 30 insertions(+), 22 deletions(-) create mode 100644 examples/providers/openrouter/sample_sunflower.jpg diff --git a/examples/providers/openrouter/09_multimodal_image_input.py b/examples/providers/openrouter/09_multimodal_image_input.py index ed1d3c3..49009d0 100644 --- a/examples/providers/openrouter/09_multimodal_image_input.py +++ b/examples/providers/openrouter/09_multimodal_image_input.py @@ -1,4 +1,12 @@ -"""OpenRouter example with text plus image input.""" +"""OpenRouter example with text plus image input. + +The image ships with this example and is sent as base64 bytes via +``ContentPart(data=...)``, so the request does not depend on a routed provider +being able to fetch a remote URL. +""" + +import base64 +from pathlib import Path from dotenv import load_dotenv @@ -8,32 +16,32 @@ # Swap this for any OpenRouter model on your account that supports image input. MODEL_ID = "openai/gpt-5-mini" -IMAGE_URL = ( - "https://upload.wikimedia.org/wikipedia/commons/4/40/Portrait_of_a_father.jpg" -) -MESSAGES = [ - { - "role": "user", - "content": [ - ContentPart( - type="text", - text="Describe this image in two short bullet points.", - ), - ContentPart( - type="image", - url=IMAGE_URL, - media_id="boardwalk-demo-image", - ), - ], - } -] +IMAGE_PATH = Path(__file__).parent / "sample_sunflower.jpg" def main() -> None: load_dotenv() + image_b64 = base64.standard_b64encode(IMAGE_PATH.read_bytes()).decode("ascii") + messages = [ + { + "role": "user", + "content": [ + ContentPart( + type="text", + text="Describe this image in two short bullet points.", + ), + ContentPart( + type="image", + data=image_b64, + media_type="image/jpeg", + ), + ], + } + ] + model = openrouter(MODEL_ID, temperature=0) - response = model.generate(messages=MESSAGES) + response = model.generate(messages=messages) print(response.strip()) diff --git a/examples/providers/openrouter/README.md b/examples/providers/openrouter/README.md index e31d8f2..12f9113 100644 --- a/examples/providers/openrouter/README.md +++ b/examples/providers/openrouter/README.md @@ -37,6 +37,6 @@ Files: - `06_generation_metadata.py`: `generate_response(...)` and normalized metadata - `07_structured_batch.py`: batched structured responses - `08_unsupported_params_policies.py`: `warn`, `quiet`, and `fail` handling for an unsupported parameter -- `09_multimodal_image_input.py`: text plus image input using `ContentPart` +- `09_multimodal_image_input.py`: text plus image input using `ContentPart`, sending the bundled `sample_sunflower.jpg` as base64 bytes - `10_raw_vs_normalized_response.py`: compare normalized fields with the underlying raw payload fields - `11_timeout_and_rate_limit.py`: three separate requests through one provider instance, with `rpm_limit=2` so the third request shows client-side throttling diff --git a/examples/providers/openrouter/sample_sunflower.jpg b/examples/providers/openrouter/sample_sunflower.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1c02c87007df65e3e397ac764fe5fad52a28c34d GIT binary patch literal 39288 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≻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 literal 0 HcmV?d00001 From cf1fa5e0f9c8149726aee2591fca8348dce5895b Mon Sep 17 00:00:00 2001 From: Patrick <pfleith.pro@gmail.com> Date: Mon, 13 Jul 2026 20:04:30 +0200 Subject: [PATCH 50/54] docs(roadmap): populate roadmap with LLM provider launch plan - record shipped capability-aware provider layer and example suites - add launch checklist: pipeline execution correctness and architecture - add provider hardening, tests, and launch documentation plan - capture feature expansion (audio/file/image io) and long-term items --- docs-agents/ROADMAP.md | 151 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 5 deletions(-) diff --git a/docs-agents/ROADMAP.md b/docs-agents/ROADMAP.md index 83dd3ed..72c440e 100644 --- a/docs-agents/ROADMAP.md +++ b/docs-agents/ROADMAP.md @@ -2,22 +2,163 @@ ## Shipped -- <feature> +- Capability-aware LLM provider layer: per-target capability resolution (provider + endpoint + model), one common config surface, `unsupported_params` policy (`fail`/`warn`/`quiet`). +- Provider factories: `openai`, `anthropic`, `gemini`, `mistral`, `openrouter`, `ollama`, `openai_compatible`. +- Chat and Responses endpoint modes, structured output (Pydantic), reasoning controls (`thinking` / `reasoning_effort`). +- Multimodal **input** normalization: text, image, video, file/document content parts. +- Native batching with warned fallback concurrency; retries, backoff, jitter, timeout, client-side RPM throttling. +- Example suites (11 scripts each) for openai, anthropic, mistral, ollama, openrouter. +- Mocked contract/capability/adapter tests (C*/K*/A* coverage in `tests/test_llm_provider_contract.py`). ## In progress -- <feature> +- LLM provider redesign hardening on branch `feat/implement-new-and-robust-llm-providers`. ## Next up -- <feature> +Launch checklist, grouped by area. Pipeline execution correctness and architecture +are the newest additions and gate the release; provider hardening and documentation +run alongside them. + +### Pipeline execution correctness + +- **Wire up or remove dead execution controls.** Several `RunConfig` / `run()` + parameters are accepted and documented but have no effect — silent no-ops are worse + than missing features. For each: implement it, or delete it and document the real path. + - `limit` — currently ignored; must truncate source records at step 0. Highest + priority: `examples/scripts/37_limit_stop_and_strategies.py` leads with + `run(limit=3)` and today processes the full seed set (~20 LLM calls instead of 3). + - `rate_limits` — stored in the manifest and documented on `run()`, but never + throttles. Either apply it in the runner batch loop, or remove it and document + that throttling lives on the provider (`rpm_limit`). + - `resume_from` — documented ("resume from a named step, discard later steps"), + referenced nowhere. Implement or remove. + - `max_concurrent` — implies runner-level batch concurrency that does not exist + (batches run sequentially; concurrency is provider-internal). Implement or remove. +- **Execution & resume tests.** Cover the headline features that are currently + untested: checkpoint save/resume (including mid-LLM-step resume), `limit`, + `stop_after`, and `llm_strategy` ordering (by_model / round_robin / by_record). + +### Pipeline architecture + +- **Branch runner integration.** LLM steps nested inside `Branch` execute via + `step.process()` directly, bypassing the runner's batching, checkpoint/resume, rate + limiting, and execution strategy — this hits the flagship preference-data pipeline + (`examples/scripts/42`). Either have the runner recurse into branch paths, or clearly + document the limitation and its cost (a crash mid-Branch re-runs every branch call + on resume). +- **Pipeline validation / `compile()`.** No structural checks today; mistakes surface + as deep runtime `KeyError`s or silent empty output. Add validation: source first / + sink last, `input_columns` & `forward_columns` references exist, `by` columns exist + (Sample/Group/Pair), and Branch↔JoinBranches pairing — all with actionable error + messages raised before execution. + +### Provider hardening & tests + +- **Mocked reliability tests (R01–R07).** Cover retry/backoff/rate-limit code that already exists but is untested. + - R01 retryable error triggers bounded retries; R02 non-retryable fails immediately. + - R03 backoff grows across attempts; R04 jitter stays within range (inject `_sleep`, assert delays). + - R05 timeout is forwarded and timeout failure surfaces clearly. + - R06 `rpm_limit` throttles before dispatch (mocked clock/sleep, no live call). + - R07 batch retry preserves output ordering (per-item batch failure re-runs through single path). +- **Gemini example suite.** Add `examples/providers/gemini/` mirroring the other providers (11 scripts + README + sample image). +- **Capability-driven live test catalogue (L01–L10).** A curated model catalog + shared live suite parametrized over it, so adding a model is one catalog entry. Replaces ad-hoc per-provider `integration` tests; wire the `live` marker. + +### Documentation (launch) + +Bring the published docs (mkdocs, `docs/`) to release quality. The site today covers +Home, Concepts, a few Guides, three Cookbook recipes, Providers, Models, and API. +Gaps to close, roughly in priority order: + +- **Step reference (largest gap).** One reference page per step family documenting + every parameter and its non-obvious behavior: + - Sources & Seed — list / file / huggingface; `Seed.values/expand/range/product/zip`. + - Sinks — jsonl / csv / parquet / hub / list; Hub token, private, train/test split, dataset card. + - Data ops — Map, FlatMap, AddUUID; Filter (full operator table: comparison, + `$in`/`$nin`, string ops, `$len_*`, `$exists`, `$type`, `$all`/`$any`, `$or`/`$and`); + Group (`col:func` aggregation spec, min/max_per_group); Pair (strategies, + within/across, output formats, max_pairs); Concat; Join (how modes, suffixes). + - Sample — all nine strategies, required `by`, `n`/`frac`, `seed`, `replace`, and + the step-vs-config duality (`.pick()`). + - LLM steps — LLMStep (expansion math prompt×model×language×num_outputs, parse + modes text/json/xml, prompt-from-file, forward/exclude columns, skip_if, + `{language}`/`{language_name}`, `_model`/`_prompt_index`/`_language` metadata); + Classify / Score / Compare (llm-vs-fn dual mode, rubric/criteria, output modes, + include_explanation/confidence); Rewrite (modes); Extract (custom fields vs + predefined extractors, flatten). + - Branch / JoinBranches — tagging, cartesian join, suffixes, inner/outer, runner caveat. +- **Execution & configuration guide.** Everything `run()` / `RunConfig` exposes after + the Tier-1 cleanup, and exactly what each does: checkpoint_dir, resume, batch_size, + llm_strategy, limit, stop_after, and where rate limiting actually lives (provider + `rpm_limit` vs runner). Prevents a repeat of the dead-parameter confusion. +- **Provider user guide.** User-facing counterpart to the internal provider-doc + consolidation: each factory (openai / anthropic / gemini / mistral / openrouter / + ollama / openai_compatible), required API-key env vars, endpoint modes + (chat / responses), the capability model and per-target resolution, the + `unsupported_params` policy (fail/warn/quiet), reliability knobs + (retries/backoff/jitter/timeout/rpm_limit), native batching, structured output, and + reasoning controls. Absorbs `llm_provider_requirements.md`; remove it from the root + once merged. +- **Structured output guide.** `parse_mode` (text/json/xml) at the step level vs + Pydantic `response_format` at the provider level — when to use which. Resolves the + design-doc-vs-code divergence. +- **Multimodal input guide.** Passing image/video/file/document (and later audio) + content parts, and capability gating per target. Grows with the modality features below. +- **Migration guide (v1 → v2).** Map each removed dataset class (Classification, MCQ, + preference, instruction) to its pipeline equivalent and call out the breaking removal + of the dataset-class API. Port design-doc Appendix C. +- **Environment & install reference.** All env vars (provider API keys, `HF_TOKEN`, + `LANGFUSE_*`) and optional extras (datasets, pyarrow, huggingface_hub, langfuse), + with a minimal end-to-end setup path. +- **Cookbook expansion.** Promote the flagship `examples/scripts/` into recipes: + preference/DPO via Branch, multi-hop QA, instruction dataset, MCQ, text augmentation + (Rewrite), LLM-as-judge scoring + filtering, and multilingual generation. Add an + examples index mapping each script (01–45) to what it demonstrates. +- **Error handling & troubleshooting.** `on_parse_error` (skip/raise), partial + results / skipped records, resuming after a crash, debugging parse failures, and + common provider errors. +- **Changelog / release notes.** Populate `docs-agents/CHANGELOG.md` for the new + version and write a public "what's new / breaking changes" page (dataset classes + removed → pipelines). +- **Contributing & development guide.** Test markers (integration / live / multimodal / + ollama / vllm / llamacpp) and layers (contract C*, capability K*, adapter A*, + reliability R*, live L*), how to add a provider or a step, and project layout. + Absorbs `llm_provider_test_plan.md` and `llm_provider_test_guide.md`; remove them + from the root once merged. +- **Retire `SOFTWARE_DESCRIPTION.md`.** Fold its content into the docs above and + generate the user manual (`docs-agents/SUM.md`) with the `write-manual` skill; delete + `SOFTWARE_DESCRIPTION.md` once superseded. +- **README & API reference polish.** Release-quality README (feature list, doc links) + and a complete auto-generated API page (mkdocstrings) over the full public surface; + ensure `py.typed` ships. + +### Provider feature expansion + +- **Audio input support.** + - Implement: enable audio content parts end-to-end for models/providers that declare `Modality.AUDIO` (already normalized to `input_audio`; verify per-target gating). + - Test: mocked contract test (M03) + capability gating + one live test on an audio-capable model. + - Example: `NN_audio_input.py` for at least one audio-capable provider. +- **File / document input support.** + - Implement: enable file/document parts for models/providers that declare `Modality.FILE` / `Modality.DOCUMENT`, chat and Responses shapes. + - Test: mocked contract test (M05) + capability gating + one live test on a document-capable model. + - Example: `NN_document_input.py` for at least one document-capable provider. +- **Image output support (image-generation models).** + - Implement: request-side selection + response normalization for image-generation-capable chat/Responses targets (M09); expose generated images on `NormalizedResponse.images`. + - Test: mocked contract test for image-output path + one live test. + - Example: `NN_image_output.py` for an image-generation-capable provider. ## Later / long term -- <theme or feature> +- **Caching.** Full caching design from requirements: provider-native prompt caching, router/gateway caching, local prefix/KV reuse, optional client-side result cache; capability-aware cache keys/hints; cache tests (H01–H07). Only `cache_mode` metadata exists today. +- **vLLM support.** Delta live tests + example suite (needs a running server). +- **llama.cpp support.** Delta live tests + example suite (needs a running server). +- **openai-compatible generic backend.** Tests + example for the generic self-hosted path. +- Video input live coverage; `previous_response_id` continuation live scenario (E07); full-catalog live sweep (E08). ## Improvements & tech debt Non-feature work: rework, refactor, performance, cleanup. -- <item> +- **Detailed LLMProvider test guide** — drafted at `llm_provider_test_guide.md` (how to mock LiteLLM, inject `_sleep`, cover each test layer, add reliability/multimodal tests, extend the model catalog); to be folded into the Contributing & development guide (see Documentation) and removed from the root. +- Migrate existing per-provider `integration` tests onto the `live` marker and the shared catalogue once (L*) lands; retire duplicated ad-hoc coverage. +- Unused markers (`multimodal`, `ollama`, `vllm`, `llamacpp`) are declared but not yet applied to tests. From 6907a8041598799f58eee1f1b98950fd3e35e3c2 Mon Sep 17 00:00:00 2001 From: Patrick <pfleith.pro@gmail.com> Date: Tue, 14 Jul 2026 06:43:49 +0200 Subject: [PATCH 51/54] feat(llm): add first-class reasoning support for gemini - enable reasoning_effort and supports_reasoning on GEMINI_CHAT - forwarded natively for gemini/* (no allowlist); thinking=True maps to effort 'low' --- datafast/llm/capabilities.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/datafast/llm/capabilities.py b/datafast/llm/capabilities.py index 10e7df6..f925f7c 100644 --- a/datafast/llm/capabilities.py +++ b/datafast/llm/capabilities.py @@ -74,7 +74,9 @@ GEMINI_CHAT = TargetCapabilities( endpoint_modes=frozenset({EndpointMode.CHAT}), default_endpoint_mode=EndpointMode.CHAT, - supported_params=COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS, + supported_params=( + COMMON_CHAT_PARAMS | SAMPLING_CHAT_PARAMS | frozenset({"reasoning_effort"}) + ), modalities=frozenset({ Modality.TEXT, Modality.IMAGE, @@ -85,6 +87,11 @@ structured_output=StructuredOutputMode.JSON_SCHEMA, batch_mode=BatchMode.LITELLM_BATCH, cache_mode=CacheMode.PROVIDER_PROMPT, + supports_reasoning=True, + notes=( + "Reasoning is forwarded natively via reasoning_effort (thinking=True " + "maps to effort 'low'); LiteLLM handles gemini/* without an allowlist.", + ), ) From ed9db53e5a00f46d1acdf5fa2098965203263145 Mon Sep 17 00:00:00 2001 From: Patrick <pfleith.pro@gmail.com> Date: Tue, 14 Jul 2026 06:43:54 +0200 Subject: [PATCH 52/54] test(llm): cover reliability behaviors and gemini reasoning - reliability: bounded retries, backoff growth, jitter range, timeout forwarding, RPM throttling, batch-retry ordering - gemini: capability resolution, reasoning_effort forwarding, thinking=True defaults to low effort --- tests/test_llm_provider_contract.py | 205 ++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/tests/test_llm_provider_contract.py b/tests/test_llm_provider_contract.py index b7c786f..c966a9c 100644 --- a/tests/test_llm_provider_contract.py +++ b/tests/test_llm_provider_contract.py @@ -1,4 +1,5 @@ import pytest +from litellm import exceptions as litellm_exceptions from pydantic import BaseModel import datafast.llm.provider as provider_module @@ -7,6 +8,8 @@ ContentPart, EndpointMode, Modality, + RetryPolicy, + GeminiProvider, MistralProvider, OllamaProvider, OpenAIProvider, @@ -400,6 +403,52 @@ def fake_completion(**kwargs): assert "reasoning_effort" not in captured +def test_gemini_reasoning_capability_resolution(): + # All catalogued Gemini models support reasoning natively. + for model_id in ("gemini-2.5-pro", "gemini-3.5-flash", "gemini-3.1-flash-lite"): + caps = resolve_capabilities("gemini", model_id) + assert caps.supports_reasoning is True + assert "reasoning_effort" in caps.supported_params + + +def test_gemini_reasoning_effort_is_forwarded(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok", reasoning_content="chain of thought") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = GeminiProvider( + model_id="gemini-2.5-pro", api_key="test-key", reasoning_effort="high" + ) + + response = provider.generate_response(prompt="think it through") + + assert captured["reasoning_effort"] == "high" + # LiteLLM forwards reasoning_effort to gemini/* natively, so no allowlist. + assert "allowed_openai_params" not in captured + assert response.reasoning_content == "chain of thought" + + +def test_gemini_thinking_true_defaults_to_low_effort(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = GeminiProvider( + model_id="gemini-2.5-pro", api_key="test-key", thinking=True + ) + + assert provider.generate(prompt="ping") == "ok" + assert captured["reasoning_effort"] == "low" + + def test_provider_params_escape_hatch_is_forwarded(monkeypatch): captured = {} @@ -511,6 +560,162 @@ def fake_completion(**kwargs): assert calls[1]["drop_params"] is True +def _retryable(cls=litellm_exceptions.RateLimitError): + """A litellm exception the provider treats as retryable.""" + return cls(message="boom", llm_provider="openrouter", model="demo-model") + + +def test_retryable_error_is_retried_until_bounded_limit(monkeypatch): + calls = [] + + def fake_completion(**kwargs): + calls.append(kwargs) + if len(calls) < 3: + raise _retryable() + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = OpenRouterProvider( + model_id="demo-model", api_key="test-key", retry_limit=2 + ) + provider._sleep = lambda delay: None + + assert provider.generate(prompt="ping") == "ok" + assert len(calls) == 3 # initial attempt + two bounded retries + + +def test_non_retryable_error_fails_without_retry(monkeypatch): + calls = [] + + def fake_completion(**kwargs): + calls.append(kwargs) + raise _retryable(litellm_exceptions.AuthenticationError) + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = OpenRouterProvider(model_id="demo-model", api_key="test-key") + provider._sleep = lambda delay: None + + with pytest.raises(RuntimeError): + provider.generate(prompt="ping") + assert len(calls) == 1 + + +def test_backoff_grows_across_retries(monkeypatch): + def fake_completion(**kwargs): + raise _retryable() + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + delays = [] + provider = OpenRouterProvider( + model_id="demo-model", + api_key="test-key", + retry_policy=RetryPolicy(max_retries=3, jitter=0.0), + ) + provider._sleep = delays.append + + with pytest.raises(RuntimeError): + provider.generate(prompt="ping") + assert delays == [1.0, 2.0, 4.0] + + +def test_jitter_stays_within_expected_range(monkeypatch): + def fake_completion(**kwargs): + raise _retryable() + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + delays = [] + provider = OpenRouterProvider( + model_id="demo-model", + api_key="test-key", + retry_policy=RetryPolicy(max_retries=3, jitter=0.25), + ) + provider._sleep = delays.append + + with pytest.raises(RuntimeError): + provider.generate(prompt="ping") + for attempt, delay in enumerate(delays): + base = 1.0 * (2 ** attempt) + assert base <= delay <= base * 1.25 + + +def test_timeout_is_forwarded_and_failure_surfaces(monkeypatch): + captured = {} + + def fake_completion(**kwargs): + captured.update(kwargs) + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + provider = OpenRouterProvider( + model_id="demo-model", api_key="test-key", timeout=30 + ) + assert provider.generate(prompt="ping") == "ok" + assert captured["timeout"] == 30 + + def raise_timeout(**kwargs): + raise _retryable(litellm_exceptions.Timeout) + + monkeypatch.setattr(provider_module.litellm, "completion", raise_timeout) + provider = OpenRouterProvider( + model_id="demo-model", api_key="test-key", retry_limit=0 + ) + with pytest.raises(RuntimeError, match="openrouter"): + provider.generate(prompt="ping") + + +def test_rpm_limit_throttles_before_dispatch(monkeypatch): + calls = [] + + def fake_completion(**kwargs): + calls.append(kwargs) + return _DummyChatResponse("ok") + + monkeypatch.setattr(provider_module.litellm, "completion", fake_completion) + + clock = [0.0] + delays = [] + monkeypatch.setattr(provider_module.time, "monotonic", lambda: clock[0]) + + provider = OpenRouterProvider( + model_id="demo-model", api_key="test-key", rpm_limit=2 + ) + + def fake_sleep(delay): + delays.append(delay) + clock[0] += delay + + provider._sleep = fake_sleep + + for _ in range(3): + provider.generate(prompt="ping") + + assert len(calls) == 3 + assert delays == [61.0] # third request waits for the window to clear + + +def test_batch_retry_preserves_output_order(monkeypatch): + def fake_batch_completion(**kwargs): + return [_DummyChatResponse("a"), _retryable(), _DummyChatResponse("c")] + + monkeypatch.setattr( + provider_module.litellm, "batch_completion", fake_batch_completion + ) + monkeypatch.setattr( + provider_module.litellm, + "completion", + lambda **kwargs: _DummyChatResponse("b"), + ) + + provider = OpenRouterProvider(model_id="demo-model", api_key="test-key") + + assert provider.generate(prompt=["a", "b", "c"]) == ["a", "b", "c"] + + def test_generate_response_preserves_litellm_reasoning_metadata(monkeypatch): monkeypatch.setattr( provider_module.litellm, From 03e514ba1c4030e8b386ffc3fa1e5c09171a1b10 Mon Sep 17 00:00:00 2001 From: Patrick <pfleith.pro@gmail.com> Date: Tue, 14 Jul 2026 06:43:59 +0200 Subject: [PATCH 53/54] docs(examples): add gemini provider example suite - 11 scripts mirroring the other providers, plus README and sample image - reasoning demos use native reasoning_effort; register gemini/ in providers index --- examples/providers/README.md | 1 + examples/providers/gemini/01_simple_prompt.py | 22 ++++++ examples/providers/gemini/02_batch_prompts.py | 26 +++++++ .../gemini/03_messages_with_system_prompt.py | 31 ++++++++ .../providers/gemini/04_structured_output.py | 28 +++++++ .../providers/gemini/05_batch_messages.py | 40 ++++++++++ .../gemini/06_generation_metadata.py | 51 ++++++++++++ .../providers/gemini/07_structured_batch.py | 35 +++++++++ .../gemini/08_unsupported_params_policies.py | 53 +++++++++++++ .../gemini/09_multimodal_image_input.py | 49 ++++++++++++ .../gemini/10_raw_vs_normalized_response.py | 69 +++++++++++++++++ .../gemini/11_timeout_and_rate_limit.py | 73 ++++++++++++++++++ examples/providers/gemini/README.md | 42 ++++++++++ .../providers/gemini/sample_sunflower.jpg | Bin 0 -> 39288 bytes 14 files changed, 520 insertions(+) create mode 100644 examples/providers/gemini/01_simple_prompt.py create mode 100644 examples/providers/gemini/02_batch_prompts.py create mode 100644 examples/providers/gemini/03_messages_with_system_prompt.py create mode 100644 examples/providers/gemini/04_structured_output.py create mode 100644 examples/providers/gemini/05_batch_messages.py create mode 100644 examples/providers/gemini/06_generation_metadata.py create mode 100644 examples/providers/gemini/07_structured_batch.py create mode 100644 examples/providers/gemini/08_unsupported_params_policies.py create mode 100644 examples/providers/gemini/09_multimodal_image_input.py create mode 100644 examples/providers/gemini/10_raw_vs_normalized_response.py create mode 100644 examples/providers/gemini/11_timeout_and_rate_limit.py create mode 100644 examples/providers/gemini/README.md create mode 100644 examples/providers/gemini/sample_sunflower.jpg diff --git a/examples/providers/README.md b/examples/providers/README.md index 0acd3eb..766306f 100644 --- a/examples/providers/README.md +++ b/examples/providers/README.md @@ -5,6 +5,7 @@ This folder contains direct, provider-focused examples. - `openrouter/`: simple OpenRouter calls with `model.generate(...)` - `anthropic/`: simple Anthropic calls with `model.generate(...)` - `openai/`: simple OpenAI calls with `model.generate(...)` +- `gemini/`: simple Gemini calls with `model.generate(...)` - `mistral/`: simple Mistral calls with `model.generate(...)` - `ollama/`: simple local Ollama calls with `model.generate(...)` diff --git a/examples/providers/gemini/01_simple_prompt.py b/examples/providers/gemini/01_simple_prompt.py new file mode 100644 index 0000000..ded930c --- /dev/null +++ b/examples/providers/gemini/01_simple_prompt.py @@ -0,0 +1,22 @@ +"""Minimal Gemini example with a single prompt.""" + +from dotenv import load_dotenv + +from datafast import gemini +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "gemini-3.1-flash-lite" +PROMPT = "Write one sentence explaining what Google Gemini is." + + +def main() -> None: + load_dotenv() + + model = gemini(MODEL_ID, temperature=0) + response = model.generate(prompt=PROMPT) + print(format_generated_responses(PROMPT, response)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/gemini/02_batch_prompts.py b/examples/providers/gemini/02_batch_prompts.py new file mode 100644 index 0000000..644ef77 --- /dev/null +++ b/examples/providers/gemini/02_batch_prompts.py @@ -0,0 +1,26 @@ +"""Minimal Gemini example with a batch of prompts.""" + +from dotenv import load_dotenv + +from datafast import gemini +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "gemini-3.1-flash-lite" +PROMPTS = [ + "Give a one-sentence definition of synthetic data.", + "Give a one-sentence definition of retrieval-augmented generation.", + "Give a one-sentence definition of tool calling.", +] + + +def main() -> None: + load_dotenv() + + model = gemini(MODEL_ID, temperature=0) + responses = model.generate(prompt=PROMPTS) + print(format_generated_responses(PROMPTS, responses)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/gemini/03_messages_with_system_prompt.py b/examples/providers/gemini/03_messages_with_system_prompt.py new file mode 100644 index 0000000..ee5ad3e --- /dev/null +++ b/examples/providers/gemini/03_messages_with_system_prompt.py @@ -0,0 +1,31 @@ +"""Gemini example using explicit chat messages.""" + +from dotenv import load_dotenv + +from datafast import gemini +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "gemini-3.1-flash-lite" +MESSAGES = [ + { + "role": "system", + "content": "You are a concise technical assistant. Answer in exactly two bullets.", + }, + { + "role": "user", + "content": "Explain why teams use Gemini for structured data generation.", + }, +] + + +def main() -> None: + load_dotenv() + + model = gemini(MODEL_ID, temperature=0) + response = model.generate(messages=MESSAGES) + print(format_generated_responses(MESSAGES[-1]["content"], response)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/gemini/04_structured_output.py b/examples/providers/gemini/04_structured_output.py new file mode 100644 index 0000000..9b6f33c --- /dev/null +++ b/examples/providers/gemini/04_structured_output.py @@ -0,0 +1,28 @@ +"""Gemini example with structured output validation.""" + +from dotenv import load_dotenv +from pydantic import BaseModel + +from datafast import gemini + + +MODEL_ID = "gemini-3.1-flash-lite" +PROMPT = "Return a JSON object describing Google Gemini in two short sentences." + + +class ProviderSummary(BaseModel): + name: str + summary: str + best_for: str + + +def main() -> None: + load_dotenv() + + model = gemini(MODEL_ID, temperature=0) + response = model.generate(prompt=PROMPT, response_format=ProviderSummary) + print(response.model_dump_json(indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/gemini/05_batch_messages.py b/examples/providers/gemini/05_batch_messages.py new file mode 100644 index 0000000..deb2ac8 --- /dev/null +++ b/examples/providers/gemini/05_batch_messages.py @@ -0,0 +1,40 @@ +"""Gemini example with a batch of message lists.""" + +from dotenv import load_dotenv + +from datafast import gemini +from datafast.llm_utils import format_generated_responses + + +MODEL_ID = "gemini-3.1-flash-lite" +BATCH_MESSAGES = [ + [ + { + "role": "system", + "content": "You answer for engineers in one sentence.", + }, + { + "role": "user", + "content": "What is prompt caching?", + }, + ], + [ + { + "role": "user", + "content": "What is structured output?", + }, + ], +] + + +def main() -> None: + load_dotenv() + + model = gemini(MODEL_ID, temperature=0) + responses = model.generate(messages=BATCH_MESSAGES) + prompts = [messages[-1]["content"] for messages in BATCH_MESSAGES] + print(format_generated_responses(prompts, responses)) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/gemini/06_generation_metadata.py b/examples/providers/gemini/06_generation_metadata.py new file mode 100644 index 0000000..db09000 --- /dev/null +++ b/examples/providers/gemini/06_generation_metadata.py @@ -0,0 +1,51 @@ +"""Gemini example returning normalized response metadata. + +Reasoning is first-class on Gemini: 2.5+/3.x models accept a `reasoning_effort` +parameter. With reasoning_effort="high" the API returns a thinking trace before +the final text, which Datafast normalizes into `reasoning_content`. This example +requests reasoning so those fields populate, showing the full metadata surface +that Datafast exposes uniformly across providers. +""" + +from dotenv import load_dotenv + +from datafast import gemini + + +MODEL_ID = "gemini-2.5-pro" +PROMPT = ( + "A train travels 60 miles per hour for 2.5 hours. " + "Work it out carefully, then give the final answer in one short sentence." +) + + +def main() -> None: + load_dotenv() + + model = gemini(MODEL_ID, temperature=0.3, reasoning_effort="high") + response = model.generate_response(prompt=PROMPT) + usage = getattr(response.raw, "usage", None) + completion_details = getattr(usage, "completion_tokens_details", None) + reasoning_tokens = ( + getattr(completion_details, "reasoning_tokens", None) + if completion_details is not None + else None + ) + + print("Text") + print("----") + print(response.text.strip()) + print() + print("Metadata") + print("--------") + reasoning = response.reasoning_content or "" + print(f"reasoning_content: {bool(reasoning)} ({len(reasoning)} chars)") + print(f"reasoning_tokens: {reasoning_tokens}") + print(f"thinking_blocks: {len(response.thinking_blocks)}") + print(f"images: {len(response.images)}") + print(f"audio: {bool(response.audio)}") + print(f"output_items: {len(response.output_items)}") + + +if __name__ == "__main__": + main() diff --git a/examples/providers/gemini/07_structured_batch.py b/examples/providers/gemini/07_structured_batch.py new file mode 100644 index 0000000..220da49 --- /dev/null +++ b/examples/providers/gemini/07_structured_batch.py @@ -0,0 +1,35 @@ +"""Gemini example with batched structured responses.""" + +from dotenv import load_dotenv +from pydantic import BaseModel + +from datafast import gemini + + +MODEL_ID = "gemini-3.1-flash-lite" +PROMPTS = [ + "Return JSON for Python with fields language, category, and one_sentence_use_case.", + "Return JSON for Rust with fields language, category, and one_sentence_use_case.", + "Return JSON for SQL with fields language, category, and one_sentence_use_case.", +] + + +class LanguageCard(BaseModel): + language: str + category: str + one_sentence_use_case: str + + +def main() -> None: + load_dotenv() + + model = gemini(MODEL_ID, temperature=0) + responses = model.generate(prompt=PROMPTS, response_format=LanguageCard) + + for response in responses: + print(response.model_dump_json(indent=2)) + print() + + +if __name__ == "__main__": + main() diff --git a/examples/providers/gemini/08_unsupported_params_policies.py b/examples/providers/gemini/08_unsupported_params_policies.py new file mode 100644 index 0000000..edea8a3 --- /dev/null +++ b/examples/providers/gemini/08_unsupported_params_policies.py @@ -0,0 +1,53 @@ +"""Gemini example showing unsupported parameter policies. + +``previous_response_id`` is a Responses-API concept. Gemini runs on the chat +endpoint, so it is unsupported and flows through the warn/quiet/fail policy. +""" + +from __future__ import annotations + +import warnings + +from dotenv import load_dotenv + +from datafast import gemini + + +MODEL_ID = "gemini-3.1-flash-lite" +PROMPT = "Explain Google Gemini in one short sentence." + + +def run_case(policy: str) -> None: + model = gemini( + MODEL_ID, + temperature=0, + unsupported_params=policy, + ) + + print(f"Policy: {policy}") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + try: + response = model.generate(prompt=PROMPT, previous_response_id="resp_demo") + except ValueError as exc: + print("status: error") + print(f"detail: {exc}") + else: + print("status: ok") + print(f"text: {response.strip()}") + + print(f"warnings: {len(caught)}") + for warning in caught: + print(f"- {warning.message}") + print() + + +def main() -> None: + load_dotenv() + + for policy in ("warn", "quiet", "fail"): + run_case(policy) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/gemini/09_multimodal_image_input.py b/examples/providers/gemini/09_multimodal_image_input.py new file mode 100644 index 0000000..66a5515 --- /dev/null +++ b/examples/providers/gemini/09_multimodal_image_input.py @@ -0,0 +1,49 @@ +"""Gemini example with text plus image input. + +The image ships with this example and is sent as base64 bytes via +``ContentPart(data=...)``. Passing bytes directly is the portable way to feed +Gemini an image regardless of host reachability. +""" + +import base64 +from pathlib import Path + +from dotenv import load_dotenv + +from datafast import gemini +from datafast.llm import ContentPart + + +# Swap this for any Gemini model that supports image input. +MODEL_ID = "gemini-2.5-pro" +IMAGE_PATH = Path(__file__).parent / "sample_sunflower.jpg" + + +def main() -> None: + load_dotenv() + + image_b64 = base64.standard_b64encode(IMAGE_PATH.read_bytes()).decode("ascii") + messages = [ + { + "role": "user", + "content": [ + ContentPart( + type="text", + text="Describe this image in two short bullet points.", + ), + ContentPart( + type="image", + data=image_b64, + media_type="image/jpeg", + ), + ], + } + ] + + model = gemini(MODEL_ID, temperature=0) + response = model.generate(messages=messages) + print(response.strip()) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/gemini/10_raw_vs_normalized_response.py b/examples/providers/gemini/10_raw_vs_normalized_response.py new file mode 100644 index 0000000..1b04b2f --- /dev/null +++ b/examples/providers/gemini/10_raw_vs_normalized_response.py @@ -0,0 +1,69 @@ +"""Gemini example comparing normalized fields with raw payload fields.""" + +from __future__ import annotations + +from dotenv import load_dotenv + +from datafast import gemini + + +MODEL_ID = "gemini-2.5-pro" +PROMPT = ( + "A train travels 60 miles per hour for 2.5 hours. " + "Work it out carefully, then give the final answer in one short sentence." +) + + +def _get_attr_or_key(value, name: str): + if value is None: + return None + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _first_choice_message(raw_response): + choices = _get_attr_or_key(raw_response, "choices") or [] + if not choices: + return None + return _get_attr_or_key(choices[0], "message") + + +def main() -> None: + load_dotenv() + + # Reasoning is requested so the normalized vs raw reasoning fields populate. + model = gemini(MODEL_ID, temperature=0, reasoning_effort="high") + response = model.generate_response(prompt=PROMPT) + + usage = getattr(response.raw, "usage", None) + message = _first_choice_message(response.raw) + choices = getattr(response.raw, "choices", None) + raw_text = _get_attr_or_key(message, "content") + raw_reasoning = _get_attr_or_key(message, "reasoning_content") + + print("Comparison") + print("----------") + print(f"normalized.text: {response.text.strip()!r}") + print(f"raw choices[0].message.content: {raw_text!r}") + print(f"reasoning_content: {bool(response.reasoning_content)}") + print(f"raw reasoning_content: {bool(raw_reasoning)}") + print(f"thinking_blocks: {len(response.thinking_blocks)}") + if response.reasoning_content: + print() + print("Normalized reasoning") + print("--------------------") + print(response.reasoning_content) + print() + print("Raw") + print("---") + print(f"raw_type: {type(response.raw).__name__}") + print(f"has_usage: {usage is not None}") + print(f"has_choices: {choices is not None}") + if usage is not None: + print(f"prompt_tokens: {getattr(usage, 'prompt_tokens', None)}") + print(f"completion_tokens: {getattr(usage, 'completion_tokens', None)}") + + +if __name__ == "__main__": + main() diff --git a/examples/providers/gemini/11_timeout_and_rate_limit.py b/examples/providers/gemini/11_timeout_and_rate_limit.py new file mode 100644 index 0000000..4aaa58e --- /dev/null +++ b/examples/providers/gemini/11_timeout_and_rate_limit.py @@ -0,0 +1,73 @@ +"""Gemini example showing timeout and rpm_limit across multiple requests.""" + +import time + +from dotenv import load_dotenv + +from datafast import gemini + + +MODEL_ID = "gemini-3.1-flash-lite" +TIMEOUT_SECONDS = 30 +RPM_LIMIT = 2 +PROMPTS = [ + "Reply with exactly: request one acknowledged.", + "Reply with exactly: request two acknowledged.", + "Reply with exactly: request three acknowledged.", +] + + +def main() -> None: + load_dotenv() + + model = gemini( + MODEL_ID, + temperature=0, + timeout=TIMEOUT_SECONDS, + rpm_limit=RPM_LIMIT, + ) + + print("Config") + print("------") + print(f"model: {MODEL_ID}") + print(f"timeout: {TIMEOUT_SECONDS}s") + print(f"rpm_limit: {RPM_LIMIT}") + print() + print( + "This script sends three separate requests through one provider instance." + ) + print( + "With rpm_limit=2, the third request should pause for roughly one minute " + "before Datafast sends it." + ) + print() + + started = time.monotonic() + for index, prompt in enumerate(PROMPTS, start=1): + request_started = time.monotonic() + response = model.generate(prompt=prompt) + request_elapsed = time.monotonic() - request_started + + print(f"Request {index}") + print(f"prompt: {prompt}") + print(f"response: {response}") + print(f"call_elapsed_seconds: {request_elapsed:.2f}") + print() + + elapsed = time.monotonic() - started + + print("Notes") + print("-----") + print(f"total_elapsed_seconds: {elapsed:.2f}") + print( + "Datafast forwards timeout on each request and enforces rpm_limit on the " + "provider instance before the next request is sent." + ) + print( + "Requests 1 and 2 should complete normally. Request 3 should be the one " + "that clearly shows client-side throttling." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/providers/gemini/README.md b/examples/providers/gemini/README.md new file mode 100644 index 0000000..0268c3e --- /dev/null +++ b/examples/providers/gemini/README.md @@ -0,0 +1,42 @@ +# Gemini Examples + +Requirements: + +- `GEMINI_API_KEY` set in your environment or `.env` + +Notes: + +- Datafast suppresses LiteLLM's provider help banner by default for cleaner example + output. +- Set `DATAFAST_LITELLM_SUPPRESS_DEBUG_INFO=0` if you want LiteLLM to print that + extra provider/debug information while troubleshooting. + +Run: + +```bash +.venv/bin/python examples/providers/gemini/01_simple_prompt.py +.venv/bin/python examples/providers/gemini/02_batch_prompts.py +.venv/bin/python examples/providers/gemini/03_messages_with_system_prompt.py +.venv/bin/python examples/providers/gemini/04_structured_output.py +.venv/bin/python examples/providers/gemini/05_batch_messages.py +.venv/bin/python examples/providers/gemini/06_generation_metadata.py +.venv/bin/python examples/providers/gemini/07_structured_batch.py +.venv/bin/python examples/providers/gemini/08_unsupported_params_policies.py +.venv/bin/python examples/providers/gemini/09_multimodal_image_input.py +.venv/bin/python examples/providers/gemini/10_raw_vs_normalized_response.py +.venv/bin/python examples/providers/gemini/11_timeout_and_rate_limit.py +``` + +Files: + +- `01_simple_prompt.py`: one prompt, one response +- `02_batch_prompts.py`: a list of prompts sent through one `generate(...)` call +- `03_messages_with_system_prompt.py`: chat messages with a system instruction +- `04_structured_output.py`: validated Pydantic output +- `05_batch_messages.py`: a batch of independent message lists +- `06_generation_metadata.py`: `generate_response(...)` and normalized metadata with native reasoning via `reasoning_effort` +- `07_structured_batch.py`: batched structured responses +- `08_unsupported_params_policies.py`: `warn`, `quiet`, and `fail` handling for an unsupported parameter +- `09_multimodal_image_input.py`: text plus image input using `ContentPart`, sending the bundled `sample_sunflower.jpg` as base64 bytes +- `10_raw_vs_normalized_response.py`: compare normalized fields with the underlying raw payload fields +- `11_timeout_and_rate_limit.py`: three separate requests through one provider instance, with `rpm_limit=2` so the third request shows client-side throttling diff --git a/examples/providers/gemini/sample_sunflower.jpg b/examples/providers/gemini/sample_sunflower.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1c02c87007df65e3e397ac764fe5fad52a28c34d GIT binary patch literal 39288 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≻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 literal 0 HcmV?d00001 From cd647ecc3fb73c1fcda0606473c5277e75ae0569 Mon Sep 17 00:00:00 2001 From: Patrick <pfleith.pro@gmail.com> Date: Tue, 14 Jul 2026 06:44:05 +0200 Subject: [PATCH 54/54] docs: record shipped provider work in roadmap and tasks - move reliability tests, gemini example suite, and first-class reasoning to Shipped - update task list: gemini manual-test task added, roadmap tasks marked done --- docs-agents/ROADMAP.md | 13 +++---------- docs-agents/TASKS.md | 5 +++-- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/docs-agents/ROADMAP.md b/docs-agents/ROADMAP.md index 72c440e..a435a71 100644 --- a/docs-agents/ROADMAP.md +++ b/docs-agents/ROADMAP.md @@ -4,11 +4,11 @@ - Capability-aware LLM provider layer: per-target capability resolution (provider + endpoint + model), one common config surface, `unsupported_params` policy (`fail`/`warn`/`quiet`). - Provider factories: `openai`, `anthropic`, `gemini`, `mistral`, `openrouter`, `ollama`, `openai_compatible`. -- Chat and Responses endpoint modes, structured output (Pydantic), reasoning controls (`thinking` / `reasoning_effort`). +- Chat and Responses endpoint modes, structured output (Pydantic), reasoning controls (`thinking` / `reasoning_effort`); first-class reasoning across anthropic, gemini, mistral, ollama. - Multimodal **input** normalization: text, image, video, file/document content parts. - Native batching with warned fallback concurrency; retries, backoff, jitter, timeout, client-side RPM throttling. -- Example suites (11 scripts each) for openai, anthropic, mistral, ollama, openrouter. -- Mocked contract/capability/adapter tests (C*/K*/A* coverage in `tests/test_llm_provider_contract.py`). +- Example suites (11 scripts each) for openai, anthropic, gemini, mistral, ollama, openrouter. +- Mocked contract/capability/adapter/reliability tests in `tests/test_llm_provider_contract.py` (reliability: bounded retries, backoff growth, jitter range, timeout forwarding, RPM throttling, batch-retry ordering). ## In progress @@ -55,13 +55,6 @@ run alongside them. ### Provider hardening & tests -- **Mocked reliability tests (R01–R07).** Cover retry/backoff/rate-limit code that already exists but is untested. - - R01 retryable error triggers bounded retries; R02 non-retryable fails immediately. - - R03 backoff grows across attempts; R04 jitter stays within range (inject `_sleep`, assert delays). - - R05 timeout is forwarded and timeout failure surfaces clearly. - - R06 `rpm_limit` throttles before dispatch (mocked clock/sleep, no live call). - - R07 batch retry preserves output ordering (per-item batch failure re-runs through single path). -- **Gemini example suite.** Add `examples/providers/gemini/` mirroring the other providers (11 scripts + README + sample image). - **Capability-driven live test catalogue (L01–L10).** A curated model catalog + shared live suite parametrized over it, so adding a model is one catalog entry. Replaces ad-hoc per-provider `integration` tests; wire the `live` marker. ### Documentation (launch) diff --git a/docs-agents/TASKS.md b/docs-agents/TASKS.md index 9e4821f..b6f9790 100644 --- a/docs-agents/TASKS.md +++ b/docs-agents/TASKS.md @@ -4,13 +4,14 @@ ## To do -- [ ] Make sure the roadmap clearly outlines the release of the new version of datafast -- [ ] Make a roadmap +- [ ] Manually test all Gemini example scripts <!-- examples/providers/gemini --> - [ ] Review the documentation and identify missing blocks before publishing the new version of datafast - [ ] <task> <!-- optional (context) --> ## Done +- [X] Make sure the roadmap clearly outlines the release of the new version of datafast +- [X] Make a roadmap - [X] Try out the OpenAI example scripts <!-- examples/providers/openai --> - [X] Try out the Mistral example scripts <!-- examples/providers/mistral --> - [X] Try out the Anthropic example scripts <!-- examples/providers/anthropic -->