openai-sqlite-cache 0.0.1__py3-none-any.whl

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.
@@ -0,0 +1,175 @@
1
+ """Drop-in OpenAI Python SDK with a persistent local SQLite cache.
2
+
3
+ Use ``import cached_openai as openai`` and keep the rest of an existing OpenAI
4
+ SDK integration unchanged.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ import types as _stdlib_types
11
+ from pathlib import Path
12
+ from typing import Any, Optional
13
+
14
+ import openai as _openai
15
+ from openai import * # noqa: F403
16
+
17
+ from . import _client as _cached_client
18
+ from ._cache import SQLiteCache
19
+ from ._http import CachingSyncClient, wrap_sync_client
20
+ from ._module_alias import install_module_alias
21
+ from ._settings import _UNSET, configure_settings, get_settings
22
+ from ._version import PACKAGE_VERSION
23
+
24
+ OpenAI = _cached_client.OpenAI
25
+ AsyncOpenAI = _cached_client.AsyncOpenAI
26
+ Client = _cached_client.Client
27
+ AsyncClient = _cached_client.AsyncClient
28
+ AzureOpenAI = _cached_client.AzureOpenAI
29
+ AsyncAzureOpenAI = _cached_client.AsyncAzureOpenAI
30
+ CachedOpenAI = _cached_client.CachedOpenAI
31
+ AsyncCachedOpenAI = _cached_client.AsyncCachedOpenAI
32
+
33
+ __version__ = PACKAGE_VERSION
34
+ openai_version = _openai.__version__
35
+
36
+ _MODULE_CONFIG_NAMES = {
37
+ "api_key",
38
+ "organization",
39
+ "project",
40
+ "webhook_secret",
41
+ "base_url",
42
+ "timeout",
43
+ "max_retries",
44
+ "default_headers",
45
+ "default_query",
46
+ "http_client",
47
+ "api_type",
48
+ "api_version",
49
+ "azure_endpoint",
50
+ "azure_ad_token",
51
+ "azure_ad_token_provider",
52
+ }
53
+
54
+ _MODULE_RESOURCE_NAMES = {
55
+ "audio",
56
+ "batches",
57
+ "beta",
58
+ "chat",
59
+ "completions",
60
+ "containers",
61
+ "conversations",
62
+ "embeddings",
63
+ "evals",
64
+ "files",
65
+ "fine_tuning",
66
+ "images",
67
+ "models",
68
+ "moderations",
69
+ "realtime",
70
+ "responses",
71
+ "skills",
72
+ "uploads",
73
+ "vector_stores",
74
+ "videos",
75
+ "webhooks",
76
+ }
77
+
78
+
79
+ def _reset_upstream_module_client() -> None:
80
+ reset = getattr(_openai, "_reset_client", None)
81
+ if reset is not None:
82
+ reset()
83
+
84
+
85
+ def _prepare_module_client() -> None:
86
+ settings = get_settings()
87
+ if not settings.enabled:
88
+ return
89
+ current = getattr(_openai, "http_client", None)
90
+ if current is None:
91
+ current = _openai.DefaultHttpxClient()
92
+ wrapped = wrap_sync_client(current, settings)
93
+ if wrapped is not getattr(_openai, "http_client", None):
94
+ _openai.http_client = wrapped
95
+ _reset_upstream_module_client()
96
+
97
+
98
+ def configure_cache(
99
+ *,
100
+ path: Any = _UNSET,
101
+ ttl_seconds: Any = _UNSET,
102
+ enabled: Any = _UNSET,
103
+ ) -> dict:
104
+ """Configure defaults used by new clients and the module-level client."""
105
+
106
+ settings = configure_settings(path=path, ttl_seconds=ttl_seconds, enabled=enabled)
107
+ current = getattr(_openai, "http_client", None)
108
+ if isinstance(current, CachingSyncClient):
109
+ _openai.http_client = current._cached_openai_inner
110
+ _reset_upstream_module_client()
111
+ return {
112
+ "path": str(settings.path.expanduser().resolve()),
113
+ "ttl_seconds": settings.ttl_seconds,
114
+ "enabled": settings.enabled,
115
+ }
116
+
117
+
118
+ def clear_cache(path: Optional[Any] = None) -> int:
119
+ """Delete all cached response rows and return the number removed."""
120
+
121
+ cache_path = get_settings().path if path is None else Path(path).expanduser()
122
+ return SQLiteCache(cache_path).clear()
123
+
124
+
125
+ def cache_info(path: Optional[Any] = None) -> dict:
126
+ """Return cache path, row count, stored bytes, and cumulative hit count."""
127
+
128
+ cache_path = get_settings().path if path is None else Path(path).expanduser()
129
+ return SQLiteCache(cache_path).stats()
130
+
131
+
132
+ def __getattr__(name: str) -> Any:
133
+ if name in _MODULE_RESOURCE_NAMES:
134
+ _prepare_module_client()
135
+ return getattr(_openai, name)
136
+
137
+
138
+ def __dir__() -> list:
139
+ return sorted(set(globals()) | set(dir(_openai)))
140
+
141
+
142
+ class _CachedOpenAIModule(_stdlib_types.ModuleType):
143
+ def __setattr__(self, name: str, value: Any) -> None:
144
+ if name in _MODULE_CONFIG_NAMES:
145
+ if name == "http_client" and isinstance(value, CachingSyncClient):
146
+ value = value._cached_openai_inner
147
+ setattr(_openai, name, value)
148
+ _reset_upstream_module_client()
149
+ return
150
+ super().__setattr__(name, value)
151
+
152
+
153
+ sys.modules[__name__].__class__ = _CachedOpenAIModule
154
+
155
+ # Preserve class identity for imports such as
156
+ # ``from cached_openai.types.chat import ChatCompletion``.
157
+ types = install_module_alias(__name__ + ".types", "openai.types")
158
+
159
+ __all__ = sorted(
160
+ set(getattr(_openai, "__all__", []))
161
+ | {
162
+ "AsyncAzureOpenAI",
163
+ "AsyncCachedOpenAI",
164
+ "AsyncClient",
165
+ "AsyncOpenAI",
166
+ "AzureOpenAI",
167
+ "CachedOpenAI",
168
+ "Client",
169
+ "OpenAI",
170
+ "cache_info",
171
+ "clear_cache",
172
+ "configure_cache",
173
+ "openai_version",
174
+ }
175
+ )
@@ -0,0 +1,145 @@
1
+ """A small, process-safe SQLite store for HTTP response payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sqlite3
8
+ import time
9
+ from contextlib import closing
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from threading import RLock
13
+ from typing import List, Optional, Sequence, Tuple
14
+
15
+ HeaderList = List[Tuple[str, str]]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CachedResponse:
20
+ status_code: int
21
+ headers: HeaderList
22
+ body: bytes
23
+
24
+
25
+ class SQLiteCache:
26
+ """Persistent cache whose rows contain responses, never request bodies."""
27
+
28
+ def __init__(self, path: Path):
29
+ self.path = path.expanduser().resolve()
30
+ self._lock = RLock()
31
+ self._initialize()
32
+
33
+ def _connect(self) -> sqlite3.Connection:
34
+ connection = sqlite3.connect(str(self.path), timeout=10.0)
35
+ connection.execute("PRAGMA busy_timeout = 10000")
36
+ return connection
37
+
38
+ def _initialize(self) -> None:
39
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
40
+ with self._lock, closing(self._connect()) as connection, connection:
41
+ connection.execute("PRAGMA journal_mode = WAL")
42
+ connection.execute(
43
+ """
44
+ CREATE TABLE IF NOT EXISTS responses (
45
+ cache_key TEXT PRIMARY KEY,
46
+ status_code INTEGER NOT NULL,
47
+ headers_json TEXT NOT NULL,
48
+ body BLOB NOT NULL,
49
+ created_at REAL NOT NULL,
50
+ last_accessed_at REAL NOT NULL,
51
+ hit_count INTEGER NOT NULL DEFAULT 0
52
+ )
53
+ """
54
+ )
55
+ try:
56
+ os.chmod(self.path, 0o600)
57
+ except OSError:
58
+ pass
59
+
60
+ def get(
61
+ self, cache_key: str, ttl_seconds: Optional[float]
62
+ ) -> Optional[CachedResponse]:
63
+ now = time.time()
64
+ with self._lock, closing(self._connect()) as connection, connection:
65
+ row = connection.execute(
66
+ """
67
+ SELECT status_code, headers_json, body, created_at
68
+ FROM responses
69
+ WHERE cache_key = ?
70
+ """,
71
+ (cache_key,),
72
+ ).fetchone()
73
+ if row is None:
74
+ return None
75
+ if ttl_seconds is not None and now - float(row[3]) >= ttl_seconds:
76
+ connection.execute(
77
+ "DELETE FROM responses WHERE cache_key = ?", (cache_key,)
78
+ )
79
+ return None
80
+ connection.execute(
81
+ """
82
+ UPDATE responses
83
+ SET last_accessed_at = ?, hit_count = hit_count + 1
84
+ WHERE cache_key = ?
85
+ """,
86
+ (now, cache_key),
87
+ )
88
+ raw_headers = json.loads(str(row[1]))
89
+ headers = [(str(name), str(value)) for name, value in raw_headers]
90
+ return CachedResponse(
91
+ status_code=int(row[0]), headers=headers, body=bytes(row[2])
92
+ )
93
+
94
+ def put(
95
+ self,
96
+ cache_key: str,
97
+ status_code: int,
98
+ headers: Sequence[Tuple[str, str]],
99
+ body: bytes,
100
+ ) -> None:
101
+ now = time.time()
102
+ headers_json = json.dumps(
103
+ list(headers), ensure_ascii=False, separators=(",", ":")
104
+ )
105
+ with self._lock, closing(self._connect()) as connection, connection:
106
+ connection.execute(
107
+ """
108
+ INSERT INTO responses (
109
+ cache_key, status_code, headers_json, body,
110
+ created_at, last_accessed_at, hit_count
111
+ ) VALUES (?, ?, ?, ?, ?, ?, 0)
112
+ ON CONFLICT(cache_key) DO UPDATE SET
113
+ status_code = excluded.status_code,
114
+ headers_json = excluded.headers_json,
115
+ body = excluded.body,
116
+ created_at = excluded.created_at,
117
+ last_accessed_at = excluded.last_accessed_at,
118
+ hit_count = 0
119
+ """,
120
+ (cache_key, status_code, headers_json, sqlite3.Binary(body), now, now),
121
+ )
122
+
123
+ def clear(self) -> int:
124
+ with self._lock, closing(self._connect()) as connection, connection:
125
+ count = int(
126
+ connection.execute("SELECT COUNT(*) FROM responses").fetchone()[0]
127
+ )
128
+ connection.execute("DELETE FROM responses")
129
+ return count
130
+
131
+ def stats(self) -> dict:
132
+ with self._lock, closing(self._connect()) as connection, connection:
133
+ row = connection.execute(
134
+ """
135
+ SELECT COUNT(*), COALESCE(SUM(LENGTH(body)), 0),
136
+ COALESCE(SUM(hit_count), 0)
137
+ FROM responses
138
+ """
139
+ ).fetchone()
140
+ return {
141
+ "path": str(self.path),
142
+ "entries": int(row[0]),
143
+ "response_bytes": int(row[1]),
144
+ "hits": int(row[2]),
145
+ }
@@ -0,0 +1,166 @@
1
+ """Drop-in cached variants of OpenAI's explicit clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import openai as _openai
9
+
10
+ from ._http import (
11
+ CachingAsyncClient,
12
+ CachingSyncClient,
13
+ wrap_async_client,
14
+ wrap_sync_client,
15
+ )
16
+ from ._settings import _UNSET, CacheSettings, settings_for_client
17
+
18
+
19
+ def _settings_from_existing(
20
+ existing: CacheSettings,
21
+ cache_path: Any,
22
+ cache_ttl: Any,
23
+ cache_enabled: Any,
24
+ ) -> CacheSettings:
25
+ path = (
26
+ existing.path
27
+ if cache_path is _UNSET or cache_path is None
28
+ else Path(cache_path).expanduser()
29
+ )
30
+ ttl = existing.ttl_seconds if cache_ttl is _UNSET else cache_ttl
31
+ enabled = (
32
+ existing.enabled
33
+ if cache_enabled is _UNSET or cache_enabled is None
34
+ else cache_enabled
35
+ )
36
+ if ttl is not None and float(ttl) < 0:
37
+ raise ValueError("cache_ttl must be zero or greater")
38
+ return CacheSettings(
39
+ path=path,
40
+ ttl_seconds=None if ttl is None else float(ttl),
41
+ enabled=bool(enabled),
42
+ )
43
+
44
+
45
+ def _sync_kwargs(
46
+ kwargs: dict, cache_path: Any, cache_ttl: Any, cache_enabled: Any
47
+ ) -> dict:
48
+ original = kwargs.get("http_client")
49
+ if isinstance(original, CachingSyncClient):
50
+ settings = _settings_from_existing(
51
+ original._cached_openai_settings,
52
+ cache_path,
53
+ cache_ttl,
54
+ cache_enabled,
55
+ )
56
+ else:
57
+ settings = settings_for_client(
58
+ cache_path=cache_path,
59
+ cache_ttl=cache_ttl,
60
+ cache_enabled=cache_enabled,
61
+ )
62
+ if not settings.enabled:
63
+ if isinstance(original, CachingSyncClient):
64
+ kwargs["http_client"] = original._cached_openai_inner
65
+ return kwargs
66
+ if original is None:
67
+ original = _openai.DefaultHttpxClient()
68
+ kwargs["http_client"] = wrap_sync_client(original, settings)
69
+ return kwargs
70
+
71
+
72
+ def _async_kwargs(
73
+ kwargs: dict, cache_path: Any, cache_ttl: Any, cache_enabled: Any
74
+ ) -> dict:
75
+ original = kwargs.get("http_client")
76
+ if isinstance(original, CachingAsyncClient):
77
+ settings = _settings_from_existing(
78
+ original._cached_openai_settings,
79
+ cache_path,
80
+ cache_ttl,
81
+ cache_enabled,
82
+ )
83
+ else:
84
+ settings = settings_for_client(
85
+ cache_path=cache_path,
86
+ cache_ttl=cache_ttl,
87
+ cache_enabled=cache_enabled,
88
+ )
89
+ if not settings.enabled:
90
+ if isinstance(original, CachingAsyncClient):
91
+ kwargs["http_client"] = original._cached_openai_inner
92
+ return kwargs
93
+ if original is None:
94
+ original = _openai.DefaultAsyncHttpxClient()
95
+ kwargs["http_client"] = wrap_async_client(original, settings)
96
+ return kwargs
97
+
98
+
99
+ class OpenAI(_openai.OpenAI):
100
+ """The official synchronous OpenAI client with an SQLite cache."""
101
+
102
+ def __init__(
103
+ self,
104
+ *args: Any,
105
+ cache_path: Any = _UNSET,
106
+ cache_ttl: Any = _UNSET,
107
+ cache_enabled: Any = _UNSET,
108
+ **kwargs: Any,
109
+ ) -> None:
110
+ super().__init__(
111
+ *args, **_sync_kwargs(kwargs, cache_path, cache_ttl, cache_enabled)
112
+ )
113
+
114
+
115
+ class AsyncOpenAI(_openai.AsyncOpenAI):
116
+ """The official asynchronous OpenAI client with an SQLite cache."""
117
+
118
+ def __init__(
119
+ self,
120
+ *args: Any,
121
+ cache_path: Any = _UNSET,
122
+ cache_ttl: Any = _UNSET,
123
+ cache_enabled: Any = _UNSET,
124
+ **kwargs: Any,
125
+ ) -> None:
126
+ super().__init__(
127
+ *args, **_async_kwargs(kwargs, cache_path, cache_ttl, cache_enabled)
128
+ )
129
+
130
+
131
+ class AzureOpenAI(_openai.AzureOpenAI):
132
+ """The official synchronous Azure OpenAI client with an SQLite cache."""
133
+
134
+ def __init__(
135
+ self,
136
+ *args: Any,
137
+ cache_path: Any = _UNSET,
138
+ cache_ttl: Any = _UNSET,
139
+ cache_enabled: Any = _UNSET,
140
+ **kwargs: Any,
141
+ ) -> None:
142
+ super().__init__(
143
+ *args, **_sync_kwargs(kwargs, cache_path, cache_ttl, cache_enabled)
144
+ )
145
+
146
+
147
+ class AsyncAzureOpenAI(_openai.AsyncAzureOpenAI):
148
+ """The official asynchronous Azure OpenAI client with an SQLite cache."""
149
+
150
+ def __init__(
151
+ self,
152
+ *args: Any,
153
+ cache_path: Any = _UNSET,
154
+ cache_ttl: Any = _UNSET,
155
+ cache_enabled: Any = _UNSET,
156
+ **kwargs: Any,
157
+ ) -> None:
158
+ super().__init__(
159
+ *args, **_async_kwargs(kwargs, cache_path, cache_ttl, cache_enabled)
160
+ )
161
+
162
+
163
+ Client = OpenAI
164
+ AsyncClient = AsyncOpenAI
165
+ CachedOpenAI = OpenAI
166
+ AsyncCachedOpenAI = AsyncOpenAI
@@ -0,0 +1,186 @@
1
+ """Stable, privacy-conscious request fingerprints for cacheable OpenAI APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from email.parser import BytesParser
8
+ from email.policy import default as email_policy
9
+ from typing import Any, Dict, List, Mapping, Optional, Tuple
10
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
11
+
12
+ _CACHEABLE_SUFFIXES = (
13
+ "/responses",
14
+ "/responses/compact",
15
+ "/chat/completions",
16
+ "/completions",
17
+ "/embeddings",
18
+ "/moderations",
19
+ "/images/generations",
20
+ "/images/edits",
21
+ "/images/variations",
22
+ "/audio/speech",
23
+ "/audio/transcriptions",
24
+ "/audio/translations",
25
+ "/videos",
26
+ )
27
+
28
+ _CREDENTIAL_HEADERS = {
29
+ "authorization",
30
+ "api-key",
31
+ "cookie",
32
+ "x-api-key",
33
+ }
34
+
35
+ _IGNORED_HEADERS = {
36
+ "accept-encoding",
37
+ "connection",
38
+ "content-length",
39
+ "host",
40
+ "idempotency-key",
41
+ "traceparent",
42
+ "tracestate",
43
+ "transfer-encoding",
44
+ "user-agent",
45
+ }
46
+
47
+
48
+ def _canonical_json(value: Any) -> bytes:
49
+ return json.dumps(
50
+ value,
51
+ ensure_ascii=False,
52
+ sort_keys=True,
53
+ separators=(",", ":"),
54
+ allow_nan=False,
55
+ ).encode("utf-8")
56
+
57
+
58
+ def _normalized_url(url: str) -> str:
59
+ parts = urlsplit(url)
60
+ hostname = (parts.hostname or "").lower()
61
+ port = parts.port
62
+ default_port = (parts.scheme.lower() == "https" and port == 443) or (
63
+ parts.scheme.lower() == "http" and port == 80
64
+ )
65
+ authority = hostname if port is None or default_port else f"{hostname}:{port}"
66
+ query = urlencode(
67
+ sorted(parse_qsl(parts.query, keep_blank_values=True)), doseq=True
68
+ )
69
+ return urlunsplit((parts.scheme.lower(), authority, parts.path, query, ""))
70
+
71
+
72
+ def _is_cacheable_path(path: str) -> bool:
73
+ normalized = path.rstrip("/").lower()
74
+ return any(normalized.endswith(suffix) for suffix in _CACHEABLE_SUFFIXES)
75
+
76
+
77
+ def _header_items(headers: Any) -> List[Tuple[str, str]]:
78
+ if hasattr(headers, "multi_items"):
79
+ return [(str(name), str(value)) for name, value in headers.multi_items()]
80
+ if hasattr(headers, "items"):
81
+ return [(str(name), str(value)) for name, value in headers.items()]
82
+ return [(str(name), str(value)) for name, value in headers]
83
+
84
+
85
+ def _multipart_value(content_type: str, body: bytes) -> Tuple[Any, Optional[str]]:
86
+ message = BytesParser(policy=email_policy).parsebytes(
87
+ b"Content-Type: "
88
+ + content_type.encode("latin-1")
89
+ + b"\r\nMIME-Version: 1.0\r\n\r\n"
90
+ + body
91
+ )
92
+ parts: List[Dict[str, Any]] = []
93
+ model: Optional[str] = None
94
+ for part in message.iter_parts():
95
+ name = part.get_param("name", header="content-disposition") or ""
96
+ filename = part.get_filename()
97
+ payload = part.get_payload(decode=True) or b""
98
+ item: Dict[str, Any] = {
99
+ "name": name,
100
+ "filename": filename,
101
+ "content_type": part.get_content_type(),
102
+ "sha256": hashlib.sha256(payload).hexdigest(),
103
+ }
104
+ if filename is None:
105
+ charset = part.get_content_charset() or "utf-8"
106
+ try:
107
+ value = payload.decode(charset)
108
+ except (LookupError, UnicodeDecodeError):
109
+ value = payload.decode("utf-8", errors="replace")
110
+ item["value"] = value
111
+ if name == "model":
112
+ model = value
113
+ parts.append(item)
114
+ return {"kind": "multipart", "parts": parts}, model
115
+
116
+
117
+ def request_fingerprint(
118
+ method: str, url: str, headers: Any, body: bytes
119
+ ) -> Optional[str]:
120
+ """Return a versioned SHA-256 key, or ``None`` for non-inference APIs."""
121
+
122
+ if method.upper() != "POST" or not _is_cacheable_path(urlsplit(url).path):
123
+ return None
124
+
125
+ items = _header_items(headers)
126
+ lowered: Dict[str, List[str]] = {}
127
+ for name, value in items:
128
+ lowered.setdefault(name.lower(), []).append(value)
129
+ content_type = (lowered.get("content-type") or [""])[0]
130
+ media_type = content_type.split(";", 1)[0].strip().lower()
131
+
132
+ model: Optional[str] = None
133
+ body_value: Any
134
+ if media_type == "application/json" or (
135
+ not media_type and body.lstrip().startswith((b"{", b"["))
136
+ ):
137
+ try:
138
+ body_value = json.loads(body.decode("utf-8"))
139
+ if isinstance(body_value, Mapping) and body_value.get("model") is not None:
140
+ model = str(body_value["model"])
141
+ body_value = {"kind": "json", "value": body_value}
142
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
143
+ body_value = {"kind": "bytes", "sha256": hashlib.sha256(body).hexdigest()}
144
+ elif media_type == "multipart/form-data":
145
+ try:
146
+ body_value, model = _multipart_value(content_type, body)
147
+ except Exception:
148
+ body_value = {"kind": "bytes", "sha256": hashlib.sha256(body).hexdigest()}
149
+ elif media_type == "application/x-www-form-urlencoded":
150
+ fields = sorted(parse_qsl(body.decode("utf-8"), keep_blank_values=True))
151
+ body_value = {"kind": "form", "fields": fields}
152
+ for name, value in fields:
153
+ if name == "model":
154
+ model = value
155
+ break
156
+ else:
157
+ body_value = {"kind": "bytes", "sha256": hashlib.sha256(body).hexdigest()}
158
+
159
+ scoped_headers = {}
160
+ for name, values in lowered.items():
161
+ if (
162
+ name in _CREDENTIAL_HEADERS
163
+ or name in _IGNORED_HEADERS
164
+ or name == "content-type"
165
+ or name.startswith("x-stainless-")
166
+ ):
167
+ continue
168
+ scoped_headers[name] = sorted(values)
169
+ scoped_headers["content-type"] = [media_type]
170
+ credential_material = "\0".join(
171
+ f"{name}:{value}"
172
+ for name in sorted(_CREDENTIAL_HEADERS)
173
+ for value in lowered.get(name, [])
174
+ )
175
+ credential_scope = hashlib.sha256(credential_material.encode("utf-8")).hexdigest()
176
+
177
+ material = {
178
+ "version": 1,
179
+ "method": method.upper(),
180
+ "url": _normalized_url(url),
181
+ "model": model or "<default>",
182
+ "headers": scoped_headers,
183
+ "credential_scope": credential_scope,
184
+ "body": body_value,
185
+ }
186
+ return "v1:" + hashlib.sha256(_canonical_json(material)).hexdigest()