cmpnd 0.23.1__tar.gz → 0.23.2__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 (310) hide show
  1. {cmpnd-0.23.1 → cmpnd-0.23.2}/PKG-INFO +1 -1
  2. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/callback.py +260 -46
  3. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/models.py +89 -1
  4. {cmpnd-0.23.1 → cmpnd-0.23.2}/docs/sdk-instrumentation.md +28 -2
  5. {cmpnd-0.23.1 → cmpnd-0.23.2}/pyproject.toml +1 -1
  6. {cmpnd-0.23.1 → cmpnd-0.23.2}/scripts/test-in-linux.sh +6 -6
  7. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_exporter.py +67 -1
  8. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_lm_usage_shapes.py +225 -0
  9. {cmpnd-0.23.1 → cmpnd-0.23.2}/uv.lock +1 -1
  10. {cmpnd-0.23.1 → cmpnd-0.23.2}/.gitignore +0 -0
  11. {cmpnd-0.23.1 → cmpnd-0.23.2}/CLAUDE.md +0 -0
  12. {cmpnd-0.23.1 → cmpnd-0.23.2}/README.md +0 -0
  13. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/__init__.py +0 -0
  14. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/_exporter_pool.py +0 -0
  15. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/_gepa_patch.py +0 -0
  16. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/_http.py +0 -0
  17. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/_program_patch.py +0 -0
  18. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/_rlm_patch.py +0 -0
  19. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/_timespec.py +0 -0
  20. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/__init__.py +0 -0
  21. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/api_client.py +0 -0
  22. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/__init__.py +0 -0
  23. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/admin.py +0 -0
  24. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/assessments.py +0 -0
  25. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/auth.py +0 -0
  26. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/datasets.py +0 -0
  27. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/deployments.py +0 -0
  28. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/evals.py +0 -0
  29. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/login.py +0 -0
  30. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/optimizations.py +0 -0
  31. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/org.py +0 -0
  32. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/programs.py +0 -0
  33. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/provider_credentials.py +0 -0
  34. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/signatures.py +0 -0
  35. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/tool_servers.py +0 -0
  36. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/top.py +0 -0
  37. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/traces.py +0 -0
  38. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/commands/valsets.py +0 -0
  39. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/credentials.py +0 -0
  40. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/deprecation.py +0 -0
  41. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/pagination.py +0 -0
  42. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/shell.py +0 -0
  43. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/trace_render.py +0 -0
  44. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/cli/verbs.py +0 -0
  45. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/client.py +0 -0
  46. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/configuration.py +0 -0
  47. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/context.py +0 -0
  48. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/dataset_sync.py +0 -0
  49. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/datasets.py +0 -0
  50. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/decorators.py +0 -0
  51. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/deployment.py +0 -0
  52. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/eval_handler.py +0 -0
  53. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/execution.py +0 -0
  54. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/exporter.py +0 -0
  55. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/exporter_http.py +0 -0
  56. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/helpers.py +0 -0
  57. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/identity.py +0 -0
  58. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/imaging.py +0 -0
  59. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/ir/__init__.py +0 -0
  60. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/ir/decode.py +0 -0
  61. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/ir/encode.py +0 -0
  62. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/ir/hash.py +0 -0
  63. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/ir/program.py +0 -0
  64. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/ir/reflection.py +0 -0
  65. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/ir/types.py +0 -0
  66. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/ir/xxh64.py +0 -0
  67. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/mcp_servers.py +0 -0
  68. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/optimization.py +0 -0
  69. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/optimizers/__init__.py +0 -0
  70. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/optimizers/gepa.py +0 -0
  71. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/optimizers/gepa_callback.py +0 -0
  72. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/optimizers/resume.py +0 -0
  73. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/optimizers/tracker.py +0 -0
  74. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/packaging.py +0 -0
  75. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/retry.py +0 -0
  76. {cmpnd-0.23.1 → cmpnd-0.23.2}/cmpnd/traces.py +0 -0
  77. {cmpnd-0.23.1 → cmpnd-0.23.2}/docs/2026-04-02-code-review.md +0 -0
  78. {cmpnd-0.23.1 → cmpnd-0.23.2}/docs/cli.md +0 -0
  79. {cmpnd-0.23.1 → cmpnd-0.23.2}/docs/client.md +0 -0
  80. {cmpnd-0.23.1 → cmpnd-0.23.2}/docs/custom-proposer.md +0 -0
  81. {cmpnd-0.23.1 → cmpnd-0.23.2}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  82. {cmpnd-0.23.1 → cmpnd-0.23.2}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  83. {cmpnd-0.23.1 → cmpnd-0.23.2}/docs/reading-traces.md +0 -0
  84. {cmpnd-0.23.1 → cmpnd-0.23.2}/examples/generalizing_proposer.py +0 -0
  85. {cmpnd-0.23.1 → cmpnd-0.23.2}/examples/local_ollama.py +0 -0
  86. {cmpnd-0.23.1 → cmpnd-0.23.2}/ingest/LINEAGE.md +0 -0
  87. {cmpnd-0.23.1 → cmpnd-0.23.2}/ingest/README.md +0 -0
  88. {cmpnd-0.23.1 → cmpnd-0.23.2}/ingest/cmpnd_ingest/__init__.py +0 -0
  89. {cmpnd-0.23.1 → cmpnd-0.23.2}/ingest/cmpnd_ingest/bodies.py +0 -0
  90. {cmpnd-0.23.1 → cmpnd-0.23.2}/ingest/cmpnd_ingest/exporter_http.py +0 -0
  91. {cmpnd-0.23.1 → cmpnd-0.23.2}/ingest/cmpnd_ingest/protocols.py +0 -0
  92. {cmpnd-0.23.1 → cmpnd-0.23.2}/ingest/cmpnd_ingest/py.typed +0 -0
  93. {cmpnd-0.23.1 → cmpnd-0.23.2}/ingest/cmpnd_ingest/request_log.py +0 -0
  94. {cmpnd-0.23.1 → cmpnd-0.23.2}/ingest/cmpnd_ingest/retry.py +0 -0
  95. {cmpnd-0.23.1 → cmpnd-0.23.2}/ingest/pyproject.toml +0 -0
  96. {cmpnd-0.23.1 → cmpnd-0.23.2}/scripts/audit-sdist.sh +0 -0
  97. {cmpnd-0.23.1 → cmpnd-0.23.2}/scripts/capture_review_run.py +0 -0
  98. {cmpnd-0.23.1 → cmpnd-0.23.2}/scripts/check_release_artifacts.py +0 -0
  99. {cmpnd-0.23.1 → cmpnd-0.23.2}/scripts/fixtures/review_run.json.gz +0 -0
  100. {cmpnd-0.23.1 → cmpnd-0.23.2}/scripts/gepa_resume_bug.py +0 -0
  101. {cmpnd-0.23.1 → cmpnd-0.23.2}/scripts/require_version_bump.py +0 -0
  102. {cmpnd-0.23.1 → cmpnd-0.23.2}/scripts/seed_review_run.py +0 -0
  103. {cmpnd-0.23.1 → cmpnd-0.23.2}/stubs/dspy/__init__.pyi +0 -0
  104. {cmpnd-0.23.1 → cmpnd-0.23.2}/stubs/dspy/primitives/__init__.pyi +0 -0
  105. {cmpnd-0.23.1 → cmpnd-0.23.2}/stubs/dspy/primitives/prediction.pyi +0 -0
  106. {cmpnd-0.23.1 → cmpnd-0.23.2}/stubs/dspy/utils/__init__.pyi +0 -0
  107. {cmpnd-0.23.1 → cmpnd-0.23.2}/stubs/dspy/utils/callback.pyi +0 -0
  108. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/__init__.py +0 -0
  109. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/_exporter_helpers.py +0 -0
  110. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/_fake_clock.py +0 -0
  111. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/_identity_helpers.py +0 -0
  112. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/conftest.py +0 -0
  113. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/__init__.py +0 -0
  114. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/committee_task.py +0 -0
  115. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/conftest.py +0 -0
  116. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/data/committee_train.ndjson.gz +0 -0
  117. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/data/committee_val.ndjson.gz +0 -0
  118. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/fake_lm.py +0 -0
  119. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/helpers.py +0 -0
  120. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/target_capabilities.py +0 -0
  121. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_assessments_cli.py +0 -0
  122. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_capture_prompts.py +0 -0
  123. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_composite_module.py +0 -0
  124. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_dataset_auto_creation.py +0 -0
  125. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_deploy_execute.py +0 -0
  126. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_deploy_programs.py +0 -0
  127. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_edge_cases.py +0 -0
  128. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_example_hash.py +0 -0
  129. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_gepa_optimization.py +0 -0
  130. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_interpreter_spans.py +0 -0
  131. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_lm_accuracy.py +0 -0
  132. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_module_tracing.py +0 -0
  133. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_optimization_lineage.py +0 -0
  134. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_optimization_metric_identity.py +0 -0
  135. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_optimize_failures.py +0 -0
  136. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_optimize_loop.py +0 -0
  137. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_optimize_review_committee.py +0 -0
  138. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
  139. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_parallel_export.py +0 -0
  140. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_project_attribution.py +0 -0
  141. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_react_tool_spans.py +0 -0
  142. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_save_load.py +0 -0
  143. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_trace_structure.py +0 -0
  144. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_traces_read.py +0 -0
  145. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_user_attribution.py +0 -0
  146. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/e2e/test_valsets_cli.py +0 -0
  147. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/01_predict_qa.json +0 -0
  148. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  149. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  150. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  151. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  152. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  153. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/07_program_of_thought.json +0 -0
  154. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/08_opaque_module.json +0 -0
  155. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  156. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/10_react.json +0 -0
  157. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/11_refine.json +0 -0
  158. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/12_retrieve.json +0 -0
  159. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/13_knn.json +0 -0
  160. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/14_parallel.json +0 -0
  161. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/identity_vectors/README.md +0 -0
  162. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/ingest_conformance.py +0 -0
  163. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/integration/__init__.py +0 -0
  164. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/integration/conftest.py +0 -0
  165. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/integration/hosted_optimize_probe.py +0 -0
  166. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/integration/optimization_run_probe.py +0 -0
  167. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  168. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/integration/test_logs_renders_traces.py +0 -0
  169. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/integration/test_optimization_run.py +0 -0
  170. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/integration/test_optimizing_progress.py +0 -0
  171. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/integration/test_unhappy_path.py +0 -0
  172. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/load/__init__.py +0 -0
  173. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/load/__main__.py +0 -0
  174. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/load/drain.py +0 -0
  175. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/load/ingest.py +0 -0
  176. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/load/latency_transport.py +0 -0
  177. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/load/rlm_shape.py +0 -0
  178. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/load/sweep.py +0 -0
  179. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/load/test_rlm_shape.py +0 -0
  180. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/__init__.py +0 -0
  181. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/conftest.py +0 -0
  182. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/seed_parity.py +0 -0
  183. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/test_auth.py +0 -0
  184. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/test_datasets.py +0 -0
  185. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/test_deployments.py +0 -0
  186. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/test_evals.py +0 -0
  187. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/test_health.py +0 -0
  188. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/test_modules.py +0 -0
  189. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/test_optimizations.py +0 -0
  190. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/test_programs.py +0 -0
  191. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/test_signatures.py +0 -0
  192. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/test_trace_streaming.py +0 -0
  193. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/parity/test_traces.py +0 -0
  194. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/__init__.py +0 -0
  195. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/_provider.py +0 -0
  196. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/_reliability.py +0 -0
  197. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/_result.py +0 -0
  198. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/mint_bedrock_token.py +0 -0
  199. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/test_deploy_programs.py +0 -0
  200. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/test_multi_client_stress.py +0 -0
  201. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/test_optimize_client_committee.py +0 -0
  202. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/test_optimize_loop.py +0 -0
  203. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/test_optimize_loop_wasm.py +0 -0
  204. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/test_optimize_stress.py +0 -0
  205. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/test_provider_roles.py +0 -0
  206. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/test_real_provider_twins.py +0 -0
  207. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/test_reliability.py +0 -0
  208. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/test_reliability_classify.py +0 -0
  209. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/test_rlm_wasm.py +0 -0
  210. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/stress/test_trace_search.py +0 -0
  211. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_callback.py +0 -0
  212. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_candidate_lineage.py +0 -0
  213. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/__init__.py +0 -0
  214. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/conftest.py +0 -0
  215. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_admin.py +0 -0
  216. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_api_client.py +0 -0
  217. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_assessments.py +0 -0
  218. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_auth.py +0 -0
  219. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_credentials.py +0 -0
  220. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_datasets.py +0 -0
  221. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_deployments.py +0 -0
  222. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_evals.py +0 -0
  223. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_login.py +0 -0
  224. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_main.py +0 -0
  225. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_optimizations.py +0 -0
  226. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_org.py +0 -0
  227. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_pagination.py +0 -0
  228. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_parity.py +0 -0
  229. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_programs.py +0 -0
  230. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_provider_credentials.py +0 -0
  231. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_schema_sample.py +0 -0
  232. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_shell.py +0 -0
  233. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_signatures.py +0 -0
  234. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_tool_servers.py +0 -0
  235. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_top.py +0 -0
  236. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_traces.py +0 -0
  237. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_traces_search_wire.py +0 -0
  238. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cli/test_valsets.py +0 -0
  239. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_client.py +0 -0
  240. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_clustering/__init__.py +0 -0
  241. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_clustering/conftest.py +0 -0
  242. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_clustering/test_edge_cases.py +0 -0
  243. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_clustering/test_hash_consistency.py +0 -0
  244. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_clustering/test_type_conversion.py +0 -0
  245. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_cmp551_grouping_identity.py +0 -0
  246. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_configuration.py +0 -0
  247. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_context.py +0 -0
  248. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_context_propagation.py +0 -0
  249. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_dataset_polling.py +0 -0
  250. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_dataset_sync.py +0 -0
  251. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_datasets.py +0 -0
  252. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_decorators.py +0 -0
  253. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_deploy.py +0 -0
  254. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_deploy_mcp_tool_references.py +0 -0
  255. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_eval.py +0 -0
  256. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_eval_example_attribution.py +0 -0
  257. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_eval_start.py +0 -0
  258. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_eval_trace_grouping.py +0 -0
  259. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_exception_paths.py +0 -0
  260. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_execute.py +0 -0
  261. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_exporter_queue.py +0 -0
  262. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_exporter_real.py +0 -0
  263. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_fake_lm.py +0 -0
  264. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_gepa_callback.py +0 -0
  265. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_gepa_integration.py +0 -0
  266. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_gepa_resume.py +0 -0
  267. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_identity.py +0 -0
  268. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_imaging.py +0 -0
  269. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_import_weight.py +0 -0
  270. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_ingest_conformance.py +0 -0
  271. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_ingest_second_producer.py +0 -0
  272. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_integration.py +0 -0
  273. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_interpreter_callback.py +0 -0
  274. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_ir_encoder_properties.py +0 -0
  275. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_ir_golden_vectors.py +0 -0
  276. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_ir_roundtrip.py +0 -0
  277. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_mcp_tools.py +0 -0
  278. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_models.py +0 -0
  279. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_optimization_progress.py +0 -0
  280. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_optimization_start.py +0 -0
  281. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_optimize.py +0 -0
  282. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_optimize_budget.py +0 -0
  283. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_optimize_lift.py +0 -0
  284. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_optimize_lms.py +0 -0
  285. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_optimize_proposer.py +0 -0
  286. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_optimize_reflection_lm.py +0 -0
  287. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_optimize_terminal_failure.py +0 -0
  288. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_packaging.py +0 -0
  289. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_packaging_callable_source.py +0 -0
  290. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_packaging_mcp_tools.py +0 -0
  291. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_packaging_program_source.py +0 -0
  292. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_poll_until.py +0 -0
  293. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_program_lineage_persist.py +0 -0
  294. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_project_payload.py +0 -0
  295. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_reflector_properties.py +0 -0
  296. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_reflector_smoke.py +0 -0
  297. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_repro_reactv2_bugs.py +0 -0
  298. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_require_version_bump.py +0 -0
  299. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_retry.py +0 -0
  300. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_reversible_failures.py +0 -0
  301. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_rlm_patch.py +0 -0
  302. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_schemas.py +0 -0
  303. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_seed_review_run.py +0 -0
  304. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_trace_render.py +0 -0
  305. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_traces_read.py +0 -0
  306. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_tracked_gepa.py +0 -0
  307. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_tracker_enrich.py +0 -0
  308. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_truncation.py +0 -0
  309. {cmpnd-0.23.1 → cmpnd-0.23.2}/tests/test_xxh64.py +0 -0
  310. {cmpnd-0.23.1 → cmpnd-0.23.2}/todo.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cmpnd
3
- Version: 0.23.1
3
+ Version: 0.23.2
4
4
  Summary: DSPy observability and deployment SDK for cmpnd
5
5
  Project-URL: Homepage, https://cmpnd.ai
6
6
  Author-email: cmpnd <hello@cmpnd.ai>
@@ -181,6 +181,27 @@ def _normalize_usage(usage: Any) -> dict[str, int | None]:
181
181
  columns behind them are ``NOT NULL``). ``reasoning_tokens`` and
182
182
  ``cache_write_tokens`` come back as ``None`` when the provider said nothing.
183
183
  """
184
+ evidence = _normalize_usage_evidence(usage)
185
+ prompt = evidence["prompt_tokens"]
186
+ completion = evidence["completion_tokens"]
187
+ total = evidence["total_tokens"]
188
+ if total is None and (prompt is not None or completion is not None):
189
+ # The legacy span projection has always derived a total. The evidence
190
+ # object keeps an omitted total as null and preserves the raw block.
191
+ total = (prompt or 0) + (completion or 0)
192
+
193
+ return {
194
+ "prompt_tokens": prompt or 0,
195
+ "completion_tokens": completion or 0,
196
+ "total_tokens": total or 0,
197
+ "cached_prompt_tokens": evidence["cache_read_tokens"] or 0,
198
+ "reasoning_tokens": evidence["reasoning_tokens"],
199
+ "cache_write_tokens": evidence["cache_write_tokens"],
200
+ }
201
+
202
+
203
+ def _normalize_usage_evidence(usage: Any) -> dict[str, int | None]:
204
+ """Normalize queryable counters without flattening absence into zero."""
184
205
  prompt = _reported_count(
185
206
  _usage_field(usage, "prompt_tokens"),
186
207
  _usage_field(usage, "input_tokens"),
@@ -190,11 +211,6 @@ def _normalize_usage(usage: Any) -> dict[str, int | None]:
190
211
  _usage_field(usage, "output_tokens"),
191
212
  )
192
213
  total = _reported_count(_usage_field(usage, "total_tokens"))
193
- if total is None and (prompt is not None or completion is not None):
194
- # Derived only where the provider omitted it — a reported total is
195
- # evidence and stays as sent, even when the split disagrees with it.
196
- total = (prompt or 0) + (completion or 0)
197
-
198
214
  cached = _reported_count(
199
215
  _usage_detail(usage, "prompt_tokens_details", "cached_tokens"),
200
216
  _usage_detail(usage, "input_tokens_details", "cached_tokens"),
@@ -209,20 +225,159 @@ def _normalize_usage(usage: Any) -> dict[str, int | None]:
209
225
  cache_write = _reported_count(
210
226
  _usage_field(usage, "cache_write_tokens"),
211
227
  _usage_field(usage, "cache_creation_input_tokens"),
228
+ _usage_detail(usage, "prompt_tokens_details", "cache_creation_tokens"),
229
+ )
230
+ cache_write_5m = _reported_count(
231
+ _usage_detail(usage, "cache_creation", "ephemeral_5m_input_tokens"),
232
+ _usage_detail(usage, "cache_creation_token_details", "ephemeral_5m_input_tokens"),
233
+ )
234
+ cache_write_1h = _reported_count(
235
+ _usage_detail(usage, "cache_creation", "ephemeral_1h_input_tokens"),
236
+ _usage_detail(usage, "cache_creation_token_details", "ephemeral_1h_input_tokens"),
237
+ )
238
+ audio_prompt = _reported_count(
239
+ _usage_detail(usage, "prompt_tokens_details", "audio_tokens"),
240
+ _usage_detail(usage, "input_tokens_details", "audio_tokens"),
241
+ _usage_field(usage, "input_audio_tokens"),
242
+ _usage_field(usage, "audio_prompt_tokens"),
243
+ )
244
+ audio_completion = _reported_count(
245
+ _usage_detail(usage, "completion_tokens_details", "audio_tokens"),
246
+ _usage_detail(usage, "output_tokens_details", "audio_tokens"),
247
+ _usage_field(usage, "output_audio_tokens"),
248
+ _usage_field(usage, "audio_completion_tokens"),
212
249
  )
213
250
 
214
251
  return {
215
- "prompt_tokens": prompt or 0,
216
- "completion_tokens": completion or 0,
217
- "total_tokens": total or 0,
218
- # A subset of prompt_tokens: every dialect above counts its cache reads
219
- # inside the prompt total, so the subset invariant holds by construction.
220
- "cached_prompt_tokens": cached or 0,
252
+ "prompt_tokens": prompt,
253
+ "completion_tokens": completion,
254
+ "total_tokens": total,
255
+ "cache_read_tokens": cached,
221
256
  "reasoning_tokens": reasoning,
222
257
  "cache_write_tokens": cache_write,
258
+ "cache_write_5m_tokens": cache_write_5m,
259
+ "cache_write_1h_tokens": cache_write_1h,
260
+ "audio_prompt_tokens": audio_prompt,
261
+ "audio_completion_tokens": audio_completion,
223
262
  }
224
263
 
225
264
 
265
+ _MISSING = object()
266
+
267
+
268
+ def _value_field(value: Any, key: str) -> Any:
269
+ """Read a field from a mapping, DSPy model, or response object."""
270
+ if value is None:
271
+ return None
272
+ if isinstance(value, dict):
273
+ return value.get(key)
274
+ getter = getattr(value, "get", None)
275
+ if callable(getter):
276
+ try:
277
+ found = getter(key, _MISSING)
278
+ except TypeError:
279
+ try:
280
+ found = getter(key)
281
+ except (KeyError, TypeError):
282
+ found = _MISSING
283
+ if found is not _MISSING:
284
+ return found
285
+ return getattr(value, key, None)
286
+
287
+
288
+ def _text_field(value: Any, key: str) -> str | None:
289
+ found = _value_field(value, key)
290
+ if isinstance(found, str) and found:
291
+ return found
292
+ return None
293
+
294
+
295
+ def _bool_field(value: Any, key: str) -> bool | None:
296
+ found = _value_field(value, key)
297
+ return found if isinstance(found, bool) else None
298
+
299
+
300
+ def _present_field(value: Any, key: str) -> tuple[bool, Any]:
301
+ """Read a field while preserving whether the object declared it."""
302
+ if isinstance(value, dict):
303
+ return key in value, value.get(key)
304
+ model_fields = getattr(type(value), "model_fields", None)
305
+ if isinstance(model_fields, dict) and key in model_fields:
306
+ return True, getattr(value, key, None)
307
+ values = getattr(value, "__dict__", None)
308
+ if isinstance(values, dict) and key in values:
309
+ return True, values.get(key)
310
+ return False, None
311
+
312
+
313
+ def _history_usage(entry: Any) -> tuple[bool, Any]:
314
+ """Return whether history carried usage and the value it carried."""
315
+ response = _value_field(entry, "response")
316
+ response_declares_usage, response_usage = _present_field(response, "usage")
317
+ if response_declares_usage:
318
+ return response_usage is not None, response_usage
319
+ if isinstance(entry, dict):
320
+ usage = entry.get("usage")
321
+ return "usage" in entry and usage is not None, usage
322
+ usage = _value_field(entry, "usage")
323
+ return usage is not None, usage
324
+
325
+
326
+ def _usage_dialect(instance: Any, usage: Any) -> str | None:
327
+ """Name the response surface only when its shape establishes one."""
328
+ model_type = _text_field(instance, "model_type")
329
+ if model_type == "responses":
330
+ return "responses"
331
+ if model_type in {"chat", "text"}:
332
+ return "chat_completions"
333
+ if _usage_field(usage, "inputTokens") is not None or _usage_field(usage, "outputTokens") is not None:
334
+ return "converse"
335
+ if _usage_field(usage, "prompt_tokens") is not None or _usage_field(usage, "completion_tokens") is not None:
336
+ return "chat_completions"
337
+ return None
338
+
339
+
340
+ def _cache_ttl_requested(*values: Any) -> str | None:
341
+ """Return one unambiguous requested cache TTL from request data."""
342
+ found: set[str] = set()
343
+
344
+ def visit(value: Any, in_cache_control: bool = False) -> None:
345
+ if isinstance(value, dict):
346
+ for key, child in value.items():
347
+ if key in {"cache_ttl", "cache_ttl_requested"} and child is not None:
348
+ found.add(str(child))
349
+ elif in_cache_control and key == "ttl" and child is not None:
350
+ found.add(str(child))
351
+ else:
352
+ visit(child, in_cache_control or key == "cache_control")
353
+ elif isinstance(value, (list, tuple)):
354
+ for child in value:
355
+ visit(child, in_cache_control)
356
+
357
+ for value in values:
358
+ visit(value)
359
+ return next(iter(found)) if len(found) == 1 else None
360
+
361
+
362
+ def _request_selector(entry: Any, instance: Any, key: str) -> str | None:
363
+ history_kwargs = _value_field(entry, "kwargs")
364
+ return _text_field(history_kwargs, key) or _text_field(getattr(instance, "kwargs", None), key)
365
+
366
+
367
+ def _response_text(response: Any, key: str) -> str | None:
368
+ """Read provider response metadata from DSPy's supported containers."""
369
+ for container in (
370
+ response,
371
+ _value_field(response, "provider_data"),
372
+ _value_field(response, "metadata"),
373
+ _value_field(response, "_hidden_params"),
374
+ ):
375
+ found = _text_field(container, key)
376
+ if found:
377
+ return found
378
+ return None
379
+
380
+
226
381
  def _finish_reason(response: Any) -> str | None:
227
382
  """Read why the model stopped, from whichever response shape answered.
228
383
 
@@ -991,6 +1146,13 @@ class _LMMixin(_CallbackBase):
991
1146
  lm_kwargs = {
992
1147
  k: v for k, v in getattr(instance, "kwargs", {}).items() if k not in {"api_key", "api_base", "api_secret"}
993
1148
  }
1149
+ span.lm_usage = models.LMUsageObservationV1(
1150
+ sdk_call_id=call_id,
1151
+ model_requested=span.model_name,
1152
+ service_tier_requested=_text_field(lm_kwargs, "service_tier"),
1153
+ cache_ttl_requested=_cache_ttl_requested(lm_kwargs, inputs),
1154
+ binding=_text_field(instance, "binding"),
1155
+ )
994
1156
  span.attributes.update(
995
1157
  {
996
1158
  "lm.model_type": getattr(instance, "model_type", "unknown"),
@@ -1027,38 +1189,95 @@ class _LMMixin(_CallbackBase):
1027
1189
 
1028
1190
  span.end_time = datetime.now(timezone.utc)
1029
1191
 
1192
+ latest_entry: Any = None
1193
+ response: Any = None
1194
+ usage: Any = None
1195
+ usage_reported = False
1196
+ if instance and hasattr(instance, "history") and instance.history:
1197
+ try:
1198
+ latest_entry = instance.history[-1]
1199
+ usage_reported, usage = _history_usage(latest_entry)
1200
+ response = _value_field(latest_entry, "response")
1201
+ logger.debug(
1202
+ f"LM history usage for {span.model_name}: "
1203
+ f"history_len={len(instance.history)}, "
1204
+ f"usage_type={type(usage).__name__}, "
1205
+ f"usage={usage}"
1206
+ )
1207
+ except (IndexError, AttributeError, TypeError) as e:
1208
+ logger.debug(f"LM history access failed: {e}")
1209
+ else:
1210
+ has_hist = hasattr(instance, "history") if instance else False
1211
+ hist_len = len(instance.history) if has_hist else "N/A"
1212
+ logger.debug(
1213
+ f"LM history unavailable: instance={instance is not None}, "
1214
+ f"has_history={has_hist}, history_len={hist_len}"
1215
+ )
1216
+
1217
+ if not usage_reported and isinstance(outputs, dict) and "usage" in outputs:
1218
+ usage = outputs.get("usage")
1219
+ usage_reported = usage is not None
1220
+ if response is None:
1221
+ response = outputs
1222
+
1223
+ observation = span.lm_usage
1224
+ if observation is not None:
1225
+ observation.observed_at = span.end_time
1226
+ observation.outcome = "success" if exception is None else None
1227
+ cache_hit = _bool_field(response, "cache_hit")
1228
+ if cache_hit is None:
1229
+ cache_hit = _bool_field(_value_field(response, "_hidden_params"), "cache_hit")
1230
+ observation.dspy_client_cache_hit = cache_hit
1231
+ observation.usage_reported = usage_reported and cache_hit is not True
1232
+ serialized_usage = models._safe_serialize(usage)
1233
+ observation.usage_raw = (
1234
+ serialized_usage if observation.usage_reported and isinstance(serialized_usage, dict) else None
1235
+ )
1236
+ observation.usage_dialect = _usage_dialect(instance, usage)
1237
+ if observation.usage_dialect in {"chat_completions", "responses"}:
1238
+ observation.prompt_inclusion = "includes_cache_reads"
1239
+ elif observation.usage_dialect == "converse":
1240
+ observation.prompt_inclusion = "excludes_cache_reads"
1241
+
1242
+ if usage_reported and usage is not None:
1243
+ evidence = _normalize_usage_evidence(usage)
1244
+ for name, value in evidence.items():
1245
+ setattr(observation, name, value)
1246
+ if (
1247
+ observation.prompt_tokens is not None
1248
+ and observation.cache_read_tokens is not None
1249
+ and observation.prompt_inclusion == "includes_cache_reads"
1250
+ and observation.cache_read_tokens <= observation.prompt_tokens
1251
+ ):
1252
+ observation.fresh_input_tokens = observation.prompt_tokens - observation.cache_read_tokens
1253
+ elif observation.prompt_tokens is not None and observation.prompt_inclusion == "excludes_cache_reads":
1254
+ observation.fresh_input_tokens = observation.prompt_tokens
1255
+
1256
+ requested_model = _text_field(latest_entry, "model")
1257
+ if requested_model:
1258
+ observation.model_requested = requested_model
1259
+ reported_model = _text_field(latest_entry, "response_model") or _text_field(response, "model")
1260
+ if reported_model:
1261
+ observation.model_reported = reported_model
1262
+ requested_tier = _request_selector(latest_entry, instance, "service_tier")
1263
+ if requested_tier:
1264
+ observation.service_tier_requested = requested_tier
1265
+ requested_ttl = _cache_ttl_requested(_value_field(latest_entry, "kwargs"))
1266
+ if requested_ttl:
1267
+ observation.cache_ttl_requested = requested_ttl
1268
+ effective_tier = _response_text(response, "service_tier")
1269
+ if effective_tier:
1270
+ observation.service_tier_effective = effective_tier
1271
+ observation.provider_response_id = _response_text(response, "response_id") or _response_text(response, "id")
1272
+ observation.egress_call_id = _response_text(response, "egress_call_id") or _text_field(
1273
+ latest_entry, "egress_call_id"
1274
+ )
1275
+
1030
1276
  if exception:
1031
1277
  span.set_error(exception)
1032
1278
  else:
1033
1279
  span.status = models.TraceStatus.OK
1034
1280
 
1035
- # Extract token usage - try LM's history first (where DSPy stores it),
1036
- # then fall back to outputs dict (for backwards compatibility / testing)
1037
- usage = {}
1038
- if instance and hasattr(instance, "history") and instance.history:
1039
- try:
1040
- latest_entry = instance.history[-1]
1041
- usage = latest_entry.get("usage", {})
1042
- logger.debug(
1043
- f"LM history usage for {span.model_name}: "
1044
- f"history_len={len(instance.history)}, "
1045
- f"usage_type={type(usage).__name__}, "
1046
- f"usage={usage}"
1047
- )
1048
- except (IndexError, AttributeError, TypeError) as e:
1049
- logger.debug(f"LM history access failed: {e}")
1050
- else:
1051
- has_hist = hasattr(instance, "history") if instance else False
1052
- hist_len = len(instance.history) if has_hist else "N/A"
1053
- logger.debug(
1054
- f"LM history unavailable: instance={instance is not None}, "
1055
- f"has_history={has_hist}, history_len={hist_len}"
1056
- )
1057
-
1058
- # Fallback: check outputs dict for usage (legacy / test compatibility)
1059
- if not usage and isinstance(outputs, dict):
1060
- usage = outputs.get("usage", {})
1061
-
1062
1281
  if usage:
1063
1282
  counts = _normalize_usage(usage)
1064
1283
  span.prompt_tokens = counts["prompt_tokens"] or 0
@@ -1072,14 +1291,9 @@ class _LMMixin(_CallbackBase):
1072
1291
  logger.debug(f"NO usage data for {span.model_name} (call_id={call_id})")
1073
1292
 
1074
1293
  # Extract finish_reason from LM history response
1075
- if instance and hasattr(instance, "history") and instance.history:
1076
- try:
1077
- latest_entry = instance.history[-1]
1078
- finish_reason = _finish_reason(latest_entry.get("response"))
1079
- if finish_reason:
1080
- span.set_attribute("lm.finish_reason", finish_reason)
1081
- except (IndexError, AttributeError, TypeError):
1082
- pass
1294
+ finish_reason = _finish_reason(response)
1295
+ if finish_reason:
1296
+ span.set_attribute("lm.finish_reason", finish_reason)
1083
1297
 
1084
1298
  # Capture response outputs (truncated)
1085
1299
  if outputs and self._capture_outputs:
@@ -2,13 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import json
6
7
  import re
7
8
  from dataclasses import dataclass, field
8
9
  from datetime import datetime, timezone
9
10
  from enum import Enum
10
11
  from typing import Any
11
- from uuid import UUID
12
+ from uuid import UUID, uuid4
12
13
 
13
14
  from . import identity, imaging
14
15
 
@@ -412,6 +413,91 @@ class TraceStatus(str, Enum):
412
413
  ERROR = "error"
413
414
 
414
415
 
416
+ @dataclass
417
+ class LMUsageObservationV1:
418
+ """Provider-usage evidence attached to one SDK-observed LM call.
419
+
420
+ Optional counters preserve the distinction between an absent measurement
421
+ and a reported zero. ``provider_cost`` accepts only a provider-origin amount.
422
+ DSPy and LiteLLM calculated costs do not belong here.
423
+ """
424
+
425
+ source_observation_id: str = field(default_factory=lambda: str(uuid4()))
426
+ observed_at: datetime | None = None
427
+ usage_reported: bool = False
428
+ usage_raw: dict[str, Any] | None = None
429
+ usage_dialect: str | None = None
430
+ normalization_version: int = 1
431
+ prompt_inclusion: str | None = None
432
+ model_requested: str | None = None
433
+ model_reported: str | None = None
434
+ service_tier_requested: str | None = None
435
+ service_tier_effective: str | None = None
436
+ cache_ttl_requested: str | None = None
437
+ provider_cost: str | None = None
438
+ provider_cost_currency: str | None = None
439
+ dspy_client_cache_hit: bool | None = None
440
+ binding: str | None = None
441
+ sdk_call_id: str | None = None
442
+ provider_response_id: str | None = None
443
+ egress_call_id: str | None = None
444
+ prompt_tokens: int | None = None
445
+ completion_tokens: int | None = None
446
+ total_tokens: int | None = None
447
+ fresh_input_tokens: int | None = None
448
+ cache_read_tokens: int | None = None
449
+ cache_write_tokens: int | None = None
450
+ cache_write_5m_tokens: int | None = None
451
+ cache_write_1h_tokens: int | None = None
452
+ reasoning_tokens: int | None = None
453
+ audio_prompt_tokens: int | None = None
454
+ audio_completion_tokens: int | None = None
455
+ outcome: str | None = None
456
+
457
+ def to_api_dict(self) -> dict[str, Any]:
458
+ """Return the versioned wire object with a deterministic digest."""
459
+ payload = {
460
+ "schema_version": 1,
461
+ "source_observation_id": self.source_observation_id,
462
+ "observed_at": self.observed_at.isoformat() if self.observed_at else None,
463
+ "usage_reported": self.usage_reported,
464
+ "usage_raw": _safe_serialize(self.usage_raw),
465
+ "usage_dialect": self.usage_dialect,
466
+ "normalization_version": self.normalization_version,
467
+ "prompt_inclusion": self.prompt_inclusion,
468
+ "model_requested": self.model_requested,
469
+ "model_reported": self.model_reported,
470
+ "service_tier_requested": self.service_tier_requested,
471
+ "service_tier_effective": self.service_tier_effective,
472
+ "cache_ttl_requested": self.cache_ttl_requested,
473
+ "provider_cost": self.provider_cost,
474
+ "provider_cost_currency": self.provider_cost_currency,
475
+ "dspy_client_cache_hit": self.dspy_client_cache_hit,
476
+ "binding": self.binding,
477
+ "sdk_call_id": self.sdk_call_id,
478
+ "provider_response_id": self.provider_response_id,
479
+ "egress_call_id": self.egress_call_id,
480
+ "prompt_tokens": self.prompt_tokens,
481
+ "completion_tokens": self.completion_tokens,
482
+ "total_tokens": self.total_tokens,
483
+ "fresh_input_tokens": self.fresh_input_tokens,
484
+ "cache_read_tokens": self.cache_read_tokens,
485
+ "cache_write_tokens": self.cache_write_tokens,
486
+ "cache_write_5m_tokens": self.cache_write_5m_tokens,
487
+ "cache_write_1h_tokens": self.cache_write_1h_tokens,
488
+ "reasoning_tokens": self.reasoning_tokens,
489
+ "audio_prompt_tokens": self.audio_prompt_tokens,
490
+ "audio_completion_tokens": self.audio_completion_tokens,
491
+ "outcome": self.outcome,
492
+ }
493
+ safe_payload = _safe_serialize(payload)
494
+ if not isinstance(safe_payload, dict):
495
+ raise TypeError("LM usage observation did not serialize to a JSON object")
496
+ canonical = json.dumps(safe_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
497
+ safe_payload["source_payload_digest"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
498
+ return safe_payload
499
+
500
+
415
501
  @dataclass
416
502
  class SpanBuilder:
417
503
  """Mutable span builder for collecting data during execution."""
@@ -469,6 +555,7 @@ class SpanBuilder:
469
555
  # Normalized by callback._normalize_usage.
470
556
  reasoning_tokens: int | None = None
471
557
  cache_write_tokens: int | None = None
558
+ lm_usage: LMUsageObservationV1 | None = None
472
559
  # Left unset by the SDK. A dollar figure is a derivation from these counts
473
560
  # and a dated rate catalog, and only serve holds the catalog; the SDK's job
474
561
  # is to report what the provider said. serve still reads the wire field, so
@@ -582,6 +669,7 @@ class SpanBuilder:
582
669
  "cached_prompt_tokens": self.cached_prompt_tokens,
583
670
  "reasoning_tokens": self.reasoning_tokens,
584
671
  "cache_write_tokens": self.cache_write_tokens,
672
+ "lm_usage": self.lm_usage.to_api_dict() if self.lm_usage else None,
585
673
  "cost": self.cost,
586
674
  "demos": _safe_serialize(self.demos),
587
675
  "demo_count": self.demo_count if self.demo_count else len(self.demos),
@@ -37,7 +37,7 @@ For an explicit, scoped alternative that traces only the programs you run throug
37
37
  The hook methods, in the order you'd expect them to fire for a typical `dspy.ChainOfThought` call:
38
38
 
39
39
  - **Module lifecycle** — `on_module_start` (`callback.py:195`) creates a new `SpanBuilder`, and, if this is the root module call, also creates a new `TraceBuilder` via `_create_trace` (`callback.py:579`). `on_module_end` (`callback.py:307`) closes the span, attaches outputs, and, if it was the root, exports the trace. A call is root when no span is on the stack — or when the only span above it is the `Evaluate` span, which contains many independent program runs and parents none of them, so each run under a `dspy.Evaluate` roots its own trace. Such a root span is built with `create_child_span(..., root=True)` so it does not adopt the `Evaluate` span as its parent.
40
- - **LM calls** — `on_lm_start` (`callback.py:448`) / `on_lm_end` (`callback.py:498`) wrap the actual provider call. These are where token counts, cost, model name, and the raw prompt get attached. `capture_prompts` (`config.py:29`) controls whether the prompt payload is recorded.
40
+ - **LM calls.** `on_lm_start` and `on_lm_end` wrap the provider call. The callback records the model, the current span token projections, and a versioned `lm_usage` evidence object. `capture_prompts` controls whether the prompt payload is recorded.
41
41
  - **Adapter format/parse** — `on_adapter_format_start` / `_end` and `on_adapter_parse_start` / `_end` (`callback.py:604`–`631`). Both route through shared helpers `_start_adapter_span` (`callback.py:640`) and `_end_adapter_span` (`callback.py:664`). Adapter spans have their raw inputs/outputs stripped in `models.py` — see `_ADAPTER_SPAN_TYPES` handling in `python-sdk/cmpnd/models.py:177` — because the same content already appears on the enclosing LM span.
42
42
  - **Tools** — `on_tool_start` (`callback.py:700`) / `on_tool_end` (`callback.py:733`). One span per `dspy.Tool` invocation, with the tool's args as inputs.
43
43
  - **Interpreter (code execution)** — `_InterpreterMixin` (`callback.py:1119`), for the DSPy `CodeInterpreter` hooks DSPy >= 3.3.1 fires around a program running code in the interpreter (`RLM`, `ProgramOfThought`, `CodeAct`, `Flex` — every module reaching it through `_create_interpreter`; `ReAct` / `ReActV2` use no interpreter and fire none). `on_interpreter_execute_start` / `_end` emit one `INTERPRETER_EXECUTE` span per REPL cell — the code in, its captured stdout out, and the `SUBMIT()` result (a DSPy `FinalOutput`) recorded distinctly from stdout. `on_interpreter_tool_call_start` / `_end` synthesize a `TOOL` span for a tool called from interpreter code **only when no real `dspy.Tool` span covered the call** (a user tool routes through `Tool.__call__` and fires `on_tool_*`, so its row is suppressed to avoid a duplicate; the `llm_query` builtins fire no tool hook, so they get the synthesized span). `on_interpreter_startup_*` / `on_interpreter_shutdown_*` emit no span — their duration folds into an `interpreter.startup_ms` / `interpreter.shutdown_ms` attribute on the enclosing span, and an event with no enclosing span (a pooled pre-warm) is dropped.
@@ -387,7 +387,7 @@ The exporter POSTs to a handful of endpoints, all under `/api/v1/` on the cmpnd-
387
387
  - `POST /api/v1/traces/:id/start` and `/finalize` — two-tier ingestion. `/start` writes an `in_progress` placeholder row; `/finalize` closes a streaming (RLM) trace.
388
388
  - `POST /api/v1/eval_runs`, `POST /api/v1/optimization_runs`, and related progress endpoints.
389
389
 
390
- The trace payload is built in `python-sdk/cmpnd/models.py` by `TraceBuilder.to_api_dict()` (`models.py:398`) and `SpanBuilder.to_api_dict()` (`models.py:300`). A span's dict looks roughly like this, with every field mirrored verbatim to the Postgres `spans` schema documented in [`serve/DB.md`](../../serve/DB.md) ("Analytics tables"):
390
+ The trace payload is built in `python-sdk/cmpnd/models.py` by `TraceBuilder.to_api_dict()` and `SpanBuilder.to_api_dict()`. Serve stores the span's compatibility fields on the `spans` row and stores its `lm_usage` evidence separately. A span's dict looks roughly like this:
391
391
 
392
392
  ```python
393
393
  {
@@ -404,6 +404,22 @@ The trace payload is built in `python-sdk/cmpnd/models.py` by `TraceBuilder.to_a
404
404
  "cached_prompt_tokens": 96, # provider prompt-cache reads, subset of prompt_tokens
405
405
  "reasoning_tokens": 12, # subset of completion_tokens; None when unreported
406
406
  "cache_write_tokens": None, # prompt-cache writes; None when unreported
407
+ "lm_usage": {
408
+ "schema_version": 1,
409
+ "source_observation_id": "...",
410
+ "source_payload_digest": "...",
411
+ "observed_at": "...",
412
+ "usage_reported": True,
413
+ "usage_raw": {"prompt_tokens": 137, "completion_tokens": 42},
414
+ "model_requested": "gpt-4o-mini",
415
+ "model_reported": "gpt-4o-mini-2026-08-01",
416
+ "prompt_tokens": 137,
417
+ "completion_tokens": 42,
418
+ "cache_read_tokens": 96,
419
+ "cache_write_tokens": None,
420
+ "reasoning_tokens": 12,
421
+ # ...plus selectors and correlation IDs when DSPy exposes them
422
+ },
407
423
  "inputs": { ... },
408
424
  "outputs": { ... },
409
425
  # ...plus status, timings, attributes, events
@@ -412,6 +428,16 @@ The trace payload is built in `python-sdk/cmpnd/models.py` by `TraceBuilder.to_a
412
428
 
413
429
  See the `to_api_dict` methods in `python-sdk/cmpnd/models.py` for the full field list — don't trust a doc to stay in sync with it.
414
430
 
431
+ ### LM usage evidence
432
+
433
+ Every LM span carries one `LMUsageObservationV1`. The observation preserves the complete JSON-compatible usage object that DSPy exposes. LiteLLM may have normalized that object before the SDK receives it, so the SDK labels a dialect only when the response surface or usage shape establishes one.
434
+
435
+ The queryable counters use `None` for an absent measurement and `0` for a reported zero. The legacy span counters keep their established defaults for compatibility. The evidence object also records requested and reported models, requested and effective service tiers, one unambiguous requested cache TTL, DSPy client-cache status, provider response identity, and an optional hosted egress call ID.
436
+
437
+ The callback mints `source_observation_id` when the LM call starts and records `observed_at` when it ends. Export retries serialize the same observation, including the same identity, timestamp, and SHA-256 payload digest. A DSPy client-cache hit remains an SDK observation, but `usage_reported` is false because no provider call occurred.
438
+
439
+ `provider_cost` stays `None`. DSPy and LiteLLM can attach a calculated cost to history, but that value is not provider evidence. The SDK will populate this field only when an adapter can prove that the provider supplied both the amount and its currency.
440
+
415
441
  ## Field truncation (span inputs/outputs)
416
442
 
417
443
  A span's `inputs`/`outputs` whose serialized JSON exceeds the 64 KB budget (`_MAX_FIELD_JSON_SIZE`) are truncated **structurally** — the shape is preserved and only the oversized leaves become `_truncated` envelopes — rather than collapsed to a flat string preview. `_MAX_FIELD_JSON_SIZE` is a **whole-payload** cap: `truncate_field(value, max_size)` returns a value whose serialized length is `<= max_size` at the top level, so the emitted `inputs`/`outputs` payload is bounded as a whole, not merely leaf by leaf. The one truncator is `truncate_field` in `python-sdk/cmpnd/models.py` (with the dict case in `_truncate_dict`); treat that as the source of truth and read it rather than trusting the summary below to stay exact.
@@ -5,7 +5,7 @@ name = "cmpnd"
5
5
  # it's new) — no git tags. Bump with `uv version --bump {minor,patch}` or by
6
6
  # hand. Runtime `cmpnd.__version__` reads installed metadata, so it tracks
7
7
  # this automatically. See docs/plans/2026-07-02-sdk-pypi-tag-free-publish.md.
8
- version = "0.23.1"
8
+ version = "0.23.2"
9
9
  description = "DSPy observability and deployment SDK for cmpnd"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -10,15 +10,15 @@
10
10
  # Discovering that in CI, one push at a time, is the loop this replaces.
11
11
  #
12
12
  # The repo-root `docs/` comes along because the ingest-conformance fixtures read
13
- # `docs/ingest-api.yaml` and `docs/schemas/*` by path from the repo root. Without
14
- # it the lane reports a handful of FileNotFoundError failures that look like
15
- # defects and are not — and a lane that cries wolf is a lane whose output you stop
16
- # reading.
13
+ # `docs/ingest-api.yaml` and `docs/schemas/*` by path from the repo root. The
14
+ # editable fake-provider package comes along because uv resolves it from the SDK
15
+ # lock. Without either tree, the lane fails before it tests the SDK.
17
16
  #
18
17
  # It deliberately does NOT mount the host `.venv`: those wheels are macOS
19
18
  # wheels, and a container reusing them fails in ways that teach you nothing about
20
19
  # the code. uv resolves fresh inside, which is also what makes this a real answer
21
- # to "would CI see this too".
20
+ # to "would CI see this too". The copy also drops `.pyc` files because their
21
+ # embedded source paths may name a worktree that moved or no longer exists.
22
22
  #
23
23
  # Usage — everything, or a pytest selector:
24
24
  # python-sdk/scripts/test-in-linux.sh
@@ -46,4 +46,4 @@ exec docker run --rm \
46
46
  --tmpfs /work:exec,size=3g \
47
47
  -e UV_LINK_MODE=copy \
48
48
  "${IMAGE}" \
49
- bash -c "mkdir -p /work && cp -r /repo/python-sdk /repo/docs /work/ && rm -rf /work/python-sdk/.venv && uv sync --extra dev --quiet && uv run --no-sync python -m pytest $(printf '%q ' "${ARGS[@]}")"
49
+ bash -c "mkdir -p /work && cp -r /repo/python-sdk /repo/fake-provider /repo/docs /work/ && rm -rf /work/python-sdk/.venv && find /work/python-sdk -name '*.pyc' -delete && uv sync --extra dev --quiet && uv run --no-sync python -m pytest $(printf '%q ' "${ARGS[@]}")"