callm-toolkit 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/CHANGELOG.md +16 -1
  2. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/PKG-INFO +2 -2
  3. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/README.md +1 -1
  4. callm_toolkit-0.3.0/src/callm/__about__.py +1 -0
  5. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/interception.py +35 -2
  6. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/base.py +8 -1
  7. callm_toolkit-0.3.0/tests/test_parse.py +123 -0
  8. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_regressions.py +6 -5
  9. callm_toolkit-0.2.0/src/callm/__about__.py +0 -1
  10. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/.gitignore +0 -0
  11. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/LICENSE +0 -0
  12. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/benchmarks/run.py +0 -0
  13. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/examples/async_budgets.py +0 -0
  14. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/examples/demo.tape +0 -0
  15. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/examples/offline_demo.py +0 -0
  16. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/examples/quickstart.py +0 -0
  17. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/examples/structured_output.py +0 -0
  18. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/examples/ticket_triage.py +0 -0
  19. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/pyproject.toml +0 -0
  20. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/__init__.py +0 -0
  21. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/__main__.py +0 -0
  22. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/api.py +0 -0
  23. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/budgets.py +0 -0
  24. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/classify.py +0 -0
  25. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/cli.py +0 -0
  26. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/config.py +0 -0
  27. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/data/__init__.py +0 -0
  28. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/data/pricing.json +0 -0
  29. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/decorator.py +0 -0
  30. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/embeddings.py +0 -0
  31. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/errors.py +0 -0
  32. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/__init__.py +0 -0
  33. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/cache.py +0 -0
  34. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/cost.py +0 -0
  35. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/fallback.py +0 -0
  36. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/retry.py +0 -0
  37. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/security.py +0 -0
  38. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/telemetry.py +0 -0
  39. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/validator.py +0 -0
  40. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/pipeline.py +0 -0
  41. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/pricing.py +0 -0
  42. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/__init__.py +0 -0
  43. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/anthropic.py +0 -0
  44. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/google.py +0 -0
  45. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/openai.py +0 -0
  46. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/openai_responses.py +0 -0
  47. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/registry.py +0 -0
  48. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/py.typed +0 -0
  49. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/reports.py +0 -0
  50. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/security/__init__.py +0 -0
  51. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/security/injection.py +0 -0
  52. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/security/pii.py +0 -0
  53. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/storage/__init__.py +0 -0
  54. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/storage/base.py +0 -0
  55. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/storage/memory.py +0 -0
  56. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/storage/redis.py +0 -0
  57. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/storage/sqlite.py +0 -0
  58. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/types.py +0 -0
  59. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/validation.py +0 -0
  60. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/conftest.py +0 -0
  61. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/helpers.py +0 -0
  62. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_api.py +0 -0
  63. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_cache.py +0 -0
  64. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_cli.py +0 -0
  65. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_core.py +0 -0
  66. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_cost.py +0 -0
  67. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_decorator.py +0 -0
  68. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_extras.py +0 -0
  69. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_fallback.py +0 -0
  70. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_providers.py +0 -0
  71. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_responses.py +0 -0
  72. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_retry.py +0 -0
  73. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_security.py +0 -0
  74. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_telemetry.py +0 -0
  75. {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_validation.py +0 -0
@@ -6,6 +6,20 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-02
10
+
11
+ ### Added
12
+
13
+ - OpenAI structured outputs: `client.chat.completions.parse(...)`, sync and async, is now
14
+ intercepted. PII masking, budgets, caching, retries, cost and telemetry apply, and the
15
+ caller still gets a `ParsedChatCompletion` with `.parsed`, also on cache hits. Different
16
+ `response_format` models never share a cache entry.
17
+
18
+ ### Fixed
19
+
20
+ - Storing a `ParsedChatCompletion` for the cache no longer prints pydantic serializer
21
+ warnings.
22
+
9
23
  ## [0.2.0] - 2026-09-29
10
24
 
11
25
  ### Added
@@ -100,7 +114,8 @@ Initial release.
100
114
  budgets) from enclosing decorators and shields apply to inner calls.
101
115
  - `callm` CLI: `stats`, `calls`, `cache`, `pricing`, `telemetry`, `info`.
102
116
 
103
- [Unreleased]: https://github.com/TanbirRamim/callm/compare/v0.2.0...HEAD
117
+ [Unreleased]: https://github.com/TanbirRamim/callm/compare/v0.3.0...HEAD
118
+ [0.3.0]: https://github.com/TanbirRamim/callm/compare/v0.2.0...v0.3.0
104
119
  [0.2.0]: https://github.com/TanbirRamim/callm/compare/v0.1.2...v0.2.0
105
120
  [0.1.2]: https://github.com/TanbirRamim/callm/compare/v0.1.1...v0.1.2
106
121
  [0.1.1]: https://github.com/TanbirRamim/callm/compare/v0.1.0...v0.1.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: callm-toolkit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: The production toolkit for LLM calls: caching, retries, fallback, cost tracking, security and structured output in one decorator.
5
5
  Project-URL: Homepage, https://github.com/TanbirRamim/callm
6
6
  Project-URL: Documentation, https://tanbirramim.github.io/callm
@@ -364,7 +364,7 @@ def summarize(): ─────────► │ 1. Telemetry cost, la
364
364
  ```
365
365
 
366
366
  1. `@callm` sets a scope (a `ContextVar`) while your function runs.
367
- 2. The official SDK methods (`chat.completions.create`, OpenAI's `responses.create`,
367
+ 2. The official SDK methods (`chat.completions.create` and `.parse`, OpenAI's `responses.create`,
368
368
  `messages.create`, `models.generate_content`, sync and async) are instrumented on first use. Outside a callm
369
369
  scope they call straight through, so importing callm never changes other code.
370
370
  3. Inside a scope, the SDK call is converted into a provider-neutral request and sent through
@@ -305,7 +305,7 @@ def summarize(): ─────────► │ 1. Telemetry cost, la
305
305
  ```
306
306
 
307
307
  1. `@callm` sets a scope (a `ContextVar`) while your function runs.
308
- 2. The official SDK methods (`chat.completions.create`, OpenAI's `responses.create`,
308
+ 2. The official SDK methods (`chat.completions.create` and `.parse`, OpenAI's `responses.create`,
309
309
  `messages.create`, `models.generate_content`, sync and async) are instrumented on first use. Outside a callm
310
310
  scope they call straight through, so importing callm never changes other code.
311
311
  3. Inside a scope, the SDK call is converted into a provider-neutral request and sent through
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -103,6 +103,8 @@ class PatchTarget:
103
103
  PATCH_TARGETS: tuple[PatchTarget, ...] = (
104
104
  PatchTarget("openai", "openai.resources.chat.completions", "Completions", "create", False),
105
105
  PatchTarget("openai", "openai.resources.chat.completions", "AsyncCompletions", "create", True),
106
+ PatchTarget("openai", "openai.resources.chat.completions", "Completions", "parse", False),
107
+ PatchTarget("openai", "openai.resources.chat.completions", "AsyncCompletions", "parse", True),
106
108
  PatchTarget("openai-responses", "openai.resources.responses", "Responses", "create", False),
107
109
  PatchTarget("openai-responses", "openai.resources.responses", "AsyncResponses", "create", True),
108
110
  PatchTarget("anthropic", "anthropic.resources.messages", "Messages", "create", False),
@@ -122,7 +124,7 @@ PATCH_TARGETS: tuple[PatchTarget, ...] = (
122
124
  "openai",
123
125
  "openai.resources.chat.completions",
124
126
  ("Completions", "AsyncCompletions"),
125
- ("parse", "stream"),
127
+ ("stream",),
126
128
  ),
127
129
  (
128
130
  "openai",
@@ -280,7 +282,38 @@ def clean_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
280
282
  def _finish(target: PatchTarget, state: CallState, response: LLMResponse) -> Any:
281
283
  if state.request.stream:
282
284
  return response.raw
283
- return get_provider(target.provider).native_for(response)
285
+ native = get_provider(target.provider).native_for(response)
286
+ if target.provider == "openai" and target.method == "parse":
287
+ native = _as_parsed_completion(native, state.request.native_extra)
288
+ return native
289
+
290
+
291
+ def _as_parsed_completion(native: Any, extra: dict[str, Any]) -> Any:
292
+ """The ``ParsedChatCompletion`` that ``chat.completions.parse`` promises its caller.
293
+
294
+ A live call already returns one. A cache hit, or a response synthesized after a
295
+ fallback, is a plain ``ChatCompletion``, so run it through the SDK's own parser
296
+ with the caller's ``response_format`` and ``tools``.
297
+ """
298
+ if type(native).__name__.startswith("Parsed"):
299
+ return native
300
+ try:
301
+ import openai
302
+ from openai.lib._parsing import parse_chat_completion
303
+ except ImportError:
304
+ return native
305
+ not_given: Any = getattr(openai, "omit", None) or getattr(openai, "NOT_GIVEN", None)
306
+ response_format: Any = extra.get("response_format", not_given)
307
+ input_tools: Any = extra.get("tools", not_given)
308
+ try:
309
+ return parse_chat_completion(
310
+ response_format=response_format,
311
+ input_tools=input_tools,
312
+ chat_completion=native,
313
+ )
314
+ except (TypeError, AttributeError): # an AttrDict without the SDK types
315
+ logger.debug("callm: could not rebuild a ParsedChatCompletion", exc_info=True)
316
+ return native
284
317
 
285
318
 
286
319
  _warned_methods: set[str] = set()
@@ -61,7 +61,14 @@ def dump_json(obj: Any) -> dict[str, Any] | None:
61
61
  dump = getattr(obj, "model_dump", None)
62
62
  if callable(dump):
63
63
  try:
64
- result = dump(mode="json", exclude_none=True)
64
+ # warnings=False: ParsedChatCompletion types `parsed` loosely, so pydantic
65
+ # would warn on every call even though the dump is correct.
66
+ result = dump(mode="json", exclude_none=True, warnings=False)
67
+ except TypeError: # an object whose model_dump has no `warnings` option
68
+ try:
69
+ result = dump(mode="json", exclude_none=True)
70
+ except Exception:
71
+ return None
65
72
  except Exception:
66
73
  return None
67
74
  return result if isinstance(result, dict) else None
@@ -0,0 +1,123 @@
1
+ """OpenAI structured outputs: ``client.chat.completions.parse``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+ from pydantic import BaseModel
9
+
10
+ import callm
11
+ from callm.errors import BudgetExceededError
12
+ from helpers import openai_body
13
+
14
+
15
+ class Invoice(BaseModel):
16
+ vendor: str
17
+ total: float
18
+
19
+
20
+ INVOICE_JSON = json.dumps({"vendor": "ACME", "total": 12.5})
21
+
22
+
23
+ @pytest.fixture(autouse=True)
24
+ def _needs_parse(openai_client):
25
+ if not hasattr(openai_client.chat.completions, "parse"):
26
+ pytest.skip("this openai version has no chat.completions.parse")
27
+
28
+
29
+ def ask(client, text="Extract the invoice: ACME, 12.50 EUR"):
30
+ return client.chat.completions.parse(
31
+ model="gpt-4o-mini",
32
+ messages=[{"role": "user", "content": text}],
33
+ response_format=Invoice,
34
+ )
35
+
36
+
37
+ def test_parse_is_tracked_and_returns_the_parsed_object(openai_client, openai_server, records):
38
+ openai_server.queue(openai_body(text=INVOICE_JSON))
39
+
40
+ result = callm.callm(lambda: ask(openai_client))()
41
+
42
+ assert type(result).__name__.startswith("ParsedChatCompletion")
43
+ assert result.choices[0].message.parsed == Invoice(vendor="ACME", total=12.5)
44
+ sent = openai_server.last.body
45
+ assert sent["response_format"]["type"] == "json_schema"
46
+ assert sent["response_format"]["json_schema"]["name"] == "Invoice"
47
+ (record,) = records()
48
+ assert record.provider == "openai"
49
+ assert record.cost_usd and record.cost_usd > 0
50
+
51
+
52
+ def test_cache_hit_still_returns_a_parsed_completion(openai_client, openai_server, records):
53
+ openai_server.queue(openai_body(text=INVOICE_JSON))
54
+ cached = callm.callm(cache=True)(lambda: ask(openai_client))
55
+
56
+ first = cached()
57
+ second = cached()
58
+
59
+ assert openai_server.count == 1
60
+ assert type(second).__name__.startswith("ParsedChatCompletion")
61
+ assert second.choices[0].message.parsed == first.choices[0].message.parsed
62
+ assert records()[0].cache_hit is True
63
+
64
+
65
+ def test_different_response_formats_do_not_share_a_cache_entry(openai_client, openai_server):
66
+ class Receipt(BaseModel):
67
+ vendor: str
68
+ total: float
69
+
70
+ openai_server.queue(openai_body(text=INVOICE_JSON), openai_body(text=INVOICE_JSON))
71
+
72
+ @callm.callm(cache=True)
73
+ def both():
74
+ a = ask(openai_client)
75
+ b = openai_client.chat.completions.parse(
76
+ model="gpt-4o-mini",
77
+ messages=[{"role": "user", "content": "Extract the invoice: ACME, 12.50 EUR"}],
78
+ response_format=Receipt,
79
+ )
80
+ return a, b
81
+
82
+ a, b = both()
83
+ assert openai_server.count == 2
84
+ assert isinstance(b.choices[0].message.parsed, Receipt)
85
+
86
+
87
+ def test_pii_is_masked_in_parse_requests(openai_client, openai_server):
88
+ openai_server.queue(openai_body(text=INVOICE_JSON))
89
+
90
+ callm.callm(block_pii=True)(lambda: ask(openai_client, "Invoice from ops@acme.example"))()
91
+
92
+ assert "ops@acme.example" not in json.dumps(openai_server.last.body)
93
+
94
+
95
+ def test_budget_blocks_parse_before_sending(openai_client, openai_server):
96
+ @callm.callm(max_cost=0.0000001)
97
+ def expensive():
98
+ return openai_client.chat.completions.parse(
99
+ model="gpt-4o",
100
+ messages=[{"role": "user", "content": "x" * 4000}],
101
+ response_format=Invoice,
102
+ max_completion_tokens=4000,
103
+ )
104
+
105
+ with pytest.raises(BudgetExceededError):
106
+ expensive()
107
+ assert openai_server.count == 0
108
+
109
+
110
+ async def test_async_parse(async_openai_client, openai_server, records):
111
+ openai_server.queue(openai_body(text=INVOICE_JSON))
112
+
113
+ @callm.callm
114
+ async def run():
115
+ return await async_openai_client.chat.completions.parse(
116
+ model="gpt-4o-mini",
117
+ messages=[{"role": "user", "content": "Extract the invoice"}],
118
+ response_format=Invoice,
119
+ )
120
+
121
+ result = await run()
122
+ assert result.choices[0].message.parsed == Invoice(vendor="ACME", total=12.5)
123
+ assert records()[0].provider == "openai"
@@ -513,8 +513,8 @@ def test_last_call_is_per_context_for_concurrent_tasks(async_openai_client):
513
513
  def test_unintercepted_sdk_methods_warn_inside_callm(openai_client, openai_server, caplog):
514
514
  import logging
515
515
 
516
- if not hasattr(openai_client.chat.completions, "parse"):
517
- pytest.skip("this openai version has no chat.completions.parse")
516
+ if not hasattr(openai_client.chat.completions, "stream"):
517
+ pytest.skip("this openai version has no chat.completions.stream")
518
518
  from callm import interception
519
519
 
520
520
  interception._warned_methods.clear()
@@ -522,18 +522,19 @@ def test_unintercepted_sdk_methods_warn_inside_callm(openai_client, openai_serve
522
522
 
523
523
  @callm.callm(block_pii=True, max_cost=1.0)
524
524
  def ask():
525
- return openai_client.chat.completions.parse(model="gpt-4o-mini", messages=messages)
525
+ # stream() returns a manager and sends nothing until it is entered
526
+ return openai_client.chat.completions.stream(model="gpt-4o-mini", messages=messages)
526
527
 
527
528
  with caplog.at_level(logging.WARNING, logger="callm"):
528
529
  ask()
529
530
  ask()
530
531
  warnings = [r.getMessage() for r in caplog.records if "not intercepted" in r.getMessage()]
531
532
  assert len(warnings) == 1 # once per method, not per call
532
- assert "chat.completions.parse" in warnings[0]
533
+ assert "chat.completions.stream" in warnings[0]
533
534
  assert "PII masking" in warnings[0] and "budgets" in warnings[0]
534
535
 
535
536
  caplog.clear()
536
537
  with caplog.at_level(logging.WARNING, logger="callm"):
537
538
  interception._warned_methods.clear()
538
- openai_client.chat.completions.parse(model="gpt-4o-mini", messages=messages) # outside
539
+ openai_client.chat.completions.stream(model="gpt-4o-mini", messages=messages) # outside
539
540
  assert not [r for r in caplog.records if "not intercepted" in r.getMessage()]
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"
File without changes
File without changes