cmpnd 0.33.2__tar.gz → 0.33.3__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 (361) hide show
  1. {cmpnd-0.33.2 → cmpnd-0.33.3}/PKG-INFO +1 -1
  2. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/callback.py +55 -9
  3. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/helpers.py +41 -5
  4. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/models.py +0 -2
  5. {cmpnd-0.33.2 → cmpnd-0.33.3}/docs/sdk-instrumentation.md +4 -2
  6. {cmpnd-0.33.2 → cmpnd-0.33.3}/pyproject.toml +1 -1
  7. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/_traced_run.py +13 -3
  8. cmpnd-0.33.3/tests/conftest.py +8 -0
  9. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/load/rlm_size_report.py +9 -3
  10. cmpnd-0.33.3/tests/test_lm15_native_calls.py +157 -0
  11. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_lm_usage_shapes.py +20 -7
  12. cmpnd-0.33.3/tests/test_signature_inputs.py +154 -0
  13. {cmpnd-0.33.2 → cmpnd-0.33.3}/uv.lock +1 -1
  14. cmpnd-0.33.2/tests/conftest.py +0 -3
  15. {cmpnd-0.33.2 → cmpnd-0.33.3}/.gitignore +0 -0
  16. {cmpnd-0.33.2 → cmpnd-0.33.3}/AGENTS.md +0 -0
  17. {cmpnd-0.33.2 → cmpnd-0.33.3}/CLAUDE.md +0 -0
  18. {cmpnd-0.33.2 → cmpnd-0.33.3}/README.md +0 -0
  19. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/__init__.py +0 -0
  20. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/_assessments.py +0 -0
  21. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/_authored_import.py +0 -0
  22. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/_deployments.py +0 -0
  23. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/_exporter_pool.py +0 -0
  24. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/_gepa_patch.py +0 -0
  25. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/_http.py +0 -0
  26. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/_program_patch.py +0 -0
  27. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/_rlm_patch.py +0 -0
  28. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/_runtime_tools.py +0 -0
  29. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/_timespec.py +0 -0
  30. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/_traces.py +0 -0
  31. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/_valsets.py +0 -0
  32. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/authored.py +0 -0
  33. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/__init__.py +0 -0
  34. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/api_client.py +0 -0
  35. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/__init__.py +0 -0
  36. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/admin.py +0 -0
  37. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/assessments.py +0 -0
  38. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/auth.py +0 -0
  39. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/datasets.py +0 -0
  40. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/deployments.py +0 -0
  41. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/evals.py +0 -0
  42. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/login.py +0 -0
  43. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/optimizations.py +0 -0
  44. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/org.py +0 -0
  45. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/programs.py +0 -0
  46. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/provider_credentials.py +0 -0
  47. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/signatures.py +0 -0
  48. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/tool_servers.py +0 -0
  49. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/top.py +0 -0
  50. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/traces.py +0 -0
  51. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/commands/valsets.py +0 -0
  52. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/credentials.py +0 -0
  53. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/deprecation.py +0 -0
  54. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/pagination.py +0 -0
  55. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/shell.py +0 -0
  56. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/trace_render.py +0 -0
  57. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/tree_help.py +0 -0
  58. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/cli/verbs.py +0 -0
  59. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/client.py +0 -0
  60. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/configuration.py +0 -0
  61. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/context.py +0 -0
  62. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/dataset_sync.py +0 -0
  63. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/datasets.py +0 -0
  64. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/decorators.py +0 -0
  65. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/deployment.py +0 -0
  66. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/deployment_metadata.py +0 -0
  67. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/eval_handler.py +0 -0
  68. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/execution.py +0 -0
  69. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/exporter.py +0 -0
  70. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/exporter_http.py +0 -0
  71. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/identity.py +0 -0
  72. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/imaging.py +0 -0
  73. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/ir/__init__.py +0 -0
  74. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/ir/decode.py +0 -0
  75. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/ir/encode.py +0 -0
  76. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/ir/hash.py +0 -0
  77. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/ir/program.py +0 -0
  78. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/ir/reflection.py +0 -0
  79. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/ir/types.py +0 -0
  80. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/ir/xxh64.py +0 -0
  81. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/lm_configuration.py +0 -0
  82. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/mcp_servers.py +0 -0
  83. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/optimization.py +0 -0
  84. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/optimizers/__init__.py +0 -0
  85. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/optimizers/gepa.py +0 -0
  86. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/optimizers/gepa_callback.py +0 -0
  87. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/optimizers/resume.py +0 -0
  88. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/optimizers/tracker.py +0 -0
  89. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/packaging.py +0 -0
  90. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/references.py +0 -0
  91. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/retry.py +0 -0
  92. {cmpnd-0.33.2 → cmpnd-0.33.3}/cmpnd/traces.py +0 -0
  93. {cmpnd-0.33.2 → cmpnd-0.33.3}/docs/2026-04-02-code-review.md +0 -0
  94. {cmpnd-0.33.2 → cmpnd-0.33.3}/docs/cli.md +0 -0
  95. {cmpnd-0.33.2 → cmpnd-0.33.3}/docs/client.md +0 -0
  96. {cmpnd-0.33.2 → cmpnd-0.33.3}/docs/custom-proposer.md +0 -0
  97. {cmpnd-0.33.2 → cmpnd-0.33.3}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  98. {cmpnd-0.33.2 → cmpnd-0.33.3}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  99. {cmpnd-0.33.2 → cmpnd-0.33.3}/docs/reading-traces.md +0 -0
  100. {cmpnd-0.33.2 → cmpnd-0.33.3}/examples/generalizing_proposer.py +0 -0
  101. {cmpnd-0.33.2 → cmpnd-0.33.3}/examples/local_ollama.py +0 -0
  102. {cmpnd-0.33.2 → cmpnd-0.33.3}/ingest/LINEAGE.md +0 -0
  103. {cmpnd-0.33.2 → cmpnd-0.33.3}/ingest/README.md +0 -0
  104. {cmpnd-0.33.2 → cmpnd-0.33.3}/ingest/cmpnd_ingest/__init__.py +0 -0
  105. {cmpnd-0.33.2 → cmpnd-0.33.3}/ingest/cmpnd_ingest/bodies.py +0 -0
  106. {cmpnd-0.33.2 → cmpnd-0.33.3}/ingest/cmpnd_ingest/exporter_http.py +0 -0
  107. {cmpnd-0.33.2 → cmpnd-0.33.3}/ingest/cmpnd_ingest/protocols.py +0 -0
  108. {cmpnd-0.33.2 → cmpnd-0.33.3}/ingest/cmpnd_ingest/py.typed +0 -0
  109. {cmpnd-0.33.2 → cmpnd-0.33.3}/ingest/cmpnd_ingest/request_log.py +0 -0
  110. {cmpnd-0.33.2 → cmpnd-0.33.3}/ingest/cmpnd_ingest/retry.py +0 -0
  111. {cmpnd-0.33.2 → cmpnd-0.33.3}/ingest/cmpnd_ingest/trace_facts.py +0 -0
  112. {cmpnd-0.33.2 → cmpnd-0.33.3}/ingest/pyproject.toml +0 -0
  113. {cmpnd-0.33.2 → cmpnd-0.33.3}/scripts/audit-sdist.sh +0 -0
  114. {cmpnd-0.33.2 → cmpnd-0.33.3}/scripts/capture_review_run.py +0 -0
  115. {cmpnd-0.33.2 → cmpnd-0.33.3}/scripts/check_release_artifacts.py +0 -0
  116. {cmpnd-0.33.2 → cmpnd-0.33.3}/scripts/fixtures/review_run.json.gz +0 -0
  117. {cmpnd-0.33.2 → cmpnd-0.33.3}/scripts/gepa_resume_bug.py +0 -0
  118. {cmpnd-0.33.2 → cmpnd-0.33.3}/scripts/release-stress.sh +0 -0
  119. {cmpnd-0.33.2 → cmpnd-0.33.3}/scripts/require_version_bump.py +0 -0
  120. {cmpnd-0.33.2 → cmpnd-0.33.3}/scripts/seed_review_run.py +0 -0
  121. {cmpnd-0.33.2 → cmpnd-0.33.3}/scripts/test-in-linux.sh +0 -0
  122. {cmpnd-0.33.2 → cmpnd-0.33.3}/stubs/dspy/__init__.pyi +0 -0
  123. {cmpnd-0.33.2 → cmpnd-0.33.3}/stubs/dspy/primitives/__init__.pyi +0 -0
  124. {cmpnd-0.33.2 → cmpnd-0.33.3}/stubs/dspy/primitives/prediction.pyi +0 -0
  125. {cmpnd-0.33.2 → cmpnd-0.33.3}/stubs/dspy/utils/__init__.pyi +0 -0
  126. {cmpnd-0.33.2 → cmpnd-0.33.3}/stubs/dspy/utils/callback.pyi +0 -0
  127. {cmpnd-0.33.2 → cmpnd-0.33.3}/stubs/dspy/utils/dummies.pyi +0 -0
  128. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/__init__.py +0 -0
  129. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/_deployment_signature_helpers.py +0 -0
  130. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/_exporter_helpers.py +0 -0
  131. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/_fake_clock.py +0 -0
  132. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/_identity_helpers.py +0 -0
  133. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/_resolve_refs.py +0 -0
  134. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/__init__.py +0 -0
  135. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/committee_task.py +0 -0
  136. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/conftest.py +0 -0
  137. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/data/committee_train.ndjson.gz +0 -0
  138. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/data/committee_val.ndjson.gz +0 -0
  139. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/fake_lm.py +0 -0
  140. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/helpers.py +0 -0
  141. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/target_capabilities.py +0 -0
  142. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_assessments_cli.py +0 -0
  143. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_capture_prompts.py +0 -0
  144. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_composite_module.py +0 -0
  145. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_dataset_auto_creation.py +0 -0
  146. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_deploy_execute.py +0 -0
  147. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_deploy_programs.py +0 -0
  148. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_edge_cases.py +0 -0
  149. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_example_hash.py +0 -0
  150. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_gepa_optimization.py +0 -0
  151. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_interpreter_spans.py +0 -0
  152. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_lm_accuracy.py +0 -0
  153. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_module_tracing.py +0 -0
  154. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_optimization_lineage.py +0 -0
  155. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_optimization_metric_identity.py +0 -0
  156. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_optimize_failures.py +0 -0
  157. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_optimize_loop.py +0 -0
  158. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_optimize_review_committee.py +0 -0
  159. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
  160. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_parallel_export.py +0 -0
  161. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_project_attribution.py +0 -0
  162. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_react_tool_spans.py +0 -0
  163. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_save_load.py +0 -0
  164. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_trace_structure.py +0 -0
  165. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_traces_read.py +0 -0
  166. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_user_attribution.py +0 -0
  167. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/e2e/test_valsets_cli.py +0 -0
  168. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/01_predict_qa.json +0 -0
  169. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  170. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  171. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  172. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  173. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  174. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/07_program_of_thought.json +0 -0
  175. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/08_opaque_module.json +0 -0
  176. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  177. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/10_react.json +0 -0
  178. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/11_refine.json +0 -0
  179. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/12_retrieve.json +0 -0
  180. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/13_knn.json +0 -0
  181. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/14_parallel.json +0 -0
  182. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/identity_vectors/README.md +0 -0
  183. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/ingest_conformance.py +0 -0
  184. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/integration/__init__.py +0 -0
  185. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/integration/authored_optimize/judge.py +0 -0
  186. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/integration/authored_optimize/optimize_hosted.py +0 -0
  187. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/integration/conftest.py +0 -0
  188. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/integration/hosted_mcp_tool_optimize_probe.py +0 -0
  189. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/integration/hosted_optimize_probe.py +0 -0
  190. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/integration/optimization_run_probe.py +0 -0
  191. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  192. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/integration/test_logs_renders_traces.py +0 -0
  193. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/integration/test_optimization_run.py +0 -0
  194. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/integration/test_optimizing_progress.py +0 -0
  195. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/integration/test_unhappy_path.py +0 -0
  196. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/load/__init__.py +0 -0
  197. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/load/__main__.py +0 -0
  198. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/load/drain.py +0 -0
  199. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/load/ingest.py +0 -0
  200. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/load/latency_transport.py +0 -0
  201. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/load/rlm_shape.py +0 -0
  202. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/load/sweep.py +0 -0
  203. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/load/test_rlm_shape.py +0 -0
  204. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/load/test_rlm_size_report.py +0 -0
  205. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/__init__.py +0 -0
  206. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/conftest.py +0 -0
  207. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/seed_parity.py +0 -0
  208. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/test_auth.py +0 -0
  209. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/test_datasets.py +0 -0
  210. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/test_deployments.py +0 -0
  211. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/test_evals.py +0 -0
  212. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/test_health.py +0 -0
  213. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/test_modules.py +0 -0
  214. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/test_optimizations.py +0 -0
  215. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/test_programs.py +0 -0
  216. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/test_signatures.py +0 -0
  217. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/test_trace_streaming.py +0 -0
  218. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/parity/test_traces.py +0 -0
  219. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/span_reference_fixture.py +0 -0
  220. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/__init__.py +0 -0
  221. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/_provider.py +0 -0
  222. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/_readiness.py +0 -0
  223. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/_reliability.py +0 -0
  224. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/_result.py +0 -0
  225. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/mint_bedrock_token.py +0 -0
  226. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_deploy_programs.py +0 -0
  227. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_multi_client_stress.py +0 -0
  228. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_optimize_client_committee.py +0 -0
  229. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_optimize_loop.py +0 -0
  230. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_optimize_loop_wasm.py +0 -0
  231. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_optimize_stress.py +0 -0
  232. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_provider_roles.py +0 -0
  233. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_readiness.py +0 -0
  234. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_real_provider_twins.py +0 -0
  235. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_reliability.py +0 -0
  236. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_reliability_classify.py +0 -0
  237. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_rlm_wasm.py +0 -0
  238. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/stress/test_trace_search.py +0 -0
  239. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_adapter_reentrancy.py +0 -0
  240. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_assessments_api.py +0 -0
  241. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_authored.py +0 -0
  242. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_authored_bundle_collisions.py +0 -0
  243. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_authored_metric_contract.py +0 -0
  244. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_authored_named_models.py +0 -0
  245. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_authored_optimize.py +0 -0
  246. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_callback.py +0 -0
  247. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_candidate_lineage.py +0 -0
  248. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/__init__.py +0 -0
  249. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/conftest.py +0 -0
  250. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_admin.py +0 -0
  251. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_api_client.py +0 -0
  252. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_assessments.py +0 -0
  253. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_auth.py +0 -0
  254. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_credentials.py +0 -0
  255. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_datasets.py +0 -0
  256. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_deployments.py +0 -0
  257. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_evals.py +0 -0
  258. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_login.py +0 -0
  259. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_main.py +0 -0
  260. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_optimizations.py +0 -0
  261. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_org.py +0 -0
  262. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_pagination.py +0 -0
  263. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_parity.py +0 -0
  264. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_programs.py +0 -0
  265. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_provider_credentials.py +0 -0
  266. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_schema_sample.py +0 -0
  267. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_shell.py +0 -0
  268. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_signatures.py +0 -0
  269. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_tool_servers.py +0 -0
  270. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_top.py +0 -0
  271. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_traces.py +0 -0
  272. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_traces_search_wire.py +0 -0
  273. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_tree_help.py +0 -0
  274. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cli/test_valsets.py +0 -0
  275. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_client.py +0 -0
  276. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_clustering/__init__.py +0 -0
  277. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_clustering/conftest.py +0 -0
  278. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_clustering/test_edge_cases.py +0 -0
  279. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_clustering/test_hash_consistency.py +0 -0
  280. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_clustering/test_type_conversion.py +0 -0
  281. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_cmp551_grouping_identity.py +0 -0
  282. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_configuration.py +0 -0
  283. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_context.py +0 -0
  284. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_context_propagation.py +0 -0
  285. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_dataset_polling.py +0 -0
  286. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_dataset_sync.py +0 -0
  287. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_datasets.py +0 -0
  288. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_decorators.py +0 -0
  289. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_deploy.py +0 -0
  290. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_deploy_mcp_tool_references.py +0 -0
  291. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_deployment_metadata.py +0 -0
  292. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_deployments_read.py +0 -0
  293. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_eval.py +0 -0
  294. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_eval_example_attribution.py +0 -0
  295. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_eval_start.py +0 -0
  296. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_eval_trace_grouping.py +0 -0
  297. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_exception_paths.py +0 -0
  298. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_execute.py +0 -0
  299. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_execute_run_identity.py +0 -0
  300. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_exporter.py +0 -0
  301. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_exporter_queue.py +0 -0
  302. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_exporter_real.py +0 -0
  303. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_fake_lm.py +0 -0
  304. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_gepa_callback.py +0 -0
  305. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_gepa_integration.py +0 -0
  306. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_gepa_output_normalization.py +0 -0
  307. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_gepa_resume.py +0 -0
  308. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_identity.py +0 -0
  309. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_imaging.py +0 -0
  310. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_import_weight.py +0 -0
  311. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_ingest_conformance.py +0 -0
  312. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_ingest_second_producer.py +0 -0
  313. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_integration.py +0 -0
  314. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_interpreter_callback.py +0 -0
  315. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_ir_encoder_properties.py +0 -0
  316. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_ir_golden_vectors.py +0 -0
  317. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_ir_roundtrip.py +0 -0
  318. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_lm_configuration.py +0 -0
  319. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_mcp_tools.py +0 -0
  320. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_models.py +0 -0
  321. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_optimization_progress.py +0 -0
  322. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_optimization_start.py +0 -0
  323. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_optimize.py +0 -0
  324. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_optimize_budget.py +0 -0
  325. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_optimize_lift.py +0 -0
  326. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_optimize_lms.py +0 -0
  327. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_optimize_proposer.py +0 -0
  328. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_optimize_reflection_lm.py +0 -0
  329. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_optimize_terminal_failure.py +0 -0
  330. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_packaging.py +0 -0
  331. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_packaging_callable_source.py +0 -0
  332. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_packaging_mcp_tools.py +0 -0
  333. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_packaging_program_source.py +0 -0
  334. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_poll_until.py +0 -0
  335. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_program_lineage_persist.py +0 -0
  336. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_project_payload.py +0 -0
  337. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_real_provider_twins_readiness.py +0 -0
  338. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_reflector_properties.py +0 -0
  339. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_reflector_smoke.py +0 -0
  340. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_release_stress_script.py +0 -0
  341. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_repro_reactv2_bugs.py +0 -0
  342. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_require_version_bump.py +0 -0
  343. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_retry.py +0 -0
  344. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_reversible_failures.py +0 -0
  345. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_rlm_patch.py +0 -0
  346. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_run_attribution_paths.py +0 -0
  347. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_runtime_tools.py +0 -0
  348. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_schemas.py +0 -0
  349. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_seed_review_run.py +0 -0
  350. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_span_references.py +0 -0
  351. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_span_sent_once.py +0 -0
  352. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_trace_fact_wire_contract.py +0 -0
  353. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_trace_facts.py +0 -0
  354. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_trace_render.py +0 -0
  355. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_traces_read.py +0 -0
  356. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_tracked_gepa.py +0 -0
  357. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_tracker_enrich.py +0 -0
  358. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_truncation.py +0 -0
  359. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_valsets_api.py +0 -0
  360. {cmpnd-0.33.2 → cmpnd-0.33.3}/tests/test_xxh64.py +0 -0
  361. {cmpnd-0.33.2 → cmpnd-0.33.3}/todo.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cmpnd
3
- Version: 0.33.2
3
+ Version: 0.33.3
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>
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import contextvars
6
6
  import logging
7
+ from collections.abc import Mapping
7
8
  from datetime import datetime, timezone
8
9
  from typing import Any
9
10
  from uuid import UUID, uuid4
@@ -320,17 +321,49 @@ def _present_field(value: Any, key: str) -> tuple[bool, Any]:
320
321
  return False, None
321
322
 
322
323
 
324
+ def _usage_reported(usage: Any) -> bool:
325
+ """Whether a usage value reports anything.
326
+
327
+ An empty mapping counts as nothing. DSPy >= 3.4 writes ``usage={}`` for a
328
+ call its own cache answered, and a reported zero is a mapping of zeros,
329
+ never an empty one.
330
+ """
331
+ return usage is not None and not (isinstance(usage, Mapping) and not usage)
332
+
333
+
323
334
  def _history_usage(entry: Any) -> tuple[bool, Any]:
324
335
  """Return whether history carried usage and the value it carried."""
325
336
  response = _value_field(entry, "response")
326
337
  response_declares_usage, response_usage = _present_field(response, "usage")
327
338
  if response_declares_usage:
328
- return response_usage is not None, response_usage
339
+ return _usage_reported(response_usage), response_usage
329
340
  if isinstance(entry, dict):
330
341
  usage = entry.get("usage")
331
- return "usage" in entry and usage is not None, usage
342
+ return _usage_reported(usage), usage
332
343
  usage = _value_field(entry, "usage")
333
- return usage is not None, usage
344
+ return _usage_reported(usage), usage
345
+
346
+
347
+ def _lm15_cache_hit(entry: Any, response: Any) -> bool | None:
348
+ """Recognize a DSPy cache hit on a DSPy >= 3.4 lm15-native call.
349
+
350
+ Nothing on the history entry or the ``Response`` says the cache answered.
351
+ DSPy's ``CallResult.load`` rebuilds the cached ``Response`` with its token
352
+ counts intact and gives the call an empty ``usage``, and the history entry
353
+ copies that empty ``usage``. A fresh native call derives the entry's
354
+ ``usage`` from those same counts, so it is empty only when the
355
+ ``Response`` has none. An empty entry ``usage`` beside a ``Response`` that
356
+ does carry counts is therefore a cache hit.
357
+
358
+ Returns ``None`` when the shapes do not establish one either way.
359
+ """
360
+ if not isinstance(entry, dict) or entry.get("usage") != {}:
361
+ return None
362
+ usage = getattr(response, "usage", None)
363
+ if usage is None or isinstance(usage, Mapping):
364
+ return None
365
+ counts = (getattr(usage, name, None) for name in ("input_tokens", "output_tokens", "total_tokens"))
366
+ return True if any(isinstance(count, int) for count in counts) else None
334
367
 
335
368
 
336
369
  def _usage_dialect(instance: Any, usage: Any) -> str | None:
@@ -388,15 +421,22 @@ def _response_text(response: Any, key: str) -> str | None:
388
421
  return None
389
422
 
390
423
 
424
+ #: lm15 finish reasons whose chat spelling differs. lm15 names a tool-call stop
425
+ #: in the singular (``FinishReason`` in DSPy's vendored ``lm15/types.py``); its
426
+ #: ``stop``, ``length`` and ``content_filter`` are already the chat words.
427
+ _LM15_FINISH_REASONS = {"tool_call": "tool_calls"}
428
+
429
+
391
430
  def _finish_reason(response: Any) -> str | None:
392
431
  """Read why the model stopped, from whichever response shape answered.
393
432
 
394
- Chat puts it on a choice. DSPy's typed ``LMResponse`` puts it on an output.
395
- The Responses API has neither: it reports a ``status`` plus, when the answer
396
- was cut short, an ``incomplete_details.reason``. Those map onto the chat
397
- vocabulary, which is what serve's truncation warning reads — a Responses
398
- ``max_output_tokens`` and a chat ``length`` are the same fact and must land
399
- on the same word.
433
+ Chat puts it on a choice. DSPy's typed ``LMResponse`` (3.3) puts it on an
434
+ output. The lm15 ``Response`` a DSPy >= 3.4 engine returns carries it at the
435
+ top level. The Responses API has none of these: it reports a ``status``
436
+ plus, when the answer was cut short, an ``incomplete_details.reason``. All of
437
+ them map onto the chat vocabulary, which is what serve's truncation warning
438
+ reads — a Responses ``max_output_tokens`` and a chat ``length`` are the same
439
+ fact and must land on the same word.
400
440
 
401
441
  Returns ``None`` when the response reports nothing about it.
402
442
  """
@@ -415,6 +455,10 @@ def _finish_reason(response: Any) -> str | None:
415
455
  if reason:
416
456
  return str(reason)
417
457
 
458
+ reason = getattr(response, "finish_reason", None)
459
+ if isinstance(reason, str) and reason:
460
+ return _LM15_FINISH_REASONS.get(reason, reason)
461
+
418
462
  incomplete = getattr(response, "incomplete_details", None)
419
463
  reason = incomplete.get("reason") if isinstance(incomplete, dict) else getattr(incomplete, "reason", None)
420
464
  if reason:
@@ -1381,6 +1425,8 @@ class _LMMixin(_CallbackBase):
1381
1425
  cache_hit = _bool_field(response, "cache_hit")
1382
1426
  if cache_hit is None:
1383
1427
  cache_hit = _bool_field(_value_field(response, "_hidden_params"), "cache_hit")
1428
+ if cache_hit is None:
1429
+ cache_hit = _lm15_cache_hit(latest_entry, response)
1384
1430
  observation.dspy_client_cache_hit = cache_hit
1385
1431
  observation.usage_reported = usage_reported and cache_hit is not True
1386
1432
  serialized_usage = models._safe_serialize(usage)
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  from typing import Any
7
7
 
8
- from . import models
8
+ from . import identity, models
9
9
 
10
10
 
11
11
  def truncate_field(value: Any, max_size: int, depth: int = 0) -> Any:
@@ -21,8 +21,44 @@ def truncate_field(value: Any, max_size: int, depth: int = 0) -> Any:
21
21
  return models.truncate_field(value, max_size, depth)
22
22
 
23
23
 
24
- def clean_inputs(inputs: dict[str, Any], _dspy: Any, max_field_size: int) -> dict[str, Any]:
25
- """Clean and prepare inputs for storage, with per-field truncation."""
24
+ def signature_label(signature: Any) -> str:
25
+ """Name a DSPy signature in a few words: its class name, else its fields.
26
+
27
+ A signature built from a string is a ``StringSignature`` whatever it holds,
28
+ so it is named by its ``in1, in2 -> out1`` summary instead.
29
+ """
30
+ name = getattr(signature, "__name__", None)
31
+ if name and name != "StringSignature":
32
+ return name
33
+ return identity.build_signature_summary(signature) or str(name or "")
34
+
35
+
36
+ def _is_signature(value: Any, dspy: Any) -> bool:
37
+ signature_type = getattr(dspy, "Signature", None)
38
+ return isinstance(value, type) and isinstance(signature_type, type) and issubclass(value, signature_type)
39
+
40
+
41
+ def _input_value(value: Any, dspy: Any) -> Any:
42
+ """Replace a signature class, alone or among positional args, by its label.
43
+
44
+ DSPy hands every adapter ``format``/``parse`` hook the predictor's
45
+ signature, and a ``Predict`` called with ``signature=`` gets one too. A
46
+ signature class's repr embeds its whole instructions and every field's
47
+ metadata, which the span already records once as its own attributes.
48
+ """
49
+ if _is_signature(value, dspy):
50
+ return signature_label(value)
51
+ if isinstance(value, (list, tuple)) and any(_is_signature(item, dspy) for item in value):
52
+ return [signature_label(item) if _is_signature(item, dspy) else item for item in value]
53
+ return value
54
+
55
+
56
+ def clean_inputs(inputs: dict[str, Any], dspy: Any, max_field_size: int) -> dict[str, Any]:
57
+ """Clean and prepare inputs for storage, with per-field truncation.
58
+
59
+ A signature class among the inputs is recorded by its label, not its repr
60
+ (``_input_value``).
61
+ """
26
62
  result = {}
27
63
 
28
64
  # Merge kwargs into top level
@@ -32,10 +68,10 @@ def clean_inputs(inputs: dict[str, Any], _dspy: Any, max_field_size: int) -> dic
32
68
  kwargs = inputs.get("kwargs", {})
33
69
  for k, v in inputs.items():
34
70
  if k != "kwargs":
35
- serialized = models._safe_serialize(v)
71
+ serialized = models._safe_serialize(_input_value(v, dspy))
36
72
  result[k] = truncate_field(serialized, max_field_size, depth=1)
37
73
  for k, v in kwargs.items():
38
- serialized = models._safe_serialize(v)
74
+ serialized = models._safe_serialize(_input_value(v, dspy))
39
75
  result[k] = truncate_field(serialized, max_field_size, depth=1)
40
76
 
41
77
  # Remove empty args
@@ -713,8 +713,6 @@ class SpanBuilder:
713
713
  selected_demo_ids: list[str] = field(default_factory=list)
714
714
 
715
715
  # Inputs/Outputs
716
- # Initialised to {} by default; thin() sets these to None when stripping
717
- # ADAPTER_FORMAT / ADAPTER_PARSE spans to reduce export payload size.
718
716
  inputs: dict[str, Any] | None = field(default_factory=dict)
719
717
  outputs: dict[str, Any] | None = field(default_factory=dict)
720
718
 
@@ -81,7 +81,7 @@ The hook methods, in the order you'd expect them to fire for a typical `dspy.Cha
81
81
 
82
82
  - **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.
83
83
  - **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.
84
- - **Adapter format/parse** — `on_adapter_format_start` / `_end` and `on_adapter_parse_start` / `_end`. Both route through the shared helpers `_start_adapter_span` and `_end_adapter_span`. The callback records one span per adapter call. DSPy's `Adapter.__init_subclass__` wraps `format` and `parse` on every subclass, so a `JSONAdapter.format` call fires the hooks twice and a `BAMLAdapter.format` call three times, each with a fresh `call_id` and the same instance. `_start_adapter_span` skips a call when the current span is the same method's adapter span on the same instance, and `_end_adapter_span` returns early for the skipped `call_id`. The outermost span is kept, since its output is what reaches the LM. `tests/test_adapter_reentrancy.py` runs the real adapters under DSPy's dispatch.
84
+ - **Adapter format/parse** — `on_adapter_format_start` / `_end` and `on_adapter_parse_start` / `_end`. Both route through the shared helpers `_start_adapter_span` and `_end_adapter_span`. The callback records one span per adapter call. DSPy's `Adapter.__init_subclass__` wraps `format` and `parse` on every subclass, so a `JSONAdapter.format` call fires the hooks twice and a `BAMLAdapter.format` call three times, each with a fresh `call_id` and the same instance. `_start_adapter_span` skips a call when the current span is the same method's adapter span on the same instance, and `_end_adapter_span` returns early for the skipped `call_id`. The outermost span is kept, since its output is what reaches the LM. `tests/test_adapter_reentrancy.py` runs the real adapters under DSPy's dispatch. An adapter span's inputs are the method's arguments, so they include the predictor's `signature`. `helpers.clean_inputs` records any signature class among a span's inputs by its label, the class name or the `in1, in2 -> out1` fields of a string signature. It never records the repr, which embeds the whole instructions and every field's metadata. The same rule covers a `Predict` called with `signature=`, as DSPy's RLM does on every turn from DSPy `main` on. The span records the signature's fields and instructions once, as its own attributes.
85
85
  - **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.
86
86
  - **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.
87
87
  - **Evaluate** — `on_evaluate_start` (`callback.py:773`) / `on_evaluate_end` (`callback.py:782`). These are thin forwarders that delegate to `EvalHandler` (see "Eval handling" below).
@@ -709,7 +709,9 @@ Every LM span carries one `LMUsageObservationV1`. The observation preserves the
709
709
 
710
710
  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.
711
711
 
712
- 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.
712
+ 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. An empty usage object also leaves `usage_reported` false, since it reports no count. On the LiteLLM path DSPy stamps `cache_hit` on the cached response, and `dspy_client_cache_hit` reads it there. An lm15-native call on DSPy 3.4 carries no such flag, so the SDK infers the hit from its shape: DSPy empties the history entry's `usage` while the cached `Response` keeps its token counts, which a fresh call never does (`_lm15_cache_hit` in `python-sdk/cmpnd/callback.py`).
713
+
714
+ The LM span's `lm.finish_reason` attribute records why the model stopped, in the chat vocabulary serve's truncation warning reads. Each response shape spells it differently. Chat puts it on the first choice and DSPy 3.3's typed `LMResponse` on the first output. The lm15 `Response` a DSPy 3.4 engine returns carries it at the top level and names a tool-call stop `tool_call`, which the SDK records as `tool_calls`. The Responses API reports a `status`, and its `max_output_tokens` cutoff lands as `length`. `_finish_reason` in `python-sdk/cmpnd/callback.py` holds the mapping.
713
715
 
714
716
  `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.
715
717
 
@@ -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.33.2"
8
+ version = "0.33.3"
9
9
  description = "DSPy observability and deployment SDK for cmpnd"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -10,6 +10,7 @@ callback hands to its exporter, whether it streams them or batches them.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import inspect
13
14
  import json
14
15
  from typing import Any
15
16
  from unittest import mock
@@ -51,7 +52,7 @@ class CaptureExporter:
51
52
 
52
53
 
53
54
  def make_callback(**kwargs: Any) -> Any:
54
- with mock.patch("cmpnd.exporter.ensure_exporter_started"):
55
+ with mock.patch("cmpnd.exporter.ensure_exporter_started", autospec=True):
55
56
  from cmpnd.callback import CmpndCallback
56
57
 
57
58
  return CmpndCallback(**kwargs)
@@ -62,7 +63,7 @@ def run(program: Any, lm: Any, adapter: Any, callback: Any = None, **inputs: Any
62
63
  exp = CaptureExporter()
63
64
  exp.lm = lm
64
65
  cb = callback or make_callback()
65
- with mock.patch("cmpnd.exporter.get_exporter", return_value=exp):
66
+ with mock.patch("cmpnd.exporter.get_exporter", autospec=True, return_value=exp):
66
67
  with dspy.context(lm=lm, adapter=adapter, callbacks=[cb]):
67
68
  result = program(**inputs)
68
69
  return result, exp
@@ -81,8 +82,17 @@ def run_rlm(
81
82
  rlm = dspy.RLM("question -> answer", max_iters=turns)
82
83
  adapter = adapter or dspy.ChatAdapter()
83
84
  interpreter = FakeInterpreter(lambda t: interpreter_output(t, output_size))
85
+ # DSPy 3.4 renamed RLM.forward's caller-owned interpreter parameter from
86
+ # positional ``interpreter`` to keyword-only ``interpreter_factory``.
87
+ new_rlm_api = "interpreter_factory" in inspect.signature(getattr(rlm, "forward")).parameters
88
+
89
+ def _call_rlm(**kw: Any) -> Any:
90
+ if new_rlm_api:
91
+ return rlm(interpreter_factory=lambda: interpreter, **kw)
92
+ return rlm(interpreter, **kw)
93
+
84
94
  return run(
85
- lambda **kw: rlm(interpreter, **kw),
95
+ _call_rlm,
86
96
  DummyLM(rlm_answers(turns), adapter=adapter),
87
97
  adapter,
88
98
  callback=callback,
@@ -0,0 +1,8 @@
1
+ """Shared pytest configuration for the cmpnd SDK test suite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # litellm imports openai._models during test collection. Initialize openai
6
+ # first to avoid the circular import in openai 1.109.1 when that module loads
7
+ # before openai's own __init__ has finished.
8
+ import openai # noqa: F401 (imported for its import-order side effect)
@@ -24,6 +24,7 @@ span, is not counted.
24
24
  from __future__ import annotations
25
25
 
26
26
  import argparse
27
+ import inspect
27
28
  import json
28
29
  import os
29
30
  import subprocess
@@ -130,7 +131,7 @@ def run_rlm(turns: int, output_size: int, adapter: str = "chat") -> list[dict[st
130
131
  from cmpnd.configuration import configure
131
132
 
132
133
  configure(api_key="size-report", endpoint="http://localhost:8000")
133
- with mock.patch("cmpnd.exporter.ensure_exporter_started"):
134
+ with mock.patch("cmpnd.exporter.ensure_exporter_started", autospec=True):
134
135
  from cmpnd.callback import CmpndCallback
135
136
 
136
137
  callback = CmpndCallback()
@@ -144,9 +145,14 @@ def run_rlm(turns: int, output_size: int, adapter: str = "chat") -> list[dict[st
144
145
 
145
146
  interpreter = FakeInterpreter(output)
146
147
  capture = _Capture()
147
- with mock.patch("cmpnd.exporter.get_exporter", return_value=capture):
148
+ # DSPy 3.4 takes a factory instead of the positional interpreter.
149
+ new_rlm_api = "interpreter_factory" in inspect.signature(getattr(rlm, "forward")).parameters
150
+ with mock.patch("cmpnd.exporter.get_exporter", autospec=True, return_value=capture):
148
151
  with dspy.context(lm=lm, adapter=adapter_instance, callbacks=[callback]):
149
- rlm(interpreter, question="Which rows changed?")
152
+ if new_rlm_api:
153
+ rlm(interpreter_factory=lambda: interpreter, question="Which rows changed?")
154
+ else:
155
+ rlm(interpreter, question="Which rows changed?")
150
156
  return [s.to_api_dict() for s in capture.spans]
151
157
 
152
158
 
@@ -0,0 +1,157 @@
1
+ """LM calls DSPy answers through its lm15 engines, driven end to end.
2
+
3
+ DSPy 3.4 runs an LM call through an engine that returns an lm15 ``Response``,
4
+ and the history entry the callback reads holds that ``Response`` rather than a
5
+ provider's chat or Responses object. The ``Response`` is a slots dataclass with
6
+ a top-level ``finish_reason`` in lm15's own vocabulary, and a DSPy cache hit on
7
+ this path leaves the entry's ``usage`` empty with nothing on the entry or the
8
+ ``Response`` saying it was a hit.
9
+
10
+ These drive a real ``dspy.Predict`` over DSPy's own ``DummyLM``, which on 3.4
11
+ answers through ``DummyEngine`` and so takes the same native path a provider
12
+ call does. No shape is hand-built: if DSPy changes what the entry holds, these
13
+ see it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+ from unittest import mock
20
+
21
+ import pytest
22
+
23
+ from cmpnd.configuration import configure
24
+ from cmpnd.context import clear_context
25
+
26
+ dspy = pytest.importorskip("dspy")
27
+
28
+ try:
29
+ from dspy.clients.engines.dummy_engine import DummyEngine
30
+ from dspy.lm15 import Response
31
+ except ImportError: # DSPy < 3.4 answers DummyLM without an lm15 engine.
32
+ pytest.skip("needs DSPy>=3.4 (lm15 engines)", allow_module_level=True)
33
+
34
+ from dspy.utils.dummies import DummyLM # noqa: E402
35
+
36
+ from tests.test_eval_trace_grouping import RecordingExporter # noqa: E402
37
+
38
+
39
+ class QA(dspy.Signature):
40
+ """Answer the question."""
41
+
42
+ question: str = dspy.InputField()
43
+ answer: str = dspy.OutputField()
44
+
45
+
46
+ @pytest.fixture(autouse=True)
47
+ def _sdk_configured():
48
+ configure(api_key="test_key", endpoint="http://localhost:8000")
49
+ yield
50
+ clear_context()
51
+ import cmpnd.configuration
52
+ from cmpnd.exporter import shutdown_exporter
53
+
54
+ cmpnd.configuration._config = None
55
+ shutdown_exporter()
56
+
57
+
58
+ @pytest.fixture
59
+ def memory_cache():
60
+ """DSPy's cache in memory only, empty, and restored afterwards."""
61
+ from dspy.clients import configure_cache
62
+
63
+ saved = dspy.cache
64
+ configure_cache(enable_disk_cache=False, enable_memory_cache=True)
65
+ yield
66
+ dspy.cache = saved
67
+
68
+
69
+ class _FinishingEngine(DummyEngine):
70
+ """``DummyEngine`` answering with a chosen lm15 finish reason."""
71
+
72
+ def __init__(self, owner: Any, finish_reason: str) -> None:
73
+ super().__init__(owner)
74
+ self._finish_reason = finish_reason
75
+
76
+ def _complete_messages(self, messages: list[dict[str, Any]]) -> Response:
77
+ response = super()._complete_messages(messages)
78
+ return Response(
79
+ id=response.id,
80
+ model=response.model,
81
+ message=response.message,
82
+ finish_reason=self._finish_reason,
83
+ usage=response.usage,
84
+ )
85
+
86
+
87
+ def _lm_spans(lm: Any, calls: int = 1) -> list[Any]:
88
+ """Run ``Predict(QA)`` ``calls`` times on ``lm``; return every LM span, in order."""
89
+ from cmpnd.callback import CmpndCallback
90
+
91
+ with mock.patch("cmpnd.exporter.ensure_exporter_started", autospec=True):
92
+ callback = CmpndCallback()
93
+ recorder = RecordingExporter()
94
+
95
+ with (
96
+ mock.patch("cmpnd.exporter.get_exporter", autospec=True, return_value=recorder),
97
+ dspy.context(lm=lm, callbacks=[callback]),
98
+ ):
99
+ for _ in range(calls):
100
+ dspy.Predict(QA)(question="What is the capital of France?")
101
+
102
+ spans = [s for t in recorder.traces for s in (getattr(t, "_batch_spans", None) or [])] + recorder.spans
103
+ lm_spans = [s for s in spans if s.span_type.value == "lm_call"]
104
+ assert len(lm_spans) == calls, f"expected {calls} LM spans, got {[s.name for s in spans]}"
105
+ return sorted(lm_spans, key=lambda s: s.start_time)
106
+
107
+
108
+ def test_history_holds_an_lm15_response():
109
+ """The premise: DummyLM's history entry carries the native ``Response``."""
110
+ lm = DummyLM([{"answer": "Paris"}])
111
+ _lm_spans(lm)
112
+ assert isinstance(lm.history[-1]["response"], Response)
113
+
114
+
115
+ @pytest.mark.parametrize(
116
+ ("native", "recorded"),
117
+ [
118
+ ("stop", "stop"),
119
+ ("length", "length"),
120
+ # lm15 names a tool-call stop in the singular; serve reads the chat word.
121
+ ("tool_call", "tool_calls"),
122
+ ("content_filter", "content_filter"),
123
+ ],
124
+ )
125
+ def test_finish_reason_on_a_native_call(native: str, recorded: str):
126
+ lm = DummyLM([{"answer": "Paris"}])
127
+ lm._engine_spec = _FinishingEngine(lm, native)
128
+
129
+ (span,) = _lm_spans(lm)
130
+ assert span.attributes.get("lm.finish_reason") == recorded
131
+
132
+
133
+ def test_a_dspy_cache_hit_reports_no_usage(memory_cache: None):
134
+ """A repeated identical call is answered from DSPy's cache.
135
+
136
+ The provider was not called, so there is no usage to report. The hit's
137
+ usage is unreported rather than zero, and the observation says DSPy's
138
+ cache answered.
139
+ """
140
+ lm = DummyLM({"capital of France": {"answer": "Paris"}})
141
+ # DummyLM opts out of DSPy's cache so a scripted list is consumed in order.
142
+ # This test is about the cache, so it opts back in.
143
+ lm._cache_responses = True
144
+
145
+ first, second = _lm_spans(lm, calls=2)
146
+
147
+ assert lm.history[-1]["usage"] == {}, "premise: DSPy answered the second call from its cache"
148
+ fresh = first.to_api_dict()["lm_usage"]
149
+ hit = second.to_api_dict()["lm_usage"]
150
+ assert fresh["usage_reported"] is True
151
+ assert fresh["dspy_client_cache_hit"] is None
152
+ assert hit["usage_reported"] is False
153
+ assert hit["usage_raw"] is None
154
+ assert hit["prompt_tokens"] is None
155
+ assert hit["dspy_client_cache_hit"] is True
156
+ # The cached answer still says why the model stopped.
157
+ assert second.attributes.get("lm.finish_reason") == "stop"
@@ -39,7 +39,8 @@ def _typed_lm_types_available() -> bool:
39
39
 
40
40
  ``dspy.core.types`` (``LMUsage``, ``LMResponse``) arrived in DSPy 3.3.0.
41
41
  Measured across the versions ``.github/workflows/ci-sdk.yml`` pins: absent on
42
- 3.1.3, 3.2.0 and 3.2.1, present on 3.3.0 and 3.3.1. Probed by import rather
42
+ 3.1.3, 3.2.0 and 3.2.1, present on 3.3.0 and 3.3.1, and removed in 3.4.0b1,
43
+ whose ``dspy.core.types`` raises ``ImportError``. Probed by import rather
43
44
  than by version number, so the answer stays right whatever the matrix pins
44
45
  next.
45
46
 
@@ -98,7 +99,7 @@ def _span_for(usage: Any, response: Any = None) -> Any:
98
99
  """Drive on_lm_start/on_lm_end over one history entry; return the span."""
99
100
  from cmpnd.callback import CmpndCallback
100
101
 
101
- with mock.patch("cmpnd.exporter.ensure_exporter_started"):
102
+ with mock.patch("cmpnd.exporter.ensure_exporter_started", autospec=True):
102
103
  callback = CmpndCallback()
103
104
  recorder = _RecordingExporter()
104
105
 
@@ -106,7 +107,7 @@ def _span_for(usage: Any, response: Any = None) -> Any:
106
107
  if response is not None:
107
108
  entry["response"] = response
108
109
 
109
- with mock.patch("cmpnd.exporter.get_exporter", return_value=recorder):
110
+ with mock.patch("cmpnd.exporter.get_exporter", autospec=True, return_value=recorder):
110
111
  callback.on_lm_start(call_id="lm1", instance=_lm([entry]), inputs={})
111
112
  callback.on_lm_end(call_id="lm1", outputs=None, exception=None)
112
113
 
@@ -118,13 +119,13 @@ def _span_for_entry(entry: Any, *, lm_kwargs: dict[str, Any] | None = None) -> A
118
119
  """Drive one complete history entry through the callback."""
119
120
  from cmpnd.callback import CmpndCallback
120
121
 
121
- with mock.patch("cmpnd.exporter.ensure_exporter_started"):
122
+ with mock.patch("cmpnd.exporter.ensure_exporter_started", autospec=True):
122
123
  callback = CmpndCallback()
123
124
  recorder = _RecordingExporter()
124
125
  lm = _lm([entry])
125
126
  lm.kwargs = lm_kwargs or {}
126
127
 
127
- with mock.patch("cmpnd.exporter.get_exporter", return_value=recorder):
128
+ with mock.patch("cmpnd.exporter.get_exporter", autospec=True, return_value=recorder):
128
129
  callback.on_lm_start(call_id="sdk-call-1", instance=lm, inputs={})
129
130
  callback.on_lm_end(call_id="sdk-call-1", outputs=None, exception=None)
130
131
 
@@ -471,6 +472,18 @@ def test_dspy_cache_hit_is_not_provider_reported_usage():
471
472
  assert observation["usage_raw"] is None
472
473
 
473
474
 
475
+ def test_an_empty_usage_object_is_not_reported_usage():
476
+ """An empty mapping reports nothing, unlike a mapping of zeros.
477
+
478
+ DSPy >= 3.4 writes ``usage={}`` for a call its cache answered, with nothing
479
+ else on this entry to say so, so the empty object is all the callback sees.
480
+ """
481
+ observation = _span_for_entry({"usage": {}}).to_api_dict()["lm_usage"]
482
+ assert observation["usage_reported"] is False
483
+ assert observation["usage_raw"] is None
484
+ assert observation["dspy_client_cache_hit"] is None
485
+
486
+
474
487
  @_needs_typed_lm_types
475
488
  def test_typed_history_preserves_response_usage_and_request_extensions():
476
489
  from dspy.core.types import (
@@ -580,7 +593,7 @@ def test_prediction_usage_aggregates_on_every_shape(shape: str):
580
593
 
581
594
  from cmpnd.callback import CmpndCallback
582
595
 
583
- with mock.patch("cmpnd.exporter.ensure_exporter_started"):
596
+ with mock.patch("cmpnd.exporter.ensure_exporter_started", autospec=True):
584
597
  callback = CmpndCallback()
585
598
 
586
599
  span = mock.MagicMock()
@@ -608,7 +621,7 @@ def test_prediction_usage_sums_across_models():
608
621
 
609
622
  from cmpnd.callback import CmpndCallback
610
623
 
611
- with mock.patch("cmpnd.exporter.ensure_exporter_started"):
624
+ with mock.patch("cmpnd.exporter.ensure_exporter_started", autospec=True):
612
625
  callback = CmpndCallback()
613
626
 
614
627
  span = mock.MagicMock()