dhisana 0.0.1.dev314__tar.gz → 0.0.1.dev315__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 (133) hide show
  1. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/PKG-INFO +1 -1
  2. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/setup.py +1 -1
  3. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/apollo_tools.py +98 -5
  4. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/fetch_openai_config.py +74 -0
  5. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_structured_output_internal.py +102 -1
  6. dhisana-0.0.1.dev315/src/dhisana/utils/usage_hook.py +103 -0
  7. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana.egg-info/PKG-INFO +1 -1
  8. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana.egg-info/SOURCES.txt +5 -1
  9. dhisana-0.0.1.dev315/tests/test_apollo_usage_emit.py +175 -0
  10. dhisana-0.0.1.dev315/tests/test_openai_usage_emit.py +239 -0
  11. dhisana-0.0.1.dev315/tests/test_usage_hook.py +75 -0
  12. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/README.md +0 -0
  13. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/pyproject.toml +0 -0
  14. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/setup.cfg +0 -0
  15. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/__init__.py +0 -0
  16. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/cli/__init__.py +0 -0
  17. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/cli/cli.py +0 -0
  18. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/cli/datasets.py +0 -0
  19. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/cli/models.py +0 -0
  20. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/cli/predictions.py +0 -0
  21. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/schemas/__init__.py +0 -0
  22. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/schemas/common.py +0 -0
  23. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/schemas/sales.py +0 -0
  24. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/ui/__init__.py +0 -0
  25. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/ui/components.py +0 -0
  26. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/__init__.py +0 -0
  27. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/add_mapping.py +0 -0
  28. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/agent_tools.py +0 -0
  29. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/assistant_tool_tag.py +0 -0
  30. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/built_with_api_tools.py +0 -0
  31. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/cache_output_tools.py +0 -0
  32. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/cache_output_tools_local.py +0 -0
  33. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/check_email_validity_tools.py +0 -0
  34. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/check_for_intent_signal.py +0 -0
  35. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
  36. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/clay_tools.py +0 -0
  37. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/clean_properties.py +0 -0
  38. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/company_utils.py +0 -0
  39. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/compose_salesnav_query.py +0 -0
  40. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/compose_search_query.py +0 -0
  41. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
  42. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/composite_tools.py +0 -0
  43. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/dataframe_tools.py +0 -0
  44. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/domain_parser.py +0 -0
  45. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/email_body_utils.py +0 -0
  46. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/email_parse_helpers.py +0 -0
  47. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/email_provider.py +0 -0
  48. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/enrich_lead_information.py +0 -0
  49. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
  50. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/field_validators.py +0 -0
  51. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/g2_tools.py +0 -0
  52. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_content.py +0 -0
  53. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_custom_message.py +0 -0
  54. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_email.py +0 -0
  55. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_email_response.py +0 -0
  56. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_flow.py +0 -0
  57. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
  58. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
  59. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
  60. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_sms_whatsapp.py +0 -0
  61. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/google_custom_search.py +0 -0
  62. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/google_oauth_tools.py +0 -0
  63. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/google_workspace_tools.py +0 -0
  64. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/hubspot_clearbit.py +0 -0
  65. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
  66. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/instantly_tools.py +0 -0
  67. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/linkedin_crawler.py +0 -0
  68. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/lusha_tools.py +0 -0
  69. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/mailgun_tools.py +0 -0
  70. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/mailreach_tools.py +0 -0
  71. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/microsoft365_tools.py +0 -0
  72. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
  73. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openai_helpers.py +0 -0
  74. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
  75. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
  76. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
  77. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
  78. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
  79. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
  80. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/profile.py +0 -0
  81. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/proxy_curl_tools.py +0 -0
  82. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
  83. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/python_function_to_tools.py +0 -0
  84. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/research_lead.py +0 -0
  85. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
  86. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
  87. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/search_router.py +0 -0
  88. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/search_router_jobs.py +0 -0
  89. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/sendgrid_tools.py +0 -0
  90. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serarch_router_local_business.py +0 -0
  91. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
  92. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
  93. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serpapi_google_search.py +0 -0
  94. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
  95. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serpapi_search_tools.py +0 -0
  96. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
  97. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serperdev_local_business.py +0 -0
  98. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serperdev_search.py +0 -0
  99. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/smtp_email_tools.py +0 -0
  100. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/test_connect.py +0 -0
  101. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/trasform_json.py +0 -0
  102. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/web_download_parse_tools.py +0 -0
  103. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/workflow_code_model.py +0 -0
  104. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/zoominfo_tools.py +0 -0
  105. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/workflow/__init__.py +0 -0
  106. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/workflow/agent.py +0 -0
  107. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/workflow/flow.py +0 -0
  108. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/workflow/task.py +0 -0
  109. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/workflow/test.py +0 -0
  110. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana.egg-info/dependency_links.txt +0 -0
  111. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana.egg-info/entry_points.txt +0 -0
  112. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana.egg-info/requires.txt +0 -0
  113. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana.egg-info/top_level.txt +0 -0
  114. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_agent_tools.py +0 -0
  115. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_apollo_company_search.py +0 -0
  116. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_apollo_json_error_handling.py +0 -0
  117. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_apollo_lead_search.py +0 -0
  118. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_connectivity.py +0 -0
  119. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_email_body_utils.py +0 -0
  120. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_generate_email.py +0 -0
  121. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_generate_sms_whatsapp.py +0 -0
  122. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_google_document.py +0 -0
  123. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_hubspot_call_logs.py +0 -0
  124. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_linkedin_serper.py +0 -0
  125. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_mailreach.py +0 -0
  126. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_mcp_connectivity.py +0 -0
  127. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_normalize_graph_datetime.py +0 -0
  128. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_proxycurl_get_company_search_id.py +0 -0
  129. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_proxycurl_job_count.py +0 -0
  130. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_reply_html_format.py +0 -0
  131. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_reply_thread_fallback.py +0 -0
  132. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_send_email_recipients.py +0 -0
  133. {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_structured_output_with_mcp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dhisana
3
- Version: 0.0.1.dev314
3
+ Version: 0.0.1.dev315
4
4
  Summary: A Python SDK for Dhisana AI Platform
5
5
  Home-page: https://github.com/dhisana-ai/dhisana-python-sdk
6
6
  Author: Admin
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='dhisana',
5
- version='0.0.1-dev314',
5
+ version='0.0.1-dev315',
6
6
  description='A Python SDK for Dhisana AI Platform',
7
7
  author='Admin',
8
8
  author_email='contact@dhisana.ai',
@@ -21,6 +21,53 @@ logger = logging.getLogger(__name__)
21
21
  APOLLO_CACHE_TTL = 14 * 24 * 60 * 60 # 14 days in seconds
22
22
 
23
23
 
24
+ def _count_apollo_records(result: Any, keys: Tuple[str, ...]) -> int:
25
+ """Count records in Apollo response fields that may be lists or objects."""
26
+ if not isinstance(result, dict):
27
+ return 0
28
+
29
+ total = 0
30
+ for key in keys:
31
+ value = result.get(key)
32
+ if isinstance(value, list):
33
+ total += len(value)
34
+ elif value:
35
+ total += 1
36
+ return total
37
+
38
+
39
+ def _emit_apollo_usage(
40
+ *,
41
+ tool_config: Optional[List[Dict]] = None,
42
+ endpoint: Optional[str] = None,
43
+ records_attempted: int = 0,
44
+ records_returned: int = 0,
45
+ records_enriched: int = 0,
46
+ ) -> None:
47
+ """Emit an Apollo provider-usage record (best-effort, never raises)."""
48
+ try:
49
+ from dhisana.utils.fetch_openai_config import (
50
+ credential_owner_from_tool_config,
51
+ )
52
+ from dhisana.utils.usage_hook import ProviderUsageRecord, emit_usage
53
+
54
+ emit_usage(
55
+ ProviderUsageRecord(
56
+ provider="apollo",
57
+ model_or_endpoint=endpoint,
58
+ credential_owner=credential_owner_from_tool_config(
59
+ tool_config, "apollo"
60
+ ),
61
+ api_calls=1,
62
+ records_attempted=records_attempted,
63
+ records_returned=records_returned,
64
+ records_enriched=records_enriched,
65
+ )
66
+ )
67
+ except Exception: # noqa: BLE001 - metering must never break a provider call
68
+ logger.debug("failed to emit Apollo usage", exc_info=True)
69
+
70
+
24
71
  def get_apollo_access_token(tool_config: Optional[List[Dict]] = None) -> Tuple[str, bool]:
25
72
  """
26
73
  Retrieves an Apollo access token from tool configuration or environment variables.
@@ -180,6 +227,7 @@ async def enrich_person_info_from_apollo(
180
227
  logger.debug(f"Received response status: {response.status}")
181
228
  if response.status == 200:
182
229
  result = await response.json()
230
+ records_returned = _count_apollo_records(result, ("person",))
183
231
  # Cache on success if organization_id is available
184
232
  if organization_id and cache_key_identifier:
185
233
  cache_key = f"{organization_id}:{cache_key_identifier}"
@@ -188,6 +236,13 @@ async def enrich_person_info_from_apollo(
188
236
  except Exception:
189
237
  logger.warning("Failed to cache Apollo person data.")
190
238
  logger.info("Successfully retrieved person info from Apollo.")
239
+ _emit_apollo_usage(
240
+ tool_config=tool_config,
241
+ endpoint="people/match",
242
+ records_attempted=1,
243
+ records_returned=records_returned,
244
+ records_enriched=records_returned,
245
+ )
191
246
  return result
192
247
  elif response.status == 429:
193
248
  msg = "Rate limit exceeded"
@@ -274,6 +329,13 @@ async def lookup_person_in_apollo_by_name(
274
329
  if response.status == 200:
275
330
  result = await response.json()
276
331
  logger.info("Successfully looked up person by name on Apollo.")
332
+ records_returned = _count_apollo_records(result, ("people", "contacts"))
333
+ _emit_apollo_usage(
334
+ tool_config=tool_config,
335
+ endpoint="mixed_people/search",
336
+ records_attempted=1,
337
+ records_returned=records_returned,
338
+ )
277
339
  return result
278
340
  elif response.status == 429:
279
341
  msg = "Rate limit exceeded"
@@ -363,6 +425,7 @@ async def enrich_organization_info_from_apollo(
363
425
  logger.debug(f"Received response status: {response.status}")
364
426
  if response.status == 200:
365
427
  result = await response.json()
428
+ records_returned = _count_apollo_records(result, ("organization",))
366
429
  # Cache on success if organization_id is available
367
430
  if organization_id and organization_domain:
368
431
  cache_key = f"{organization_id}:{organization_domain}"
@@ -371,6 +434,13 @@ async def enrich_organization_info_from_apollo(
371
434
  except Exception:
372
435
  logger.warning("Failed to cache Apollo org data.")
373
436
  logger.info("Successfully retrieved organization info from Apollo.")
437
+ _emit_apollo_usage(
438
+ tool_config=tool_config,
439
+ endpoint="organizations/enrich",
440
+ records_attempted=1,
441
+ records_returned=records_returned,
442
+ records_enriched=records_returned,
443
+ )
374
444
  return result
375
445
  elif response.status == 429:
376
446
  msg = "Rate limit exceeded"
@@ -405,7 +475,13 @@ async def enrich_organization_info_from_apollo(
405
475
  giveup=lambda e: e.status != 429,
406
476
  factor=2,
407
477
  )
408
- async def fetch_apollo_data(session, url: str, headers: Dict[str, str], payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
478
+ async def fetch_apollo_data(
479
+ session,
480
+ url: str,
481
+ headers: Dict[str, str],
482
+ payload: Dict[str, Any],
483
+ tool_config: Optional[List[Dict]] = None,
484
+ ) -> Optional[Dict[str, Any]]:
409
485
  logger.info("Entering fetch_apollo_data")
410
486
  logger.debug("Making POST request to Apollo.")
411
487
  async with session.post(url, headers=headers, json=payload) as response:
@@ -413,6 +489,15 @@ async def fetch_apollo_data(session, url: str, headers: Dict[str, str], payload:
413
489
  if response.status == 200:
414
490
  result = await response.json()
415
491
  logger.info("Successfully fetched data from Apollo.")
492
+ records_returned = _count_apollo_records(
493
+ result,
494
+ ("people", "contacts", "organizations", "accounts"),
495
+ )
496
+ _emit_apollo_usage(
497
+ tool_config=tool_config,
498
+ endpoint=url,
499
+ records_returned=records_returned,
500
+ )
416
501
  return result
417
502
  elif response.status == 429:
418
503
  msg = "Rate limit exceeded"
@@ -453,7 +538,9 @@ async def search_people_with_apollo(
453
538
  logger.info(f"Sending payload to Apollo (single page): {json.dumps(dynamic_payload, indent=2)}")
454
539
 
455
540
  async with aiohttp.ClientSession() as session:
456
- data = await fetch_apollo_data(session, url, headers, dynamic_payload)
541
+ data = await fetch_apollo_data(
542
+ session, url, headers, dynamic_payload, tool_config=tool_config
543
+ )
457
544
  if not data:
458
545
  logger.error("No data returned from Apollo.")
459
546
  return []
@@ -1006,7 +1093,9 @@ async def search_leads_with_apollo_page(
1006
1093
  url = "https://api.apollo.io/api/v1/mixed_people/search"
1007
1094
 
1008
1095
  async with aiohttp.ClientSession() as session:
1009
- apollo_response = await fetch_apollo_data(session, url, headers, page_payload)
1096
+ apollo_response = await fetch_apollo_data(
1097
+ session, url, headers, page_payload, tool_config=tool_config
1098
+ )
1010
1099
  if not apollo_response:
1011
1100
  return {"current_page": page, "per_page": per_page, "total_entries": 0, "total_pages": 0, "has_next_page": False, "results": []}
1012
1101
 
@@ -1393,7 +1482,9 @@ async def search_companies_with_apollo(
1393
1482
  logger.info(f"Sending payload to Apollo organizations endpoint (single page): {json.dumps(dynamic_payload, indent=2)}")
1394
1483
 
1395
1484
  async with aiohttp.ClientSession() as session:
1396
- data = await fetch_apollo_data(session, url, headers, dynamic_payload)
1485
+ data = await fetch_apollo_data(
1486
+ session, url, headers, dynamic_payload, tool_config=tool_config
1487
+ )
1397
1488
  if not data:
1398
1489
  logger.error("No data returned from Apollo organizations search.")
1399
1490
  return []
@@ -1852,7 +1943,9 @@ async def search_companies_with_apollo_page(
1852
1943
  url = "https://api.apollo.io/api/v1/organizations/search"
1853
1944
 
1854
1945
  async with aiohttp.ClientSession() as session:
1855
- apollo_response = await fetch_apollo_data(session, url, headers, cleaned_payload)
1946
+ apollo_response = await fetch_apollo_data(
1947
+ session, url, headers, cleaned_payload, tool_config=tool_config
1948
+ )
1856
1949
  if not apollo_response:
1857
1950
  return {
1858
1951
  "current_page": page,
@@ -131,3 +131,77 @@ def get_openai_access_token(tool_config: Optional[List[Dict]] = None) -> str:
131
131
  """Return just the API key (legacy helper)."""
132
132
  _, key, _ = _discover_credentials(tool_config)
133
133
  return key
134
+
135
+
136
+ # ─────────────────────────────────────────────────────────────────────────────
137
+ # 4. Credential-owner derivation (for usage metering)
138
+ # ─────────────────────────────────────────────────────────────────────────────
139
+
140
+ def openai_provider_from_tool_config(tool_config: Optional[List[Dict]]) -> str:
141
+ """Return the provider label selected by the OpenAI credential config.
142
+
143
+ Values are ``"openai"`` or ``"azure_openai"``. This mirrors
144
+ :func:`_discover_credentials` without requiring valid secrets during
145
+ best-effort metering.
146
+ """
147
+ if _extract_config(tool_config, "openai"):
148
+ return "openai"
149
+ if _extract_config(tool_config, "azure_openai"):
150
+ return "azure_openai"
151
+
152
+ # Metadata-only configs appear in unit tests and in some host payloads.
153
+ if tool_config:
154
+ if any(block.get("name") == "openai" for block in tool_config):
155
+ return "openai"
156
+ if any(block.get("name") == "azure_openai" for block in tool_config):
157
+ return "azure_openai"
158
+
159
+ return "openai"
160
+
161
+
162
+ def credential_owner_from_tool_config(
163
+ tool_config: Optional[List[Dict]],
164
+ provider: str,
165
+ ) -> str:
166
+ """Derive the credential owner for ``provider`` from ``tool_config``.
167
+
168
+ Returns one of ``"dhisana_default"``, ``"customer_owned"``,
169
+ ``"partner_owned"``, or ``"unknown"`` (lowercase; the host normalizes to its
170
+ own enum). The value is used only for usage metering and never affects which
171
+ credentials are used for a provider call.
172
+
173
+ Resolution:
174
+ 1. An explicit ``credential_owner`` (or legacy ``owner``) field on the
175
+ provider's config block wins.
176
+ 2. Otherwise a block flagged ``is_default`` is treated as a
177
+ Dhisana-managed default key (``dhisana_default``).
178
+ 3. A present-but-not-default block is treated as customer-supplied
179
+ (``customer_owned``).
180
+ 4. No matching block → ``unknown``.
181
+
182
+ For the OpenAI provider, both the ``openai`` and ``azure_openai`` blocks are
183
+ considered.
184
+ """
185
+ valid = {"dhisana_default", "customer_owned", "partner_owned", "unknown"}
186
+ candidate_names = [provider]
187
+ if provider == "openai":
188
+ candidate_names = ["openai", "azure_openai"]
189
+
190
+ if not tool_config:
191
+ return "unknown"
192
+
193
+ for name in candidate_names:
194
+ block = next((b for b in tool_config if b.get("name") == name), None)
195
+ if not block:
196
+ continue
197
+
198
+ explicit = block.get("credential_owner") or block.get("owner")
199
+ if explicit:
200
+ normalized = str(explicit).strip().lower()
201
+ return normalized if normalized in valid else "unknown"
202
+
203
+ if block.get("is_default"):
204
+ return "dhisana_default"
205
+ return "customer_owned"
206
+
207
+ return "unknown"
@@ -17,7 +17,10 @@ from dhisana.utils import cache_output_tools
17
17
  from dhisana.utils.fetch_openai_config import (
18
18
  _extract_config,
19
19
  create_async_openai_client,
20
+ credential_owner_from_tool_config,
21
+ openai_provider_from_tool_config,
20
22
  )
23
+ from dhisana.utils.usage_hook import ProviderUsageRecord, emit_usage
21
24
 
22
25
  # Import search and scrape utilities for web search tools
23
26
  try:
@@ -150,6 +153,84 @@ def is_context_length_error(error: Exception) -> bool:
150
153
  return "context_length_exceeded" in error_str or "context window" in error_str
151
154
 
152
155
 
156
+ def _usage_attr(usage: Any, name: str, default: int = 0) -> int:
157
+ """Read an integer field from an OpenAI usage object (attr or dict)."""
158
+ if usage is None:
159
+ return default
160
+ value = getattr(usage, name, None)
161
+ if value is None and isinstance(usage, dict):
162
+ value = usage.get(name)
163
+ try:
164
+ return int(value) if value is not None else default
165
+ except (TypeError, ValueError):
166
+ return default
167
+
168
+
169
+ def _cached_input_tokens(usage: Any) -> int:
170
+ """Read cached prompt tokens from a Responses API usage object."""
171
+ if usage is None:
172
+ return 0
173
+ details = getattr(usage, "input_tokens_details", None)
174
+ if details is None and isinstance(usage, dict):
175
+ details = usage.get("input_tokens_details")
176
+ if details is None:
177
+ return 0
178
+ return _usage_attr(details, "cached_tokens", 0)
179
+
180
+
181
+ def _count_web_search_calls(completion: Any) -> int:
182
+ """Count web-search tool invocations in a Responses API completion."""
183
+ count = 0
184
+ output = getattr(completion, "output", None) or []
185
+ for item in output:
186
+ item_type = getattr(item, "type", None)
187
+ if item_type is None and isinstance(item, dict):
188
+ item_type = item.get("type")
189
+ if item_type and "web_search" in str(item_type):
190
+ count += 1
191
+ continue
192
+ if item_type == "function_call":
193
+ name = getattr(item, "name", None)
194
+ if name is None and isinstance(item, dict):
195
+ name = item.get("name")
196
+ if name in {"search_google", "fetch_url_content"}:
197
+ count += 1
198
+ return count
199
+
200
+
201
+ def _emit_openai_usage(
202
+ completion: Any,
203
+ *,
204
+ model: Optional[str],
205
+ tool_config: Optional[List[Dict]],
206
+ web_search_calls: int = 0,
207
+ ) -> None:
208
+ """Build a :class:`ProviderUsageRecord` from ``completion`` and emit it.
209
+
210
+ Best-effort: this never raises into the calling provider path. The hook's
211
+ :func:`emit_usage` is a no-op when no sink is registered.
212
+ """
213
+ try:
214
+ usage = getattr(completion, "usage", None)
215
+ provider = openai_provider_from_tool_config(tool_config)
216
+ record = ProviderUsageRecord(
217
+ provider=provider,
218
+ model_or_endpoint=model,
219
+ credential_owner=credential_owner_from_tool_config(tool_config, provider),
220
+ input_tokens=_usage_attr(usage, "input_tokens", 0),
221
+ cached_input_tokens=_cached_input_tokens(usage),
222
+ output_tokens=_usage_attr(usage, "output_tokens", 0),
223
+ web_search_calls=web_search_calls,
224
+ grounded_search_queries=web_search_calls,
225
+ api_calls=1,
226
+ provider_request_id=getattr(completion, "id", None),
227
+ provider_status=getattr(completion, "status", None),
228
+ )
229
+ emit_usage(record)
230
+ except Exception: # noqa: BLE001 - metering must never break a provider call
231
+ logging.debug("failed to emit OpenAI usage", exc_info=True)
232
+
233
+
153
234
  # ──────────────────────────────────────────────────────────────────────────────
154
235
  # 2. Vector-store utilities (unchanged logic, new client factory)
155
236
  # ──────────────────────────────────────────────────────────────────────────────
@@ -292,6 +373,9 @@ async def get_structured_output_internal(
292
373
  logging.error(f"OpenAI API error: {e}")
293
374
  return f"OpenAI API error: {str(e)}", "API_ERROR"
294
375
 
376
+ # ─── usage metering (best-effort) ───────────────────────────────────
377
+ _emit_openai_usage(completion, model=model, tool_config=tool_config)
378
+
295
379
  # ─── handle model output ────────────────────────────────────────────
296
380
  return _parse_completion_response(completion, response_format, cache_key)
297
381
 
@@ -365,6 +449,12 @@ async def _get_structured_output_with_web_search(
365
449
  logging.debug(f"[WebSearch] Sending request attempt {attempt + 1}")
366
450
  completion = await client_async.responses.create(**request)
367
451
  logging.info(f"[WebSearch] Response received, output items: {len(completion.output) if completion and completion.output else 0}")
452
+ _emit_openai_usage(
453
+ completion,
454
+ model=model,
455
+ tool_config=tool_config,
456
+ web_search_calls=_count_web_search_calls(completion),
457
+ )
368
458
  break
369
459
  except (RateLimitError, OpenAIError) as e:
370
460
  is_rl = (
@@ -437,7 +527,7 @@ async def _get_structured_output_with_web_search(
437
527
  logging.info(f"[WebSearch] Executed tool {func_name}, result length: {len(tool_result)}")
438
528
 
439
529
  logging.info(f"[WebSearch] Tool loop completed after {tool_iteration} iteration(s)")
440
-
530
+
441
531
  # Parse and return the final response
442
532
  result, status = _parse_completion_response(completion, response_format, cache_key)
443
533
  logging.info(f"[WebSearch] Parse result status: {status}")
@@ -606,6 +696,14 @@ async def get_structured_output_with_mcp(
606
696
  if not completion:
607
697
  return "OpenAI request retry loop failed", "API_ERROR"
608
698
 
699
+ # ─── usage metering (best-effort) ─────────────────────────────────────────
700
+ _emit_openai_usage(
701
+ completion,
702
+ model=model,
703
+ tool_config=tool_config,
704
+ web_search_calls=_count_web_search_calls(completion),
705
+ )
706
+
609
707
  # ─── Parse the model’s structured output ──────────────────────────────────
610
708
  if not (completion and completion.output):
611
709
  return "No output returned", "FAIL"
@@ -724,6 +822,9 @@ async def get_structured_output_with_assistant_and_vector_store(
724
822
  store=False,
725
823
  )
726
824
 
825
+ # ─── usage metering (best-effort) ─────────────────────────────────────
826
+ _emit_openai_usage(completion, model=model, tool_config=tool_config)
827
+
727
828
  if completion and completion.output and len(completion.output) > 0:
728
829
  raw_text = None
729
830
  for out in completion.output:
@@ -0,0 +1,103 @@
1
+ """
2
+ Provider-usage hook for the Dhisana SDK.
3
+
4
+ This module provides a lightweight, dependency-free mechanism for the SDK to
5
+ emit provider-usage telemetry (token counts, API-call counts, record counts) to
6
+ an optional sink registered by the host application (for example, the agentops
7
+ backend's metering pipeline).
8
+
9
+ Design constraints:
10
+ * This module MUST NOT import the backend, a database driver, or the
11
+ metering ledger. It depends only on the Python standard library.
12
+ * :func:`emit_usage` is best-effort: it swallows *all* exceptions so that
13
+ metering can never break a provider call.
14
+ * Backward compatible: with no sink registered, the hook is a no-op.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from contextvars import ContextVar, Token
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Callable, Dict, Optional
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ @dataclass
28
+ class ProviderUsageRecord:
29
+ """A single provider-usage observation emitted by the SDK.
30
+
31
+ Field names mirror the host metering ledger's ``ProviderUsageInput`` so the
32
+ host can duck-type this record without importing the SDK. The host normalizes
33
+ ``credential_owner`` and fills any missing field with a safe zero default, so
34
+ new fields can be added here without breaking older hosts.
35
+ """
36
+
37
+ provider: str
38
+ model_or_endpoint: Optional[str] = None
39
+ credential_owner: str = "unknown"
40
+ input_tokens: int = 0
41
+ cached_input_tokens: int = 0
42
+ output_tokens: int = 0
43
+ embedding_tokens: int = 0
44
+ grounded_search_queries: int = 0
45
+ web_search_calls: int = 0
46
+ api_calls: int = 1
47
+ provider_credit_units: Optional[float] = None
48
+ records_attempted: int = 0
49
+ records_returned: int = 0
50
+ records_enriched: int = 0
51
+ provider_request_id: Optional[str] = None
52
+ provider_status: Optional[str] = None
53
+ metadata: Dict[str, Any] = field(default_factory=dict)
54
+
55
+
56
+ UsageSink = Callable[[ProviderUsageRecord], None]
57
+
58
+ _usage_sink: ContextVar[Optional[UsageSink]] = ContextVar(
59
+ "dhisana_usage_sink", default=None
60
+ )
61
+
62
+
63
+ def set_usage_sink(sink: Optional[UsageSink]) -> Token:
64
+ """Register ``sink`` for the current context and return a reset token.
65
+
66
+ The token must be passed to :func:`reset_usage_sink` to restore the previous
67
+ sink (typically in a ``finally`` block around an operation scope).
68
+ """
69
+ return _usage_sink.set(sink)
70
+
71
+
72
+ def reset_usage_sink(token: Token) -> None:
73
+ """Restore the previous sink using the token from :func:`set_usage_sink`.
74
+
75
+ A token created in a different context is ignored rather than raised so that
76
+ cleanup is always safe.
77
+ """
78
+ try:
79
+ _usage_sink.reset(token)
80
+ except Exception: # noqa: BLE001 - cleanup must always be safe
81
+ # Token belongs to a different context or was already consumed.
82
+ pass
83
+
84
+
85
+ def current_usage_sink() -> Optional[UsageSink]:
86
+ """Return the sink registered for the current context, or ``None``."""
87
+ return _usage_sink.get()
88
+
89
+
90
+ def emit_usage(record: ProviderUsageRecord) -> None:
91
+ """Emit ``record`` to the registered sink, if any.
92
+
93
+ Best-effort by contract: any exception (a missing sink, a failing sink, or a
94
+ malformed record) is swallowed so that metering can never break the provider
95
+ call that produced the usage.
96
+ """
97
+ try:
98
+ sink = _usage_sink.get()
99
+ if sink is None:
100
+ return
101
+ sink(record)
102
+ except Exception: # noqa: BLE001 - metering must never break the caller
103
+ logger.debug("emit_usage sink raised; ignoring", exc_info=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dhisana
3
- Version: 0.0.1.dev314
3
+ Version: 0.0.1.dev315
4
4
  Summary: A Python SDK for Dhisana AI Platform
5
5
  Home-page: https://github.com/dhisana-ai/dhisana-python-sdk
6
6
  Author: Admin
@@ -93,6 +93,7 @@ src/dhisana/utils/serperdev_search.py
93
93
  src/dhisana/utils/smtp_email_tools.py
94
94
  src/dhisana/utils/test_connect.py
95
95
  src/dhisana/utils/trasform_json.py
96
+ src/dhisana/utils/usage_hook.py
96
97
  src/dhisana/utils/web_download_parse_tools.py
97
98
  src/dhisana/utils/workflow_code_model.py
98
99
  src/dhisana/utils/zoominfo_tools.py
@@ -109,6 +110,7 @@ tests/test_agent_tools.py
109
110
  tests/test_apollo_company_search.py
110
111
  tests/test_apollo_json_error_handling.py
111
112
  tests/test_apollo_lead_search.py
113
+ tests/test_apollo_usage_emit.py
112
114
  tests/test_connectivity.py
113
115
  tests/test_email_body_utils.py
114
116
  tests/test_generate_email.py
@@ -119,9 +121,11 @@ tests/test_linkedin_serper.py
119
121
  tests/test_mailreach.py
120
122
  tests/test_mcp_connectivity.py
121
123
  tests/test_normalize_graph_datetime.py
124
+ tests/test_openai_usage_emit.py
122
125
  tests/test_proxycurl_get_company_search_id.py
123
126
  tests/test_proxycurl_job_count.py
124
127
  tests/test_reply_html_format.py
125
128
  tests/test_reply_thread_fallback.py
126
129
  tests/test_send_email_recipients.py
127
- tests/test_structured_output_with_mcp.py
130
+ tests/test_structured_output_with_mcp.py
131
+ tests/test_usage_hook.py