dhisana 0.0.1.dev116__py3-none-any.whl → 0.0.1.dev236__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dhisana/schemas/common.py +10 -1
- dhisana/schemas/sales.py +203 -22
- dhisana/utils/add_mapping.py +0 -2
- dhisana/utils/apollo_tools.py +739 -119
- dhisana/utils/built_with_api_tools.py +4 -2
- dhisana/utils/check_email_validity_tools.py +35 -18
- dhisana/utils/check_for_intent_signal.py +1 -2
- dhisana/utils/check_linkedin_url_validity.py +34 -8
- dhisana/utils/clay_tools.py +3 -2
- dhisana/utils/clean_properties.py +1 -4
- dhisana/utils/compose_salesnav_query.py +0 -1
- dhisana/utils/compose_search_query.py +7 -3
- dhisana/utils/composite_tools.py +0 -1
- dhisana/utils/dataframe_tools.py +2 -2
- dhisana/utils/email_body_utils.py +72 -0
- dhisana/utils/email_provider.py +174 -35
- dhisana/utils/enrich_lead_information.py +183 -53
- dhisana/utils/fetch_openai_config.py +129 -0
- dhisana/utils/field_validators.py +1 -1
- dhisana/utils/g2_tools.py +0 -1
- dhisana/utils/generate_content.py +0 -1
- dhisana/utils/generate_email.py +68 -23
- dhisana/utils/generate_email_response.py +294 -46
- dhisana/utils/generate_flow.py +0 -1
- dhisana/utils/generate_linkedin_connect_message.py +9 -2
- dhisana/utils/generate_linkedin_response_message.py +137 -66
- dhisana/utils/generate_structured_output_internal.py +317 -164
- dhisana/utils/google_custom_search.py +150 -44
- dhisana/utils/google_oauth_tools.py +721 -0
- dhisana/utils/google_workspace_tools.py +278 -54
- dhisana/utils/hubspot_clearbit.py +3 -1
- dhisana/utils/hubspot_crm_tools.py +718 -272
- dhisana/utils/instantly_tools.py +3 -1
- dhisana/utils/lusha_tools.py +10 -7
- dhisana/utils/mailgun_tools.py +150 -0
- dhisana/utils/microsoft365_tools.py +447 -0
- dhisana/utils/openai_assistant_and_file_utils.py +121 -177
- dhisana/utils/openai_helpers.py +8 -6
- dhisana/utils/parse_linkedin_messages_txt.py +1 -3
- dhisana/utils/profile.py +37 -0
- dhisana/utils/proxy_curl_tools.py +377 -76
- dhisana/utils/proxycurl_search_leads.py +426 -0
- dhisana/utils/research_lead.py +3 -3
- dhisana/utils/sales_navigator_crawler.py +1 -6
- dhisana/utils/salesforce_crm_tools.py +323 -50
- dhisana/utils/search_router.py +131 -0
- dhisana/utils/search_router_jobs.py +51 -0
- dhisana/utils/sendgrid_tools.py +126 -91
- dhisana/utils/serarch_router_local_business.py +75 -0
- dhisana/utils/serpapi_additional_tools.py +290 -0
- dhisana/utils/serpapi_google_jobs.py +117 -0
- dhisana/utils/serpapi_google_search.py +188 -0
- dhisana/utils/serpapi_local_business_search.py +129 -0
- dhisana/utils/serpapi_search_tools.py +360 -432
- dhisana/utils/serperdev_google_jobs.py +125 -0
- dhisana/utils/serperdev_local_business.py +154 -0
- dhisana/utils/serperdev_search.py +233 -0
- dhisana/utils/smtp_email_tools.py +178 -18
- dhisana/utils/test_connect.py +1603 -130
- dhisana/utils/trasform_json.py +3 -3
- dhisana/utils/web_download_parse_tools.py +0 -1
- dhisana/utils/zoominfo_tools.py +2 -3
- dhisana/workflow/test.py +1 -1
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/METADATA +1 -1
- dhisana-0.0.1.dev236.dist-info/RECORD +100 -0
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/WHEEL +1 -1
- dhisana-0.0.1.dev116.dist-info/RECORD +0 -83
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/entry_points.txt +0 -0
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/top_level.txt +0 -0
|
@@ -1,284 +1,443 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import hashlib
|
|
3
3
|
import json
|
|
4
|
-
import os
|
|
5
|
-
import re
|
|
6
|
-
import time
|
|
7
4
|
import logging
|
|
8
|
-
import
|
|
9
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
import random
|
|
10
6
|
|
|
11
7
|
from fastapi import HTTPException
|
|
12
|
-
from pydantic import BaseModel
|
|
8
|
+
from pydantic import BaseModel
|
|
13
9
|
|
|
14
|
-
import
|
|
15
|
-
from dhisana.utils import cache_output_tools
|
|
16
|
-
from dhisana.utils.openai_helpers import get_openai_access_token
|
|
10
|
+
from openai import OpenAIError, RateLimitError
|
|
17
11
|
from openai.lib._parsing._completions import type_to_response_format_param
|
|
12
|
+
|
|
18
13
|
from json_repair import repair_json
|
|
19
14
|
|
|
15
|
+
from dhisana.utils import cache_output_tools
|
|
16
|
+
from dhisana.utils.fetch_openai_config import (
|
|
17
|
+
_extract_config,
|
|
18
|
+
create_async_openai_client,
|
|
19
|
+
)
|
|
20
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
21
|
+
|
|
22
|
+
from openai import OpenAIError, RateLimitError
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
20
25
|
|
|
21
26
|
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
27
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
# 2. Vector-store utilities (unchanged logic, new client factory)
|
|
29
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
25
31
|
|
|
26
32
|
async def get_vector_store_object(
|
|
27
|
-
vector_store_id: str,
|
|
28
|
-
tool_config: Optional[List[Dict]] = None
|
|
33
|
+
vector_store_id: str, tool_config: Optional[List[Dict]] = None
|
|
29
34
|
) -> Dict:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
client_async = create_async_openai_client(tool_config)
|
|
36
|
+
try:
|
|
37
|
+
return await client_async.vector_stores.retrieve(vector_store_id=vector_store_id)
|
|
38
|
+
except OpenAIError as e:
|
|
39
|
+
logging.error(f"Error retrieving vector store {vector_store_id}: {e}")
|
|
40
|
+
return None
|
|
34
41
|
|
|
35
42
|
async def list_vector_store_files(
|
|
36
|
-
vector_store_id: str,
|
|
37
|
-
tool_config: Optional[List[Dict]] = None
|
|
43
|
+
vector_store_id: str, tool_config: Optional[List[Dict]] = None
|
|
38
44
|
) -> List:
|
|
39
|
-
|
|
40
|
-
client_async = openai.AsyncOpenAI(api_key=openai_key)
|
|
45
|
+
client_async = create_async_openai_client(tool_config)
|
|
41
46
|
page = await client_async.vector_stores.files.list(vector_store_id=vector_store_id)
|
|
42
47
|
return page.data
|
|
43
48
|
|
|
44
49
|
|
|
50
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
# 3. Core logic – only the client initialisation lines changed
|
|
52
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
45
54
|
async def get_structured_output_internal(
|
|
46
55
|
prompt: str,
|
|
47
56
|
response_format: BaseModel,
|
|
48
57
|
effort: str = "low",
|
|
49
58
|
use_web_search: bool = False,
|
|
50
|
-
model: str = "gpt-
|
|
51
|
-
tool_config: Optional[List[Dict]] = None
|
|
59
|
+
model: str = "gpt-5.1-chat",
|
|
60
|
+
tool_config: Optional[List[Dict]] = None,
|
|
61
|
+
use_cache: bool = True
|
|
52
62
|
):
|
|
53
63
|
"""
|
|
54
|
-
Makes a direct call to the new Responses API for structured output
|
|
55
|
-
bypassing file_search. No vector store usage, no chain-of-thought.
|
|
56
|
-
|
|
57
|
-
Updated behavior:
|
|
58
|
-
- If there's a JSON parsing error on the initial parse, we try using
|
|
59
|
-
jsonfix (repair_json) to repair minor errors, then re-parse.
|
|
60
|
-
- If parsing still fails, return "FAIL".
|
|
61
|
-
- If there's a refusal object from the model output,
|
|
62
|
-
we log the refusal reason and return "FAIL".
|
|
63
|
-
"""
|
|
64
|
+
Makes a direct call to the new Responses API for structured output.
|
|
64
65
|
|
|
66
|
+
On a 429 (rate-limit) error the call is retried once after
|
|
67
|
+
20 s + random exponential back-off.
|
|
68
|
+
"""
|
|
65
69
|
try:
|
|
66
|
-
#
|
|
70
|
+
# ─── caching bookkeeping ────────────────────────────────────────────
|
|
67
71
|
response_type_str = response_format.__name__
|
|
68
|
-
message_hash = hashlib.md5(prompt.encode(
|
|
69
|
-
response_type_hash = hashlib.md5(response_type_str.encode(
|
|
72
|
+
message_hash = hashlib.md5(prompt.encode("utf-8")).hexdigest()
|
|
73
|
+
response_type_hash = hashlib.md5(response_type_str.encode("utf-8")).hexdigest()
|
|
70
74
|
cache_key = f"{message_hash}:{response_type_hash}"
|
|
71
75
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return parsed_cached_response, "SUCCESS"
|
|
76
|
+
if use_cache:
|
|
77
|
+
cached_response = cache_output_tools.retrieve_output(
|
|
78
|
+
"get_structured_output_internal", cache_key
|
|
79
|
+
)
|
|
80
|
+
if cached_response is not None:
|
|
81
|
+
parsed_cached_response = response_format.parse_raw(cached_response)
|
|
82
|
+
return parsed_cached_response, "SUCCESS"
|
|
80
83
|
|
|
81
|
-
#
|
|
84
|
+
# ─── JSON schema for function calling ───────────────────────────────
|
|
82
85
|
schema = type_to_response_format_param(response_format)
|
|
83
86
|
json_schema_format = {
|
|
84
87
|
"name": response_type_str,
|
|
85
88
|
"type": "json_schema",
|
|
86
|
-
"schema": schema[
|
|
89
|
+
"schema": schema["json_schema"]["schema"],
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
client_async = openai.AsyncOpenAI(api_key=openai_key)
|
|
92
|
+
# ─── client initialisation (NEW) ────────────────────────────────────
|
|
93
|
+
client_async = create_async_openai_client(tool_config)
|
|
92
94
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
store=False,
|
|
105
|
-
)
|
|
106
|
-
else:
|
|
107
|
-
# Only set reasoning if model starts with 'o'
|
|
108
|
-
if model.startswith("o"):
|
|
109
|
-
completion = await client_async.responses.create(
|
|
95
|
+
openai_cfg = _extract_config(tool_config, "openai")
|
|
96
|
+
# TODO: Azure OpenAI does not support web_search yet
|
|
97
|
+
if not openai_cfg:
|
|
98
|
+
use_web_search = False
|
|
99
|
+
|
|
100
|
+
# -------------------------------------------------------------------
|
|
101
|
+
# Internal helper to perform ONE attempt
|
|
102
|
+
# -------------------------------------------------------------------
|
|
103
|
+
async def _make_request():
|
|
104
|
+
if use_web_search and model.startswith("gpt-"):
|
|
105
|
+
return await client_async.responses.create(
|
|
110
106
|
input=[
|
|
111
107
|
{"role": "system", "content": "You are a helpful AI. Output JSON only."},
|
|
112
|
-
{"role": "user", "content": prompt}
|
|
108
|
+
{"role": "user", "content": prompt},
|
|
113
109
|
],
|
|
114
110
|
model=model,
|
|
115
|
-
reasoning={"effort": effort},
|
|
116
111
|
text={"format": json_schema_format},
|
|
112
|
+
tool_choice="required",
|
|
113
|
+
tools=[{"type": "web_search_preview"}],
|
|
117
114
|
store=False,
|
|
118
115
|
)
|
|
119
|
-
|
|
120
|
-
|
|
116
|
+
if model.startswith("o"): # reasoning param only for "o" family
|
|
117
|
+
return await client_async.responses.create(
|
|
121
118
|
input=[
|
|
122
119
|
{"role": "system", "content": "You are a helpful AI. Output JSON only."},
|
|
123
|
-
{"role": "user", "content": prompt}
|
|
120
|
+
{"role": "user", "content": prompt},
|
|
124
121
|
],
|
|
125
122
|
model=model,
|
|
123
|
+
reasoning={"effort": effort},
|
|
126
124
|
text={"format": json_schema_format},
|
|
127
125
|
store=False,
|
|
128
126
|
)
|
|
127
|
+
return await client_async.responses.create(
|
|
128
|
+
input=[
|
|
129
|
+
{"role": "system", "content": "You are a helpful AI. Output JSON only."},
|
|
130
|
+
{"role": "user", "content": prompt},
|
|
131
|
+
],
|
|
132
|
+
model=model,
|
|
133
|
+
text={"format": json_schema_format},
|
|
134
|
+
store=False,
|
|
135
|
+
)
|
|
129
136
|
|
|
130
|
-
#
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
# -------------------------------------------------------------------
|
|
138
|
+
# Call with one retry on 429
|
|
139
|
+
# -------------------------------------------------------------------
|
|
140
|
+
max_retries = 1
|
|
141
|
+
attempt = 0
|
|
142
|
+
while True:
|
|
143
|
+
try:
|
|
144
|
+
completion = await _make_request()
|
|
145
|
+
break # success → exit loop
|
|
146
|
+
except (RateLimitError, OpenAIError) as e:
|
|
147
|
+
# Detect 429 / rate-limit
|
|
148
|
+
is_rl = (
|
|
149
|
+
isinstance(e, RateLimitError)
|
|
150
|
+
or getattr(e, "status_code", None) == 429
|
|
151
|
+
or "rate_limit" in str(e).lower()
|
|
152
|
+
)
|
|
153
|
+
if is_rl and attempt < max_retries:
|
|
154
|
+
attempt += 1
|
|
155
|
+
# 20 s base + exponential jitter
|
|
156
|
+
wait_time = 20 + random.uniform(0, 2 ** attempt)
|
|
157
|
+
logging.warning(
|
|
158
|
+
f"Rate-limit hit (429). Waiting {wait_time:.2f}s then retrying "
|
|
159
|
+
f"({attempt}/{max_retries})."
|
|
160
|
+
)
|
|
161
|
+
await asyncio.sleep(wait_time)
|
|
162
|
+
continue # retry once
|
|
163
|
+
logging.error(f"OpenAI API error: {e}")
|
|
164
|
+
raise HTTPException(status_code=502, detail="Error communicating with the OpenAI API.")
|
|
133
165
|
|
|
134
|
-
|
|
166
|
+
# ─── handle model output (unchanged) ────────────────────────────────
|
|
167
|
+
if completion and completion.output and len(completion.output) > 0:
|
|
168
|
+
raw_text = None
|
|
135
169
|
for out in completion.output:
|
|
136
|
-
if out.type ==
|
|
170
|
+
if out.type == "message" and out.content:
|
|
137
171
|
for content_item in out.content:
|
|
138
|
-
|
|
139
|
-
if hasattr(content_item, 'text'):
|
|
172
|
+
if hasattr(content_item, "text"):
|
|
140
173
|
raw_text = content_item.text
|
|
141
174
|
break
|
|
142
175
|
else:
|
|
143
|
-
logging.warning("request refused
|
|
176
|
+
logging.warning("request refused: %s", str(content_item))
|
|
144
177
|
return "Request refused.", "FAIL"
|
|
145
178
|
if raw_text:
|
|
146
179
|
break
|
|
147
180
|
|
|
148
|
-
# If no raw_text was found
|
|
149
181
|
if not raw_text or not raw_text.strip():
|
|
150
182
|
return "No text returned (possibly refusal or empty response)", "FAIL"
|
|
151
183
|
|
|
152
|
-
# 6. Attempt to parse the returned JSON
|
|
153
184
|
try:
|
|
154
185
|
parsed_obj = response_format.parse_raw(raw_text)
|
|
155
|
-
# Cache the successful result
|
|
156
186
|
cache_output_tools.cache_output(
|
|
157
|
-
"get_structured_output_internal",
|
|
158
|
-
cache_key,
|
|
159
|
-
parsed_obj.json()
|
|
187
|
+
"get_structured_output_internal", cache_key, parsed_obj.json()
|
|
160
188
|
)
|
|
161
189
|
return parsed_obj, "SUCCESS"
|
|
162
190
|
|
|
163
|
-
except Exception
|
|
164
|
-
# If initial parse fails, attempt to fix JSON
|
|
191
|
+
except Exception:
|
|
165
192
|
logging.warning("ERROR: Could not parse JSON from model output.")
|
|
166
|
-
logging.warning("Attempting to fix JSON format using jsonfix...")
|
|
167
|
-
logging.warning(raw_text)
|
|
168
|
-
|
|
169
193
|
try:
|
|
170
|
-
fixed_json = repair_json(raw_text)
|
|
194
|
+
fixed_json = repair_json(raw_text)
|
|
171
195
|
parsed_obj = response_format.parse_raw(fixed_json)
|
|
172
|
-
|
|
173
|
-
# Cache the successful result after fix
|
|
174
196
|
cache_output_tools.cache_output(
|
|
175
|
-
"get_structured_output_internal",
|
|
176
|
-
cache_key,
|
|
177
|
-
parsed_obj.json()
|
|
197
|
+
"get_structured_output_internal", cache_key, parsed_obj.json()
|
|
178
198
|
)
|
|
179
199
|
return parsed_obj, "SUCCESS"
|
|
180
|
-
|
|
181
200
|
except Exception as e2:
|
|
182
|
-
logging.warning(
|
|
183
|
-
"ERROR: JSON parse still failed even after attempting to fix formatting."
|
|
184
|
-
)
|
|
185
|
-
logging.warning(str(e2))
|
|
201
|
+
logging.warning("JSON repair failed: %s", str(e2))
|
|
186
202
|
return raw_text, "FAIL"
|
|
187
203
|
else:
|
|
188
|
-
# No output
|
|
189
204
|
return "No output returned", "FAIL"
|
|
190
205
|
|
|
191
|
-
except
|
|
206
|
+
except OpenAIError as e:
|
|
192
207
|
logging.error(f"OpenAI API error: {e}")
|
|
193
|
-
raise HTTPException(
|
|
194
|
-
status_code=502,
|
|
195
|
-
detail="Error communicating with the OpenAI API."
|
|
196
|
-
)
|
|
208
|
+
raise HTTPException(status_code=502, detail="Error communicating with the OpenAI API.")
|
|
197
209
|
except Exception as e:
|
|
198
210
|
logging.error(f"Unexpected error: {e}")
|
|
199
|
-
raise HTTPException(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
211
|
+
raise HTTPException(status_code=500, detail="Unexpected server error.")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def get_structured_output_with_mcp(
|
|
216
|
+
prompt: str,
|
|
217
|
+
response_format: BaseModel,
|
|
218
|
+
effort: str = "low",
|
|
219
|
+
use_web_search: bool = False,
|
|
220
|
+
model: str = "gpt-5.1-chat",
|
|
221
|
+
tool_config: Optional[List[Dict[str, Any]]] = None,
|
|
222
|
+
) -> Tuple[Union[BaseModel, str], str]:
|
|
223
|
+
"""
|
|
224
|
+
Sends a JSON-schema-constrained prompt to an OpenAI model, with an MCP
|
|
225
|
+
server configured as a `tool`.
|
|
226
|
+
|
|
227
|
+
* If the model returns a tool call that *requires approval*, the function
|
|
228
|
+
immediately returns a minimal object that satisfies `response_format`
|
|
229
|
+
with `"APPROVAL_PENDING"` in `response_summary`, along with the status
|
|
230
|
+
string ``"PENDING_APPROVAL"``.
|
|
231
|
+
* Once the tool has executed (the provider returns `mcp_tool_result`) or
|
|
232
|
+
the model replies directly with the JSON payload, the parsed object is
|
|
233
|
+
cached and returned with status ``"SUCCESS"``.
|
|
234
|
+
* Any MCP tool-listing messages are ignored.
|
|
235
|
+
"""
|
|
236
|
+
# ─── Validate MCP configuration ────────────────────────────────────────────
|
|
237
|
+
mcp_cfg = _extract_config(tool_config, "mcpServer") or {}
|
|
238
|
+
server_label: str = mcp_cfg.get("serverLabel", "")
|
|
239
|
+
server_url: str | None = mcp_cfg.get("serverUrl")
|
|
240
|
+
api_key_header_name: str | None = mcp_cfg.get("apiKeyHeaderName")
|
|
241
|
+
api_key_header_value: str | None = mcp_cfg.get("apiKeyHeaderValue")
|
|
242
|
+
|
|
243
|
+
if not (server_url and api_key_header_name and api_key_header_value):
|
|
244
|
+
raise HTTPException(400, detail="MCP server configuration incomplete.")
|
|
245
|
+
|
|
246
|
+
# ─── Cache key (prompt + schema) ──────────────────────────────────────────
|
|
247
|
+
response_type_str = response_format.__name__
|
|
248
|
+
cache_key = (
|
|
249
|
+
f"{hashlib.md5(prompt.encode()).hexdigest()}:"
|
|
250
|
+
f"{hashlib.md5(response_type_str.encode()).hexdigest()}"
|
|
251
|
+
)
|
|
252
|
+
if (cached := cache_output_tools.retrieve_output("get_structured_output_with_mcp", cache_key)):
|
|
253
|
+
return response_format.parse_raw(cached), "SUCCESS"
|
|
254
|
+
|
|
255
|
+
# ─── JSON-schema format for `text` param ──────────────────────────────────
|
|
256
|
+
schema_cfg = type_to_response_format_param(response_format)
|
|
257
|
+
json_schema_format = {
|
|
258
|
+
"name": response_type_str,
|
|
259
|
+
"type": "json_schema",
|
|
260
|
+
"schema": schema_cfg["json_schema"]["schema"],
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
# ─── Build tool list ──────────────────────────────────────────────────────
|
|
264
|
+
tools: List[Dict[str, Any]] = [
|
|
265
|
+
{
|
|
266
|
+
"type": "mcp",
|
|
267
|
+
"server_label": server_label,
|
|
268
|
+
"server_url": server_url,
|
|
269
|
+
"headers": {api_key_header_name: api_key_header_value},
|
|
270
|
+
"require_approval": "never"
|
|
271
|
+
}
|
|
272
|
+
]
|
|
273
|
+
if use_web_search and model.startswith("gpt-"):
|
|
274
|
+
tools.append({"type": "web_search_preview"})
|
|
203
275
|
|
|
276
|
+
# ─── Async OpenAI client ──────────────────────────────────────────────────
|
|
277
|
+
client_async = create_async_openai_client(tool_config)
|
|
204
278
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
279
|
+
async def _make_request():
|
|
280
|
+
kwargs: Dict[str, Any] = {
|
|
281
|
+
"input": [
|
|
282
|
+
{"role": "system", "content": "You are a helpful AI. Output JSON only."},
|
|
283
|
+
{"role": "user", "content": prompt},
|
|
284
|
+
],
|
|
285
|
+
"model": model,
|
|
286
|
+
"text": {"format": json_schema_format},
|
|
287
|
+
"store": False,
|
|
288
|
+
"tools": tools,
|
|
289
|
+
"tool_choice": "required",
|
|
290
|
+
}
|
|
291
|
+
if model.startswith("o"):
|
|
292
|
+
kwargs["reasoning"] = {"effort": effort}
|
|
293
|
+
return await client_async.responses.create(**kwargs)
|
|
294
|
+
|
|
295
|
+
# ─── Retry once for 429s ──────────────────────────────────────────────────
|
|
296
|
+
for attempt in range(2):
|
|
297
|
+
try:
|
|
298
|
+
completion = await _make_request()
|
|
299
|
+
break
|
|
300
|
+
except (RateLimitError, OpenAIError) as exc:
|
|
301
|
+
if attempt == 0 and (
|
|
302
|
+
isinstance(exc, RateLimitError)
|
|
303
|
+
or getattr(exc, "status_code", None) == 429
|
|
304
|
+
or "rate_limit" in str(exc).lower()
|
|
305
|
+
):
|
|
306
|
+
sleep_for = 20 + random.uniform(0, 2.0)
|
|
307
|
+
logging.warning("429 rate-limit hit; retrying in %.1fs", sleep_for)
|
|
308
|
+
await asyncio.sleep(sleep_for)
|
|
309
|
+
continue
|
|
310
|
+
logging.error("OpenAI API error: %s", exc)
|
|
311
|
+
raise HTTPException(502, detail="Error communicating with the OpenAI API.") from exc
|
|
312
|
+
else: # pragma: no cover
|
|
313
|
+
raise HTTPException(502, detail="OpenAI request retry loop failed.")
|
|
314
|
+
|
|
315
|
+
# ─── Parse the model’s structured output ──────────────────────────────────
|
|
316
|
+
if not (completion and completion.output):
|
|
317
|
+
return "No output returned", "FAIL"
|
|
318
|
+
|
|
319
|
+
raw_text: str | None = None
|
|
320
|
+
status: str = "SUCCESS"
|
|
321
|
+
|
|
322
|
+
for out in completion.output:
|
|
323
|
+
# 1️⃣ Human approval required
|
|
324
|
+
if out.type == "mcp_approval_request":
|
|
325
|
+
logging.info("Tool call '%s' awaiting approval", out.name)
|
|
326
|
+
placeholder_obj = response_format.parse_obj({"response_summary": "APPROVAL_PENDING"})
|
|
327
|
+
return placeholder_obj, "PENDING_APPROVAL"
|
|
328
|
+
|
|
329
|
+
# 2️⃣ Ignore capability listings
|
|
330
|
+
if out.type == "mcp_list_tools":
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
# 3️⃣ Tool finished: provider returned result object
|
|
334
|
+
if out.type == "mcp_tool_result":
|
|
335
|
+
try:
|
|
336
|
+
# If result already matches schema, emit directly
|
|
337
|
+
raw_text = (
|
|
338
|
+
json.dumps(out.result)
|
|
339
|
+
if isinstance(out.result, (dict, list))
|
|
340
|
+
else json.dumps({"response_summary": str(out.result)})
|
|
341
|
+
)
|
|
342
|
+
except Exception: # pragma: no cover
|
|
343
|
+
raw_text = json.dumps({"response_summary": "TOOL_EXECUTION_COMPLETE"})
|
|
344
|
+
break
|
|
345
|
+
|
|
346
|
+
# 4️⃣ Regular assistant message
|
|
347
|
+
if out.type == "message" and out.content:
|
|
348
|
+
for c in out.content:
|
|
349
|
+
if hasattr(c, "text") and c.text:
|
|
350
|
+
raw_text = c.text
|
|
351
|
+
break
|
|
352
|
+
if raw_text:
|
|
353
|
+
break
|
|
354
|
+
|
|
355
|
+
# 5️⃣ Anything else
|
|
356
|
+
logging.debug("Unhandled output type: %s", out.type)
|
|
357
|
+
|
|
358
|
+
if not raw_text or not raw_text.strip():
|
|
359
|
+
return "No response", status
|
|
360
|
+
|
|
361
|
+
# ─── Convert JSON -> pydantic object, with repair fallback ────────────────
|
|
362
|
+
try:
|
|
363
|
+
parsed_obj = response_format.parse_raw(raw_text)
|
|
364
|
+
except Exception:
|
|
365
|
+
logging.warning("Initial parse failed; attempting JSON repair")
|
|
366
|
+
parsed_obj = response_format.parse_raw(repair_json(raw_text))
|
|
367
|
+
|
|
368
|
+
# ─── Cache & return ───────────────────────────────────────────────────────
|
|
369
|
+
cache_output_tools.cache_output(
|
|
370
|
+
"get_structured_output_with_mcp", cache_key, parsed_obj.json()
|
|
371
|
+
)
|
|
372
|
+
return parsed_obj, status
|
|
209
373
|
|
|
210
374
|
async def get_structured_output_with_assistant_and_vector_store(
|
|
211
375
|
prompt: str,
|
|
212
376
|
response_format: BaseModel,
|
|
213
377
|
vector_store_id: str,
|
|
214
378
|
effort: str = "low",
|
|
215
|
-
|
|
379
|
+
model="gpt-5.1-chat",
|
|
380
|
+
tool_config: Optional[List[Dict]] = None,
|
|
381
|
+
use_cache: bool = True
|
|
216
382
|
):
|
|
217
383
|
"""
|
|
218
|
-
|
|
219
|
-
Otherwise, do a single call to the new Responses API with a 'file_search' tool
|
|
220
|
-
to incorporate vector-store knowledge.
|
|
384
|
+
Same logic, now uses create_async_openai_client().
|
|
221
385
|
"""
|
|
222
386
|
try:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
387
|
+
vector_store = await get_vector_store_object(vector_store_id, tool_config)
|
|
388
|
+
if not vector_store:
|
|
389
|
+
return await get_structured_output_internal(
|
|
390
|
+
prompt, response_format, tool_config=tool_config
|
|
391
|
+
)
|
|
392
|
+
|
|
227
393
|
files = await list_vector_store_files(vector_store_id, tool_config)
|
|
228
394
|
if not files:
|
|
229
|
-
|
|
230
|
-
|
|
395
|
+
return await get_structured_output_internal(
|
|
396
|
+
prompt, response_format, tool_config=tool_config
|
|
397
|
+
)
|
|
231
398
|
|
|
232
|
-
# 3. If files exist => do a single "Responses" call with file_search
|
|
233
399
|
response_type_str = response_format.__name__
|
|
234
|
-
message_hash = hashlib.md5(prompt.encode(
|
|
235
|
-
response_type_hash = hashlib.md5(response_type_str.encode(
|
|
400
|
+
message_hash = hashlib.md5(prompt.encode("utf-8")).hexdigest()
|
|
401
|
+
response_type_hash = hashlib.md5(response_type_str.encode("utf-8")).hexdigest()
|
|
236
402
|
cache_key = f"{message_hash}:{response_type_hash}"
|
|
237
|
-
cached_response = cache_output_tools.retrieve_output(
|
|
238
|
-
"get_structured_output_with_assistant_and_vector_store",
|
|
239
|
-
cache_key
|
|
240
|
-
)
|
|
241
|
-
if cached_response is not None:
|
|
242
|
-
parsed_cached_response = response_format.parse_raw(cached_response)
|
|
243
|
-
return parsed_cached_response, "SUCCESS"
|
|
244
403
|
|
|
404
|
+
if use_cache:
|
|
405
|
+
cached_response = cache_output_tools.retrieve_output(
|
|
406
|
+
"get_structured_output_with_assistant_and_vector_store", cache_key
|
|
407
|
+
)
|
|
408
|
+
if cached_response is not None:
|
|
409
|
+
parsed_cached_response = response_format.model_validate_json(cached_response)
|
|
410
|
+
return parsed_cached_response, "SUCCESS"
|
|
411
|
+
|
|
245
412
|
schema = type_to_response_format_param(response_format)
|
|
246
413
|
json_schema_format = {
|
|
247
414
|
"name": response_type_str,
|
|
248
415
|
"type": "json_schema",
|
|
249
|
-
"schema": schema[
|
|
416
|
+
"schema": schema["json_schema"]["schema"],
|
|
250
417
|
}
|
|
251
418
|
|
|
252
|
-
|
|
253
|
-
client_async = openai.AsyncOpenAI(api_key=openai_key)
|
|
419
|
+
client_async = create_async_openai_client(tool_config)
|
|
254
420
|
|
|
255
|
-
# Single call to the new Responses API
|
|
256
421
|
completion = await client_async.responses.create(
|
|
257
422
|
input=[
|
|
258
423
|
{"role": "system", "content": "You are a helpful AI. Output JSON only."},
|
|
259
|
-
{"role": "user", "content": prompt}
|
|
424
|
+
{"role": "user", "content": prompt},
|
|
260
425
|
],
|
|
261
|
-
model=
|
|
426
|
+
model=model,
|
|
262
427
|
text={"format": json_schema_format},
|
|
263
|
-
tools=[{
|
|
264
|
-
"type": "file_search",
|
|
265
|
-
"vector_store_ids": [vector_store_id],
|
|
266
|
-
}],
|
|
267
|
-
reasoning={"effort": effort},
|
|
428
|
+
tools=[{"type": "file_search", "vector_store_ids": [vector_store_id]}],
|
|
268
429
|
tool_choice="required",
|
|
269
|
-
store=False
|
|
430
|
+
store=False,
|
|
270
431
|
)
|
|
271
432
|
|
|
272
|
-
if completion.output and len(completion.output) > 0:
|
|
433
|
+
if completion and completion.output and len(completion.output) > 0:
|
|
273
434
|
raw_text = None
|
|
274
|
-
# Find the first output whose type is 'message'
|
|
275
435
|
for out in completion.output:
|
|
276
|
-
if out.type ==
|
|
436
|
+
if out.type == "message" and out.content and len(out.content) > 0:
|
|
277
437
|
raw_text = out.content[0].text
|
|
278
438
|
break
|
|
279
439
|
|
|
280
440
|
if not raw_text or not raw_text.strip():
|
|
281
|
-
logging.error("No response text from the model.")
|
|
282
441
|
raise HTTPException(status_code=502, detail="No response from the model.")
|
|
283
442
|
|
|
284
443
|
try:
|
|
@@ -286,24 +445,18 @@ async def get_structured_output_with_assistant_and_vector_store(
|
|
|
286
445
|
cache_output_tools.cache_output(
|
|
287
446
|
"get_structured_output_with_assistant_and_vector_store",
|
|
288
447
|
cache_key,
|
|
289
|
-
parsed_obj.json()
|
|
448
|
+
parsed_obj.json(),
|
|
290
449
|
)
|
|
291
450
|
return parsed_obj, "SUCCESS"
|
|
292
|
-
except Exception
|
|
293
|
-
logging.warning("
|
|
451
|
+
except Exception:
|
|
452
|
+
logging.warning("Model returned invalid JSON.")
|
|
294
453
|
return raw_text, "FAIL"
|
|
295
454
|
else:
|
|
296
455
|
return "No output returned", "FAIL"
|
|
297
456
|
|
|
298
|
-
except
|
|
457
|
+
except OpenAIError as e:
|
|
299
458
|
logging.error(f"OpenAI API error: {e}")
|
|
300
|
-
raise HTTPException(
|
|
301
|
-
status_code=502,
|
|
302
|
-
detail="Error communicating with the OpenAI API."
|
|
303
|
-
)
|
|
459
|
+
raise HTTPException(status_code=502, detail="Error communicating with the OpenAI API.")
|
|
304
460
|
except Exception as e:
|
|
305
461
|
logging.error(f"Unexpected error: {e}")
|
|
306
|
-
raise HTTPException(
|
|
307
|
-
status_code=500,
|
|
308
|
-
detail="An unexpected error occurred while processing your request."
|
|
309
|
-
)
|
|
462
|
+
raise HTTPException(status_code=500, detail="Unexpected server error.")
|