@effect-uai/exa 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Betalyra Sociedade Unipessoal Lda.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,49 @@
1
+ import { ExaCategory, ExaSearchType } from "./models.mjs";
2
+ import { Context, Effect, Layer, Redacted } from "effect";
3
+ import { HttpClient } from "effect/unstable/http";
4
+ import * as AiError from "@effect-uai/core/AiError";
5
+ import { CommonSearchRequest, SearchResponse, WebSearch } from "@effect-uai/core/WebSearch";
6
+
7
+ //#region src/ExaSearch.d.ts
8
+ declare namespace ExaSearch_d_exports {
9
+ export { Config, ExaSearch, ExaSearchRequest, ExaSearchService, layer, make };
10
+ }
11
+ /**
12
+ * Exa-typed search request. Widens {@link CommonSearchRequest} with Exa's
13
+ * own knobs; the common fields map onto Exa's wire filters (see the codec
14
+ * below). There is no `model` - `/search` ranks the web directly, with
15
+ * `type` choosing the retrieval strategy.
16
+ */
17
+ type ExaSearchRequest = CommonSearchRequest & {
18
+ /** Retrieval strategy - see {@link ExaSearchType}. Defaults to `auto`. */readonly type?: ExaSearchType; /** Content-vertical filter - see {@link ExaCategory}. */
19
+ readonly category?: ExaCategory;
20
+ };
21
+ type ExaSearchService = {
22
+ readonly search: (request: ExaSearchRequest) => Effect.Effect<SearchResponse, AiError.AiError>;
23
+ };
24
+ declare const ExaSearch_base: Context.ServiceClass<ExaSearch, "@betalyra/effect-uai/providers/exa/ExaSearch", ExaSearchService>;
25
+ /**
26
+ * Provider-typed service tag. Yield this for Exa-typed search (`type`,
27
+ * `category`, the per-result `score`); yield the generic `WebSearch` tag
28
+ * for provider-portable code. Both are registered by {@link layer}.
29
+ */
30
+ declare class ExaSearch extends ExaSearch_base {}
31
+ type Config = {
32
+ readonly apiKey: Redacted.Redacted;
33
+ readonly baseUrl?: string;
34
+ };
35
+ /**
36
+ * Build an `ExaSearchService` value. For Layer-based setup, prefer
37
+ * {@link layer}.
38
+ */
39
+ declare const make: (cfg: Config) => Effect.Effect<ExaSearchService, never, HttpClient.HttpClient>;
40
+ /**
41
+ * Layer registering both the provider-typed `ExaSearch` tag and the
42
+ * generic `WebSearch` tag over one implementation. A `CommonSearchRequest`
43
+ * is structurally an `ExaSearchRequest` with `type` / `category` unset, so
44
+ * the generic registration forwards directly.
45
+ */
46
+ declare const layer: (cfg: Config) => Layer.Layer<ExaSearch | WebSearch, never, HttpClient.HttpClient>;
47
+ //#endregion
48
+ export { Config, ExaSearch, ExaSearchRequest, ExaSearchService, layer, make, ExaSearch_d_exports as t };
49
+ //# sourceMappingURL=ExaSearch.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExaSearch.d.mts","names":[],"sources":["../src/ExaSearch.ts"],"mappings":";;;;;;;;;;;;;;;;KAwBY,gBAAA,GAAmB,mBAAA;qFAEpB,IAAA,GAAO,aAAA;WAEP,QAAA,GAAW,WAAA;AAAA;AAAA,KAGV,gBAAA;EAAA,SACD,MAAA,GAAS,OAAA,EAAS,gBAAA,KAAqB,MAAA,CAAO,MAAA,CAAO,cAAA,EAAgB,OAAA,CAAQ,OAAA;AAAA;AAAA,cACvF,cAAA;;;;;;cAOY,SAAA,SAAkB,cAAA;AAAA,KAInB,MAAA;EAAA,SACD,MAAA,EAAQ,QAAA,CAAS,QAAA;EAAA,SACjB,OAAA;AAAA;;;;;cA4KE,IAAA,GAAQ,GAAA,EAAK,MAAA,KAAS,MAAA,CAAO,MAAA,CAAO,gBAAA,SAAyB,UAAA,CAAW,UAAA;;;;;;;cAYxE,KAAA,GACX,GAAA,EAAK,MAAA,KACJ,KAAA,CAAM,KAAA,CAAM,SAAA,GAAY,SAAA,SAAkB,UAAA,CAAW,UAAA"}
@@ -0,0 +1,150 @@
1
+ import { t as __exportAll } from "./chunk-CfYAbeIz.mjs";
2
+ import { Context, DateTime, Effect, Layer, Option, Redacted, Schema } from "effect";
3
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http";
4
+ import * as AiError from "@effect-uai/core/AiError";
5
+ import * as Capabilities from "@effect-uai/core/Capabilities";
6
+ import { WebSearch } from "@effect-uai/core/WebSearch";
7
+ //#region src/ExaSearch.ts
8
+ var ExaSearch_exports = /* @__PURE__ */ __exportAll({
9
+ ExaSearch: () => ExaSearch,
10
+ layer: () => layer,
11
+ make: () => make
12
+ });
13
+ /**
14
+ * Provider-typed service tag. Yield this for Exa-typed search (`type`,
15
+ * `category`, the per-result `score`); yield the generic `WebSearch` tag
16
+ * for provider-portable code. Both are registered by {@link layer}.
17
+ */
18
+ var ExaSearch = class extends Context.Service()("@betalyra/effect-uai/providers/exa/ExaSearch") {};
19
+ const recencyDuration = {
20
+ hour: "1 hour",
21
+ day: "1 day",
22
+ week: "7 days",
23
+ month: "30 days",
24
+ year: "365 days"
25
+ };
26
+ const buildBody = (request, now) => {
27
+ const startPublishedDate = request.startDate !== void 0 ? DateTime.formatIso(request.startDate) : request.recency !== void 0 ? DateTime.formatIso(DateTime.subtractDuration(now, recencyDuration[request.recency])) : void 0;
28
+ return {
29
+ query: request.query,
30
+ ...request.maxResults !== void 0 && { numResults: request.maxResults },
31
+ ...request.type !== void 0 && { type: request.type },
32
+ ...request.category !== void 0 && { category: request.category },
33
+ ...request.includeDomains !== void 0 && { includeDomains: request.includeDomains },
34
+ ...request.excludeDomains !== void 0 && { excludeDomains: request.excludeDomains },
35
+ ...startPublishedDate !== void 0 && { startPublishedDate },
36
+ ...request.endDate !== void 0 && { endPublishedDate: DateTime.formatIso(request.endDate) },
37
+ ...request.country !== void 0 && { userLocation: request.country }
38
+ };
39
+ };
40
+ const WireResult = Schema.Struct({
41
+ id: Schema.optional(Schema.String),
42
+ url: Schema.String,
43
+ title: Schema.optional(Schema.NullOr(Schema.String)),
44
+ score: Schema.optional(Schema.Number),
45
+ publishedDate: Schema.optional(Schema.NullOr(Schema.String)),
46
+ author: Schema.optional(Schema.NullOr(Schema.String))
47
+ });
48
+ const WireCost = Schema.Struct({ total: Schema.optional(Schema.Number) });
49
+ const WireResponse = Schema.Struct({
50
+ requestId: Schema.optional(Schema.String),
51
+ results: Schema.Array(WireResult),
52
+ costDollars: Schema.optional(WireCost)
53
+ });
54
+ /** Lenient parse of Exa's ISO date strings; unparseable -> omitted. */
55
+ const parseDate = (s) => s == null ? void 0 : Option.getOrUndefined(DateTime.make(s));
56
+ const toResult = (r) => {
57
+ const publishedDate = parseDate(r.publishedDate);
58
+ return {
59
+ url: r.url,
60
+ ...r.title != null && { title: r.title },
61
+ ...r.score !== void 0 && { score: r.score },
62
+ ...publishedDate !== void 0 && { publishedDate },
63
+ raw: r
64
+ };
65
+ };
66
+ const toResponse = (wire) => ({
67
+ results: wire.results.map(toResult),
68
+ raw: wire
69
+ });
70
+ const transportFailure = (cause) => new AiError.Unavailable({
71
+ provider: "exa",
72
+ raw: cause
73
+ });
74
+ const httpStatusError = (status, body) => {
75
+ const provider = "exa";
76
+ const raw = body;
77
+ if (status === 429) return new AiError.RateLimited({
78
+ provider,
79
+ raw
80
+ });
81
+ if (status === 408 || status === 504) return new AiError.Timeout({
82
+ provider,
83
+ raw
84
+ });
85
+ if (status === 401) return new AiError.AuthFailed({
86
+ provider,
87
+ subtype: "auth",
88
+ raw
89
+ });
90
+ if (status === 403) return new AiError.AuthFailed({
91
+ provider,
92
+ subtype: "permission",
93
+ raw
94
+ });
95
+ if (status === 402) return new AiError.AuthFailed({
96
+ provider,
97
+ subtype: "billing",
98
+ raw
99
+ });
100
+ if (status >= 500) return new AiError.Unavailable({
101
+ provider,
102
+ status,
103
+ raw
104
+ });
105
+ return new AiError.InvalidRequest({
106
+ provider,
107
+ raw
108
+ });
109
+ };
110
+ const baseUrl = (cfg) => cfg.baseUrl ?? "https://api.exa.ai";
111
+ const postSearch = (cfg, body) => Effect.gen(function* () {
112
+ const client = yield* HttpClient.HttpClient;
113
+ const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/search`).pipe(HttpClientRequest.setHeader("x-api-key", Redacted.value(cfg.apiKey)), HttpClientRequest.bodyJsonUnsafe(body));
114
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure));
115
+ if (response.status >= 400) {
116
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""));
117
+ return yield* httpStatusError(response.status, text);
118
+ }
119
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure));
120
+ return yield* Schema.decodeUnknownEffect(WireResponse)(json).pipe(Effect.mapError(transportFailure));
121
+ });
122
+ const searchImpl = (cfg) => (request) => Effect.gen(function* () {
123
+ yield* Capabilities.warnDroppedWhen(request.language, {
124
+ provider: "exa",
125
+ capability: "language",
126
+ field: "language",
127
+ reason: "Exa /search has no language filter; the hint is ignored."
128
+ });
129
+ return toResponse(yield* postSearch(cfg, buildBody(request, yield* DateTime.now)));
130
+ });
131
+ /**
132
+ * Build an `ExaSearchService` value. For Layer-based setup, prefer
133
+ * {@link layer}.
134
+ */
135
+ const make = (cfg) => Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({ search: (request) => searchImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)) }));
136
+ /**
137
+ * Layer registering both the provider-typed `ExaSearch` tag and the
138
+ * generic `WebSearch` tag over one implementation. A `CommonSearchRequest`
139
+ * is structurally an `ExaSearchRequest` with `type` / `category` unset, so
140
+ * the generic registration forwards directly.
141
+ */
142
+ const layer = (cfg) => {
143
+ const typed = Layer.effect(ExaSearch, make(cfg));
144
+ const generic = Layer.effect(WebSearch, Effect.map(make(cfg), (s) => ({ search: (request) => s.search(request) })));
145
+ return Layer.merge(typed, generic);
146
+ };
147
+ //#endregion
148
+ export { ExaSearch, layer, make, ExaSearch_exports as t };
149
+
150
+ //# sourceMappingURL=ExaSearch.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExaSearch.mjs","names":[],"sources":["../src/ExaSearch.ts"],"sourcesContent":["import { Context, DateTime, type Duration, Effect, Layer, Option, Redacted, Schema } from \"effect\"\nimport { HttpClient, HttpClientRequest } from \"effect/unstable/http\"\nimport * as AiError from \"@effect-uai/core/AiError\"\nimport * as Capabilities from \"@effect-uai/core/Capabilities\"\nimport {\n type CommonSearchRequest,\n type SearchRecency,\n type SearchResponse,\n type SearchResult,\n WebSearch,\n type WebSearchService,\n} from \"@effect-uai/core/WebSearch\"\nimport type { ExaCategory, ExaSearchType } from \"./models.js\"\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\n/**\n * Exa-typed search request. Widens {@link CommonSearchRequest} with Exa's\n * own knobs; the common fields map onto Exa's wire filters (see the codec\n * below). There is no `model` - `/search` ranks the web directly, with\n * `type` choosing the retrieval strategy.\n */\nexport type ExaSearchRequest = CommonSearchRequest & {\n /** Retrieval strategy - see {@link ExaSearchType}. Defaults to `auto`. */\n readonly type?: ExaSearchType\n /** Content-vertical filter - see {@link ExaCategory}. */\n readonly category?: ExaCategory\n}\n\nexport type ExaSearchService = {\n readonly search: (request: ExaSearchRequest) => Effect.Effect<SearchResponse, AiError.AiError>\n}\n\n/**\n * Provider-typed service tag. Yield this for Exa-typed search (`type`,\n * `category`, the per-result `score`); yield the generic `WebSearch` tag\n * for provider-portable code. Both are registered by {@link layer}.\n */\nexport class ExaSearch extends Context.Service<ExaSearch, ExaSearchService>()(\n \"@betalyra/effect-uai/providers/exa/ExaSearch\",\n) {}\n\nexport type Config = {\n readonly apiKey: Redacted.Redacted\n readonly baseUrl?: string\n}\n\n// ---------------------------------------------------------------------------\n// Codec - request body\n// ---------------------------------------------------------------------------\n\ntype WireBody = {\n readonly query: string\n readonly numResults?: number\n readonly type?: string\n readonly category?: string\n readonly includeDomains?: ReadonlyArray<string>\n readonly excludeDomains?: ReadonlyArray<string>\n readonly startPublishedDate?: string\n readonly endPublishedDate?: string\n readonly userLocation?: string\n}\n\n// Exa has no recency enum; map the coarse enum onto a published-after date\n// computed from \"now\" (per the capabilities policy: lossless translation).\nconst recencyDuration: Record<SearchRecency, Duration.Input> = {\n hour: \"1 hour\",\n day: \"1 day\",\n week: \"7 days\",\n month: \"30 days\",\n year: \"365 days\",\n}\n\nconst buildBody = (request: ExaSearchRequest, now: DateTime.DateTime): WireBody => {\n // Explicit `startDate` wins over the `recency` sugar; otherwise derive the\n // published-after bound from the recency window.\n const startPublishedDate =\n request.startDate !== undefined\n ? DateTime.formatIso(request.startDate)\n : request.recency !== undefined\n ? DateTime.formatIso(DateTime.subtractDuration(now, recencyDuration[request.recency]))\n : undefined\n\n return {\n query: request.query,\n ...(request.maxResults !== undefined && { numResults: request.maxResults }),\n ...(request.type !== undefined && { type: request.type }),\n ...(request.category !== undefined && { category: request.category }),\n ...(request.includeDomains !== undefined && { includeDomains: request.includeDomains }),\n ...(request.excludeDomains !== undefined && { excludeDomains: request.excludeDomains }),\n ...(startPublishedDate !== undefined && { startPublishedDate }),\n ...(request.endDate !== undefined && { endPublishedDate: DateTime.formatIso(request.endDate) }),\n ...(request.country !== undefined && { userLocation: request.country }),\n }\n}\n\n// ---------------------------------------------------------------------------\n// Codec - response\n// ---------------------------------------------------------------------------\n\nconst WireResult = Schema.Struct({\n id: Schema.optional(Schema.String),\n url: Schema.String,\n title: Schema.optional(Schema.NullOr(Schema.String)),\n score: Schema.optional(Schema.Number),\n publishedDate: Schema.optional(Schema.NullOr(Schema.String)),\n author: Schema.optional(Schema.NullOr(Schema.String)),\n})\ntype WireResult = typeof WireResult.Type\n\nconst WireCost = Schema.Struct({\n total: Schema.optional(Schema.Number),\n})\n\nconst WireResponse = Schema.Struct({\n requestId: Schema.optional(Schema.String),\n results: Schema.Array(WireResult),\n costDollars: Schema.optional(WireCost),\n})\ntype WireResponse = typeof WireResponse.Type\n\n/** Lenient parse of Exa's ISO date strings; unparseable -> omitted. */\nconst parseDate = (s: string | null | undefined): DateTime.DateTime | undefined =>\n s == null ? undefined : Option.getOrUndefined(DateTime.make(s))\n\n// Exa's pure `/search` returns no text excerpt unless you request `contents`\n// (the out-of-scope extract feature), so `snippet` stays undefined here -\n// the honest result is url + title + score + publishedDate. `author` and\n// the full `raw` object remain on `raw` for typed-tag callers.\nconst toResult = (r: WireResult): SearchResult => {\n const publishedDate = parseDate(r.publishedDate)\n return {\n url: r.url,\n ...(r.title != null && { title: r.title }),\n ...(r.score !== undefined && { score: r.score }),\n ...(publishedDate !== undefined && { publishedDate }),\n raw: r,\n }\n}\n\n// `costDollars` is still decoded (so it survives on `raw`) but not promoted\n// to a typed cost field: search cost/usage is deferred to the unified usage\n// design (see plans/usage-tracking.md).\nconst toResponse = (wire: WireResponse): SearchResponse => ({\n results: wire.results.map(toResult),\n raw: wire,\n})\n\n// ---------------------------------------------------------------------------\n// HTTP\n// ---------------------------------------------------------------------------\n\nconst transportFailure = (cause: unknown): AiError.AiError =>\n new AiError.Unavailable({ provider: \"exa\", raw: cause })\n\nconst httpStatusError = (status: number, body: string): AiError.AiError => {\n const provider = \"exa\"\n const raw = body\n if (status === 429) return new AiError.RateLimited({ provider, raw })\n if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })\n if (status === 401) return new AiError.AuthFailed({ provider, subtype: \"auth\", raw })\n if (status === 403) return new AiError.AuthFailed({ provider, subtype: \"permission\", raw })\n if (status === 402) return new AiError.AuthFailed({ provider, subtype: \"billing\", raw })\n if (status >= 500) return new AiError.Unavailable({ provider, status, raw })\n return new AiError.InvalidRequest({ provider, raw })\n}\n\nconst baseUrl = (cfg: Config): string => cfg.baseUrl ?? \"https://api.exa.ai\"\n\nconst postSearch = (\n cfg: Config,\n body: WireBody,\n): Effect.Effect<WireResponse, AiError.AiError, HttpClient.HttpClient> =>\n Effect.gen(function* () {\n const client = yield* HttpClient.HttpClient\n const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/search`).pipe(\n HttpClientRequest.setHeader(\"x-api-key\", Redacted.value(cfg.apiKey)),\n HttpClientRequest.bodyJsonUnsafe(body),\n )\n const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))\n if (response.status >= 400) {\n const text = yield* response.text.pipe(Effect.orElseSucceed(() => \"\"))\n return yield* httpStatusError(response.status, text)\n }\n const json = yield* response.json.pipe(Effect.mapError(transportFailure))\n return yield* Schema.decodeUnknownEffect(WireResponse)(json).pipe(\n Effect.mapError(transportFailure),\n )\n })\n\nconst searchImpl =\n (cfg: Config) =>\n (\n request: ExaSearchRequest,\n ): Effect.Effect<SearchResponse, AiError.AiError, HttpClient.HttpClient> =>\n Effect.gen(function* () {\n // Exa has no language filter (bucket 2: warn, don't fail).\n yield* Capabilities.warnDroppedWhen(request.language, {\n provider: \"exa\",\n capability: \"language\",\n field: \"language\",\n reason: \"Exa /search has no language filter; the hint is ignored.\",\n })\n const now = yield* DateTime.now\n const wire = yield* postSearch(cfg, buildBody(request, now))\n return toResponse(wire)\n })\n\n// ---------------------------------------------------------------------------\n// Constructors\n// ---------------------------------------------------------------------------\n\n/**\n * Build an `ExaSearchService` value. For Layer-based setup, prefer\n * {@link layer}.\n */\nexport const make = (cfg: Config): Effect.Effect<ExaSearchService, never, HttpClient.HttpClient> =>\n Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({\n search: (request) =>\n searchImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),\n }))\n\n/**\n * Layer registering both the provider-typed `ExaSearch` tag and the\n * generic `WebSearch` tag over one implementation. A `CommonSearchRequest`\n * is structurally an `ExaSearchRequest` with `type` / `category` unset, so\n * the generic registration forwards directly.\n */\nexport const layer = (\n cfg: Config,\n): Layer.Layer<ExaSearch | WebSearch, never, HttpClient.HttpClient> => {\n const typed = Layer.effect(ExaSearch, make(cfg))\n const generic = Layer.effect(\n WebSearch,\n Effect.map(make(cfg), (s): WebSearchService => ({ search: (request) => s.search(request) })),\n )\n return Layer.merge(typed, generic)\n}\n"],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ //#endregion
13
+ export { __exportAll as t };
@@ -0,0 +1,3 @@
1
+ import { ExaCategory, ExaSearchType } from "./models.mjs";
2
+ import { t as ExaSearch_d_exports } from "./ExaSearch.mjs";
3
+ export { ExaCategory, ExaSearch_d_exports as ExaSearch, ExaSearchType };
package/dist/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import { t as ExaSearch_exports } from "./ExaSearch.mjs";
2
+ import "./models.mjs";
3
+ export { ExaSearch_exports as ExaSearch };
@@ -0,0 +1,24 @@
1
+ //#region src/models.d.ts
2
+ /**
3
+ * Exa's search mode. The `/search` endpoint has no `model`; `type` selects
4
+ * the retrieval strategy instead, and is the one knob that makes Exa
5
+ * distinctive (semantic / embeddings-backed search vs. keyword).
6
+ *
7
+ * Current documented values: `auto` (default; picks per query), `fast`,
8
+ * `instant`, and the deep-research variants (`deep-lite`, `deep`,
9
+ * `deep-reasoning`). The legacy `neural` / `keyword` values still work on
10
+ * the wire; the `(string & {})` tail keeps any of them assignable without
11
+ * an SDK bump.
12
+ *
13
+ * Provider-specific, so it lives on `ExaSearchRequest`, not the
14
+ * cross-provider `CommonSearchRequest`.
15
+ */
16
+ type ExaSearchType = "auto" | "fast" | "instant" | "deep-lite" | "deep" | "deep-reasoning" | "neural" | "keyword" | (string & {});
17
+ /**
18
+ * Exa's result category filter. Narrows the index to a content vertical.
19
+ * Provider-specific, so it lives on `ExaSearchRequest`.
20
+ */
21
+ type ExaCategory = "company" | "research paper" | "news" | "personal site" | "financial report" | "people" | (string & {});
22
+ //#endregion
23
+ export { ExaCategory, ExaSearchType };
24
+ //# sourceMappingURL=models.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"models.d.mts","names":[],"sources":["../src/models.ts"],"mappings":";;AAcA;;;;;AAgBA;;;;;;;;KAhBY,aAAA;;;;;KAgBA,WAAA"}
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@effect-uai/exa",
3
+ "version": "0.7.0",
4
+ "description": "Exa provider for @effect-uai/core (web search).",
5
+ "keywords": [
6
+ "ai",
7
+ "effect",
8
+ "exa",
9
+ "search",
10
+ "web-search"
11
+ ],
12
+ "homepage": "https://github.com/betalyra/effect-uai/tree/main/packages/providers/exa",
13
+ "bugs": {
14
+ "url": "https://github.com/betalyra/effect-uai/issues"
15
+ },
16
+ "license": "MIT",
17
+ "author": "Betalyra",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/betalyra/effect-uai",
21
+ "directory": "packages/providers/exa"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "src",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "type": "module",
30
+ "main": "./dist/index.mjs",
31
+ "types": "./dist/index.d.mts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.mts",
35
+ "import": "./dist/index.mjs"
36
+ },
37
+ "./ExaSearch": {
38
+ "types": "./dist/ExaSearch.d.mts",
39
+ "import": "./dist/ExaSearch.mjs"
40
+ }
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "devDependencies": {
46
+ "effect": "4.0.0-beta.57",
47
+ "typescript": "^6.0.3",
48
+ "@effect-uai/core": "0.7.0"
49
+ },
50
+ "peerDependencies": {
51
+ "@effect-uai/core": ">=0.2.0 <1",
52
+ "effect": "4.0.0-beta.57"
53
+ },
54
+ "scripts": {
55
+ "build": "tsdown",
56
+ "typecheck": "tsc --noEmit"
57
+ }
58
+ }
@@ -0,0 +1,240 @@
1
+ import { Context, DateTime, type Duration, Effect, Layer, Option, Redacted, Schema } from "effect"
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import * as AiError from "@effect-uai/core/AiError"
4
+ import * as Capabilities from "@effect-uai/core/Capabilities"
5
+ import {
6
+ type CommonSearchRequest,
7
+ type SearchRecency,
8
+ type SearchResponse,
9
+ type SearchResult,
10
+ WebSearch,
11
+ type WebSearchService,
12
+ } from "@effect-uai/core/WebSearch"
13
+ import type { ExaCategory, ExaSearchType } from "./models.js"
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Public types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Exa-typed search request. Widens {@link CommonSearchRequest} with Exa's
21
+ * own knobs; the common fields map onto Exa's wire filters (see the codec
22
+ * below). There is no `model` - `/search` ranks the web directly, with
23
+ * `type` choosing the retrieval strategy.
24
+ */
25
+ export type ExaSearchRequest = CommonSearchRequest & {
26
+ /** Retrieval strategy - see {@link ExaSearchType}. Defaults to `auto`. */
27
+ readonly type?: ExaSearchType
28
+ /** Content-vertical filter - see {@link ExaCategory}. */
29
+ readonly category?: ExaCategory
30
+ }
31
+
32
+ export type ExaSearchService = {
33
+ readonly search: (request: ExaSearchRequest) => Effect.Effect<SearchResponse, AiError.AiError>
34
+ }
35
+
36
+ /**
37
+ * Provider-typed service tag. Yield this for Exa-typed search (`type`,
38
+ * `category`, the per-result `score`); yield the generic `WebSearch` tag
39
+ * for provider-portable code. Both are registered by {@link layer}.
40
+ */
41
+ export class ExaSearch extends Context.Service<ExaSearch, ExaSearchService>()(
42
+ "@betalyra/effect-uai/providers/exa/ExaSearch",
43
+ ) {}
44
+
45
+ export type Config = {
46
+ readonly apiKey: Redacted.Redacted
47
+ readonly baseUrl?: string
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Codec - request body
52
+ // ---------------------------------------------------------------------------
53
+
54
+ type WireBody = {
55
+ readonly query: string
56
+ readonly numResults?: number
57
+ readonly type?: string
58
+ readonly category?: string
59
+ readonly includeDomains?: ReadonlyArray<string>
60
+ readonly excludeDomains?: ReadonlyArray<string>
61
+ readonly startPublishedDate?: string
62
+ readonly endPublishedDate?: string
63
+ readonly userLocation?: string
64
+ }
65
+
66
+ // Exa has no recency enum; map the coarse enum onto a published-after date
67
+ // computed from "now" (per the capabilities policy: lossless translation).
68
+ const recencyDuration: Record<SearchRecency, Duration.Input> = {
69
+ hour: "1 hour",
70
+ day: "1 day",
71
+ week: "7 days",
72
+ month: "30 days",
73
+ year: "365 days",
74
+ }
75
+
76
+ const buildBody = (request: ExaSearchRequest, now: DateTime.DateTime): WireBody => {
77
+ // Explicit `startDate` wins over the `recency` sugar; otherwise derive the
78
+ // published-after bound from the recency window.
79
+ const startPublishedDate =
80
+ request.startDate !== undefined
81
+ ? DateTime.formatIso(request.startDate)
82
+ : request.recency !== undefined
83
+ ? DateTime.formatIso(DateTime.subtractDuration(now, recencyDuration[request.recency]))
84
+ : undefined
85
+
86
+ return {
87
+ query: request.query,
88
+ ...(request.maxResults !== undefined && { numResults: request.maxResults }),
89
+ ...(request.type !== undefined && { type: request.type }),
90
+ ...(request.category !== undefined && { category: request.category }),
91
+ ...(request.includeDomains !== undefined && { includeDomains: request.includeDomains }),
92
+ ...(request.excludeDomains !== undefined && { excludeDomains: request.excludeDomains }),
93
+ ...(startPublishedDate !== undefined && { startPublishedDate }),
94
+ ...(request.endDate !== undefined && { endPublishedDate: DateTime.formatIso(request.endDate) }),
95
+ ...(request.country !== undefined && { userLocation: request.country }),
96
+ }
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Codec - response
101
+ // ---------------------------------------------------------------------------
102
+
103
+ const WireResult = Schema.Struct({
104
+ id: Schema.optional(Schema.String),
105
+ url: Schema.String,
106
+ title: Schema.optional(Schema.NullOr(Schema.String)),
107
+ score: Schema.optional(Schema.Number),
108
+ publishedDate: Schema.optional(Schema.NullOr(Schema.String)),
109
+ author: Schema.optional(Schema.NullOr(Schema.String)),
110
+ })
111
+ type WireResult = typeof WireResult.Type
112
+
113
+ const WireCost = Schema.Struct({
114
+ total: Schema.optional(Schema.Number),
115
+ })
116
+
117
+ const WireResponse = Schema.Struct({
118
+ requestId: Schema.optional(Schema.String),
119
+ results: Schema.Array(WireResult),
120
+ costDollars: Schema.optional(WireCost),
121
+ })
122
+ type WireResponse = typeof WireResponse.Type
123
+
124
+ /** Lenient parse of Exa's ISO date strings; unparseable -> omitted. */
125
+ const parseDate = (s: string | null | undefined): DateTime.DateTime | undefined =>
126
+ s == null ? undefined : Option.getOrUndefined(DateTime.make(s))
127
+
128
+ // Exa's pure `/search` returns no text excerpt unless you request `contents`
129
+ // (the out-of-scope extract feature), so `snippet` stays undefined here -
130
+ // the honest result is url + title + score + publishedDate. `author` and
131
+ // the full `raw` object remain on `raw` for typed-tag callers.
132
+ const toResult = (r: WireResult): SearchResult => {
133
+ const publishedDate = parseDate(r.publishedDate)
134
+ return {
135
+ url: r.url,
136
+ ...(r.title != null && { title: r.title }),
137
+ ...(r.score !== undefined && { score: r.score }),
138
+ ...(publishedDate !== undefined && { publishedDate }),
139
+ raw: r,
140
+ }
141
+ }
142
+
143
+ // `costDollars` is still decoded (so it survives on `raw`) but not promoted
144
+ // to a typed cost field: search cost/usage is deferred to the unified usage
145
+ // design (see plans/usage-tracking.md).
146
+ const toResponse = (wire: WireResponse): SearchResponse => ({
147
+ results: wire.results.map(toResult),
148
+ raw: wire,
149
+ })
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // HTTP
153
+ // ---------------------------------------------------------------------------
154
+
155
+ const transportFailure = (cause: unknown): AiError.AiError =>
156
+ new AiError.Unavailable({ provider: "exa", raw: cause })
157
+
158
+ const httpStatusError = (status: number, body: string): AiError.AiError => {
159
+ const provider = "exa"
160
+ const raw = body
161
+ if (status === 429) return new AiError.RateLimited({ provider, raw })
162
+ if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })
163
+ if (status === 401) return new AiError.AuthFailed({ provider, subtype: "auth", raw })
164
+ if (status === 403) return new AiError.AuthFailed({ provider, subtype: "permission", raw })
165
+ if (status === 402) return new AiError.AuthFailed({ provider, subtype: "billing", raw })
166
+ if (status >= 500) return new AiError.Unavailable({ provider, status, raw })
167
+ return new AiError.InvalidRequest({ provider, raw })
168
+ }
169
+
170
+ const baseUrl = (cfg: Config): string => cfg.baseUrl ?? "https://api.exa.ai"
171
+
172
+ const postSearch = (
173
+ cfg: Config,
174
+ body: WireBody,
175
+ ): Effect.Effect<WireResponse, AiError.AiError, HttpClient.HttpClient> =>
176
+ Effect.gen(function* () {
177
+ const client = yield* HttpClient.HttpClient
178
+ const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/search`).pipe(
179
+ HttpClientRequest.setHeader("x-api-key", Redacted.value(cfg.apiKey)),
180
+ HttpClientRequest.bodyJsonUnsafe(body),
181
+ )
182
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
183
+ if (response.status >= 400) {
184
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
185
+ return yield* httpStatusError(response.status, text)
186
+ }
187
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure))
188
+ return yield* Schema.decodeUnknownEffect(WireResponse)(json).pipe(
189
+ Effect.mapError(transportFailure),
190
+ )
191
+ })
192
+
193
+ const searchImpl =
194
+ (cfg: Config) =>
195
+ (
196
+ request: ExaSearchRequest,
197
+ ): Effect.Effect<SearchResponse, AiError.AiError, HttpClient.HttpClient> =>
198
+ Effect.gen(function* () {
199
+ // Exa has no language filter (bucket 2: warn, don't fail).
200
+ yield* Capabilities.warnDroppedWhen(request.language, {
201
+ provider: "exa",
202
+ capability: "language",
203
+ field: "language",
204
+ reason: "Exa /search has no language filter; the hint is ignored.",
205
+ })
206
+ const now = yield* DateTime.now
207
+ const wire = yield* postSearch(cfg, buildBody(request, now))
208
+ return toResponse(wire)
209
+ })
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Constructors
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /**
216
+ * Build an `ExaSearchService` value. For Layer-based setup, prefer
217
+ * {@link layer}.
218
+ */
219
+ export const make = (cfg: Config): Effect.Effect<ExaSearchService, never, HttpClient.HttpClient> =>
220
+ Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({
221
+ search: (request) =>
222
+ searchImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
223
+ }))
224
+
225
+ /**
226
+ * Layer registering both the provider-typed `ExaSearch` tag and the
227
+ * generic `WebSearch` tag over one implementation. A `CommonSearchRequest`
228
+ * is structurally an `ExaSearchRequest` with `type` / `category` unset, so
229
+ * the generic registration forwards directly.
230
+ */
231
+ export const layer = (
232
+ cfg: Config,
233
+ ): Layer.Layer<ExaSearch | WebSearch, never, HttpClient.HttpClient> => {
234
+ const typed = Layer.effect(ExaSearch, make(cfg))
235
+ const generic = Layer.effect(
236
+ WebSearch,
237
+ Effect.map(make(cfg), (s): WebSearchService => ({ search: (request) => s.search(request) })),
238
+ )
239
+ return Layer.merge(typed, generic)
240
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * as ExaSearch from "./ExaSearch.js"
2
+ export * from "./models.js"
package/src/models.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Exa's search mode. The `/search` endpoint has no `model`; `type` selects
3
+ * the retrieval strategy instead, and is the one knob that makes Exa
4
+ * distinctive (semantic / embeddings-backed search vs. keyword).
5
+ *
6
+ * Current documented values: `auto` (default; picks per query), `fast`,
7
+ * `instant`, and the deep-research variants (`deep-lite`, `deep`,
8
+ * `deep-reasoning`). The legacy `neural` / `keyword` values still work on
9
+ * the wire; the `(string & {})` tail keeps any of them assignable without
10
+ * an SDK bump.
11
+ *
12
+ * Provider-specific, so it lives on `ExaSearchRequest`, not the
13
+ * cross-provider `CommonSearchRequest`.
14
+ */
15
+ export type ExaSearchType =
16
+ | "auto"
17
+ | "fast"
18
+ | "instant"
19
+ | "deep-lite"
20
+ | "deep"
21
+ | "deep-reasoning"
22
+ | "neural"
23
+ | "keyword"
24
+ // eslint-disable-next-line @typescript-eslint/ban-types
25
+ | (string & {})
26
+
27
+ /**
28
+ * Exa's result category filter. Narrows the index to a content vertical.
29
+ * Provider-specific, so it lives on `ExaSearchRequest`.
30
+ */
31
+ export type ExaCategory =
32
+ | "company"
33
+ | "research paper"
34
+ | "news"
35
+ | "personal site"
36
+ | "financial report"
37
+ | "people"
38
+ // eslint-disable-next-line @typescript-eslint/ban-types
39
+ | (string & {})