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