Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions docs/ai_configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,17 @@ keeps `0.2`. Unset temperature uses the provider's default (`1.0` for Gemini 3).
For extraction, a saved definition's model retains its existing precedence over
the caller's `model`. Both tuning and runtime defaults are resolved for that
selected model before applying explicit caller overrides.
Extraction accepts the simple recipe form `reasoning: none` and the equivalent
nested form `reasoning: {effort: none}`. Direct Python calls likewise accept
`reasoning="none"` or `reasoning={"effort": "none"}`. A scalar is normalized to
the object form before resolving overrides; nested objects retain their other
reasoning options.
Explicit reasoning takes precedence over saved reasoning, which takes precedence
over configured reasoning. Declared reasoning and verbosity enums are checked
against the requested value; unsupported values are warned about and omitted.
over configured reasoning. Extraction defaults to `none` for every model,
including unlisted and future models. Reasoning is always sent to the provider;
unsupported settings produce a provider error instead of silently applying the
provider default. Declared verbosity enums are checked against the requested
value; unsupported verbosity values are warned about and omitted.
Recipe reasoning includes `max`. Saved `ReasoningEffort` remains `none|low` to
preserve compatibility with existing editors that use that narrower contract.

Expand Down Expand Up @@ -167,8 +175,13 @@ remains available through `extract.ai(protocol="chat_completions")`. Extraction
resolves configuration once per operation and uses a private transport for its
individual rows.

Generation remains unreleased. Its operation keeps `low` reasoning; extraction
keeps `none` where supported. Its existing direct-Python and recipe strictness
The packaged OpenAI model reasoning defaults are `none`. Extraction and
generation inherit those model settings without an operation-level reasoning
override, so configured model defaults remain effective. Extraction also sends
`none` when an unlisted model has no configured effort. Explicit caller and
saved-definition settings retain their precedence.

Generation remains unreleased. Its existing direct-Python and recipe strictness
defaults are represented by `strict` and `recipe_strict`, respectively.

Extraction uses the operation's `defaults` directly. There is no profile registry
Expand Down
14 changes: 8 additions & 6 deletions docs/extract_ai_user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ settings such as concurrency, cache, and the base prompt belong to the operation
See [AI model configuration](ai_configuration.md) for the schema, caller coverage,
override precedence, test-role selection, and version-1 compatibility.

The public parameters remain `reasoning: {effort: ...}` and
`verbosity: low | medium | high`. Supported values in the catalog describe the
model; they are separate from the value requested by a recipe. Existing saved
model validation and legacy model-family compatibility behavior are preserved.
Use `reasoning: none` or `reasoning: low` for a simple effort setting. The nested
form, such as `reasoning: {effort: none}`, remains supported and can include other
reasoning options. Both forms have the same precedence over saved and configured
defaults. Extraction always sends the effective reasoning setting; unsupported
settings produce a provider error. `verbosity: low | medium | high` remains
separate. Supported values in the catalog describe the model; they are separate
from the value requested by a recipe. Existing saved-model validation is preserved.

For each model upgrade, verify capabilities against
[OpenAI's model documentation](https://developers.openai.com/api/docs/models)
Expand Down Expand Up @@ -336,8 +339,7 @@ wrangles:
- Title
- Technical Data
api_key: ${OPENAI_API_KEY}
reasoning:
effort: low
reasoning: low
instructions:
- Extract values supported by the supplied product information.
- Return null when a top-level attribute is absent.
Expand Down
28 changes: 28 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Shared fixtures for integration tests."""
from copy import deepcopy

import pytest
from wrangles import ai_config, data


@pytest.fixture
def saved_extract_schema_model(monkeypatch):
"""Fetch the real saved schema but select the configured test model locally.

These tests exercise saved definitions and output shapes, not the legacy
model choice stored in shared test data. Do not write back to that data.
"""
fetch_model_content = data.model_content

def model_content(*args, **kwargs):
content = deepcopy(fetch_model_content(*args, **kwargs))
settings = content.setdefault("Settings", {})
# Remove legacy aliases so an old model cannot win during normalization.
for key in list(settings):
normalized = "".join(char.lower() for char in key if char.isalnum())
if normalized in {"gptmodel", "aimodel", "model"}:
del settings[key]
settings["GPTModel"] = ai_config.resolve("extract.ai", role="test")["model"]
return content

monkeypatch.setattr(data, "model_content", model_content)
31 changes: 12 additions & 19 deletions tests/recipes/wrangles/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4265,6 +4265,8 @@ def test_ai_o3_mini(self):
wrangles:
- extract.ai:
model: o3-mini
reasoning:
effort: low
api_key: ${OPENAI_API_KEY}
timeout: 60
retries: 2
Expand Down Expand Up @@ -4324,13 +4326,8 @@ def test_ai_reasoning_and_verbosity(self):
])
assert matches >= 1

def test_ai_pre_gpt5_reasoning_and_verbosity_ignored(self, caplog):
"""
Test extract.ai with a pre-gpt5 model that does not support
reasoning/verbosity. The recipe should still complete
successfully, ignoring those settings with a warning
rather than failing the request.
"""
def test_ai_pre_gpt5_reasoning_rejected_and_verbosity_ignored(self, caplog):
"""The recipe reports provider errors instead of dropping reasoning."""
with caplog.at_level(logging.WARNING, logger="wrangles.extract"):
df = wrangles.recipe.run(
"""
Expand All @@ -4354,12 +4351,8 @@ def test_ai_pre_gpt5_reasoning_and_verbosity_ignored(self, caplog):
"data": ["wrench 25mm", "6m cable"],
})
)
matches = sum([
df['length'][0] == '25mm',
df['length'][1] == '6m',
])
assert matches >= 1
assert "Ignoring 'reasoning' parameter" in caplog.text
assert all('OpenAI API error' in value and 'reasoning' in value for value in df['length'])
assert "Ignoring 'reasoning' parameter" not in caplog.text
assert "Ignoring 'verbosity' parameter" in caplog.text

def test_ai_invalid_model_fails_recipe(self):
Expand Down Expand Up @@ -4418,7 +4411,7 @@ def test_ai_legacy_chat_completions_endpoint(self):
])
assert matches >= 1

def test_model_id(self):
def test_model_id(self, saved_extract_schema_model):
"""
Test using extract.ai with a saved model
"""
Expand All @@ -4444,7 +4437,7 @@ def test_model_id(self):
('square' in df['Shapes'].values or 'circle' in df['Shapes'].values or 'diamond' in df['Shapes'].values)
)

def test_model_id_additional_properties(self):
def test_model_id_additional_properties(self, saved_extract_schema_model):
"""
Test non-explicitly passed properties, i.e. kwargs
This is set to specify a type of integer for the items in the array
Expand All @@ -4465,7 +4458,7 @@ def test_model_id_additional_properties(self):
)
assert 3 in df['Numbers'][0] or 2 in df['Numbers'][0]

def test_model_id_named_output_single_column(self):
def test_model_id_named_output_single_column(self, saved_extract_schema_model):
"""
Test using a predefined model that specifies
one output for all extracted data
Expand Down Expand Up @@ -4493,7 +4486,7 @@ def test_model_id_named_output_single_column(self):
'Colors' in df['result'][0]
)

def test_model_id_named_output_multi_column(self):
def test_model_id_named_output_multi_column(self, saved_extract_schema_model):
"""
Test using a predefined model that specifies
one output per model row
Expand Down Expand Up @@ -4523,7 +4516,7 @@ def test_model_id_named_output_multi_column(self):
('square' in df['Shapes'].values or 'circle' in df['Shapes'].values)
)

def test_model_id_object_with_properties(self):
def test_model_id_object_with_properties(self, saved_extract_schema_model):
"""
Test a model_id that contains an output of type object
that contains properties defined as JSON
Expand All @@ -4545,7 +4538,7 @@ def test_model_id_object_with_properties(self):
)
assert 'unit' in df['attributes'][0] and 'value' in df['attributes'][1]

def test_model_id_array_of_objects(self):
def test_model_id_array_of_objects(self, saved_extract_schema_model):
"""
Test a model_id that contains an output of type array
that contains items as objects with properties defined as JSON
Expand Down
18 changes: 18 additions & 0 deletions tests/recipes/wrangles/test_generate_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@ def call_openai(api_key, payload, url, timeout, retries, previous_response_id=No
return calls


@pytest.mark.parametrize("via_recipe", [False, True])
@pytest.mark.parametrize("model", ["gpt-6-luna", "gpt-6-sol"])
@pytest.mark.parametrize("configured_effort", [None, "high"])
def test_generate_preserves_packaged_and_configured_model_reasoning(
generate_config, generation_calls, via_recipe, model, configured_effort
):
config, install = generate_config
if configured_effort is not None:
config["providers"]["openai"]["models"][model]["defaults"]["reasoning"] = {
"effort": configured_effort,
}
install()
_run_generate(via_recipe, model=model)
assert len(generation_calls) == 2
for payload, _ in generation_calls:
assert payload["reasoning"] == {"effort": configured_effort or "none"}


@pytest.mark.parametrize("via_recipe", [False, True])
@pytest.mark.parametrize("explicit_model", [None, "explicit-model"])
@pytest.mark.parametrize("configured_model", [None, "configured-model"])
Expand Down
4 changes: 3 additions & 1 deletion tests/recipes/wrangles/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9374,6 +9374,7 @@ def test_all_wrangle_docstrings_parse_as_yaml(self):

def test_extract_ai_schema_documents_public_parameters_and_example_shape(self):
import inspect
import jsonschema
import yaml

method = wrangles.recipe._recipe_wrangles.extract.ai
Expand Down Expand Up @@ -9435,7 +9436,8 @@ def test_extract_ai_schema_documents_public_parameters_and_example_shape(self):
definition["description"].strip()
for definition in field_schema["properties"].values()
)
assert "effort" in properties["reasoning"]["properties"]
jsonschema.validate("none", properties["reasoning"])
jsonschema.validate({"effort": "none"}, properties["reasoning"])

def test_extract_codes_schema_matches_microservice_params(self):
import yaml
Expand Down
4 changes: 2 additions & 2 deletions tests/test_ai_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def test_packaged_operation_defaults_and_model_lifecycle():
generation = ai_config.resolve("generate.ai")
assert extraction["model"] == generation["model"] == "gpt-6-luna"
assert extraction["reasoning"] == {"effort": "none"}
assert generation["reasoning"] == {"effort": "low"}
assert generation["reasoning"] == {"effort": "none"}
assert generation["strict"] is True
assert generation["recipe_strict"] is False
assert extraction["request_timeout_seconds"] == 12
Expand Down Expand Up @@ -407,7 +407,7 @@ def test_inherited_snapshot_defaults_are_validated(monkeypatch, tmp_path):
(lambda c: c["providers"]["openai"]["models"]["gpt-6-luna"]["supported_values"].update({"reasoning.effort": True}), "list of enum values"),
(lambda c: c["providers"]["openai"]["models"]["gpt-6-luna"]["supported_values"].update({"reasoning.effort": [True]}), "list of enum values"),
(lambda c: c["providers"]["openai"]["models"]["gpt-6-luna"]["defaults"]["reasoning"].update(effort="invalid"), "must be one of"),
(lambda c: c["operations"]["generate.ai"]["defaults"]["reasoning"].update(effort="invalid"), "must be one of"),
(lambda c: c["operations"]["generate.ai"]["defaults"].update(reasoning={"effort": "invalid"}), "must be one of"),
(lambda c: c["operations"]["generate.ai"]["defaults"].update(reasoning="low"), "reasoning must be an object"),
(lambda c: c["operations"]["extract.ai"]["defaults"].update(default_concurrency=True), "must be an integer"),
(lambda c: c["operations"]["extract.ai"]["defaults"].update(request_timeout_seconds=float("nan")), "positive finite"),
Expand Down
Loading
Loading