Skip to content

feat(#3674): Perplexity connector + web-grounded search tooling (epic)#164

Merged
Skobeltsyn merged 5 commits into
mainfrom
feat/3674-perplexity-connector
Jun 8, 2026
Merged

feat(#3674): Perplexity connector + web-grounded search tooling (epic)#164
Skobeltsyn merged 5 commits into
mainfrom
feat/3674-perplexity-connector

Conversation

@Skobeltsyn
Copy link
Copy Markdown
Contributor

Perplexity connector + web-grounded search tooling (epic #3674)

Adds Perplexity as the seventh model provider and — the valuable part — a perplexitySearch tool so an agent on its own model (Claude/OpenAI/Ollama/…) can fetch live, cited facts. Grounded in the Perplexity cookbook + API reference. Additive only; no public-API change to existing surfaces.

Slices

  • #3675 — PerplexityClient (03ad2be): thin OpenAiClient subclass for api.perplexity.ai (mirrors DeepSeek/Kimi/OpenRouter), model { perplexity("sonar") }. Unlike Kimi/DeepSeek, Perplexity accepts response_format json_schema, so constrained decoding stays on. Wired through ModelProvider, ModelConfig, ModelBuilder, the factory, and the permission manifest.
  • #3676 — perplexitySearch tool (7dedc86): ToolDef with untrustedOutput = true, so results are wrapped in the {"trusted":false} envelope (#642). Renders the answer + a numbered source list parsed from search_results[] (fallback citations[]); sources reach the model context and the JSONL audit row. Injectable PerplexitySearchBackend seam.
  • #3677 — controls + structured output (33562ac): perplexitySearchOptions { }search_mode (web/academic/sec), search_recency_filter, search_domain_filter (allow + -deny), web_search_options.search_context_size, reasoning_effort, and native response_format json_schema via structuredOutput(@Generable).
  • #3678 — docs (7349e46): README + docs/providers.md + CHANGELOG + two internals-agent adjuncts. Also split the tool module into one-type-per-file (#3199; allowlist stays empty).

Tests

Unit: PerplexityClientTest (6) + PerplexityModelDslTest (5) + PerplexitySearchTest (12) + PerplexitySearchControlsTest (9) — parse / fallback / render / untrusted-envelope / every control's wire param / builder round-trip / factory resolution. Full ./gradlew build green across all modules (detekt + all gradle guards + adjunct frontmatter).

Live verification

Live tests are tagged live-llm (excluded from the merge gate). A live run confirmed the request reaches api.perplexity.ai and the error-envelope path parses correctly — the local .secrets/perplexity-key currently returns "Invalid API key", so live tags stay live-llm (documented); flip to live-cloud-api once a valid key lands, same precedent as Kimi.

Related: WebTools epic #2677 (crawl & render) — Perplexity is the grounded-answer/search surface, distinct.

🤖 Generated with Claude Code

Skobeltsyn and others added 5 commits June 7, 2026 23:59
Thin OpenAiClient subclass for api.perplexity.ai (mirrors KimiClient/
DeepSeekClient): providerName "perplexity", DEFAULT_BASE_URL, and a
model { perplexity("sonar") } DSL slot. Unlike Kimi/DeepSeek, Perplexity
accepts OpenAI's response_format.json_schema, so constrained decoding is
left ON (inherited).

Wires ModelProvider.PERPLEXITY through ModelConfig (perplexityBaseUrl),
ModelBuilder (slot + build() error pointing at .secrets/perplexity-key),
ModelClientFactory (dispatch + semconv name), and PermissionManifest
(baseUrl + manifestName). Slice 1 of epic #3674.

Tests: PerplexityClientTest (parse/identity/error/headers/constrained-
decoding ON), PerplexityModelDslTest (DSL + factory resolution),
gated live PerplexityClientIntegrationTest, plus a Perplexity branch in
SessionCancellationTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A `perplexity_search` ToolDef (untrustedOutput=true) so an agent on its
OWN model can fetch grounded, cited facts from Perplexity's Sonar API.
Result renders the answer + a numbered source list (sources land in the
model context AND the JSONL audit row); the agentic loop wraps it in the
{trusted:false} envelope (#642) since web output is the canonical
injection vector.

- PerplexitySearch.kt: PerplexitySearchArgs (@generable), PerplexitySource,
  PerplexitySearchResult (render), PerplexitySearchOptions, a
  PerplexitySearchBackend seam + HttpPerplexitySearchBackend default, and
  pure buildPerplexitySearchBody / parsePerplexitySearchResponse
  (search_results[] preferred, citations[] fallback, error envelope ->
  PerplexitySearchException). perplexitySearchTool(apiKey) factory returns
  ERROR strings on blank query / backend failure (loop convention).
- Register via `tools { +perplexitySearchTool(key) }`. Slice 2 of #3674.

Tests: PerplexitySearchTest (12 — body/parse/fallback/render/tool/envelope),
gated live PerplexitySearchLiveTest. Live run confirmed the request reaches
api.perplexity.ai and the error path parses correctly (local key currently
invalid -> live tests stay `live-llm`, documented; flip to live-cloud-api
once a valid key lands). Also trims an over-length factory error line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends PerplexitySearchOptions with the documented Perplexity controls,
all omitted when unset (a bare options object reproduces the slice-2 body):
- search_mode (SearchMode: WEB/ACADEMIC/SEC)
- search_recency_filter (SearchRecency: HOUR..YEAR)
- search_domain_filter (allow plain + deny with a leading '-')
- web_search_options.search_context_size (SearchContextSize: LOW/MED/HIGH)
- reasoning_effort (reuses ReasoningEffort LOW/MED/HIGH; API 'minimal' n/a)
- response_format json_schema via structuredOutput(KClass) — maps a
  @generable type to a strict schema (reuses generation.jsonSchema()).

Adds a perplexitySearchOptions { } builder DSL (allowDomains/denyDomains/
structuredOutput). buildPerplexitySearchBody now assembles optional fields.
Slice 3 of #3674.

Tests: PerplexitySearchControlsTest (9) pins each control's exact wire
param + the builder round-trip + non-@generable rejection. Controls are
verified by serialization unit tests; live confirmation stays blocked on
the invalid local key (same as slices 1-2). detekt + compile green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er-file split (#3199)

Docs (slice 4 of #3674):
- README: provider count 6 -> 7 in both spots; add the `model { perplexity("sonar") }`
  slot and a `perplexitySearch` web-grounded-search feature bullet.
- docs/providers.md: intro enum list (seven values; Perplexity extends the OpenAI
  wire shape, constrained-decoding ON) + a "Web-grounded search tool" section with
  register/controls examples.
- CHANGELOG [Unreleased]: the epic entry (connector #3675, tool #3676, controls #3677).
- internals-agent adjuncts: model/PerplexityClient.md + model/PerplexitySearch.md
  (auto-discovered by the InternalsAgent scan).

Refactor (#3199): split the original multi-type PerplexitySearch.kt into one type
per file (Args/Source/Result/Options/OptionsBuilder/Backend/HttpBackend/Exception +
SearchMode/Recency/ContextSize), keeping only the pure wire helpers + the
perplexitySearchTool factory in PerplexitySearch.kt. The checkOneTypePerFile
allowlist is empty (burndown complete) — not regressed.

Full `./gradlew build` green (all modules, detekt, guards, adjunct frontmatter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… seam

Live verification with a valid key surfaced the bug: OpenAiClient hardcoded
`/v1/chat/completions`. Perplexity serves `/chat/completions` (no `/v1`) —
hitting `/v1` there returns HTTP 404 with an empty body, which sendBounded
passed through, so the connector returned blank text. (The perplexitySearch
tool was unaffected — its backend already used the correct path.)

- OpenAiClient: extract a `protected open val chatCompletionsPath`
  (default `/v1/chat/completions`), used by both sendChat + sendChatStream.
  Unchanged for OpenAI / DeepSeek / Kimi / OpenRouter.
- PerplexityClient: override to `/chat/completions`.
- Regression test pins both paths.

Verified end-to-end against api.perplexity.ai: connector chat + streaming
return grounded text + usage; perplexitySearch returns answer + real
citations. Live tests re-tagged `live-llm` -> `live-cloud-api` (DeepSeek
parity: run in default :test when a key is present, skip otherwise) and the
stale "invalid key" notes in tests/docs/CHANGELOG updated. Full build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Skobeltsyn Skobeltsyn merged commit 39741b4 into main Jun 8, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant