personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

providers: add json_schema parameter with advisory validation

Adds an optional `json_schema: dict | None` parameter to generate(),
agenerate(), and generate_with_result() in think/models.py and threads
it through all four provider modules. Each provider translates to its
native structured-output mechanism (Gemini response_json_schema, OpenAI
Responses text.format.json_schema, Anthropic output_config with
forced-tool-use fallback on BadRequestError, Ollama dict format).

After the call, an advisory jsonschema.validate pass logs violations
and surfaces a schema_validation field on GenerateResult from
generate_with_result; it never raises and never mutates the returned
text. When json_schema is None, every provider's SDK kwargs are
byte-for-byte identical to before. Lode 1 of 3 — talents and callers
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+1015 -12
+1
pyproject.toml
··· 51 51 "openai-agents>=0.1.0", 52 52 "anthropic", 53 53 "httpx", 54 + "jsonschema>=4.26,<5", 54 55 "genai-prices", 55 56 "pypdf", 56 57 "pdf2image",
+124
tests/test_anthropic.py
··· 7 7 import sys 8 8 import types 9 9 from types import SimpleNamespace 10 + from unittest.mock import AsyncMock, MagicMock 10 11 11 12 from think.models import CLAUDE_SONNET_4 12 13 ··· 93 94 else: 94 95 self.messages = DummyMessages() 95 96 97 + class DummyBadRequestError(Exception): 98 + pass 99 + 96 100 anthropic_stub.Anthropic = DummyClient 97 101 anthropic_stub.AsyncAnthropic = DummyClient # Add async version 102 + anthropic_stub.BadRequestError = DummyBadRequestError 98 103 99 104 # Add types to the types module 100 105 anthropic_types_stub.MessageParam = dict ··· 401 406 events = [json.loads(line) for line in out_lines if line] 402 407 if events: 403 408 assert any(e["event"] == "error" for e in events) 409 + 410 + 411 + class TestRunGenerateJsonSchema: 412 + def test_no_schema_keeps_prompt_append(self, monkeypatch): 413 + provider = importlib.reload( 414 + importlib.import_module("think.providers.anthropic") 415 + ) 416 + mock_client = MagicMock() 417 + mock_response = MagicMock() 418 + mock_response.content = [SimpleNamespace(type="text", text="{}")] 419 + mock_response.usage = None 420 + mock_response.stop_reason = "end_turn" 421 + mock_client.messages.create.return_value = mock_response 422 + monkeypatch.setattr(provider, "_get_anthropic_client", lambda: mock_client) 423 + 424 + provider.run_generate( 425 + "hello", 426 + json_output=True, 427 + system_instruction="base", 428 + ) 429 + 430 + call_kwargs = mock_client.messages.create.call_args.kwargs 431 + assert call_kwargs["system"].endswith( 432 + "Respond with valid JSON only. No explanation or markdown." 433 + ) 434 + 435 + def test_with_schema_uses_output_config(self, monkeypatch): 436 + provider = importlib.reload( 437 + importlib.import_module("think.providers.anthropic") 438 + ) 439 + mock_client = MagicMock() 440 + mock_response = MagicMock() 441 + mock_response.content = [SimpleNamespace(type="text", text="{}")] 442 + mock_response.usage = None 443 + mock_response.stop_reason = "end_turn" 444 + mock_client.messages.create.return_value = mock_response 445 + monkeypatch.setattr(provider, "_get_anthropic_client", lambda: mock_client) 446 + schema = {"type": "object"} 447 + 448 + provider.run_generate( 449 + "hello", 450 + system_instruction="base", 451 + json_schema=schema, 452 + ) 453 + 454 + call_kwargs = mock_client.messages.create.call_args.kwargs 455 + assert call_kwargs["output_config"] == { 456 + "format": {"type": "json_schema", "schema": schema} 457 + } 458 + assert call_kwargs["system"] == "base" 459 + 460 + def test_fallback_on_bad_request(self, monkeypatch): 461 + provider = importlib.reload( 462 + importlib.import_module("think.providers.anthropic") 463 + ) 464 + mock_client = MagicMock() 465 + 466 + class DummyBadRequestError(Exception): 467 + pass 468 + 469 + fallback_response = MagicMock() 470 + fallback_response.content = [ 471 + SimpleNamespace(type="tool_use", input={"key": "value"}), 472 + ] 473 + fallback_response.usage = None 474 + fallback_response.stop_reason = "end_turn" 475 + mock_client.messages.create.side_effect = [ 476 + DummyBadRequestError("bad schema"), 477 + fallback_response, 478 + ] 479 + 480 + monkeypatch.setattr(provider, "BadRequestError", DummyBadRequestError) 481 + monkeypatch.setattr(provider, "_get_anthropic_client", lambda: mock_client) 482 + schema = {"type": "object"} 483 + 484 + result = provider.run_generate("hello", json_schema=schema) 485 + 486 + assert mock_client.messages.create.call_count == 2 487 + retry_kwargs = mock_client.messages.create.call_args_list[1].kwargs 488 + assert retry_kwargs["tools"] == [ 489 + { 490 + "name": "response", 491 + "description": "Generate the requested JSON response.", 492 + "input_schema": schema, 493 + } 494 + ] 495 + assert retry_kwargs["tool_choice"] == {"type": "tool", "name": "response"} 496 + assert "output_config" not in retry_kwargs 497 + assert result["text"] == json.dumps({"key": "value"}) 498 + 499 + def test_async_with_schema_uses_output_config(self, monkeypatch): 500 + provider = importlib.reload( 501 + importlib.import_module("think.providers.anthropic") 502 + ) 503 + mock_client = MagicMock() 504 + mock_client.messages.create = AsyncMock() 505 + mock_response = MagicMock() 506 + mock_response.content = [SimpleNamespace(type="text", text="{}")] 507 + mock_response.usage = None 508 + mock_response.stop_reason = "end_turn" 509 + mock_client.messages.create.return_value = mock_response 510 + monkeypatch.setattr( 511 + provider, "_get_async_anthropic_client", lambda: mock_client 512 + ) 513 + schema = {"type": "object"} 514 + 515 + asyncio.run( 516 + provider.run_agenerate( 517 + "hello", 518 + system_instruction="base", 519 + json_schema=schema, 520 + ) 521 + ) 522 + 523 + call_kwargs = mock_client.messages.create.call_args.kwargs 524 + assert call_kwargs["output_config"] == { 525 + "format": {"type": "json_schema", "schema": schema} 526 + } 527 + assert call_kwargs["system"] == "base"
+104 -1
tests/test_google.py
··· 6 6 import json 7 7 import sys 8 8 from types import SimpleNamespace 9 - from unittest.mock import AsyncMock 9 + from unittest.mock import AsyncMock, MagicMock 10 10 11 11 from tests.conftest import setup_google_genai_stub 12 12 from think.models import GEMINI_FLASH ··· 257 257 """Test message when finish_reason is None.""" 258 258 msg = _format_completion_message(None, had_tool_calls=False) 259 259 assert msg == "Completed (unknown)." 260 + 261 + 262 + class TestRunGenerateJsonSchema: 263 + def test_no_schema_kwargs_unchanged(self, monkeypatch): 264 + setup_google_genai_stub(monkeypatch, with_thinking=False) 265 + sys.modules.pop("think.providers.google", None) 266 + provider = importlib.reload(importlib.import_module("think.providers.google")) 267 + 268 + mock_client = MagicMock() 269 + mock_client.models.generate_content.return_value = SimpleNamespace( 270 + text="[]", 271 + candidates=[], 272 + usage_metadata=None, 273 + ) 274 + monkeypatch.setattr( 275 + provider, "get_or_create_client", lambda _client=None: mock_client 276 + ) 277 + 278 + provider.run_generate("hello", model=GEMINI_FLASH, json_output=True) 279 + 280 + config = mock_client.models.generate_content.call_args.kwargs["config"] 281 + assert config.response_mime_type == "application/json" 282 + assert getattr(config, "response_json_schema", None) is None 283 + 284 + def test_with_schema_adds_json_schema(self, monkeypatch): 285 + setup_google_genai_stub(monkeypatch, with_thinking=False) 286 + sys.modules.pop("think.providers.google", None) 287 + provider = importlib.reload(importlib.import_module("think.providers.google")) 288 + 289 + schema = {"type": "object"} 290 + mock_client = MagicMock() 291 + mock_client.models.generate_content.return_value = SimpleNamespace( 292 + text="[]", 293 + candidates=[], 294 + usage_metadata=None, 295 + ) 296 + monkeypatch.setattr( 297 + provider, "get_or_create_client", lambda _client=None: mock_client 298 + ) 299 + 300 + provider.run_generate( 301 + "hello", model=GEMINI_FLASH, json_output=True, json_schema=schema 302 + ) 303 + 304 + config = mock_client.models.generate_content.call_args.kwargs["config"] 305 + assert config.response_mime_type == "application/json" 306 + assert config.response_json_schema == schema 307 + 308 + def test_async_no_schema_kwargs_unchanged(self, monkeypatch): 309 + setup_google_genai_stub(monkeypatch, with_thinking=False) 310 + sys.modules.pop("think.providers.google", None) 311 + provider = importlib.reload(importlib.import_module("think.providers.google")) 312 + 313 + mock_client = MagicMock() 314 + mock_client.aio.models.generate_content = AsyncMock( 315 + return_value=SimpleNamespace( 316 + text="[]", 317 + candidates=[], 318 + usage_metadata=None, 319 + ) 320 + ) 321 + monkeypatch.setattr( 322 + provider, "get_or_create_client", lambda _client=None: mock_client 323 + ) 324 + 325 + asyncio.run( 326 + provider.run_agenerate("hello", model=GEMINI_FLASH, json_output=True) 327 + ) 328 + 329 + config = mock_client.aio.models.generate_content.call_args.kwargs["config"] 330 + assert config.response_mime_type == "application/json" 331 + assert getattr(config, "response_json_schema", None) is None 332 + 333 + def test_async_with_schema_adds_json_schema(self, monkeypatch): 334 + setup_google_genai_stub(monkeypatch, with_thinking=False) 335 + sys.modules.pop("think.providers.google", None) 336 + provider = importlib.reload(importlib.import_module("think.providers.google")) 337 + 338 + schema = {"type": "object"} 339 + mock_client = MagicMock() 340 + mock_client.aio.models.generate_content = AsyncMock( 341 + return_value=SimpleNamespace( 342 + text="[]", 343 + candidates=[], 344 + usage_metadata=None, 345 + ) 346 + ) 347 + monkeypatch.setattr( 348 + provider, "get_or_create_client", lambda _client=None: mock_client 349 + ) 350 + 351 + asyncio.run( 352 + provider.run_agenerate( 353 + "hello", 354 + model=GEMINI_FLASH, 355 + json_output=True, 356 + json_schema=schema, 357 + ) 358 + ) 359 + 360 + config = mock_client.aio.models.generate_content.call_args.kwargs["config"] 361 + assert config.response_mime_type == "application/json" 362 + assert config.response_json_schema == schema
+251
tests/test_models.py
··· 3 3 4 4 """Tests for think.models module.""" 5 5 6 + import asyncio 7 + import logging 8 + from types import SimpleNamespace 9 + from unittest.mock import AsyncMock, MagicMock, patch 10 + 6 11 import pytest 7 12 8 13 from think.models import ( ··· 21 26 TIER_LITE, 22 27 TIER_PRO, 23 28 TYPE_DEFAULTS, 29 + IncompleteJSONError, 30 + _validate_schema, 31 + agenerate, 24 32 calc_token_cost, 33 + generate, 34 + generate_with_result, 25 35 get_context_registry, 26 36 get_usage_cost, 27 37 iter_token_log, ··· 738 748 entry = json.loads(log_file.read_text().strip()) 739 749 assert entry["usage"]["cache_creation_tokens"] == 2000 740 750 assert entry["usage"]["cached_tokens"] == 3000 751 + 752 + 753 + class TestValidateSchema: 754 + def test_valid_instance(self): 755 + schema = { 756 + "type": "object", 757 + "properties": {"field": {"type": "string"}}, 758 + "required": ["field"], 759 + } 760 + 761 + result = _validate_schema('{"field": "ok"}', schema) 762 + 763 + assert result == {"valid": True, "errors": []} 764 + 765 + def test_schema_violation_type(self): 766 + schema = { 767 + "type": "object", 768 + "properties": {"field": {"type": "integer"}}, 769 + "required": ["field"], 770 + } 771 + 772 + result = _validate_schema('{"field": "ok"}', schema) 773 + 774 + assert result["valid"] is False 775 + assert len(result["errors"]) == 1 776 + assert result["errors"][0]["path"] == "/field" 777 + assert result["errors"][0]["constraint"] == "type" 778 + assert result["errors"][0]["message"] 779 + 780 + def test_schema_violation_required(self): 781 + schema = {"type": "object", "required": ["field"]} 782 + 783 + result = _validate_schema("{}", schema) 784 + 785 + assert result["valid"] is False 786 + assert len(result["errors"]) == 1 787 + assert result["errors"][0]["path"] == "" 788 + assert result["errors"][0]["constraint"] == "required" 789 + 790 + def test_multiple_violations(self): 791 + schema = { 792 + "type": "object", 793 + "properties": { 794 + "field": {"type": "integer"}, 795 + "other": {"type": "string"}, 796 + }, 797 + "required": ["field", "other"], 798 + } 799 + 800 + result = _validate_schema('{"field": "bad"}', schema) 801 + 802 + assert result["valid"] is False 803 + assert len(result["errors"]) == 2 804 + assert {error["constraint"] for error in result["errors"]} == { 805 + "required", 806 + "type", 807 + } 808 + 809 + def test_parse_failure(self): 810 + schema = {"type": "object"} 811 + 812 + result = _validate_schema("{", schema) 813 + 814 + assert result["valid"] is False 815 + assert result["errors"] == [ 816 + { 817 + "path": "", 818 + "constraint": "json_parse", 819 + "message": result["errors"][0]["message"], 820 + } 821 + ] 822 + 823 + def test_json_pointer_escape(self): 824 + schema = { 825 + "type": "object", 826 + "properties": { 827 + "a/b": { 828 + "type": "object", 829 + "properties": {"c~d": {"type": "integer"}}, 830 + } 831 + }, 832 + } 833 + 834 + result = _validate_schema('{"a/b": {"c~d": "bad"}}', schema) 835 + 836 + assert result["valid"] is False 837 + assert result["errors"][0]["path"] == "/a~1b/c~0d" 838 + 839 + def test_warning_logged_for_violations(self, caplog): 840 + schema = { 841 + "type": "object", 842 + "properties": {"field": {"type": "integer"}}, 843 + } 844 + 845 + with caplog.at_level(logging.WARNING): 846 + _validate_schema('{"field": "bad"}', schema) 847 + 848 + assert any( 849 + record.levelno == logging.WARNING 850 + and "schema_validation:" in record.getMessage() 851 + for record in caplog.records 852 + ) 853 + 854 + def test_invalid_schema_does_not_raise(self): 855 + result = _validate_schema('{"field": "ok"}', {"type": "not-a-real-type"}) 856 + 857 + assert result["valid"] is False 858 + assert len(result["errors"]) == 1 859 + assert result["errors"][0]["constraint"] == "schema_validation" 860 + 861 + 862 + class TestGenerateJsonSchemaPlumbing: 863 + def test_generate_forces_json_output_with_schema(self): 864 + schema = {"type": "object"} 865 + provider_module = SimpleNamespace( 866 + run_generate=MagicMock(return_value={"text": "{}", "finish_reason": "stop"}) 867 + ) 868 + 869 + with ( 870 + patch("think.models.resolve_provider", return_value=("fake", "model")), 871 + patch("think.providers.get_provider_module", return_value=provider_module), 872 + ): 873 + result = generate("hello", "test.context", json_schema=schema) 874 + 875 + assert result == "{}" 876 + call_kwargs = provider_module.run_generate.call_args.kwargs 877 + assert call_kwargs["json_output"] is True 878 + assert call_kwargs["json_schema"] == schema 879 + 880 + def test_agenerate_forces_json_output_with_schema(self): 881 + schema = {"type": "object"} 882 + provider_module = SimpleNamespace( 883 + run_agenerate=AsyncMock( 884 + return_value={"text": "{}", "finish_reason": "stop"} 885 + ) 886 + ) 887 + 888 + with ( 889 + patch("think.models.resolve_provider", return_value=("fake", "model")), 890 + patch("think.providers.get_provider_module", return_value=provider_module), 891 + ): 892 + result = asyncio.run(agenerate("hello", "test.context", json_schema=schema)) 893 + 894 + assert result == "{}" 895 + call_kwargs = provider_module.run_agenerate.call_args.kwargs 896 + assert call_kwargs["json_output"] is True 897 + assert call_kwargs["json_schema"] == schema 898 + 899 + def test_generate_with_result_adds_schema_validation(self): 900 + provider_module = SimpleNamespace( 901 + run_generate=MagicMock(return_value={"text": "{}", "finish_reason": "stop"}) 902 + ) 903 + validation = {"valid": True, "errors": []} 904 + 905 + with ( 906 + patch("think.models.resolve_provider", return_value=("fake", "model")), 907 + patch("think.providers.get_provider_module", return_value=provider_module), 908 + patch("think.models._validate_schema", return_value=validation), 909 + ): 910 + result = generate_with_result( 911 + "hello", 912 + "test.context", 913 + json_schema={"type": "object"}, 914 + ) 915 + 916 + assert result["schema_validation"] == validation 917 + 918 + def test_generate_with_result_omits_schema_validation_without_schema(self): 919 + provider_module = SimpleNamespace( 920 + run_generate=MagicMock(return_value={"text": "{}", "finish_reason": "stop"}) 921 + ) 922 + 923 + with ( 924 + patch("think.models.resolve_provider", return_value=("fake", "model")), 925 + patch("think.providers.get_provider_module", return_value=provider_module), 926 + patch("think.models._validate_schema") as mock_validate_schema, 927 + ): 928 + result = generate_with_result("hello", "test.context") 929 + 930 + assert "schema_validation" not in result 931 + mock_validate_schema.assert_not_called() 932 + 933 + def test_generate_and_agenerate_do_not_surface_schema_validation(self): 934 + sync_provider = SimpleNamespace( 935 + run_generate=MagicMock(return_value={"text": "{}", "finish_reason": "stop"}) 936 + ) 937 + async_provider = SimpleNamespace( 938 + run_agenerate=AsyncMock( 939 + return_value={"text": "{}", "finish_reason": "stop"} 940 + ) 941 + ) 942 + 943 + with ( 944 + patch("think.models.resolve_provider", return_value=("fake", "model")), 945 + patch("think.providers.get_provider_module", return_value=sync_provider), 946 + patch( 947 + "think.models._validate_schema", 948 + return_value={"valid": True, "errors": []}, 949 + ) as mock_validate_schema, 950 + ): 951 + sync_result = generate( 952 + "hello", "test.context", json_schema={"type": "object"} 953 + ) 954 + 955 + with ( 956 + patch("think.models.resolve_provider", return_value=("fake", "model")), 957 + patch("think.providers.get_provider_module", return_value=async_provider), 958 + patch( 959 + "think.models._validate_schema", 960 + return_value={"valid": True, "errors": []}, 961 + ) as mock_async_validate, 962 + ): 963 + async_result = asyncio.run( 964 + agenerate("hello", "test.context", json_schema={"type": "object"}) 965 + ) 966 + 967 + assert sync_result == "{}" 968 + assert async_result == "{}" 969 + mock_validate_schema.assert_called_once() 970 + mock_async_validate.assert_called_once() 971 + 972 + def test_truncation_raises_before_schema_validation(self): 973 + provider_module = SimpleNamespace( 974 + run_generate=MagicMock(return_value={"text": "{}", "finish_reason": "stop"}) 975 + ) 976 + 977 + with ( 978 + patch("think.models.resolve_provider", return_value=("fake", "model")), 979 + patch("think.providers.get_provider_module", return_value=provider_module), 980 + patch( 981 + "think.models._validate_json_response", 982 + side_effect=IncompleteJSONError("max_tokens", "{}"), 983 + ), 984 + patch("think.models._validate_schema") as mock_validate_schema, 985 + ): 986 + with pytest.raises(IncompleteJSONError): 987 + generate_with_result( 988 + "hello", "test.context", json_schema={"type": "object"} 989 + ) 990 + 991 + mock_validate_schema.assert_not_called()
+56
tests/test_ollama.py
··· 141 141 ) 142 142 assert body["format"] == "json" 143 143 144 + def test_json_schema_dict(self): 145 + provider = _ollama_provider() 146 + schema = {"type": "object"} 147 + body = provider._build_request_body( 148 + "m", 149 + [{"role": "user", "content": "hi"}], 150 + 0.3, 151 + 1024, 152 + True, 153 + None, 154 + schema, 155 + ) 156 + assert body["format"] == schema 157 + 144 158 def test_no_json_output(self): 145 159 provider = _ollama_provider() 146 160 body = provider._build_request_body( ··· 421 435 body = call_kwargs.kwargs["json"] 422 436 assert body["format"] == "json" 423 437 438 + def test_json_schema_dict(self): 439 + provider = _ollama_provider() 440 + mock_response = MagicMock() 441 + mock_response.json.return_value = _make_ollama_response( 442 + content='{"key": "value"}' 443 + ) 444 + mock_response.raise_for_status = MagicMock() 445 + schema = {"type": "object"} 446 + 447 + with patch.object(provider, "_get_client") as mock_get: 448 + mock_client = MagicMock() 449 + mock_client.post.return_value = mock_response 450 + mock_get.return_value = mock_client 451 + 452 + provider.run_generate("hello", model=OLLAMA_FLASH, json_schema=schema) 453 + 454 + call_kwargs = mock_client.post.call_args 455 + body = call_kwargs.kwargs["json"] 456 + assert body["format"] == schema 457 + 424 458 def test_system_instruction(self): 425 459 provider = _ollama_provider() 426 460 mock_response = MagicMock() ··· 480 514 481 515 assert result["text"] == "Hello!" 482 516 assert result["finish_reason"] == "stop" 517 + 518 + def test_async_json_schema_dict(self): 519 + provider = _ollama_provider() 520 + mock_response = MagicMock() 521 + mock_response.json.return_value = _make_ollama_response( 522 + content='{"key": "value"}' 523 + ) 524 + mock_response.raise_for_status = MagicMock() 525 + schema = {"type": "object"} 526 + 527 + with patch.object(provider, "_get_async_client") as mock_get: 528 + mock_client = MagicMock() 529 + mock_client.post = AsyncMock(return_value=mock_response) 530 + mock_get.return_value = mock_client 531 + 532 + asyncio.run( 533 + provider.run_agenerate("hello", model=OLLAMA_FLASH, json_schema=schema) 534 + ) 535 + 536 + call_kwargs = mock_client.post.call_args 537 + body = call_kwargs.kwargs["json"] 538 + assert body["format"] == schema 483 539 484 540 485 541 # ---------------------------------------------------------------------------
+210
tests/test_openai.py
··· 726 726 called_kwargs = mock_client.responses.create.call_args.kwargs 727 727 assert called_kwargs["text"] == {"format": {"type": "json_object"}} 728 728 729 + def test_no_schema_format_unchanged(self): 730 + provider = _openai_provider() 731 + mock_client = MagicMock() 732 + mock_client.responses.create = MagicMock() 733 + mock_response = MagicMock() 734 + mock_response.output_text = "Hello" 735 + mock_response.status = "completed" 736 + mock_response.incomplete_details = None 737 + mock_response.usage = None 738 + mock_response.output = [] 739 + mock_client.responses.create.return_value = mock_response 740 + 741 + with patch( 742 + "think.providers.openai._get_openai_client", return_value=mock_client 743 + ): 744 + provider.run_generate( 745 + "hello", 746 + model="gpt-5.2", 747 + json_output=True, 748 + json_schema=None, 749 + ) 750 + 751 + called_kwargs = mock_client.responses.create.call_args.kwargs 752 + assert called_kwargs["text"] == {"format": {"type": "json_object"}} 753 + 754 + def test_with_schema_format_shape(self): 755 + provider = _openai_provider() 756 + mock_client = MagicMock() 757 + mock_client.responses.create = MagicMock() 758 + mock_response = MagicMock() 759 + mock_response.output_text = "{}" 760 + mock_response.status = "completed" 761 + mock_response.incomplete_details = None 762 + mock_response.usage = None 763 + mock_response.output = [] 764 + mock_client.responses.create.return_value = mock_response 765 + schema = {"type": "object"} 766 + 767 + with patch( 768 + "think.providers.openai._get_openai_client", return_value=mock_client 769 + ): 770 + provider.run_generate("hello", model="gpt-5.2", json_schema=schema) 771 + 772 + called_kwargs = mock_client.responses.create.call_args.kwargs 773 + assert called_kwargs["text"] == { 774 + "format": { 775 + "type": "json_schema", 776 + "name": "response", 777 + "schema": schema, 778 + "strict": True, 779 + } 780 + } 781 + 782 + def test_schema_title_becomes_name(self): 783 + provider = _openai_provider() 784 + mock_client = MagicMock() 785 + mock_client.responses.create = MagicMock() 786 + mock_response = MagicMock() 787 + mock_response.output_text = "{}" 788 + mock_response.status = "completed" 789 + mock_response.incomplete_details = None 790 + mock_response.usage = None 791 + mock_response.output = [] 792 + mock_client.responses.create.return_value = mock_response 793 + 794 + with patch( 795 + "think.providers.openai._get_openai_client", return_value=mock_client 796 + ): 797 + provider.run_generate( 798 + "hello", 799 + model="gpt-5.2", 800 + json_schema={"title": "MyThing", "type": "object"}, 801 + ) 802 + 803 + called_kwargs = mock_client.responses.create.call_args.kwargs 804 + assert called_kwargs["text"]["format"]["name"] == "MyThing" 805 + 806 + def test_schema_bad_title_falls_back(self): 807 + provider = _openai_provider() 808 + mock_client = MagicMock() 809 + mock_client.responses.create = MagicMock() 810 + mock_response = MagicMock() 811 + mock_response.output_text = "{}" 812 + mock_response.status = "completed" 813 + mock_response.incomplete_details = None 814 + mock_response.usage = None 815 + mock_response.output = [] 816 + mock_client.responses.create.return_value = mock_response 817 + 818 + with patch( 819 + "think.providers.openai._get_openai_client", return_value=mock_client 820 + ): 821 + provider.run_generate( 822 + "hello", 823 + model="gpt-5.2", 824 + json_schema={"title": "bad name with spaces", "type": "object"}, 825 + ) 826 + 827 + called_kwargs = mock_client.responses.create.call_args.kwargs 828 + assert called_kwargs["text"]["format"]["name"] == "response" 829 + 729 830 def test_with_system_instruction(self): 730 831 provider = _openai_provider() 731 832 mock_client = MagicMock() ··· 821 922 result = asyncio.run(provider.run_agenerate("hello", model="gpt-5.2")) 822 923 823 924 assert result["thinking"] == [{"summary": "Let me think..."}] 925 + 926 + def test_no_schema_format_unchanged(self): 927 + provider = _openai_provider() 928 + mock_client = MagicMock() 929 + mock_client.responses.create = AsyncMock() 930 + mock_response = MagicMock() 931 + mock_response.output_text = "Hello" 932 + mock_response.status = "completed" 933 + mock_response.incomplete_details = None 934 + mock_response.usage = None 935 + mock_response.output = [] 936 + mock_client.responses.create.return_value = mock_response 937 + 938 + with patch( 939 + "think.providers.openai._get_async_openai_client", return_value=mock_client 940 + ): 941 + asyncio.run( 942 + provider.run_agenerate( 943 + "hello", 944 + model="gpt-5.2", 945 + json_output=True, 946 + json_schema=None, 947 + ) 948 + ) 949 + 950 + called_kwargs = mock_client.responses.create.call_args.kwargs 951 + assert called_kwargs["text"] == {"format": {"type": "json_object"}} 952 + 953 + def test_with_schema_format_shape(self): 954 + provider = _openai_provider() 955 + mock_client = MagicMock() 956 + mock_client.responses.create = AsyncMock() 957 + mock_response = MagicMock() 958 + mock_response.output_text = "{}" 959 + mock_response.status = "completed" 960 + mock_response.incomplete_details = None 961 + mock_response.usage = None 962 + mock_response.output = [] 963 + mock_client.responses.create.return_value = mock_response 964 + schema = {"type": "object"} 965 + 966 + with patch( 967 + "think.providers.openai._get_async_openai_client", return_value=mock_client 968 + ): 969 + asyncio.run( 970 + provider.run_agenerate("hello", model="gpt-5.2", json_schema=schema) 971 + ) 972 + 973 + called_kwargs = mock_client.responses.create.call_args.kwargs 974 + assert called_kwargs["text"] == { 975 + "format": { 976 + "type": "json_schema", 977 + "name": "response", 978 + "schema": schema, 979 + "strict": True, 980 + } 981 + } 982 + 983 + def test_schema_title_becomes_name(self): 984 + provider = _openai_provider() 985 + mock_client = MagicMock() 986 + mock_client.responses.create = AsyncMock() 987 + mock_response = MagicMock() 988 + mock_response.output_text = "{}" 989 + mock_response.status = "completed" 990 + mock_response.incomplete_details = None 991 + mock_response.usage = None 992 + mock_response.output = [] 993 + mock_client.responses.create.return_value = mock_response 994 + 995 + with patch( 996 + "think.providers.openai._get_async_openai_client", return_value=mock_client 997 + ): 998 + asyncio.run( 999 + provider.run_agenerate( 1000 + "hello", 1001 + model="gpt-5.2", 1002 + json_schema={"title": "MyThing", "type": "object"}, 1003 + ) 1004 + ) 1005 + 1006 + called_kwargs = mock_client.responses.create.call_args.kwargs 1007 + assert called_kwargs["text"]["format"]["name"] == "MyThing" 1008 + 1009 + def test_schema_bad_title_falls_back(self): 1010 + provider = _openai_provider() 1011 + mock_client = MagicMock() 1012 + mock_client.responses.create = AsyncMock() 1013 + mock_response = MagicMock() 1014 + mock_response.output_text = "{}" 1015 + mock_response.status = "completed" 1016 + mock_response.incomplete_details = None 1017 + mock_response.usage = None 1018 + mock_response.output = [] 1019 + mock_client.responses.create.return_value = mock_response 1020 + 1021 + with patch( 1022 + "think.providers.openai._get_async_openai_client", return_value=mock_client 1023 + ): 1024 + asyncio.run( 1025 + provider.run_agenerate( 1026 + "hello", 1027 + model="gpt-5.2", 1028 + json_schema={"title": "bad name with spaces", "type": "object"}, 1029 + ) 1030 + ) 1031 + 1032 + called_kwargs = mock_client.responses.create.call_args.kwargs 1033 + assert called_kwargs["text"]["format"]["name"] == "response"
+143 -1
think/models.py
··· 13 13 from typing import Any, Dict, List, Optional, Union 14 14 15 15 import frontmatter 16 + from jsonschema import Draft202012Validator 16 17 17 18 from think.utils import get_config, get_journal 19 + 20 + logger = logging.getLogger(__name__) 18 21 19 22 # --------------------------------------------------------------------------- 20 23 # Tier constants ··· 908 911 ) 909 912 910 913 914 + def _validate_schema(text: str, schema: dict) -> dict: 915 + """Validate JSON text against a JSON Schema and log any violations.""" 916 + 917 + def truncate_repr(value: Any) -> str: 918 + value_repr = repr(value) 919 + if len(value_repr) <= 80: 920 + return value_repr 921 + return value_repr[:77] + "..." 922 + 923 + def build_pointer(path: Any) -> str: 924 + segments = list(path) 925 + if not segments: 926 + return "" 927 + escaped_segments = [] 928 + for segment in segments: 929 + escaped = str(segment).replace("~", "~0").replace("/", "~1") 930 + escaped_segments.append(escaped) 931 + return "/" + "/".join(escaped_segments) 932 + 933 + try: 934 + parsed = json.loads(text) 935 + except ValueError as exc: 936 + error = { 937 + "path": "", 938 + "constraint": "json_parse", 939 + "message": str(exc), 940 + } 941 + logger.warning( 942 + "schema_validation: %s: %s: %s (value=%s)", 943 + "", 944 + "json_parse", 945 + str(exc), 946 + truncate_repr(text), 947 + ) 948 + return {"valid": False, "errors": [error]} 949 + 950 + errors = [] 951 + try: 952 + validator = Draft202012Validator(schema) 953 + validation_errors = list(validator.iter_errors(parsed)) 954 + except Exception as exc: 955 + error = { 956 + "path": "", 957 + "constraint": "schema_validation", 958 + "message": str(exc), 959 + } 960 + logger.warning( 961 + "schema_validation: %s: %s: %s (value=%s)", 962 + "", 963 + "schema_validation", 964 + str(exc), 965 + truncate_repr(parsed), 966 + ) 967 + return {"valid": False, "errors": [error]} 968 + 969 + for error in validation_errors: 970 + path = build_pointer(error.absolute_path) 971 + constraint = str(error.validator) 972 + message = error.message 973 + errors.append( 974 + { 975 + "path": path, 976 + "constraint": constraint, 977 + "message": message, 978 + } 979 + ) 980 + logger.warning( 981 + "schema_validation: %s: %s: %s (value=%s)", 982 + path, 983 + constraint, 984 + message, 985 + truncate_repr(error.instance), 986 + ) 987 + 988 + return {"valid": len(errors) == 0, "errors": errors} 989 + 990 + 911 991 def generate( 912 992 contents: Union[str, List[Any]], 913 993 context: str, ··· 915 995 max_output_tokens: int = 8192 * 2, 916 996 system_instruction: Optional[str] = None, 917 997 json_output: bool = False, 998 + *, 999 + json_schema: dict | None = None, 918 1000 thinking_budget: Optional[int] = None, 919 1001 timeout_s: Optional[float] = None, 920 1002 **kwargs: Any, ··· 939 1021 System instruction for the model. 940 1022 json_output : bool 941 1023 Whether to request JSON response format. 1024 + json_schema : dict, optional 1025 + JSON Schema to request structured output from the provider. When supplied, 1026 + this forces json_output=True and runs advisory local validation on the 1027 + returned text after truncation checks. 942 1028 thinking_budget : int, optional 943 1029 Token budget for model thinking (ignored by providers that don't support it). 944 1030 timeout_s : float, optional ··· 960 1046 """ 961 1047 from think.providers import get_provider_module 962 1048 1049 + if json_schema is not None: 1050 + json_output = True 1051 + 963 1052 # Allow model override via kwargs (used by callers with explicit model selection) 964 1053 model_override = kwargs.pop("model", None) 965 1054 ··· 978 1067 max_output_tokens=max_output_tokens, 979 1068 system_instruction=system_instruction, 980 1069 json_output=json_output, 1070 + json_schema=json_schema, 981 1071 thinking_budget=thinking_budget, 982 1072 timeout_s=timeout_s, 983 1073 **kwargs, ··· 996 1086 # Validate JSON output if requested 997 1087 _validate_json_response(result, json_output) 998 1088 1089 + if json_schema is not None: 1090 + _validate_schema(result["text"], json_schema) 1091 + 999 1092 return result["text"] 1000 1093 1001 1094 ··· 1099 1192 max_output_tokens: int = 8192 * 2, 1100 1193 system_instruction: Optional[str] = None, 1101 1194 json_output: bool = False, 1195 + *, 1196 + json_schema: dict | None = None, 1102 1197 thinking_budget: Optional[int] = None, 1103 1198 timeout_s: Optional[float] = None, 1104 1199 **kwargs: Any, ··· 1109 1204 just the text. Used by cortex-managed generators that need usage data 1110 1205 for event emission. 1111 1206 1207 + Parameters 1208 + ---------- 1209 + contents : str or List 1210 + The content to send to the model. 1211 + context : str 1212 + Context string for routing and token logging. 1213 + temperature : float 1214 + Temperature for generation (default: 0.3). 1215 + max_output_tokens : int 1216 + Maximum tokens for the model's response output. 1217 + system_instruction : str, optional 1218 + System instruction for the model. 1219 + json_output : bool 1220 + Whether to request JSON response format. 1221 + json_schema : dict, optional 1222 + JSON Schema to request structured output from the provider. When supplied, 1223 + this forces json_output=True and runs advisory local validation on the 1224 + returned text after truncation checks. 1225 + thinking_budget : int, optional 1226 + Token budget for model thinking (ignored by providers that don't support it). 1227 + timeout_s : float, optional 1228 + Request timeout in seconds. 1229 + **kwargs 1230 + Additional provider-specific options passed through to the backend. 1231 + 1112 1232 Returns 1113 1233 ------- 1114 1234 dict 1115 - GenerateResult with: text, usage, finish_reason, thinking. 1235 + GenerateResult with: text, usage, finish_reason, thinking, and 1236 + schema_validation when json_schema is supplied. Validation is advisory 1237 + and runs after truncation checks succeed. 1116 1238 """ 1117 1239 from think.providers import get_provider_module 1240 + 1241 + if json_schema is not None: 1242 + json_output = True 1118 1243 1119 1244 model_override = kwargs.pop("model", None) 1120 1245 provider_override = kwargs.pop("provider", None) ··· 1136 1261 max_output_tokens=max_output_tokens, 1137 1262 system_instruction=system_instruction, 1138 1263 json_output=json_output, 1264 + json_schema=json_schema, 1139 1265 thinking_budget=thinking_budget, 1140 1266 timeout_s=timeout_s, 1141 1267 **kwargs, ··· 1154 1280 # Validate JSON output if requested 1155 1281 _validate_json_response(result, json_output) 1156 1282 1283 + if json_schema is not None: 1284 + result["schema_validation"] = _validate_schema(result["text"], json_schema) 1285 + 1157 1286 return result 1158 1287 1159 1288 ··· 1164 1293 max_output_tokens: int = 8192 * 2, 1165 1294 system_instruction: Optional[str] = None, 1166 1295 json_output: bool = False, 1296 + *, 1297 + json_schema: dict | None = None, 1167 1298 thinking_budget: Optional[int] = None, 1168 1299 timeout_s: Optional[float] = None, 1169 1300 **kwargs: Any, ··· 1188 1319 System instruction for the model. 1189 1320 json_output : bool 1190 1321 Whether to request JSON response format. 1322 + json_schema : dict, optional 1323 + JSON Schema to request structured output from the provider. When supplied, 1324 + this forces json_output=True and runs advisory local validation on the 1325 + returned text after truncation checks. 1191 1326 thinking_budget : int, optional 1192 1327 Token budget for model thinking (ignored by providers that don't support it). 1193 1328 timeout_s : float, optional ··· 1208 1343 If json_output=True and response was truncated. 1209 1344 """ 1210 1345 from think.providers import get_provider_module 1346 + 1347 + if json_schema is not None: 1348 + json_output = True 1211 1349 1212 1350 # Allow model override via kwargs (used by Batch for explicit model selection) 1213 1351 model_override = kwargs.pop("model", None) ··· 1227 1365 max_output_tokens=max_output_tokens, 1228 1366 system_instruction=system_instruction, 1229 1367 json_output=json_output, 1368 + json_schema=json_schema, 1230 1369 thinking_budget=thinking_budget, 1231 1370 timeout_s=timeout_s, 1232 1371 **kwargs, ··· 1244 1383 1245 1384 # Validate JSON output if requested 1246 1385 _validate_json_response(result, json_output) 1386 + 1387 + if json_schema is not None: 1388 + _validate_schema(result["text"], json_schema) 1247 1389 1248 1390 return result["text"] 1249 1391
+75 -7
think/providers/anthropic.py
··· 31 31 32 32 from __future__ import annotations 33 33 34 + import json 34 35 import logging 35 36 import os 37 + import re 36 38 import traceback 37 39 from pathlib import Path 38 40 from typing import Any, Callable 39 41 40 - from anthropic import AsyncAnthropic 42 + from anthropic import AsyncAnthropic, BadRequestError 41 43 from anthropic.types import ( 42 44 MessageParam, 43 45 RedactedThinkingBlock, ··· 64 66 _DEFAULT_MODEL = CLAUDE_SONNET_4 65 67 66 68 logger = logging.getLogger(__name__) 69 + _TOOL_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") 67 70 68 71 _DEFAULT_MAX_TOKENS = 8096 * 2 69 72 _MIN_THINKING_BUDGET = 1024 # Anthropic minimum ··· 393 396 return text, thinking_blocks if thinking_blocks else None 394 397 395 398 399 + def _derive_tool_name(schema: dict | None) -> str: 400 + """Return a valid tool name for Anthropic schema output requests.""" 401 + if isinstance(schema, dict): 402 + title = schema.get("title") 403 + if isinstance(title, str) and title and _TOOL_NAME_RE.fullmatch(title): 404 + return title 405 + return "response" 406 + 407 + 408 + def _extract_first_tool_use_json(response: Any) -> str: 409 + """Serialize the first tool_use block input from an Anthropic response.""" 410 + for block in getattr(response, "content", []): 411 + if getattr(block, "type", None) == "tool_use": 412 + return json.dumps(getattr(block, "input", None)) 413 + raise ValueError("Anthropic schema fallback response missing tool_use block") 414 + 415 + 396 416 # Cache for Anthropic clients 397 417 _anthropic_client = None 398 418 _async_anthropic_client = None ··· 447 467 system_instruction: str | None = None, 448 468 json_output: bool = False, 449 469 thinking_budget: int | None = None, 470 + json_schema: dict | None = None, 450 471 timeout_s: float | None = None, 451 472 **kwargs: Any, 452 473 ) -> GenerateResult: ··· 460 481 461 482 # Handle JSON output by adding to system instruction 462 483 system = system_instruction or "" 463 - if json_output: 484 + if json_schema is None and json_output: 464 485 json_instruction = "Respond with valid JSON only. No explanation or markdown." 465 486 system = f"{system}\n\n{json_instruction}" if system else json_instruction 466 487 ··· 487 508 if timeout_s: 488 509 request_kwargs["timeout"] = timeout_s 489 510 490 - response = client.messages.create(**request_kwargs) 511 + if json_schema is not None: 512 + tool_name = _derive_tool_name(json_schema) 513 + request_kwargs["output_config"] = { 514 + "format": {"type": "json_schema", "schema": json_schema} 515 + } 516 + try: 517 + response = client.messages.create(**request_kwargs) 518 + text, thinking = _extract_text_and_thinking(response) 519 + except BadRequestError: 520 + retry_kwargs = dict(request_kwargs) 521 + retry_kwargs.pop("output_config", None) 522 + retry_kwargs["tools"] = [ 523 + { 524 + "name": tool_name, 525 + "description": "Generate the requested JSON response.", 526 + "input_schema": json_schema, 527 + } 528 + ] 529 + retry_kwargs["tool_choice"] = {"type": "tool", "name": tool_name} 530 + response = client.messages.create(**retry_kwargs) 531 + text = _extract_first_tool_use_json(response) 532 + _, thinking = _extract_text_and_thinking(response) 533 + else: 534 + response = client.messages.create(**request_kwargs) 535 + text, thinking = _extract_text_and_thinking(response) 491 536 492 - text, thinking = _extract_text_and_thinking(response) 493 537 return GenerateResult( 494 538 text=text, 495 539 usage=_extract_usage_dict(response), ··· 506 550 system_instruction: str | None = None, 507 551 json_output: bool = False, 508 552 thinking_budget: int | None = None, 553 + json_schema: dict | None = None, 509 554 timeout_s: float | None = None, 510 555 **kwargs: Any, 511 556 ) -> GenerateResult: ··· 519 564 520 565 # Handle JSON output by adding to system instruction 521 566 system = system_instruction or "" 522 - if json_output: 567 + if json_schema is None and json_output: 523 568 json_instruction = "Respond with valid JSON only. No explanation or markdown." 524 569 system = f"{system}\n\n{json_instruction}" if system else json_instruction 525 570 ··· 546 591 if timeout_s: 547 592 request_kwargs["timeout"] = timeout_s 548 593 549 - response = await client.messages.create(**request_kwargs) 594 + if json_schema is not None: 595 + tool_name = _derive_tool_name(json_schema) 596 + request_kwargs["output_config"] = { 597 + "format": {"type": "json_schema", "schema": json_schema} 598 + } 599 + try: 600 + response = await client.messages.create(**request_kwargs) 601 + text, thinking = _extract_text_and_thinking(response) 602 + except BadRequestError: 603 + retry_kwargs = dict(request_kwargs) 604 + retry_kwargs.pop("output_config", None) 605 + retry_kwargs["tools"] = [ 606 + { 607 + "name": tool_name, 608 + "description": "Generate the requested JSON response.", 609 + "input_schema": json_schema, 610 + } 611 + ] 612 + retry_kwargs["tool_choice"] = {"type": "tool", "name": tool_name} 613 + response = await client.messages.create(**retry_kwargs) 614 + text = _extract_first_tool_use_json(response) 615 + _, thinking = _extract_text_and_thinking(response) 616 + else: 617 + response = await client.messages.create(**request_kwargs) 618 + text, thinking = _extract_text_and_thinking(response) 550 619 551 - text, thinking = _extract_text_and_thinking(response) 552 620 return GenerateResult( 553 621 text=text, 554 622 usage=_extract_usage_dict(response),
+7
think/providers/google.py
··· 212 212 system_instruction: str | None, 213 213 json_output: bool, 214 214 thinking_budget: int | None, 215 + json_schema: dict | None = None, 215 216 timeout_s: float | None = None, 216 217 ) -> types.GenerateContentConfig: 217 218 """Build the GenerateContentConfig. ··· 232 233 233 234 if json_output: 234 235 config_args["response_mime_type"] = "application/json" 236 + if json_schema is not None: 237 + config_args["response_json_schema"] = json_schema 235 238 236 239 # Set thinking config when caller explicitly specified a budget. 237 240 # thinking_budget=0 must explicitly disable thinking (not omit config), ··· 456 459 system_instruction: str | None = None, 457 460 json_output: bool = False, 458 461 thinking_budget: int | None = None, 462 + json_schema: dict | None = None, 459 463 timeout_s: float | None = None, 460 464 **kwargs: Any, 461 465 ) -> GenerateResult: ··· 475 479 system_instruction=system_instruction, 476 480 json_output=json_output, 477 481 thinking_budget=thinking_budget, 482 + json_schema=json_schema, 478 483 timeout_s=timeout_s, 479 484 ) 480 485 ··· 500 505 system_instruction: str | None = None, 501 506 json_output: bool = False, 502 507 thinking_budget: int | None = None, 508 + json_schema: dict | None = None, 503 509 timeout_s: float | None = None, 504 510 **kwargs: Any, 505 511 ) -> GenerateResult: ··· 519 525 system_instruction=system_instruction, 520 526 json_output=json_output, 521 527 thinking_budget=thinking_budget, 528 + json_schema=json_schema, 522 529 timeout_s=timeout_s, 523 530 ) 524 531
+8 -1
think/providers/ollama.py
··· 163 163 max_output_tokens: int, 164 164 json_output: bool, 165 165 thinking_budget: int | None, 166 + json_schema: dict | None = None, 166 167 ) -> dict[str, Any]: 167 168 """Build the native Ollama /api/chat request body. 168 169 ··· 203 204 else: 204 205 body["think"] = False 205 206 206 - if json_output: 207 + if json_schema is not None: 208 + body["format"] = json_schema 209 + elif json_output: 207 210 body["format"] = "json" 208 211 209 212 return body ··· 281 284 system_instruction: str | None = None, 282 285 json_output: bool = False, 283 286 thinking_budget: int | None = None, 287 + json_schema: dict | None = None, 284 288 timeout_s: float | None = None, 285 289 **kwargs: Any, 286 290 ) -> GenerateResult: ··· 299 303 max_output_tokens, 300 304 json_output, 301 305 thinking_budget, 306 + json_schema, 302 307 ) 303 308 304 309 response = client.post( ··· 318 323 system_instruction: str | None = None, 319 324 json_output: bool = False, 320 325 thinking_budget: int | None = None, 326 + json_schema: dict | None = None, 321 327 timeout_s: float | None = None, 322 328 **kwargs: Any, 323 329 ) -> GenerateResult: ··· 336 342 max_output_tokens, 337 343 json_output, 338 344 thinking_budget, 345 + json_schema, 339 346 ) 340 347 341 348 response = await client.post(
+33 -2
think/providers/openai.py
··· 35 35 import functools 36 36 import logging 37 37 import os 38 + import re 38 39 import traceback 39 40 from pathlib import Path 40 41 from typing import Any, Callable ··· 57 58 # Agent configuration is now loaded via get_talent() in cortex.py 58 59 59 60 LOG = logging.getLogger("think.providers.openai") 61 + _SCHEMA_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") 60 62 61 63 62 64 def _parse_model_effort(model: str) -> tuple[str, str | None]: ··· 296 298 return str(contents), system_instruction 297 299 298 300 301 + def _derive_schema_name(schema: dict | None) -> str: 302 + """Return a valid schema name for OpenAI structured outputs.""" 303 + if isinstance(schema, dict): 304 + title = schema.get("title") 305 + if isinstance(title, str) and title and _SCHEMA_NAME_RE.fullmatch(title): 306 + return title 307 + return "response" 308 + 309 + 299 310 def _normalize_finish_reason(response: Any) -> str | None: 300 311 """Normalize OpenAI finish_reason to standard values. 301 312 ··· 370 381 max_output_tokens: int = 8192 * 2, 371 382 system_instruction: str | None = None, 372 383 json_output: bool = False, 384 + json_schema: dict | None = None, 373 385 timeout_s: float | None = None, 374 386 **kwargs: Any, 375 387 ) -> GenerateResult: ··· 395 407 if effort is not None: 396 408 request_kwargs["reasoning"] = {"effort": effort} 397 409 398 - if json_output: 410 + if json_schema is not None: 411 + request_kwargs["text"] = { 412 + "format": { 413 + "type": "json_schema", 414 + "name": _derive_schema_name(json_schema), 415 + "schema": json_schema, 416 + "strict": True, 417 + } 418 + } 419 + elif json_output: 399 420 request_kwargs["text"] = {"format": {"type": "json_object"}} 400 421 401 422 if timeout_s: ··· 416 437 max_output_tokens: int = 8192 * 2, 417 438 system_instruction: str | None = None, 418 439 json_output: bool = False, 440 + json_schema: dict | None = None, 419 441 timeout_s: float | None = None, 420 442 **kwargs: Any, 421 443 ) -> GenerateResult: ··· 441 463 if effort is not None: 442 464 request_kwargs["reasoning"] = {"effort": effort} 443 465 444 - if json_output: 466 + if json_schema is not None: 467 + request_kwargs["text"] = { 468 + "format": { 469 + "type": "json_schema", 470 + "name": _derive_schema_name(json_schema), 471 + "schema": json_schema, 472 + "strict": True, 473 + } 474 + } 475 + elif json_output: 445 476 request_kwargs["text"] = {"format": {"type": "json_object"}} 446 477 447 478 if timeout_s:
+1
think/providers/shared.py
··· 169 169 usage: Optional[dict] # Normalized usage dict (input_tokens, output_tokens, etc.) 170 170 finish_reason: Optional[str] # Normalized: "stop", "max_tokens", "safety", etc. 171 171 thinking: Optional[list] # List of thinking block dicts 172 + schema_validation: Optional[dict] # Validation result when json_schema is supplied 172 173 173 174 174 175 # ---------------------------------------------------------------------------
+2
uv.lock
··· 3527 3527 { name = "google-genai" }, 3528 3528 { name = "httpx" }, 3529 3529 { name = "icalendar" }, 3530 + { name = "jsonschema" }, 3530 3531 { name = "markdown" }, 3531 3532 { name = "mistune" }, 3532 3533 { name = "mypy" }, ··· 3574 3575 { name = "google-genai" }, 3575 3576 { name = "httpx" }, 3576 3577 { name = "icalendar" }, 3578 + { name = "jsonschema", specifier = ">=4.26,<5" }, 3577 3579 { name = "markdown" }, 3578 3580 { name = "mistune" }, 3579 3581 { name = "mypy" },