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.
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/PKG-INFO +1 -1
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/setup.py +1 -1
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/apollo_tools.py +98 -5
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/fetch_openai_config.py +74 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_structured_output_internal.py +102 -1
- dhisana-0.0.1.dev315/src/dhisana/utils/usage_hook.py +103 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana.egg-info/PKG-INFO +1 -1
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana.egg-info/SOURCES.txt +5 -1
- dhisana-0.0.1.dev315/tests/test_apollo_usage_emit.py +175 -0
- dhisana-0.0.1.dev315/tests/test_openai_usage_emit.py +239 -0
- dhisana-0.0.1.dev315/tests/test_usage_hook.py +75 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/README.md +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/pyproject.toml +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/setup.cfg +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/__init__.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/cli/__init__.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/cli/cli.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/cli/datasets.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/cli/models.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/cli/predictions.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/schemas/__init__.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/schemas/common.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/schemas/sales.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/ui/__init__.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/ui/components.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/__init__.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/add_mapping.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/agent_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/assistant_tool_tag.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/built_with_api_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/cache_output_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/cache_output_tools_local.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/check_email_validity_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/check_for_intent_signal.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/clay_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/clean_properties.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/company_utils.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/compose_salesnav_query.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/compose_search_query.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/composite_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/dataframe_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/domain_parser.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/email_body_utils.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/email_parse_helpers.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/email_provider.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/enrich_lead_information.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/field_validators.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/g2_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_content.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_custom_message.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_email.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_email_response.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_flow.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/generate_sms_whatsapp.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/google_custom_search.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/google_oauth_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/google_workspace_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/hubspot_clearbit.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/instantly_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/linkedin_crawler.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/lusha_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/mailgun_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/mailreach_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/microsoft365_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openai_helpers.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/profile.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/proxy_curl_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/python_function_to_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/research_lead.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/search_router.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/search_router_jobs.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/sendgrid_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serarch_router_local_business.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serpapi_google_search.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serpapi_search_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serperdev_local_business.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/serperdev_search.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/smtp_email_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/test_connect.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/trasform_json.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/web_download_parse_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/workflow_code_model.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/utils/zoominfo_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/workflow/__init__.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/workflow/agent.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/workflow/flow.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/workflow/task.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana/workflow/test.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana.egg-info/dependency_links.txt +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana.egg-info/entry_points.txt +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana.egg-info/requires.txt +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/src/dhisana.egg-info/top_level.txt +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_agent_tools.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_apollo_company_search.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_apollo_json_error_handling.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_apollo_lead_search.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_connectivity.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_email_body_utils.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_generate_email.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_generate_sms_whatsapp.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_google_document.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_hubspot_call_logs.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_linkedin_serper.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_mailreach.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_mcp_connectivity.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_normalize_graph_datetime.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_proxycurl_get_company_search_id.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_proxycurl_job_count.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_reply_html_format.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_reply_thread_fallback.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_send_email_recipients.py +0 -0
- {dhisana-0.0.1.dev314 → dhisana-0.0.1.dev315}/tests/test_structured_output_with_mcp.py +0 -0
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
|
@@ -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
|