PraisonAI 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- praisonai/__init__.py +54 -0
- praisonai/__main__.py +15 -0
- praisonai/acp/__init__.py +54 -0
- praisonai/acp/config.py +159 -0
- praisonai/acp/server.py +587 -0
- praisonai/acp/session.py +219 -0
- praisonai/adapters/__init__.py +50 -0
- praisonai/adapters/readers.py +395 -0
- praisonai/adapters/rerankers.py +315 -0
- praisonai/adapters/retrievers.py +394 -0
- praisonai/adapters/vector_stores.py +409 -0
- praisonai/agent_scheduler.py +337 -0
- praisonai/agents_generator.py +903 -0
- praisonai/api/call.py +292 -0
- praisonai/auto.py +1197 -0
- praisonai/capabilities/__init__.py +275 -0
- praisonai/capabilities/a2a.py +140 -0
- praisonai/capabilities/assistants.py +283 -0
- praisonai/capabilities/audio.py +320 -0
- praisonai/capabilities/batches.py +469 -0
- praisonai/capabilities/completions.py +336 -0
- praisonai/capabilities/container_files.py +155 -0
- praisonai/capabilities/containers.py +93 -0
- praisonai/capabilities/embeddings.py +158 -0
- praisonai/capabilities/files.py +467 -0
- praisonai/capabilities/fine_tuning.py +293 -0
- praisonai/capabilities/guardrails.py +182 -0
- praisonai/capabilities/images.py +330 -0
- praisonai/capabilities/mcp.py +190 -0
- praisonai/capabilities/messages.py +270 -0
- praisonai/capabilities/moderations.py +154 -0
- praisonai/capabilities/ocr.py +217 -0
- praisonai/capabilities/passthrough.py +204 -0
- praisonai/capabilities/rag.py +207 -0
- praisonai/capabilities/realtime.py +160 -0
- praisonai/capabilities/rerank.py +165 -0
- praisonai/capabilities/responses.py +266 -0
- praisonai/capabilities/search.py +109 -0
- praisonai/capabilities/skills.py +133 -0
- praisonai/capabilities/vector_store_files.py +334 -0
- praisonai/capabilities/vector_stores.py +304 -0
- praisonai/capabilities/videos.py +141 -0
- praisonai/chainlit_ui.py +304 -0
- praisonai/chat/__init__.py +106 -0
- praisonai/chat/app.py +125 -0
- praisonai/cli/__init__.py +26 -0
- praisonai/cli/app.py +213 -0
- praisonai/cli/commands/__init__.py +75 -0
- praisonai/cli/commands/acp.py +70 -0
- praisonai/cli/commands/completion.py +333 -0
- praisonai/cli/commands/config.py +166 -0
- praisonai/cli/commands/debug.py +142 -0
- praisonai/cli/commands/diag.py +55 -0
- praisonai/cli/commands/doctor.py +166 -0
- praisonai/cli/commands/environment.py +179 -0
- praisonai/cli/commands/lsp.py +112 -0
- praisonai/cli/commands/mcp.py +210 -0
- praisonai/cli/commands/profile.py +457 -0
- praisonai/cli/commands/run.py +228 -0
- praisonai/cli/commands/schedule.py +150 -0
- praisonai/cli/commands/serve.py +97 -0
- praisonai/cli/commands/session.py +212 -0
- praisonai/cli/commands/traces.py +145 -0
- praisonai/cli/commands/version.py +101 -0
- praisonai/cli/configuration/__init__.py +18 -0
- praisonai/cli/configuration/loader.py +353 -0
- praisonai/cli/configuration/paths.py +114 -0
- praisonai/cli/configuration/schema.py +164 -0
- praisonai/cli/features/__init__.py +268 -0
- praisonai/cli/features/acp.py +236 -0
- praisonai/cli/features/action_orchestrator.py +546 -0
- praisonai/cli/features/agent_scheduler.py +773 -0
- praisonai/cli/features/agent_tools.py +474 -0
- praisonai/cli/features/agents.py +375 -0
- praisonai/cli/features/at_mentions.py +471 -0
- praisonai/cli/features/auto_memory.py +182 -0
- praisonai/cli/features/autonomy_mode.py +490 -0
- praisonai/cli/features/background.py +356 -0
- praisonai/cli/features/base.py +168 -0
- praisonai/cli/features/capabilities.py +1326 -0
- praisonai/cli/features/checkpoints.py +338 -0
- praisonai/cli/features/code_intelligence.py +652 -0
- praisonai/cli/features/compaction.py +294 -0
- praisonai/cli/features/compare.py +534 -0
- praisonai/cli/features/cost_tracker.py +514 -0
- praisonai/cli/features/debug.py +810 -0
- praisonai/cli/features/deploy.py +517 -0
- praisonai/cli/features/diag.py +289 -0
- praisonai/cli/features/doctor/__init__.py +63 -0
- praisonai/cli/features/doctor/checks/__init__.py +24 -0
- praisonai/cli/features/doctor/checks/acp_checks.py +240 -0
- praisonai/cli/features/doctor/checks/config_checks.py +366 -0
- praisonai/cli/features/doctor/checks/db_checks.py +366 -0
- praisonai/cli/features/doctor/checks/env_checks.py +543 -0
- praisonai/cli/features/doctor/checks/lsp_checks.py +199 -0
- praisonai/cli/features/doctor/checks/mcp_checks.py +349 -0
- praisonai/cli/features/doctor/checks/memory_checks.py +268 -0
- praisonai/cli/features/doctor/checks/network_checks.py +251 -0
- praisonai/cli/features/doctor/checks/obs_checks.py +328 -0
- praisonai/cli/features/doctor/checks/performance_checks.py +235 -0
- praisonai/cli/features/doctor/checks/permissions_checks.py +259 -0
- praisonai/cli/features/doctor/checks/selftest_checks.py +322 -0
- praisonai/cli/features/doctor/checks/serve_checks.py +426 -0
- praisonai/cli/features/doctor/checks/skills_checks.py +231 -0
- praisonai/cli/features/doctor/checks/tools_checks.py +371 -0
- praisonai/cli/features/doctor/engine.py +266 -0
- praisonai/cli/features/doctor/formatters.py +310 -0
- praisonai/cli/features/doctor/handler.py +397 -0
- praisonai/cli/features/doctor/models.py +264 -0
- praisonai/cli/features/doctor/registry.py +239 -0
- praisonai/cli/features/endpoints.py +1019 -0
- praisonai/cli/features/eval.py +560 -0
- praisonai/cli/features/external_agents.py +231 -0
- praisonai/cli/features/fast_context.py +410 -0
- praisonai/cli/features/flow_display.py +566 -0
- praisonai/cli/features/git_integration.py +651 -0
- praisonai/cli/features/guardrail.py +171 -0
- praisonai/cli/features/handoff.py +185 -0
- praisonai/cli/features/hooks.py +583 -0
- praisonai/cli/features/image.py +384 -0
- praisonai/cli/features/interactive_runtime.py +585 -0
- praisonai/cli/features/interactive_tools.py +380 -0
- praisonai/cli/features/interactive_tui.py +603 -0
- praisonai/cli/features/jobs.py +632 -0
- praisonai/cli/features/knowledge.py +531 -0
- praisonai/cli/features/lite.py +244 -0
- praisonai/cli/features/lsp_cli.py +225 -0
- praisonai/cli/features/mcp.py +169 -0
- praisonai/cli/features/message_queue.py +587 -0
- praisonai/cli/features/metrics.py +211 -0
- praisonai/cli/features/n8n.py +673 -0
- praisonai/cli/features/observability.py +293 -0
- praisonai/cli/features/ollama.py +361 -0
- praisonai/cli/features/output_style.py +273 -0
- praisonai/cli/features/package.py +631 -0
- praisonai/cli/features/performance.py +308 -0
- praisonai/cli/features/persistence.py +636 -0
- praisonai/cli/features/profile.py +226 -0
- praisonai/cli/features/profiler/__init__.py +81 -0
- praisonai/cli/features/profiler/core.py +558 -0
- praisonai/cli/features/profiler/optimizations.py +652 -0
- praisonai/cli/features/profiler/suite.py +386 -0
- praisonai/cli/features/profiling.py +350 -0
- praisonai/cli/features/queue/__init__.py +73 -0
- praisonai/cli/features/queue/manager.py +395 -0
- praisonai/cli/features/queue/models.py +286 -0
- praisonai/cli/features/queue/persistence.py +564 -0
- praisonai/cli/features/queue/scheduler.py +484 -0
- praisonai/cli/features/queue/worker.py +372 -0
- praisonai/cli/features/recipe.py +1723 -0
- praisonai/cli/features/recipes.py +449 -0
- praisonai/cli/features/registry.py +229 -0
- praisonai/cli/features/repo_map.py +860 -0
- praisonai/cli/features/router.py +466 -0
- praisonai/cli/features/sandbox_executor.py +515 -0
- praisonai/cli/features/serve.py +829 -0
- praisonai/cli/features/session.py +222 -0
- praisonai/cli/features/skills.py +856 -0
- praisonai/cli/features/slash_commands.py +650 -0
- praisonai/cli/features/telemetry.py +179 -0
- praisonai/cli/features/templates.py +1384 -0
- praisonai/cli/features/thinking.py +305 -0
- praisonai/cli/features/todo.py +334 -0
- praisonai/cli/features/tools.py +680 -0
- praisonai/cli/features/tui/__init__.py +83 -0
- praisonai/cli/features/tui/app.py +580 -0
- praisonai/cli/features/tui/cli.py +566 -0
- praisonai/cli/features/tui/debug.py +511 -0
- praisonai/cli/features/tui/events.py +99 -0
- praisonai/cli/features/tui/mock_provider.py +328 -0
- praisonai/cli/features/tui/orchestrator.py +652 -0
- praisonai/cli/features/tui/screens/__init__.py +50 -0
- praisonai/cli/features/tui/screens/main.py +245 -0
- praisonai/cli/features/tui/screens/queue.py +174 -0
- praisonai/cli/features/tui/screens/session.py +124 -0
- praisonai/cli/features/tui/screens/settings.py +148 -0
- praisonai/cli/features/tui/widgets/__init__.py +56 -0
- praisonai/cli/features/tui/widgets/chat.py +261 -0
- praisonai/cli/features/tui/widgets/composer.py +224 -0
- praisonai/cli/features/tui/widgets/queue_panel.py +200 -0
- praisonai/cli/features/tui/widgets/status.py +167 -0
- praisonai/cli/features/tui/widgets/tool_panel.py +248 -0
- praisonai/cli/features/workflow.py +720 -0
- praisonai/cli/legacy.py +236 -0
- praisonai/cli/main.py +5559 -0
- praisonai/cli/schedule_cli.py +54 -0
- praisonai/cli/state/__init__.py +31 -0
- praisonai/cli/state/identifiers.py +161 -0
- praisonai/cli/state/sessions.py +313 -0
- praisonai/code/__init__.py +93 -0
- praisonai/code/agent_tools.py +344 -0
- praisonai/code/diff/__init__.py +21 -0
- praisonai/code/diff/diff_strategy.py +432 -0
- praisonai/code/tools/__init__.py +27 -0
- praisonai/code/tools/apply_diff.py +221 -0
- praisonai/code/tools/execute_command.py +275 -0
- praisonai/code/tools/list_files.py +274 -0
- praisonai/code/tools/read_file.py +206 -0
- praisonai/code/tools/search_replace.py +248 -0
- praisonai/code/tools/write_file.py +217 -0
- praisonai/code/utils/__init__.py +46 -0
- praisonai/code/utils/file_utils.py +307 -0
- praisonai/code/utils/ignore_utils.py +308 -0
- praisonai/code/utils/text_utils.py +276 -0
- praisonai/db/__init__.py +64 -0
- praisonai/db/adapter.py +531 -0
- praisonai/deploy/__init__.py +62 -0
- praisonai/deploy/api.py +231 -0
- praisonai/deploy/docker.py +454 -0
- praisonai/deploy/doctor.py +367 -0
- praisonai/deploy/main.py +327 -0
- praisonai/deploy/models.py +179 -0
- praisonai/deploy/providers/__init__.py +33 -0
- praisonai/deploy/providers/aws.py +331 -0
- praisonai/deploy/providers/azure.py +358 -0
- praisonai/deploy/providers/base.py +101 -0
- praisonai/deploy/providers/gcp.py +314 -0
- praisonai/deploy/schema.py +208 -0
- praisonai/deploy.py +185 -0
- praisonai/endpoints/__init__.py +53 -0
- praisonai/endpoints/a2u_server.py +410 -0
- praisonai/endpoints/discovery.py +165 -0
- praisonai/endpoints/providers/__init__.py +28 -0
- praisonai/endpoints/providers/a2a.py +253 -0
- praisonai/endpoints/providers/a2u.py +208 -0
- praisonai/endpoints/providers/agents_api.py +171 -0
- praisonai/endpoints/providers/base.py +231 -0
- praisonai/endpoints/providers/mcp.py +263 -0
- praisonai/endpoints/providers/recipe.py +206 -0
- praisonai/endpoints/providers/tools_mcp.py +150 -0
- praisonai/endpoints/registry.py +131 -0
- praisonai/endpoints/server.py +161 -0
- praisonai/inbuilt_tools/__init__.py +24 -0
- praisonai/inbuilt_tools/autogen_tools.py +117 -0
- praisonai/inc/__init__.py +2 -0
- praisonai/inc/config.py +96 -0
- praisonai/inc/models.py +155 -0
- praisonai/integrations/__init__.py +56 -0
- praisonai/integrations/base.py +303 -0
- praisonai/integrations/claude_code.py +270 -0
- praisonai/integrations/codex_cli.py +255 -0
- praisonai/integrations/cursor_cli.py +195 -0
- praisonai/integrations/gemini_cli.py +222 -0
- praisonai/jobs/__init__.py +67 -0
- praisonai/jobs/executor.py +425 -0
- praisonai/jobs/models.py +230 -0
- praisonai/jobs/router.py +314 -0
- praisonai/jobs/server.py +186 -0
- praisonai/jobs/store.py +203 -0
- praisonai/llm/__init__.py +66 -0
- praisonai/llm/registry.py +382 -0
- praisonai/mcp_server/__init__.py +152 -0
- praisonai/mcp_server/adapters/__init__.py +74 -0
- praisonai/mcp_server/adapters/agents.py +128 -0
- praisonai/mcp_server/adapters/capabilities.py +168 -0
- praisonai/mcp_server/adapters/cli_tools.py +568 -0
- praisonai/mcp_server/adapters/extended_capabilities.py +462 -0
- praisonai/mcp_server/adapters/knowledge.py +93 -0
- praisonai/mcp_server/adapters/memory.py +104 -0
- praisonai/mcp_server/adapters/prompts.py +306 -0
- praisonai/mcp_server/adapters/resources.py +124 -0
- praisonai/mcp_server/adapters/tools_bridge.py +280 -0
- praisonai/mcp_server/auth/__init__.py +48 -0
- praisonai/mcp_server/auth/api_key.py +291 -0
- praisonai/mcp_server/auth/oauth.py +460 -0
- praisonai/mcp_server/auth/oidc.py +289 -0
- praisonai/mcp_server/auth/scopes.py +260 -0
- praisonai/mcp_server/cli.py +852 -0
- praisonai/mcp_server/elicitation.py +445 -0
- praisonai/mcp_server/icons.py +302 -0
- praisonai/mcp_server/recipe_adapter.py +573 -0
- praisonai/mcp_server/recipe_cli.py +824 -0
- praisonai/mcp_server/registry.py +703 -0
- praisonai/mcp_server/sampling.py +422 -0
- praisonai/mcp_server/server.py +490 -0
- praisonai/mcp_server/tasks.py +443 -0
- praisonai/mcp_server/transports/__init__.py +18 -0
- praisonai/mcp_server/transports/http_stream.py +376 -0
- praisonai/mcp_server/transports/stdio.py +132 -0
- praisonai/persistence/__init__.py +84 -0
- praisonai/persistence/config.py +238 -0
- praisonai/persistence/conversation/__init__.py +25 -0
- praisonai/persistence/conversation/async_mysql.py +427 -0
- praisonai/persistence/conversation/async_postgres.py +410 -0
- praisonai/persistence/conversation/async_sqlite.py +371 -0
- praisonai/persistence/conversation/base.py +151 -0
- praisonai/persistence/conversation/json_store.py +250 -0
- praisonai/persistence/conversation/mysql.py +387 -0
- praisonai/persistence/conversation/postgres.py +401 -0
- praisonai/persistence/conversation/singlestore.py +240 -0
- praisonai/persistence/conversation/sqlite.py +341 -0
- praisonai/persistence/conversation/supabase.py +203 -0
- praisonai/persistence/conversation/surrealdb.py +287 -0
- praisonai/persistence/factory.py +301 -0
- praisonai/persistence/hooks/__init__.py +18 -0
- praisonai/persistence/hooks/agent_hooks.py +297 -0
- praisonai/persistence/knowledge/__init__.py +26 -0
- praisonai/persistence/knowledge/base.py +144 -0
- praisonai/persistence/knowledge/cassandra.py +232 -0
- praisonai/persistence/knowledge/chroma.py +295 -0
- praisonai/persistence/knowledge/clickhouse.py +242 -0
- praisonai/persistence/knowledge/cosmosdb_vector.py +438 -0
- praisonai/persistence/knowledge/couchbase.py +286 -0
- praisonai/persistence/knowledge/lancedb.py +216 -0
- praisonai/persistence/knowledge/langchain_adapter.py +291 -0
- praisonai/persistence/knowledge/lightrag_adapter.py +212 -0
- praisonai/persistence/knowledge/llamaindex_adapter.py +256 -0
- praisonai/persistence/knowledge/milvus.py +277 -0
- praisonai/persistence/knowledge/mongodb_vector.py +306 -0
- praisonai/persistence/knowledge/pgvector.py +335 -0
- praisonai/persistence/knowledge/pinecone.py +253 -0
- praisonai/persistence/knowledge/qdrant.py +301 -0
- praisonai/persistence/knowledge/redis_vector.py +291 -0
- praisonai/persistence/knowledge/singlestore_vector.py +299 -0
- praisonai/persistence/knowledge/surrealdb_vector.py +309 -0
- praisonai/persistence/knowledge/upstash_vector.py +266 -0
- praisonai/persistence/knowledge/weaviate.py +223 -0
- praisonai/persistence/migrations/__init__.py +10 -0
- praisonai/persistence/migrations/manager.py +251 -0
- praisonai/persistence/orchestrator.py +406 -0
- praisonai/persistence/state/__init__.py +21 -0
- praisonai/persistence/state/async_mongodb.py +200 -0
- praisonai/persistence/state/base.py +107 -0
- praisonai/persistence/state/dynamodb.py +226 -0
- praisonai/persistence/state/firestore.py +175 -0
- praisonai/persistence/state/gcs.py +155 -0
- praisonai/persistence/state/memory.py +245 -0
- praisonai/persistence/state/mongodb.py +158 -0
- praisonai/persistence/state/redis.py +190 -0
- praisonai/persistence/state/upstash.py +144 -0
- praisonai/persistence/tests/__init__.py +3 -0
- praisonai/persistence/tests/test_all_backends.py +633 -0
- praisonai/profiler.py +1214 -0
- praisonai/recipe/__init__.py +134 -0
- praisonai/recipe/bridge.py +278 -0
- praisonai/recipe/core.py +893 -0
- praisonai/recipe/exceptions.py +54 -0
- praisonai/recipe/history.py +402 -0
- praisonai/recipe/models.py +266 -0
- praisonai/recipe/operations.py +440 -0
- praisonai/recipe/policy.py +422 -0
- praisonai/recipe/registry.py +849 -0
- praisonai/recipe/runtime.py +214 -0
- praisonai/recipe/security.py +711 -0
- praisonai/recipe/serve.py +859 -0
- praisonai/recipe/server.py +613 -0
- praisonai/scheduler/__init__.py +45 -0
- praisonai/scheduler/agent_scheduler.py +552 -0
- praisonai/scheduler/base.py +124 -0
- praisonai/scheduler/daemon_manager.py +225 -0
- praisonai/scheduler/state_manager.py +155 -0
- praisonai/scheduler/yaml_loader.py +193 -0
- praisonai/scheduler.py +194 -0
- praisonai/setup/__init__.py +1 -0
- praisonai/setup/build.py +21 -0
- praisonai/setup/post_install.py +23 -0
- praisonai/setup/setup_conda_env.py +25 -0
- praisonai/setup.py +16 -0
- praisonai/templates/__init__.py +116 -0
- praisonai/templates/cache.py +364 -0
- praisonai/templates/dependency_checker.py +358 -0
- praisonai/templates/discovery.py +391 -0
- praisonai/templates/loader.py +564 -0
- praisonai/templates/registry.py +511 -0
- praisonai/templates/resolver.py +206 -0
- praisonai/templates/security.py +327 -0
- praisonai/templates/tool_override.py +498 -0
- praisonai/templates/tools_doctor.py +256 -0
- praisonai/test.py +105 -0
- praisonai/train.py +562 -0
- praisonai/train_vision.py +306 -0
- praisonai/ui/agents.py +824 -0
- praisonai/ui/callbacks.py +57 -0
- praisonai/ui/chainlit_compat.py +246 -0
- praisonai/ui/chat.py +532 -0
- praisonai/ui/code.py +717 -0
- praisonai/ui/colab.py +474 -0
- praisonai/ui/colab_chainlit.py +81 -0
- praisonai/ui/components/aicoder.py +284 -0
- praisonai/ui/context.py +283 -0
- praisonai/ui/database_config.py +56 -0
- praisonai/ui/db.py +294 -0
- praisonai/ui/realtime.py +488 -0
- praisonai/ui/realtimeclient/__init__.py +756 -0
- praisonai/ui/realtimeclient/tools.py +242 -0
- praisonai/ui/sql_alchemy.py +710 -0
- praisonai/upload_vision.py +140 -0
- praisonai/version.py +1 -0
- praisonai-3.0.0.dist-info/METADATA +3493 -0
- praisonai-3.0.0.dist-info/RECORD +393 -0
- praisonai-3.0.0.dist-info/WHEEL +5 -0
- praisonai-3.0.0.dist-info/entry_points.txt +4 -0
- praisonai-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
# Derived from https://github.com/openai/openai-realtime-console. Will integrate with Chainlit when more mature.
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import asyncio
|
|
5
|
+
import inspect
|
|
6
|
+
import numpy as np
|
|
7
|
+
import json
|
|
8
|
+
import websockets
|
|
9
|
+
from websockets.exceptions import ConnectionClosed
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
import base64
|
|
13
|
+
|
|
14
|
+
from chainlit.logger import logger
|
|
15
|
+
from chainlit.config import config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def float_to_16bit_pcm(float32_array):
|
|
19
|
+
"""
|
|
20
|
+
Converts a numpy array of float32 amplitude data to a numpy array in int16 format.
|
|
21
|
+
:param float32_array: numpy array of float32
|
|
22
|
+
:return: numpy array of int16
|
|
23
|
+
"""
|
|
24
|
+
int16_array = np.clip(float32_array, -1, 1) * 32767
|
|
25
|
+
return int16_array.astype(np.int16)
|
|
26
|
+
|
|
27
|
+
def base64_to_array_buffer(base64_string):
|
|
28
|
+
"""
|
|
29
|
+
Converts a base64 string to a numpy array buffer.
|
|
30
|
+
:param base64_string: base64 encoded string
|
|
31
|
+
:return: numpy array of uint8
|
|
32
|
+
"""
|
|
33
|
+
binary_data = base64.b64decode(base64_string)
|
|
34
|
+
return np.frombuffer(binary_data, dtype=np.uint8)
|
|
35
|
+
|
|
36
|
+
def array_buffer_to_base64(array_buffer):
|
|
37
|
+
"""
|
|
38
|
+
Converts a numpy array buffer to a base64 string.
|
|
39
|
+
:param array_buffer: numpy array
|
|
40
|
+
:return: base64 encoded string
|
|
41
|
+
"""
|
|
42
|
+
if array_buffer.dtype == np.float32:
|
|
43
|
+
array_buffer = float_to_16bit_pcm(array_buffer)
|
|
44
|
+
elif array_buffer.dtype == np.int16:
|
|
45
|
+
array_buffer = array_buffer.tobytes()
|
|
46
|
+
else:
|
|
47
|
+
array_buffer = array_buffer.tobytes()
|
|
48
|
+
|
|
49
|
+
return base64.b64encode(array_buffer).decode('utf-8')
|
|
50
|
+
|
|
51
|
+
def merge_int16_arrays(left, right):
|
|
52
|
+
"""
|
|
53
|
+
Merge two numpy arrays of int16.
|
|
54
|
+
:param left: numpy array of int16
|
|
55
|
+
:param right: numpy array of int16
|
|
56
|
+
:return: merged numpy array of int16
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(left, np.ndarray) and left.dtype == np.int16 and isinstance(right, np.ndarray) and right.dtype == np.int16:
|
|
59
|
+
return np.concatenate((left, right))
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError("Both items must be numpy arrays of int16")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RealtimeEventHandler:
|
|
65
|
+
def __init__(self):
|
|
66
|
+
self.event_handlers = defaultdict(list)
|
|
67
|
+
|
|
68
|
+
def on(self, event_name, handler):
|
|
69
|
+
self.event_handlers[event_name].append(handler)
|
|
70
|
+
|
|
71
|
+
def clear_event_handlers(self):
|
|
72
|
+
self.event_handlers = defaultdict(list)
|
|
73
|
+
|
|
74
|
+
def dispatch(self, event_name, event):
|
|
75
|
+
for handler in self.event_handlers[event_name]:
|
|
76
|
+
if inspect.iscoroutinefunction(handler):
|
|
77
|
+
asyncio.create_task(handler(event))
|
|
78
|
+
else:
|
|
79
|
+
handler(event)
|
|
80
|
+
|
|
81
|
+
async def wait_for_next(self, event_name):
|
|
82
|
+
future = asyncio.Future()
|
|
83
|
+
|
|
84
|
+
def handler(event):
|
|
85
|
+
if not future.done():
|
|
86
|
+
future.set_result(event)
|
|
87
|
+
|
|
88
|
+
self.on(event_name, handler)
|
|
89
|
+
return await future
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RealtimeAPI(RealtimeEventHandler):
|
|
93
|
+
def __init__(self, url=None, api_key=None):
|
|
94
|
+
super().__init__()
|
|
95
|
+
self.default_url = 'wss://api.openai.com/v1/realtime'
|
|
96
|
+
|
|
97
|
+
# Support custom base URL from environment variable
|
|
98
|
+
base_url = os.getenv("OPENAI_BASE_URL")
|
|
99
|
+
if base_url:
|
|
100
|
+
# Convert HTTP/HTTPS base URL to WebSocket URL for realtime API
|
|
101
|
+
if base_url.startswith('https://'):
|
|
102
|
+
ws_url = base_url.replace('https://', 'wss://').rstrip('/') + '/realtime'
|
|
103
|
+
elif base_url.startswith('http://'):
|
|
104
|
+
ws_url = base_url.replace('http://', 'ws://').rstrip('/') + '/realtime'
|
|
105
|
+
else:
|
|
106
|
+
# Assume it's already a WebSocket URL
|
|
107
|
+
ws_url = base_url.rstrip('/') + '/realtime' if not base_url.endswith('/realtime') else base_url
|
|
108
|
+
self.url = url or ws_url
|
|
109
|
+
else:
|
|
110
|
+
self.url = url or self.default_url
|
|
111
|
+
|
|
112
|
+
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
|
|
113
|
+
self.ws = None
|
|
114
|
+
|
|
115
|
+
def is_connected(self):
|
|
116
|
+
if self.ws is None:
|
|
117
|
+
return False
|
|
118
|
+
# Some websockets versions don't have a closed attribute
|
|
119
|
+
try:
|
|
120
|
+
return not self.ws.closed
|
|
121
|
+
except AttributeError:
|
|
122
|
+
# Fallback: check if websocket is still alive by checking state
|
|
123
|
+
try:
|
|
124
|
+
return hasattr(self.ws, 'state') and self.ws.state.name == 'OPEN'
|
|
125
|
+
except:
|
|
126
|
+
# Last fallback: assume connected if ws exists
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
def log(self, *args):
|
|
130
|
+
logger.debug(f"[Websocket/{datetime.utcnow().isoformat()}]", *args)
|
|
131
|
+
|
|
132
|
+
async def connect(self, model='gpt-5-nano-realtime-preview-2024-12-17'):
|
|
133
|
+
if self.is_connected():
|
|
134
|
+
raise Exception("Already connected")
|
|
135
|
+
|
|
136
|
+
headers = {
|
|
137
|
+
'Authorization': f'Bearer {self.api_key}',
|
|
138
|
+
'OpenAI-Beta': 'realtime=v1'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Try different header parameter names for compatibility
|
|
142
|
+
try:
|
|
143
|
+
self.ws = await websockets.connect(f"{self.url}?model={model}", additional_headers=headers)
|
|
144
|
+
except TypeError:
|
|
145
|
+
# Fallback to older websockets versions
|
|
146
|
+
try:
|
|
147
|
+
self.ws = await websockets.connect(f"{self.url}?model={model}", extra_headers=headers)
|
|
148
|
+
except TypeError:
|
|
149
|
+
# Last fallback - some versions might not support headers parameter
|
|
150
|
+
raise Exception("Websockets library version incompatible. Please update websockets to version 11.0 or higher.")
|
|
151
|
+
|
|
152
|
+
self.log(f"Connected to {self.url}")
|
|
153
|
+
asyncio.create_task(self._receive_messages())
|
|
154
|
+
|
|
155
|
+
async def _receive_messages(self):
|
|
156
|
+
try:
|
|
157
|
+
async for message in self.ws:
|
|
158
|
+
event = json.loads(message)
|
|
159
|
+
if event['type'] == "error":
|
|
160
|
+
logger.error(f"OpenAI Realtime API Error: {event}")
|
|
161
|
+
self.log("received:", event)
|
|
162
|
+
self.dispatch(f"server.{event['type']}", event)
|
|
163
|
+
self.dispatch("server.*", event)
|
|
164
|
+
except ConnectionClosed as e:
|
|
165
|
+
logger.info(f"WebSocket connection closed normally: {e}")
|
|
166
|
+
# Mark connection as closed
|
|
167
|
+
self.ws = None
|
|
168
|
+
# Dispatch disconnection event
|
|
169
|
+
self.dispatch("disconnected", {"reason": str(e)})
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.warning(f"WebSocket receive loop ended: {e}")
|
|
172
|
+
# Mark connection as closed
|
|
173
|
+
self.ws = None
|
|
174
|
+
# Dispatch disconnection event
|
|
175
|
+
self.dispatch("disconnected", {"reason": str(e)})
|
|
176
|
+
|
|
177
|
+
async def send(self, event_name, data=None):
|
|
178
|
+
if not self.is_connected():
|
|
179
|
+
raise Exception("RealtimeAPI is not connected")
|
|
180
|
+
data = data or {}
|
|
181
|
+
if not isinstance(data, dict):
|
|
182
|
+
raise Exception("data must be a dictionary")
|
|
183
|
+
event = {
|
|
184
|
+
"event_id": self._generate_id("evt_"),
|
|
185
|
+
"type": event_name,
|
|
186
|
+
**data
|
|
187
|
+
}
|
|
188
|
+
self.dispatch(f"client.{event_name}", event)
|
|
189
|
+
self.dispatch("client.*", event)
|
|
190
|
+
self.log("sent:", event)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
await self.ws.send(json.dumps(event))
|
|
194
|
+
except ConnectionClosed as e:
|
|
195
|
+
logger.info(f"WebSocket connection closed during send: {e}")
|
|
196
|
+
# Mark connection as closed if send fails
|
|
197
|
+
self.ws = None
|
|
198
|
+
raise Exception(f"WebSocket connection lost: {e}")
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.error(f"Failed to send WebSocket message: {e}")
|
|
201
|
+
# Mark connection as closed if send fails
|
|
202
|
+
self.ws = None
|
|
203
|
+
raise Exception(f"WebSocket connection lost: {e}")
|
|
204
|
+
|
|
205
|
+
def _generate_id(self, prefix):
|
|
206
|
+
return f"{prefix}{int(datetime.utcnow().timestamp() * 1000)}"
|
|
207
|
+
|
|
208
|
+
async def disconnect(self):
|
|
209
|
+
if self.ws:
|
|
210
|
+
try:
|
|
211
|
+
await self.ws.close()
|
|
212
|
+
logger.info(f"Disconnected from {self.url}")
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logger.warning(f"Error during WebSocket close: {e}")
|
|
215
|
+
finally:
|
|
216
|
+
self.ws = None
|
|
217
|
+
self.log(f"WebSocket connection cleaned up")
|
|
218
|
+
|
|
219
|
+
class RealtimeConversation:
|
|
220
|
+
default_frequency = config.features.audio.sample_rate
|
|
221
|
+
|
|
222
|
+
EventProcessors = {
|
|
223
|
+
'conversation.item.created': lambda self, event: self._process_item_created(event),
|
|
224
|
+
'conversation.item.truncated': lambda self, event: self._process_item_truncated(event),
|
|
225
|
+
'conversation.item.deleted': lambda self, event: self._process_item_deleted(event),
|
|
226
|
+
'conversation.item.input_audio_transcription.completed': lambda self, event: self._process_input_audio_transcription_completed(event),
|
|
227
|
+
'input_audio_buffer.speech_started': lambda self, event: self._process_speech_started(event),
|
|
228
|
+
'input_audio_buffer.speech_stopped': lambda self, event, input_audio_buffer: self._process_speech_stopped(event, input_audio_buffer),
|
|
229
|
+
'response.created': lambda self, event: self._process_response_created(event),
|
|
230
|
+
'response.output_item.added': lambda self, event: self._process_output_item_added(event),
|
|
231
|
+
'response.output_item.done': lambda self, event: self._process_output_item_done(event),
|
|
232
|
+
'response.content_part.added': lambda self, event: self._process_content_part_added(event),
|
|
233
|
+
'response.audio_transcript.delta': lambda self, event: self._process_audio_transcript_delta(event),
|
|
234
|
+
'response.audio.delta': lambda self, event: self._process_audio_delta(event),
|
|
235
|
+
'response.text.delta': lambda self, event: self._process_text_delta(event),
|
|
236
|
+
'response.function_call_arguments.delta': lambda self, event: self._process_function_call_arguments_delta(event),
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
def __init__(self):
|
|
240
|
+
self.clear()
|
|
241
|
+
|
|
242
|
+
def clear(self):
|
|
243
|
+
self.item_lookup = {}
|
|
244
|
+
self.items = []
|
|
245
|
+
self.response_lookup = {}
|
|
246
|
+
self.responses = []
|
|
247
|
+
self.queued_speech_items = {}
|
|
248
|
+
self.queued_transcript_items = {}
|
|
249
|
+
self.queued_input_audio = None
|
|
250
|
+
|
|
251
|
+
def queue_input_audio(self, input_audio):
|
|
252
|
+
self.queued_input_audio = input_audio
|
|
253
|
+
|
|
254
|
+
def process_event(self, event, *args):
|
|
255
|
+
event_processor = self.EventProcessors.get(event['type'])
|
|
256
|
+
if not event_processor:
|
|
257
|
+
raise Exception(f"Missing conversation event processor for {event['type']}")
|
|
258
|
+
return event_processor(self, event, *args)
|
|
259
|
+
|
|
260
|
+
def get_item(self, id):
|
|
261
|
+
return self.item_lookup.get(id)
|
|
262
|
+
|
|
263
|
+
def get_items(self):
|
|
264
|
+
return self.items[:]
|
|
265
|
+
|
|
266
|
+
def _process_item_created(self, event):
|
|
267
|
+
item = event['item']
|
|
268
|
+
new_item = item.copy()
|
|
269
|
+
if new_item['id'] not in self.item_lookup:
|
|
270
|
+
self.item_lookup[new_item['id']] = new_item
|
|
271
|
+
self.items.append(new_item)
|
|
272
|
+
new_item['formatted'] = {
|
|
273
|
+
'audio': [],
|
|
274
|
+
'text': '',
|
|
275
|
+
'transcript': ''
|
|
276
|
+
}
|
|
277
|
+
if new_item['id'] in self.queued_speech_items:
|
|
278
|
+
new_item['formatted']['audio'] = self.queued_speech_items[new_item['id']]['audio']
|
|
279
|
+
del self.queued_speech_items[new_item['id']]
|
|
280
|
+
if 'content' in new_item:
|
|
281
|
+
text_content = [c for c in new_item['content'] if c['type'] in ['text', 'input_text']]
|
|
282
|
+
for content in text_content:
|
|
283
|
+
new_item['formatted']['text'] += content['text']
|
|
284
|
+
if new_item['id'] in self.queued_transcript_items:
|
|
285
|
+
new_item['formatted']['transcript'] = self.queued_transcript_items[new_item['id']]['transcript']
|
|
286
|
+
del self.queued_transcript_items[new_item['id']]
|
|
287
|
+
if new_item['type'] == 'message':
|
|
288
|
+
if new_item['role'] == 'user':
|
|
289
|
+
new_item['status'] = 'completed'
|
|
290
|
+
if self.queued_input_audio:
|
|
291
|
+
new_item['formatted']['audio'] = self.queued_input_audio
|
|
292
|
+
self.queued_input_audio = None
|
|
293
|
+
else:
|
|
294
|
+
new_item['status'] = 'in_progress'
|
|
295
|
+
elif new_item['type'] == 'function_call':
|
|
296
|
+
new_item['formatted']['tool'] = {
|
|
297
|
+
'type': 'function',
|
|
298
|
+
'name': new_item['name'],
|
|
299
|
+
'call_id': new_item['call_id'],
|
|
300
|
+
'arguments': ''
|
|
301
|
+
}
|
|
302
|
+
new_item['status'] = 'in_progress'
|
|
303
|
+
elif new_item['type'] == 'function_call_output':
|
|
304
|
+
new_item['status'] = 'completed'
|
|
305
|
+
new_item['formatted']['output'] = new_item['output']
|
|
306
|
+
return new_item, None
|
|
307
|
+
|
|
308
|
+
def _process_item_truncated(self, event):
|
|
309
|
+
item_id = event['item_id']
|
|
310
|
+
audio_end_ms = event['audio_end_ms']
|
|
311
|
+
item = self.item_lookup.get(item_id)
|
|
312
|
+
if not item:
|
|
313
|
+
raise Exception(f'item.truncated: Item "{item_id}" not found')
|
|
314
|
+
end_index = (audio_end_ms * self.default_frequency) // 1000
|
|
315
|
+
item['formatted']['transcript'] = ''
|
|
316
|
+
item['formatted']['audio'] = item['formatted']['audio'][:end_index]
|
|
317
|
+
return item, None
|
|
318
|
+
|
|
319
|
+
def _process_item_deleted(self, event):
|
|
320
|
+
item_id = event['item_id']
|
|
321
|
+
item = self.item_lookup.get(item_id)
|
|
322
|
+
if not item:
|
|
323
|
+
raise Exception(f'item.deleted: Item "{item_id}" not found')
|
|
324
|
+
del self.item_lookup[item['id']]
|
|
325
|
+
self.items.remove(item)
|
|
326
|
+
return item, None
|
|
327
|
+
|
|
328
|
+
def _process_input_audio_transcription_completed(self, event):
|
|
329
|
+
item_id = event['item_id']
|
|
330
|
+
content_index = event['content_index']
|
|
331
|
+
transcript = event['transcript']
|
|
332
|
+
formatted_transcript = transcript or ' '
|
|
333
|
+
item = self.item_lookup.get(item_id)
|
|
334
|
+
if not item:
|
|
335
|
+
self.queued_transcript_items[item_id] = {'transcript': formatted_transcript}
|
|
336
|
+
return None, None
|
|
337
|
+
item['content'][content_index]['transcript'] = transcript
|
|
338
|
+
item['formatted']['transcript'] = formatted_transcript
|
|
339
|
+
return item, {'transcript': transcript}
|
|
340
|
+
|
|
341
|
+
def _process_speech_started(self, event):
|
|
342
|
+
item_id = event['item_id']
|
|
343
|
+
audio_start_ms = event['audio_start_ms']
|
|
344
|
+
self.queued_speech_items[item_id] = {'audio_start_ms': audio_start_ms}
|
|
345
|
+
return None, None
|
|
346
|
+
|
|
347
|
+
def _process_speech_stopped(self, event, input_audio_buffer):
|
|
348
|
+
item_id = event['item_id']
|
|
349
|
+
audio_end_ms = event['audio_end_ms']
|
|
350
|
+
speech = self.queued_speech_items[item_id]
|
|
351
|
+
speech['audio_end_ms'] = audio_end_ms
|
|
352
|
+
if input_audio_buffer:
|
|
353
|
+
start_index = (speech['audio_start_ms'] * self.default_frequency) // 1000
|
|
354
|
+
end_index = (speech['audio_end_ms'] * self.default_frequency) // 1000
|
|
355
|
+
speech['audio'] = input_audio_buffer[start_index:end_index]
|
|
356
|
+
return None, None
|
|
357
|
+
|
|
358
|
+
def _process_response_created(self, event):
|
|
359
|
+
response = event['response']
|
|
360
|
+
if response['id'] not in self.response_lookup:
|
|
361
|
+
self.response_lookup[response['id']] = response
|
|
362
|
+
self.responses.append(response)
|
|
363
|
+
return None, None
|
|
364
|
+
|
|
365
|
+
def _process_output_item_added(self, event):
|
|
366
|
+
response_id = event['response_id']
|
|
367
|
+
item = event['item']
|
|
368
|
+
response = self.response_lookup.get(response_id)
|
|
369
|
+
if not response:
|
|
370
|
+
raise Exception(f'response.output_item.added: Response "{response_id}" not found')
|
|
371
|
+
response['output'].append(item['id'])
|
|
372
|
+
return None, None
|
|
373
|
+
|
|
374
|
+
def _process_output_item_done(self, event):
|
|
375
|
+
item = event['item']
|
|
376
|
+
if not item:
|
|
377
|
+
raise Exception('response.output_item.done: Missing "item"')
|
|
378
|
+
found_item = self.item_lookup.get(item['id'])
|
|
379
|
+
if not found_item:
|
|
380
|
+
raise Exception(f'response.output_item.done: Item "{item["id"]}" not found')
|
|
381
|
+
found_item['status'] = item['status']
|
|
382
|
+
return found_item, None
|
|
383
|
+
|
|
384
|
+
def _process_content_part_added(self, event):
|
|
385
|
+
item_id = event['item_id']
|
|
386
|
+
part = event['part']
|
|
387
|
+
item = self.item_lookup.get(item_id)
|
|
388
|
+
if not item:
|
|
389
|
+
raise Exception(f'response.content_part.added: Item "{item_id}" not found')
|
|
390
|
+
item['content'].append(part)
|
|
391
|
+
return item, None
|
|
392
|
+
|
|
393
|
+
def _process_audio_transcript_delta(self, event):
|
|
394
|
+
item_id = event['item_id']
|
|
395
|
+
content_index = event['content_index']
|
|
396
|
+
delta = event['delta']
|
|
397
|
+
item = self.item_lookup.get(item_id)
|
|
398
|
+
if not item:
|
|
399
|
+
raise Exception(f'response.audio_transcript.delta: Item "{item_id}" not found')
|
|
400
|
+
item['content'][content_index]['transcript'] += delta
|
|
401
|
+
item['formatted']['transcript'] += delta
|
|
402
|
+
return item, {'transcript': delta}
|
|
403
|
+
|
|
404
|
+
def _process_audio_delta(self, event):
|
|
405
|
+
item_id = event['item_id']
|
|
406
|
+
content_index = event['content_index']
|
|
407
|
+
delta = event['delta']
|
|
408
|
+
item = self.item_lookup.get(item_id)
|
|
409
|
+
if not item:
|
|
410
|
+
logger.debug(f'response.audio.delta: Item "{item_id}" not found')
|
|
411
|
+
return None, None
|
|
412
|
+
array_buffer = base64_to_array_buffer(delta)
|
|
413
|
+
append_values = array_buffer.tobytes()
|
|
414
|
+
item['formatted']['audio'].append(append_values)
|
|
415
|
+
return item, {'audio': append_values}
|
|
416
|
+
|
|
417
|
+
def _process_text_delta(self, event):
|
|
418
|
+
item_id = event['item_id']
|
|
419
|
+
content_index = event['content_index']
|
|
420
|
+
delta = event['delta']
|
|
421
|
+
item = self.item_lookup.get(item_id)
|
|
422
|
+
if not item:
|
|
423
|
+
raise Exception(f'response.text.delta: Item "{item_id}" not found')
|
|
424
|
+
item['content'][content_index]['text'] += delta
|
|
425
|
+
item['formatted']['text'] += delta
|
|
426
|
+
return item, {'text': delta}
|
|
427
|
+
|
|
428
|
+
def _process_function_call_arguments_delta(self, event):
|
|
429
|
+
item_id = event['item_id']
|
|
430
|
+
delta = event['delta']
|
|
431
|
+
item = self.item_lookup.get(item_id)
|
|
432
|
+
if not item:
|
|
433
|
+
raise Exception(f'response.function_call_arguments.delta: Item "{item_id}" not found')
|
|
434
|
+
item['arguments'] += delta
|
|
435
|
+
item['formatted']['tool']['arguments'] += delta
|
|
436
|
+
return item, {'arguments': delta}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class RealtimeClient(RealtimeEventHandler):
|
|
440
|
+
def __init__(self, url=None, api_key=None):
|
|
441
|
+
super().__init__()
|
|
442
|
+
self.default_session_config = {
|
|
443
|
+
"modalities": ["text", "audio"],
|
|
444
|
+
"instructions": "System settings:\nTool use: enabled.\n\nInstructions:\n- You are an artificial intelligence agent responsible for helping test realtime voice capabilities\n- Please make sure to respond with a helpful voice via audio\n- Be kind, helpful, and curteous\n- It is okay to ask the user questions\n- Use tools and functions you have available liberally, it is part of the training apparatus\n- Be open to exploration and conversation\n- Remember: this is just for fun and testing!\n\nPersonality:\n- Be upbeat and genuine\n- Try speaking quickly as if excited\n",
|
|
445
|
+
"voice": "shimmer",
|
|
446
|
+
"input_audio_format": "pcm16",
|
|
447
|
+
"output_audio_format": "pcm16",
|
|
448
|
+
"input_audio_transcription": { "model": 'whisper-1' },
|
|
449
|
+
"turn_detection": { "type": 'server_vad' },
|
|
450
|
+
"tools": [],
|
|
451
|
+
"tool_choice": "auto",
|
|
452
|
+
"temperature": 0.8,
|
|
453
|
+
}
|
|
454
|
+
self.session_config = {}
|
|
455
|
+
self.transcription_models = [{"model": "whisper-1"}]
|
|
456
|
+
self.default_server_vad_config = {
|
|
457
|
+
"type": "server_vad",
|
|
458
|
+
"threshold": 0.5,
|
|
459
|
+
"prefix_padding_ms": 300,
|
|
460
|
+
"silence_duration_ms": 200,
|
|
461
|
+
}
|
|
462
|
+
self.realtime = RealtimeAPI(url, api_key)
|
|
463
|
+
self.conversation = RealtimeConversation()
|
|
464
|
+
self._reset_config()
|
|
465
|
+
self._add_api_event_handlers()
|
|
466
|
+
|
|
467
|
+
def _reset_config(self):
|
|
468
|
+
self.session_created = False
|
|
469
|
+
self.tools = {}
|
|
470
|
+
self.session_config = self.default_session_config.copy()
|
|
471
|
+
self.input_audio_buffer = bytearray()
|
|
472
|
+
return True
|
|
473
|
+
|
|
474
|
+
def _add_api_event_handlers(self):
|
|
475
|
+
self.realtime.on("client.*", self._log_event)
|
|
476
|
+
self.realtime.on("server.*", self._log_event)
|
|
477
|
+
self.realtime.on("server.session.created", self._on_session_created)
|
|
478
|
+
self.realtime.on("server.response.created", self._process_event)
|
|
479
|
+
self.realtime.on("server.response.output_item.added", self._process_event)
|
|
480
|
+
self.realtime.on("server.response.content_part.added", self._process_event)
|
|
481
|
+
self.realtime.on("server.input_audio_buffer.speech_started", self._on_speech_started)
|
|
482
|
+
self.realtime.on("server.input_audio_buffer.speech_stopped", self._on_speech_stopped)
|
|
483
|
+
self.realtime.on("server.conversation.item.created", self._on_item_created)
|
|
484
|
+
self.realtime.on("server.conversation.item.truncated", self._process_event)
|
|
485
|
+
self.realtime.on("server.conversation.item.deleted", self._process_event)
|
|
486
|
+
self.realtime.on("server.conversation.item.input_audio_transcription.completed", self._process_event)
|
|
487
|
+
self.realtime.on("server.response.audio_transcript.delta", self._process_event)
|
|
488
|
+
self.realtime.on("server.response.audio.delta", self._process_event)
|
|
489
|
+
self.realtime.on("server.response.text.delta", self._process_event)
|
|
490
|
+
self.realtime.on("server.response.function_call_arguments.delta", self._process_event)
|
|
491
|
+
self.realtime.on("server.response.output_item.done", self._on_output_item_done)
|
|
492
|
+
|
|
493
|
+
def _log_event(self, event):
|
|
494
|
+
realtime_event = {
|
|
495
|
+
"time": datetime.utcnow().isoformat(),
|
|
496
|
+
"source": "client" if event["type"].startswith("client.") else "server",
|
|
497
|
+
"event": event,
|
|
498
|
+
}
|
|
499
|
+
self.dispatch("realtime.event", realtime_event)
|
|
500
|
+
|
|
501
|
+
def _on_session_created(self, event):
|
|
502
|
+
try:
|
|
503
|
+
session_id = event.get('session', {}).get('id', 'unknown')
|
|
504
|
+
model = event.get('session', {}).get('model', 'unknown')
|
|
505
|
+
logger.info(f"OpenAI Realtime session created - ID: {session_id}, Model: {model}")
|
|
506
|
+
except Exception as e:
|
|
507
|
+
logger.warning(f"Error processing session created event: {e}")
|
|
508
|
+
logger.debug(f"Session event details: {event}")
|
|
509
|
+
self.session_created = True
|
|
510
|
+
|
|
511
|
+
def _process_event(self, event, *args):
|
|
512
|
+
item, delta = self.conversation.process_event(event, *args)
|
|
513
|
+
if item:
|
|
514
|
+
self.dispatch("conversation.updated", {"item": item, "delta": delta})
|
|
515
|
+
return item, delta
|
|
516
|
+
|
|
517
|
+
def _on_speech_started(self, event):
|
|
518
|
+
self._process_event(event)
|
|
519
|
+
self.dispatch("conversation.interrupted", event)
|
|
520
|
+
|
|
521
|
+
def _on_speech_stopped(self, event):
|
|
522
|
+
self._process_event(event, self.input_audio_buffer)
|
|
523
|
+
|
|
524
|
+
def _on_item_created(self, event):
|
|
525
|
+
item, delta = self._process_event(event)
|
|
526
|
+
self.dispatch("conversation.item.appended", {"item": item})
|
|
527
|
+
if item and item["status"] == "completed":
|
|
528
|
+
self.dispatch("conversation.item.completed", {"item": item})
|
|
529
|
+
|
|
530
|
+
async def _on_output_item_done(self, event):
|
|
531
|
+
item, delta = self._process_event(event)
|
|
532
|
+
if item and item["status"] == "completed":
|
|
533
|
+
self.dispatch("conversation.item.completed", {"item": item})
|
|
534
|
+
if item and item.get("formatted", {}).get("tool"):
|
|
535
|
+
await self._call_tool(item["formatted"]["tool"])
|
|
536
|
+
|
|
537
|
+
async def _call_tool(self, tool):
|
|
538
|
+
try:
|
|
539
|
+
json_arguments = json.loads(tool["arguments"])
|
|
540
|
+
tool_config = self.tools.get(tool["name"])
|
|
541
|
+
if not tool_config:
|
|
542
|
+
raise Exception(f'Tool "{tool["name"]}" has not been added')
|
|
543
|
+
result = await tool_config["handler"](**json_arguments)
|
|
544
|
+
await self.realtime.send("conversation.item.create", {
|
|
545
|
+
"item": {
|
|
546
|
+
"type": "function_call_output",
|
|
547
|
+
"call_id": tool["call_id"],
|
|
548
|
+
"output": json.dumps(result),
|
|
549
|
+
}
|
|
550
|
+
})
|
|
551
|
+
except Exception as e:
|
|
552
|
+
error_message = json.dumps({"error": str(e)})
|
|
553
|
+
logger.error(f"Tool call error: {error_message}")
|
|
554
|
+
await self.realtime.send("conversation.item.create", {
|
|
555
|
+
"item": {
|
|
556
|
+
"type": "function_call_output",
|
|
557
|
+
"call_id": tool["call_id"],
|
|
558
|
+
"output": error_message,
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
await self.create_response()
|
|
562
|
+
|
|
563
|
+
def is_connected(self):
|
|
564
|
+
return self.realtime.is_connected()
|
|
565
|
+
|
|
566
|
+
def reset(self):
|
|
567
|
+
self.disconnect()
|
|
568
|
+
self.realtime.clear_event_handlers()
|
|
569
|
+
self._reset_config()
|
|
570
|
+
self._add_api_event_handlers()
|
|
571
|
+
return True
|
|
572
|
+
|
|
573
|
+
async def connect(self, model=None):
|
|
574
|
+
if self.is_connected():
|
|
575
|
+
raise Exception("Already connected, use .disconnect() first")
|
|
576
|
+
|
|
577
|
+
# Use provided model, OPENAI_MODEL_NAME environment variable, or default
|
|
578
|
+
if model is None:
|
|
579
|
+
model = os.getenv("OPENAI_MODEL_NAME", 'gpt-5-nano-realtime-preview-2024-12-17')
|
|
580
|
+
|
|
581
|
+
await self.realtime.connect(model)
|
|
582
|
+
await self.update_session()
|
|
583
|
+
return True
|
|
584
|
+
|
|
585
|
+
async def wait_for_session_created(self):
|
|
586
|
+
if not self.is_connected():
|
|
587
|
+
raise Exception("Not connected, use .connect() first")
|
|
588
|
+
while not self.session_created:
|
|
589
|
+
await asyncio.sleep(0.001)
|
|
590
|
+
return True
|
|
591
|
+
|
|
592
|
+
async def disconnect(self):
|
|
593
|
+
self.session_created = False
|
|
594
|
+
self.conversation.clear()
|
|
595
|
+
if self.realtime.is_connected():
|
|
596
|
+
await self.realtime.disconnect()
|
|
597
|
+
logger.info("RealtimeClient disconnected")
|
|
598
|
+
|
|
599
|
+
def get_turn_detection_type(self):
|
|
600
|
+
return self.session_config.get("turn_detection", {}).get("type")
|
|
601
|
+
|
|
602
|
+
async def add_tool(self, definition, handler):
|
|
603
|
+
if not definition.get("name"):
|
|
604
|
+
raise Exception("Missing tool name in definition")
|
|
605
|
+
name = definition["name"]
|
|
606
|
+
if name in self.tools:
|
|
607
|
+
raise Exception(f'Tool "{name}" already added. Please use .removeTool("{name}") before trying to add again.')
|
|
608
|
+
if not callable(handler):
|
|
609
|
+
raise Exception(f'Tool "{name}" handler must be a function')
|
|
610
|
+
self.tools[name] = {"definition": definition, "handler": handler}
|
|
611
|
+
await self.update_session()
|
|
612
|
+
return self.tools[name]
|
|
613
|
+
|
|
614
|
+
def remove_tool(self, name):
|
|
615
|
+
if name not in self.tools:
|
|
616
|
+
raise Exception(f'Tool "{name}" does not exist, can not be removed.')
|
|
617
|
+
del self.tools[name]
|
|
618
|
+
return True
|
|
619
|
+
|
|
620
|
+
async def delete_item(self, id):
|
|
621
|
+
await self.realtime.send("conversation.item.delete", {"item_id": id})
|
|
622
|
+
return True
|
|
623
|
+
|
|
624
|
+
async def update_session(self, **kwargs):
|
|
625
|
+
self.session_config.update(kwargs)
|
|
626
|
+
use_tools = [
|
|
627
|
+
{**tool_definition, "type": "function"}
|
|
628
|
+
for tool_definition in self.session_config.get("tools", [])
|
|
629
|
+
] + [
|
|
630
|
+
{**self.tools[key]["definition"], "type": "function"}
|
|
631
|
+
for key in self.tools
|
|
632
|
+
]
|
|
633
|
+
session = {**self.session_config, "tools": use_tools}
|
|
634
|
+
logger.debug(f"Updating session: {session}")
|
|
635
|
+
if self.realtime.is_connected():
|
|
636
|
+
await self.realtime.send("session.update", {"session": session})
|
|
637
|
+
return True
|
|
638
|
+
|
|
639
|
+
async def create_conversation_item(self, item):
|
|
640
|
+
await self.realtime.send("conversation.item.create", {
|
|
641
|
+
"item": item
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
async def send_user_message_content(self, content=[]):
|
|
645
|
+
if content:
|
|
646
|
+
for c in content:
|
|
647
|
+
if c["type"] == "input_audio":
|
|
648
|
+
if isinstance(c["audio"], (bytes, bytearray)):
|
|
649
|
+
c["audio"] = array_buffer_to_base64(c["audio"])
|
|
650
|
+
await self.realtime.send("conversation.item.create", {
|
|
651
|
+
"item": {
|
|
652
|
+
"type": "message",
|
|
653
|
+
"role": "user",
|
|
654
|
+
"content": content,
|
|
655
|
+
}
|
|
656
|
+
})
|
|
657
|
+
await self.create_response()
|
|
658
|
+
return True
|
|
659
|
+
|
|
660
|
+
async def append_input_audio(self, array_buffer):
|
|
661
|
+
if not self.is_connected():
|
|
662
|
+
logger.warning("Cannot append audio: RealtimeClient is not connected")
|
|
663
|
+
return False
|
|
664
|
+
|
|
665
|
+
if len(array_buffer) > 0:
|
|
666
|
+
try:
|
|
667
|
+
await self.realtime.send("input_audio_buffer.append", {
|
|
668
|
+
"audio": array_buffer_to_base64(np.array(array_buffer)),
|
|
669
|
+
})
|
|
670
|
+
self.input_audio_buffer.extend(array_buffer)
|
|
671
|
+
except Exception as e:
|
|
672
|
+
logger.error(f"Failed to append input audio: {e}")
|
|
673
|
+
# Connection might be lost, mark as disconnected
|
|
674
|
+
if "connection" in str(e).lower() or "closed" in str(e).lower():
|
|
675
|
+
logger.warning("WebSocket connection appears to be lost. Audio input will be queued until reconnection.")
|
|
676
|
+
return False
|
|
677
|
+
return True
|
|
678
|
+
|
|
679
|
+
async def create_response(self):
|
|
680
|
+
if self.get_turn_detection_type() is None and len(self.input_audio_buffer) > 0:
|
|
681
|
+
await self.realtime.send("input_audio_buffer.commit")
|
|
682
|
+
self.conversation.queue_input_audio(self.input_audio_buffer)
|
|
683
|
+
self.input_audio_buffer = bytearray()
|
|
684
|
+
await self.realtime.send("response.create")
|
|
685
|
+
return True
|
|
686
|
+
|
|
687
|
+
async def cancel_response(self, id=None, sample_count=0):
|
|
688
|
+
if not id:
|
|
689
|
+
await self.realtime.send("response.cancel")
|
|
690
|
+
return {"item": None}
|
|
691
|
+
else:
|
|
692
|
+
item = self.conversation.get_item(id)
|
|
693
|
+
if not item:
|
|
694
|
+
raise Exception(f'Could not find item "{id}"')
|
|
695
|
+
if item["type"] != "message":
|
|
696
|
+
raise Exception('Can only cancelResponse messages with type "message"')
|
|
697
|
+
if item["role"] != "assistant":
|
|
698
|
+
raise Exception('Can only cancelResponse messages with role "assistant"')
|
|
699
|
+
await self.realtime.send("response.cancel")
|
|
700
|
+
audio_index = next((i for i, c in enumerate(item["content"]) if c["type"] == "audio"), -1)
|
|
701
|
+
if audio_index == -1:
|
|
702
|
+
raise Exception("Could not find audio on item to cancel")
|
|
703
|
+
await self.realtime.send("conversation.item.truncate", {
|
|
704
|
+
"item_id": id,
|
|
705
|
+
"content_index": audio_index,
|
|
706
|
+
"audio_end_ms": int((sample_count / self.conversation.default_frequency) * 1000),
|
|
707
|
+
})
|
|
708
|
+
return {"item": item}
|
|
709
|
+
|
|
710
|
+
async def wait_for_next_item(self):
|
|
711
|
+
event = await self.wait_for_next("conversation.item.appended")
|
|
712
|
+
return {"item": event["item"]}
|
|
713
|
+
|
|
714
|
+
async def wait_for_next_completed_item(self):
|
|
715
|
+
event = await self.wait_for_next("conversation.item.completed")
|
|
716
|
+
return {"item": event["item"]}
|
|
717
|
+
|
|
718
|
+
async def _send_chainlit_message(self, item):
|
|
719
|
+
import chainlit as cl
|
|
720
|
+
|
|
721
|
+
# Debug logging
|
|
722
|
+
logger.debug(f"Received item structure: {json.dumps({k: type(v).__name__ for k, v in item.items()}, indent=2)}")
|
|
723
|
+
|
|
724
|
+
if "type" in item and item["type"] == "function_call_output":
|
|
725
|
+
# Don't send function call outputs directly to Chainlit
|
|
726
|
+
logger.debug(f"Function call output received: {item.get('output', '')}")
|
|
727
|
+
elif "role" in item:
|
|
728
|
+
if item["role"] == "user":
|
|
729
|
+
content = item.get("formatted", {}).get("text", "") or item.get("formatted", {}).get("transcript", "")
|
|
730
|
+
if content:
|
|
731
|
+
await cl.Message(content=content, author="User").send()
|
|
732
|
+
elif item["role"] == "assistant":
|
|
733
|
+
content = item.get("formatted", {}).get("text", "") or item.get("formatted", {}).get("transcript", "")
|
|
734
|
+
if content:
|
|
735
|
+
await cl.Message(content=content, author="AI").send()
|
|
736
|
+
else:
|
|
737
|
+
logger.warning(f"Unhandled role: {item['role']}")
|
|
738
|
+
else:
|
|
739
|
+
# Handle items without a 'role' or 'type'
|
|
740
|
+
logger.debug(f"Unhandled item type:\n{json.dumps(item, indent=2)}")
|
|
741
|
+
|
|
742
|
+
# Additional debug logging
|
|
743
|
+
logger.debug(f"Processed Chainlit message for item: {item.get('id', 'unknown')}")
|
|
744
|
+
|
|
745
|
+
async def ensure_connected(self):
|
|
746
|
+
"""Check connection health and attempt reconnection if needed"""
|
|
747
|
+
if not self.is_connected():
|
|
748
|
+
try:
|
|
749
|
+
logger.info("Attempting to reconnect to OpenAI Realtime API...")
|
|
750
|
+
model = os.getenv("OPENAI_MODEL_NAME", 'gpt-5-nano-realtime-preview-2024-12-17')
|
|
751
|
+
await self.connect(model)
|
|
752
|
+
return True
|
|
753
|
+
except Exception as e:
|
|
754
|
+
logger.error(f"Failed to reconnect: {e}")
|
|
755
|
+
return False
|
|
756
|
+
return True
|