From fcc0cae05ae4360ca4e84a54b80f1fcbb5ea4fcd Mon Sep 17 00:00:00 2001 From: roshan <38771624+rohoswagger@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:02:08 +0000 Subject: [PATCH] feat(llm): share Zoom setup across Voice and Decisions (#16280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add Zoom Decisions and Zoom Decisions Pro to **Admin > Decision Models**. Configure Zoom from either Voice or Decision Models. Both pages use one shared connection and one encrypted API key and secret. Zoom Decisions is opt-in: a Zoom decision provider exists only after an admin connects Zoom on Decision Models. A Voice-only setup never creates one. Decision setup does not activate Voice defaults or select a decision model. Read current credentials and sign a fresh JWT for each request. Keep Zoom decision models out of chat. Removing Zoom from Decision Models deletes only the decision provider and keeps Scribe. Disconnecting Zoom on Voice also removes its linked decision provider, after selected-model checks. The migration only adds the link column; it writes no rows. Both setup links open the [Zoom AI Services documentation](https://developers.zoom.us/docs/ai-services/). ## Validation - 336 focused backend tests and nine frontend component tests passed. - Type checks, lint, translations, deterministic migration checks, and touched-file hooks passed. - A live Zoom Decision API request returned HTTP 200 with a JWT signed using the existing Scribe credentials. - The current-head preview opens the credential form directly from Decision Models. No Voice setup is required first. - PostgreSQL tests cover opt-in linking from Decision Models only, rotation, rollback, relink and duplicate-link rejection, and deletion. ## Screenshot Shared Zoom setup from Decision Models at `9b7792d`. Credentials are empty. This screenshot demonstrates the form, not a completed connection test. ![Shared Zoom AI Services setup](https://github.com/user-attachments/assets/418e5eca-edd4-48c2-a0d4-a7103751c0cb) ## Stack testing (`29eec61fb4`) Tested the full stack at the top commit on a local dev stack, desktop and 390×844 mobile. OpenAI decisions and chat used a real key. TypeSafe, Zoom, OpenRouter, and Vercel AI Gateway used local mock upstreams; Onyx auth, validation, persistence, encryption, and UI were real. - Fixed during testing: migration `9071f636d5f2` now revises `4d8453213439` (#16351 on main). The old `down_revision` left two alembic heads and failed most CI jobs on this PR and #16284. - Passed: Voice-only Zoom setup creates no decision provider. Decision Models opens the shared credential form directly. - Passed: one encrypted Voice connection; the linked LLM provider stores no duplicate key. JWT validation and the Scribe WebSocket handshake worked against the mock. - Passed: bad credentials fail. Both setup links open the Zoom AI Services docs. - Passed: selection guards block Zoom removal and Voice disconnect. Removing Zoom from Decision Models keeps Voice/Scribe. Zoom decision models are absent from chat. - **Fixed in #16276 (`37c248ec04`):** after Zoom is disconnected on Voice, sidebar navigation to Decision Models now shows **Connect** without a reload (retested at `78cf4ccb12`). Before the fix, the card showed **Configure/Delete** from a stale `adminLlmProvidersPaged` SWR cache. ![Before the fix: stale Zoom card vs. after reload](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS9iMmMyNWEwZS0wNWVhLTQyYmUtODFmMy05YWFkYzQwYmYzNzciLCJpYXQiOjE3OTI2MTIwNjEsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJ6b29tLXN0YWxlLWJlZm9yZS1hZnRlci5wbmcifQ.Y0FiVPDvizzJT-GX494OGq6lsQoIrwMd7WHrwFzBWqQ) ![Zoom on Decision Models, desktop and mobile](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS8wZWJiYmUxOC03Mjk5LTQ1ZTYtOGE2MC1iYjAzNThhZTgyMzgiLCJpYXQiOjE3OTI2MTIwNjEsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJ6b29tLWRlc2t0b3AtbW9iaWxlLnBuZyJ9.-EcWczrz3n2IdSdZR2J8WWS3-hl0q8qxwVvoq3K6hOU) ![Zoom on Voice, desktop and mobile](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS8yMGUwMWVlNi0wYzMwLTQxOTItOWU3My01NDJlMTY0YzczM2QiLCJpYXQiOjE3OTI2MTIwNjEsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJ6b29tLXZvaWNlLWRlc2t0b3AtbW9iaWxlLnBuZyJ9.bJbNbAOJVKDduNobJUs-67TDqIwVssOhfATOxKx8usc) ![Zoom shared flow and stale card (before the fix)](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS84NDk2ZWI1ZS1hYTVlLTQyN2EtYTllNC01YWExZjVhNTI1NWIiLCJpYXQiOjE3OTI2MTIwNjEsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJ6b29tLXNoYXJlZC1mbG93LWFuZC1zdGFsZS1jYXJkLndlYnAifQ.BVuTJXEo4tadBG-oIc9li-9eTNJDFmEHnlXc-zWy_HM) ![After the fix: Voice disconnect then Decision Models](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmdfT2NlNnlnR2RrdGJQekZsaS8wZDg2MzIzYS1jZmQ1LTQyYzYtYjRiNy1mMmRiYWM4NWFiNDEiLCJpYXQiOjE3OTI2MTIwNjEsIm9yZ19pZCI6Im9yZ19PY2U2eWdHZGt0YlB6RmxpIiwiZmlsZW5hbWUiOiJjYWNoZS1uYXZpZ2F0aW9uLndlYnAifQ.aElIlomXJ3AkNjokrig55oTDvAd1FONQuUsCxyqbM-U) ## Stack PR 3 of 4: #16262 → #16276 → **#16280** → #16284. ## Summary by CodeRabbit * **New Features** * Added Zoom AI Decisions with two models supporting text and image inputs. * Added shared Zoom AI Services setup across Voice and Decision Models, including connection selection, credential reuse, rotation, and removal. * Added provider discovery, model capability indicators, decision-specific validation, and localized interface text. * Renamed Zoom Scribe setup labels to Zoom AI Services. * **Bug Fixes** * Improved connection validation and rollback. * Improved credential handling and protection against deleting active shared connections. * Preserved existing shared connection settings when updating providers. Link to Devin session: https://app.devin.ai/sessions/39d77bde91c34b75922bb05c02b93b90 Open in Devin Desktop: https://app.devin.ai/desktop/session/39d77bde91c34b75922bb05c02b93b90?variant=devin Requested by: @rohoswagger --- .agents/feature-map/PATHS.md | 3 +- .../feature-map/components/llm-providers.md | 30 +- .agents/feature-map/components/voice.md | 31 +- ...2_link_zoom_decisions_to_voice_provider.py | 37 ++ backend/onyx/db/llm.py | 18 + backend/onyx/db/models.py | 6 + backend/onyx/db/voice.py | 103 ++++- backend/onyx/llm/constants.py | 3 + backend/onyx/llm/decisions/client.py | 27 +- backend/onyx/llm/decisions/factory.py | 61 +++ backend/onyx/llm/decisions/models.py | 13 + backend/onyx/llm/factory.py | 7 +- backend/onyx/llm/model_catalog.py | 2 +- backend/onyx/llm/price_table/_supplement.json | 16 + .../llm_provider_options.py | 1 + backend/onyx/server/manage/llm/api.py | 143 ++++++- backend/onyx/server/manage/llm/models.py | 4 + backend/onyx/server/manage/voice/api.py | 111 ++++-- backend/onyx/server/manage/voice/models.py | 4 +- .../llm/test_decision_model.py | 1 + .../llm/test_decision_provider_scope.py | 226 ++++++++++- .../llm/test_llm_provider.py | 12 +- .../llm/test_llm_provider_api_base.py | 10 +- .../llm/test_llm_provider_task_defaults.py | 1 + backend/tests/unit/onyx/db/test_voice.py | 2 +- .../unit/onyx/llm/test_decision_catalog.py | 6 + .../onyx/llm/test_decision_connections.py | 356 ++++++++++++++++++ backend/tests/unit/onyx/llm/test_decisions.py | 14 +- backend/tests/unit/onyx/llm/test_factory.py | 4 +- backend/tests/unit/onyx/llm/test_typesafe.py | 15 +- .../server/manage/llm/test_decision_models.py | 5 +- .../server/manage/llm/test_decision_page.py | 25 +- .../manage/voice/test_voice_provider_api.py | 94 ++++- .../onyx/server/test_admin_audit_events.py | 4 + web/src/i18n/messages/ar.json | 11 +- web/src/i18n/messages/de.json | 11 +- web/src/i18n/messages/en.json | 11 +- web/src/i18n/messages/es.json | 11 +- web/src/i18n/messages/fr.json | 11 +- web/src/i18n/messages/ja.json | 11 +- web/src/i18n/messages/ko.json | 11 +- web/src/i18n/messages/pt.json | 11 +- web/src/i18n/messages/zh.json | 11 +- .../lib/hooks/useLLMProviderOptions.test.tsx | 1 + web/src/lib/languageModels/constants.ts | 10 + web/src/lib/languageModels/svc.ts | 2 + web/src/lib/languageModels/types.ts | 4 + web/src/lib/voice/svc.ts | 1 + web/src/lib/voice/types.ts | 1 + web/src/lib/voice/utils.ts | 6 +- .../languageModels/ZoomDecisionModal.tsx | 87 +++++ .../views/admin/DecisionModelsPage.test.tsx | 198 ++++++++-- web/src/views/admin/DecisionModelsPage.tsx | 25 +- web/src/views/admin/VoicePage/shared.test.tsx | 60 +++ web/src/views/admin/VoicePage/shared.tsx | 88 +++-- web/tests/e2e/admin/decision_models.spec.ts | 8 + .../e2e/admin/voice/zoom-provider.spec.ts | 2 +- .../e2e/pages/AdminDecisionModelsPage.ts | 19 + web/tests/e2e/pages/AdminVoicePage.ts | 10 +- 59 files changed, 1848 insertions(+), 168 deletions(-) create mode 100644 backend/alembic/versions/9071f636d5f2_link_zoom_decisions_to_voice_provider.py create mode 100644 backend/onyx/llm/decisions/factory.py create mode 100644 backend/tests/unit/onyx/llm/test_decision_connections.py create mode 100644 web/src/sections/modals/languageModels/ZoomDecisionModal.tsx create mode 100644 web/src/views/admin/VoicePage/shared.test.tsx diff --git a/.agents/feature-map/PATHS.md b/.agents/feature-map/PATHS.md index 6965258a3d..eb5d9abe14 100644 --- a/.agents/feature-map/PATHS.md +++ b/.agents/feature-map/PATHS.md @@ -205,7 +205,8 @@ so verify the callers rather than a single component. | `web/src/views/admin/ChatPreferencesPage.tsx` | chat-preferences | | `web/src/app/admin/language-models/` | llm-providers | | `web/src/views/admin/LanguageModelsPage.tsx` | llm-providers | -| `web/src/app/admin/decision-models/`, `web/src/views/admin/DecisionModelsPage.tsx` | llm-providers | +| `web/src/views/admin/VoicePage/`, `web/src/lib/voice/` | voice | +| `web/src/app/admin/decision-models/`, `web/src/views/admin/DecisionModelsPage.tsx`, `web/src/sections/modals/languageModels/ZoomDecisionModal.tsx` | llm-providers | | `web/src/views/admin/ChatPreferencesPage.tsx`, `web/src/views/admin/ChatTaskModelSettings.tsx` | chat-preferences, llm-providers | | `web/src/views/admin/DecisionModelSetting.tsx`, `backend/onyx/llm/decisions/` | llm-providers | | `web/src/sections/modals/languageModels/DecisionProviderModal.tsx` | llm-providers | diff --git a/.agents/feature-map/components/llm-providers.md b/.agents/feature-map/components/llm-providers.md index 9506b5b144..18068ea5fe 100644 --- a/.agents/feature-map/components/llm-providers.md +++ b/.agents/feature-map/components/llm-providers.md @@ -37,8 +37,10 @@ TypeSafe appears only on this page; shared OpenAI and OpenRouter connections remain available on Language Models. Dedicated decision-provider saves preserve shared chat settings, defaults, visibility, and access. New shared models stay hidden from chat. Clear the selected decision model before removing its model or provider. -TypeSafe System One, OpenAI Decisions, and OpenRouter Decisions use the separate +TypeSafe System One, Zoom AI Services, OpenAI Decisions, and OpenRouter Decisions use the separate client in `backend/onyx/llm/decisions/`. +Zoom decision requests carry a shared Zoom Scribe connection ID. Only full admins +can validate or save this link. Each request signs with the current connection credentials. A user picks a model per chat session, or leaves it on the assistant's default, or leaves that on the workspace default. Whichever call actually reaches the @@ -76,7 +78,7 @@ Admin router, prefix `/admin/llm`: | POST | `/admin/llm/default-vision` | `set_provider_as_default_vision` | Rejects a model that fails `model_supports_image_input`. | | POST/DELETE | `/admin/llm/default-chat-naming` | `set_provider_as_default_chat_naming` / `clear_default_chat_naming` | | | POST/DELETE | `/admin/llm/default-search-selection` | `set_provider_as_default_search_selection` / `clear_default_search_selection` | Independent optional model for selecting and checking retrieved sections. | -| GET/POST/DELETE | `/admin/llm/decision-model` | `get_decision_model_configuration`, `set_decision_model`, `delete_decision_model` | Explicit selection only. GET includes all eligible configured models without pagination. | +| GET/POST/DELETE | `/admin/llm/decision-model` | `get_decision_model_configuration`, `set_decision_model`, `delete_decision_model` | Decision Models page selection. Explicit opt-in only. GET includes all eligible configured models without pagination. | | POST/DELETE | `/admin/llm/default-craft` | `set_provider_as_default_craft` / `clear_default_craft` | Requires a chat-capable model; see §9. | | GET | `/admin/llm/built-in/options`, `/built-in/options/{provider_name}` | `fetch_llm_options`, `fetch_llm_provider_options` | The well-known-provider catalogue. | | GET | `/admin/llm/custom-provider-names` | `fetch_custom_provider_names` | Names for the custom provider option. | @@ -134,10 +136,17 @@ It manages decision providers and an optional model selection. Shared provider modals use dedicated decision-provider requests for saves and tests. These saves preserve chat settings, defaults, model visibility, and provider access. Shared model settings link to Language Models instead of offering edits here. -TypeSafe uses `decisionOnly` metadata to appear only on Decision Models. +TypeSafe and Zoom use `decisionOnly` metadata to appear only on Decision Models. Decision-only provider cards have deletion controls. Clear the selected decision model before removing its provider, including through the provider API. +Zoom uses `LLMProvider.zoom_voice_provider_id` to reference its shared connection. +Its encrypted key and secret stay in the VoiceProvider record. Both pages reuse +the same setup form. Only a Decision Models save creates the decision provider +and its hidden decision models. A Voice save keeps an existing link in sync but +never creates one. Decision setup does not activate Voice or choose a decision +model. Removing Zoom from Decision Models deletes only the decision provider. + `web/src/views/admin/LanguageModelsPage.tsx` owns provider configuration and the default chat model. Model selection for naming and search lives in Chat Preferences. `web/src/views/admin/ChatTaskModelSettings.tsx` renders Chat naming model and @@ -319,8 +328,8 @@ General saves preserve stored mappings. Discovery never selects additional model `decisions/client.py:DecisionClient.decide` accepts text input and typed predicate, choice, or score questions. It requires an operation `LLMFlow` and validates the provider response. It preserves probabilities, confidence, scores, and refusals. -TypeSafe and OpenRouter use the System One request shape. OpenAI uses question -arrays. Requests have a finite timeout and no automatic retries. +TypeSafe and OpenRouter use the System One request shape. OpenAI and Zoom use +question arrays. Requests have a finite timeout and no automatic retries. Image input and product use cases are outside this implementation. ### 4.4 The LiteLLM wrapper: `LitellmLLM` @@ -497,6 +506,13 @@ separate `UserUsageTracingProcessor`, independent of Braintrust/Langfuse. settings, defaults, visibility, and provider access. New shared models stay hidden from chat. Loading a catalog must not select additional decision models on an existing connection. +10. **Zoom Decisions use a linked Zoom Scribe connection.** Do not copy its + credentials into the LLM provider row or accept a custom destination. Each + request reads the linked credentials and signs a fresh token. Only full + admins can test or change a linked connection. Every `zoom_ai` provider must + be linked, and a link cannot move to another connection. Shared connection + saves are atomic and idempotent from either admin page. A failed validation must not + leave credentials or a paired provider behind. --- @@ -629,6 +645,10 @@ See `backend/AGENTS.md` for authoritative commands and required env whenever it is available; a stale `model_configuration_id` falls back to the default LLM rather than attempting a name-based lookup, specifically to avoid picking a different same-named provider. +- **Disconnecting Zoom on Voice also removes its linked decision provider.** + Clear the selected decision model first. The foreign key uses + `ON DELETE RESTRICT` to prevent dangling credential references outside this + shared deletion path. - **The tracing admin API and DB-backed tracing config are unavailable under `MULTI_TENANT`.** Env vars are the only configuration surface on cloud; `_reject_if_multi_tenant` enforces this at the endpoint level. diff --git a/.agents/feature-map/components/voice.md b/.agents/feature-map/components/voice.md index f411892e6c..5cfb9df030 100644 --- a/.agents/feature-map/components/voice.md +++ b/.agents/feature-map/components/voice.md @@ -31,6 +31,13 @@ first. Until then, the microphone button and read-aloud control are hidden (`GET /voice/status`); nothing errors mid-conversation for a capability that was never turned on. +Zoom uses one connection for Scribe and Decisions. An admin can create or edit +it from Voice or Decision Models. Both pages use the same encrypted credentials. +Zoom Decisions is opt-in: only a Decision Models save links the connection to a +decision provider. Creating it from Decision Models does not activate a voice +default or select a decision model. Voice setup keeps its existing activation +behavior. + --- ## 2. Surfaces @@ -40,7 +47,7 @@ was never turned on. | Method | Path | Handler | Notes | |---|---|---|---| | GET | `/admin/voice/providers` | `list_voice_providers` | | -| POST | `/admin/voice/providers` | `upsert_voice_provider_endpoint` | Create/update; validates credentials live via `voice_provider.validate_credentials()` before commit, rolling back on failure. | +| POST | `/admin/voice/providers` | `upsert_voice_provider_endpoint` | Create/update with live validation before commit. Zoom supports voice or decision validation. Failure rolls back the shared connection. | | DELETE | `/admin/voice/providers/{provider_id}` | `delete_voice_provider_endpoint` | | | POST | `/admin/voice/providers/{provider_id}/activate-stt` \| `/deactivate-stt` | | Sets/clears `is_default_stt`. | | POST | `/admin/voice/providers/{provider_id}/activate-tts` \| `/deactivate-tts` | | Sets/clears `is_default_tts`; gated by `_validate_tts_activation_supported`. | @@ -75,6 +82,10 @@ streaming session, so provider-specific error detail never reaches the client. `api_secret` (`SensitiveValue`/`EncryptedString`), `api_base`, `custom_config` (JSONB), `stt_model`, `tts_model`, `default_voice`, and two independent booleans `is_default_stt` / `is_default_tts`. +- Zoom decision providers reference the credential row through + `LLMProvider.zoom_voice_provider_id`. A Decision Models save creates the + linked provider and its hidden decision models without choosing a decision + default. A Voice save only keeps an existing link in sync. - **STT and TTS defaults are independent of each other and independent per row.** One provider row can be the STT default, a different row the TTS default; `fetch_default_stt_provider` and `fetch_default_tts_provider` @@ -207,6 +218,11 @@ sample rather than silently truncating or shifting it. cannot support the platform's TTS streaming contract fails `_validate_tts_activation_supported` before `is_default_tts` can be set, independent of whether it is a valid STT default. +6. **Zoom connection setup is shared and atomic.** VoiceProvider owns the key + and secret. Both admin pages update that row and its linked decision provider + in one transaction. Decision setup preserves Voice defaults and leaves the + decision model unset. Shared disconnect requires clearing a selected decision + model and removes both configurations. --- @@ -217,16 +233,16 @@ sample rather than silently truncating or shifting it. same `send-chat-message` path as typed text; this component does not have its own turn model. - [[llm-providers]]: shares the `EncryptedString`/`SensitiveValue` credential - pattern used for `LLMProvider`, though `VoiceProvider` is its own table, not - reused rows. + pattern used for `LLMProvider`. Zoom decision providers reference a + `VoiceProvider` row so credential edits apply to both capabilities. - [[observability]]: every STT/TTS provider call is wrapped in `traced_llm_call` with `LLMFlow.STT` / `LLMFlow.TTS`. - [[chat-frontend]]: `MicrophoneButton` and `VoiceModeProvider` are chat-page components that call into this component's endpoints. **Depended on by** -- Nothing else in the codebase calls into voice; it is a leaf feature - reachable only from the chat input/output UI. +- [[llm-providers]]: Decision Models reuses Zoom connection setup and the current + stored key and secret to sign decision requests. --- @@ -238,6 +254,7 @@ sample rather than silently truncating or shifting it. | changes `TranscriptResult` or the WebSocket message shape | `websocket_api.py:_forward_transcripts`/`_receive_transcripts` and the frontend's `useVoiceRecorder` parsing | | changes audio resampling (`Pcm16Resampler`) | any provider that streams PCM16 at a non-native sample rate; a phase bug here corrupts audio without raising an error | | changes `is_default_stt`/`is_default_tts` semantics | `db/voice.py:fetch_default_stt_provider`/`fetch_default_tts_provider` and `/voice/status`, since the frontend gates the mic/read-aloud UI on that endpoint | +| changes Zoom connection setup, credentials, or deletion | Decision Models setup, the linked LLM provider and DECISION flows, selected-model protection, and credential rotation | | adds any audio or transcript persistence | this document's §5 privacy invariant becomes false; update it in the same change, and check whether that needs a retention/consent story | | changes voice session admission limits | `redis/redis_pool.py:acquire_voice_session`/`release_voice_session` and `VoiceSessionPolicy.handler_seconds`, which the REST and WebSocket paths both depend on | @@ -276,6 +293,10 @@ No integration test targets this component specifically. text advancing roughly in sync with audio. 5. Deactivate the provider and confirm `/voice/status` reports both capabilities disabled, and the mic/read-aloud controls disappear. +6. Configure Zoom from Decision Models and confirm Voice shows that connection. + Confirm voice defaults did not change and the decision model remains unset. + Connect Zoom from Voice only and confirm no decision provider exists until an + admin connects it on Decision Models, without another credential entry. ### What "working" looks like diff --git a/backend/alembic/versions/9071f636d5f2_link_zoom_decisions_to_voice_provider.py b/backend/alembic/versions/9071f636d5f2_link_zoom_decisions_to_voice_provider.py new file mode 100644 index 0000000000..24a7f62022 --- /dev/null +++ b/backend/alembic/versions/9071f636d5f2_link_zoom_decisions_to_voice_provider.py @@ -0,0 +1,37 @@ +from alembic import op +import sqlalchemy as sa + + +revision = "9071f636d5f2" +down_revision = "e38f8193dd32" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "llm_provider", sa.Column("zoom_voice_provider_id", sa.Integer(), nullable=True) + ) + op.create_index( + "ix_llm_provider_zoom_voice_provider_id", + "llm_provider", + ["zoom_voice_provider_id"], + unique=True, + ) + op.create_foreign_key( + "fk_llm_provider_zoom_voice_provider", + "llm_provider", + "voice_provider", + ["zoom_voice_provider_id"], + ["id"], + ondelete="RESTRICT", + ) + + +def downgrade() -> None: + op.execute("DELETE FROM llm_provider WHERE zoom_voice_provider_id IS NOT NULL") + op.drop_constraint( + "fk_llm_provider_zoom_voice_provider", "llm_provider", type_="foreignkey" + ) + op.drop_index("ix_llm_provider_zoom_voice_provider_id", table_name="llm_provider") + op.drop_column("llm_provider", "zoom_voice_provider_id") diff --git a/backend/onyx/db/llm.py b/backend/onyx/db/llm.py index 8ef5e7ad4e..46a6beaca0 100644 --- a/backend/onyx/db/llm.py +++ b/backend/onyx/db/llm.py @@ -513,6 +513,7 @@ def upsert_decision_provider( provider.api_base = request.api_base provider.api_version = request.api_version provider.custom_config = request.custom_config + provider.zoom_voice_provider_id = request.zoom_voice_provider_id for name, model in existing.items(): has_decision = LLMModelFlowType.DECISION in model.llm_model_flow_types if has_decision and name not in selected_names: @@ -601,6 +602,13 @@ def upsert_llm_provider( ) existing_llm_provider.api_base = api_base existing_llm_provider.api_version = llm_provider_upsert_request.api_version + if ( + "zoom_voice_provider_id" in llm_provider_upsert_request.model_fields_set + or existing_llm_provider.id is None + ): + existing_llm_provider.zoom_voice_provider_id = ( + llm_provider_upsert_request.zoom_voice_provider_id + ) existing_llm_provider.custom_config = custom_config existing_llm_provider.is_public = llm_provider_upsert_request.is_public @@ -1184,6 +1192,16 @@ def fetch_existing_llm_provider( return provider_model +def fetch_llm_provider_by_zoom_voice_provider_id( + db_session: Session, zoom_voice_provider_id: int +) -> LLMProviderModel | None: + return db_session.scalar( + select(LLMProviderModel).where( + LLMProviderModel.zoom_voice_provider_id == zoom_voice_provider_id + ) + ) + + def fetch_existing_llm_provider_by_id( id: int, db_session: Session, include_model_configurations: bool = True ) -> LLMProviderModel | None: diff --git a/backend/onyx/db/models.py b/backend/onyx/db/models.py index 55227d55d1..8bcb391aca 100644 --- a/backend/onyx/db/models.py +++ b/backend/onyx/db/models.py @@ -3747,6 +3747,12 @@ class LLMProvider(Base): ) api_base: Mapped[str | None] = mapped_column(String, nullable=True) api_version: Mapped[str | None] = mapped_column(String, nullable=True) + zoom_voice_provider_id: Mapped[int | None] = mapped_column( + ForeignKey("voice_provider.id", ondelete="RESTRICT"), + nullable=True, + index=True, + unique=True, + ) # custom configs that should be passed to the LLM provider at inference time # (e.g. `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, etc. for bedrock) custom_config: Mapped[dict[str, str] | None] = mapped_column( diff --git a/backend/onyx/db/voice.py b/backend/onyx/db/voice.py index f1a1b53e5a..363116a8d3 100644 --- a/backend/onyx/db/voice.py +++ b/backend/onyx/db/voice.py @@ -4,7 +4,18 @@ from uuid import UUID from sqlalchemy import select, update from sqlalchemy.orm import Session -from onyx.db.models import User, VoiceProvider +from onyx.db.enums import LLMModelFlowType +from onyx.db.llm import ( + fetch_llm_provider_by_zoom_voice_provider_id, + remove_llm_provider, +) +from onyx.db.models import ( + LLMModelFlow, + LLMProvider, + ModelConfiguration, + User, + VoiceProvider, +) from onyx.error_handling.error_codes import OnyxErrorCode from onyx.error_handling.exceptions import OnyxError from onyx.voice.interface import normalize_provider_type @@ -87,17 +98,26 @@ def upsert_voice_provider( default_voice: str | None = None, activate_stt: bool = False, activate_tts: bool = False, + link_decision_provider: bool = False, ) -> VoiceProvider: """Create or update a voice provider.""" provider: VoiceProvider | None = None if provider_id is not None: - provider = fetch_voice_provider_by_id(db_session, provider_id) + provider = fetch_voice_provider_by_id(db_session, provider_id, for_update=True) if provider is None: raise OnyxError( OnyxErrorCode.NOT_FOUND, f"No voice provider with id {provider_id} exists.", ) + if ( + normalize_provider_type(provider.provider_type) == "zoom" + and normalize_provider_type(provider_type) != "zoom" + ): + raise OnyxError( + OnyxErrorCode.VALIDATION_ERROR, + "A shared Zoom connection cannot change provider type.", + ) else: provider = VoiceProvider() db_session.add(provider) @@ -123,6 +143,12 @@ def upsert_voice_provider( db_session.flush() + if normalize_provider_type(provider_type) == "zoom" and ( + link_decision_provider + or fetch_llm_provider_by_zoom_voice_provider_id(db_session, provider.id) + ): + ensure_zoom_decision_provider(db_session, provider) + if activate_stt: set_default_stt_provider(db_session=db_session, provider_id=provider.id) if activate_tts: @@ -132,10 +158,81 @@ def upsert_voice_provider( return provider +def ensure_zoom_decision_provider( + db_session: Session, voice: VoiceProvider +) -> LLMProvider: + provider = fetch_llm_provider_by_zoom_voice_provider_id(db_session, voice.id) + if provider is None: + provider = LLMProvider( + name=voice.name, + provider="zoom_ai", + zoom_voice_provider_id=voice.id, + is_public=True, + is_auto_mode=False, + ) + db_session.add(provider) + db_session.flush() + provider.name = voice.name + for name, label in ( + ("zoom-decisions", "Zoom Decisions"), + ("zoom-decisions-pro", "Zoom Decisions Pro"), + ): + model = db_session.scalar( + select(ModelConfiguration).where( + ModelConfiguration.llm_provider_id == provider.id, + ModelConfiguration.name == name, + ) + ) + if model is None: + model = ModelConfiguration( + llm_provider_id=provider.id, + name=name, + display_name=label, + is_visible=False, + ) + db_session.add(model) + db_session.flush() + flow = db_session.scalar( + select(LLMModelFlow).where( + LLMModelFlow.model_configuration_id == model.id, + LLMModelFlow.llm_model_flow_type == LLMModelFlowType.DECISION, + ) + ) + if flow is None: + db_session.add( + LLMModelFlow( + model_configuration_id=model.id, + llm_model_flow_type=LLMModelFlowType.DECISION, + is_default=False, + ) + ) + db_session.flush() + return provider + + def delete_voice_provider(db_session: Session, provider_id: int) -> None: """Delete a voice provider by ID.""" - provider = fetch_voice_provider_by_id(db_session, provider_id) + provider = fetch_voice_provider_by_id(db_session, provider_id, for_update=True) if provider: + linked = db_session.scalars( + select(LLMProvider).where(LLMProvider.zoom_voice_provider_id == provider_id) + ).all() + for decision in linked: + selected = db_session.scalar( + select(LLMModelFlow.id) + .join(ModelConfiguration) + .where( + ModelConfiguration.llm_provider_id == decision.id, + LLMModelFlow.is_default.is_(True), + ) + .limit(1) + ) + if selected is not None: + raise OnyxError( + OnyxErrorCode.RESOURCE_IN_USE, + "Clear the selected decision model before removing this Zoom connection.", + ) + remove_llm_provider(db_session, decision.id, commit=False) db_session.delete(provider) db_session.flush() diff --git a/backend/onyx/llm/constants.py b/backend/onyx/llm/constants.py index d469de8afc..3876d642c7 100644 --- a/backend/onyx/llm/constants.py +++ b/backend/onyx/llm/constants.py @@ -15,6 +15,7 @@ class LlmProviderNames(str, Enum): OPENAI = "openai" TYPESAFE = "typesafe" + ZOOM_AI = "zoom_ai" ANTHROPIC = "anthropic" GOOGLE = "google" BEDROCK = "bedrock" @@ -48,6 +49,7 @@ LLM_PROVIDER_MODEL_PAGE_SIZE = 50 WELL_KNOWN_PROVIDER_NAMES = [ LlmProviderNames.TYPESAFE, + LlmProviderNames.ZOOM_AI, LlmProviderNames.OPENAI, LlmProviderNames.ANTHROPIC, LlmProviderNames.VERTEX_AI, @@ -68,6 +70,7 @@ WELL_KNOWN_PROVIDER_NAMES = [ # Proper capitalization for known providers and vendors PROVIDER_DISPLAY_NAMES: dict[str, str] = { LlmProviderNames.TYPESAFE: "TypeSafe AI", + LlmProviderNames.ZOOM_AI: "Zoom AI Services", LlmProviderNames.OPENAI: "OpenAI", LlmProviderNames.ANTHROPIC: "Anthropic", LlmProviderNames.GOOGLE: "Google", diff --git a/backend/onyx/llm/decisions/client.py b/backend/onyx/llm/decisions/client.py index 62a12fcaca..2a7d483f60 100644 --- a/backend/onyx/llm/decisions/client.py +++ b/backend/onyx/llm/decisions/client.py @@ -1,4 +1,5 @@ import math +from collections.abc import Callable import httpx from pydantic import JsonValue @@ -11,11 +12,13 @@ from onyx.llm.decisions.models import ( DecisionProvider, DecisionRequest, DecisionResponse, + DecisionUsage, PredicateAnswer, ScoreAnswer, ScoreProbability, ScoreQuestion, SystemOneResponse, + ZoomResponse, ) from onyx.tracing.flows import LLMFlow from onyx.tracing.framework.traces import TraceContentMode @@ -23,6 +26,7 @@ from onyx.tracing.llm_utils import traced_llm_call _ENDPOINTS = { DecisionProvider.TYPESAFE: ("https://api.typesafe.ai/v1", "systemone"), + DecisionProvider.ZOOM_AI: ("https://api.zoom.us/v2", "aiservices/decisions/decide"), DecisionProvider.OPENAI: ("https://api.openai.com/v1", "decisions"), DecisionProvider.OPENROUTER: ("https://openrouter.ai/api/alpha", "decisions"), } @@ -142,10 +146,14 @@ class DecisionClient: api_base: str | None = None, timeout: float = 30.0, extra_headers: dict[str, str] | None = None, + api_key_resolver: Callable[[], str] | None = None, ) -> None: self.provider = provider + self._api_key_resolver = api_key_resolver self.model = model base, path = _ENDPOINTS[provider] + if provider == DecisionProvider.ZOOM_AI and api_base: + raise ValueError("Zoom Decisions uses its fixed hosted endpoint") base = (api_base or base).rstrip("/") if provider == DecisionProvider.OPENROUTER and base.endswith("/v1"): base = base.removesuffix("/v1") + "/alpha" @@ -176,10 +184,27 @@ class DecisionClient: input_messages=body, ) as span: with httpx.Client(timeout=self.timeout) as client: - result = client.post(self.url, json=body, headers=self._headers) + headers = dict(self._headers) + if self._api_key_resolver: + headers["Authorization"] = f"Bearer {self._api_key_resolver()}" + result = client.post(self.url, json=body, headers=headers) result.raise_for_status() if system_one: response = _system_one_response(result.json()) + elif self.provider == DecisionProvider.ZOOM_AI: + zoom = ZoomResponse.model_validate(result.json()) + response = DecisionResponse( + model=zoom.model, + answers=zoom.answers, + usage=( + DecisionUsage( + input_tokens=zoom.usage.input_units, + output_tokens=zoom.usage.output_units, + ) + if zoom.usage and zoom.usage.unit_type == "tokens" + else None + ), + ) else: response = DecisionResponse.model_validate(result.json()) response = _validate_answers(request, response) diff --git a/backend/onyx/llm/decisions/factory.py b/backend/onyx/llm/decisions/factory.py new file mode 100644 index 0000000000..37df69bc79 --- /dev/null +++ b/backend/onyx/llm/decisions/factory.py @@ -0,0 +1,61 @@ +from functools import partial + +from sqlalchemy.orm import Session + +from onyx.db.engine.sql_engine import get_session_with_current_tenant +from onyx.db.voice import fetch_voice_provider_by_id +from onyx.llm.decisions.client import DecisionClient +from onyx.llm.decisions.models import DecisionProvider +from onyx.voice.interface import normalize_provider_type +from onyx.voice.providers.zoom import build_zoom_scribe_jwt + + +def validate_zoom_voice_provider( + db_session: Session, provider_id: int, *, for_update: bool = False +) -> None: + provider = fetch_voice_provider_by_id( + db_session, provider_id, for_update=for_update + ) + if provider is None or normalize_provider_type(provider.provider_type) != "zoom": + raise ValueError("Select an existing Zoom Scribe connection") + if not provider.api_key or not provider.api_secret: + raise ValueError("The Zoom Scribe connection requires an API key and secret") + + +def _zoom_decision_token(provider_id: int) -> str: + with get_session_with_current_tenant() as db_session: + validate_zoom_voice_provider(db_session, provider_id) + provider = fetch_voice_provider_by_id(db_session, provider_id) + if provider is None or provider.api_key is None or provider.api_secret is None: + raise ValueError("The Zoom Scribe credentials are no longer available") + return build_zoom_scribe_jwt( + provider.api_key.get_value(apply_mask=False), + provider.api_secret.get_value(apply_mask=False), + ) + + +def build_decision_client( + *, + provider: str, + model: str, + api_key: str | None, + api_base: str | None = None, + zoom_voice_provider_id: int | None = None, + extra_headers: dict[str, str] | None = None, +) -> DecisionClient: + if zoom_voice_provider_id is not None and provider != DecisionProvider.ZOOM_AI: + raise ValueError("Only Zoom Decisions can link a Zoom Scribe connection") + if provider == DecisionProvider.ZOOM_AI and zoom_voice_provider_id is None: + raise ValueError("Zoom Decisions requires a linked Zoom connection") + return DecisionClient( + provider=DecisionProvider(provider), + model=model, + api_key=api_key or "", + api_base=api_base, + extra_headers=extra_headers, + api_key_resolver=( + partial(_zoom_decision_token, zoom_voice_provider_id) + if zoom_voice_provider_id is not None + else None + ), + ) diff --git a/backend/onyx/llm/decisions/models.py b/backend/onyx/llm/decisions/models.py index 7555f7aee4..e68a160fb4 100644 --- a/backend/onyx/llm/decisions/models.py +++ b/backend/onyx/llm/decisions/models.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator class DecisionProvider(StrEnum): TYPESAFE = "typesafe" + ZOOM_AI = "zoom_ai" OPENAI = "openai" OPENROUTER = "openrouter" @@ -164,3 +165,15 @@ class SystemOneResponse(DecisionModel): ], ] usage: DecisionUsage | None = None + + +class ZoomUsage(DecisionModel): + input_units: int = Field(ge=0) + output_units: int = Field(ge=0) + unit_type: str + + +class ZoomResponse(DecisionModel): + model: str + answers: list[DecisionAnswer] + usage: ZoomUsage | None = None diff --git a/backend/onyx/llm/factory.py b/backend/onyx/llm/factory.py index 72d55ec063..2c8e8bee8a 100644 --- a/backend/onyx/llm/factory.py +++ b/backend/onyx/llm/factory.py @@ -18,7 +18,7 @@ from onyx.db.models import LLMProvider as LLMProviderModel from onyx.db.models import Persona, SearchSettings, User from onyx.llm.constants import LlmProviderNames from onyx.llm.decisions.client import DecisionClient -from onyx.llm.decisions.models import DecisionProvider +from onyx.llm.decisions.factory import build_decision_client from onyx.llm.interfaces import LLM, LlmRequestPolicy from onyx.llm.models import ReasoningEffort, UserChatDefaults from onyx.llm.multi_llm import LitellmLLM @@ -451,11 +451,12 @@ def get_decision_client( tenant_id=get_current_tenant_id(), llm_provider_api_key=api_key, ) - return DecisionClient( - provider=DecisionProvider(provider.provider), + return build_decision_client( + provider=provider.provider, model=model.name, api_key=api_key, api_base=provider.api_base, + zoom_voice_provider_id=provider.zoom_voice_provider_id, extra_headers={ **build_llm_extra_headers(additional_headers), **_build_provider_extra_headers( diff --git a/backend/onyx/llm/model_catalog.py b/backend/onyx/llm/model_catalog.py index 91616a6a47..afd40bc43e 100644 --- a/backend/onyx/llm/model_catalog.py +++ b/backend/onyx/llm/model_catalog.py @@ -179,7 +179,7 @@ def model_supports_chat(provider: str, model_name: str) -> bool: entry = _vendored_model_entry(provider, name) if entry is not None: return _catalog_mode(entry, name) in CHAT_MODES - if provider == "typesafe": + if provider in {"typesafe", "zoom_ai"}: return False return True diff --git a/backend/onyx/llm/price_table/_supplement.json b/backend/onyx/llm/price_table/_supplement.json index 9afdb6f3cb..2f1a7967d1 100644 --- a/backend/onyx/llm/price_table/_supplement.json +++ b/backend/onyx/llm/price_table/_supplement.json @@ -16,6 +16,22 @@ } } }, + "zoom_ai": { + "models": { + "zoom-decisions": { + "name": "Zoom Decisions", + "mode": "decisions", + "supports_decisions": true, + "modalities": {"input": ["text", "image"], "output": ["decisions"]} + }, + "zoom-decisions-pro": { + "name": "Zoom Decisions Pro", + "mode": "decisions", + "supports_decisions": true, + "modalities": {"input": ["text", "image"], "output": ["decisions"]} + } + } + }, "openai": { "models": { "gpt-6-luna": { diff --git a/backend/onyx/llm/well_known_providers/llm_provider_options.py b/backend/onyx/llm/well_known_providers/llm_provider_options.py index ace250c208..58fc631150 100644 --- a/backend/onyx/llm/well_known_providers/llm_provider_options.py +++ b/backend/onyx/llm/well_known_providers/llm_provider_options.py @@ -60,6 +60,7 @@ def _get_provider_to_models_map() -> dict[str, list[str]]: """ return { LlmProviderNames.TYPESAFE: iter_models(LlmProviderNames.TYPESAFE), + LlmProviderNames.ZOOM_AI: iter_models(LlmProviderNames.ZOOM_AI), OPENAI_PROVIDER_NAME: get_openai_model_names(), BEDROCK_PROVIDER_NAME: [], # Dynamic - fetched from AWS API ANTHROPIC_PROVIDER_NAME: get_anthropic_model_names(), diff --git a/backend/onyx/server/manage/llm/api.py b/backend/onyx/server/manage/llm/api.py index db6a0eb07b..d6ecd845d3 100644 --- a/backend/onyx/server/manage/llm/api.py +++ b/backend/onyx/server/manage/llm/api.py @@ -24,6 +24,7 @@ from onyx.db.llm import ( fetch_existing_llm_provider_by_id, fetch_existing_llm_providers, fetch_existing_models, + fetch_llm_provider_by_zoom_voice_provider_id, fetch_llm_providers_with_router_models, fetch_model_configuration_by_id, fetch_model_configurations_page, @@ -54,7 +55,10 @@ from onyx.llm.constants import ( WELL_KNOWN_PROVIDER_NAMES, LlmProviderNames, ) -from onyx.llm.decisions.client import DecisionClient +from onyx.llm.decisions.factory import ( + build_decision_client, + validate_zoom_voice_provider, +) from onyx.llm.decisions.models import ( DecisionProvider, DecisionRequest, @@ -526,10 +530,76 @@ def fetch_llm_provider_options( raise OnyxError(OnyxErrorCode.NOT_FOUND, f"Provider {provider_name} not found") +def _validate_zoom_decision_connection( + *, + provider: str, + zoom_voice_provider_id: int | None, + existing_provider: LLMProviderModel | None, + api_key: str | None, + api_base: str | None, + custom_config: dict[str, str] | None, + user: User, + db_session: Session, +) -> None: + has_existing_link = ( + existing_provider is not None + and existing_provider.zoom_voice_provider_id is not None + ) + if zoom_voice_provider_id is not None or has_existing_link: + if not has_global_permission(user, Permission.FULL_ADMIN_PANEL_ACCESS): + raise OnyxError(OnyxErrorCode.INSUFFICIENT_PERMISSIONS) + if ( + has_existing_link + and existing_provider is not None + and zoom_voice_provider_id != existing_provider.zoom_voice_provider_id + ): + raise OnyxError( + OnyxErrorCode.VALIDATION_ERROR, + "A linked Zoom provider cannot change its Zoom Scribe connection", + ) + if zoom_voice_provider_id is not None and provider != LlmProviderNames.ZOOM_AI: + raise OnyxError( + OnyxErrorCode.VALIDATION_ERROR, + "Only Zoom Decisions can link a Zoom Scribe connection", + ) + if provider == LlmProviderNames.ZOOM_AI: + if zoom_voice_provider_id is None: + raise OnyxError( + OnyxErrorCode.VALIDATION_ERROR, + "Zoom Decisions requires a Zoom connection. Connect Zoom from Decision Models", + ) + if api_base or custom_config: + raise OnyxError( + OnyxErrorCode.VALIDATION_ERROR, + "Zoom Decisions uses its fixed hosted endpoint and does not accept custom configuration", + ) + if api_key: + raise OnyxError( + OnyxErrorCode.VALIDATION_ERROR, + "Linked Zoom connections use the saved Scribe credentials. Remove the API key", + ) + try: + validate_zoom_voice_provider( + db_session, zoom_voice_provider_id, for_update=True + ) + except ValueError as error: + raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(error)) from error + linked = fetch_llm_provider_by_zoom_voice_provider_id( + db_session, zoom_voice_provider_id + ) + if linked is not None and ( + existing_provider is None or linked.id != existing_provider.id + ): + raise OnyxError( + OnyxErrorCode.CONFLICT, + "This Zoom connection is already linked to another provider.", + ) + + @admin_router.post("/test") def test_llm_configuration( test_llm_request: TestLLMRequest, - _: User = Depends(require_permission(Permission.MANAGE_LLMS)), + user: User = Depends(require_permission(Permission.MANAGE_LLMS)), db_session: Session = Depends(get_session), ) -> None: """Test LLM configuration settings""" @@ -573,6 +643,23 @@ def test_llm_configuration( if existing_provider and not test_llm_request.custom_config_changed: test_custom_config = existing_provider.custom_config + zoom_voice_provider_id = test_llm_request.zoom_voice_provider_id + if ( + existing_provider + and "zoom_voice_provider_id" not in test_llm_request.model_fields_set + ): + zoom_voice_provider_id = existing_provider.zoom_voice_provider_id + _validate_zoom_decision_connection( + provider=test_llm_request.provider, + zoom_voice_provider_id=zoom_voice_provider_id, + existing_provider=existing_provider, + api_key=test_api_key, + api_base=test_llm_request.api_base, + custom_config=test_custom_config, + user=user, + db_session=db_session, + ) + test_custom_config = _validate_and_normalize_vertex_auth( provider=test_llm_request.provider, custom_config=test_custom_config, @@ -776,7 +863,7 @@ def update_model_routing( def _resolve_decision_connection( - request: DecisionProviderConnection, db_session: Session + request: DecisionProviderConnection, db_session: Session, user: User ) -> None: if request.provider not in DecisionProvider: raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, "Unsupported decision provider") @@ -797,6 +884,8 @@ def _resolve_decision_connection( request.api_base = existing.api_base if "api_version" not in request.model_fields_set: request.api_version = existing.api_version + if "zoom_voice_provider_id" not in request.model_fields_set: + request.zoom_voice_provider_id = existing.zoom_voice_provider_id request.custom_config = _restore_masked_custom_config_values( existing_custom_config=existing.custom_config, new_custom_config=request.custom_config, @@ -822,7 +911,20 @@ def _resolve_decision_connection( raise OnyxError( OnyxErrorCode.VALIDATION_ERROR, "TypeSafe AI uses its hosted System One API" ) - if not (request.api_key or "").strip(): + _validate_zoom_decision_connection( + provider=request.provider, + zoom_voice_provider_id=request.zoom_voice_provider_id, + existing_provider=existing, + api_key=request.api_key, + api_base=request.api_base, + custom_config=request.custom_config, + user=user, + db_session=db_session, + ) + if ( + request.provider != LlmProviderNames.ZOOM_AI + and not (request.api_key or "").strip() + ): raise OnyxError( OnyxErrorCode.VALIDATION_ERROR, "Decision providers require an API key" ) @@ -856,7 +958,7 @@ def put_decision_provider( user: User = Depends(require_permission(Permission.MANAGE_LLMS)), db_session: Session = Depends(get_session), ) -> LLMProviderView: - _resolve_decision_connection(request, db_session) + _resolve_decision_connection(request, db_session, user) try: result = upsert_decision_provider( request, db_session, _decision_provider_catalog(request) @@ -883,10 +985,10 @@ def put_decision_provider( @admin_router.post("/decision-provider/test") def test_decision_provider( request: TestDecisionProviderRequest, - _: User = Depends(require_permission(Permission.MANAGE_LLMS)), + user: User = Depends(require_permission(Permission.MANAGE_LLMS)), db_session: Session = Depends(get_session), ) -> None: - _resolve_decision_connection(request, db_session) + _resolve_decision_connection(request, db_session, user) catalog = _decision_provider_catalog(request) if not ( request.model in catalog @@ -898,11 +1000,12 @@ def test_decision_provider( "The selected model does not support decisions", ) try: - DecisionClient( - provider=DecisionProvider(request.provider), + build_decision_client( + provider=request.provider, model=request.model, api_key=request.api_key or "", api_base=request.api_base, + zoom_voice_provider_id=request.zoom_voice_provider_id, ).decide( DecisionRequest( input="The sky is blue.", @@ -951,6 +1054,24 @@ def put_llm_provider( f"LLM Provider with name {llm_provider_upsert_request.name} and id={llm_provider_upsert_request.id} does not exist", ) + if ( + existing_provider + and "zoom_voice_provider_id" not in llm_provider_upsert_request.model_fields_set + ): + llm_provider_upsert_request.zoom_voice_provider_id = ( + existing_provider.zoom_voice_provider_id + ) + _validate_zoom_decision_connection( + provider=llm_provider_upsert_request.provider, + zoom_voice_provider_id=llm_provider_upsert_request.zoom_voice_provider_id, + existing_provider=existing_provider, + api_key=llm_provider_upsert_request.api_key, + api_base=llm_provider_upsert_request.api_base, + custom_config=llm_provider_upsert_request.custom_config, + user=user, + db_session=db_session, + ) + # SSRF Protection: Validate api_base and custom_config match stored values if existing_provider: if ( @@ -998,6 +1119,10 @@ def put_llm_provider( if existing_provider and not llm_provider_upsert_request.custom_config_changed: llm_provider_upsert_request.custom_config = existing_provider.custom_config + if llm_provider_upsert_request.zoom_voice_provider_id is not None: + llm_provider_upsert_request.api_key = None + llm_provider_upsert_request.custom_config = None + llm_provider_upsert_request.custom_config = _validate_and_normalize_vertex_auth( provider=llm_provider_upsert_request.provider, custom_config=llm_provider_upsert_request.custom_config, diff --git a/backend/onyx/server/manage/llm/models.py b/backend/onyx/server/manage/llm/models.py index d3eaef22ca..04945d1977 100644 --- a/backend/onyx/server/manage/llm/models.py +++ b/backend/onyx/server/manage/llm/models.py @@ -69,6 +69,7 @@ class TestLLMRequest(BaseModel): provider: str model: str api_key: str | None = None + zoom_voice_provider_id: int | None = None api_base: str | None = None api_version: str | None = None custom_config: dict[str, str] | None = None @@ -155,6 +156,7 @@ class LLMProvider(BaseModel): name: str | None = None provider: str api_key: str | None = None + zoom_voice_provider_id: int | None = None api_base: str | None = None api_version: str | None = None custom_config: dict[str, str] | None = None @@ -171,6 +173,7 @@ class DecisionProviderConnection(BaseModel): name: str | None = None provider: str api_key: str | None = None + zoom_voice_provider_id: int | None = None api_key_changed: bool = False api_base: str | None = None api_version: str | None = None @@ -276,6 +279,7 @@ class LLMProviderView(LLMProvider): name=llm_provider_model.name, provider=provider, api_key=api_key, + zoom_voice_provider_id=llm_provider_model.zoom_voice_provider_id, api_base=llm_provider_model.api_base, api_version=llm_provider_model.api_version, custom_config=llm_provider_model.custom_config, diff --git a/backend/onyx/server/manage/voice/api.py b/backend/onyx/server/manage/voice/api.py index c39624ed17..c1db5ee40b 100644 --- a/backend/onyx/server/manage/voice/api.py +++ b/backend/onyx/server/manage/voice/api.py @@ -2,6 +2,7 @@ from typing import Any from fastapi import APIRouter, Depends, Response from sqlalchemy.orm import Session +from starlette.concurrency import run_in_threadpool from onyx.auth.permissions import require_permission from onyx.db.engine.sql_engine import get_session @@ -21,6 +22,13 @@ from onyx.db.voice import ( ) from onyx.error_handling.error_codes import OnyxErrorCode from onyx.error_handling.exceptions import OnyxError +from onyx.llm.decisions.client import DecisionClient +from onyx.llm.decisions.models import ( + DecisionProvider, + DecisionRequest, + PredicateQuestion, +) +from onyx.server.manage.llm.provider_cache import invalidate_provider_listing_cache from onyx.server.manage.voice.models import ( VoiceOption, VoiceProviderTestRequest, @@ -28,11 +36,13 @@ from onyx.server.manage.voice.models import ( VoiceProviderUpsertRequest, VoiceProviderView, ) +from onyx.tracing.flows import LLMFlow from onyx.utils.encryption import mask_string from onyx.utils.logger import setup_logger from onyx.utils.url import SSRFException, validate_outbound_http_url from onyx.voice.factory import get_voice_provider from onyx.voice.interface import VoiceProviderInterface, normalize_provider_type +from onyx.voice.providers.zoom import build_zoom_scribe_jwt logger = setup_logger() @@ -45,6 +55,38 @@ STORED_API_SECRET_PLACEHOLDER = "********" _CUSTOM_CONFIG_CREDENTIAL_KEYS = {"api_key", "api_secret"} +async def _validate_decision_credentials(provider: VoiceProvider) -> None: + if provider.provider_type != "zoom": + raise OnyxError( + OnyxErrorCode.VALIDATION_ERROR, + "Decision validation is only supported for Zoom.", + ) + if not provider.api_key or not provider.api_secret: + raise OnyxError( + OnyxErrorCode.VALIDATION_ERROR, "Zoom requires an API key and secret." + ) + client = DecisionClient( + provider=DecisionProvider.ZOOM_AI, + model="zoom-decisions", + api_key=build_zoom_scribe_jwt( + provider.api_key.get_value(apply_mask=False), + provider.api_secret.get_value(apply_mask=False), + ), + ) + await run_in_threadpool( + client.decide, + DecisionRequest( + input="The sky is blue.", + questions=[ + PredicateQuestion( + name="check", instructions="Does the input mention the sky?" + ) + ], + ), + flow=LLMFlow.MODEL_VALIDATION, + ) + + def _contains_custom_config_credential_key( value: dict[str, Any] | list[Any], ) -> bool: @@ -213,6 +255,13 @@ async def upsert_voice_provider_endpoint( api_key_changed = request.api_key_changed api_secret = request.api_secret api_secret_changed = request.api_secret_changed + if request.validation_mode == "decision" and ( + request.provider_type != "zoom" or request.activate_stt or request.activate_tts + ): + raise OnyxError( + OnyxErrorCode.VALIDATION_ERROR, + "Decision setup supports Zoom without activating voice defaults.", + ) # If llm_provider_id is specified, copy the API key from that LLM provider if request.llm_provider_id is not None: @@ -236,31 +285,33 @@ async def upsert_voice_provider_endpoint( ) custom_config = _reject_custom_config_credentials(request.custom_config) - provider = upsert_voice_provider( - db_session=db_session, - provider_id=request.id, - name=request.name, - provider_type=request.provider_type, - api_key=api_key, - api_key_changed=api_key_changed, - api_secret=api_secret, - api_secret_changed=api_secret_changed, - api_base=api_base, - custom_config=custom_config, - stt_model=request.stt_model, - tts_model=request.tts_model, - default_voice=request.default_voice, - activate_stt=request.activate_stt, - activate_tts=request.activate_tts, - ) - - # Validate credentials before committing - rollback on failure try: + provider = upsert_voice_provider( + db_session=db_session, + provider_id=request.id, + name=request.name, + provider_type=request.provider_type, + api_key=api_key, + api_key_changed=api_key_changed, + api_secret=api_secret, + api_secret_changed=api_secret_changed, + api_base=api_base, + custom_config=custom_config, + stt_model=request.stt_model, + tts_model=request.tts_model, + default_voice=request.default_voice, + activate_stt=request.activate_stt, + activate_tts=request.activate_tts, + link_decision_provider=request.validation_mode == "decision", + ) voice_provider = get_voice_provider(provider) _validate_target_uri_supported(voice_provider, api_base) if request.activate_tts or provider.is_default_tts: _validate_tts_activation_supported(voice_provider) - await voice_provider.validate_credentials() + if request.validation_mode == "decision": + await _validate_decision_credentials(provider) + else: + await voice_provider.validate_credentials() except OnyxError: db_session.rollback() raise @@ -270,7 +321,7 @@ async def upsert_voice_provider_endpoint( raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e)) from e except Exception as e: db_session.rollback() - logger.error("Voice provider credential validation failed on save: %s", e) + logger.warning("Voice provider credential validation failed on save") raise OnyxError( OnyxErrorCode.VALIDATION_ERROR, VOICE_PROVIDER_VALIDATION_FAILURE_MESSAGE, @@ -278,6 +329,8 @@ async def upsert_voice_provider_endpoint( db_session.commit() + if request.provider_type == "zoom": + invalidate_provider_listing_cache() return _provider_to_view(provider) @@ -292,6 +345,7 @@ def delete_voice_provider_endpoint( """Delete a voice provider.""" delete_voice_provider(db_session, provider_id) db_session.commit() + invalidate_provider_listing_cache() return Response(status_code=204) @@ -377,8 +431,12 @@ async def test_voice_provider( db_session, request.id, request.provider_type ) elif request.use_stored_key: - existing_provider = fetch_voice_provider_by_type( - db_session, request.provider_type + existing_provider = ( + _fetch_provider_for_stored_secret( + db_session, request.id, request.provider_type + ) + if request.id is not None + else fetch_voice_provider_by_type(db_session, request.provider_type) ) if request.use_stored_key: @@ -426,11 +484,14 @@ async def test_voice_provider( # Validate credentials with a real API call try: - await provider.validate_credentials() + if request.validation_mode == "decision": + await _validate_decision_credentials(temp_provider) + else: + await provider.validate_credentials() except OnyxError: raise except Exception as e: - logger.error("Voice provider connection test failed: %s", e) + logger.warning("Voice provider connection test failed") raise OnyxError( OnyxErrorCode.VALIDATION_ERROR, VOICE_PROVIDER_VALIDATION_FAILURE_MESSAGE, diff --git a/backend/onyx/server/manage/voice/models.py b/backend/onyx/server/manage/voice/models.py index 5ed3fb9f34..68c7581254 100644 --- a/backend/onyx/server/manage/voice/models.py +++ b/backend/onyx/server/manage/voice/models.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, Field, field_validator @@ -52,6 +52,7 @@ class VoiceProviderUpsertRequest(BaseModel): id: int | None = Field(default=None, description="Existing provider ID to update.") name: str + validation_mode: Literal["voice", "decision"] = "voice" provider_type: str # "openai", "azure", "elevenlabs", "zoom" api_key: str | None = Field( default=None, @@ -109,6 +110,7 @@ class VoiceProviderTestRequest(BaseModel): description="Existing provider ID to use when testing stored credentials.", ) provider_type: str + validation_mode: Literal["voice", "decision"] = "voice" api_key: str | None = Field( default=None, description="API key for testing. If not provided, use_stored_key must be true.", diff --git a/backend/tests/external_dependency_unit/llm/test_decision_model.py b/backend/tests/external_dependency_unit/llm/test_decision_model.py index c0921b04cc..6fc6f53357 100644 --- a/backend/tests/external_dependency_unit/llm/test_decision_model.py +++ b/backend/tests/external_dependency_unit/llm/test_decision_model.py @@ -45,6 +45,7 @@ def db_session() -> Generator[Session, None, None]: tables=[ Base.metadata.tables[name] for name in ( + "voice_provider", "llm_provider", "model_configuration", "llm_model_flow", diff --git a/backend/tests/external_dependency_unit/llm/test_decision_provider_scope.py b/backend/tests/external_dependency_unit/llm/test_decision_provider_scope.py index bd5c441a9d..78e5548821 100644 --- a/backend/tests/external_dependency_unit/llm/test_decision_provider_scope.py +++ b/backend/tests/external_dependency_unit/llm/test_decision_provider_scope.py @@ -1,5 +1,6 @@ from collections.abc import Generator -from unittest.mock import patch +from typing import Literal +from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest @@ -14,12 +15,18 @@ from onyx.db.llm import ( upsert_decision_provider, upsert_llm_provider, ) -from onyx.db.models import LLMProvider, ModelConfiguration +from onyx.db.models import LLMProvider, ModelConfiguration, VoiceProvider +from onyx.db.voice import delete_voice_provider, upsert_voice_provider +from onyx.error_handling.error_codes import OnyxErrorCode +from onyx.error_handling.exceptions import OnyxError +from onyx.server.manage.llm.api import _validate_zoom_decision_connection from onyx.server.manage.llm.models import ( DecisionProviderUpsertRequest, LLMProviderUpsertRequest, ModelConfigurationUpsertRequest, ) +from onyx.server.manage.voice.api import upsert_voice_provider_endpoint +from onyx.server.manage.voice.models import VoiceProviderUpsertRequest @pytest.fixture @@ -139,6 +146,221 @@ def test_provider_setup_does_not_select_decision_model_or_enable_chat( } +def test_selected_shared_zoom_connection_cannot_be_deleted( + transaction: Session, +) -> None: + voice = upsert_voice_provider( + db_session=transaction, + provider_id=None, + name=f"zoom-link-{uuid4().hex}", + provider_type="zoom", + api_key="key", + api_key_changed=True, + api_secret="secret", + api_secret_changed=True, + link_decision_provider=True, + ) + provider = transaction.scalar( + select(LLMProvider).where(LLMProvider.zoom_voice_provider_id == voice.id) + ) + assert provider is not None + saved = transaction.get(LLMProvider, provider.id) + assert saved is not None + assert saved.zoom_voice_provider_id == voice.id + assert saved.api_key is None + assert saved.custom_config is None + update_default_model( + transaction, provider.id, "zoom-decisions", LLMModelFlowType.DECISION + ) + with pytest.raises(OnyxError) as error: + delete_voice_provider(transaction, voice.id) + assert error.value.error_code == OnyxErrorCode.RESOURCE_IN_USE + assert "selected decision model" in error.value.detail + assert transaction.get(VoiceProvider, voice.id) is not None + + +def test_shared_zoom_setup_rotation_and_deletion(transaction: Session) -> None: + clear_decision_model(transaction) + name = f"zoom-shared-{uuid4().hex}" + voice = upsert_voice_provider( + db_session=transaction, + provider_id=None, + name=name, + provider_type="zoom", + api_key="first-key", + api_key_changed=True, + api_secret="first-secret", + api_secret_changed=True, + link_decision_provider=True, + ) + voice_id = voice.id + linked = transaction.scalar( + select(LLMProvider).where(LLMProvider.zoom_voice_provider_id == voice.id) + ) + assert linked is not None + linked_id = linked.id + models = transaction.scalars( + select(ModelConfiguration).where( + ModelConfiguration.llm_provider_id == linked.id + ) + ).all() + assert {model.name for model in models} == {"zoom-decisions", "zoom-decisions-pro"} + assert all( + not model.is_visible + and model.llm_model_flow_types == [LLMModelFlowType.DECISION] + for model in models + ) + assert fetch_default_model(transaction, LLMModelFlowType.DECISION) is None + assert not voice.is_default_stt and not voice.is_default_tts + upsert_voice_provider( + db_session=transaction, + provider_id=voice_id, + name=name, + provider_type="zoom", + api_key="rotated-key", + api_key_changed=True, + api_secret="rotated-secret", + api_secret_changed=True, + ) + transaction.expire_all() + assert ( + len( + transaction.scalars( + select(LLMProvider).where( + LLMProvider.zoom_voice_provider_id == voice_id + ) + ).all() + ) + == 1 + ) + saved = transaction.get(VoiceProvider, voice_id) + assert ( + saved is not None and saved.api_key is not None and saved.api_secret is not None + ) + assert saved.api_key.get_value(apply_mask=False) == "rotated-key" + assert saved.api_secret.get_value(apply_mask=False) == "rotated-secret" + delete_voice_provider(transaction, voice_id) + transaction.expire_all() + assert transaction.get(VoiceProvider, voice_id) is None + assert transaction.get(LLMProvider, linked_id) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["voice", "decision"]) +async def test_only_decision_setup_links_zoom_decisions( + transaction: Session, mode: Literal["voice", "decision"] +) -> None: + clear_decision_model(transaction) + with ( + patch( + "onyx.voice.providers.zoom.ZoomVoiceProvider.validate_credentials", + new_callable=AsyncMock, + ) as scribe, + patch( + "onyx.server.manage.voice.api._validate_decision_credentials", + new_callable=AsyncMock, + ) as decisions, + patch("onyx.server.manage.voice.api.invalidate_provider_listing_cache"), + ): + saved = await upsert_voice_provider_endpoint( + VoiceProviderUpsertRequest( + name=f"zoom-{mode}-{uuid4().hex}", + provider_type="zoom", + validation_mode=mode, + api_key="key", + api_secret="secret", + ), + MagicMock(), + transaction, + ) + assert scribe.await_count == int(mode == "voice") + assert decisions.await_count == int(mode == "decision") + assert not saved.is_default_stt and not saved.is_default_tts + linked = transaction.scalar( + select(LLMProvider.id).where(LLMProvider.zoom_voice_provider_id == saved.id) + ) + assert (linked is not None) is (mode == "decision") + assert fetch_default_model(transaction, LLMModelFlowType.DECISION) is None + + +@pytest.mark.asyncio +async def test_failed_decision_validation_rolls_back_both_records( + transaction: Session, +) -> None: + name = f"zoom-failure-{uuid4().hex}" + with patch( + "onyx.server.manage.voice.api._validate_decision_credentials", + new_callable=AsyncMock, + side_effect=RuntimeError("failure"), + ): + with pytest.raises(OnyxError, match="Connection test failed"): + await upsert_voice_provider_endpoint( + VoiceProviderUpsertRequest( + name=name, + provider_type="zoom", + validation_mode="decision", + api_key="key", + api_secret="secret", + ), + MagicMock(), + transaction, + ) + assert ( + transaction.scalar(select(VoiceProvider.id).where(VoiceProvider.name == name)) + is None + ) + assert ( + transaction.scalar(select(LLMProvider.id).where(LLMProvider.name == name)) + is None + ) + + +def test_zoom_link_validation_rejects_relink_and_duplicate_link( + transaction: Session, +) -> None: + voices = [ + upsert_voice_provider( + db_session=transaction, + provider_id=None, + name=f"zoom-relink-{uuid4().hex}", + provider_type="zoom", + api_key="key", + api_key_changed=True, + api_secret="secret", + api_secret_changed=True, + link_decision_provider=True, + ) + for _ in range(2) + ] + linked = transaction.scalar( + select(LLMProvider).where(LLMProvider.zoom_voice_provider_id == voices[0].id) + ) + assert linked is not None + + def validate(voice_id: int | None, existing: LLMProvider | None) -> None: + _validate_zoom_decision_connection( + provider="zoom_ai", + zoom_voice_provider_id=voice_id, + existing_provider=existing, + api_key=None, + api_base=None, + custom_config=None, + user=MagicMock(), + db_session=transaction, + ) + + with patch("onyx.server.manage.llm.api.has_global_permission", return_value=True): + validate(voices[0].id, linked) + for voice_id in (None, voices[1].id): + with pytest.raises(OnyxError, match="cannot change"): + validate(voice_id, linked) + with pytest.raises(OnyxError, match="requires a Zoom connection"): + validate(None, None) + with pytest.raises(OnyxError) as error: + validate(voices[0].id, None) + assert error.value.error_code == OnyxErrorCode.CONFLICT + + def test_scope_rejects_unverified_decision_flag(transaction: Session) -> None: with pytest.raises(ValueError, match="does not support decisions"): upsert_decision_provider( diff --git a/backend/tests/external_dependency_unit/llm/test_llm_provider.py b/backend/tests/external_dependency_unit/llm/test_llm_provider.py index 226cc4c166..1ccf940cd4 100644 --- a/backend/tests/external_dependency_unit/llm/test_llm_provider.py +++ b/backend/tests/external_dependency_unit/llm/test_llm_provider.py @@ -110,7 +110,7 @@ class TestLLMConfigurationEndpoint: custom_config_changed=False, model="gpt-4o-mini", ), - _=_create_mock_admin(), + user=_create_mock_admin(), db_session=db_session, ) @@ -154,7 +154,7 @@ class TestLLMConfigurationEndpoint: custom_config_changed=False, model="gpt-4o-mini", ), - _=_create_mock_admin(), + user=_create_mock_admin(), db_session=db_session, ) @@ -200,7 +200,7 @@ class TestLLMConfigurationEndpoint: custom_config_changed=False, model="gpt-4o-mini", ), - _=_create_mock_admin(), + user=_create_mock_admin(), db_session=db_session, ) @@ -249,7 +249,7 @@ class TestLLMConfigurationEndpoint: custom_config_changed=False, model="gpt-4o-mini", ), - _=_create_mock_admin(), + user=_create_mock_admin(), db_session=db_session, ) @@ -311,7 +311,7 @@ class TestLLMConfigurationEndpoint: custom_config_changed=False, # Using existing config model="gpt-4o-mini", ), - _=_create_mock_admin(), + user=_create_mock_admin(), db_session=db_session, ) @@ -351,7 +351,7 @@ class TestLLMConfigurationEndpoint: custom_config_changed=False, model=model_name, ), - _=_create_mock_admin(), + user=_create_mock_admin(), db_session=db_session, ) diff --git a/backend/tests/external_dependency_unit/llm/test_llm_provider_api_base.py b/backend/tests/external_dependency_unit/llm/test_llm_provider_api_base.py index d334d349ca..7680f67ca4 100644 --- a/backend/tests/external_dependency_unit/llm/test_llm_provider_api_base.py +++ b/backend/tests/external_dependency_unit/llm/test_llm_provider_api_base.py @@ -539,7 +539,7 @@ def test_upload_with_custom_config_then_change( custom_config_changed=True, custom_config=custom_config, ), - _=_create_mock_admin(), + user=_create_mock_admin(), db_session=db_session, ) @@ -571,7 +571,7 @@ def test_upload_with_custom_config_then_change( api_key_changed=False, custom_config_changed=False, ), - _=_create_mock_admin(), + user=_create_mock_admin(), db_session=db_session, ) @@ -741,7 +741,7 @@ def test_preserves_masked_sensitive_custom_config_on_test_request( "vertex_location": "us-central1", }, ), - _=_create_mock_admin(), + user=_create_mock_admin(), db_session=db_session, ) @@ -815,7 +815,7 @@ def test_vertex_workload_identity_provider_create( api_key_changed=False, custom_config_changed=False, ), - _=_create_mock_admin(), + user=_create_mock_admin(), db_session=db_session, ) @@ -915,7 +915,7 @@ def test_vertex_service_account_backwards_compat_routes_credentials( api_key_changed=False, custom_config_changed=False, ), - _=_create_mock_admin(), + user=_create_mock_admin(), db_session=db_session, ) diff --git a/backend/tests/external_dependency_unit/llm/test_llm_provider_task_defaults.py b/backend/tests/external_dependency_unit/llm/test_llm_provider_task_defaults.py index 790a0d970d..afebec97c7 100644 --- a/backend/tests/external_dependency_unit/llm/test_llm_provider_task_defaults.py +++ b/backend/tests/external_dependency_unit/llm/test_llm_provider_task_defaults.py @@ -63,6 +63,7 @@ def test_schema_engine() -> Generator[Engine, None, None]: tables=[ Base.metadata.tables[table_name] for table_name in ( + "voice_provider", "llm_provider", "model_configuration", "llm_model_flow", diff --git a/backend/tests/unit/onyx/db/test_voice.py b/backend/tests/unit/onyx/db/test_voice.py index 659d1dde4a..6a6f32c459 100644 --- a/backend/tests/unit/onyx/db/test_voice.py +++ b/backend/tests/unit/onyx/db/test_voice.py @@ -416,7 +416,7 @@ class TestDeleteVoiceProvider: def test_hard_deletes_provider_when_found(self, mock_db_session: MagicMock) -> None: provider = _make_voice_provider(id=1) - mock_db_session.scalar.return_value = provider + mock_db_session.scalar.side_effect = [provider, None] delete_voice_provider(mock_db_session, 1) diff --git a/backend/tests/unit/onyx/llm/test_decision_catalog.py b/backend/tests/unit/onyx/llm/test_decision_catalog.py index c573ca4e25..12a1c3228b 100644 --- a/backend/tests/unit/onyx/llm/test_decision_catalog.py +++ b/backend/tests/unit/onyx/llm/test_decision_catalog.py @@ -11,6 +11,8 @@ from onyx.server.manage.llm.models import ModelConfigurationView, OpenRouterMode "provider,model,chat,decisions", [ ("typesafe", "jev-latest", False, True), + ("zoom_ai", "zoom-decisions", False, True), + ("zoom_ai", "zoom-decisions-pro", False, True), ("openai", "gpt-6-luna", True, True), ("openai", "openai/gpt-6-luna", True, True), ("openai", "gpt-5-mini", True, False), @@ -31,6 +33,10 @@ def test_catalog_capabilities_are_independent_and_provider_scoped( def test_direct_decision_models_are_discoverable() -> None: assert fetch_models_for_provider("typesafe") == ["jev-latest", "jev-preview"] + assert fetch_models_for_provider("zoom_ai") == [ + "zoom-decisions", + "zoom-decisions-pro", + ] @pytest.mark.parametrize( diff --git a/backend/tests/unit/onyx/llm/test_decision_connections.py b/backend/tests/unit/onyx/llm/test_decision_connections.py new file mode 100644 index 0000000000..e735210587 --- /dev/null +++ b/backend/tests/unit/onyx/llm/test_decision_connections.py @@ -0,0 +1,356 @@ +from unittest.mock import MagicMock, patch + +import httpx +import jwt +import pytest + +from onyx.db.models import LLMProvider, VoiceProvider +from onyx.error_handling.error_codes import OnyxErrorCode +from onyx.error_handling.exceptions import OnyxError +from onyx.llm.decisions import factory +from onyx.llm.decisions.client import DecisionClient +from onyx.llm.decisions.models import ( + DecisionProvider, + DecisionRequest, + PredicateQuestion, +) +from onyx.llm.well_known_providers import llm_provider_options +from onyx.server.manage.llm import api +from onyx.server.manage.llm.models import ( + DecisionProviderUpsertRequest, + LLMProviderUpsertRequest, +) +from onyx.server.manage.llm.models import ( + TestDecisionProviderRequest as DecisionTestRequest, +) +from onyx.server.manage.llm.models import TestLLMRequest as LLMTestRequest +from onyx.tracing.flows import LLMFlow +from onyx.utils.sensitive import SensitiveValue + + +def _secret(value: str) -> SensitiveValue[str]: + return SensitiveValue( + encrypted_bytes=value.encode(), decrypt_fn=lambda raw: raw.decode() + ) + + +def _voice(key: str = "scribe-key", secret: str = "scribe-secret" * 4) -> VoiceProvider: + return VoiceProvider( + id=4, + name="Zoom", + provider_type="zoom", + api_key=_secret(key), + api_secret=_secret(secret), + ) + + +def test_zoom_setup_options_include_both_decision_models() -> None: + with patch.object( + llm_provider_options, + "get_recommendations", + return_value=llm_provider_options._load_bundled_recommendations(), + ): + options = api.fetch_llm_provider_options("zoom_ai", MagicMock()) + assert options.name == "zoom_ai" + assert [model.name for model in options.known_models] == [ + "zoom-decisions", + "zoom-decisions-pro", + ] + assert all(model.supports_decisions for model in options.known_models) + assert all(not model.supports_chat for model in options.known_models) + + +def test_linked_zoom_reads_rotated_credentials_for_each_request() -> None: + voice = _voice() + tokens: list[str] = [] + + def handle(request: httpx.Request) -> httpx.Response: + assert str(request.url) == "https://api.zoom.us/v2/aiservices/decisions/decide" + tokens.append(request.headers["Authorization"].removeprefix("Bearer ")) + return httpx.Response( + 200, + json={ + "model": "zoom-decisions", + "answers": [{"type": "predicate", "name": "check", "probability": 0.9}], + }, + ) + + original_client = httpx.Client + with ( + patch.object(factory, "get_session_with_current_tenant"), + patch.object( + factory, "fetch_voice_provider_by_id", side_effect=lambda *_, **__: voice + ), + patch( + "onyx.llm.decisions.client.httpx.Client", + side_effect=lambda **kwargs: original_client( + transport=httpx.MockTransport(handle), **kwargs + ), + ), + ): + client = factory.build_decision_client( + provider="zoom_ai", + model="zoom-decisions", + api_key=None, + zoom_voice_provider_id=4, + ) + request = DecisionRequest( + input="The sky is blue", + questions=[ + PredicateQuestion(name="check", instructions="Is the sky blue?") + ], + ) + client.decide(request, flow=LLMFlow.MODEL_VALIDATION) + voice = _voice("rotated-key", "rotated-secret" * 4) + client.decide(request, flow=LLMFlow.MODEL_VALIDATION) + + assert ( + jwt.decode(tokens[0], "scribe-secret" * 4, algorithms=["HS256"])["iss"] + == "scribe-key" + ) + assert ( + jwt.decode(tokens[1], "rotated-secret" * 4, algorithms=["HS256"])["iss"] + == "rotated-key" + ) + assert tokens[0] != tokens[1] + + +@pytest.mark.parametrize( + "voice", + [ + None, + VoiceProvider(provider_type="openai"), + VoiceProvider(provider_type="zoom"), + VoiceProvider(provider_type="zoom", api_key=_secret("key")), + ], +) +def test_link_requires_zoom_key_and_secret(voice: VoiceProvider | None) -> None: + with patch.object(factory, "fetch_voice_provider_by_id", return_value=voice): + with pytest.raises(ValueError): + factory.validate_zoom_voice_provider(MagicMock(), 4) + + +@pytest.mark.parametrize( + "provider,source,api_base,custom_config,api_key", + [ + ("openai", 4, None, None, None), + ("zoom_ai", 4, "https://example.com", None, None), + ("zoom_ai", 4, None, {"api_secret": "secret"}, None), + ("zoom_ai", 4, None, None, "copied-key"), + ], +) +@pytest.mark.parametrize( + "operation", ["test", "update", "decision_test", "decision_update"] +) +def test_generic_routes_reject_invalid_zoom_link( + provider: str, + source: int, + api_base: str | None, + custom_config: dict[str, str] | None, + api_key: str | None, + operation: str, +) -> None: + with ( + patch.object(api, "has_global_permission", return_value=True), + patch.object(api, "build_decision_client") as build, + ): + with pytest.raises(OnyxError): + if operation == "test": + api.test_llm_configuration( + LLMTestRequest( + provider=provider, + model="zoom-decisions", + zoom_voice_provider_id=source, + api_key=api_key, + api_base=api_base, + custom_config=custom_config, + api_key_changed=True, + custom_config_changed=True, + ), + MagicMock(), + MagicMock(), + ) + elif operation == "decision_test": + api.test_decision_provider( + DecisionTestRequest( + provider=provider, + model="zoom-decisions", + zoom_voice_provider_id=source, + api_key=api_key, + api_base=api_base, + custom_config=custom_config, + api_key_changed=True, + custom_config_changed=True, + ), + MagicMock(), + MagicMock(), + ) + elif operation == "decision_update": + api.put_decision_provider( + DecisionProviderUpsertRequest( + provider=provider, + selected_model_names=["zoom-decisions"], + zoom_voice_provider_id=source, + api_key=api_key, + api_base=api_base, + custom_config=custom_config, + ), + MagicMock(), + MagicMock(), + ) + else: + api.put_llm_provider( + LLMProviderUpsertRequest( + provider=provider, + zoom_voice_provider_id=source, + api_key=api_key, + api_base=api_base, + custom_config=custom_config, + ), + True, + MagicMock(), + MagicMock(), + ) + build.assert_not_called() + + +@pytest.mark.parametrize( + "operation", ["test", "update", "decision_test", "decision_update"] +) +def test_llm_manager_cannot_test_or_update_linked_voice_credentials( + operation: str, +) -> None: + saved = LLMProvider(id=7, provider="zoom_ai", zoom_voice_provider_id=4) + with ( + patch.object(api, "fetch_existing_llm_provider_by_id", return_value=saved), + patch.object(api, "has_global_permission", return_value=False), + ): + with pytest.raises(OnyxError) as error: + if operation == "test": + api.test_llm_configuration( + LLMTestRequest( + id=7, + provider="zoom_ai", + model="zoom-decisions", + api_key_changed=True, + custom_config_changed=True, + ), + MagicMock(), + MagicMock(), + ) + elif operation == "decision_test": + api.test_decision_provider( + DecisionTestRequest( + id=7, provider="zoom_ai", model="zoom-decisions" + ), + MagicMock(), + MagicMock(), + ) + elif operation == "decision_update": + api.put_decision_provider( + DecisionProviderUpsertRequest( + id=7, provider="zoom_ai", selected_model_names=[] + ), + MagicMock(), + MagicMock(), + ) + else: + api.put_llm_provider( + LLMProviderUpsertRequest( + id=7, provider="zoom_ai", api_key_changed=True + ), + False, + MagicMock(), + MagicMock(), + ) + assert error.value.error_code == OnyxErrorCode.INSUFFICIENT_PERMISSIONS + + +def test_zoom_client_rejects_custom_destination() -> None: + with pytest.raises(ValueError, match="fixed hosted endpoint"): + DecisionClient( + DecisionProvider.ZOOM_AI, + "zoom-decisions", + "secret", + api_base="https://example.com", + ) + + +def test_other_provider_cannot_resolve_voice_credentials() -> None: + with pytest.raises(ValueError, match="Only Zoom"): + factory.build_decision_client( + provider="openai", + model="gpt-6-luna", + api_key=None, + zoom_voice_provider_id=4, + ) + + +def test_decision_scope_tests_dual_capability_openai_model_with_decision_protocol() -> ( + None +): + with ( + patch.object(api, "build_decision_client") as build, + patch.object(api, "get_llm") as chat, + ): + api.test_decision_provider( + DecisionTestRequest( + provider="openai", + model="gpt-6-luna", + api_key="test-key", + api_key_changed=True, + custom_config_changed=True, + ), + MagicMock(), + MagicMock(), + ) + build.assert_called_once_with( + provider="openai", + model="gpt-6-luna", + api_key="test-key", + api_base=None, + zoom_voice_provider_id=None, + ) + build.return_value.decide.assert_called_once() + chat.assert_not_called() + + +def test_decision_scope_test_rejects_chat_model_even_with_client_decision_flag() -> ( + None +): + with ( + patch.object(api, "build_decision_client") as build, + patch.object(api, "get_llm") as chat, + ): + with pytest.raises(OnyxError, match="does not support decisions"): + api.test_decision_provider( + DecisionTestRequest( + provider="openai", + model="gpt-4o", + api_key="test-key", + api_key_changed=True, + custom_config_changed=True, + ), + MagicMock(), + MagicMock(), + ) + build.assert_not_called() + chat.assert_not_called() + + +def test_decision_page_catalog_contains_supported_providers_without_selecting_a_model() -> ( + None +): + with ( + patch.object(api, "fetch_existing_models", return_value=[]), + patch.object(api, "fetch_default_model", return_value=None), + ): + response = api.get_decision_model_configuration(MagicMock(), MagicMock()) + assert response.model is None + assert response.providers == [] + assert set(response.supported_providers) == { + "typesafe", + "zoom_ai", + "openai", + "openrouter", + } diff --git a/backend/tests/unit/onyx/llm/test_decisions.py b/backend/tests/unit/onyx/llm/test_decisions.py index 57cf07ece2..24276b8cd0 100644 --- a/backend/tests/unit/onyx/llm/test_decisions.py +++ b/backend/tests/unit/onyx/llm/test_decisions.py @@ -138,6 +138,10 @@ def test_system_one_protocol( "provider,url", [ (DecisionProvider.OPENAI, "https://api.openai.com/v1/decisions"), + ( + DecisionProvider.ZOOM_AI, + "https://api.zoom.us/v2/aiservices/decisions/decide", + ), ], ) def test_array_protocol_preserves_refusals( @@ -180,7 +184,11 @@ def test_array_protocol_preserves_refusals( {"type": "refusal", "name": "team"}, {"type": "refusal", "name": "severity"}, ], - "usage": {"input_tokens": 42, "output_tokens": 0}, + "usage": ( + {"input_units": 42, "output_units": 0, "unit_type": "tokens"} + if provider == DecisionProvider.ZOOM_AI + else {"input_tokens": 42, "output_tokens": 0} + ), }, ) @@ -324,7 +332,9 @@ def test_traces_respect_content_mode(mode: TraceContentMode) -> None: assert span.span_data.output == [response] -@pytest.mark.parametrize("provider", [DecisionProvider.OPENAI]) +@pytest.mark.parametrize( + "provider", [DecisionProvider.OPENAI, DecisionProvider.ZOOM_AI] +) def test_array_protocol_returns_distributions( provider: DecisionProvider, decision_request: DecisionRequest ) -> None: diff --git a/backend/tests/unit/onyx/llm/test_factory.py b/backend/tests/unit/onyx/llm/test_factory.py index 9ba936bfa8..967af95b2b 100644 --- a/backend/tests/unit/onyx/llm/test_factory.py +++ b/backend/tests/unit/onyx/llm/test_factory.py @@ -221,7 +221,9 @@ def test_decision_client_uses_selected_provider_and_model() -> None: patch("onyx.llm.factory.get_session_with_current_tenant"), patch("onyx.llm.factory.fetch_default_model", return_value=model), patch("onyx.llm.factory.check_llm_cost_limit_for_provider") as cost, - patch("onyx.llm.factory.DecisionClient", wraps=DecisionClient) as build, + patch( + "onyx.llm.decisions.factory.DecisionClient", wraps=DecisionClient + ) as build, ): client = get_decision_client(additional_headers={"X-Test": "value"}) assert client is not None diff --git a/backend/tests/unit/onyx/llm/test_typesafe.py b/backend/tests/unit/onyx/llm/test_typesafe.py index 27ed7a1256..5f6055d3d5 100644 --- a/backend/tests/unit/onyx/llm/test_typesafe.py +++ b/backend/tests/unit/onyx/llm/test_typesafe.py @@ -116,7 +116,7 @@ def test_discovery_uses_saved_credentials_and_syncs_decision_models() -> None: def test_discovery_rejects_another_provider_before_reading_credentials() -> None: from onyx.server.manage.llm import api - stored = MagicMock(provider="openai") + stored = MagicMock(provider="openai", zoom_voice_provider_id=None) with patch.object(api, "fetch_existing_llm_provider_by_id", return_value=stored): with pytest.raises(OnyxError, match="requires a TypeSafe"): api.get_typesafe_available_models( @@ -128,7 +128,7 @@ def test_discovery_rejects_another_provider_before_reading_credentials() -> None def test_update_cannot_reuse_another_providers_key() -> None: from onyx.server.manage.llm.api import put_llm_provider - stored = MagicMock(provider="openai") + stored = MagicMock(provider="openai", zoom_voice_provider_id=None) with ( patch( "onyx.server.manage.llm.api.fetch_existing_llm_provider_by_id", @@ -150,7 +150,12 @@ def test_update_cannot_reuse_another_providers_key() -> None: def test_admin_test_dispatch_restores_key_without_chat_client() -> None: from onyx.server.manage.llm.api import test_decision_provider as test_configuration - stored = MagicMock(provider="typesafe", api_base=None, custom_config=None) + stored = MagicMock( + provider="typesafe", + api_base=None, + custom_config=None, + zoom_voice_provider_id=None, + ) stored.api_key.get_value.return_value = "stored-secret" with ( patch( @@ -161,7 +166,7 @@ def test_admin_test_dispatch_restores_key_without_chat_client() -> None: "onyx.server.manage.llm.api._decision_provider_catalog", return_value={"jev-latest": False}, ), - patch("onyx.server.manage.llm.api.DecisionClient") as check, + patch("onyx.server.manage.llm.api.build_decision_client") as check, patch("onyx.server.manage.llm.api.get_llm") as chat, ): test_configuration( @@ -196,7 +201,7 @@ def test_saved_credentials_cannot_move_between_providers( "onyx.server.manage.llm.api.fetch_existing_llm_provider_by_id", return_value=stored, ), - patch("onyx.server.manage.llm.api.DecisionClient") as check, + patch("onyx.server.manage.llm.api.build_decision_client") as check, patch("onyx.server.manage.llm.api.get_llm") as chat, ): with pytest.raises(OnyxError, match="must match"): diff --git a/backend/tests/unit/onyx/server/manage/llm/test_decision_models.py b/backend/tests/unit/onyx/server/manage/llm/test_decision_models.py index 6dd9f5c7fe..25d29699f1 100644 --- a/backend/tests/unit/onyx/server/manage/llm/test_decision_models.py +++ b/backend/tests/unit/onyx/server/manage/llm/test_decision_models.py @@ -151,7 +151,7 @@ def test_openrouter_discovery_rejects_other_provider( def test_ordinary_openrouter_validation_does_not_fetch_decision_catalog() -> None: with ( patch.object(api, "_get_openrouter_models_response") as discover, - patch.object(api, "DecisionClient") as decision_client, + patch.object(api, "build_decision_client") as decision_client, patch.object(api, "get_llm") as get_llm, patch.object(api, "test_llm", return_value=None) as probe, ): @@ -185,7 +185,7 @@ def test_openrouter_validation_uses_authoritative_capabilities( "_get_openrouter_models_response", return_value={"data": [_catalog_model("vendor/model", catalog_modalities)]}, ) as discover, - patch.object(api, "DecisionClient") as decision_client, + patch.object(api, "build_decision_client") as decision_client, patch.object(api, "get_llm") as get_llm, patch.object(api, "test_llm", return_value=None) as probe, ): @@ -211,6 +211,7 @@ def test_openrouter_validation_uses_authoritative_capabilities( model="vendor/model", api_key="test-key", api_base=None, + zoom_voice_provider_id=None, ) decision_client.return_value.decide.assert_called_once_with( DecisionRequest( diff --git a/backend/tests/unit/onyx/server/manage/llm/test_decision_page.py b/backend/tests/unit/onyx/server/manage/llm/test_decision_page.py index 1d272d5342..c4822d308e 100644 --- a/backend/tests/unit/onyx/server/manage/llm/test_decision_page.py +++ b/backend/tests/unit/onyx/server/manage/llm/test_decision_page.py @@ -4,9 +4,8 @@ from unittest.mock import MagicMock, patch import pytest from pydantic import ValidationError -from onyx.db.models import ModelConfiguration +from onyx.db.models import LLMProvider, ModelConfiguration from onyx.error_handling.exceptions import OnyxError -from onyx.llm.decisions.models import DecisionProvider from onyx.server.manage.llm import api from onyx.server.manage.llm.models import DecisionProviderUpsertRequest from onyx.server.manage.llm.models import TestDecisionProviderRequest as LLMTestRequest @@ -14,7 +13,7 @@ from onyx.server.manage.llm.models import TestDecisionProviderRequest as LLMTest def test_decision_scope_tests_shared_model_with_decision_protocol() -> None: with ( - patch.object(api, "DecisionClient") as decision, + patch.object(api, "build_decision_client") as decision, patch.object(api, "get_llm") as chat, ): api.test_decision_provider( @@ -29,17 +28,18 @@ def test_decision_scope_tests_shared_model_with_decision_protocol() -> None: MagicMock(), ) decision.assert_called_once_with( - provider=DecisionProvider.OPENAI, + provider="openai", model="gpt-6-luna", api_key="test-key", api_base=None, + zoom_voice_provider_id=None, ) decision.return_value.decide.assert_called_once() chat.assert_not_called() def test_decision_scope_rejects_client_claim_for_chat_only_model() -> None: - with patch.object(api, "DecisionClient") as decision: + with patch.object(api, "build_decision_client") as decision: with pytest.raises(OnyxError, match="does not support decisions"): api.test_decision_provider( LLMTestRequest( @@ -63,7 +63,12 @@ def test_catalog_does_not_select_a_model() -> None: response = api.get_decision_model_configuration(MagicMock(), MagicMock()) assert response.model is None assert response.providers == [] - assert set(response.supported_providers) == {"typesafe", "openai", "openrouter"} + assert set(response.supported_providers) == { + "typesafe", + "openai", + "openrouter", + "zoom_ai", + } def test_decision_request_rejects_chat_settings() -> None: @@ -80,6 +85,7 @@ def test_decision_request_rejects_chat_settings() -> None: def test_decision_update_rejects_redirect_with_stored_credentials() -> None: stored = SimpleNamespace( provider="openai", + zoom_voice_provider_id=None, api_base=None, api_version=None, custom_config=None, @@ -105,6 +111,7 @@ def test_decision_update_rejects_redirect_with_stored_credentials() -> None: def test_decision_update_preserves_omitted_connection_fields() -> None: stored = SimpleNamespace( provider="openai", + zoom_voice_provider_id=None, api_base="https://gateway.example/v1", api_version="v1", custom_config={"header": "saved"}, @@ -115,7 +122,7 @@ def test_decision_update_preserves_omitted_connection_fields() -> None: id=7, provider="openai", selected_model_names=[] ) with patch.object(api, "fetch_existing_llm_provider_by_id", return_value=stored): - api._resolve_decision_connection(request, MagicMock()) + api._resolve_decision_connection(request, MagicMock(), MagicMock()) assert request.api_key == "stored-secret" assert request.api_base == "https://gateway.example/v1" assert request.api_version == "v1" @@ -124,6 +131,8 @@ def test_decision_update_preserves_omitted_connection_fields() -> None: @pytest.mark.parametrize("force", [False, True]) def test_selected_decision_model_blocks_provider_removal(force: bool) -> None: + session = MagicMock() + session.scalar.return_value = LLMProvider(id=7, zoom_voice_provider_id=None) with ( patch.object( api, @@ -133,5 +142,5 @@ def test_selected_decision_model_blocks_provider_removal(force: bool) -> None: patch.object(api, "remove_llm_provider") as remove, ): with pytest.raises(OnyxError, match="Clear the selected decision model"): - api.delete_llm_provider(7, force, MagicMock(), MagicMock()) + api.delete_llm_provider(7, force, MagicMock(), session) remove.assert_not_called() diff --git a/backend/tests/unit/onyx/server/manage/voice/test_voice_provider_api.py b/backend/tests/unit/onyx/server/manage/voice/test_voice_provider_api.py index 9b9c77d1e3..bd502f87bc 100644 --- a/backend/tests/unit/onyx/server/manage/voice/test_voice_provider_api.py +++ b/backend/tests/unit/onyx/server/manage/voice/test_voice_provider_api.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -14,6 +14,7 @@ from onyx.server.manage.voice.models import ( VoiceProviderTestRequest, VoiceProviderUpsertRequest, ) +from onyx.tracing.flows import LLMFlow from onyx.voice.factory import get_voice_provider from onyx.voice.providers.openai import OpenAIVoiceProvider @@ -92,9 +93,100 @@ async def test_upsert_rejects_tts_activation_without_tts_models( assert mock_voice_provider.validate_credentials_called is False db_session.rollback.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["voice", "decision"]) +async def test_zoom_save_validates_requested_service(mode: str) -> None: + from onyx.server.manage.voice.api import upsert_voice_provider_endpoint + + provider = _make_provider("zoom") + db_session = MagicMock() + voice = MockVoiceProvider(tts_models=[]) + with ( + patch( + "onyx.server.manage.voice.api.upsert_voice_provider", return_value=provider + ) as save, + patch("onyx.server.manage.voice.api.get_voice_provider", return_value=voice), + patch( + "onyx.server.manage.voice.api._validate_decision_credentials", + new_callable=AsyncMock, + ) as decide, + patch("onyx.server.manage.voice.api.invalidate_provider_listing_cache"), + ): + request = VoiceProviderUpsertRequest.model_validate( + { + "name": "Zoom", + "provider_type": "zoom", + "validation_mode": mode, + "api_key": "key", + "api_secret": "secret", + } + ) + result = await upsert_voice_provider_endpoint(request, MagicMock(), db_session) + assert result.id == provider.id + assert voice.validate_credentials_called is (mode == "voice") + assert decide.await_count == int(mode == "decision") + assert save.call_args.kwargs["activate_stt"] is False + assert save.call_args.kwargs["activate_tts"] is False + db_session.commit.assert_called_once() + + +@pytest.mark.asyncio +async def test_zoom_decision_validation_rolls_back_save() -> None: + provider = _make_provider("zoom") + db_session = MagicMock() + with ( + patch( + "onyx.server.manage.voice.api.upsert_voice_provider", return_value=provider + ), + patch( + "onyx.server.manage.voice.api.get_voice_provider", + return_value=MockVoiceProvider([]), + ), + patch( + "onyx.server.manage.voice.api._validate_decision_credentials", + new_callable=AsyncMock, + side_effect=RuntimeError("credential must not leak"), + ), + ): + with pytest.raises(OnyxError, match="Connection test failed"): + await upsert_voice_provider_endpoint( + VoiceProviderUpsertRequest( + name="Zoom", + provider_type="zoom", + validation_mode="decision", + ), + MagicMock(), + db_session, + ) + db_session.rollback.assert_called_once() db_session.commit.assert_not_called() +@pytest.mark.asyncio +async def test_zoom_decision_validation_uses_signed_fixed_endpoint() -> None: + from onyx.server.manage.voice.api import _validate_decision_credentials + + provider = _make_provider("zoom") + provider.api_key = "key" # ty: ignore[invalid-assignment] + provider.api_secret = "secret" # ty: ignore[invalid-assignment] + with ( + patch( + "onyx.server.manage.voice.api.build_zoom_scribe_jwt", + return_value="signed-token", + ) as signer, + patch("onyx.server.manage.voice.api.DecisionClient") as client, + ): + await _validate_decision_credentials(provider) + signer.assert_called_once_with("key", "secret") + assert client.call_args.kwargs["api_key"] == "signed-token" + assert "api_base" not in client.call_args.kwargs + assert ( + client.return_value.decide.call_args.kwargs["flow"] == LLMFlow.MODEL_VALIDATION + ) + + @pytest.mark.asyncio async def test_upsert_rejects_default_tts_row_changed_to_stt_only_type( monkeypatch: pytest.MonkeyPatch, diff --git a/backend/tests/unit/onyx/server/test_admin_audit_events.py b/backend/tests/unit/onyx/server/test_admin_audit_events.py index 60fbd1d093..d6ab841857 100644 --- a/backend/tests/unit/onyx/server/test_admin_audit_events.py +++ b/backend/tests/unit/onyx/server/test_admin_audit_events.py @@ -99,7 +99,11 @@ def test_delete_api_key_emits_event( @patch("onyx.server.manage.llm.api.invalidate_provider_listing_cache") @patch("onyx.server.manage.llm.api.remove_llm_provider") +@patch( + "onyx.server.manage.llm.api.fetch_existing_llm_provider_by_id", return_value=None +) def test_delete_llm_provider_emits_event( + _mock_fetch: MagicMock, _mock_remove: MagicMock, _mock_invalidate: MagicMock, caplog: pytest.LogCaptureFixture, diff --git a/web/src/i18n/messages/ar.json b/web/src/i18n/messages/ar.json index c2dbc39494..de12382124 100644 --- a/web/src/i18n/messages/ar.json +++ b/web/src/i18n/messages/ar.json @@ -1275,7 +1275,14 @@ "retry": "إعادة المحاولة", "saveFailed": "تعذر حفظ الاتصال.", "cancel": "إلغاء", - "save": "حفظ" + "save": "حفظ", + "zoom": { + "title": "ربط خدمات Zoom AI", + "description": "استخدم اتصالاً واحداً لـ Zoom Scribe وZoom AI Decisions. تسري التغييرات على كليهما.", + "connection": "اتصال Zoom", + "selectConnection": "اختر اتصال Zoom", + "removeDescription": "يؤدي هذا إلى إزالة اتصال Zoom المشترك من الصوت ونماذج القرار." + } }, "addConnector": { "categories": { @@ -10505,7 +10512,7 @@ "required": "مفتاح API مطلوب" }, "apiSecret": { - "description": "الصق [سر API]({url}) من {provider} للوصول إلى Scribe.", + "description": "الصق [سر API]({url}) من {provider} للوصول إلى خدمات Zoom AI.", "label": "سر API", "placeholder": "سر API", "required": "سر API مطلوب" diff --git a/web/src/i18n/messages/de.json b/web/src/i18n/messages/de.json index 42a76d33c9..05327037e3 100644 --- a/web/src/i18n/messages/de.json +++ b/web/src/i18n/messages/de.json @@ -1275,7 +1275,14 @@ "retry": "Erneut versuchen", "saveFailed": "Die Verbindung konnte nicht gespeichert werden.", "cancel": "Abbrechen", - "save": "Speichern" + "save": "Speichern", + "zoom": { + "title": "Zoom AI Services verbinden", + "description": "Verwenden Sie eine Verbindung für Zoom Scribe und Zoom AI Decisions. Änderungen gelten für beide.", + "connection": "Zoom-Verbindung", + "selectConnection": "Zoom-Verbindung auswählen", + "removeDescription": "Dies entfernt die gemeinsame Zoom-Verbindung aus Sprache und Entscheidungsmodellen." + } }, "addConnector": { "categories": { @@ -10505,7 +10512,7 @@ "required": "API-Schlüssel ist erforderlich" }, "apiSecret": { - "description": "Fügen Sie Ihr [API-Secret]({url}) von {provider} ein, um auf Scribe zuzugreifen.", + "description": "Fügen Sie Ihr [API-Secret]({url}) von {provider} ein, um auf Zoom AI Services zuzugreifen.", "label": "API-Secret", "placeholder": "API-Secret", "required": "API-Secret ist erforderlich" diff --git a/web/src/i18n/messages/en.json b/web/src/i18n/messages/en.json index 2d39c6f403..94e7603e53 100644 --- a/web/src/i18n/messages/en.json +++ b/web/src/i18n/messages/en.json @@ -1275,7 +1275,14 @@ "retry": "Try again", "saveFailed": "Could not save the connection.", "cancel": "Cancel", - "save": "Save" + "save": "Save", + "zoom": { + "title": "Connect Zoom AI Services", + "description": "Use one connection for Zoom Scribe and Zoom AI Decisions. Changes apply to both.", + "connection": "Zoom connection", + "selectConnection": "Select a Zoom connection", + "removeDescription": "This removes the shared Zoom connection from Voice and Decision Models." + } }, "addConnector": { "categories": { @@ -10505,7 +10512,7 @@ "required": "API key is required" }, "apiSecret": { - "description": "Paste your [API secret]({url}) from {provider} to access Scribe.", + "description": "Paste your [API secret]({url}) from {provider} to access Zoom AI Services.", "label": "API Secret", "placeholder": "API secret", "required": "API secret is required" diff --git a/web/src/i18n/messages/es.json b/web/src/i18n/messages/es.json index aa9e9aed05..8c94b38c08 100644 --- a/web/src/i18n/messages/es.json +++ b/web/src/i18n/messages/es.json @@ -1275,7 +1275,14 @@ "retry": "Reintentar", "saveFailed": "No se pudo guardar la conexión.", "cancel": "Cancelar", - "save": "Guardar" + "save": "Guardar", + "zoom": { + "title": "Conectar Zoom AI Services", + "description": "Usa una conexión para Zoom Scribe y Zoom AI Decisions. Los cambios se aplican a ambos.", + "connection": "Conexión de Zoom", + "selectConnection": "Seleccionar una conexión de Zoom", + "removeDescription": "Esto elimina la conexión compartida de Zoom de Voz y Modelos de decisión." + } }, "addConnector": { "categories": { @@ -10505,7 +10512,7 @@ "required": "La clave de API es obligatoria" }, "apiSecret": { - "description": "Pega tu [secreto de API]({url}) de {provider} para acceder a Scribe.", + "description": "Pega tu [secreto de API]({url}) de {provider} para acceder a Zoom AI Services.", "label": "Secreto de API", "placeholder": "Secreto de API", "required": "El secreto de API es obligatorio" diff --git a/web/src/i18n/messages/fr.json b/web/src/i18n/messages/fr.json index bf257103b3..a5c4f6774c 100644 --- a/web/src/i18n/messages/fr.json +++ b/web/src/i18n/messages/fr.json @@ -1275,7 +1275,14 @@ "retry": "Réessayer", "saveFailed": "Impossible d’enregistrer la connexion.", "cancel": "Annuler", - "save": "Enregistrer" + "save": "Enregistrer", + "zoom": { + "title": "Connecter Zoom AI Services", + "description": "Utilisez une connexion pour Zoom Scribe et Zoom AI Decisions. Les modifications s’appliquent aux deux.", + "connection": "Connexion Zoom", + "selectConnection": "Sélectionner une connexion Zoom", + "removeDescription": "Cela supprime la connexion Zoom partagée de Voix et Modèles de décision." + } }, "addConnector": { "categories": { @@ -10505,7 +10512,7 @@ "required": "La clé d'API est obligatoire" }, "apiSecret": { - "description": "Collez votre [secret d'API]({url}) de {provider} pour accéder à Scribe.", + "description": "Collez votre [secret API]({url}) de {provider} pour accéder à Zoom AI Services.", "label": "Secret d'API", "placeholder": "Secret d'API", "required": "Le secret d'API est obligatoire" diff --git a/web/src/i18n/messages/ja.json b/web/src/i18n/messages/ja.json index db3ec87876..14fec9aa72 100644 --- a/web/src/i18n/messages/ja.json +++ b/web/src/i18n/messages/ja.json @@ -1275,7 +1275,14 @@ "retry": "再試行", "saveFailed": "接続を保存できませんでした。", "cancel": "キャンセル", - "save": "保存" + "save": "保存", + "zoom": { + "title": "Zoom AI Services に接続", + "description": "Zoom Scribe と Zoom AI Decisions に同じ接続を使用します。変更は両方に適用されます。", + "connection": "Zoom 接続", + "selectConnection": "Zoom 接続を選択", + "removeDescription": "音声と決定モデルから共有 Zoom 接続を削除します。" + } }, "addConnector": { "categories": { @@ -10505,7 +10512,7 @@ "required": "API キーは必須です" }, "apiSecret": { - "description": "Scribe にアクセスするには、{provider} の [API シークレット]({url}) を貼り付けてください。", + "description": "{provider} の [API シークレット]({url}) を貼り付けて Zoom AI Services にアクセスします。", "label": "API シークレット", "placeholder": "API シークレット", "required": "API シークレットは必須です" diff --git a/web/src/i18n/messages/ko.json b/web/src/i18n/messages/ko.json index a7f956e5eb..8cf8153fcf 100644 --- a/web/src/i18n/messages/ko.json +++ b/web/src/i18n/messages/ko.json @@ -1275,7 +1275,14 @@ "retry": "다시 시도", "saveFailed": "연결을 저장할 수 없습니다.", "cancel": "취소", - "save": "저장" + "save": "저장", + "zoom": { + "title": "Zoom AI Services 연결", + "description": "Zoom Scribe와 Zoom AI Decisions에 하나의 연결을 사용합니다. 변경 사항은 둘 다에 적용됩니다.", + "connection": "Zoom 연결", + "selectConnection": "Zoom 연결 선택", + "removeDescription": "음성 및 결정 모델에서 공유 Zoom 연결이 삭제됩니다." + } }, "addConnector": { "categories": { @@ -10505,7 +10512,7 @@ "required": "API 키가 필요합니다" }, "apiSecret": { - "description": "Scribe에 액세스하려면 {provider}의 [API 시크릿]({url})을 붙여넣으세요.", + "description": "Zoom AI Services에 액세스하려면 {provider}의 [API 시크릿]({url})을 붙여넣으세요.", "label": "API 시크릿", "placeholder": "API 시크릿", "required": "API 시크릿이 필요합니다" diff --git a/web/src/i18n/messages/pt.json b/web/src/i18n/messages/pt.json index 74c2ef1071..58f8b43829 100644 --- a/web/src/i18n/messages/pt.json +++ b/web/src/i18n/messages/pt.json @@ -1275,7 +1275,14 @@ "retry": "Tentar novamente", "saveFailed": "Não foi possível salvar a conexão.", "cancel": "Cancelar", - "save": "Salvar" + "save": "Salvar", + "zoom": { + "title": "Conectar Zoom AI Services", + "description": "Use uma conexão para Zoom Scribe e Zoom AI Decisions. As alterações se aplicam a ambos.", + "connection": "Conexão do Zoom", + "selectConnection": "Selecionar uma conexão do Zoom", + "removeDescription": "Isso remove a conexão compartilhada do Zoom de Voz e Modelos de decisão." + } }, "addConnector": { "categories": { @@ -10505,7 +10512,7 @@ "required": "A chave de API é obrigatória" }, "apiSecret": { - "description": "Cole seu [segredo de API]({url}) do {provider} para acessar o Scribe.", + "description": "Cole seu [segredo de API]({url}) de {provider} para acessar o Zoom AI Services.", "label": "Segredo de API", "placeholder": "Segredo de API", "required": "O segredo de API é obrigatório" diff --git a/web/src/i18n/messages/zh.json b/web/src/i18n/messages/zh.json index 658c7eaf34..8a65dc2fd0 100644 --- a/web/src/i18n/messages/zh.json +++ b/web/src/i18n/messages/zh.json @@ -1275,7 +1275,14 @@ "retry": "重试", "saveFailed": "无法保存连接。", "cancel": "取消", - "save": "保存" + "save": "保存", + "zoom": { + "title": "连接 Zoom AI Services", + "description": "Zoom Scribe 和 Zoom AI Decisions 共用一个连接。更改会同时应用于两者。", + "connection": "Zoom 连接", + "selectConnection": "选择 Zoom 连接", + "removeDescription": "这将从语音和决策模型中删除共享的 Zoom 连接。" + } }, "addConnector": { "categories": { @@ -10505,7 +10512,7 @@ "required": "API 密钥为必填项" }, "apiSecret": { - "description": "粘贴来自 {provider} 的 [API 机密]({url}),以访问 Scribe。", + "description": "粘贴来自 {provider} 的 [API 密钥机密]({url}) 以访问 Zoom AI Services。", "label": "API 机密", "placeholder": "API 机密", "required": "API 机密为必填项" diff --git a/web/src/lib/hooks/useLLMProviderOptions.test.tsx b/web/src/lib/hooks/useLLMProviderOptions.test.tsx index 6cce4b28c5..59636f208c 100644 --- a/web/src/lib/hooks/useLLMProviderOptions.test.tsx +++ b/web/src/lib/hooks/useLLMProviderOptions.test.tsx @@ -6,6 +6,7 @@ jest.mock("swr", () => ({ default: () => ({ data: [ { name: "typesafe", known_models: [] }, + { name: "zoom_ai", known_models: [] }, { name: "openai", known_models: [] }, ], error: undefined, diff --git a/web/src/lib/languageModels/constants.ts b/web/src/lib/languageModels/constants.ts index 450534f835..29814ab660 100644 --- a/web/src/lib/languageModels/constants.ts +++ b/web/src/lib/languageModels/constants.ts @@ -20,11 +20,13 @@ import { SvgPortkey, SvgTypeSafe, SvgVercel, + SvgZoom, } from "@opal/logos"; import { ZAIIcon } from "@/components/icons/icons"; import { LLMProviderName } from "@/lib/languageModels/types"; import OpenAIModal from "@/sections/modals/languageModels/OpenAIModal"; import TypeSafeModal from "@/sections/modals/languageModels/TypeSafeModal"; +import ZoomDecisionModal from "@/sections/modals/languageModels/ZoomDecisionModal"; import AnthropicModal from "@/sections/modals/languageModels/AnthropicModal"; import OllamaModal from "@/sections/modals/languageModels/OllamaModal"; import AzureModal from "@/sections/modals/languageModels/AzureModal"; @@ -51,6 +53,13 @@ export const PROVIDERS: Record = { companyName: "TypeSafe AI", Modal: TypeSafeModal, }, + [LLMProviderName.ZOOM_AI]: { + decisionOnly: true, + icon: SvgZoom, + productName: "Zoom AI Decisions", + companyName: "Zoom", + Modal: ZoomDecisionModal, + }, [LLMProviderName.OPENAI]: { icon: SvgOpenAI, productName: "GPT", @@ -190,6 +199,7 @@ export const AGGREGATOR_PROVIDERS = new Set([ export const MODEL_ICON_MAP: Record = { [LLMProviderName.TYPESAFE]: SvgTypeSafe, + [LLMProviderName.ZOOM_AI]: SvgZoom, [LLMProviderName.OPENAI]: SvgOpenAI, [LLMProviderName.ANTHROPIC]: SvgClaude, [LLMProviderName.OLLAMA_CHAT]: SvgOllama, diff --git a/web/src/lib/languageModels/svc.ts b/web/src/lib/languageModels/svc.ts index bb4b81222f..7b02111612 100644 --- a/web/src/lib/languageModels/svc.ts +++ b/web/src/lib/languageModels/svc.ts @@ -887,6 +887,8 @@ export const fetchVercelAIGatewayModels = async ( const data: VercelAIGatewayModelResponse[] = await response.json(); const models: ModelConfiguration[] = data.map((modelData) => ({ + supports_chat: modelData.supports_chat, + supports_decisions: modelData.supports_decisions, name: modelData.name, display_name: modelData.display_name, is_visible: true, diff --git a/web/src/lib/languageModels/types.ts b/web/src/lib/languageModels/types.ts index b48efc48c1..64ed848fa0 100644 --- a/web/src/lib/languageModels/types.ts +++ b/web/src/lib/languageModels/types.ts @@ -62,6 +62,7 @@ export interface ModelConfiguration { export enum LLMProviderName { TYPESAFE = "typesafe", + ZOOM_AI = "zoom_ai", OPENAI = "openai", ANTHROPIC = "anthropic", OLLAMA_CHAT = "ollama_chat", @@ -103,6 +104,7 @@ export interface LLMModelDescriptor { export interface LLMProviderView { id: number; + zoom_voice_provider_id?: number | null; name: string | null; provider: string; api_key: string | null; @@ -333,6 +335,8 @@ export interface VercelAIGatewayFetchParams { } export interface VercelAIGatewayModelResponse { + supports_chat?: boolean; + supports_decisions?: boolean; name: string; display_name: string; max_input_tokens: number | null; diff --git a/web/src/lib/voice/svc.ts b/web/src/lib/voice/svc.ts index e1562a9665..79f7718305 100644 --- a/web/src/lib/voice/svc.ts +++ b/web/src/lib/voice/svc.ts @@ -33,6 +33,7 @@ export async function deactivateVoiceProvider( /** Validates provider credentials with a live API call before saving. */ export async function testVoiceProvider(request: { + validation_mode?: "voice" | "decision"; id?: number; provider_type: string; api_key?: string; diff --git a/web/src/lib/voice/types.ts b/web/src/lib/voice/types.ts index 83f014be90..3e1bd4e0d3 100644 --- a/web/src/lib/voice/types.ts +++ b/web/src/lib/voice/types.ts @@ -55,6 +55,7 @@ export interface VoiceFormValues { /** Request body for creating or updating a voice provider configuration. */ export interface VoiceProviderUpsertRequest { + validation_mode?: "voice" | "decision"; id?: number; name: string; provider_type: string; diff --git a/web/src/lib/voice/utils.ts b/web/src/lib/voice/utils.ts index a167ae9253..cf57778cae 100644 --- a/web/src/lib/voice/utils.ts +++ b/web/src/lib/voice/utils.ts @@ -132,10 +132,10 @@ export const VOICE_PROVIDER_DETAILS: Record = { }, }, zoom: { - label: "Zoom Scribe", + label: "Zoom AI Services", icon: SvgZoom, - apiKeyUrl: "https://developers.zoom.us/docs/ai-services/build-platform/", - docsUrl: "https://developers.zoom.us/docs/ai-services/scribe/", + apiKeyUrl: "https://developers.zoom.us/docs/ai-services/", + docsUrl: "https://developers.zoom.us/docs/ai-services/", sttModels: [{ id: "scribe-live", name: "Scribe Live" }], requiresApiSecret: true, sttLanguageOptions: [ diff --git a/web/src/sections/modals/languageModels/ZoomDecisionModal.tsx b/web/src/sections/modals/languageModels/ZoomDecisionModal.tsx new file mode 100644 index 0000000000..f0d529db7d --- /dev/null +++ b/web/src/sections/modals/languageModels/ZoomDecisionModal.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { + InputSingleSelect, + MessageCard, + Modal, + OnyxLoader, +} from "@opal/components"; +import { InputVertical } from "@opal/layouts"; +import { SvgZoom } from "@opal/logos"; +import { useVoiceProviders } from "@/lib/voice/hooks"; +import type { LLMProviderFormProps } from "@/lib/languageModels/types"; +import { VoiceProviderSetupModal } from "@/views/admin/VoicePage/shared"; + +export default function ZoomDecisionModal({ + existingLlmProvider, + onOpenChange, + onSuccess, +}: LLMProviderFormProps) { + const t = useTranslations("admin.decisionModels"); + const { providers, isLoading, error } = useVoiceProviders(); + const [selectedId, setSelectedId] = useState(null); + const zoomProviders = providers.filter( + (provider) => provider.provider_type === "zoom" + ); + const linkedId = existingLlmProvider?.zoom_voice_provider_id; + const existingProvider = + zoomProviders.find( + (provider) => provider.id === (linkedId ?? selectedId) + ) ?? + (!linkedId && zoomProviders.length === 1 ? zoomProviders[0] : undefined); + const close = () => onOpenChange?.(false); + + if ( + !isLoading && + !error && + (!linkedId || existingProvider) && + (zoomProviders.length < 2 || existingProvider) + ) { + return ( + { + void onSuccess?.(); + close(); + }} + /> + ); + } + + return ( + + + + + {isLoading ? ( + + ) : error || linkedId ? ( + + ) : ( + + setSelectedId(Number(value))} + placeholder={t("zoom.selectConnection")} + options={zoomProviders.map((provider) => ({ + value: String(provider.id), + title: provider.name, + }))} + /> + + )} + + + + ); +} diff --git a/web/src/views/admin/DecisionModelsPage.test.tsx b/web/src/views/admin/DecisionModelsPage.test.tsx index d96b23b1e9..3dfb0f05a4 100644 --- a/web/src/views/admin/DecisionModelsPage.test.tsx +++ b/web/src/views/admin/DecisionModelsPage.test.tsx @@ -6,6 +6,7 @@ import { within, } from "@tests/setup/test-utils"; import DecisionModelsPage from "@/views/admin/DecisionModelsPage"; +import { PointerEventsCheckLevel } from "@testing-library/user-event"; import { SWR_KEYS } from "@/lib/swr-keys"; import type { LLMProviderView } from "@/lib/languageModels/types"; @@ -18,9 +19,9 @@ jest.mock("@/lib/settings/hooks", () => ({ useSettings: () => ({ hide_provider_grouping: false }), })); -const typesafeModel = { - name: "system-one", - display_name: "System One", +const zoomModel = { + name: "zoom-decisions", + display_name: "Zoom Decisions", is_visible: false, max_input_tokens: null, supports_image_input: false, @@ -28,10 +29,11 @@ const typesafeModel = { supports_chat: false, supports_decisions: true, }; -const savedTypeSafe: LLMProviderView = { +const savedZoom: LLMProviderView = { id: 8, - name: "TypeSafe connection", - provider: "typesafe", + name: "Zoom decisions connection", + provider: "zoom_ai", + zoom_voice_provider_id: 4, api_key: null, api_base: null, api_version: null, @@ -42,41 +44,73 @@ const savedTypeSafe: LLMProviderView = { groups: [], personas: [], model_configurations: [ - { ...typesafeModel, effectiveDisplayName: "System One" }, + { ...zoomModel, effectiveDisplayName: "Zoom Decisions" }, ], }; afterEach(() => jest.restoreAllMocks()); -function mockApi(hasTypeSafeConnection = false, selected = false) { +function mockApi( + hasZoomConnection = false, + hasScribeConnection = true, + selected = false, + configuredProvider = savedZoom +) { return jest.spyOn(global, "fetch").mockImplementation(async (url) => { if (url === SWR_KEYS.decisionModel) return new Response( JSON.stringify({ - model: selected ? { provider_id: 8, model_name: "system-one" } : null, - providers: hasTypeSafeConnection ? [savedTypeSafe] : [], - supported_providers: ["typesafe", "openai", "openrouter"], + model: selected + ? { provider_id: 8, model_name: "zoom-decisions" } + : null, + providers: hasZoomConnection ? [configuredProvider] : [], + supported_providers: ["typesafe", "openai", "openrouter", "zoom_ai"], }) ); if (url === SWR_KEYS.adminLlmProvidersPaged) return new Response( JSON.stringify({ - providers: hasTypeSafeConnection ? [savedTypeSafe] : [], + providers: hasZoomConnection ? [configuredProvider] : [], default_text: null, }) ); - if (url === SWR_KEYS.wellKnownLlmProvider("typesafe")) + if (url === SWR_KEYS.voiceProviders) + return new Response( + JSON.stringify( + hasScribeConnection + ? [ + { + id: 4, + provider_type: "zoom", + name: "Company Scribe", + api_key: "masked-key", + api_secret: "masked-secret", + is_default_stt: true, + is_default_tts: false, + stt_model: "scribe-live", + tts_model: null, + default_voice: null, + custom_config: { language: "en-US" }, + target_uri: null, + }, + ] + : [] + ) + ); + if (String(url).startsWith(SWR_KEYS.voiceProviders)) + return new Response(JSON.stringify({ success: true })); + if (url === SWR_KEYS.wellKnownLlmProvider("zoom_ai")) return new Response( JSON.stringify({ - name: "typesafe", - known_models: [typesafeModel], + name: "zoom_ai", + known_models: [zoomModel], recommended_default_model: null, }) ); if (url === SWR_KEYS.adminLlmProvider(8)) - return new Response(JSON.stringify(savedTypeSafe)); + return new Response(JSON.stringify(configuredProvider)); if (String(url).startsWith(SWR_KEYS.adminLlmProviders)) - return new Response(JSON.stringify(savedTypeSafe)); + return new Response(JSON.stringify(configuredProvider)); throw new Error(`Unexpected URL ${String(url)}`); }); } @@ -86,7 +120,7 @@ it("shows the provider catalog without choosing a decision model", async () => { render(); expect(await screen.findByText("Decision Models")).toBeInTheDocument(); expect(await screen.findByPlaceholderText("Not configured")).toHaveValue(""); - expect(screen.getAllByRole("button", { name: "Connect" })).toHaveLength(3); + expect(screen.getAllByRole("button", { name: "Connect" })).toHaveLength(4); expect( screen.getByText("TypeSafe AI", { selector: "span" }) ).toBeInTheDocument(); @@ -96,6 +130,78 @@ it("shows the provider catalog without choosing a decision model", async () => { expect(fetchSpy.mock.calls.every(([, init]) => !init?.method)).toBe(true); }); +it("reuses the saved Zoom credentials and preserves opt-in defaults", async () => { + const fetchSpy = mockApi(); + const user = setupUser({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + render(); + const card = await screen.findByTestId("decision-provider-zoom_ai-new"); + await user.click(within(card).getByRole("button", { name: "Connect" })); + await user.click(await screen.findByRole("button", { name: "Update" })); + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith( + SWR_KEYS.voiceProviders, + expect.objectContaining({ method: "POST" }) + ) + ); + const request = fetchSpy.mock.calls.find( + ([url, init]) => url === SWR_KEYS.voiceProviders && init?.method === "POST" + )?.[1]; + expect(JSON.parse(String(request?.body))).toMatchObject({ + id: 4, + provider_type: "zoom", + validation_mode: "decision", + activate_stt: false, + activate_tts: false, + api_key_changed: false, + api_secret_changed: false, + }); + expect(JSON.parse(String(request?.body))).not.toHaveProperty("api_key"); + expect(JSON.parse(String(request?.body))).not.toHaveProperty("api_secret"); + const testRequest = fetchSpy.mock.calls.find( + ([url]) => url === `${SWR_KEYS.voiceProviders}/test` + )?.[1]; + expect(JSON.parse(String(testRequest?.body))).toMatchObject({ + id: 4, + validation_mode: "decision", + use_stored_key: true, + use_stored_secret: true, + }); +}); + +it("creates the shared Zoom connection directly from Decision Models", async () => { + const fetchSpy = mockApi(false, false); + const user = setupUser(); + render(); + const card = await screen.findByTestId("decision-provider-zoom_ai-new"); + await user.click(within(card).getByRole("button", { name: "Connect" })); + await user.type(await screen.findByLabelText("API Key"), "new-zoom-key"); + await user.type(screen.getByLabelText("API Secret"), "new-zoom-secret"); + expect( + within(screen.getByRole("dialog")).queryByRole("combobox") + ).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /^Connect$/ })); + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith( + SWR_KEYS.voiceProviders, + expect.objectContaining({ method: "POST" }) + ) + ); + const request = fetchSpy.mock.calls.find( + ([url, init]) => url === SWR_KEYS.voiceProviders && init?.method === "POST" + )?.[1]; + expect(JSON.parse(String(request?.body))).toMatchObject({ + provider_type: "zoom", + api_key: "new-zoom-key", + api_secret: "new-zoom-secret", + validation_mode: "decision", + activate_stt: false, + activate_tts: false, + }); + expect(fetchSpy.mock.calls.some(([, init]) => init?.method === "PUT")).toBe( + false + ); +}); + it("loads the complete saved provider before editing a decision connection", async () => { const fetchSpy = mockApi(true); const user = setupUser(); @@ -103,10 +209,42 @@ it("loads the complete saved provider before editing a decision connection", asy await user.click(await screen.findByRole("button", { name: "Configure" })); expect(await screen.findByRole("dialog")).toBeInTheDocument(); expect(fetchSpy).toHaveBeenCalledWith(SWR_KEYS.adminLlmProvider(8)); - expect(await screen.findByLabelText(/api key/i)).toBeInTheDocument(); + expect( + await screen.findByRole("button", { name: "Update" }) + ).toBeInTheDocument(); }); -it("removes a decision-only provider through the existing delete endpoint", async () => { +it("rotates the shared Zoom key without resending the stored secret", async () => { + const fetchSpy = mockApi(true); + const user = setupUser({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + render(); + await user.click(await screen.findByRole("button", { name: "Configure" })); + const key = await screen.findByLabelText("API Key"); + await user.clear(key); + await user.type(key, "rotated-zoom-key"); + await user.click(screen.getByRole("button", { name: "Update" })); + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith( + SWR_KEYS.voiceProviders, + expect.objectContaining({ method: "POST" }) + ) + ); + const request = fetchSpy.mock.calls.find( + ([url, init]) => url === SWR_KEYS.voiceProviders && init?.method === "POST" + )?.[1]; + const body = JSON.parse(String(request?.body)); + expect(body).toMatchObject({ + id: 4, + api_key: "rotated-zoom-key", + api_key_changed: true, + api_secret_changed: false, + activate_stt: false, + activate_tts: false, + }); + expect(body).not.toHaveProperty("api_secret"); +}); + +it("removes only the Zoom decision provider and keeps Scribe", async () => { const fetchSpy = mockApi(true); const user = setupUser(); render(); @@ -115,14 +253,22 @@ it("removes a decision-only provider through the existing delete endpoint", asyn within(screen.getByRole("dialog")).getByRole("button", { name: "Delete" }) ); await waitFor(() => - expect(fetchSpy).toHaveBeenCalledWith(`${SWR_KEYS.adminLlmProviders}/8`, { - method: "DELETE", - }) + expect(fetchSpy).toHaveBeenCalledWith( + `${SWR_KEYS.adminLlmProviders}/8`, + expect.objectContaining({ method: "DELETE" }) + ) ); + expect( + fetchSpy.mock.calls.some( + ([url, init]) => + String(url).startsWith(String(SWR_KEYS.voiceProviders)) && + init?.method === "DELETE" + ) + ).toBe(false); }); it("requires clearing the selected decision model before removing its provider", async () => { - const fetchSpy = mockApi(true, true); + const fetchSpy = mockApi(true, true, true); const user = setupUser(); render(); await user.click(await screen.findByRole("button", { name: "Delete" })); @@ -146,13 +292,13 @@ it("refetches a cached provider list that another page made stale", async () => new Map([ [ SWR_KEYS.adminLlmProvidersPaged, - { data: { providers: [savedTypeSafe], default_text: null } }, + { data: { providers: [savedZoom], default_text: null } }, ], ]), }, }); await waitFor(() => - expect(screen.getAllByRole("button", { name: "Connect" })).toHaveLength(3) + expect(screen.getAllByRole("button", { name: "Connect" })).toHaveLength(4) ); expect( screen.queryByRole("button", { name: "Configure" }) diff --git a/web/src/views/admin/DecisionModelsPage.tsx b/web/src/views/admin/DecisionModelsPage.tsx index d56df29a48..d7bba899c1 100644 --- a/web/src/views/admin/DecisionModelsPage.tsx +++ b/web/src/views/admin/DecisionModelsPage.tsx @@ -34,6 +34,7 @@ import DecisionModelSetting from "@/views/admin/DecisionModelSetting"; import { SWR_KEYS } from "@/lib/swr-keys"; import { deleteLlmProvider } from "@/lib/languageModels/svc"; import DecisionProviderModal from "@/sections/modals/languageModels/DecisionProviderModal"; +import ZoomDecisionModal from "@/sections/modals/languageModels/ZoomDecisionModal"; interface DecisionProviderCardProps { providerName: string; @@ -136,14 +137,22 @@ function DecisionProviderCard({ } /> )} - {open && (!provider || fullProvider) && ( - refreshLlmProviderCaches(mutate)} - /> - )} + {open && + (!provider || fullProvider) && + (providerName === "zoom_ai" ? ( + refreshLlmProviderCaches(mutate)} + /> + ) : ( + refreshLlmProviderCaches(mutate)} + /> + ))} {provider && deleteModal.isOpen && ( diff --git a/web/src/views/admin/VoicePage/shared.test.tsx b/web/src/views/admin/VoicePage/shared.test.tsx new file mode 100644 index 0000000000..6ea76bfeec --- /dev/null +++ b/web/src/views/admin/VoicePage/shared.test.tsx @@ -0,0 +1,60 @@ +import { render, screen, setupUser, waitFor } from "@tests/setup/test-utils"; +import { PointerEventsCheckLevel } from "@testing-library/user-event"; +import { VoiceProviderSetupModal } from "@/views/admin/VoicePage/shared"; +import { SWR_KEYS } from "@/lib/swr-keys"; + +jest.mock("@/lib/settings/hooks", () => ({ + useSettings: () => ({ appName: "Onyx" }), +})); + +afterEach(() => jest.restoreAllMocks()); + +it("sets up the shared Zoom credentials from Voice with voice validation", async () => { + const fetchSpy = jest + .spyOn(global, "fetch") + .mockImplementation( + async () => new Response(JSON.stringify({ success: true })) + ); + const onSuccess = jest.fn(); + const user = setupUser({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + render( + + ); + expect( + await screen.findByText("Connect Zoom AI Services") + ).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toHaveTextContent( + "Use one connection for Zoom Scribe and Zoom AI Decisions. Changes apply to both." + ); + await user.type(screen.getByLabelText("API Key"), "zoom-key"); + await user.type(screen.getByLabelText("API Secret"), "zoom-secret"); + await user.click(screen.getByRole("button", { name: "Connect" })); + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + const testRequest = fetchSpy.mock.calls.find( + ([url]) => url === `${SWR_KEYS.voiceProviders}/test` + )?.[1]; + expect(JSON.parse(String(testRequest?.body))).toMatchObject({ + validation_mode: "voice", + provider_type: "zoom", + api_key: "zoom-key", + api_secret: "zoom-secret", + }); + const saveRequest = fetchSpy.mock.calls.find( + ([url]) => url === SWR_KEYS.voiceProviders + )?.[1]; + expect(JSON.parse(String(saveRequest?.body))).toMatchObject({ + validation_mode: "voice", + provider_type: "zoom", + activate_stt: true, + activate_tts: false, + custom_config: { language: "en-US" }, + }); + expect( + fetchSpy.mock.calls.some(([url]) => url === SWR_KEYS.decisionModel) + ).toBe(false); +}); diff --git a/web/src/views/admin/VoicePage/shared.tsx b/web/src/views/admin/VoicePage/shared.tsx index 408aa07989..0adb197a7a 100644 --- a/web/src/views/admin/VoicePage/shared.tsx +++ b/web/src/views/admin/VoicePage/shared.tsx @@ -4,6 +4,9 @@ import { IconLoader } from "@opal/loaders"; import { escapeMarkdown, markdown } from "@opal/utils"; import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; +import { useSWRConfig } from "swr"; +import { refreshLlmProviderCaches } from "@/lib/languageModels/cache"; +import { SWR_KEYS } from "@/lib/swr-keys"; import { useSettings } from "@/lib/settings/hooks"; import { Formik, Form } from "formik"; import * as Yup from "yup"; @@ -14,7 +17,7 @@ import { InputSingleComboBoxField, InputSingleSelectField } from "@opal/form"; import InputTypeInField from "@/refresh-components/form/InputTypeInField"; import PasswordInputTypeInField from "@/refresh-components/form/PasswordInputTypeInField"; import { InputVertical, toast } from "@opal/layouts"; -import { Section } from "@/layouts/general-layouts"; +import { Section } from "@opal/layouts"; import { SvgArrowExchange, SvgUnplug } from "@opal/icons"; import { Button, Text } from "@opal/components"; import { useModalClose } from "@opal/components"; @@ -50,13 +53,15 @@ const AZURE_PORTAL_URL = "https://portal.azure.com/"; // VoiceProviderSetupModal // --------------------------------------------------------------------------- -interface VoiceProviderSetupModalProps { - providerType: string; +type VoiceProviderSetupModalProps = { existingProvider: VoiceProviderView | null; - mode: ProviderMode; defaultModelId?: string | null; onSuccess: () => void; -} + onClose?: () => void; +} & ( + | { mode: ProviderMode; providerType: string } + | { mode: "connection"; providerType: "zoom" } +); export function VoiceProviderSetupModal({ providerType, @@ -64,12 +69,15 @@ export function VoiceProviderSetupModal({ mode, defaultModelId, onSuccess, + onClose: onCloseProp, }: VoiceProviderSetupModalProps) { const t = useTranslations("admin.voice"); + const tDecision = useTranslations("admin.decisionModels"); + const { mutate } = useSWRConfig(); const tPlaceholders = useTranslations("common.placeholders"); const tInputSelect = useTranslations("common.inputSelect"); const { appName } = useSettings(); - const onClose = useModalClose(); + const onClose = useModalClose(onCloseProp); const detail = getVoiceProviderDetail(providerType); const initialSttModel = mode === "stt" && defaultModelId @@ -230,6 +238,7 @@ export function VoiceProviderSetupModal({ } const testResponse = await testVoiceProvider({ + validation_mode: mode === "connection" ? "decision" : "voice", id: existingProvider?.id, provider_type: providerType, api_key: apiKeyChanged ? values.api_key : undefined, @@ -254,8 +263,9 @@ export function VoiceProviderSetupModal({ } const response = await upsertVoiceProvider({ + validation_mode: mode === "connection" ? "decision" : "voice", id: existingProvider?.id, - name: detail.label, + name: existingProvider?.name ?? detail.label, provider_type: providerType, api_key: apiKeyChanged ? values.api_key : undefined, api_secret: apiSecretChanged ? values.api_secret : undefined, @@ -268,15 +278,25 @@ export function VoiceProviderSetupModal({ stt_model: values.stt_model, tts_model: values.tts_model, default_voice: values.default_voice, - activate_stt: isEditing - ? (existingProvider?.is_default_stt ?? false) - : mode === "stt", - activate_tts: isEditing - ? (existingProvider?.is_default_tts ?? false) - : mode === "tts", + activate_stt: + mode === "connection" + ? false + : isEditing + ? (existingProvider?.is_default_stt ?? false) + : mode === "stt", + activate_tts: + mode === "connection" + ? false + : isEditing + ? (existingProvider?.is_default_tts ?? false) + : mode === "tts", }); if (response.ok) { + await Promise.all([ + mutate(SWR_KEYS.voiceProviders), + refreshLlmProviderCaches(mutate), + ]); onSuccess(); } else { const data: ErrorResponseBody = await response.json().catch(() => ({})); @@ -309,17 +329,23 @@ export function VoiceProviderSetupModal({ moreIcon1={SvgArrowExchange} moreIcon2={SvgOnyxLogo} title={ - isEditing - ? t("setupModal.editHeader.title", { - provider: detail.label, - }) - : t("setupModal.createHeader.title", { + providerType === "zoom" + ? tDecision("zoom.title") + : isEditing + ? t("setupModal.editHeader.title", { + provider: detail.label, + }) + : t("setupModal.createHeader.title", { + provider: detail.label, + }) + } + description={ + providerType === "zoom" + ? tDecision("zoom.description") + : t("setupModal.header.description", { provider: detail.label, }) } - description={t("setupModal.header.description", { - provider: detail.label, - })} onClose={onClose} /> @@ -354,6 +380,7 @@ export function VoiceProviderSetupModal({ > @@ -372,6 +399,7 @@ export function VoiceProviderSetupModal({ >