feat(llm): share Zoom setup across Voice and Decisions (#16280)

## 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.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

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
This commit is contained in:
roshan authored and GitHub committed 2026-10-22 01:02:08 +00:00
1 parent a0c949df51
commit fcc0cae05a
59 files changed
+1848 -168

No files matched your search

+2 -1
View File
@@ -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 |
@@ -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.
+26 -5
View File
@@ -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
@@ -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")
+18
View File
@@ -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:
+6
View File
@@ -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(
+100 -3
View File
@@ -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()
+3
View File
@@ -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",
+26 -1
View File
@@ -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)
+61
View File
@@ -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
),
)
+13
View File
@@ -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
+4 -3
View File
@@ -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(
+1 -1
View File
@@ -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
@@ -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": {
@@ -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(),
+134 -9
View File
@@ -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,
+4
View File
@@ -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,
+86 -25
View File
@@ -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,
+3 -1
View File
@@ -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.",
@@ -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",
@@ -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(
@@ -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,
)
@@ -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,
)
@@ -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",
+1 -1
View File
@@ -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)
@@ -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(
@@ -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",
}
+12 -2
View File
@@ -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:
+3 -1
View File
@@ -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
+10 -5
View File
@@ -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"):
@@ -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(
@@ -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()
@@ -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,
@@ -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,
+9 -2
View File
@@ -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 مطلوب"
+9 -2
View File
@@ -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"
+9 -2
View File
@@ -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"
+9 -2
View File
@@ -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"
+9 -2
View File
@@ -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"
+9 -2
View File
@@ -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 シークレットは必須です"
+9 -2
View File
@@ -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 시크릿이 필요합니다"
+9 -2
View File
@@ -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"
+9 -2
View File
@@ -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 机密为必填项"
@@ -6,6 +6,7 @@ jest.mock("swr", () => ({
default: () => ({
data: [
{ name: "typesafe", known_models: [] },
{ name: "zoom_ai", known_models: [] },
{ name: "openai", known_models: [] },
],
error: undefined,
+10
View File
@@ -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<string, ProviderEntry> = {
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<string, IconFunctionComponent> = {
[LLMProviderName.TYPESAFE]: SvgTypeSafe,
[LLMProviderName.ZOOM_AI]: SvgZoom,
[LLMProviderName.OPENAI]: SvgOpenAI,
[LLMProviderName.ANTHROPIC]: SvgClaude,
[LLMProviderName.OLLAMA_CHAT]: SvgOllama,
+2
View File
@@ -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,
+4
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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;
+3 -3
View File
@@ -132,10 +132,10 @@ export const VOICE_PROVIDER_DETAILS: Record<string, VoiceProviderDetail> = {
},
},
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: [
@@ -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<number | null>(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 (
<VoiceProviderSetupModal
providerType="zoom"
existingProvider={existingProvider ?? null}
mode="connection"
onClose={close}
onSuccess={() => {
void onSuccess?.();
close();
}}
/>
);
}
return (
<Modal open onOpenChange={close}>
<Modal.Content width="sm">
<Modal.Header
icon={SvgZoom}
title={t("zoom.title")}
description={t("zoom.description")}
onClose={close}
/>
<Modal.Body>
{isLoading ? (
<OnyxLoader size={24} />
) : error || linkedId ? (
<MessageCard variant="error" title={t("loadFailed")} />
) : (
<InputVertical title={t("zoom.connection")}>
<InputSingleSelect
value={selectedId === null ? "" : String(selectedId)}
onValueChange={(value) => setSelectedId(Number(value))}
placeholder={t("zoom.selectConnection")}
options={zoomProviders.map((provider) => ({
value: String(provider.id),
title: provider.name,
}))}
/>
</InputVertical>
)}
</Modal.Body>
</Modal.Content>
</Modal>
);
}
+172 -26
View File
@@ -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(<DecisionModelsPage />);
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(<DecisionModelsPage />);
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(<DecisionModelsPage />);
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(<DecisionModelsPage />);
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(<DecisionModelsPage />);
@@ -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(<DecisionModelsPage />);
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" })
+17 -8
View File
@@ -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) && (
<DecisionProviderModal
providerName={providerName}
existingLlmProvider={fullProvider ?? undefined}
onOpenChange={setOpen}
onSuccess={() => refreshLlmProviderCaches(mutate)}
/>
)}
{open &&
(!provider || fullProvider) &&
(providerName === "zoom_ai" ? (
<ZoomDecisionModal
existingLlmProvider={fullProvider ?? undefined}
onOpenChange={setOpen}
onSuccess={() => refreshLlmProviderCaches(mutate)}
/>
) : (
<DecisionProviderModal
providerName={providerName}
existingLlmProvider={fullProvider ?? undefined}
onOpenChange={setOpen}
onSuccess={() => refreshLlmProviderCaches(mutate)}
/>
))}
</Section>
</Card>
{provider && deleteModal.isOpen && (
@@ -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(
<VoiceProviderSetupModal
providerType="zoom"
existingProvider={null}
mode="stt"
onSuccess={onSuccess}
/>
);
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);
});
+65 -23
View File
@@ -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}
/>
<Modal.Body>
@@ -354,6 +380,7 @@ export function VoiceProviderSetupModal({
>
<PasswordInputTypeInField
name="api_key"
aria-label={t("setupModal.apiKey.label")}
placeholder={t("setupModal.apiKey.placeholder")}
existingValue={existingProvider?.api_key ?? undefined}
/>
@@ -372,6 +399,7 @@ export function VoiceProviderSetupModal({
>
<PasswordInputTypeInField
name="api_secret"
aria-label={t("setupModal.apiSecret.label")}
placeholder={t("setupModal.apiSecret.placeholder")}
existingValue={
existingProvider?.api_secret ?? undefined
@@ -491,7 +519,11 @@ export function VoiceProviderSetupModal({
</Button>
<Button
type="submit"
disabled={isSubmitting || !isValid || !dirty}
disabled={
isSubmitting ||
!isValid ||
(!dirty && mode !== "connection")
}
icon={isSubmitting ? IconLoader : undefined}
>
{isEditing
@@ -527,6 +559,8 @@ export function VoiceDisconnectModal({
onSuccess,
}: VoiceDisconnectModalProps) {
const t = useTranslations("admin.voice");
const tDecision = useTranslations("admin.decisionModels");
const { mutate } = useSWRConfig();
const onClose = useModalClose();
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -547,6 +581,10 @@ export function VoiceDisconnectModal({
label: disconnectTarget.providerLabel,
})
);
await Promise.all([
mutate(SWR_KEYS.voiceProviders),
refreshLlmProviderCaches(mutate),
]);
onSuccess();
onClose?.();
} catch (err) {
@@ -564,7 +602,11 @@ export function VoiceDisconnectModal({
title={t("disconnectModal.header.title", {
label: disconnectTarget.providerLabel,
})}
description={t("disconnectModal.header.description")}
description={
disconnectTarget.providerType === "zoom"
? tDecision("zoom.removeDescription")
: t("disconnectModal.header.description")
}
submit={
<Button
variant="danger"
@@ -8,3 +8,11 @@ test("Decision Models has its own admin page and keeps selection opt-in", async
await decisionModels.openFromChatPreferences();
await decisionModels.expectUnsetModel();
});
test("Zoom setup provides shared credentials directly on Decision Models", async ({
page,
}) => {
const decisionModels = new AdminDecisionModelsPage(page);
await decisionModels.openFromChatPreferences();
await decisionModels.openZoomSetup();
});
@@ -110,7 +110,7 @@ test.describe("Zoom Scribe voice provider", () => {
custom_config: { language: "fr-FR" },
});
expect(capturedUpserts[0]).toMatchObject({
name: "Zoom Scribe",
name: "Zoom AI Services",
provider_type: "zoom",
api_key: "zoom-raw-key",
api_secret: "zoom-raw-secret",
@@ -30,4 +30,23 @@ export class AdminDecisionModelsPage {
this.page.getByText("Providers", { exact: true })
).toBeVisible();
}
async openZoomSetup(): Promise<void> {
await this.page
.getByTestId(/^decision-provider-zoom_ai-/)
.getByRole("button", { name: /Connect|Configure/ })
.click();
const dialog = this.page.getByRole("dialog");
await expect(
dialog.getByText("Connect Zoom AI Services", { exact: true })
).toBeVisible();
await expect(dialog.getByLabel("API Key")).toBeVisible();
await expect(dialog.getByLabel("API Secret")).toBeVisible();
await expect(dialog).toContainText(
"Use one connection for Zoom Scribe and Zoom AI Decisions. Changes apply to both."
);
await expect(
dialog.getByRole("link", { name: "Set up Voice" })
).toHaveCount(0);
}
}
+8 -2
View File
@@ -57,7 +57,10 @@ export class AdminVoicePage {
async openZoomSetupModal(): Promise<void> {
await this.zoomSttCard.getByRole("button", { name: "Connect" }).click();
await expect(this.modal).toBeVisible();
await expect(this.modal).toContainText("Set up Zoom Scribe");
await expect(this.modal).toContainText("Connect Zoom AI Services");
await expect(
this.modal.getByRole("button", { name: "Connect" })
).toBeVisible();
}
async openZoomEditModal(): Promise<void> {
@@ -66,7 +69,10 @@ export class AdminVoicePage {
.getByRole("button", { name: "Edit Zoom Scribe" })
.click();
await expect(this.modal).toBeVisible();
await expect(this.modal).toContainText("Configure Zoom Scribe");
await expect(this.modal).toContainText("Connect Zoom AI Services");
await expect(
this.modal.getByRole("button", { name: "Update" })
).toBeVisible();
}
async expectZoomSetupFields(): Promise<void> {