ai-parrot 0.17.2__cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.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.
Files changed (535) hide show
  1. agentui/.prettierrc +15 -0
  2. agentui/QUICKSTART.md +272 -0
  3. agentui/README.md +59 -0
  4. agentui/env.example +16 -0
  5. agentui/jsconfig.json +14 -0
  6. agentui/package-lock.json +4242 -0
  7. agentui/package.json +34 -0
  8. agentui/scripts/postinstall/apply-patches.mjs +260 -0
  9. agentui/src/app.css +61 -0
  10. agentui/src/app.d.ts +13 -0
  11. agentui/src/app.html +12 -0
  12. agentui/src/components/LoadingSpinner.svelte +64 -0
  13. agentui/src/components/ThemeSwitcher.svelte +159 -0
  14. agentui/src/components/index.js +4 -0
  15. agentui/src/lib/api/bots.ts +60 -0
  16. agentui/src/lib/api/chat.ts +22 -0
  17. agentui/src/lib/api/http.ts +25 -0
  18. agentui/src/lib/components/BotCard.svelte +33 -0
  19. agentui/src/lib/components/ChatBubble.svelte +63 -0
  20. agentui/src/lib/components/Toast.svelte +21 -0
  21. agentui/src/lib/config.ts +20 -0
  22. agentui/src/lib/stores/auth.svelte.ts +73 -0
  23. agentui/src/lib/stores/theme.svelte.js +64 -0
  24. agentui/src/lib/stores/toast.svelte.ts +31 -0
  25. agentui/src/lib/utils/conversation.ts +39 -0
  26. agentui/src/routes/+layout.svelte +20 -0
  27. agentui/src/routes/+page.svelte +232 -0
  28. agentui/src/routes/login/+page.svelte +200 -0
  29. agentui/src/routes/talk/[agentId]/+page.svelte +297 -0
  30. agentui/src/routes/talk/[agentId]/+page.ts +7 -0
  31. agentui/static/README.md +1 -0
  32. agentui/svelte.config.js +11 -0
  33. agentui/tailwind.config.ts +53 -0
  34. agentui/tsconfig.json +3 -0
  35. agentui/vite.config.ts +10 -0
  36. ai_parrot-0.17.2.dist-info/METADATA +472 -0
  37. ai_parrot-0.17.2.dist-info/RECORD +535 -0
  38. ai_parrot-0.17.2.dist-info/WHEEL +6 -0
  39. ai_parrot-0.17.2.dist-info/entry_points.txt +2 -0
  40. ai_parrot-0.17.2.dist-info/licenses/LICENSE +21 -0
  41. ai_parrot-0.17.2.dist-info/top_level.txt +6 -0
  42. crew-builder/.prettierrc +15 -0
  43. crew-builder/QUICKSTART.md +259 -0
  44. crew-builder/README.md +113 -0
  45. crew-builder/env.example +17 -0
  46. crew-builder/jsconfig.json +14 -0
  47. crew-builder/package-lock.json +4182 -0
  48. crew-builder/package.json +37 -0
  49. crew-builder/scripts/postinstall/apply-patches.mjs +260 -0
  50. crew-builder/src/app.css +62 -0
  51. crew-builder/src/app.d.ts +13 -0
  52. crew-builder/src/app.html +12 -0
  53. crew-builder/src/components/LoadingSpinner.svelte +64 -0
  54. crew-builder/src/components/ThemeSwitcher.svelte +149 -0
  55. crew-builder/src/components/index.js +9 -0
  56. crew-builder/src/lib/api/bots.ts +60 -0
  57. crew-builder/src/lib/api/chat.ts +80 -0
  58. crew-builder/src/lib/api/client.ts +56 -0
  59. crew-builder/src/lib/api/crew/crew.ts +136 -0
  60. crew-builder/src/lib/api/index.ts +5 -0
  61. crew-builder/src/lib/api/o365/auth.ts +65 -0
  62. crew-builder/src/lib/auth/auth.ts +54 -0
  63. crew-builder/src/lib/components/AgentNode.svelte +43 -0
  64. crew-builder/src/lib/components/BotCard.svelte +33 -0
  65. crew-builder/src/lib/components/ChatBubble.svelte +67 -0
  66. crew-builder/src/lib/components/ConfigPanel.svelte +278 -0
  67. crew-builder/src/lib/components/JsonTreeNode.svelte +76 -0
  68. crew-builder/src/lib/components/JsonViewer.svelte +24 -0
  69. crew-builder/src/lib/components/MarkdownEditor.svelte +48 -0
  70. crew-builder/src/lib/components/ThemeToggle.svelte +36 -0
  71. crew-builder/src/lib/components/Toast.svelte +67 -0
  72. crew-builder/src/lib/components/Toolbar.svelte +157 -0
  73. crew-builder/src/lib/components/index.ts +10 -0
  74. crew-builder/src/lib/config.ts +8 -0
  75. crew-builder/src/lib/stores/auth.svelte.ts +228 -0
  76. crew-builder/src/lib/stores/crewStore.ts +369 -0
  77. crew-builder/src/lib/stores/theme.svelte.js +145 -0
  78. crew-builder/src/lib/stores/toast.svelte.ts +69 -0
  79. crew-builder/src/lib/utils/conversation.ts +39 -0
  80. crew-builder/src/lib/utils/markdown.ts +122 -0
  81. crew-builder/src/lib/utils/talkHistory.ts +47 -0
  82. crew-builder/src/routes/+layout.svelte +20 -0
  83. crew-builder/src/routes/+page.svelte +539 -0
  84. crew-builder/src/routes/agents/+page.svelte +247 -0
  85. crew-builder/src/routes/agents/[agentId]/+page.svelte +288 -0
  86. crew-builder/src/routes/agents/[agentId]/+page.ts +7 -0
  87. crew-builder/src/routes/builder/+page.svelte +204 -0
  88. crew-builder/src/routes/crew/ask/+page.svelte +1052 -0
  89. crew-builder/src/routes/crew/ask/+page.ts +1 -0
  90. crew-builder/src/routes/integrations/o365/+page.svelte +304 -0
  91. crew-builder/src/routes/login/+page.svelte +197 -0
  92. crew-builder/src/routes/talk/[agentId]/+page.svelte +487 -0
  93. crew-builder/src/routes/talk/[agentId]/+page.ts +7 -0
  94. crew-builder/static/README.md +1 -0
  95. crew-builder/svelte.config.js +11 -0
  96. crew-builder/tailwind.config.ts +53 -0
  97. crew-builder/tsconfig.json +3 -0
  98. crew-builder/vite.config.ts +10 -0
  99. mcp_servers/calculator_server.py +309 -0
  100. parrot/__init__.py +27 -0
  101. parrot/__pycache__/__init__.cpython-310.pyc +0 -0
  102. parrot/__pycache__/version.cpython-310.pyc +0 -0
  103. parrot/_version.py +34 -0
  104. parrot/a2a/__init__.py +48 -0
  105. parrot/a2a/client.py +658 -0
  106. parrot/a2a/discovery.py +89 -0
  107. parrot/a2a/mixin.py +257 -0
  108. parrot/a2a/models.py +376 -0
  109. parrot/a2a/server.py +770 -0
  110. parrot/agents/__init__.py +29 -0
  111. parrot/bots/__init__.py +12 -0
  112. parrot/bots/a2a_agent.py +19 -0
  113. parrot/bots/abstract.py +3139 -0
  114. parrot/bots/agent.py +1129 -0
  115. parrot/bots/basic.py +9 -0
  116. parrot/bots/chatbot.py +669 -0
  117. parrot/bots/data.py +1618 -0
  118. parrot/bots/database/__init__.py +5 -0
  119. parrot/bots/database/abstract.py +3071 -0
  120. parrot/bots/database/cache.py +286 -0
  121. parrot/bots/database/models.py +468 -0
  122. parrot/bots/database/prompts.py +154 -0
  123. parrot/bots/database/retries.py +98 -0
  124. parrot/bots/database/router.py +269 -0
  125. parrot/bots/database/sql.py +41 -0
  126. parrot/bots/db/__init__.py +6 -0
  127. parrot/bots/db/abstract.py +556 -0
  128. parrot/bots/db/bigquery.py +602 -0
  129. parrot/bots/db/cache.py +85 -0
  130. parrot/bots/db/documentdb.py +668 -0
  131. parrot/bots/db/elastic.py +1014 -0
  132. parrot/bots/db/influx.py +898 -0
  133. parrot/bots/db/mock.py +96 -0
  134. parrot/bots/db/multi.py +783 -0
  135. parrot/bots/db/prompts.py +185 -0
  136. parrot/bots/db/sql.py +1255 -0
  137. parrot/bots/db/tools.py +212 -0
  138. parrot/bots/document.py +680 -0
  139. parrot/bots/hrbot.py +15 -0
  140. parrot/bots/kb.py +170 -0
  141. parrot/bots/mcp.py +36 -0
  142. parrot/bots/orchestration/README.md +463 -0
  143. parrot/bots/orchestration/__init__.py +1 -0
  144. parrot/bots/orchestration/agent.py +155 -0
  145. parrot/bots/orchestration/crew.py +3330 -0
  146. parrot/bots/orchestration/fsm.py +1179 -0
  147. parrot/bots/orchestration/hr.py +434 -0
  148. parrot/bots/orchestration/storage/__init__.py +4 -0
  149. parrot/bots/orchestration/storage/memory.py +100 -0
  150. parrot/bots/orchestration/storage/mixin.py +119 -0
  151. parrot/bots/orchestration/verify.py +202 -0
  152. parrot/bots/product.py +204 -0
  153. parrot/bots/prompts/__init__.py +96 -0
  154. parrot/bots/prompts/agents.py +155 -0
  155. parrot/bots/prompts/data.py +216 -0
  156. parrot/bots/prompts/output_generation.py +8 -0
  157. parrot/bots/scraper/__init__.py +3 -0
  158. parrot/bots/scraper/models.py +122 -0
  159. parrot/bots/scraper/scraper.py +1173 -0
  160. parrot/bots/scraper/templates.py +115 -0
  161. parrot/bots/stores/__init__.py +5 -0
  162. parrot/bots/stores/local.py +172 -0
  163. parrot/bots/webdev.py +81 -0
  164. parrot/cli.py +17 -0
  165. parrot/clients/__init__.py +16 -0
  166. parrot/clients/base.py +1491 -0
  167. parrot/clients/claude.py +1191 -0
  168. parrot/clients/factory.py +129 -0
  169. parrot/clients/google.py +4567 -0
  170. parrot/clients/gpt.py +1975 -0
  171. parrot/clients/grok.py +432 -0
  172. parrot/clients/groq.py +986 -0
  173. parrot/clients/hf.py +582 -0
  174. parrot/clients/models.py +18 -0
  175. parrot/conf.py +395 -0
  176. parrot/embeddings/__init__.py +9 -0
  177. parrot/embeddings/base.py +157 -0
  178. parrot/embeddings/google.py +98 -0
  179. parrot/embeddings/huggingface.py +74 -0
  180. parrot/embeddings/openai.py +84 -0
  181. parrot/embeddings/processor.py +88 -0
  182. parrot/exceptions.c +13868 -0
  183. parrot/exceptions.cpython-310-x86_64-linux-gnu.so +0 -0
  184. parrot/exceptions.pxd +22 -0
  185. parrot/exceptions.pxi +15 -0
  186. parrot/exceptions.pyx +44 -0
  187. parrot/generators/__init__.py +29 -0
  188. parrot/generators/base.py +200 -0
  189. parrot/generators/html.py +293 -0
  190. parrot/generators/react.py +205 -0
  191. parrot/generators/streamlit.py +203 -0
  192. parrot/generators/template.py +105 -0
  193. parrot/handlers/__init__.py +4 -0
  194. parrot/handlers/agent.py +861 -0
  195. parrot/handlers/agents/__init__.py +1 -0
  196. parrot/handlers/agents/abstract.py +900 -0
  197. parrot/handlers/bots.py +338 -0
  198. parrot/handlers/chat.py +915 -0
  199. parrot/handlers/creation.sql +192 -0
  200. parrot/handlers/crew/ARCHITECTURE.md +362 -0
  201. parrot/handlers/crew/README_BOTMANAGER_PERSISTENCE.md +303 -0
  202. parrot/handlers/crew/README_REDIS_PERSISTENCE.md +366 -0
  203. parrot/handlers/crew/__init__.py +0 -0
  204. parrot/handlers/crew/handler.py +801 -0
  205. parrot/handlers/crew/models.py +229 -0
  206. parrot/handlers/crew/redis_persistence.py +523 -0
  207. parrot/handlers/jobs/__init__.py +10 -0
  208. parrot/handlers/jobs/job.py +384 -0
  209. parrot/handlers/jobs/mixin.py +627 -0
  210. parrot/handlers/jobs/models.py +115 -0
  211. parrot/handlers/jobs/worker.py +31 -0
  212. parrot/handlers/models.py +596 -0
  213. parrot/handlers/o365_auth.py +105 -0
  214. parrot/handlers/stream.py +337 -0
  215. parrot/interfaces/__init__.py +6 -0
  216. parrot/interfaces/aws.py +143 -0
  217. parrot/interfaces/credentials.py +113 -0
  218. parrot/interfaces/database.py +27 -0
  219. parrot/interfaces/google.py +1123 -0
  220. parrot/interfaces/hierarchy.py +1227 -0
  221. parrot/interfaces/http.py +651 -0
  222. parrot/interfaces/images/__init__.py +0 -0
  223. parrot/interfaces/images/plugins/__init__.py +24 -0
  224. parrot/interfaces/images/plugins/abstract.py +58 -0
  225. parrot/interfaces/images/plugins/analisys.py +148 -0
  226. parrot/interfaces/images/plugins/classify.py +150 -0
  227. parrot/interfaces/images/plugins/classifybase.py +182 -0
  228. parrot/interfaces/images/plugins/detect.py +150 -0
  229. parrot/interfaces/images/plugins/exif.py +1103 -0
  230. parrot/interfaces/images/plugins/hash.py +52 -0
  231. parrot/interfaces/images/plugins/vision.py +104 -0
  232. parrot/interfaces/images/plugins/yolo.py +66 -0
  233. parrot/interfaces/images/plugins/zerodetect.py +197 -0
  234. parrot/interfaces/o365.py +978 -0
  235. parrot/interfaces/onedrive.py +822 -0
  236. parrot/interfaces/sharepoint.py +1435 -0
  237. parrot/interfaces/soap.py +257 -0
  238. parrot/loaders/__init__.py +8 -0
  239. parrot/loaders/abstract.py +1131 -0
  240. parrot/loaders/audio.py +199 -0
  241. parrot/loaders/basepdf.py +53 -0
  242. parrot/loaders/basevideo.py +1568 -0
  243. parrot/loaders/csv.py +409 -0
  244. parrot/loaders/docx.py +116 -0
  245. parrot/loaders/epubloader.py +316 -0
  246. parrot/loaders/excel.py +199 -0
  247. parrot/loaders/factory.py +55 -0
  248. parrot/loaders/files/__init__.py +0 -0
  249. parrot/loaders/files/abstract.py +39 -0
  250. parrot/loaders/files/html.py +26 -0
  251. parrot/loaders/files/text.py +63 -0
  252. parrot/loaders/html.py +152 -0
  253. parrot/loaders/markdown.py +442 -0
  254. parrot/loaders/pdf.py +373 -0
  255. parrot/loaders/pdfmark.py +320 -0
  256. parrot/loaders/pdftables.py +506 -0
  257. parrot/loaders/ppt.py +476 -0
  258. parrot/loaders/qa.py +63 -0
  259. parrot/loaders/splitters/__init__.py +10 -0
  260. parrot/loaders/splitters/base.py +138 -0
  261. parrot/loaders/splitters/md.py +228 -0
  262. parrot/loaders/splitters/token.py +143 -0
  263. parrot/loaders/txt.py +26 -0
  264. parrot/loaders/video.py +89 -0
  265. parrot/loaders/videolocal.py +218 -0
  266. parrot/loaders/videounderstanding.py +377 -0
  267. parrot/loaders/vimeo.py +167 -0
  268. parrot/loaders/web.py +599 -0
  269. parrot/loaders/youtube.py +504 -0
  270. parrot/manager/__init__.py +5 -0
  271. parrot/manager/manager.py +1030 -0
  272. parrot/mcp/__init__.py +28 -0
  273. parrot/mcp/adapter.py +105 -0
  274. parrot/mcp/cli.py +174 -0
  275. parrot/mcp/client.py +119 -0
  276. parrot/mcp/config.py +75 -0
  277. parrot/mcp/integration.py +842 -0
  278. parrot/mcp/oauth.py +933 -0
  279. parrot/mcp/server.py +225 -0
  280. parrot/mcp/transports/__init__.py +3 -0
  281. parrot/mcp/transports/base.py +279 -0
  282. parrot/mcp/transports/grpc_session.py +163 -0
  283. parrot/mcp/transports/http.py +312 -0
  284. parrot/mcp/transports/mcp.proto +108 -0
  285. parrot/mcp/transports/quic.py +1082 -0
  286. parrot/mcp/transports/sse.py +330 -0
  287. parrot/mcp/transports/stdio.py +309 -0
  288. parrot/mcp/transports/unix.py +395 -0
  289. parrot/mcp/transports/websocket.py +547 -0
  290. parrot/memory/__init__.py +16 -0
  291. parrot/memory/abstract.py +209 -0
  292. parrot/memory/agent.py +32 -0
  293. parrot/memory/cache.py +175 -0
  294. parrot/memory/core.py +555 -0
  295. parrot/memory/file.py +153 -0
  296. parrot/memory/mem.py +131 -0
  297. parrot/memory/redis.py +613 -0
  298. parrot/models/__init__.py +46 -0
  299. parrot/models/basic.py +118 -0
  300. parrot/models/compliance.py +208 -0
  301. parrot/models/crew.py +395 -0
  302. parrot/models/detections.py +654 -0
  303. parrot/models/generation.py +85 -0
  304. parrot/models/google.py +223 -0
  305. parrot/models/groq.py +23 -0
  306. parrot/models/openai.py +30 -0
  307. parrot/models/outputs.py +285 -0
  308. parrot/models/responses.py +938 -0
  309. parrot/notifications/__init__.py +743 -0
  310. parrot/openapi/__init__.py +3 -0
  311. parrot/openapi/components.yaml +641 -0
  312. parrot/openapi/config.py +322 -0
  313. parrot/outputs/__init__.py +32 -0
  314. parrot/outputs/formats/__init__.py +108 -0
  315. parrot/outputs/formats/altair.py +359 -0
  316. parrot/outputs/formats/application.py +122 -0
  317. parrot/outputs/formats/base.py +351 -0
  318. parrot/outputs/formats/bokeh.py +356 -0
  319. parrot/outputs/formats/card.py +424 -0
  320. parrot/outputs/formats/chart.py +436 -0
  321. parrot/outputs/formats/d3.py +255 -0
  322. parrot/outputs/formats/echarts.py +310 -0
  323. parrot/outputs/formats/generators/__init__.py +0 -0
  324. parrot/outputs/formats/generators/abstract.py +61 -0
  325. parrot/outputs/formats/generators/panel.py +145 -0
  326. parrot/outputs/formats/generators/streamlit.py +86 -0
  327. parrot/outputs/formats/generators/terminal.py +63 -0
  328. parrot/outputs/formats/holoviews.py +310 -0
  329. parrot/outputs/formats/html.py +147 -0
  330. parrot/outputs/formats/jinja2.py +46 -0
  331. parrot/outputs/formats/json.py +87 -0
  332. parrot/outputs/formats/map.py +933 -0
  333. parrot/outputs/formats/markdown.py +172 -0
  334. parrot/outputs/formats/matplotlib.py +237 -0
  335. parrot/outputs/formats/mixins/__init__.py +0 -0
  336. parrot/outputs/formats/mixins/emaps.py +855 -0
  337. parrot/outputs/formats/plotly.py +341 -0
  338. parrot/outputs/formats/seaborn.py +310 -0
  339. parrot/outputs/formats/table.py +397 -0
  340. parrot/outputs/formats/template_report.py +138 -0
  341. parrot/outputs/formats/yaml.py +125 -0
  342. parrot/outputs/formatter.py +152 -0
  343. parrot/outputs/templates/__init__.py +95 -0
  344. parrot/pipelines/__init__.py +0 -0
  345. parrot/pipelines/abstract.py +210 -0
  346. parrot/pipelines/detector.py +124 -0
  347. parrot/pipelines/models.py +90 -0
  348. parrot/pipelines/planogram.py +3002 -0
  349. parrot/pipelines/table.sql +97 -0
  350. parrot/plugins/__init__.py +106 -0
  351. parrot/plugins/importer.py +80 -0
  352. parrot/py.typed +0 -0
  353. parrot/registry/__init__.py +18 -0
  354. parrot/registry/registry.py +594 -0
  355. parrot/scheduler/__init__.py +1189 -0
  356. parrot/scheduler/models.py +60 -0
  357. parrot/security/__init__.py +16 -0
  358. parrot/security/prompt_injection.py +268 -0
  359. parrot/security/security_events.sql +25 -0
  360. parrot/services/__init__.py +1 -0
  361. parrot/services/mcp/__init__.py +8 -0
  362. parrot/services/mcp/config.py +13 -0
  363. parrot/services/mcp/server.py +295 -0
  364. parrot/services/o365_remote_auth.py +235 -0
  365. parrot/stores/__init__.py +7 -0
  366. parrot/stores/abstract.py +352 -0
  367. parrot/stores/arango.py +1090 -0
  368. parrot/stores/bigquery.py +1377 -0
  369. parrot/stores/cache.py +106 -0
  370. parrot/stores/empty.py +10 -0
  371. parrot/stores/faiss_store.py +1157 -0
  372. parrot/stores/kb/__init__.py +9 -0
  373. parrot/stores/kb/abstract.py +68 -0
  374. parrot/stores/kb/cache.py +165 -0
  375. parrot/stores/kb/doc.py +325 -0
  376. parrot/stores/kb/hierarchy.py +346 -0
  377. parrot/stores/kb/local.py +457 -0
  378. parrot/stores/kb/prompt.py +28 -0
  379. parrot/stores/kb/redis.py +659 -0
  380. parrot/stores/kb/store.py +115 -0
  381. parrot/stores/kb/user.py +374 -0
  382. parrot/stores/models.py +59 -0
  383. parrot/stores/pgvector.py +3 -0
  384. parrot/stores/postgres.py +2853 -0
  385. parrot/stores/utils/__init__.py +0 -0
  386. parrot/stores/utils/chunking.py +197 -0
  387. parrot/telemetry/__init__.py +3 -0
  388. parrot/telemetry/mixin.py +111 -0
  389. parrot/template/__init__.py +3 -0
  390. parrot/template/engine.py +259 -0
  391. parrot/tools/__init__.py +23 -0
  392. parrot/tools/abstract.py +644 -0
  393. parrot/tools/agent.py +363 -0
  394. parrot/tools/arangodbsearch.py +537 -0
  395. parrot/tools/arxiv_tool.py +188 -0
  396. parrot/tools/calculator/__init__.py +3 -0
  397. parrot/tools/calculator/operations/__init__.py +38 -0
  398. parrot/tools/calculator/operations/calculus.py +80 -0
  399. parrot/tools/calculator/operations/statistics.py +76 -0
  400. parrot/tools/calculator/tool.py +150 -0
  401. parrot/tools/cloudwatch.py +988 -0
  402. parrot/tools/codeinterpreter/__init__.py +127 -0
  403. parrot/tools/codeinterpreter/executor.py +371 -0
  404. parrot/tools/codeinterpreter/internals.py +473 -0
  405. parrot/tools/codeinterpreter/models.py +643 -0
  406. parrot/tools/codeinterpreter/prompts.py +224 -0
  407. parrot/tools/codeinterpreter/tool.py +664 -0
  408. parrot/tools/company_info/__init__.py +6 -0
  409. parrot/tools/company_info/tool.py +1138 -0
  410. parrot/tools/correlationanalysis.py +437 -0
  411. parrot/tools/database/abstract.py +286 -0
  412. parrot/tools/database/bq.py +115 -0
  413. parrot/tools/database/cache.py +284 -0
  414. parrot/tools/database/models.py +95 -0
  415. parrot/tools/database/pg.py +343 -0
  416. parrot/tools/databasequery.py +1159 -0
  417. parrot/tools/db.py +1800 -0
  418. parrot/tools/ddgo.py +370 -0
  419. parrot/tools/decorators.py +271 -0
  420. parrot/tools/dftohtml.py +282 -0
  421. parrot/tools/document.py +549 -0
  422. parrot/tools/ecs.py +819 -0
  423. parrot/tools/edareport.py +368 -0
  424. parrot/tools/elasticsearch.py +1049 -0
  425. parrot/tools/employees.py +462 -0
  426. parrot/tools/epson/__init__.py +96 -0
  427. parrot/tools/excel.py +683 -0
  428. parrot/tools/file/__init__.py +13 -0
  429. parrot/tools/file/abstract.py +76 -0
  430. parrot/tools/file/gcs.py +378 -0
  431. parrot/tools/file/local.py +284 -0
  432. parrot/tools/file/s3.py +511 -0
  433. parrot/tools/file/tmp.py +309 -0
  434. parrot/tools/file/tool.py +501 -0
  435. parrot/tools/file_reader.py +129 -0
  436. parrot/tools/flowtask/__init__.py +19 -0
  437. parrot/tools/flowtask/tool.py +761 -0
  438. parrot/tools/gittoolkit.py +508 -0
  439. parrot/tools/google/__init__.py +18 -0
  440. parrot/tools/google/base.py +169 -0
  441. parrot/tools/google/tools.py +1251 -0
  442. parrot/tools/googlelocation.py +5 -0
  443. parrot/tools/googleroutes.py +5 -0
  444. parrot/tools/googlesearch.py +5 -0
  445. parrot/tools/googlesitesearch.py +5 -0
  446. parrot/tools/googlevoice.py +2 -0
  447. parrot/tools/gvoice.py +695 -0
  448. parrot/tools/ibisworld/README.md +225 -0
  449. parrot/tools/ibisworld/__init__.py +11 -0
  450. parrot/tools/ibisworld/tool.py +366 -0
  451. parrot/tools/jiratoolkit.py +1718 -0
  452. parrot/tools/manager.py +1098 -0
  453. parrot/tools/math.py +152 -0
  454. parrot/tools/metadata.py +476 -0
  455. parrot/tools/msteams.py +1621 -0
  456. parrot/tools/msword.py +635 -0
  457. parrot/tools/multidb.py +580 -0
  458. parrot/tools/multistoresearch.py +369 -0
  459. parrot/tools/networkninja.py +167 -0
  460. parrot/tools/nextstop/__init__.py +4 -0
  461. parrot/tools/nextstop/base.py +286 -0
  462. parrot/tools/nextstop/employee.py +733 -0
  463. parrot/tools/nextstop/store.py +462 -0
  464. parrot/tools/notification.py +435 -0
  465. parrot/tools/o365/__init__.py +42 -0
  466. parrot/tools/o365/base.py +295 -0
  467. parrot/tools/o365/bundle.py +522 -0
  468. parrot/tools/o365/events.py +554 -0
  469. parrot/tools/o365/mail.py +992 -0
  470. parrot/tools/o365/onedrive.py +497 -0
  471. parrot/tools/o365/sharepoint.py +641 -0
  472. parrot/tools/openapi_toolkit.py +904 -0
  473. parrot/tools/openweather.py +527 -0
  474. parrot/tools/pdfprint.py +1001 -0
  475. parrot/tools/powerbi.py +518 -0
  476. parrot/tools/powerpoint.py +1113 -0
  477. parrot/tools/pricestool.py +146 -0
  478. parrot/tools/products/__init__.py +246 -0
  479. parrot/tools/prophet_tool.py +171 -0
  480. parrot/tools/pythonpandas.py +630 -0
  481. parrot/tools/pythonrepl.py +910 -0
  482. parrot/tools/qsource.py +436 -0
  483. parrot/tools/querytoolkit.py +395 -0
  484. parrot/tools/quickeda.py +827 -0
  485. parrot/tools/resttool.py +553 -0
  486. parrot/tools/retail/__init__.py +0 -0
  487. parrot/tools/retail/bby.py +528 -0
  488. parrot/tools/sandboxtool.py +703 -0
  489. parrot/tools/sassie/__init__.py +352 -0
  490. parrot/tools/scraping/__init__.py +7 -0
  491. parrot/tools/scraping/docs/select.md +466 -0
  492. parrot/tools/scraping/documentation.md +1278 -0
  493. parrot/tools/scraping/driver.py +436 -0
  494. parrot/tools/scraping/models.py +576 -0
  495. parrot/tools/scraping/options.py +85 -0
  496. parrot/tools/scraping/orchestrator.py +517 -0
  497. parrot/tools/scraping/readme.md +740 -0
  498. parrot/tools/scraping/tool.py +3115 -0
  499. parrot/tools/seasonaldetection.py +642 -0
  500. parrot/tools/shell_tool/__init__.py +5 -0
  501. parrot/tools/shell_tool/actions.py +408 -0
  502. parrot/tools/shell_tool/engine.py +155 -0
  503. parrot/tools/shell_tool/models.py +322 -0
  504. parrot/tools/shell_tool/tool.py +442 -0
  505. parrot/tools/site_search.py +214 -0
  506. parrot/tools/textfile.py +418 -0
  507. parrot/tools/think.py +378 -0
  508. parrot/tools/toolkit.py +298 -0
  509. parrot/tools/webapp_tool.py +187 -0
  510. parrot/tools/whatif.py +1279 -0
  511. parrot/tools/workday/MULTI_WSDL_EXAMPLE.md +249 -0
  512. parrot/tools/workday/__init__.py +6 -0
  513. parrot/tools/workday/models.py +1389 -0
  514. parrot/tools/workday/tool.py +1293 -0
  515. parrot/tools/yfinance_tool.py +306 -0
  516. parrot/tools/zipcode.py +217 -0
  517. parrot/utils/__init__.py +2 -0
  518. parrot/utils/helpers.py +73 -0
  519. parrot/utils/parsers/__init__.py +5 -0
  520. parrot/utils/parsers/toml.c +12078 -0
  521. parrot/utils/parsers/toml.cpython-310-x86_64-linux-gnu.so +0 -0
  522. parrot/utils/parsers/toml.pyx +21 -0
  523. parrot/utils/toml.py +11 -0
  524. parrot/utils/types.cpp +20936 -0
  525. parrot/utils/types.cpython-310-x86_64-linux-gnu.so +0 -0
  526. parrot/utils/types.pyx +213 -0
  527. parrot/utils/uv.py +11 -0
  528. parrot/version.py +10 -0
  529. parrot/yaml-rs/Cargo.lock +350 -0
  530. parrot/yaml-rs/Cargo.toml +19 -0
  531. parrot/yaml-rs/pyproject.toml +19 -0
  532. parrot/yaml-rs/python/yaml_rs/__init__.py +81 -0
  533. parrot/yaml-rs/src/lib.rs +222 -0
  534. requirements/docker-compose.yml +24 -0
  535. requirements/requirements-dev.txt +21 -0
parrot/clients/gpt.py ADDED
@@ -0,0 +1,1975 @@
1
+ from typing import AsyncIterator, Dict, List, Optional, Union, Any, Tuple, Iterable
2
+ import base64
3
+ import io
4
+ import json
5
+ import mimetypes
6
+ import uuid
7
+ from pathlib import Path
8
+ import time
9
+ import asyncio
10
+ from logging import getLogger
11
+ from enum import Enum
12
+ from dataclasses import is_dataclass
13
+ from PIL import Image
14
+ import pytesseract
15
+ from pydantic import BaseModel, ValidationError, TypeAdapter
16
+ from datamodel.parsers.json import json_decoder, json_decoder # pylint: disable=E0611 # noqa
17
+ from navconfig import config, BASE_DIR
18
+ from tenacity import (
19
+ AsyncRetrying,
20
+ stop_after_attempt,
21
+ wait_exponential,
22
+ retry_if_exception_type
23
+ )
24
+ from openai import AsyncOpenAI
25
+ from openai import APIConnectionError, RateLimitError, APIError, BadRequestError
26
+ from .base import AbstractClient
27
+ from ..models import (
28
+ AIMessage,
29
+ AIMessageFactory,
30
+ ToolCall,
31
+ CompletionUsage,
32
+ VideoGenerationPrompt,
33
+ StructuredOutputConfig,
34
+ OutputFormat
35
+ )
36
+ from ..models.openai import OpenAIModel
37
+ from ..models.outputs import (
38
+ SentimentAnalysis,
39
+ ProductReview
40
+ )
41
+ from ..models.detections import (
42
+ DetectionBox,
43
+ ShelfRegion,
44
+ IdentifiedProduct
45
+ )
46
+
47
+
48
+ getLogger('httpx').setLevel('WARNING')
49
+ getLogger('httpcore').setLevel('WARNING')
50
+ getLogger('openai').setLevel('INFO')
51
+
52
+ # Reasoning models like o3 / o3-pro / o3-mini and o4-mini
53
+ # (including deep-research variants) are Responses-only.
54
+ RESPONSES_ONLY_MODELS = {
55
+ "o3",
56
+ "o3-pro",
57
+ "o3-mini",
58
+ "o3-deep-research",
59
+ "o4-mini",
60
+ "o4-mini-deep-research",
61
+ "gpt-5-pro",
62
+ "gpt-5-mini",
63
+ }
64
+
65
+ STRUCTURED_OUTPUT_COMPATIBLE_MODELS = {
66
+ OpenAIModel.GPT_4O_MINI.value,
67
+ OpenAIModel.GPT_O4.value,
68
+ OpenAIModel.GPT_4O.value,
69
+ OpenAIModel.GPT4_1.value,
70
+ OpenAIModel.GPT_4_1_MINI.value,
71
+ OpenAIModel.GPT_4_1_NANO.value,
72
+ OpenAIModel.GPT5_MINI.value,
73
+ OpenAIModel.GPT5.value,
74
+ OpenAIModel.GPT5_CHAT.value,
75
+ OpenAIModel.GPT5_PRO.value,
76
+ }
77
+
78
+ DEFAULT_STRUCTURED_OUTPUT_MODEL = OpenAIModel.GPT_4O_MINI.value
79
+
80
+
81
+ class OpenAIClient(AbstractClient):
82
+ """Client for interacting with OpenAI's API."""
83
+
84
+ client_type: str = 'openai'
85
+ model: str = OpenAIModel.GPT4_TURBO.value
86
+ client_name: str = 'openai'
87
+ _default_model: str = 'gpt-4o-mini'
88
+
89
+ def __init__(
90
+ self,
91
+ api_key: str = None,
92
+ base_url: str = "https://api.openai.com/v1",
93
+ **kwargs
94
+ ):
95
+ self.api_key = api_key or config.get('OPENAI_API_KEY')
96
+ self.base_url = base_url
97
+ self.base_headers = {
98
+ "Content-Type": "application/json",
99
+ "Authorization": f"Bearer {self.api_key}"
100
+ }
101
+ super().__init__(**kwargs)
102
+
103
+ async def get_client(self) -> AsyncOpenAI:
104
+ """Initialize the OpenAI client."""
105
+ return AsyncOpenAI(
106
+ api_key=self.api_key,
107
+ base_url=self.base_url,
108
+ timeout=config.get('OPENAI_TIMEOUT', 60),
109
+ )
110
+
111
+ async def _download_openai_file(self, file_id: str) -> Optional[bytes]:
112
+ """Download a file from OpenAI's Files API handling various SDK shapes."""
113
+ if not file_id:
114
+ return None
115
+
116
+ files_resource = getattr(self.client, "files", None)
117
+ if files_resource is None:
118
+ return None
119
+
120
+ candidate_methods = [
121
+ getattr(files_resource, "content", None),
122
+ getattr(files_resource, "retrieve_content", None),
123
+ getattr(files_resource, "download", None),
124
+ ]
125
+
126
+ async def _invoke(method, *args, **kwargs):
127
+ if asyncio.iscoroutinefunction(method):
128
+ return await method(*args, **kwargs)
129
+ result = method(*args, **kwargs)
130
+ if asyncio.iscoroutine(result):
131
+ result = await result
132
+ return result
133
+
134
+ arg_permutations = [
135
+ ((file_id,), {}),
136
+ ((), {"id": file_id}),
137
+ ((), {"id": file_id}),
138
+ ((), {"file": file_id}),
139
+ ]
140
+
141
+ for method in candidate_methods:
142
+ if method is None:
143
+ continue
144
+
145
+ result = None
146
+ for args, kwargs in arg_permutations:
147
+ try:
148
+ result = await _invoke(method, *args, **kwargs)
149
+ break
150
+ except TypeError:
151
+ continue
152
+ except Exception: # pylint: disable=broad-except
153
+ result = None
154
+ continue
155
+
156
+ if result is None:
157
+ continue
158
+
159
+ if isinstance(result, bytes):
160
+ return result
161
+
162
+ if isinstance(result, dict):
163
+ if isinstance(result.get("data"), bytes):
164
+ return result["data"]
165
+ if isinstance(result.get("content"), bytes):
166
+ return result["content"]
167
+
168
+ if hasattr(result, "content"):
169
+ content = result.content
170
+ if asyncio.iscoroutine(content):
171
+ content = await content
172
+ if isinstance(content, bytes):
173
+ return content
174
+
175
+ if hasattr(result, "read"):
176
+ read_method = result.read
177
+ data = await read_method() if asyncio.iscoroutinefunction(read_method) else read_method()
178
+ if isinstance(data, bytes):
179
+ return data
180
+
181
+ if hasattr(result, "body") and hasattr(result.body, "read"):
182
+ read_method = result.body.read
183
+ data = await read_method() if asyncio.iscoroutinefunction(read_method) else read_method()
184
+ if isinstance(data, bytes):
185
+ return data
186
+
187
+ return None
188
+
189
+ async def _upload_file(
190
+ self,
191
+ file_path: Union[str, Path],
192
+ purpose: str = 'fine-tune'
193
+ ) -> None:
194
+ """Upload a file to OpenAI."""
195
+ with open(file_path, 'rb') as file:
196
+ await self.client.files.create(
197
+ file=file,
198
+ purpose=purpose
199
+ )
200
+
201
+ async def _chat_completion(
202
+ self,
203
+ model: str,
204
+ messages: Any,
205
+ use_tools: bool = False,
206
+ **kwargs
207
+ ):
208
+ retry_policy = AsyncRetrying(
209
+ retry=retry_if_exception_type((APIConnectionError, RateLimitError, APIError)),
210
+ wait=wait_exponential(multiplier=1, min=2, max=10),
211
+ stop=stop_after_attempt(5),
212
+ reraise=True
213
+ )
214
+ if use_tools:
215
+ method = self.client.chat.completions.create
216
+ else:
217
+ method = getattr(self.client.chat.completions, 'parse', self.client.chat.completions.create)
218
+ async for attempt in retry_policy:
219
+ with attempt:
220
+ return await method(
221
+ model=model,
222
+ messages=messages,
223
+ **kwargs
224
+ )
225
+
226
+ def _is_responses_model(self, model_str: str) -> bool:
227
+ """Return True if the selected model must go through Responses API."""
228
+ # allow aliases/enums already normalized to str
229
+ ms = (model_str or "").strip()
230
+ return ms in RESPONSES_ONLY_MODELS
231
+
232
+
233
+ def _prepare_responses_args(self, *, messages, args):
234
+ """
235
+ Map your existing args/messages into Responses API fields.
236
+
237
+ - Lift the first system message into `instructions` when present
238
+ - Keep the rest as chat-style list under `input`
239
+ - Pass tools/response_format/temperature/max_output_tokens if provided
240
+ """
241
+
242
+ def _as_response_content(role: str, content: Any, message: Dict[str, Any]) -> List[Dict[str, Any]]:
243
+ """Translate chat `content` into Responses-style content blocks."""
244
+
245
+ def _normalize_text(text_value: Any, *, text_type: str) -> Optional[Dict[str, Any]]:
246
+ if text_value is None:
247
+ return None
248
+ text = text_value if isinstance(text_value, str) else str(text_value)
249
+ if not text:
250
+ return None
251
+ return {"type": text_type, "text": text}
252
+
253
+ if role == "tool":
254
+ tool_call_id = message.get("tool_call_id")
255
+ # Responses expects tool output blocks
256
+ if isinstance(content, list):
257
+ normalized_output = "\n".join(
258
+ str(part) if not isinstance(part, dict) else str(part.get("text") or part.get("output") or "")
259
+ for part in content
260
+ )
261
+ else:
262
+ normalized_output = "" if content is None else str(content)
263
+
264
+ block = {
265
+ "type": "tool_output",
266
+ "tool_call_id": tool_call_id,
267
+ "output": normalized_output,
268
+ }
269
+ if message.get("name"):
270
+ block["name"] = message["name"]
271
+ return [block]
272
+
273
+ text_type = "input_text" if role in {"user", "tool_user"} else "output_text"
274
+
275
+ parts: List[Dict[str, Any]] = []
276
+
277
+ def _append_text(value: Any):
278
+ block = _normalize_text(value, text_type=text_type)
279
+ if block:
280
+ parts.append(block)
281
+
282
+ if isinstance(content, list):
283
+ for item in content:
284
+ if isinstance(item, dict):
285
+ item_type = item.get("type")
286
+
287
+ if item_type in {
288
+ "input_text",
289
+ "output_text",
290
+ "input_image",
291
+ "input_audio",
292
+ "tool_output",
293
+ "tool_call",
294
+ "input_file",
295
+ "computer_screenshot",
296
+ "summary_text",
297
+ }:
298
+ parts.append(item)
299
+ continue
300
+
301
+ if item_type == "text":
302
+ _append_text(item.get("text"))
303
+ continue
304
+
305
+ if item_type is None and {"id", "function"}.issubset(item.keys()):
306
+ parts.append(
307
+ {
308
+ "type": "tool_call",
309
+ "id": item.get("id"),
310
+ "name": (item.get("function") or {}).get("name"),
311
+ "arguments": (item.get("function") or {}).get("arguments"),
312
+ }
313
+ )
314
+ continue
315
+
316
+ parts.append(item)
317
+ else:
318
+ _append_text(item)
319
+ else:
320
+ _append_text(content)
321
+
322
+ if role == "assistant" and message.get("tool_calls"):
323
+ for tool_call in message["tool_calls"]:
324
+ if isinstance(tool_call, dict):
325
+ parts.append(
326
+ {
327
+ "type": "tool_call",
328
+ "id": tool_call.get("id"),
329
+ "name": (tool_call.get("function") or {}).get("name"),
330
+ "arguments": (tool_call.get("function") or {}).get("arguments"),
331
+ }
332
+ )
333
+
334
+ return parts
335
+
336
+ instructions = None
337
+ input_msgs = []
338
+ for m in messages:
339
+ role = m.get("role")
340
+ if role == "system" and instructions is None:
341
+ sys_content = m.get("content")
342
+ if isinstance(sys_content, list):
343
+ instructions = " ".join(
344
+ part.get("text", "") if isinstance(part, dict) else str(part)
345
+ for part in sys_content
346
+ ).strip()
347
+ else:
348
+ instructions = sys_content
349
+ continue
350
+
351
+ content_blocks = _as_response_content(role, m.get("content"), m)
352
+ msg_payload: Dict[str, Any] = {"role": role, "content": content_blocks}
353
+
354
+ if m.get("tool_calls"):
355
+ msg_payload["tool_calls"] = m["tool_calls"]
356
+ if m.get("tool_call_id"):
357
+ msg_payload["tool_call_id"] = m["tool_call_id"]
358
+ if m.get("name"):
359
+ msg_payload["name"] = m["name"]
360
+
361
+ input_msgs.append(msg_payload)
362
+
363
+ req = {
364
+ "instructions": instructions,
365
+ "input": input_msgs,
366
+ }
367
+
368
+ if "tools" in args:
369
+ req["tools"] = args["tools"]
370
+ if "tool_choice" in args:
371
+ req["tool_choice"] = args["tool_choice"]
372
+ if "temperature" in args and args["temperature"] is not None:
373
+ req["temperature"] = args["temperature"]
374
+ if "max_tokens" in args and args["max_tokens"] is not None:
375
+ req["max_output_tokens"] = args["max_tokens"]
376
+ if "parallel_tool_calls" in args:
377
+ req["parallel_tool_calls"] = args["parallel_tool_calls"]
378
+ return req
379
+
380
+ @staticmethod
381
+ def _with_extra_body(payload: Dict[str, Any], extra_body: Dict[str, Any]) -> Dict[str, Any]:
382
+ merged = dict(payload)
383
+ existing_raw = merged.pop("extra_body", None)
384
+ existing = (
385
+ dict(existing_raw) if isinstance(existing_raw, dict) else {}
386
+ ) | extra_body
387
+ if existing:
388
+ merged["extra_body"] = existing
389
+ return merged
390
+
391
+ async def _call_responses_create(self, payloads):
392
+ """
393
+ Try several payload shapes against responses.create().
394
+ We retry not only on TypeError (client-side signature issues)
395
+ but also on BadRequestError when the server reports unknown params,
396
+ so we can fall back to older-SDK-compatible shapes.
397
+ """
398
+ last_exc = None
399
+ for payload in payloads:
400
+ try:
401
+ return await self.client.responses.create(**payload)
402
+ except TypeError as exc:
403
+ last_exc = exc
404
+ except BadRequestError as exc:
405
+ # 2.6.0 returns 400 unknown_parameter for fields like "response", "modalities", etc.
406
+ msg = getattr(exc, "message", "") or ""
407
+ body = getattr(getattr(exc, "response", None), "json", lambda: {})()
408
+ code = (body.get("error") or {}).get("code", "")
409
+ param = (body.get("error") or {}).get("param", "")
410
+ if code == "unknown_parameter" or "Unknown parameter" in msg or param in {"response", "modalities", "video"}:
411
+ last_exc = exc
412
+ continue
413
+ raise # other 400s should bubble up
414
+ if last_exc:
415
+ raise last_exc
416
+ raise RuntimeError(
417
+ "OpenAI responses.create call failed without response"
418
+ )
419
+
420
+ async def _call_responses_stream(self, payloads):
421
+ """
422
+ Try several payload shapes against responses.stream(), mirroring
423
+ the compatibility shims we use for responses.create().
424
+ """
425
+ last_exc = None
426
+ for payload in payloads:
427
+ try:
428
+ return await self.client.responses.stream(**payload)
429
+ except TypeError as exc:
430
+ last_exc = exc
431
+ except BadRequestError as exc:
432
+ msg = getattr(exc, "message", "") or ""
433
+ body = getattr(getattr(exc, "response", None), "json", lambda: {})()
434
+ code = (body.get("error") or {}).get("code", "")
435
+ param = (body.get("error") or {}).get("param", "")
436
+ if code == "unknown_parameter" or "Unknown parameter" in msg or param in {"response", "modalities", "video"}:
437
+ last_exc = exc
438
+ continue
439
+ raise
440
+ if last_exc:
441
+ raise last_exc
442
+ raise RuntimeError(
443
+ "OpenAI responses.stream call failed without response"
444
+ )
445
+
446
+ async def _responses_completion(
447
+ self,
448
+ *,
449
+ model: str,
450
+ messages,
451
+ **args
452
+ ):
453
+ """
454
+ Adapter around OpenAI Responses API that mimics Chat Completions:
455
+ returns an object with `.choices[0].message` where `message` has
456
+ `.content: str` and `.tool_calls: list` (each item has `.id` and `.function.{name,arguments}`).
457
+ """
458
+ # 1) Build request payload from chat-like messages/args
459
+ resp_format = args.get("response_format")
460
+ req = self._prepare_responses_args(messages=messages, args=args)
461
+ req["model"] = model
462
+
463
+ # 2) Call Responses API
464
+ payload_base = dict(req)
465
+ payload_base.pop("response", None)
466
+ payload_base.pop("response_format", None)
467
+
468
+ attempts: List[Dict[str, Any]] = []
469
+ if resp_format:
470
+ # 2.6-compatible first:
471
+ attempts.append({**payload_base, "response_format": resp_format})
472
+ # Fallback to future SDKs that accept namespaced `response`:
473
+ attempts.append(self._with_extra_body(payload_base, {"response": {"format": resp_format}}))
474
+ # Last resort: drop structured constraints
475
+ attempts.append(dict(payload_base))
476
+ else:
477
+ attempts.append(dict(payload_base))
478
+
479
+ resp = await self._call_responses_create(attempts)
480
+
481
+ # 3) Extract best-effort text
482
+ output_text = getattr(resp, "output_text", None)
483
+ if output_text is None:
484
+ output_text = ""
485
+ for item in getattr(resp, "output", []) or []:
486
+ for part in getattr(item, "content", []) or []:
487
+ # common shapes the SDK returns
488
+ if isinstance(part, dict):
489
+ if part.get("type") == "output_text":
490
+ output_text += part.get("text", "") or ""
491
+ elif (text := getattr(part, "text", None)):
492
+ output_text += text
493
+
494
+ # 4) Extract & normalize tool calls
495
+ # We shape them to look like Chat Completions tool_calls:
496
+ # {"id":..., "function": {"name": ..., "arguments": "<json string>"}}
497
+ norm_tool_calls = []
498
+ finish_reason = None
499
+ stop_reason = None
500
+ for item in getattr(resp, "output", []) or []:
501
+ for part in getattr(item, "content", []) or []:
502
+ if isinstance(part, dict) and part.get("type") == "tool_call":
503
+ _id = part.get("id") or part.get("tool_call_id") or str(uuid.uuid4())
504
+ _name = part.get("name")
505
+ _args = part.get("arguments", {})
506
+ # ensure arguments is a JSON string (Chat-style)
507
+ if not isinstance(_args, str):
508
+ try:
509
+ _args = self._json.dumps(_args)
510
+ except Exception:
511
+ _args = json.dumps(_args, default=str)
512
+
513
+ # tiny compatibility holders
514
+ class _Fn:
515
+ def __init__(self, name, arguments):
516
+ self.name = name
517
+ self.arguments = arguments
518
+ class _ToolCall:
519
+ def __init__(self, id, function):
520
+ self.id = id
521
+ self.function = function
522
+
523
+ norm_tool_calls.append(_ToolCall(_id, _Fn(_name, _args)))
524
+
525
+ finish_reason = finish_reason or getattr(item, "finish_reason", None)
526
+ if isinstance(item, dict):
527
+ finish_reason = finish_reason or item.get("finish_reason")
528
+ stop_reason = stop_reason or getattr(item, "stop_reason", None)
529
+ if isinstance(item, dict):
530
+ stop_reason = stop_reason or item.get("stop_reason")
531
+
532
+ # 5) Build a Chat-like container
533
+ class _Msg:
534
+ def __init__(self, content, tool_calls):
535
+ self.content = content
536
+ self.tool_calls = tool_calls
537
+
538
+ class _Choice:
539
+ def __init__(self, message, *, finish_reason=None, stop_reason=None):
540
+ self.message = message
541
+ self.finish_reason = finish_reason
542
+ self.stop_reason = stop_reason
543
+
544
+ class _CompatResp:
545
+ def __init__(self, raw, message, *, finish_reason=None, stop_reason=None):
546
+ self.raw = raw
547
+ self.choices = [_Choice(message, finish_reason=finish_reason, stop_reason=stop_reason)]
548
+ # Usage may or may not exist; keep attribute for downstream code
549
+ self.usage = getattr(raw, "usage", None)
550
+
551
+ message = _Msg(output_text or "", norm_tool_calls)
552
+ return _CompatResp(
553
+ resp,
554
+ message,
555
+ finish_reason=finish_reason,
556
+ stop_reason=stop_reason,
557
+ )
558
+
559
+ async def ask(
560
+ self,
561
+ prompt: str,
562
+ model: Union[str, OpenAIModel] = OpenAIModel.GPT4_1,
563
+ max_tokens: Optional[int] = None,
564
+ temperature: Optional[float] = None,
565
+ files: Optional[List[Union[str, Path]]] = None,
566
+ system_prompt: Optional[str] = None,
567
+ structured_output: Union[type, StructuredOutputConfig, None] = None,
568
+ user_id: Optional[str] = None,
569
+ session_id: Optional[str] = None,
570
+ tools: Optional[List[Dict[str, Any]]] = None,
571
+ use_tools: Optional[bool] = None,
572
+ deep_research: bool = False,
573
+ background: bool = False,
574
+ vector_store_ids: Optional[List[str]] = None,
575
+ enable_web_search: bool = True,
576
+ enable_code_interpreter: bool = False,
577
+ lazy_loading: bool = False,
578
+ ) -> AIMessage:
579
+ """Ask OpenAI a question with optional conversation memory.
580
+
581
+ Args:
582
+ prompt (str): The prompt to send to the model.
583
+ model (Union[str, OpenAIModel], optional): The model to use. Defaults to GPT4_TURBO.
584
+ max_tokens (Optional[int], optional): Maximum tokens for the response. Defaults to None.
585
+ temperature (Optional[float], optional): Sampling temperature. Defaults to None.
586
+ files (Optional[List[Union[str, Path]]], optional): Files to upload. Defaults to None.
587
+ system_prompt (Optional[str], optional): System prompt to prepend. Defaults to None.
588
+ structured_output (Union[type, StructuredOutputConfig, None], optional):
589
+ Structured output definition, supporting Pydantic models, dataclasses,
590
+ or explicit StructuredOutputConfig instances. Defaults to None.
591
+ user_id (Optional[str], optional): User ID for conversation memory. Defaults to None.
592
+ session_id (Optional[str], optional): Session ID for conversation memory. Defaults to None.
593
+ tools (Optional[List[Dict[str, Any]]], optional): Tools to register for this call. Defaults to None.
594
+ use_tools (Optional[bool], optional): Whether to use tools. Defaults to None.
595
+ deep_research (bool): If True, use OpenAI's deep research models (o3/o4-deep-research).
596
+ background (bool): If True, execute research in background (not yet supported).
597
+ vector_store_ids (Optional[List[str]]): Vector store IDs for file_search tool.
598
+ enable_web_search (bool): Enable web search preview tool (default: True for deep research).
599
+ enable_code_interpreter (bool): Enable code interpreter tool.
600
+ lazy_loading (bool): If True, enable dynamic tool searching.
601
+
602
+ Returns:
603
+ AIMessage: The response from the model.
604
+
605
+ """
606
+
607
+ turn_id = str(uuid.uuid4())
608
+ original_prompt = prompt
609
+ _use_tools = use_tools if use_tools is not None else self.enable_tools
610
+
611
+ model_str = model.value if isinstance(model, Enum) else str(model)
612
+
613
+ # Deep research routing: switch to deep research model if requested
614
+ if deep_research:
615
+ # Use o3-deep-research as default deep research model
616
+ if model_str in {"gpt-4o-mini", "gpt-4o", "gpt-4-turbo", OpenAIModel.GPT4_1.value}:
617
+ model_str = "o3-deep-research"
618
+ self.logger.info(f"Deep research enabled: switching to {model_str}")
619
+ elif model_str not in RESPONSES_ONLY_MODELS:
620
+ # If not already a deep research model, switch to it
621
+ model_str = "o3-deep-research"
622
+ self.logger.info(f"Deep research enabled: switching to {model_str}")
623
+
624
+ messages, conversation_session, system_prompt = await self._prepare_conversation_context(
625
+ prompt, files, user_id, session_id, system_prompt
626
+ )
627
+
628
+ if files:
629
+ for file in files:
630
+ if isinstance(file, str):
631
+ file = Path(file)
632
+ if isinstance(file, Path):
633
+ await self._upload_file(file)
634
+
635
+ # Add search instruction if lazy loading is enabled
636
+ if lazy_loading and system_prompt:
637
+ system_prompt += "\n\nYou have access to a library of tools. Use the 'search_tools' function to find relevant tools."
638
+ elif lazy_loading and not system_prompt:
639
+ system_prompt = "You have access to a library of tools. Use the 'search_tools' function to find relevant tools."
640
+
641
+ if system_prompt:
642
+ messages.insert(0, {"role": "system", "content": system_prompt})
643
+
644
+ messages.append({"role": "user", "content": prompt})
645
+
646
+ all_tool_calls = []
647
+
648
+ output_config = self._get_structured_config(
649
+ structured_output
650
+ )
651
+
652
+ # Build tools for deep research or regular use
653
+ research_tools = []
654
+ if deep_research:
655
+ # For deep research, build specialized tools array
656
+ if enable_web_search:
657
+ research_tools.append({"type": "web_search_preview"})
658
+
659
+ if vector_store_ids:
660
+ research_tools.append({
661
+ "type": "file_search",
662
+ "vector_store_ids": vector_store_ids
663
+ })
664
+
665
+ if enable_code_interpreter:
666
+ research_tools.append({
667
+ "type": "code_interpreter",
668
+ "container": {"type": "auto"}
669
+ })
670
+
671
+ self.logger.info(f"Deep research tools configured: {len(research_tools)} tools")
672
+
673
+ # tools prep
674
+ if tools and isinstance(tools, list):
675
+ for tool in tools:
676
+ self.register_tool(tool)
677
+
678
+ # LAZY LOADING LOGIC
679
+ active_tool_names = set()
680
+ prepared_tools = None
681
+
682
+ if _use_tools:
683
+ if lazy_loading:
684
+ # Prepare only search_tools + explicitly passed tools?
685
+ # Using _prepare_lazy_tools which handles search_tools
686
+ prepared_tools = self._prepare_lazy_tools()
687
+ if prepared_tools:
688
+ active_tool_names.add("search_tools")
689
+ else:
690
+ prepared_tools = self._prepare_tools()
691
+
692
+ args = {}
693
+ if model_str in {
694
+ OpenAIModel.GPT_4O_MINI_SEARCH.value,
695
+ OpenAIModel.GPT_4O_SEARCH.value
696
+ }:
697
+ args['web_search_options'] = {
698
+ "web_search": True,
699
+ "web_search_model": "gpt-4o-mini"
700
+ }
701
+
702
+ # Merge research tools with regular tools
703
+ if deep_research and research_tools:
704
+ # For deep research, add research-specific tools
705
+ args['tools'] = research_tools
706
+ elif prepared_tools:
707
+ args['tools'] = prepared_tools
708
+ args['tool_choice'] = "auto"
709
+ args['parallel_tool_calls'] = True
710
+
711
+ if output_config and output_config.format == OutputFormat.JSON and model_str not in STRUCTURED_OUTPUT_COMPATIBLE_MODELS:
712
+ self.logger.warning(
713
+ "Model %s does not support structured outputs; switching to %s",
714
+ model_str,
715
+ DEFAULT_STRUCTURED_OUTPUT_MODEL
716
+ )
717
+ model_str = DEFAULT_STRUCTURED_OUTPUT_MODEL
718
+
719
+ if model_str != 'gpt-5-nano':
720
+ args['max_tokens'] = max_tokens or self.max_tokens
721
+ if temperature:
722
+ args['temperature'] = temperature
723
+
724
+ # -------- ROUTING: Responses-only vs Chat -----------
725
+ use_responses = self._is_responses_model(model_str)
726
+ resp_format = self._build_response_format_from(output_config) if output_config else None
727
+
728
+ if use_responses:
729
+ if output_config:
730
+ args['response_format'] = resp_format
731
+ response = await self._responses_completion(
732
+ model=model_str,
733
+ messages=messages,
734
+ **args
735
+ )
736
+ else:
737
+ if output_config:
738
+ args['response_format'] = resp_format
739
+ response = await self._chat_completion(
740
+ model=model_str,
741
+ messages=messages,
742
+ use_tools=_use_tools,
743
+ **args
744
+ )
745
+
746
+ result = response.choices[0].message
747
+
748
+ # ---------- Tool loop (works for both paths) ----------
749
+ while getattr(result, "tool_calls", None):
750
+ messages.append({
751
+ "role": "assistant",
752
+ "content": result.content,
753
+ "tool_calls": [
754
+ tc.model_dump() if hasattr(tc, "model_dump") else {
755
+ "id": tc.id,
756
+ "function": {
757
+ "name": getattr(tc.function, "name", None),
758
+ "arguments": getattr(tc.function, "arguments", "{}"),
759
+ },
760
+ }
761
+ for tc in result.tool_calls
762
+ ]
763
+ })
764
+
765
+ found_new_tools = False
766
+
767
+ for tool_call in result.tool_calls:
768
+ tool_name = tool_call.function.name
769
+ try:
770
+ try:
771
+ tool_args = self._json.loads(tool_call.function.arguments)
772
+ except json.JSONDecodeError:
773
+ tool_args = json_decoder(tool_call.function.arguments)
774
+
775
+ tc = ToolCall(
776
+ id=getattr(tool_call, "id", ""),
777
+ name=tool_name,
778
+ arguments=tool_args
779
+ )
780
+
781
+ try:
782
+ start_time = time.time()
783
+ tool_result = await self._execute_tool(tool_name, tool_args)
784
+ execution_time = time.time() - start_time
785
+
786
+ # Lazy Loading Check
787
+ if lazy_loading and tool_name == "search_tools":
788
+ new_tools = self._check_new_tools(tool_name, str(tool_result))
789
+ if new_tools:
790
+ for nt in new_tools:
791
+ if nt not in active_tool_names:
792
+ active_tool_names.add(nt)
793
+ found_new_tools = True
794
+
795
+ tc.result = tool_result
796
+ tc.execution_time = execution_time
797
+
798
+ messages.append({
799
+ "role": "tool",
800
+ "tool_call_id": getattr(tool_call, "id", ""),
801
+ "name": tool_name,
802
+ "content": str(tool_result)
803
+ })
804
+ except Exception as e:
805
+ tc.error = str(e)
806
+ messages.append({
807
+ "role": "tool",
808
+ "tool_call_id": getattr(tool_call, "id", ""),
809
+ "name": tool_name,
810
+ "content": str(e)
811
+ })
812
+
813
+ all_tool_calls.append(tc)
814
+
815
+ except Exception as e:
816
+ all_tool_calls.append(ToolCall(
817
+ id=getattr(tool_call, "id", ""),
818
+ name=tool_name,
819
+ arguments={"_error": f"malformed tool args: {e}"}
820
+ ))
821
+ messages.append({
822
+ "role": "tool",
823
+ "tool_call_id": getattr(tool_call, "id", ""),
824
+ "name": tool_name,
825
+ "content": f"Error decoding arguments: {e}"
826
+ })
827
+
828
+ # Re-prepare tools if new ones found
829
+ if lazy_loading and found_new_tools:
830
+ args['tools'] = self._prepare_tools(filter_names=list(active_tool_names))
831
+ # Note: We keep tool_choice='auto'
832
+
833
+ # continue via the same routed API
834
+ if use_responses:
835
+ if output_config:
836
+ args['response_format'] = resp_format
837
+ response = await self._responses_completion(
838
+ model=model_str,
839
+ messages=messages,
840
+ **args
841
+ )
842
+ else:
843
+ if output_config:
844
+ args['response_format'] = resp_format
845
+ response = await self._chat_completion(
846
+ model=model_str,
847
+ messages=messages,
848
+ use_tools=_use_tools,
849
+ **args
850
+ )
851
+
852
+ result = response.choices[0].message
853
+
854
+ # ---------- Finalization (unchanged) ----------
855
+ messages.append({"role": "assistant", "content": result.content})
856
+
857
+ response_text = result.content if isinstance(result.content, str) else self._json.dumps(result.content)
858
+ final_output = None
859
+ if output_config:
860
+ try:
861
+ if output_config.custom_parser:
862
+ final_output = output_config.custom_parser(response_text)
863
+ else:
864
+ final_output = await self._parse_structured_output(
865
+ response_text,
866
+ output_config
867
+ )
868
+ except Exception: # pylint: disable=broad-except
869
+ final_output = response_text
870
+
871
+ tools_used = [tc.name for tc in all_tool_calls]
872
+ assistant_response_text = result.content if isinstance(result.content, str) else self._json.dumps(result.content)
873
+ await self._update_conversation_memory(
874
+ user_id,
875
+ session_id,
876
+ conversation_session,
877
+ messages,
878
+ system_prompt,
879
+ turn_id,
880
+ original_prompt,
881
+ assistant_response_text,
882
+ tools_used
883
+ )
884
+
885
+ structured_payload = None
886
+ if final_output is not None and not (
887
+ isinstance(final_output, str) and final_output == response_text
888
+ ):
889
+ structured_payload = final_output
890
+
891
+ ai_message = AIMessageFactory.from_openai(
892
+ response=response,
893
+ input_text=original_prompt,
894
+ model=model_str,
895
+ user_id=user_id,
896
+ session_id=session_id,
897
+ turn_id=turn_id,
898
+ structured_output=structured_payload
899
+ )
900
+
901
+ ai_message.tool_calls = all_tool_calls
902
+ return ai_message
903
+
904
+ async def ask_stream(
905
+ self,
906
+ prompt: str,
907
+ model: Union[str, OpenAIModel] = OpenAIModel.GPT4_TURBO,
908
+ max_tokens: int = None,
909
+ temperature: float = None,
910
+ files: Optional[List[Union[str, Path]]] = None,
911
+ system_prompt: Optional[str] = None,
912
+ user_id: Optional[str] = None,
913
+ session_id: Optional[str] = None,
914
+ tools: Optional[List[Dict[str, Any]]] = None,
915
+ structured_output: Union[type, StructuredOutputConfig, None] = None,
916
+ deep_research: bool = False,
917
+ agent_config: Optional[Dict[str, Any]] = None,
918
+ vector_store_ids: Optional[List[str]] = None,
919
+ enable_web_search: bool = True,
920
+ enable_code_interpreter: bool = False,
921
+ lazy_loading: bool = False,
922
+ ) -> AsyncIterator[str]:
923
+ """Stream OpenAI's response with optional conversation memory.
924
+
925
+ Args:
926
+ deep_research: If True, use deep research models with streaming
927
+ agent_config: Optional configuration (not used for OpenAI, for interface compatibility)
928
+ vector_store_ids: Vector store IDs for file_search tool
929
+ enable_web_search: Enable web search preview tool
930
+ enable_code_interpreter: Enable code interpreter tool
931
+ lazy_loading: If True, enable dynamic tool searching
932
+ """
933
+
934
+ # Generate unique turn ID for tracking
935
+ turn_id = str(uuid.uuid4())
936
+ # Extract model value if it's an enum
937
+ model_str = model.value if isinstance(model, Enum) else model
938
+
939
+ # Deep research routing (same as in ask method)
940
+ if deep_research:
941
+ if model_str in {"gpt-4o-mini", "gpt-4o", "gpt-4-turbo", OpenAIModel.GPT4_1.value}:
942
+ model_str = "o3-deep-research"
943
+ self.logger.info(f"Deep research streaming enabled: switching to {model_str}")
944
+ elif model_str not in RESPONSES_ONLY_MODELS:
945
+ model_str = "o3-deep-research"
946
+ self.logger.info(f"Deep research streaming enabled: switching to {model_str}")
947
+
948
+ messages, conversation_session, system_prompt = await self._prepare_conversation_context(
949
+ prompt, files, user_id, session_id, system_prompt
950
+ )
951
+
952
+ # Upload files if they are path-like objects
953
+ if files:
954
+ for file in files:
955
+ if isinstance(file, str):
956
+ file = Path(file)
957
+ if isinstance(file, Path):
958
+ await self._upload_file(file)
959
+
960
+ if lazy_loading and system_prompt:
961
+ system_prompt += "\n\nYou have access to a library of tools. Use the 'search_tools' function to find relevant tools."
962
+ elif lazy_loading and not system_prompt:
963
+ system_prompt = "You have access to a library of tools. Use the 'search_tools' function to find relevant tools."
964
+
965
+ if system_prompt:
966
+ messages.insert(0, {"role": "system", "content": system_prompt})
967
+
968
+ # Build research tools if needed
969
+ research_tools = []
970
+ if deep_research:
971
+ if enable_web_search:
972
+ research_tools.append({"type": "web_search_preview"})
973
+ if vector_store_ids:
974
+ research_tools.append({
975
+ "type": "file_search",
976
+ "vector_store_ids": vector_store_ids
977
+ })
978
+ if enable_code_interpreter:
979
+ research_tools.append({
980
+ "type": "code_interpreter",
981
+ "container": {"type": "auto"}
982
+ })
983
+ self.logger.info(f"Deep research streaming tools: {len(research_tools)} tools")
984
+
985
+ # Prepare tools (Note: streaming with tools is more complex)
986
+ if tools and isinstance(tools, list):
987
+ for tool in tools:
988
+ self.register_tool(tool)
989
+
990
+ # LAZY LOADING LOGIC
991
+ active_tool_names = set()
992
+ tools_payload = None
993
+
994
+ if self.tools:
995
+ if lazy_loading:
996
+ tools_payload = self._prepare_lazy_tools()
997
+ if tools_payload:
998
+ active_tool_names.add("search_tools")
999
+ else:
1000
+ tools_payload = self._prepare_tools()
1001
+
1002
+ args: Dict[str, Any] = {}
1003
+
1004
+ # Merge research tools with regular tools (same logic as ask)
1005
+ if deep_research and research_tools:
1006
+ args['tools'] = research_tools
1007
+ elif tools_payload:
1008
+ args['tools'] = tools_payload
1009
+ args['tool_choice'] = "auto"
1010
+ args["parallel_tool_calls"] = True
1011
+
1012
+ max_tokens_value = max_tokens if max_tokens is not None else self.max_tokens
1013
+ if max_tokens_value is not None:
1014
+ args['max_tokens'] = max_tokens_value
1015
+
1016
+ temperature_value = temperature if temperature is not None else self.temperature
1017
+ if temperature_value is not None:
1018
+ args['temperature'] = temperature_value
1019
+
1020
+ # -------- structured output config (normalize + model guard) --------
1021
+ output_config = self._get_structured_config(structured_output)
1022
+ if output_config and output_config.format == OutputFormat.JSON and model_str not in STRUCTURED_OUTPUT_COMPATIBLE_MODELS:
1023
+ self.logger.warning(
1024
+ "Model %s does not support structured outputs; switching to %s",
1025
+ model_str,
1026
+ DEFAULT_STRUCTURED_OUTPUT_MODEL
1027
+ )
1028
+ model_str = DEFAULT_STRUCTURED_OUTPUT_MODEL
1029
+
1030
+ # Build the OpenAI response_format payload (dict) once
1031
+ resp_format = self._build_response_format_from(output_config) if output_config else None
1032
+
1033
+ use_responses = self._is_responses_model(model_str)
1034
+ assistant_content = ""
1035
+
1036
+ if use_responses:
1037
+ req = self._prepare_responses_args(messages=messages, args=args)
1038
+ req["model"] = model_str
1039
+
1040
+ payload_base = dict(req)
1041
+ payload_base.pop("response", None)
1042
+ payload_base.pop("response_format", None)
1043
+ attempts: List[Dict[str, Any]] = []
1044
+ if resp_format:
1045
+ attempts.extend(
1046
+ (
1047
+ {**payload_base, "response_format": resp_format},
1048
+ self._with_extra_body(
1049
+ payload_base, {"response": {"format": resp_format}}
1050
+ ),
1051
+ dict(payload_base),
1052
+ )
1053
+ )
1054
+ else:
1055
+ attempts: List[Dict[str, Any]] = [dict(payload_base)]
1056
+
1057
+ stream_cm = await self._call_responses_stream(attempts)
1058
+
1059
+ async with stream_cm as stream:
1060
+ async for event in stream:
1061
+ event_type = getattr(event, "type", None)
1062
+ if event_type is None and isinstance(event, dict):
1063
+ event_type = event.get("type")
1064
+
1065
+ if event_type == "response.output_text.delta":
1066
+ delta = getattr(event, "delta", None)
1067
+ if delta is None and isinstance(event, dict):
1068
+ delta = event.get("delta")
1069
+ if delta:
1070
+ assistant_content += delta
1071
+ yield delta
1072
+ elif event_type == "response.output_text.done":
1073
+ text = getattr(event, "text", None)
1074
+ if text is None and isinstance(event, dict):
1075
+ text = event.get("text")
1076
+ if text:
1077
+ assistant_content += text
1078
+ yield text
1079
+
1080
+ final_response = None
1081
+ try:
1082
+ final_response = await stream.get_final_response()
1083
+ except Exception: # pylint: disable=broad-except
1084
+ final_response = None
1085
+
1086
+ if final_response and not assistant_content:
1087
+ output_text = getattr(final_response, "output_text", None) or ""
1088
+ if not output_text:
1089
+ for item in getattr(final_response, "output", []) or []:
1090
+ for part in getattr(item, "content", []) or []:
1091
+ text_part = None
1092
+ if isinstance(part, dict):
1093
+ if part.get("type") == "output_text":
1094
+ text_part = part.get("text", "")
1095
+ else:
1096
+ text_part = getattr(part, "text", None)
1097
+ if text_part:
1098
+ output_text += text_part
1099
+ if output_text:
1100
+ assistant_content = output_text
1101
+ yield output_text
1102
+ else:
1103
+ chat_args = dict(args)
1104
+ method = getattr(
1105
+ self.client.chat.completions, "parse",
1106
+ None
1107
+ ) if output_config else None
1108
+ if callable(method):
1109
+ try:
1110
+ response_stream = await method( # pylint: disable=E1102 # noqa
1111
+ model=model_str,
1112
+ messages=messages,
1113
+ stream=True,
1114
+ response_format=(output_config.output_type if output_config else None),
1115
+ **chat_args
1116
+ )
1117
+ except TypeError:
1118
+ # parse() in this SDK may not accept stream=True → fallback to create()
1119
+ method = None
1120
+ else:
1121
+ response_stream = await self.client.chat.completions.create(
1122
+ model=model_str,
1123
+ messages=messages,
1124
+ stream=True,
1125
+ response_format=(output_config.output_type if output_config else None),
1126
+ **chat_args
1127
+ )
1128
+
1129
+ async for chunk in response_stream:
1130
+ if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content:
1131
+ text_chunk = chunk.choices[0].delta.content
1132
+ assistant_content += text_chunk
1133
+ yield text_chunk
1134
+
1135
+ # Update conversation memory if content was generated
1136
+ if assistant_content:
1137
+ messages.append({
1138
+ "role": "assistant",
1139
+ "content": assistant_content
1140
+ })
1141
+ # Update conversation memory
1142
+ await self._update_conversation_memory(
1143
+ user_id,
1144
+ session_id,
1145
+ conversation_session,
1146
+ messages,
1147
+ system_prompt,
1148
+ turn_id,
1149
+ prompt,
1150
+ assistant_content,
1151
+ []
1152
+ )
1153
+
1154
+ async def batch_ask(self, requests) -> List[AIMessage]:
1155
+ """Process multiple requests in batch."""
1156
+ # OpenAI doesn't have a native batch API like Claude, so we process sequentially
1157
+ # In a real implementation, you might want to use asyncio.gather for concurrency
1158
+ results = []
1159
+ for request in requests:
1160
+ result = await self.ask(**request)
1161
+ results.append(result)
1162
+ return results
1163
+
1164
+ def _encode_image_for_openai(
1165
+ self,
1166
+ image: Union[Path, bytes, Image.Image],
1167
+ low_quality: bool = False
1168
+ ) -> Dict[str, Any]:
1169
+ """Encode image for OpenAI's vision API."""
1170
+ if isinstance(image, Path):
1171
+ if not image.exists():
1172
+ raise FileNotFoundError(f"Image file not found: {image}")
1173
+ mime_type, _ = mimetypes.guess_type(str(image))
1174
+ mime_type = mime_type or "image/jpeg"
1175
+ with open(image, "rb") as f:
1176
+ encoded_data = base64.b64encode(f.read()).decode('utf-8')
1177
+
1178
+ elif isinstance(image, bytes):
1179
+ mime_type = "image/jpeg"
1180
+ encoded_data = base64.b64encode(image).decode('utf-8')
1181
+
1182
+ elif isinstance(image, Image.Image):
1183
+ buffer = io.BytesIO()
1184
+ if image.mode in ("RGBA", "LA", "P"):
1185
+ image = image.convert("RGB")
1186
+ image.save(buffer, format="JPEG")
1187
+ encoded_data = base64.b64encode(buffer.getvalue()).decode('utf-8')
1188
+ mime_type = "image/jpeg"
1189
+
1190
+ else:
1191
+ raise ValueError("Image must be a Path, bytes, or PIL.Image object.")
1192
+
1193
+ return {
1194
+ "type": "image_url",
1195
+ "image_url": {
1196
+ "url": f"data:{mime_type};base64,{encoded_data}",
1197
+ "detail": "low" if low_quality else "auto"
1198
+ }
1199
+ }
1200
+
1201
+ async def ask_to_image(
1202
+ self,
1203
+ prompt: str,
1204
+ image: Union[Path, bytes, Image.Image],
1205
+ reference_images: Optional[List[Union[Path, bytes, Image.Image]]] = None,
1206
+ model: str = "gpt-4-turbo",
1207
+ max_tokens: int = None,
1208
+ temperature: float = None,
1209
+ structured_output: Optional[type] = None,
1210
+ user_id: Optional[str] = None,
1211
+ session_id: Optional[str] = None,
1212
+ no_memory: bool = False,
1213
+ low_quality: bool = False
1214
+ ) -> AIMessage:
1215
+ """Ask OpenAI a question about an image with optional conversation memory."""
1216
+ turn_id = str(uuid.uuid4())
1217
+
1218
+ if no_memory:
1219
+ messages = []
1220
+ conversation_session = None
1221
+ system_prompt = None
1222
+ else:
1223
+ messages, conversation_session, system_prompt = await self._prepare_conversation_context(
1224
+ prompt, None, user_id, session_id, None
1225
+ )
1226
+
1227
+ content = [{"type": "text", "text": prompt}]
1228
+
1229
+ primary_image_content = self._encode_image_for_openai(image, low_quality=low_quality)
1230
+ content.insert(0, primary_image_content)
1231
+
1232
+ if reference_images:
1233
+ for ref_image in reference_images:
1234
+ ref_image_content = self._encode_image_for_openai(ref_image, low_quality=low_quality)
1235
+ content.insert(0, ref_image_content)
1236
+
1237
+ new_message = {"role": "user", "content": content}
1238
+
1239
+ if messages and messages[-1]["role"] == "user":
1240
+ messages[-1] = new_message
1241
+ else:
1242
+ messages.append(new_message)
1243
+
1244
+ response_format = None
1245
+ if structured_output:
1246
+ if hasattr(structured_output, 'model_json_schema'):
1247
+ response_format = {
1248
+ "type": "json_schema",
1249
+ "json_schema": {
1250
+ "name": structured_output.__name__.lower(),
1251
+ "schema": structured_output.model_json_schema()
1252
+ }
1253
+ }
1254
+ elif isinstance(structured_output, dict):
1255
+ response_format = {
1256
+ "type": "json_schema",
1257
+ "json_schema": {
1258
+ "name": "response",
1259
+ "schema": structured_output
1260
+ }
1261
+ }
1262
+ else:
1263
+ response_format = {"type": "json_object"}
1264
+
1265
+ response = await self.client.chat.completions.create(
1266
+ model=model,
1267
+ messages=messages,
1268
+ max_tokens=max_tokens or self.max_tokens,
1269
+ temperature=temperature or self.temperature,
1270
+ response_format=response_format
1271
+ )
1272
+
1273
+ result = response.choices[0].message
1274
+
1275
+ final_output = None
1276
+ assistant_response_text = ""
1277
+ if structured_output is not None:
1278
+ if isinstance(structured_output, dict):
1279
+ assistant_response_text = result.content
1280
+ try:
1281
+ final_output = self._parse_json_from_text(assistant_response_text)
1282
+ except Exception:
1283
+ final_output = assistant_response_text
1284
+ else:
1285
+ try:
1286
+ final_output = structured_output.model_validate_json(result.content)
1287
+ except Exception:
1288
+ try:
1289
+ final_output = structured_output.model_validate(result.content)
1290
+ except ValidationError:
1291
+ final_output = result.content
1292
+
1293
+ assistant_message = {
1294
+ "role": "assistant", "content": [{"type": "text", "text": result.content}]
1295
+ }
1296
+ messages.append(assistant_message)
1297
+
1298
+ # Update conversation memory
1299
+ await self._update_conversation_memory(
1300
+ user_id,
1301
+ session_id,
1302
+ conversation_session,
1303
+ messages,
1304
+ system_prompt,
1305
+ turn_id,
1306
+ prompt,
1307
+ assistant_response_text,
1308
+ []
1309
+ )
1310
+
1311
+ usage = response.usage.model_dump() if response.usage else {}
1312
+
1313
+ ai_message = AIMessageFactory.from_openai(
1314
+ response=response,
1315
+ input_text=f"[Image Analysis]: {prompt}",
1316
+ model=model,
1317
+ user_id=user_id,
1318
+ session_id=session_id,
1319
+ turn_id=turn_id,
1320
+ structured_output=final_output
1321
+ )
1322
+
1323
+ ai_message.usage = CompletionUsage(
1324
+ prompt_tokens=usage.get("prompt_tokens", 0),
1325
+ completion_tokens=usage.get("completion_tokens", 0),
1326
+ total_tokens=usage.get("total_tokens", 0),
1327
+ extra_usage=usage
1328
+ )
1329
+
1330
+ ai_message.provider = "openai"
1331
+
1332
+ return ai_message
1333
+
1334
+ async def summarize_text(
1335
+ self,
1336
+ text: str,
1337
+ max_length: int = 500,
1338
+ min_length: int = 100,
1339
+ model: Union[OpenAIModel, str] = OpenAIModel.GPT4_TURBO,
1340
+ temperature: Optional[float] = None,
1341
+ user_id: Optional[str] = None,
1342
+ session_id: Optional[str] = None,
1343
+ ) -> AIMessage:
1344
+ """
1345
+ Generate a concise summary of *text* (single paragraph, stateless).
1346
+ """
1347
+ turn_id = str(uuid.uuid4())
1348
+
1349
+ system_prompt = (
1350
+ "Your job is to produce a final summary from the following text and "
1351
+ "identify the main theme.\n"
1352
+ f"- The summary should be concise and to the point.\n"
1353
+ f"- The summary should be no longer than {max_length} characters and "
1354
+ f"no less than {min_length} characters.\n"
1355
+ "- The summary should be in a single paragraph.\n"
1356
+ "- Focus on the key information and main points.\n"
1357
+ "- Write in clear, accessible language."
1358
+ )
1359
+
1360
+ messages = [
1361
+ {"role": "system", "content": system_prompt},
1362
+ {"role": "user", "content": text},
1363
+ ]
1364
+
1365
+ response = await self._chat_completion(
1366
+ model=model.value if isinstance(model, Enum) else model,
1367
+ messages=messages,
1368
+ max_tokens=self.max_tokens,
1369
+ temperature=temperature or self.temperature,
1370
+ use_tools=False,
1371
+ )
1372
+
1373
+ result = response.choices[0].message
1374
+
1375
+ return AIMessageFactory.from_openai(
1376
+ response=response,
1377
+ input_text=text,
1378
+ model=model,
1379
+ user_id=user_id,
1380
+ session_id=session_id,
1381
+ turn_id=turn_id,
1382
+ structured_output=None,
1383
+ )
1384
+
1385
+ async def translate_text(
1386
+ self,
1387
+ text: str,
1388
+ target_lang: str,
1389
+ source_lang: Optional[str] = None,
1390
+ model: Union[OpenAIModel, str] = OpenAIModel.GPT4_TURBO,
1391
+ temperature: float = 0.2,
1392
+ user_id: Optional[str] = None,
1393
+ session_id: Optional[str] = None,
1394
+ ) -> AIMessage:
1395
+ """
1396
+ Translate *text* from *source_lang* (auto‑detected if None) into *target_lang*.
1397
+ """
1398
+ turn_id = str(uuid.uuid4())
1399
+
1400
+ if source_lang:
1401
+ system_prompt = (
1402
+ f"You are a professional translator. Translate the following text "
1403
+ f"from {source_lang} to {target_lang}.\n"
1404
+ "- Provide only the translated text, without any additional comments "
1405
+ "or explanations.\n"
1406
+ "- Maintain the original meaning and tone.\n"
1407
+ "- Use natural, fluent language in the target language.\n"
1408
+ "- Preserve formatting if present (line breaks, bullet points, etc.)."
1409
+ )
1410
+ else:
1411
+ system_prompt = (
1412
+ f"You are a professional translator. First detect the source "
1413
+ f"language of the following text, then translate it to {target_lang}.\n"
1414
+ "- Provide only the translated text, without any additional comments "
1415
+ "or explanations.\n"
1416
+ "- Maintain the original meaning and tone.\n"
1417
+ "- Use natural, fluent language in the target language.\n"
1418
+ "- Preserve formatting if present (line breaks, bullet points, etc.)."
1419
+ )
1420
+
1421
+ messages = [
1422
+ {"role": "system", "content": system_prompt},
1423
+ {"role": "user", "content": text},
1424
+ ]
1425
+
1426
+ response = await self._chat_completion(
1427
+ model=model.value if isinstance(model, Enum) else model,
1428
+ messages=messages,
1429
+ max_tokens=self.max_tokens,
1430
+ temperature=temperature,
1431
+ )
1432
+
1433
+ return AIMessageFactory.from_openai(
1434
+ response=response,
1435
+ input_text=text,
1436
+ model=model,
1437
+ user_id=user_id,
1438
+ session_id=session_id,
1439
+ turn_id=turn_id,
1440
+ structured_output=None,
1441
+ )
1442
+
1443
+ async def extract_key_points(
1444
+ self,
1445
+ text: str,
1446
+ num_points: int = 5,
1447
+ model: Union[OpenAIModel, str] = OpenAIModel.GPT4_TURBO,
1448
+ temperature: float = 0.3,
1449
+ user_id: Optional[str] = None,
1450
+ session_id: Optional[str] = None,
1451
+ ) -> AIMessage:
1452
+ """
1453
+ Extract *num_points* bullet‑point key ideas from *text* (stateless).
1454
+ """
1455
+ turn_id = str(uuid.uuid4())
1456
+
1457
+ system_prompt = (
1458
+ f"Extract the {num_points} most important key points from the following text.\n"
1459
+ "- Present each point as a clear, concise bullet point (•).\n"
1460
+ "- Focus on the main ideas and significant information.\n"
1461
+ "- Each point should be self‑contained and meaningful.\n"
1462
+ "- Order points by importance (most important first)."
1463
+ )
1464
+
1465
+ messages = [
1466
+ {"role": "system", "content": system_prompt},
1467
+ {"role": "user", "content": text},
1468
+ ]
1469
+
1470
+ response = await self._chat_completion(
1471
+ model=model.value if isinstance(model, Enum) else model,
1472
+ messages=messages,
1473
+ max_tokens=self.max_tokens,
1474
+ temperature=temperature,
1475
+ )
1476
+
1477
+ return AIMessageFactory.from_openai(
1478
+ response=response,
1479
+ input_text=text,
1480
+ model=model,
1481
+ user_id=user_id,
1482
+ session_id=session_id,
1483
+ turn_id=turn_id,
1484
+ structured_output=None,
1485
+ )
1486
+
1487
+ async def analyze_sentiment(
1488
+ self,
1489
+ text: str,
1490
+ model: Union[OpenAIModel, str] = OpenAIModel.GPT4_TURBO,
1491
+ temperature: float = 0.1,
1492
+ user_id: Optional[str] = None,
1493
+ session_id: Optional[str] = None,
1494
+ ) -> AIMessage:
1495
+ """
1496
+ Perform sentiment analysis on *text* and return a structured explanation.
1497
+ """
1498
+ turn_id = str(uuid.uuid4())
1499
+
1500
+ system_prompt = (
1501
+ "Analyze the sentiment of the following text and provide a structured response.\n"
1502
+ "Your response must include:\n"
1503
+ "1. Overall sentiment (Positive, Negative, Neutral, or Mixed)\n"
1504
+ "2. Confidence level (High, Medium, Low)\n"
1505
+ "3. Key emotional indicators found in the text\n"
1506
+ "4. Brief explanation of your analysis\n\n"
1507
+ "Format your answer clearly with numbered sections."
1508
+ )
1509
+
1510
+ messages = [
1511
+ {"role": "system", "content": system_prompt},
1512
+ {"role": "user", "content": text},
1513
+ ]
1514
+
1515
+ response = await self._chat_completion(
1516
+ model=model.value if isinstance(model, Enum) else model,
1517
+ messages=messages,
1518
+ max_tokens=self.max_tokens,
1519
+ temperature=temperature,
1520
+ )
1521
+
1522
+ return AIMessageFactory.from_openai(
1523
+ response=response,
1524
+ input_text=text,
1525
+ model=model,
1526
+ user_id=user_id,
1527
+ session_id=session_id,
1528
+ turn_id=turn_id,
1529
+ structured_output=None,
1530
+ )
1531
+
1532
+ async def analyze_product_review(
1533
+ self,
1534
+ review_text: str,
1535
+ product_id: str,
1536
+ product_name: str,
1537
+ model: Union[OpenAIModel, str] = OpenAIModel.GPT4_TURBO,
1538
+ temperature: float = 0.1,
1539
+ user_id: Optional[str] = None,
1540
+ session_id: Optional[str] = None,
1541
+ ) -> AIMessage:
1542
+ """
1543
+ Analyze a product review and extract structured information.
1544
+
1545
+ Args:
1546
+ review_text (str): The product review text to analyze.
1547
+ product_id (str): Unique identifier for the product.
1548
+ product_name (str): Name of the product being reviewed.
1549
+ model (Union[OpenAIModel, str]): The model to use.
1550
+ temperature (float): Sampling temperature for response generation.
1551
+ user_id (Optional[str]): Optional user identifier for tracking.
1552
+ session_id (Optional[str]): Optional session identifier for tracking.
1553
+ """
1554
+ turn_id = str(uuid.uuid4())
1555
+
1556
+ system_prompt = (
1557
+ f"You are a product review analysis expert. Analyze the given product review "
1558
+ f"for '{product_name}' (ID: {product_id}) and extract structured information. "
1559
+ f"Determine the sentiment (positive, negative, or neutral), estimate a rating "
1560
+ f"based on the review content (0.0-5.0 scale), and identify key product features "
1561
+ f"mentioned in the review."
1562
+ )
1563
+
1564
+ messages = [
1565
+ {"role": "system", "content": system_prompt},
1566
+ {"role": "user", "content": f"Product ID: {product_id}\nProduct Name: {product_name}\nReview: {review_text}"},
1567
+ ]
1568
+
1569
+ # Use structured output with response_format
1570
+ response = await self._chat_completion(
1571
+ model=model.value if isinstance(model, Enum) else model,
1572
+ messages=messages,
1573
+ max_tokens=self.max_tokens,
1574
+ temperature=temperature,
1575
+ response_format={
1576
+ "type": "json_schema",
1577
+ "json_schema": {
1578
+ "name": "product_review_analysis",
1579
+ "schema": ProductReview.model_json_schema(),
1580
+ "strict": True
1581
+ }
1582
+ }
1583
+ )
1584
+
1585
+ return AIMessageFactory.from_openai(
1586
+ response=response,
1587
+ input_text=review_text,
1588
+ model=model,
1589
+ user_id=user_id,
1590
+ session_id=session_id,
1591
+ turn_id=turn_id,
1592
+ structured_output=ProductReview,
1593
+ )
1594
+
1595
+ async def image_identification(
1596
+ self,
1597
+ *,
1598
+ image: Union[Path, bytes, Image.Image],
1599
+ detections: List[DetectionBox], # from parrot.models.detections
1600
+ shelf_regions: List[ShelfRegion], # "
1601
+ reference_images: Optional[List[Union[Path, bytes, Image.Image]]] = None,
1602
+ model: Union[OpenAIModel, str] = OpenAIModel.GPT_4_1_MINI,
1603
+ prompt: Optional[str] = None,
1604
+ temperature: float = 0.0,
1605
+ ocr_hints: bool = True,
1606
+ user_id: Optional[str] = None,
1607
+ session_id: Optional[str] = None,
1608
+ max_tokens: Optional[int] = None
1609
+ ) -> List[IdentifiedProduct]:
1610
+ """
1611
+ Step-2: Identify products using the detected boxes + reference images.
1612
+
1613
+ Returns a list[IdentifiedProduct] with bbox, type, model, confidence, features,
1614
+ reference_match, shelf_location, and position_on_shelf.
1615
+ """
1616
+ _has_tesseract = True
1617
+
1618
+ def _crop_box(pil_img: Image.Image, box) -> Image.Image:
1619
+ # small padding to include context
1620
+ pad = 6
1621
+ x1 = max(0, box.x1 - pad)
1622
+ y1 = max(0, box.y1 - pad)
1623
+ x2 = min(pil_img.width, box.x2 + pad)
1624
+ y2 = min(pil_img.height, box.y2 + pad)
1625
+ return pil_img.crop((x1, y1, x2, y2))
1626
+
1627
+ def _shelf_and_position(box, regions: List[ShelfRegion]) -> Tuple[str, str]:
1628
+ # map to shelf by containment / Y overlap
1629
+ best = None
1630
+ best_overlap = 0
1631
+ for r in regions:
1632
+ rx1, ry1, rx2, ry2 = r.bbox.x1, r.bbox.y1, r.bbox.x2, r.bbox.y2
1633
+ ix1, iy1 = max(rx1, box.x1), max(ry1, box.y1)
1634
+ ix2, iy2 = min(rx2, box.x2), min(ry2, box.y2)
1635
+ ov = max(0, ix2 - ix1) * max(0, iy2 - iy1)
1636
+ if ov > best_overlap:
1637
+ best_overlap, best = ov, r
1638
+ shelf = best.level if best else "unknown"
1639
+
1640
+ # left/center/right inside the shelf bbox
1641
+ if best:
1642
+ mid = (box.x1 + box.x2) / 2.0
1643
+ thirds = (best.bbox.x1 + (best.bbox.x2 - best.bbox.x1) / 3.0,
1644
+ best.bbox.x1 + 2 * (best.bbox.x2 - best.bbox.x1) / 3.0)
1645
+ position = "left" if mid < thirds[0] else ("right" if mid > thirds[1] else "center")
1646
+ else:
1647
+ position = "center"
1648
+ return shelf, position
1649
+
1650
+ # --- prepare images ---
1651
+ if isinstance(image, (str, Path)):
1652
+ pil_image = Image.open(image).convert("RGB")
1653
+ elif isinstance(image, bytes):
1654
+ pil_image = Image.open(io.BytesIO(image)).convert("RGB")
1655
+ else:
1656
+ pil_image = image.convert("RGB")
1657
+
1658
+ # crops per detection
1659
+ crops = []
1660
+ for i, det in enumerate(detections, start=1):
1661
+ crop = _crop_box(pil_image, det)
1662
+ text_hint = ""
1663
+ if ocr_hints and _has_tesseract:
1664
+ try:
1665
+ text = pytesseract.image_to_string(crop)
1666
+ text_hint = text.strip()
1667
+ except Exception:
1668
+ text_hint = ""
1669
+ shelf, pos = _shelf_and_position(det, shelf_regions)
1670
+ crops.append({
1671
+ "id": i,
1672
+ "det": det,
1673
+ "shelf": shelf,
1674
+ "position": pos,
1675
+ "ocr": text_hint,
1676
+ "img_content": self._encode_image_for_openai(crop)
1677
+ })
1678
+
1679
+ # --- build messages (full image + crops + references) ---
1680
+ # Put references first, then the full scene, then each crop.
1681
+ content_blocks = []
1682
+
1683
+ # 1) reference images
1684
+ if reference_images:
1685
+ for ref in reference_images:
1686
+ content_blocks.append(self._encode_image_for_openai(ref))
1687
+
1688
+ # 2) full scene
1689
+ content_blocks.append(self._encode_image_for_openai(pil_image))
1690
+
1691
+ # 3) one block with per-detection crop + text hint
1692
+ # Images go as separate blocks; the textual metadata goes in one text block.
1693
+ meta_lines = ["DETECTIONS:"]
1694
+ for c in crops:
1695
+ d = c["det"]
1696
+ meta_lines.append(
1697
+ f"- id:{c['id']} bbox:[{d.x1},{d.y1},{d.x2},{d.y2}] class:{d.class_name} "
1698
+ f"shelf:{c['shelf']} position:{c['position']} ocr:{c['ocr'][:80] or 'None'}"
1699
+ )
1700
+ if prompt:
1701
+ text_block = prompt + "\n\nReturn ONLY JSON with top-level key 'items' that matches the provided schema." + "\n".join(meta_lines)
1702
+ else:
1703
+ text_block = (
1704
+ "Identify each detection by comparing with the reference images. "
1705
+ "Prefer visual features (shape, control panel, ink tank layout) and use OCR hints only as supportive evidence. "
1706
+ "Allowed product_type: ['printer','product_box','fact_tag','promotional_graphic','ink_bottle']. "
1707
+ "Models to look for (if any): ['ET-2980','ET-3950','ET-4950']. "
1708
+ "Return one item per detection id.\n"
1709
+ + "\n".join(meta_lines)
1710
+ )
1711
+ content_blocks.append({"type": "text", "text": text_block})
1712
+ # add crops
1713
+ for c in crops:
1714
+ content_blocks.append(c["img_content"])
1715
+
1716
+ # --- JSON schema (strict) for enforcement ---
1717
+ # We wrap the array in an object {"items":[...]} so json_schema works consistently.
1718
+ item_schema = {
1719
+ "type": "object",
1720
+ "additionalProperties": False,
1721
+ "properties": {
1722
+ "detection_id": {"type": "integer", "minimum": 1},
1723
+ "product_type": {"type": "string"},
1724
+ "product_model": {"type": ["string", "null"]},
1725
+ "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
1726
+ "visual_features": {"type": "array", "items": {"type": "string"}},
1727
+ "reference_match": {"type": ["string", "null"]},
1728
+ "shelf_location": {"type": "string"},
1729
+ "position_on_shelf": {"type": "string"},
1730
+ "brand": {"type": ["string", "null"]},
1731
+ "advertisement_type": {"type": ["string", "null"]},
1732
+ },
1733
+ "required": [
1734
+ "detection_id","product_type","product_model","confidence","visual_features",
1735
+ "reference_match","shelf_location","position_on_shelf","brand","advertisement_type"
1736
+ ],
1737
+ }
1738
+ resp_format = {
1739
+ "type": "json_schema",
1740
+ "json_schema": {
1741
+ "name": "identified_products",
1742
+ "strict": True,
1743
+ "schema": {
1744
+ "type": "object",
1745
+ "additionalProperties": False,
1746
+ "properties": {
1747
+ "items": {
1748
+ "type": "array",
1749
+ "items": item_schema,
1750
+ "minItems": len(detections), # drop or lower if this causes 400s
1751
+ }
1752
+ },
1753
+ "required": ["items"],
1754
+ },
1755
+ },
1756
+ }
1757
+
1758
+ # ensure shelves/positions are precomputed in case the model drops them
1759
+ shelf_pos_map = {c["id"]: (c["shelf"], c["position"]) for c in crops}
1760
+
1761
+ # --- call OpenAI ---
1762
+ messages = [{"role": "user", "content": content_blocks}]
1763
+ response = await self.client.chat.completions.create(
1764
+ model=model.value if isinstance(model, Enum) else model,
1765
+ messages=messages,
1766
+ max_tokens=max_tokens or self.max_tokens,
1767
+ temperature=temperature or self.temperature,
1768
+ response_format=resp_format
1769
+ )
1770
+
1771
+ raw = response.choices[0].message.content or "{}"
1772
+ try:
1773
+ # data = json.loads(raw)
1774
+ data = json_decoder(raw)
1775
+ items = data.get("items") or data.get("detections") or []
1776
+ except Exception:
1777
+ # fallback: try best-effort parse if model didn’t honor schema
1778
+ data = self._json.loads(
1779
+ raw
1780
+ )
1781
+ items = data.get("items") or data.get("detections") or []
1782
+
1783
+ # --- build IdentifiedProduct list ---
1784
+ out: List[IdentifiedProduct] = []
1785
+ for idx, it in enumerate(items, start=1):
1786
+ det_id = int(it.get("detection_id") or idx)
1787
+ if not (1 <= det_id <= len(detections)):
1788
+ continue
1789
+
1790
+ det = detections[det_id - 1]
1791
+ shelf, pos = shelf_pos_map.get(det_id, ("unknown", "center"))
1792
+
1793
+ # allow model to override if present
1794
+ shelf = (it.get("shelf_location") or shelf)
1795
+ pos = (it.get("position_on_shelf") or pos)
1796
+
1797
+ # --- COERCION / DEFAULTS ---
1798
+ det_cls = det.class_name.lower()
1799
+ pt = (it.get("product_type") or "").strip().lower()
1800
+ pm = (it.get("product_model") or None)
1801
+
1802
+ # Default to detector class when empty
1803
+ if not pt:
1804
+ pt = "price_tag" if det_cls in ("price_tag", "fact_tag") else det_cls
1805
+
1806
+ # Shelf rule: middle/bottom should be boxes; detector box forces box
1807
+ if shelf in ("middle", "bottom") or det_cls == "product_box":
1808
+ if pt == "printer":
1809
+ pt = "product_box"
1810
+
1811
+ # Fill sensible models
1812
+ if pt in ("price_tag", "fact_tag") and not pm:
1813
+ pm = "price tag"
1814
+ if pt == "promotional_graphic" and not pm:
1815
+ # light OCR-based guess if you like; otherwise leave None
1816
+ pm = None
1817
+
1818
+ out.append(
1819
+ IdentifiedProduct(
1820
+ detection_box=det,
1821
+ product_type=it.get("product_type", "unknown"),
1822
+ product_model=it.get("product_model"),
1823
+ confidence=float(it.get("confidence", 0.5)),
1824
+ visual_features=it.get("visual_features", []),
1825
+ reference_match=it.get("reference_match"),
1826
+ shelf_location=shelf,
1827
+ position_on_shelf=pos,
1828
+ detection_id=det_id,
1829
+ brand=it.get("brand"),
1830
+ advertisement_type=it.get("advertisement_type"),
1831
+ )
1832
+ )
1833
+ return out
1834
+
1835
+ async def generate_video(
1836
+ self,
1837
+ prompt: Union[str, Any],
1838
+ *,
1839
+ model_name: str = "sora-2", # "sora-1" or "sora-2"
1840
+ duration: Optional[int] = None, # seconds (if your access supports it)
1841
+ ratio: Optional[str] = None, # "16:9", "9:16", "1:1", etc. (mapped to aspect_ratio)
1842
+ output_path: Optional[Union[str, Path]] = None,
1843
+ poll_interval: float = 2.0,
1844
+ timeout: float = 15 * 60, # 15 minutes
1845
+ extra: Optional[Dict[str, Any]] = None, # pass-through for future knobs (seed/fps/style/etc.)
1846
+ ):
1847
+ """
1848
+ Generate a video with Sora using the Videos API and return an AIMessage.
1849
+
1850
+ Notes:
1851
+ - Requires an openai 2.6.x build that exposes `client.videos`.
1852
+ - This function intentionally does NOT fall back to Responses for video,
1853
+ because 2.6.0 rejects a `response` object (400 unknown_parameter).
1854
+ """
1855
+ start_ts = time.time()
1856
+
1857
+ # -------- 0) Verify Videos API exists in this installed client --------
1858
+ videos_res = getattr(self.client, "videos", None)
1859
+ if videos_res is None:
1860
+ import openai as _openai
1861
+ ver = getattr(_openai, "__version__", "unknown")
1862
+ raise RuntimeError(
1863
+ f"openai=={ver} does not expose `client.videos`; "
1864
+ "this build cannot generate video. Please upgrade to a build that includes the Videos API."
1865
+ )
1866
+
1867
+ # -------- 1) Normalize prompt + build create kwargs --------
1868
+ if isinstance(prompt, str):
1869
+ prompt_text = prompt
1870
+ create_kwargs: Dict[str, Any] = {"model": model_name, "prompt": prompt_text}
1871
+ else:
1872
+ # supports objects like your VideoPrompt with `.prompt` and maybe `.options`
1873
+ prompt_text = getattr(prompt, "prompt", None) or str(prompt)
1874
+ create_kwargs = {"model": model_name, "prompt": prompt_text}
1875
+ # if user supplied options, merge them
1876
+ opts = getattr(prompt, "options", None)
1877
+ if isinstance(opts, dict):
1878
+ create_kwargs |= opts
1879
+
1880
+ if duration is not None:
1881
+ create_kwargs["duration"] = duration
1882
+ if ratio:
1883
+ create_kwargs["aspect_ratio"] = ratio
1884
+ if extra:
1885
+ create_kwargs |= extra
1886
+
1887
+ # choose output file
1888
+ out_path = Path(output_path) if output_path else Path.cwd() / f"{int(start_ts)}_{model_name}.mp4"
1889
+
1890
+ # -------- 2) Run job (prefer create_and_poll) --------
1891
+ create_and_poll = getattr(videos_res, "create_and_poll", None)
1892
+ if callable(create_and_poll):
1893
+ video_obj = await create_and_poll(**create_kwargs)
1894
+ else:
1895
+ create = getattr(videos_res, "create", None)
1896
+ retrieve = getattr(videos_res, "retrieve", None)
1897
+ if not callable(create) or not callable(retrieve):
1898
+ import openai as _openai
1899
+ ver = getattr(_openai, "__version__", "unknown")
1900
+ raise RuntimeError(
1901
+ f"`client.videos` exists but lacks required methods in openai=={ver} "
1902
+ "(expected videos.create and videos.retrieve)."
1903
+ )
1904
+ job = await create(**create_kwargs)
1905
+ vid_id = getattr(job, "id", None) or getattr(job, "video_id", None)
1906
+ if not vid_id:
1907
+ raise RuntimeError(f"Videos.create returned no id: {job!r}")
1908
+
1909
+ status = getattr(job, "status", None) or "queued"
1910
+ start_poll = time.time()
1911
+ while status in ("queued", "in_progress", "processing", "running"):
1912
+ if (time.time() - start_poll) > timeout:
1913
+ raise RuntimeError(f"Video job {vid_id} timed out after {timeout}s")
1914
+ await asyncio.sleep(poll_interval)
1915
+ job = await retrieve(vid_id)
1916
+ status = getattr(job, "status", None)
1917
+ if status not in ("completed", "succeeded", "success"):
1918
+ err = getattr(job, "error", None) or getattr(job, "last_error", None)
1919
+ raise RuntimeError(f"Video job {vid_id} failed with status={status}, error={err}")
1920
+ video_obj = job
1921
+
1922
+ # -------- 3) Download the MP4 --------
1923
+ download = getattr(videos_res, "download_content", None)
1924
+ vid_id = getattr(video_obj, "id", None) or getattr(video_obj, "video_id", None)
1925
+ if callable(download) and vid_id:
1926
+ content = await download(vid_id)
1927
+ data = await content.aread() if hasattr(content, "aread") else bytes(content)
1928
+ out_path.write_bytes(data)
1929
+ else:
1930
+ url = getattr(video_obj, "url", None) or getattr(video_obj, "download_url", None)
1931
+ if url:
1932
+ # You can implement your own HTTP fetch here if needed
1933
+ raise RuntimeError(
1934
+ "download_content() is unavailable and direct URL download isn't implemented. "
1935
+ "Please enable videos.download_content in your SDK."
1936
+ )
1937
+ raise RuntimeError("Could not download video: no download method or URL available on video object.")
1938
+
1939
+ # -------- 4) Build saved_files + usage + raw_dump --------
1940
+ saved_files = [{
1941
+ "path": str(out_path),
1942
+ "mime_type": "video/mp4",
1943
+ "type": "video",
1944
+ "id": vid_id,
1945
+ "model": getattr(video_obj, "model", model_name),
1946
+ "duration": getattr(video_obj, "duration", None),
1947
+ }]
1948
+
1949
+ # usage is typically token-based for text; keep a minimal structure for consistency
1950
+ usage = getattr(video_obj, "usage", None) or {
1951
+ "total_tokens": 0,
1952
+ "input_tokens": 0,
1953
+ "output_tokens": 0,
1954
+ }
1955
+
1956
+ # serialize the raw object if it’s a Pydantic-like model
1957
+ raw_dump = (
1958
+ video_obj.model_dump() if hasattr(video_obj, "model_dump")
1959
+ else getattr(video_obj, "__dict__", video_obj)
1960
+ )
1961
+
1962
+ execution_time = time.time() - start_ts
1963
+
1964
+ # -------- 5) Return AIMessage (drop-in) --------
1965
+ return AIMessageFactory.from_video(
1966
+ output=raw_dump or video_obj,
1967
+ files=saved_files,
1968
+ media=saved_files,
1969
+ input=prompt_text,
1970
+ model=model_name,
1971
+ provider="openai",
1972
+ usage=usage,
1973
+ response_time=execution_time,
1974
+ raw_response=raw_dump,
1975
+ )