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.
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/CHANGELOG.md +16 -1
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/PKG-INFO +2 -2
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/README.md +1 -1
- callm_toolkit-0.3.0/src/callm/__about__.py +1 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/interception.py +35 -2
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/base.py +8 -1
- callm_toolkit-0.3.0/tests/test_parse.py +123 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_regressions.py +6 -5
- callm_toolkit-0.2.0/src/callm/__about__.py +0 -1
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/.gitignore +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/LICENSE +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/benchmarks/run.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/examples/async_budgets.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/examples/demo.tape +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/examples/offline_demo.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/examples/quickstart.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/examples/structured_output.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/examples/ticket_triage.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/pyproject.toml +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/__init__.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/__main__.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/api.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/budgets.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/classify.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/cli.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/config.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/data/__init__.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/data/pricing.json +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/decorator.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/embeddings.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/errors.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/__init__.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/cache.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/cost.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/fallback.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/retry.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/security.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/telemetry.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/middleware/validator.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/pipeline.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/pricing.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/__init__.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/anthropic.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/google.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/openai.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/openai_responses.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/providers/registry.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/py.typed +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/reports.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/security/__init__.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/security/injection.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/security/pii.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/storage/__init__.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/storage/base.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/storage/memory.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/storage/redis.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/storage/sqlite.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/types.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/src/callm/validation.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/conftest.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/helpers.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_api.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_cache.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_cli.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_core.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_cost.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_decorator.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_extras.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_fallback.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_providers.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_responses.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_retry.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_security.py +0 -0
- {callm_toolkit-0.2.0 → callm_toolkit-0.3.0}/tests/test_telemetry.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
("
|
|
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
|
-
|
|
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
|
-
|
|
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, "
|
|
517
|
-
pytest.skip("this openai version has no chat.completions.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|