From aaeaafba252d9d37b3df192fc7237610f920a9f1 Mon Sep 17 00:00:00 2001 From: Dennis Irorere <35724017+denironyx@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:14:22 +0100 Subject: [PATCH 1/2] fix: the anthropic provider stops asking for a temperature --- HISTORY.rst | 18 ++++ docs/semantic-layer.md | 2 + graphfaker/llm/providers.py | 168 +++++++++++++++++++++++++----------- tests/test_llm_draft.py | 123 ++++++++++++++++++++++++++ 4 files changed, 259 insertions(+), 52 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 34b7abb..2bd20b2 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -177,6 +177,24 @@ byte-identical to 1.2.0. Thirty lines rather than a dependency: the parts of the format that are genuinely hard are the parts nobody puts an API key in. +* The Anthropic provider no longer asks for a temperature, which the + current models will not take. They removed the sampling controls outright + and replaced them with ``output_config.effort``, so the client raised + ``Messages.create() got an unexpected keyword argument 'temperature'`` + before anything reached the network: a near-zero temperature was not a + conservative default, it was a request that could not be made. It now + sends adaptive thinking and ``effort: high``, and streams, because + thinking makes a request take long enough that a non-streaming call can + hit the client's own timeout. + + ``temperature`` is gone from the provider protocol with it. One provider + still takes it and sets its own; it is not a parameter every provider + has. Both providers now send the current request shape and drop whatever + the far end names in its rejection, which covers an older model that has + no effort level and an older installed client that has never heard of + one. Picking the shape from the model's name would mean a table of model + names that is wrong the week after it is written. + * A seeded dataset is the same tomorrow. ``date_of_birth`` and ``date`` count backwards from today and were reading the real clock, so ``graphfaker generate fraud --seed 42`` produced a different birth-date diff --git a/docs/semantic-layer.md b/docs/semantic-layer.md index 06e45be..a88e5ca 100644 --- a/docs/semantic-layer.md +++ b/docs/semantic-layer.md @@ -44,6 +44,8 @@ Configure it with environment variables so a key never lands in a shell history. `--provider`, `--model` and `--base-url` override them per call. +There is no temperature setting, and that is not an omission. The current Anthropic models removed the sampling controls and replaced them with an effort level, which the drafter sets to `high`: a draft runs once, is read by a human, and decides how much hand-editing the file needs. Neither provider lets you make a draft reproducible, because no model does; the reproducibility lives in the file it writes. + The `.env` is the nearest one from the working directory up to the repository root, so it is found from a subdirectory and a stray file outside the repo is not. **Anything already in the environment wins**, so a key you exported on purpose, or a CI job's secrets, are never replaced by a file somebody forgot about. `--env-file` points at a different one. `graphfaker info` says which file was used and which keys are present, and never prints a value. ## Drafting a schema diff --git a/graphfaker/llm/providers.py b/graphfaker/llm/providers.py index b49fe03..3b01429 100644 --- a/graphfaker/llm/providers.py +++ b/graphfaker/llm/providers.py @@ -1,11 +1,16 @@ """Somewhere to send a prompt, in as few lines as the job needs. One method. The task is "turn this description into a schema", which is one -request and one response, so there is no streaming, no tool loop, no -conversation state and no retry policy beyond what the vendor's own client -already does. A provider is anything with :meth:`Provider.complete`, which -means bringing your own is passing an object rather than inheriting from -one. +request and one response, so there is no tool loop, no conversation state +and no retry policy beyond what the vendor's own client already does. A +provider is anything with :meth:`Provider.complete`, which means bringing +your own is passing an object rather than inheriting from one. + +What is here instead of simplicity is one thing: the request shape adapts +to what the far end will take. Both APIs have drifted and neither +announces which way, so picking a shape from the model's name means a +table of model names that is wrong the week after it is written. Send the +current shape, read the rejection, drop what it named, ask again. The two that ship cover what people actually have: Anthropic's API, and anything speaking the OpenAI chat-completions protocol, which is the hosted @@ -70,20 +75,41 @@ class Provider(Protocol): name: str model: str - def complete(self, system: str, user: str, *, max_tokens: int = 8192, temperature: float = 0.0) -> str: - """The model's reply as text, with no wrapper of any kind.""" + def complete(self, system: str, user: str, *, max_tokens: int = 8192) -> str: + """The model's reply as text, with no wrapper of any kind. + + No temperature. The current Anthropic models removed the sampling + controls outright and replaced them with an effort level, so a + temperature is not a parameter every provider has; the one that + still takes it sets its own. + """ ... @dataclass class AnthropicProvider: - """Anthropic's Messages API through the official client.""" + """Anthropic's Messages API through the official client. + + No temperature. Current models removed the sampling controls + (``temperature``, ``top_p``, ``top_k``) and replaced them with + ``output_config.effort``, so asking for a temperature is not a + conservative default, it is a request the model will not take: the + client raises before anything reaches the network. + + ``effort`` is how deeply it works instead, and ``high`` is right for + this job. The default on the current model is ``medium``, and a schema + draft runs once, is read by a human, and decides how much hand-editing + the file needs, so the extra thinking is worth paying for. Pass a + different one to the constructor for a cheaper draft. + """ model: str = DEFAULT_ANTHROPIC_MODEL api_key: str | None = None + #: low | medium | high | xhigh | max. + effort: str = "high" name: str = "anthropic" - def complete(self, system: str, user: str, *, max_tokens: int = 8192, temperature: float = 0.0) -> str: + def complete(self, system: str, user: str, *, max_tokens: int = 8192) -> str: try: import anthropic except ImportError as exc: # pragma: no cover - exercised by hand @@ -91,16 +117,28 @@ def complete(self, system: str, user: str, *, max_tokens: int = 8192, temperatur "the anthropic client is not installed; pip install 'graphfaker[llm]'" ) from exc client = anthropic.Anthropic(api_key=_require_key("ANTHROPIC_API_KEY", self.api_key)) - try: - message = client.messages.create( - model=self.model, - max_tokens=max_tokens, - temperature=temperature, - system=system, - messages=[{"role": "user", "content": user}], - ) - except Exception as exc: - raise ProviderError(f"anthropic ({self.model}): {exc}") from exc + + request: dict[str, object] = { + "model": self.model, + "max_tokens": max_tokens, + # Adaptive thinking, said explicitly rather than left to the + # default, because the default differs across the family: on the + # current models omitting it thinks anyway, on some older ones it + # does not. A schema is worth thinking about either way. + "thinking": {"type": "adaptive"}, + "output_config": {"effort": self.effort}, + "system": system, + "messages": [{"role": "user", "content": user}], + } + + def send(**kwargs: object): + # Streamed, because adaptive thinking makes a request take long + # enough that a non-streaming call can hit the client's own + # timeout before the model has finished. + with client.messages.stream(**kwargs) as stream: + return stream.get_final_message() + + message = _send(send, request, _ANTHROPIC_ADAPTATIONS, f"anthropic ({self.model})") return "".join(block.text for block in message.content if getattr(block, "type", "") == "text") @@ -122,20 +160,18 @@ class OpenAICompatibleProvider: api_key: str | None = None name: str = "openai" - def complete(self, system: str, user: str, *, max_tokens: int = 8192, temperature: float = 0.0) -> str: + def complete(self, system: str, user: str, *, max_tokens: int = 8192) -> str: """One request, retried without whatever the endpoint rejected. - The protocol has drifted. ``gpt-5`` and the reasoning models renamed + The protocol has drifted: ``gpt-5`` and the reasoning models renamed ``max_tokens`` to ``max_completion_tokens`` and accept only the - default temperature; an older local server knows ``max_tokens`` and - nothing else. Sending the union fails on both, and picking one by - looking at the model name means a table of model names that is wrong - the week after it is written. - - So: send the usual request, and when the endpoint says which - parameter it will not take, rename or drop that one and ask again. - At most one retry per parameter, and anything that is not an - unsupported-parameter complaint is raised as it arrived. + default temperature, while an older local server knows ``max_tokens`` + and nothing else. See :func:`_send`. + + A near-zero temperature is asked for and given up without complaint. + It does not make a draft reproducible, and nothing here depends on + that; it only leans the model towards the common shape of a schema + rather than an inventive one. """ try: import openai @@ -154,42 +190,70 @@ def complete(self, system: str, user: str, *, max_tokens: int = 8192, temperatur request: dict[str, object] = { "model": self.model, "max_tokens": max_tokens, - "temperature": temperature, + "temperature": 0.0, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], } - for _ in range(len(_ADAPTATIONS) + 1): - try: - reply = client.chat.completions.create(**request) - except Exception as exc: - adapted = _adapt(request, str(exc)) - if adapted is None: - where = self.base_url or "api.openai.com" - raise ProviderError(f"openai ({self.model} at {where}): {exc}") from exc - continue - return reply.choices[0].message.content or "" - raise ProviderError(f"openai ({self.model}): the endpoint rejected every form of the request") + where = self.base_url or "api.openai.com" + reply = _send( + client.chat.completions.create, request, _OPENAI_ADAPTATIONS, f"openai ({self.model} at {where})" + ) + return reply.choices[0].message.content or "" #: Parameter the endpoint refused, and what to send instead. ``None`` drops -#: it: a model that will not take a temperature has one it insists on. -_ADAPTATIONS: dict[str, str | None] = { +#: it. Both families have drifted and neither announces which way: ``gpt-5`` +#: renamed ``max_tokens`` and fixed the temperature, while an older local +#: server knows only the old name. +_OPENAI_ADAPTATIONS: dict[str, str | None] = { "max_tokens": "max_completion_tokens", "max_completion_tokens": "max_tokens", "temperature": None, } +#: The same for Anthropic, where the modern request shape is sent first and +#: dropped a field at a time for an older model or an older installed +#: client: ``thinking`` and ``output_config`` do not exist before a certain +#: generation, and a client old enough not to know the keyword raises a +#: ``TypeError`` rather than returning a 400. +_ANTHROPIC_ADAPTATIONS: dict[str, str | None] = { + "output_config": None, + "thinking": None, + "temperature": None, +} + + +def _send(call, request: dict[str, object], adaptations: dict[str, str | None], label: str): + """Make the request, dropping whatever the far end says it will not take. + + Picking the request shape by looking at the model name means a table of + model names that is wrong the week after it is written, and sending the + union of every shape fails everywhere at once. So send the current one + and let the rejection say what to remove. At most one retry per + parameter; anything that is not a complaint about a parameter is raised + as it arrived. + """ + for _ in range(len(adaptations) + 1): + try: + return call(**request) + except Exception as exc: + if _adapt(request, str(exc), adaptations) is None: + raise ProviderError(f"{label}: {exc}") from exc + raise ProviderError(f"{label}: every form of the request was rejected") + -def _adapt(request: dict[str, object], message: str) -> str | None: - """Rename or drop the parameter the endpoint named, or give up. +def _adapt(request: dict[str, object], message: str, adaptations: dict[str, str | None]) -> str | None: + """Rename or drop the parameter the far end named, or give up. Matching on the message is unlovely and is the only thing available: - the error is a 400 whose body names the parameter, and the alternative - is a table of which models take which arguments. + the failure is either a 400 whose body names the parameter or a + ``TypeError`` from a client that has removed the keyword, and both put + the name in the text. """ lowered = message.lower() - if "unsupported" not in lowered and "unrecognized" not in lowered and "not supported" not in lowered: + complaints = ("unsupported", "unrecognized", "not supported", "unexpected keyword", "removed") + if not any(word in lowered for word in complaints): return None - for parameter, replacement in _ADAPTATIONS.items(): + for parameter, replacement in adaptations.items(): if parameter not in request or parameter not in lowered: continue value = request.pop(parameter) @@ -215,8 +279,8 @@ class RecordedProvider: model: str = "recorded" calls: list[tuple[str, str]] = field(default_factory=list) - def complete(self, system: str, user: str, *, max_tokens: int = 8192, temperature: float = 0.0) -> str: - del max_tokens, temperature + def complete(self, system: str, user: str, *, max_tokens: int = 8192) -> str: + del max_tokens self.calls.append((system, user)) if len(self.calls) > len(self.replies): raise ProviderError( diff --git a/tests/test_llm_draft.py b/tests/test_llm_draft.py index c715c08..2012155 100644 --- a/tests/test_llm_draft.py +++ b/tests/test_llm_draft.py @@ -250,3 +250,126 @@ def test_a_draft_that_will_not_validate_exits_non_zero(tmp_path, monkeypatch): result = runner.invoke(app, ["schema", "draft", "a clinic", "--out", str(out)]) assert result.exit_code == 1 assert not out.exists(), "a schema that does not validate is not written" + + +# ------------------------------------------------ the request that is sent + + +class _Block: + type = "text" + + def __init__(self, text: str) -> None: + self.text = text + + +class _Message: + def __init__(self, text: str) -> None: + self.content = [_Block(text)] + + +class _Stream: + def __init__(self, text: str) -> None: + self._text = text + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def get_final_message(self): + return _Message(self._text) + + +class _FakeAnthropic: + """Records every request shape it was asked for, and refuses some.""" + + #: Parameter name to the exception a stand-in client raises for it, which + #: is how the two ways this fails are reproduced: a 400 from the API and + #: a TypeError from a client that has removed the keyword. + refuse: dict[str, Exception] = {} + calls: list[dict] = [] + + def __init__(self, api_key=None, **_): + self.messages = self + + def stream(self, **kwargs): + type(self).calls.append(kwargs) + for parameter, error in type(self).refuse.items(): + if parameter in kwargs: + raise error + return _Stream("name: ok\nnodes:\n- {name: A, count: 1}\n") + + +def _fake_anthropic(monkeypatch, refuse=None): + import anthropic + + _FakeAnthropic.calls = [] + _FakeAnthropic.refuse = refuse or {} + monkeypatch.setattr(anthropic, "Anthropic", _FakeAnthropic) + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + return _FakeAnthropic + + +def test_the_anthropic_request_carries_effort_and_no_temperature(monkeypatch): + """The bug this exists for. + + Current Anthropic models removed the sampling controls and replaced them + with an effort level, and the client raises a TypeError before anything + reaches the network: ``Messages.create() got an unexpected keyword + argument 'temperature'``. Asking for a near-zero temperature was not a + conservative default, it was a request the model will not take. + """ + from graphfaker.llm import AnthropicProvider + + fake = _fake_anthropic(monkeypatch) + AnthropicProvider().complete("system", "user") + + assert len(fake.calls) == 1 + sent = fake.calls[0] + assert "temperature" not in sent and "top_p" not in sent and "top_k" not in sent + assert sent["thinking"] == {"type": "adaptive"} + assert sent["output_config"] == {"effort": "high"} + assert sent["model"].startswith("claude-") + assert sent["system"] == "system" + assert sent["messages"] == [{"role": "user", "content": "user"}] + # No thinking budget: removed on the current models, a 400 if sent. + assert "budget_tokens" not in str(sent) + + assert AnthropicProvider(effort="low").model == AnthropicProvider().model + _fake_anthropic(monkeypatch) + AnthropicProvider(effort="low").complete("s", "u") + assert _FakeAnthropic.calls[0]["output_config"] == {"effort": "low"} + + +def test_a_client_or_model_that_refuses_a_field_gets_the_request_without_it(monkeypatch): + """Both ways the far end says no, and the request shrinks to fit. + + An older installed client raises a TypeError naming the keyword; an + older model returns a 400 naming it. Picking the shape from the model + name would mean a table of model names that is wrong the week after it + is written. + """ + from graphfaker.llm import AnthropicProvider + + fake = _fake_anthropic( + monkeypatch, + refuse={ + "output_config": TypeError("Messages.stream() got an unexpected keyword argument 'output_config'"), + "thinking": Exception("thinking: unsupported parameter for this model"), + }, + ) + AnthropicProvider().complete("s", "u") + + assert len(fake.calls) == 3, "full shape, then without output_config, then without thinking" + assert "output_config" in fake.calls[0] + assert "output_config" not in fake.calls[1] and "thinking" in fake.calls[1] + assert "output_config" not in fake.calls[2] and "thinking" not in fake.calls[2] + + +def test_an_error_that_is_not_about_a_parameter_is_raised_as_it_arrived(monkeypatch): + from graphfaker.llm import AnthropicProvider, ProviderError + + _fake_anthropic(monkeypatch, refuse={"model": Exception("connection reset by peer")}) + with pytest.raises(ProviderError, match="connection reset"): + AnthropicProvider().complete("s", "u") From fdb9ac1d14b29ff7da695a10210911a86712abec Mon Sep 17 00:00:00 2001 From: Dennis Irorere <35724017+denironyx@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:25:29 +0100 Subject: [PATCH 2/2] fix: the anthropic provider stops asking for a temperature --- tests/test_llm_draft.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/test_llm_draft.py b/tests/test_llm_draft.py index 2012155..130e339 100644 --- a/tests/test_llm_draft.py +++ b/tests/test_llm_draft.py @@ -11,6 +11,9 @@ from __future__ import annotations +import sys +from types import SimpleNamespace + import pytest from typer.testing import CliRunner @@ -302,11 +305,17 @@ def stream(self, **kwargs): def _fake_anthropic(monkeypatch, refuse=None): - import anthropic + """Stand in for the whole ``anthropic`` module, installed or not. + The provider imports it inside ``complete``, so putting a stub in + ``sys.modules`` is enough and means these tests need no network, no key + and no optional dependency. That last one matters: CI installs the dev + and examples extras, not ``graphfaker[llm]``, and a test that imported + the real client to monkeypatch it passed here and failed there. + """ _FakeAnthropic.calls = [] _FakeAnthropic.refuse = refuse or {} - monkeypatch.setattr(anthropic, "Anthropic", _FakeAnthropic) + monkeypatch.setitem(sys.modules, "anthropic", SimpleNamespace(Anthropic=_FakeAnthropic)) monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") return _FakeAnthropic @@ -336,7 +345,7 @@ def test_the_anthropic_request_carries_effort_and_no_temperature(monkeypatch): # No thinking budget: removed on the current models, a 400 if sent. assert "budget_tokens" not in str(sent) - assert AnthropicProvider(effort="low").model == AnthropicProvider().model + # The effort is the one knob, so it has to reach the request. _fake_anthropic(monkeypatch) AnthropicProvider(effort="low").complete("s", "u") assert _FakeAnthropic.calls[0]["output_config"] == {"effort": "low"}