Conversation
Greptile OverviewGreptile SummaryThis PR adds Exa as a web content provider (crawler) alongside its existing search provider functionality, implementing bidirectional synchronization between the two provider types to share the same configuration. Key Implementation DetailsBackend Changes:
Frontend Changes:
Critical Issues Found
Impact AssessmentThe sync mechanism works correctly for the standard flow where users explicitly update configuration through the UI. However, edge cases around testing and virtual provider activation are broken, preventing users from fully utilizing the shared Exa functionality. Confidence Score: 2/5
Important Files ChangedFile Analysis
Sequence DiagramsequenceDiagram
participant UI as Admin UI
participant API as FastAPI Backend
participant DB as PostgreSQL Database
Note over UI,DB: Update Exa Search Provider Flow
UI->>API: POST /admin/web-search/search-providers<br/>(provider_type=EXA)
API->>DB: upsert_web_search_provider()
DB-->>API: Exa search provider saved
Note over API: Sync logic: if Exa
API->>DB: fetch_web_content_provider_by_type(EXA)
alt Content provider exists
API->>DB: Update content provider
else Content provider doesn't exist
API->>DB: INSERT content provider (name="Exa", type=EXA)
end
API->>DB: COMMIT transaction
API-->>UI: Success response
UI->>API: GET /admin/web-search/content-providers (refresh)
Note over UI,DB: Update Exa Content Provider Flow
UI->>API: POST /admin/web-search/content-providers<br/>(provider_type=EXA)
API->>DB: upsert_web_content_provider()
DB-->>API: Exa content provider saved
Note over API: Sync logic: if Exa
API->>DB: fetch_web_search_provider_by_type(EXA)
alt Search provider exists
API->>DB: Update search provider
else Search provider doesn't exist
API->>DB: INSERT search provider (name="Exa", type=EXA)
end
API->>DB: COMMIT transaction
API-->>UI: Success response
UI->>API: GET /admin/web-search/search-providers (refresh)
Note over UI,DB: Test Exa Content Provider Issue
UI->>API: POST /admin/web-search/content-providers/test<br/>(provider_type=EXA, use_stored=true)
API->>DB: fetch_web_content_provider_by_type(EXA)
alt Content provider found
DB-->>API: Return provider
API->>API: Build ExaClient and test
else Not found (BUG)
API-->>UI: ERROR: No data found<br/>(Missing fallback to search provider)
end
|
Additional Comments (1)
Prompt To Fix With AIThis is a comment left during a code review.
Path: backend/onyx/tools/tool_implementations/web_search/clients/exa_client.py
Line: 103:117
Comment:
The `WebContent` objects returned by the `contents` method don't explicitly set the `scrape_successful` field. While it defaults to `True` in the model, Exa can return partial/empty content entries. The code should set `scrape_successful` based on whether actual content was retrieved to maintain consistency with other content providers.
```suggestion
contents: list[WebContent] = []
for result in response.results:
title = (result.title or "").strip()
full_content = (result.text or "").strip()
contents.append(
WebContent(
title=title,
link=result.url,
full_content=full_content,
published_date=(
time_str_to_utc(result.published_date)
if result.published_date
else None
),
scrape_successful=bool(title or full_content),
)
)
return contents
```
How can I resolve this? If you propose a fix, please make it concise. |
There was a problem hiding this comment.
2 issues found across 7 files
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="backend/onyx/server/manage/web_search/api.py">
<violation number="1" location="backend/onyx/server/manage/web_search/api.py:105">
P2: Check-then-insert pattern creates TOCTOU race condition. If concurrent requests both check and find no Exa content provider, both will attempt to insert, causing a unique constraint violation on the `name` field. Consider using SQLAlchemy's `insert().on_conflict_do_update()` for atomic upsert.
(Based on your team's feedback about preferring atomic on-conflict inserts over check-then-insert to avoid TOCTOU race conditions.) [FEEDBACK_USED]</violation>
<violation number="2" location="backend/onyx/server/manage/web_search/api.py:277">
P2: Check-then-insert pattern creates TOCTOU race condition. If concurrent requests both check and find no Exa search provider, both will attempt to insert, causing a unique constraint violation on the `name` field. Consider using SQLAlchemy's `insert().on_conflict_do_update()` for atomic upsert.
(Based on your team's feedback about preferring atomic on-conflict inserts over check-then-insert to avoid TOCTOU race conditions.) [FEEDBACK_USED]</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
|
@greptile |
Additional Comments (1)
Solution: Move the Prompt To Fix With AIThis is a comment left during a code review.
Path: web/src/app/admin/configuration/web-search/page.tsx
Line: 141:165
Comment:
`hasSharedExaKey` is referenced before it's defined, causing the shared key logic to break. The variable is defined at line 320-329, but `openSearchModal` (lines 141-165) and `openContentModal` (lines 167-193) reference it before it's initialized. When these functions execute, `hasSharedExaKey` will be `undefined`, making `canUseSharedExaKey` always `false`, which breaks the shared API key feature.
**Solution:** Move the `hasSharedExaKey` calculation block (the code that finds `exaSearchProvider` and `exaContentProvider` and calculates `hasSharedExaKey`) from its current location after line 318 to immediately after line 137 (after `const isLoading = ...` and before the comment `// Modal form state is owned by reducers`). This ensures the variable is available when the modal functions are called.
How can I resolve this? If you propose a fix, please make it concise. |
|
@greptile |
| result = connection.execute( | ||
| text( | ||
| """ | ||
| SELECT api_key FROM internet_search_provider | ||
| WHERE provider_type = 'exa' AND api_key IS NOT NULL | ||
| LIMIT 1 | ||
| """ | ||
| ) | ||
| ) | ||
| row = result.fetchone() | ||
|
|
||
| if row: | ||
| api_key = row[0] | ||
| # Check if Exa content provider already exists | ||
| existing = connection.execute( | ||
| text( | ||
| """ | ||
| SELECT id FROM internet_content_provider | ||
| WHERE provider_type = 'exa' | ||
| LIMIT 1 | ||
| """ | ||
| ) | ||
| ) | ||
| if existing.fetchone() is None: | ||
| # Create Exa content provider with the shared key | ||
| connection.execute( | ||
| text( | ||
| """ | ||
| INSERT INTO internet_content_provider | ||
| (name, provider_type, api_key, is_active) | ||
| VALUES ('Exa', 'exa', :api_key, false) | ||
| """ | ||
| ), | ||
| {"api_key": api_key}, | ||
| ) |
There was a problem hiding this comment.
The migration assumes the Exa search provider is named "Exa" when creating the content provider, but existing installations might have Exa search providers with custom names. This creates inconsistency:
Issue: If an existing Exa search provider is named something other than "Exa" (e.g., "Production Exa", "Team Exa"), the migration creates a content provider named "Exa" that is separate from the search provider with the custom name.
Impact: After migration, users will have mismatched providers:
- Search provider: "Custom Name" with API key
- Content provider: "Exa" with the same API key
- These appear as two separate providers in the UI and database
Note: While the migration uses LIMIT 1 which could grab any Exa provider, the real issue is the hardcoded "Exa" name doesn't match custom-named search providers.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/alembic/versions/d1b637d7050a_sync_exa_api_key_to_content_provider.py
Line: 27:61
Comment:
The migration assumes the Exa search provider is named "Exa" when creating the content provider, but existing installations might have Exa search providers with custom names. This creates inconsistency:
**Issue:** If an existing Exa search provider is named something other than "Exa" (e.g., "Production Exa", "Team Exa"), the migration creates a content provider named "Exa" that is separate from the search provider with the custom name.
**Impact:** After migration, users will have mismatched providers:
- Search provider: "Custom Name" with API key
- Content provider: "Exa" with the same API key
- These appear as two separate providers in the UI and database
**Note:** While the migration uses `LIMIT 1` which could grab any Exa provider, the real issue is the hardcoded "Exa" name doesn't match custom-named search providers.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
It can't be all the search engine names are hardcoded include Exa
Additional Comments (1)
Prompt To Fix With AIThis is a comment left during a code review.
Path: backend/onyx/server/manage/web_search/api.py
Line: 372:381
Comment:
[P0] Missing Exa fallback lookup in test endpoint. When testing Exa content provider before saving to DB (virtual provider scenario), this only queries fetch_web_content_provider_by_type which returns None. Should also check fetch_web_search_provider_by_type for Exa since both share same value. Without this, users cannot activate virtual Exa content provider when only search provider exists in DB.
How can I resolve this? If you propose a fix, please make it concise. |
There was a problem hiding this comment.
2 issues found across 1 file (changes from recent commits).
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="backend/onyx/server/manage/web_search/api.py">
<violation number="1" location="backend/onyx/server/manage/web_search/api.py:106">
P1: This check-then-insert pattern introduces a TOCTOU race condition. If two concurrent requests both find no existing content provider, both will attempt to insert, causing an IntegrityError. The previous atomic `on_conflict_do_update` pattern was correct and should be retained.
(Based on your team's feedback about preferring atomic on-conflict inserts to avoid TOCTOU race conditions.) [FEEDBACK_USED]</violation>
<violation number="2" location="backend/onyx/server/manage/web_search/api.py:278">
P1: This check-then-insert pattern introduces a TOCTOU race condition. If two concurrent requests both find no existing search provider, both will attempt to insert, causing an IntegrityError. The previous atomic `on_conflict_do_update` pattern was correct and should be retained.
(Based on your team's feedback about preferring atomic on-conflict inserts to avoid TOCTOU race conditions.) [FEEDBACK_USED]</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| existing_search_provider = fetch_web_search_provider_by_type( | ||
| WebSearchProviderType.EXA, db_session | ||
| ) | ||
| if existing_search_provider: | ||
| existing_search_provider.api_key = request.api_key | ||
| else: | ||
| stmt = insert(InternetSearchProvider).values( | ||
| name="Exa", | ||
| provider_type=WebSearchProviderType.EXA.value, | ||
| api_key=request.api_key, | ||
| is_active=False, |
There was a problem hiding this comment.
P1: This check-then-insert pattern introduces a TOCTOU race condition. If two concurrent requests both find no existing search provider, both will attempt to insert, causing an IntegrityError. The previous atomic on_conflict_do_update pattern was correct and should be retained.
(Based on your team's feedback about preferring atomic on-conflict inserts to avoid TOCTOU race conditions.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/onyx/server/manage/web_search/api.py, line 278:
<comment>This check-then-insert pattern introduces a TOCTOU race condition. If two concurrent requests both find no existing search provider, both will attempt to insert, causing an IntegrityError. The previous atomic `on_conflict_do_update` pattern was correct and should be retained.
(Based on your team's feedback about preferring atomic on-conflict inserts to avoid TOCTOU race conditions.) </comment>
<file context>
@@ -270,26 +269,25 @@ def upsert_content_provider_endpoint(
- stmt = (
- insert(InternetSearchProvider)
- .values(
+ existing_search_provider = fetch_web_search_provider_by_type(
+ WebSearchProviderType.EXA, db_session
+ )
</file context>
| existing_search_provider = fetch_web_search_provider_by_type( | |
| WebSearchProviderType.EXA, db_session | |
| ) | |
| if existing_search_provider: | |
| existing_search_provider.api_key = request.api_key | |
| else: | |
| stmt = insert(InternetSearchProvider).values( | |
| name="Exa", | |
| provider_type=WebSearchProviderType.EXA.value, | |
| api_key=request.api_key, | |
| is_active=False, | |
| stmt = ( | |
| insert(InternetSearchProvider) | |
| .values( | |
| name="Exa", | |
| provider_type=WebSearchProviderType.EXA.value, | |
| api_key=request.api_key, | |
| is_active=False, | |
| ) | |
| .on_conflict_do_update( | |
| index_elements=["provider_type"], | |
| set_={"api_key": request.api_key}, | |
| ) | |
| ) | |
| db_session.execute(stmt) |
✅ Addressed in a6fbc36
| existing_content_provider = fetch_web_content_provider_by_type( | ||
| WebContentProviderType.EXA, db_session | ||
| ) | ||
| if existing_content_provider: | ||
| existing_content_provider.api_key = request.api_key | ||
| else: | ||
| stmt = insert(InternetContentProvider).values( | ||
| name="Exa", | ||
| provider_type=WebContentProviderType.EXA.value, | ||
| api_key=request.api_key, | ||
| is_active=False, |
There was a problem hiding this comment.
P1: This check-then-insert pattern introduces a TOCTOU race condition. If two concurrent requests both find no existing content provider, both will attempt to insert, causing an IntegrityError. The previous atomic on_conflict_do_update pattern was correct and should be retained.
(Based on your team's feedback about preferring atomic on-conflict inserts to avoid TOCTOU race conditions.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/onyx/server/manage/web_search/api.py, line 106:
<comment>This check-then-insert pattern introduces a TOCTOU race condition. If two concurrent requests both find no existing content provider, both will attempt to insert, causing an IntegrityError. The previous atomic `on_conflict_do_update` pattern was correct and should be retained.
(Based on your team's feedback about preferring atomic on-conflict inserts to avoid TOCTOU race conditions.) </comment>
<file context>
@@ -97,26 +97,25 @@ def upsert_search_provider_endpoint(
- stmt = (
- insert(InternetContentProvider)
- .values(
+ existing_content_provider = fetch_web_content_provider_by_type(
+ WebContentProviderType.EXA, db_session
+ )
</file context>
| existing_content_provider = fetch_web_content_provider_by_type( | |
| WebContentProviderType.EXA, db_session | |
| ) | |
| if existing_content_provider: | |
| existing_content_provider.api_key = request.api_key | |
| else: | |
| stmt = insert(InternetContentProvider).values( | |
| name="Exa", | |
| provider_type=WebContentProviderType.EXA.value, | |
| api_key=request.api_key, | |
| is_active=False, | |
| stmt = ( | |
| insert(InternetContentProvider) | |
| .values( | |
| name="Exa", | |
| provider_type=WebContentProviderType.EXA.value, | |
| api_key=request.api_key, | |
| is_active=False, | |
| ) | |
| .on_conflict_do_update( | |
| index_elements=["provider_type"], | |
| set_={"api_key": request.api_key}, | |
| ) | |
| ) | |
| db_session.execute(stmt) |
✅ Addressed in a6fbc36
| existing_content_provider = fetch_web_content_provider_by_type( | ||
| WebContentProviderType.EXA, db_session | ||
| ) | ||
| if existing_content_provider: | ||
| existing_content_provider.api_key = request.api_key | ||
| else: | ||
| stmt = insert(InternetContentProvider).values( | ||
| name="Exa", | ||
| provider_type=WebContentProviderType.EXA.value, | ||
| api_key=request.api_key, | ||
| is_active=False, | ||
| ) | ||
| db_session.execute(stmt) |
There was a problem hiding this comment.
The sync logic queries by provider_type but inserts by name, which can cause a unique constraint violation. The code uses fetch_web_content_provider_by_type to check if an Exa content provider exists (filtering by provider_type='exa'). If it doesn't exist, it performs a plain INSERT with name='Exa'. However, if another content provider with name='Exa' but a different provider_type already exists in the database, this INSERT will fail with a unique constraint violation on the name column.
While unlikely in normal usage, this edge case should be handled by either: (1) using ON CONFLICT DO UPDATE on the name column, or (2) also checking for existence by name before inserting.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/onyx/server/manage/web_search/api.py
Line: 106:118
Comment:
The sync logic queries by provider_type but inserts by name, which can cause a unique constraint violation. The code uses fetch_web_content_provider_by_type to check if an Exa content provider exists (filtering by provider_type='exa'). If it doesn't exist, it performs a plain INSERT with name='Exa'. However, if another content provider with name='Exa' but a different provider_type already exists in the database, this INSERT will fail with a unique constraint violation on the name column.
While unlikely in normal usage, this edge case should be handled by either: (1) using ON CONFLICT DO UPDATE on the name column, or (2) also checking for existence by name before inserting.
How can I resolve this? If you propose a fix, please make it concise.| existing_search_provider = fetch_web_search_provider_by_type( | ||
| WebSearchProviderType.EXA, db_session | ||
| ) | ||
| if existing_search_provider: | ||
| existing_search_provider.api_key = request.api_key | ||
| else: | ||
| stmt = insert(InternetSearchProvider).values( | ||
| name="Exa", | ||
| provider_type=WebSearchProviderType.EXA.value, | ||
| api_key=request.api_key, | ||
| is_active=False, | ||
| ) | ||
| db_session.execute(stmt) |
There was a problem hiding this comment.
Same unique constraint violation issue as in the search-to-content sync above. The code queries by provider_type but inserts by name='Exa', which will fail if another search provider with name='Exa' but different provider_type already exists. This should use ON CONFLICT DO UPDATE or check by name before inserting to handle this edge case.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/onyx/server/manage/web_search/api.py
Line: 278:290
Comment:
Same unique constraint violation issue as in the search-to-content sync above. The code queries by provider_type but inserts by name='Exa', which will fail if another search provider with name='Exa' but different provider_type already exists. This should use ON CONFLICT DO UPDATE or check by name before inserting to handle this edge case.
How can I resolve this? If you propose a fix, please make it concise.| if existing.fetchone() is None: | ||
| # Create Exa content provider with the shared key | ||
| connection.execute( | ||
| text( | ||
| """ | ||
| INSERT INTO internet_content_provider | ||
| (name, provider_type, api_key, is_active) | ||
| VALUES ('Exa', 'exa', :api_key, false) | ||
| """ | ||
| ), | ||
| {"api_key": api_key}, | ||
| ) |
There was a problem hiding this comment.
The migration checks for existence by provider_type but inserts by name, which can cause the migration to fail. The code checks if a content provider with provider_type='exa' exists (line 44-49), and if not, it INSERTs with name='Exa' (line 55-57). However, if a content provider with name='Exa' but a different provider_type already exists, this INSERT will fail with a unique constraint violation on the name column.
While this is an edge case, migrations should be defensive and handle all scenarios. The fix would be to use INSERT ... ON CONFLICT DO NOTHING or check for both provider_type='exa' OR name='Exa' before attempting the insert.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/alembic/versions/d1b637d7050a_sync_exa_api_key_to_content_provider.py
Line: 50:61
Comment:
The migration checks for existence by provider_type but inserts by name, which can cause the migration to fail. The code checks if a content provider with provider_type='exa' exists (line 44-49), and if not, it INSERTs with name='Exa' (line 55-57). However, if a content provider with name='Exa' but a different provider_type already exists, this INSERT will fail with a unique constraint violation on the name column.
While this is an edge case, migrations should be defensive and handle all scenarios. The fix would be to use INSERT ... ON CONFLICT DO NOTHING or check for both provider_type='exa' OR name='Exa' before attempting the insert.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
I think this is fine to do, we can just add ON CONFLICT DO NOTHING
Additional Comments (2)
This breaks the workflow where a user has configured Exa as a search provider and wants to test it as a content provider before saving to the database. Prompt To Fix With AIThis is a comment left during a code review.
Path: backend/onyx/server/manage/web_search/api.py
Line: 370:379
Comment:
The test endpoint doesn't check the search provider for the shared Exa credentials. When testing an Exa content provider with use_stored_key set to true, if no Exa content provider exists in the DB yet, the code raises an error about no stored credentials found. However, since Exa search and content providers share the same credentials, the test endpoint should also check fetch_web_search_provider_by_type as a fallback when testing Exa content provider.
This breaks the workflow where a user has configured Exa as a search provider and wants to test it as a content provider before saving to the database.
How can I resolve this? If you propose a fix, please make it concise.
The virtual Exa provider only exists when hasSharedExaKey is true (line 371), meaning an Exa search provider with credentials already exists. However, when activating this virtual provider, the credentials are not transferred to the new content provider record. The result is that the Exa content provider is created with no credentials, causing it to fail when actually used. The activation should either: (1) fetch and include the shared credentials from the search provider in the payload, or (2) trigger the backend sync logic by setting api_key_changed to true and passing the actual credentials. Prompt To Fix With AIThis is a comment left during a code review.
Path: web/src/app/admin/configuration/web-search/page.tsx
Line: 619:631
Comment:
When activating a virtual Exa content provider (id = -3), the code sends api_key null and api_key_changed false to the backend. This creates an Exa content provider in the database WITHOUT copying the shared credentials from the Exa search provider.
The virtual Exa provider only exists when hasSharedExaKey is true (line 371), meaning an Exa search provider with credentials already exists. However, when activating this virtual provider, the credentials are not transferred to the new content provider record.
The result is that the Exa content provider is created with no credentials, causing it to fail when actually used. The activation should either: (1) fetch and include the shared credentials from the search provider in the payload, or (2) trigger the backend sync logic by setting api_key_changed to true and passing the actual credentials.
How can I resolve this? If you propose a fix, please make it concise. |
89a3e2b to
a6fbc36
Compare
| if existing.fetchone() is None: | ||
| # Create Exa content provider with the shared key | ||
| connection.execute( | ||
| text( | ||
| """ | ||
| INSERT INTO internet_content_provider | ||
| (name, provider_type, api_key, is_active) | ||
| VALUES ('Exa', 'exa', :api_key, false) | ||
| """ | ||
| ), | ||
| {"api_key": api_key}, | ||
| ) |
There was a problem hiding this comment.
I think this is fine to do, we can just add ON CONFLICT DO NOTHING
| ) | ||
|
|
||
|
|
||
| def downgrade() -> None: |
There was a problem hiding this comment.
If it was impossible to have an exa provider before these code changes, I'd say we should delete it here. shouldn't matter in most cases but might save someone some work in the future
There was a problem hiding this comment.
1 issue found across 1 file (changes from recent commits).
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="backend/alembic/versions/d1b637d7050a_sync_exa_api_key_to_content_provider.py">
<violation number="1" location="backend/alembic/versions/d1b637d7050a_sync_exa_api_key_to_content_provider.py:60">
P1: The downgrade may delete data that wasn't created by this migration. Since the upgrade uses `ON CONFLICT (name) DO NOTHING`, it might not insert anything if an Exa provider already existed. However, the downgrade deletes ALL providers where `provider_type = 'exa'`, potentially removing pre-existing user data. Consider keeping the original `pass` for downgrade, or narrowing the delete to only remove records that this migration definitively created (e.g., by tracking what was inserted).</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| DELETE FROM internet_content_provider | ||
| WHERE provider_type = 'exa' | ||
| """ | ||
| ) | ||
| ) |
There was a problem hiding this comment.
P1: The downgrade may delete data that wasn't created by this migration. Since the upgrade uses ON CONFLICT (name) DO NOTHING, it might not insert anything if an Exa provider already existed. However, the downgrade deletes ALL providers where provider_type = 'exa', potentially removing pre-existing user data. Consider keeping the original pass for downgrade, or narrowing the delete to only remove records that this migration definitively created (e.g., by tracking what was inserted).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/alembic/versions/d1b637d7050a_sync_exa_api_key_to_content_provider.py, line 60:
<comment>The downgrade may delete data that wasn't created by this migration. Since the upgrade uses `ON CONFLICT (name) DO NOTHING`, it might not insert anything if an Exa provider already existed. However, the downgrade deletes ALL providers where `provider_type = 'exa'`, potentially removing pre-existing user data. Consider keeping the original `pass` for downgrade, or narrowing the delete to only remove records that this migration definitively created (e.g., by tracking what was inserted).</comment>
<file context>
@@ -37,29 +37,28 @@ def upgrade() -> None:
+ connection.execute(
+ text(
+ """
+ DELETE FROM internet_content_provider
+ WHERE provider_type = 'exa'
+ """
</file context>
Description
ENG-3369
How Has This Been Tested?
Exa as a crawler in UI^
Adding API Key for Exa Search Engine adds it for Exa Web Crawler as well
Additional Options
Summary by cubic
Add Exa as a web content provider (crawler) and sync its API key with the Exa search provider. Implements ENG-3369 to use a single Exa key for both search and crawling, with backend and UI support.
New Features
Migration
Written for commit 55914c2. Summary will update on new commits.