callm-toolkit 0.1.2__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.1.2 → callm_toolkit-0.3.0}/CHANGELOG.md +31 -1
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/PKG-INFO +7 -5
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/README.md +5 -3
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/pyproject.toml +1 -1
- callm_toolkit-0.3.0/src/callm/__about__.py +1 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/cli.py +1 -1
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/decorator.py +5 -1
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/interception.py +142 -3
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/middleware/telemetry.py +1 -1
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/pricing.py +5 -2
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/providers/__init__.py +2 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/providers/base.py +8 -1
- callm_toolkit-0.3.0/src/callm/providers/openai_responses.py +283 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/providers/registry.py +2 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/types.py +5 -1
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/helpers.py +44 -0
- callm_toolkit-0.3.0/tests/test_parse.py +123 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_regressions.py +30 -0
- callm_toolkit-0.3.0/tests/test_responses.py +298 -0
- callm_toolkit-0.1.2/src/callm/__about__.py +0 -1
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/.gitignore +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/LICENSE +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/benchmarks/run.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/examples/async_budgets.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/examples/demo.tape +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/examples/offline_demo.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/examples/quickstart.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/examples/structured_output.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/examples/ticket_triage.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/__init__.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/__main__.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/api.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/budgets.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/classify.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/config.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/data/__init__.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/data/pricing.json +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/embeddings.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/errors.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/middleware/__init__.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/middleware/cache.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/middleware/cost.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/middleware/fallback.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/middleware/retry.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/middleware/security.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/middleware/validator.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/pipeline.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/providers/anthropic.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/providers/google.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/providers/openai.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/py.typed +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/reports.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/security/__init__.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/security/injection.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/security/pii.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/storage/__init__.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/storage/base.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/storage/memory.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/storage/redis.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/storage/sqlite.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/src/callm/validation.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/conftest.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_api.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_cache.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_cli.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_core.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_cost.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_decorator.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_extras.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_fallback.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_providers.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_retry.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_security.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_telemetry.py +0 -0
- {callm_toolkit-0.1.2 → callm_toolkit-0.3.0}/tests/test_validation.py +0 -0
|
@@ -6,6 +6,34 @@ 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
|
+
|
|
23
|
+
## [0.2.0] - 2026-09-29
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- OpenAI Responses API support: `client.responses.create(...)`, sync and async, is intercepted
|
|
28
|
+
like `chat.completions.create`. `instructions`, string and item `input` and tool outputs get
|
|
29
|
+
PII masking and injection checks; budgets, caching, output validation, retries, cost and
|
|
30
|
+
telemetry apply; fallbacks to other providers return a real `Response` object. The adapter is
|
|
31
|
+
also available as the `openai-responses` provider for `callm.complete` and fallback chains.
|
|
32
|
+
- Inside a callm function, SDK helpers callm does not intercept yet (`responses.parse/stream`,
|
|
33
|
+
`chat.completions.parse/stream`, `beta.chat.completions.parse/stream`, Anthropic
|
|
34
|
+
`messages.stream`) now log a one-time warning naming the protections that do not apply,
|
|
35
|
+
instead of silently skipping PII masking, budgets and telemetry.
|
|
36
|
+
|
|
9
37
|
## [0.1.2] - 2026-09-27
|
|
10
38
|
|
|
11
39
|
### Fixed
|
|
@@ -86,7 +114,9 @@ Initial release.
|
|
|
86
114
|
budgets) from enclosing decorators and shields apply to inner calls.
|
|
87
115
|
- `callm` CLI: `stats`, `calls`, `cache`, `pricing`, `telemetry`, `info`.
|
|
88
116
|
|
|
89
|
-
[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
|
|
119
|
+
[0.2.0]: https://github.com/TanbirRamim/callm/compare/v0.1.2...v0.2.0
|
|
90
120
|
[0.1.2]: https://github.com/TanbirRamim/callm/compare/v0.1.1...v0.1.2
|
|
91
121
|
[0.1.1]: https://github.com/TanbirRamim/callm/compare/v0.1.0...v0.1.1
|
|
92
122
|
[0.1.0]: https://github.com/TanbirRamim/callm/releases/tag/v0.1.0
|
|
@@ -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
|
|
@@ -10,7 +10,7 @@ Project-URL: Changelog, https://github.com/TanbirRamim/callm/blob/main/CHANGELOG
|
|
|
10
10
|
Author: Tanbir Hossain Ramim
|
|
11
11
|
License-Expression: MIT
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Keywords: anthropic,caching,fallback,gemini,llm,openai,pii,production,prompt-injection,pydantic,retry
|
|
13
|
+
Keywords: anthropic,budget,caching,cost-tracking,fallback,gemini,llm,llmops,openai,pii,production,prompt-injection,pydantic,retry,telemetry
|
|
14
14
|
Classifier: Development Status :: 4 - Beta
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
16
16
|
Classifier: Operating System :: OS Independent
|
|
@@ -364,8 +364,8 @@ 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`, `
|
|
368
|
-
`models.generate_content`, sync and async) are instrumented on first use. Outside a callm
|
|
367
|
+
2. The official SDK methods (`chat.completions.create` and `.parse`, OpenAI's `responses.create`,
|
|
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
|
|
371
371
|
the middleware chain. Each layer is independent and only enabled when configured.
|
|
@@ -473,7 +473,7 @@ Measured offline with the real OpenAI SDK against a fake server
|
|
|
473
473
|
|
|
474
474
|
## Contributing
|
|
475
475
|
|
|
476
|
-
Contributions are welcome
|
|
476
|
+
Contributions are welcome. The [good first issues](https://github.com/TanbirRamim/callm/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) are small and scoped, and every pull request gets a review. See [CONTRIBUTING.md](https://github.com/TanbirRamim/callm/blob/main/CONTRIBUTING.md). The test suite runs
|
|
477
477
|
entirely offline against the real provider SDKs with mocked HTTP transports:
|
|
478
478
|
|
|
479
479
|
```bash
|
|
@@ -481,6 +481,8 @@ uv sync
|
|
|
481
481
|
uv run pytest
|
|
482
482
|
```
|
|
483
483
|
|
|
484
|
+
If callm is useful to you, a star helps other people find it.
|
|
485
|
+
|
|
484
486
|
## License
|
|
485
487
|
|
|
486
488
|
[MIT](https://github.com/TanbirRamim/callm/blob/main/LICENSE)
|
|
@@ -305,8 +305,8 @@ 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`, `
|
|
309
|
-
`models.generate_content`, sync and async) are instrumented on first use. Outside a callm
|
|
308
|
+
2. The official SDK methods (`chat.completions.create` and `.parse`, OpenAI's `responses.create`,
|
|
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
|
|
312
312
|
the middleware chain. Each layer is independent and only enabled when configured.
|
|
@@ -414,7 +414,7 @@ Measured offline with the real OpenAI SDK against a fake server
|
|
|
414
414
|
|
|
415
415
|
## Contributing
|
|
416
416
|
|
|
417
|
-
Contributions are welcome
|
|
417
|
+
Contributions are welcome. The [good first issues](https://github.com/TanbirRamim/callm/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) are small and scoped, and every pull request gets a review. See [CONTRIBUTING.md](https://github.com/TanbirRamim/callm/blob/main/CONTRIBUTING.md). The test suite runs
|
|
418
418
|
entirely offline against the real provider SDKs with mocked HTTP transports:
|
|
419
419
|
|
|
420
420
|
```bash
|
|
@@ -422,6 +422,8 @@ uv sync
|
|
|
422
422
|
uv run pytest
|
|
423
423
|
```
|
|
424
424
|
|
|
425
|
+
If callm is useful to you, a star helps other people find it.
|
|
426
|
+
|
|
425
427
|
## License
|
|
426
428
|
|
|
427
429
|
[MIT](https://github.com/TanbirRamim/callm/blob/main/LICENSE)
|
|
@@ -11,7 +11,7 @@ license = "MIT"
|
|
|
11
11
|
license-files = ["LICENSE"]
|
|
12
12
|
requires-python = ">=3.10"
|
|
13
13
|
authors = [{ name = "Tanbir Hossain Ramim" }]
|
|
14
|
-
keywords = ["llm", "openai", "anthropic", "gemini", "caching", "retry", "fallback", "pii", "prompt-injection", "pydantic", "production"]
|
|
14
|
+
keywords = ["llm", "openai", "anthropic", "gemini", "caching", "retry", "fallback", "pii", "prompt-injection", "pydantic", "production", "llmops", "cost-tracking", "budget", "telemetry"]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 4 - Beta",
|
|
17
17
|
"Intended Audience :: Developers",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -272,7 +272,7 @@ def _cmd_info(args: argparse.Namespace) -> int:
|
|
|
272
272
|
"enabled": settings.enabled,
|
|
273
273
|
"providers": provider_names(),
|
|
274
274
|
"instrumented_methods": sorted(
|
|
275
|
-
{f"{t.provider}: {t.cls}.{t.method}" for t in PATCH_TARGETS}
|
|
275
|
+
{f"{t.provider}: {t.cls}.{t.method}" for t in PATCH_TARGETS if not t.warn_only}
|
|
276
276
|
),
|
|
277
277
|
"optional_packages": optional,
|
|
278
278
|
}
|
|
@@ -42,7 +42,9 @@ def detect_provider(value: Any) -> str | None:
|
|
|
42
42
|
"""Which provider SDK produced ``value`` (a native response object), if any."""
|
|
43
43
|
module = type(value).__module__ or ""
|
|
44
44
|
if module.startswith("openai"):
|
|
45
|
-
|
|
45
|
+
if hasattr(value, "choices"):
|
|
46
|
+
return "openai"
|
|
47
|
+
return "openai-responses" if hasattr(value, "output") and hasattr(value, "usage") else None
|
|
46
48
|
if module.startswith("anthropic"):
|
|
47
49
|
return "anthropic" if hasattr(value, "content") and hasattr(value, "usage") else None
|
|
48
50
|
if module.startswith("google.genai"):
|
|
@@ -50,6 +52,8 @@ def detect_provider(value: Any) -> str | None:
|
|
|
50
52
|
if isinstance(value, AttrDict):
|
|
51
53
|
if "choices" in value:
|
|
52
54
|
return "openai"
|
|
55
|
+
if value.get("object") == "response":
|
|
56
|
+
return "openai-responses"
|
|
53
57
|
if value.get("type") == "message":
|
|
54
58
|
return "anthropic"
|
|
55
59
|
if "candidates" in value:
|
|
@@ -95,17 +95,60 @@ class PatchTarget:
|
|
|
95
95
|
cls: str
|
|
96
96
|
method: str
|
|
97
97
|
is_async: bool
|
|
98
|
+
#: True for SDK methods callm does not intercept yet: inside @callm they only log a
|
|
99
|
+
#: one-time warning, so nobody assumes PII masking or budgets apply when they do not.
|
|
100
|
+
warn_only: bool = False
|
|
98
101
|
|
|
99
102
|
|
|
100
103
|
PATCH_TARGETS: tuple[PatchTarget, ...] = (
|
|
101
104
|
PatchTarget("openai", "openai.resources.chat.completions", "Completions", "create", False),
|
|
102
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),
|
|
108
|
+
PatchTarget("openai-responses", "openai.resources.responses", "Responses", "create", False),
|
|
109
|
+
PatchTarget("openai-responses", "openai.resources.responses", "AsyncResponses", "create", True),
|
|
103
110
|
PatchTarget("anthropic", "anthropic.resources.messages", "Messages", "create", False),
|
|
104
111
|
PatchTarget("anthropic", "anthropic.resources.messages", "AsyncMessages", "create", True),
|
|
105
112
|
PatchTarget("google", "google.genai.models", "Models", "generate_content", False),
|
|
106
113
|
PatchTarget("google", "google.genai.models", "AsyncModels", "generate_content", True),
|
|
114
|
+
*(
|
|
115
|
+
PatchTarget(provider, module, cls, method, cls.startswith("Async"), warn_only=True)
|
|
116
|
+
for provider, module, classes, methods in (
|
|
117
|
+
(
|
|
118
|
+
"openai-responses",
|
|
119
|
+
"openai.resources.responses",
|
|
120
|
+
("Responses", "AsyncResponses"),
|
|
121
|
+
("parse", "stream"),
|
|
122
|
+
),
|
|
123
|
+
(
|
|
124
|
+
"openai",
|
|
125
|
+
"openai.resources.chat.completions",
|
|
126
|
+
("Completions", "AsyncCompletions"),
|
|
127
|
+
("stream",),
|
|
128
|
+
),
|
|
129
|
+
(
|
|
130
|
+
"openai",
|
|
131
|
+
"openai.resources.beta.chat.completions",
|
|
132
|
+
("Completions", "AsyncCompletions"),
|
|
133
|
+
("parse", "stream"),
|
|
134
|
+
),
|
|
135
|
+
(
|
|
136
|
+
"anthropic",
|
|
137
|
+
"anthropic.resources.messages",
|
|
138
|
+
("Messages", "AsyncMessages"),
|
|
139
|
+
("stream",),
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
for cls in classes
|
|
143
|
+
for method in methods
|
|
144
|
+
),
|
|
107
145
|
)
|
|
108
|
-
_ROOT_PACKAGES = {
|
|
146
|
+
_ROOT_PACKAGES = {
|
|
147
|
+
"openai": "openai",
|
|
148
|
+
"openai-responses": "openai",
|
|
149
|
+
"anthropic": "anthropic",
|
|
150
|
+
"google": "google.genai",
|
|
151
|
+
}
|
|
109
152
|
_RAW_RESPONSE_HEADERS = frozenset({"x-stainless-raw-response", "x-stainless-streamed-raw-response"})
|
|
110
153
|
|
|
111
154
|
_lock = threading.RLock()
|
|
@@ -160,6 +203,7 @@ def client_endpoint(resource: Any) -> str | None:
|
|
|
160
203
|
# Where each patched resource lives on its SDK client, to rebuild it on a client copy.
|
|
161
204
|
_RESOURCE_PATHS: dict[str, tuple[str, ...]] = {
|
|
162
205
|
"openai": ("chat", "completions"),
|
|
206
|
+
"openai-responses": ("responses",),
|
|
163
207
|
"anthropic": ("messages",),
|
|
164
208
|
}
|
|
165
209
|
_no_retry_resources: weakref.WeakKeyDictionary[Any, Any] = weakref.WeakKeyDictionary()
|
|
@@ -238,7 +282,99 @@ def clean_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
|
238
282
|
def _finish(target: PatchTarget, state: CallState, response: LLMResponse) -> Any:
|
|
239
283
|
if state.request.stream:
|
|
240
284
|
return response.raw
|
|
241
|
-
|
|
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
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
_warned_methods: set[str] = set()
|
|
320
|
+
_SUPPORTED_ALTERNATIVE = {
|
|
321
|
+
"anthropic": "messages.create",
|
|
322
|
+
"openai-responses": "responses.create",
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _unprotected_features(config: CallConfig) -> str:
|
|
327
|
+
features = []
|
|
328
|
+
if config.pii is not None:
|
|
329
|
+
features.append("PII masking")
|
|
330
|
+
if config.injection is not None:
|
|
331
|
+
features.append("injection checks")
|
|
332
|
+
if config.max_cost is not None or config.budgets:
|
|
333
|
+
features.append("budgets")
|
|
334
|
+
if config.cache is not None:
|
|
335
|
+
features.append("caching")
|
|
336
|
+
if config.output_schema is not None:
|
|
337
|
+
features.append("output validation")
|
|
338
|
+
if config.fallback:
|
|
339
|
+
features.append("fallback")
|
|
340
|
+
if config.retry.max_retries:
|
|
341
|
+
features.append("callm retries")
|
|
342
|
+
features.append("cost tracking and telemetry")
|
|
343
|
+
return ", ".join(features)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _make_warning_wrapper(target: PatchTarget, original: Callable[..., Any]) -> Callable[..., Any]:
|
|
347
|
+
"""Pass-through wrapper for SDK methods callm does not intercept yet.
|
|
348
|
+
|
|
349
|
+
Plain (non-async) on purpose: async originals return their coroutine or stream manager
|
|
350
|
+
unchanged, so behaviour and return types stay exactly those of the SDK.
|
|
351
|
+
"""
|
|
352
|
+
name = f"{target.provider} {target.cls}.{target.method}"
|
|
353
|
+
label = (
|
|
354
|
+
"responses." + target.method
|
|
355
|
+
if "responses" in target.module
|
|
356
|
+
else ("messages." if target.provider == "anthropic" else "chat.completions.")
|
|
357
|
+
+ target.method
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
@functools.wraps(original)
|
|
361
|
+
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
362
|
+
ctx = ACTIVE.get()
|
|
363
|
+
if ctx is not None and not BYPASS.get() and name not in _warned_methods:
|
|
364
|
+
_warned_methods.add(name)
|
|
365
|
+
logger.warning(
|
|
366
|
+
"callm: %s (%s) is not intercepted by callm yet, so inside %s it runs without %s. "
|
|
367
|
+
"Use %s for now: https://tanbirramim.github.io/callm/faq/",
|
|
368
|
+
label,
|
|
369
|
+
target.provider,
|
|
370
|
+
ctx.name or "@callm",
|
|
371
|
+
_unprotected_features(ctx.config),
|
|
372
|
+
_SUPPORTED_ALTERNATIVE.get(target.provider, "chat.completions.create"),
|
|
373
|
+
)
|
|
374
|
+
return original(self, *args, **kwargs)
|
|
375
|
+
|
|
376
|
+
wrapper.__callm_original__ = original # type: ignore[attr-defined]
|
|
377
|
+
return wrapper
|
|
242
378
|
|
|
243
379
|
|
|
244
380
|
def _make_sync_wrapper(target: PatchTarget, original: Callable[..., Any]) -> Callable[..., Any]:
|
|
@@ -304,7 +440,10 @@ def _patch_module(module: ModuleType, provider: str) -> None:
|
|
|
304
440
|
if existing is not None: # already instrumented (e.g. module reloaded)
|
|
305
441
|
_patched[target] = existing
|
|
306
442
|
continue
|
|
307
|
-
|
|
443
|
+
if target.warn_only:
|
|
444
|
+
make = _make_warning_wrapper
|
|
445
|
+
else:
|
|
446
|
+
make = _make_async_wrapper if target.is_async else _make_sync_wrapper
|
|
308
447
|
setattr(cls, target.method, make(target, original))
|
|
309
448
|
_patched[target] = original
|
|
310
449
|
logger.debug("callm: instrumented %s.%s.%s", target.module, target.cls, target.method)
|
|
@@ -103,7 +103,7 @@ def _export_span(record: CallRecord, start_ns: int | None) -> None:
|
|
|
103
103
|
)
|
|
104
104
|
attributes: dict[str, Any] = {
|
|
105
105
|
"gen_ai.operation.name": "chat",
|
|
106
|
-
"gen_ai.system": record.provider,
|
|
106
|
+
"gen_ai.system": "openai" if record.provider == "openai-responses" else record.provider,
|
|
107
107
|
"gen_ai.request.model": record.model,
|
|
108
108
|
"gen_ai.usage.input_tokens": record.input_tokens
|
|
109
109
|
+ record.cache_read_tokens
|
|
@@ -161,8 +161,11 @@ def _lookup(table: dict[str, ModelPrice], name: str) -> ModelPrice | None:
|
|
|
161
161
|
return table[best] if best is not None else None
|
|
162
162
|
|
|
163
163
|
|
|
164
|
+
_PRICE_ALIASES = {"gemini": "google", "openai-responses": "openai"}
|
|
165
|
+
|
|
166
|
+
|
|
164
167
|
def _provider_key(provider: str) -> str:
|
|
165
|
-
return
|
|
168
|
+
return _PRICE_ALIASES.get(provider, provider)
|
|
166
169
|
|
|
167
170
|
|
|
168
171
|
def get_price(provider: str, model: str) -> ModelPrice | None:
|
|
@@ -235,7 +238,7 @@ def estimate_tokens(text: str, model: str | None = None, provider: str | None =
|
|
|
235
238
|
"""Estimate a token count. Uses ``tiktoken`` for OpenAI models when it is installed."""
|
|
236
239
|
if not text:
|
|
237
240
|
return 0
|
|
238
|
-
if provider in (None, "openai") and model is not None:
|
|
241
|
+
if provider in (None, "openai", "openai-responses") and model is not None:
|
|
239
242
|
encoder = _tiktoken_encoder(model)
|
|
240
243
|
if encoder is not None:
|
|
241
244
|
return len(encoder.encode(text, disallowed_special=()))
|
|
@@ -4,12 +4,14 @@ from callm.providers.anthropic import AnthropicProvider
|
|
|
4
4
|
from callm.providers.base import Provider
|
|
5
5
|
from callm.providers.google import GoogleProvider
|
|
6
6
|
from callm.providers.openai import OpenAIProvider
|
|
7
|
+
from callm.providers.openai_responses import OpenAIResponsesProvider
|
|
7
8
|
from callm.providers.registry import get_provider, parse_target, provider_names, register_provider
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
10
11
|
"AnthropicProvider",
|
|
11
12
|
"GoogleProvider",
|
|
12
13
|
"OpenAIProvider",
|
|
14
|
+
"OpenAIResponsesProvider",
|
|
13
15
|
"Provider",
|
|
14
16
|
"get_provider",
|
|
15
17
|
"parse_target",
|
|
@@ -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
|