codex-usage-tracking 0.3.0__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.
Files changed (50) hide show
  1. codex_usage_tracker/__init__.py +7 -0
  2. codex_usage_tracker/__main__.py +6 -0
  3. codex_usage_tracker/allowance.py +759 -0
  4. codex_usage_tracker/api_payloads.py +90 -0
  5. codex_usage_tracker/cli.py +1326 -0
  6. codex_usage_tracker/context.py +410 -0
  7. codex_usage_tracker/costing.py +176 -0
  8. codex_usage_tracker/dashboard.py +389 -0
  9. codex_usage_tracker/diagnostics.py +624 -0
  10. codex_usage_tracker/formatting.py +225 -0
  11. codex_usage_tracker/json_contracts.py +350 -0
  12. codex_usage_tracker/mcp_server.py +371 -0
  13. codex_usage_tracker/models.py +92 -0
  14. codex_usage_tracker/parser.py +491 -0
  15. codex_usage_tracker/paths.py +18 -0
  16. codex_usage_tracker/plugin_data/__init__.py +1 -0
  17. codex_usage_tracker/plugin_data/assets/icon.svg +8 -0
  18. codex_usage_tracker/plugin_data/dashboard/dashboard.css +954 -0
  19. codex_usage_tracker/plugin_data/dashboard/dashboard.js +1833 -0
  20. codex_usage_tracker/plugin_data/dashboard/dashboard_data.js +155 -0
  21. codex_usage_tracker/plugin_data/dashboard/dashboard_format.js +132 -0
  22. codex_usage_tracker/plugin_data/dashboard/dashboard_state.js +157 -0
  23. codex_usage_tracker/plugin_data/dashboard/dashboard_template.html +141 -0
  24. codex_usage_tracker/plugin_data/docs/assets/dashboard-calls.png +0 -0
  25. codex_usage_tracker/plugin_data/docs/assets/dashboard-details.png +0 -0
  26. codex_usage_tracker/plugin_data/docs/assets/dashboard-insights.png +0 -0
  27. codex_usage_tracker/plugin_data/docs/assets/dashboard-threads.png +0 -0
  28. codex_usage_tracker/plugin_data/docs/dashboard-guide.html +136 -0
  29. codex_usage_tracker/plugin_data/rate_cards/codex-credit-rates.json +69 -0
  30. codex_usage_tracker/plugin_data/skills/codex-usage-api/SKILL.md +62 -0
  31. codex_usage_tracker/plugin_data/skills/codex-usage-tracker/SKILL.md +47 -0
  32. codex_usage_tracker/plugin_installer.py +312 -0
  33. codex_usage_tracker/pricing.py +57 -0
  34. codex_usage_tracker/pricing_config.py +223 -0
  35. codex_usage_tracker/pricing_estimates.py +44 -0
  36. codex_usage_tracker/pricing_openai.py +253 -0
  37. codex_usage_tracker/projects.py +347 -0
  38. codex_usage_tracker/recommendations.py +270 -0
  39. codex_usage_tracker/reports.py +637 -0
  40. codex_usage_tracker/schema.py +71 -0
  41. codex_usage_tracker/server.py +400 -0
  42. codex_usage_tracker/store.py +666 -0
  43. codex_usage_tracker/support.py +147 -0
  44. codex_usage_tracker/threads.py +183 -0
  45. codex_usage_tracking-0.3.0.dist-info/METADATA +278 -0
  46. codex_usage_tracking-0.3.0.dist-info/RECORD +50 -0
  47. codex_usage_tracking-0.3.0.dist-info/WHEEL +5 -0
  48. codex_usage_tracking-0.3.0.dist-info/entry_points.txt +2 -0
  49. codex_usage_tracking-0.3.0.dist-info/licenses/LICENSE +21 -0
  50. codex_usage_tracking-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,225 @@
1
+ """Human-readable report formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def format_summary(rows: list[dict[str, Any]], group_by: str) -> str:
9
+ if not rows:
10
+ return "No Codex usage records found. Run refresh_usage_index first."
11
+
12
+ lines = [f"Codex usage summary by {group_by}", ""]
13
+ for row in rows:
14
+ label = row.get("group_key") or "Unknown"
15
+ total = _fmt_int(row.get("total_tokens"))
16
+ calls = _fmt_int(row.get("model_calls"))
17
+ sessions = _fmt_int(row.get("sessions"))
18
+ cached = _fmt_int(row.get("cached_input_tokens"))
19
+ uncached = _fmt_int(row.get("uncached_input_tokens"))
20
+ output = _fmt_int(row.get("output_tokens"))
21
+ reasoning = _fmt_int(row.get("reasoning_output_tokens"))
22
+ cache_ratio = _fmt_pct(row.get("avg_cache_ratio"))
23
+ cost = _cost_suffix(row)
24
+ lines.append(
25
+ f"- {label}: {total} total tokens across {calls} model calls "
26
+ f"({sessions} sessions, {cached} cached input, {uncached} uncached input, "
27
+ f"{output} output, {reasoning} reasoning output, avg cache {cache_ratio}{cost})"
28
+ )
29
+ return "\n".join(lines)
30
+
31
+
32
+ def format_session(rows: list[dict[str, Any]]) -> str:
33
+ if not rows:
34
+ return "No usage records found for that session."
35
+
36
+ first = rows[0]
37
+ thread = (
38
+ first.get("thread_name")
39
+ or first.get("parent_thread_name")
40
+ or first.get("resolved_parent_thread_name")
41
+ or first.get("session_id")
42
+ )
43
+ lines = [
44
+ f"Codex session usage: {thread}",
45
+ f"Session: {first.get('session_id')}",
46
+ "",
47
+ ]
48
+ for index, row in enumerate(rows, 1):
49
+ label = row.get("event_timestamp") or f"call {index}"
50
+ lines.append(
51
+ f"{index}. {label} | {row.get('model') or 'unknown'} "
52
+ f"({row.get('effort') or 'unknown'}) | "
53
+ f"last call {_fmt_int(row.get('total_tokens'))} tokens | "
54
+ f"cumulative {_fmt_int(row.get('cumulative_total_tokens'))} tokens | "
55
+ f"cache {_fmt_pct(row.get('cache_ratio'))} | "
56
+ f"context {_fmt_pct(row.get('context_window_percent'))}"
57
+ )
58
+ return "\n".join(lines)
59
+
60
+
61
+ def format_calls(rows: list[dict[str, Any]], title: str = "Most expensive Codex calls") -> str:
62
+ if not rows:
63
+ return "No Codex usage records found. Run refresh_usage_index first."
64
+
65
+ lines = [title, ""]
66
+ for index, row in enumerate(rows, 1):
67
+ thread = (
68
+ row.get("thread_name")
69
+ or row.get("parent_thread_name")
70
+ or row.get("resolved_parent_thread_name")
71
+ or row.get("session_id")
72
+ or "Unknown"
73
+ )
74
+ flags = row.get("efficiency_flags") or []
75
+ flag_suffix = f" | flags: {', '.join(flags)}" if flags else ""
76
+ cost = _cost_suffix(row, prefix=" | estimated cost ")
77
+ action = row.get("recommended_action")
78
+ action_suffix = f" | action: {action}" if action else ""
79
+ lines.append(
80
+ f"{index}. {row.get('event_timestamp') or 'Unknown time'} | "
81
+ f"{thread} | {row.get('model') or 'unknown'} "
82
+ f"({row.get('effort') or 'unknown'}) | "
83
+ f"last call {_fmt_int(row.get('total_tokens'))} tokens | "
84
+ f"cache {_fmt_pct(row.get('cache_ratio'))} | "
85
+ f"context {_fmt_pct(row.get('context_window_percent'))}"
86
+ f"{cost}{flag_suffix}{action_suffix}"
87
+ )
88
+ return "\n".join(lines)
89
+
90
+
91
+ def format_recommendations(payload: dict[str, Any]) -> str:
92
+ rows = payload.get("rows")
93
+ if not isinstance(rows, list) or not rows:
94
+ return "No aggregate recommendations are currently flagged."
95
+
96
+ lines = ["Codex usage recommendations", ""]
97
+ threads = payload.get("threads")
98
+ if isinstance(threads, list) and threads:
99
+ lines.append("Top threads:")
100
+ for thread in threads[:5]:
101
+ lines.append(
102
+ f"- {thread.get('thread') or 'Unknown thread'}: "
103
+ f"score {_fmt_decimal(thread.get('recommendation_score'))}, "
104
+ f"{_fmt_int(thread.get('call_count'))} calls, "
105
+ f"{_fmt_int(thread.get('total_tokens'))} tokens"
106
+ )
107
+ lines.append("")
108
+ lines.append("Top calls:")
109
+ for index, row in enumerate(rows, 1):
110
+ primary = row.get("primary_recommendation") or {}
111
+ if not isinstance(primary, dict):
112
+ primary = {}
113
+ thread = (
114
+ row.get("thread_attachment_label")
115
+ or row.get("thread_name")
116
+ or row.get("parent_thread_name")
117
+ or row.get("session_id")
118
+ or "Unknown"
119
+ )
120
+ lines.append(
121
+ f"{index}. {thread} | {row.get('model') or 'unknown'} "
122
+ f"({row.get('effort') or 'unknown'}) | "
123
+ f"score {_fmt_decimal(row.get('recommendation_score'))} | "
124
+ f"{primary.get('title') or row.get('primary_signal') or 'Recommendation'}: "
125
+ f"{row.get('recommended_action') or primary.get('action') or 'Review aggregate usage.'}"
126
+ )
127
+ return "\n".join(lines)
128
+
129
+
130
+ def format_doctor(report: dict[str, Any]) -> str:
131
+ lines = [
132
+ f"Codex Usage Tracker doctor: {str(report.get('status', 'unknown')).upper()}",
133
+ f"Failures: {report.get('failures', 0)} | warnings: {report.get('warnings', 0)}",
134
+ "",
135
+ ]
136
+ for check in report.get("checks", []):
137
+ status = str(check.get("status", "unknown")).upper()
138
+ lines.append(f"- [{status}] {check.get('name')}: {check.get('detail')}")
139
+ remediation = check.get("remediation")
140
+ if remediation:
141
+ lines.append(f" Next: {remediation}")
142
+ suggestions = report.get("repair_suggestions")
143
+ if isinstance(suggestions, list) and suggestions:
144
+ lines.extend(["", "Repair suggestions:"])
145
+ for suggestion in suggestions:
146
+ lines.append(f"- {suggestion}")
147
+ return "\n".join(lines)
148
+
149
+
150
+ def format_pricing_coverage(report: dict[str, Any], limit: int = 20) -> str:
151
+ rows = report.get("rows")
152
+ if not isinstance(rows, list) or not rows:
153
+ return "No Codex usage records found. Run refresh_usage_index first."
154
+
155
+ lines = [
156
+ "Codex pricing coverage",
157
+ "",
158
+ f"Models: {_fmt_int(report.get('model_count'))} "
159
+ f"({_fmt_int(report.get('priced_model_count'))} priced, "
160
+ f"{_fmt_int(report.get('unpriced_model_count'))} unpriced)",
161
+ f"Token coverage: {_fmt_pct(report.get('priced_token_ratio'))} priced "
162
+ f"({_fmt_int(report.get('priced_tokens'))} of {_fmt_int(report.get('total_tokens'))} tokens)",
163
+ f"Estimated total cost: {_fmt_money(report.get('estimated_cost_usd'))}",
164
+ "",
165
+ ]
166
+ for row in rows[:limit]:
167
+ model = row.get("model") or "Unknown"
168
+ status = "unpriced"
169
+ if row.get("pricing_estimated"):
170
+ status = f"estimated as {row.get('priced_as')}"
171
+ elif row.get("priced_as"):
172
+ status = f"priced as {row.get('priced_as')}"
173
+ lines.append(
174
+ f"- {model}: {status}; {_fmt_int(row.get('total_tokens'))} tokens"
175
+ f"{_cost_suffix(row)}"
176
+ )
177
+ return "\n".join(lines)
178
+
179
+
180
+ def _fmt_int(value: object) -> str:
181
+ if not isinstance(value, int | float | str):
182
+ return "0"
183
+ try:
184
+ return f"{int(value):,}"
185
+ except (TypeError, ValueError):
186
+ return "0"
187
+
188
+
189
+ def _fmt_pct(value: object) -> str:
190
+ if not isinstance(value, int | float | str):
191
+ return "0.0%"
192
+ try:
193
+ return f"{float(value) * 100:.1f}%"
194
+ except (TypeError, ValueError):
195
+ return "0.0%"
196
+
197
+
198
+ def _fmt_money(value: object) -> str:
199
+ if not isinstance(value, int | float | str):
200
+ return ""
201
+ try:
202
+ amount = float(value)
203
+ except (TypeError, ValueError):
204
+ return ""
205
+ if amount <= 0:
206
+ return "$0.00"
207
+ if amount < 0.01:
208
+ return f"${amount:.4f}"
209
+ return f"${amount:.2f}"
210
+
211
+
212
+ def _fmt_decimal(value: object) -> str:
213
+ if not isinstance(value, int | float | str):
214
+ return "0.0"
215
+ try:
216
+ return f"{float(value):.1f}"
217
+ except (TypeError, ValueError):
218
+ return "0.0"
219
+
220
+
221
+ def _cost_suffix(row: dict[str, Any], prefix: str = ", estimated cost ") -> str:
222
+ if row.get("estimated_cost_usd") is None:
223
+ return ""
224
+ marker = "*" if row.get("pricing_estimated") else ""
225
+ return f"{prefix}{_fmt_money(row.get('estimated_cost_usd'))}{marker}"
@@ -0,0 +1,350 @@
1
+ """Lightweight JSON payload contracts for CLI and MCP automation surfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ NoneType = type(None)
9
+ Number = (int, float)
10
+ TypeSpec = type | tuple[type, ...]
11
+
12
+ REFRESH_RESULT_FIELDS = {
13
+ "scanned_files": int,
14
+ "parsed_events": int,
15
+ "skipped_events": int,
16
+ "inserted_or_updated_events": int,
17
+ "db_path": str,
18
+ "parser_diagnostics": dict,
19
+ }
20
+
21
+ PLUGIN_INSTALL_FIELDS = {
22
+ "plugin_dir": str,
23
+ "marketplace_path": str,
24
+ "python_executable": str,
25
+ "replaced_existing": bool,
26
+ "restart_required": bool,
27
+ }
28
+
29
+ PATH_CREATED_FIELDS = {
30
+ "created": bool,
31
+ }
32
+
33
+ JSON_PAYLOAD_CONTRACTS: dict[str, dict[str, Any]] = {
34
+ "codex-usage-tracker-setup-v1": {
35
+ "required": {
36
+ "codex_home": str,
37
+ "codex_home_exists": bool,
38
+ "plugin": dict,
39
+ "pricing": dict,
40
+ "refresh": dict,
41
+ "doctor": dict,
42
+ "restart_required": bool,
43
+ }
44
+ },
45
+ "codex-usage-tracker-doctor-v1": {
46
+ "required": {
47
+ "status": str,
48
+ "failures": int,
49
+ "warnings": int,
50
+ "checks": list,
51
+ }
52
+ },
53
+ "codex-usage-tracker-plugin-install-v1": {"required": PLUGIN_INSTALL_FIELDS},
54
+ "codex-usage-tracker-plugin-upgrade-v1": {"required": PLUGIN_INSTALL_FIELDS},
55
+ "codex-usage-tracker-plugin-uninstall-v1": {
56
+ "required": {
57
+ "plugin_dir": str,
58
+ "marketplace_path": str,
59
+ "removed_plugin_path": bool,
60
+ "removed_marketplace_entry": bool,
61
+ "restart_required": bool,
62
+ }
63
+ },
64
+ "codex-usage-tracker-refresh-v1": {"required": REFRESH_RESULT_FIELDS},
65
+ "codex-usage-tracker-rebuild-index-v1": {"required": REFRESH_RESULT_FIELDS},
66
+ "codex-usage-tracker-reset-db-v1": {
67
+ "required": {
68
+ "db_path": str,
69
+ "deleted_usage_events": int,
70
+ }
71
+ },
72
+ "codex-usage-tracker-summary-v1": {
73
+ "required": {
74
+ "group_by": str,
75
+ "is_expensive": bool,
76
+ "privacy_mode": str,
77
+ "row_count": int,
78
+ "rows": list,
79
+ }
80
+ },
81
+ "codex-usage-tracker-query-v1": {
82
+ "required": {
83
+ "filters": dict,
84
+ "row_count": int,
85
+ "total_matched_rows": int,
86
+ "truncated": bool,
87
+ "rows": list,
88
+ },
89
+ "nested": {
90
+ "filters": {
91
+ "since": (str, NoneType),
92
+ "until": (str, NoneType),
93
+ "model": (str, NoneType),
94
+ "effort": (str, NoneType),
95
+ "thread": (str, NoneType),
96
+ "project": (str, NoneType),
97
+ "pricing_status": (str, NoneType),
98
+ "credit_confidence": (str, NoneType),
99
+ "min_tokens": (int, NoneType),
100
+ "min_credits": (int, float, NoneType),
101
+ "limit": (int, NoneType),
102
+ "privacy_mode": str,
103
+ }
104
+ },
105
+ },
106
+ "codex-usage-tracker-recommendations-v1": {
107
+ "required": {
108
+ "filters": dict,
109
+ "row_count": int,
110
+ "total_matched_rows": int,
111
+ "truncated": bool,
112
+ "threads": list,
113
+ "rows": list,
114
+ },
115
+ "nested": {
116
+ "filters": {
117
+ "since": (str, NoneType),
118
+ "until": (str, NoneType),
119
+ "model": (str, NoneType),
120
+ "effort": (str, NoneType),
121
+ "thread": (str, NoneType),
122
+ "project": (str, NoneType),
123
+ "min_score": (int, float, NoneType),
124
+ "limit": (int, NoneType),
125
+ "privacy_mode": str,
126
+ }
127
+ },
128
+ },
129
+ "codex-usage-tracker-session-v1": {
130
+ "required": {
131
+ "requested_session_id": (str, NoneType),
132
+ "resolved_session_id": (str, NoneType),
133
+ "limit": int,
134
+ "privacy_mode": str,
135
+ "row_count": int,
136
+ "rows": list,
137
+ }
138
+ },
139
+ "codex-usage-tracker-context-v1": {
140
+ "required": {
141
+ "loaded_on_demand": bool,
142
+ "raw_context_persisted": bool,
143
+ "include_tool_output": bool,
144
+ "record": dict,
145
+ "source": dict,
146
+ "entries": list,
147
+ "omitted": dict,
148
+ }
149
+ },
150
+ "codex-usage-tracker-context-disabled-v1": {
151
+ "required": {
152
+ "error": str,
153
+ "raw_context_enabled": bool,
154
+ "record_id": str,
155
+ }
156
+ },
157
+ "codex-usage-tracker-dashboard-v1": {
158
+ "required": {
159
+ "dashboard_path": str,
160
+ "file_url": str,
161
+ "opened": bool,
162
+ "limit": (int, NoneType),
163
+ "since": (str, NoneType),
164
+ "privacy_mode": str,
165
+ "include_archived": bool,
166
+ }
167
+ },
168
+ "codex-usage-tracker-open-dashboard-v1": {
169
+ "required": {
170
+ "dashboard_path": str,
171
+ "file_url": str,
172
+ "opened": bool,
173
+ "limit": (int, NoneType),
174
+ "since": (str, NoneType),
175
+ "refresh": (dict, NoneType),
176
+ "privacy_mode": str,
177
+ "include_archived": bool,
178
+ }
179
+ },
180
+ "codex-usage-tracker-serve-dashboard-v1": {
181
+ "required": {
182
+ "host": str,
183
+ "port": int,
184
+ "dashboard_path": str,
185
+ "limit": (int, NoneType),
186
+ "since": (str, NoneType),
187
+ "context_api": str,
188
+ "refresh_before_start": bool,
189
+ "privacy_mode": str,
190
+ "include_archived": bool,
191
+ }
192
+ },
193
+ "codex-usage-tracker-pricing-coverage-v1": {
194
+ "required": {
195
+ "model_count": int,
196
+ "priced_model_count": int,
197
+ "unpriced_model_count": int,
198
+ "total_tokens": Number,
199
+ "priced_tokens": Number,
200
+ "unpriced_tokens": Number,
201
+ "estimated_cost_usd": Number,
202
+ "priced_token_ratio": Number,
203
+ "pricing_loaded": bool,
204
+ "pricing_path": str,
205
+ "pricing_source": (dict, NoneType),
206
+ "rows": list,
207
+ }
208
+ },
209
+ "codex-usage-tracker-export-v1": {
210
+ "required": {
211
+ "rows": int,
212
+ "csv_path": str,
213
+ "limit": (int, NoneType),
214
+ "privacy_mode": str,
215
+ }
216
+ },
217
+ "codex-usage-tracker-init-pricing-v1": {
218
+ "required": {"pricing_path": str, **PATH_CREATED_FIELDS}
219
+ },
220
+ "codex-usage-tracker-update-pricing-v1": {
221
+ "required": {
222
+ "pricing_path": str,
223
+ "source_url": str,
224
+ "tier": str,
225
+ "fetched_at": str,
226
+ "model_count": int,
227
+ "estimated_model_count": int,
228
+ "backup_path": (str, NoneType),
229
+ }
230
+ },
231
+ "codex-usage-tracker-pin-pricing-v1": {
232
+ "required": {
233
+ "pricing_path": str,
234
+ "source_pricing_path": str,
235
+ }
236
+ },
237
+ "codex-usage-tracker-init-allowance-v1": {
238
+ "required": {"allowance_path": str, **PATH_CREATED_FIELDS}
239
+ },
240
+ "codex-usage-tracker-parse-allowance-v1": {
241
+ "required": {
242
+ "allowance_path": str,
243
+ "updated": bool,
244
+ }
245
+ },
246
+ "codex-usage-tracker-update-rate-card-v1": {
247
+ "required": {
248
+ "rate_card_path": str,
249
+ "source_url": (str, NoneType),
250
+ "fetched_at": (str, NoneType),
251
+ "model_count": int,
252
+ "alias_count": int,
253
+ "backup_path": (str, NoneType),
254
+ }
255
+ },
256
+ "codex-usage-tracker-init-thresholds-v1": {
257
+ "required": {"thresholds_path": str, **PATH_CREATED_FIELDS}
258
+ },
259
+ "codex-usage-tracker-init-projects-v1": {
260
+ "required": {"projects_path": str, **PATH_CREATED_FIELDS}
261
+ },
262
+ "codex-usage-tracker-support-bundle-v1": {
263
+ "required": {
264
+ "support_bundle_path": str,
265
+ "privacy": dict,
266
+ },
267
+ "nested": {
268
+ "privacy": {
269
+ "contains_raw_logs": bool,
270
+ "contains_prompts": bool,
271
+ "contains_assistant_messages": bool,
272
+ "contains_tool_output": bool,
273
+ "project_metadata_mode": str,
274
+ }
275
+ },
276
+ },
277
+ }
278
+
279
+
280
+ def known_json_schemas() -> tuple[str, ...]:
281
+ """Return schema identifiers that have a tracked contract."""
282
+
283
+ return tuple(sorted(JSON_PAYLOAD_CONTRACTS))
284
+
285
+
286
+ def validate_json_payload_contract(payload: object) -> list[str]:
287
+ """Return contract validation errors for one CLI or MCP JSON payload."""
288
+
289
+ if not isinstance(payload, Mapping):
290
+ return ["payload must be a JSON object"]
291
+ schema = payload.get("schema")
292
+ if not isinstance(schema, str) or not schema:
293
+ return ["payload.schema must be a non-empty string"]
294
+ contract = JSON_PAYLOAD_CONTRACTS.get(schema)
295
+ if contract is None:
296
+ return [f"payload.schema is not tracked: {schema}"]
297
+
298
+ errors: list[str] = []
299
+ for field, expected in contract.get("required", {}).items():
300
+ if field not in payload:
301
+ errors.append(f"{schema}.{field} is required")
302
+ continue
303
+ if not _matches_type(payload[field], expected):
304
+ errors.append(
305
+ f"{schema}.{field} must be {_describe_type(expected)}, "
306
+ f"got {type(payload[field]).__name__}"
307
+ )
308
+
309
+ for field, nested in contract.get("nested", {}).items():
310
+ value = payload.get(field)
311
+ if not isinstance(value, Mapping):
312
+ continue
313
+ for nested_field, expected in nested.items():
314
+ if nested_field not in value:
315
+ errors.append(f"{schema}.{field}.{nested_field} is required")
316
+ continue
317
+ if not _matches_type(value[nested_field], expected):
318
+ errors.append(
319
+ f"{schema}.{field}.{nested_field} must be {_describe_type(expected)}, "
320
+ f"got {type(value[nested_field]).__name__}"
321
+ )
322
+ return errors
323
+
324
+
325
+ def _matches_type(value: object, expected: object) -> bool:
326
+ if expected is Number:
327
+ return isinstance(value, int | float) and not isinstance(value, bool)
328
+ if isinstance(expected, tuple):
329
+ return any(_matches_type(value, item) for item in expected)
330
+ if expected is int:
331
+ return isinstance(value, int) and not isinstance(value, bool)
332
+ if expected is float:
333
+ return isinstance(value, float) and not isinstance(value, bool)
334
+ if expected is bool:
335
+ return isinstance(value, bool)
336
+ if isinstance(expected, type):
337
+ return isinstance(value, expected)
338
+ return False
339
+
340
+
341
+ def _describe_type(expected: object) -> str:
342
+ if expected is Number:
343
+ return "number"
344
+ if isinstance(expected, tuple):
345
+ return " or ".join(_describe_type(item) for item in expected)
346
+ if expected is NoneType:
347
+ return "null"
348
+ if isinstance(expected, type):
349
+ return expected.__name__
350
+ return str(expected)