clanka 0.6.1 → 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.
@@ -0,0 +1,106 @@
1
+ import { assert, describe, it } from "@effect/vitest"
2
+ import { Client } from "@modelcontextprotocol/sdk/client"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Cause from "effect/Cause"
5
+ import * as Exit from "effect/Exit"
6
+ import * as Fiber from "effect/Fiber"
7
+ import { afterAll, afterEach, beforeEach, vi } from "vitest"
8
+ import * as ExaSearch from "./ExaSearch.ts"
9
+
10
+ describe("ExaSearch lazy connection", () => {
11
+ const connect = vi.spyOn(Client.prototype, "connect")
12
+ const callTool = vi.spyOn(Client.prototype, "callTool")
13
+ const close = vi.spyOn(Client.prototype, "close")
14
+
15
+ beforeEach(() => {
16
+ connect.mockResolvedValue(undefined)
17
+ callTool.mockResolvedValue({
18
+ content: [{ type: "text", text: "Search results" }],
19
+ })
20
+ close.mockResolvedValue(undefined)
21
+ })
22
+
23
+ afterEach(() => {
24
+ vi.resetAllMocks()
25
+ })
26
+
27
+ afterAll(() => {
28
+ vi.restoreAllMocks()
29
+ })
30
+
31
+ it.effect("connects lazily and reuses the connection", () =>
32
+ Effect.gen(function* () {
33
+ const exa = yield* ExaSearch.ExaSearch
34
+ assert.strictEqual(connect.mock.calls.length, 0)
35
+
36
+ assert.strictEqual(
37
+ yield* exa.search({ query: "first" }),
38
+ "Search results",
39
+ )
40
+ assert.strictEqual(
41
+ yield* exa.search({ query: "second" }),
42
+ "Search results",
43
+ )
44
+ assert.strictEqual(connect.mock.calls.length, 1)
45
+ assert.strictEqual(callTool.mock.calls.length, 2)
46
+ }).pipe(Effect.provide(ExaSearch.layer)),
47
+ )
48
+
49
+ it.effect(
50
+ "retries after a failed first connection and then reuses success",
51
+ () =>
52
+ Effect.gen(function* () {
53
+ connect.mockRejectedValueOnce(new Error("temporary failure"))
54
+ const exa = yield* ExaSearch.ExaSearch
55
+ yield* Effect.flip(exa.search({ query: "first" }))
56
+ const retry = yield* Effect.exit(exa.search({ query: "retry" }))
57
+ assert.strictEqual(connect.mock.calls.length, 2)
58
+ assert.deepStrictEqual(retry, Exit.succeed("Search results"))
59
+ assert.strictEqual(
60
+ yield* exa.search({ query: "third" }),
61
+ "Search results",
62
+ )
63
+ assert.strictEqual(connect.mock.calls.length, 2)
64
+ assert.strictEqual(callTool.mock.calls.length, 2)
65
+ }).pipe(Effect.provide(ExaSearch.layer)),
66
+ )
67
+
68
+ it.effect(
69
+ "retries after interruption instead of cancelling later searches",
70
+ () =>
71
+ Effect.gen(function* () {
72
+ const started = Promise.withResolvers<void>()
73
+ let signal: AbortSignal | undefined
74
+ connect.mockImplementationOnce((_transport, options) => {
75
+ signal = options?.signal
76
+ started.resolve()
77
+ return new Promise<void>((_resolve, reject) => {
78
+ signal?.addEventListener("abort", () => reject(signal?.reason), {
79
+ once: true,
80
+ })
81
+ })
82
+ })
83
+ const exa = yield* ExaSearch.ExaSearch
84
+ const first = yield* exa
85
+ .search({ query: "first" })
86
+ .pipe(Effect.forkChild)
87
+ yield* Effect.promise(() => started.promise)
88
+ yield* Fiber.interrupt(first)
89
+ const interrupted = yield* Fiber.await(first)
90
+ assert.isTrue(
91
+ Exit.isFailure(interrupted) &&
92
+ Cause.hasInterruptsOnly(interrupted.cause),
93
+ )
94
+ assert.isTrue(signal?.aborted)
95
+ assert.strictEqual(callTool.mock.calls.length, 0)
96
+ const retry = yield* Effect.exit(exa.search({ query: "retry" }))
97
+ assert.strictEqual(connect.mock.calls.length, 2)
98
+ assert.deepStrictEqual(retry, Exit.succeed("Search results"))
99
+ assert.strictEqual(
100
+ yield* exa.search({ query: "third" }),
101
+ "Search results",
102
+ )
103
+ assert.strictEqual(connect.mock.calls.length, 2)
104
+ }).pipe(Effect.provide(ExaSearch.layer)),
105
+ )
106
+ })
package/src/ExaSearch.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * @since 1.0.0
3
3
  */
4
+ import * as Duration from "effect/Duration"
4
5
  import * as Effect from "effect/Effect"
6
+ import * as Exit from "effect/Exit"
5
7
  import { pipe } from "effect/Function"
6
8
  import * as Layer from "effect/Layer"
7
9
  import * as Schema from "effect/Schema"
@@ -55,7 +57,10 @@ export const layer = Layer.effect(
55
57
  Effect.gen(function* () {
56
58
  const client = yield* McpClient.McpClient
57
59
 
58
- yield* client.connect({ url: "https://mcp.exa.ai/mcp" }).pipe(Effect.orDie)
60
+ const connect = yield* Effect.cachedWithTTL(
61
+ client.connect({ url: "https://mcp.exa.ai/mcp" }),
62
+ (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
63
+ )
59
64
 
60
65
  const decode = Schema.decodeUnknownEffect(
61
66
  Schema.NonEmptyArray(ExaSearchResult),
@@ -64,6 +69,7 @@ export const layer = Layer.effect(
64
69
  return ExaSearch.of({
65
70
  search: Effect.fn("ExaSearch.search")(
66
71
  function* (options) {
72
+ yield* connect
67
73
  const results = yield* pipe(
68
74
  client.toolCall({
69
75
  name: "web_search_exa",
@@ -0,0 +1,160 @@
1
+ import { assert, describe, it } from "@effect/vitest"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Exit from "effect/Exit"
4
+ import * as Layer from "effect/Layer"
5
+ import * as Stream from "effect/Stream"
6
+ import * as LanguageModel from "effect/unstable/ai/LanguageModel"
7
+ import * as Model from "effect/unstable/ai/Model"
8
+ import {
9
+ HttpClient,
10
+ type HttpClientRequest,
11
+ HttpClientResponse,
12
+ } from "effect/unstable/http"
13
+ import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
14
+ import * as Compaction from "./Compaction.ts"
15
+ import { DeviceCodeHandler } from "./DeviceCodeHandler.ts"
16
+ import * as Public from "./index.ts"
17
+ import * as Xai from "./Xai.ts"
18
+ import { TokenData, toTokenStore } from "./XaiAuth.ts"
19
+
20
+ const capture = () => {
21
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
22
+ const client = HttpClient.make((request) =>
23
+ Effect.sync(() => {
24
+ requests.push(request)
25
+ return HttpClientResponse.fromWeb(
26
+ request,
27
+ new Response(
28
+ JSON.stringify({
29
+ error: {
30
+ message: "transport probe",
31
+ type: "invalid_request_error",
32
+ },
33
+ }),
34
+ { status: 400, headers: { "content-type": "application/json" } },
35
+ ),
36
+ )
37
+ }),
38
+ )
39
+ return { requests, layer: Layer.succeed(HttpClient.HttpClient, client) }
40
+ }
41
+ const body = (
42
+ request: HttpClientRequest.HttpClientRequest,
43
+ ): Record<string, unknown> => {
44
+ if (request.body._tag !== "Uint8Array") throw new Error("Expected JSON body")
45
+ return JSON.parse(new TextDecoder().decode(request.body.body))
46
+ }
47
+ const seed = Effect.gen(function* () {
48
+ yield* toTokenStore(yield* KeyValueStore.KeyValueStore)
49
+ .set(
50
+ "token",
51
+ new TokenData({
52
+ access: "subscription-token",
53
+ refresh: "refresh",
54
+ expires: Date.now() + 3600000,
55
+ }),
56
+ )
57
+ .pipe(Effect.orDie)
58
+ })
59
+ const noLogin = Layer.succeed(DeviceCodeHandler, {
60
+ onCode: () => Effect.die("Cached credentials must not prompt"),
61
+ })
62
+
63
+ describe("Xai provider", () => {
64
+ it("exports Xai, but not XaiAuth, from the public entry point", () => {
65
+ assert.strictEqual(Public.Xai, Xai)
66
+ assert.isFalse("XaiAuth" in Public)
67
+ assert.strictEqual(Xai.model("grok-4.6").provider, "xai")
68
+ })
69
+
70
+ for (const effort of [undefined, "low"] as const) {
71
+ it.effect(
72
+ "uses authenticated HTTP Responses with " +
73
+ (effort ?? "default high") +
74
+ " reasoning",
75
+ () =>
76
+ Effect.gen(function* () {
77
+ yield* seed
78
+ const http = capture()
79
+ yield* Effect.gen(function* () {
80
+ assert.lengthOf(
81
+ http.requests,
82
+ 0,
83
+ "Client construction must not trigger login or inference",
84
+ )
85
+ assert.strictEqual(yield* Model.ProviderName, "xai")
86
+ assert.strictEqual(yield* Model.ModelName, "grok-4.6")
87
+ const ai = yield* LanguageModel.LanguageModel
88
+ const exit = yield* ai
89
+ .streamText({ prompt: "hello" })
90
+ .pipe(Stream.runDrain, Effect.exit)
91
+ assert.isTrue(Exit.isFailure(exit))
92
+ }).pipe(
93
+ Effect.provide(
94
+ Xai.model(
95
+ "grok-4.6",
96
+ effort === undefined ? undefined : { reasoning: { effort } },
97
+ ).pipe(
98
+ Layer.provide(Xai.layerClient),
99
+ Layer.provide(http.layer),
100
+ Layer.provide(noLogin),
101
+ ),
102
+ ),
103
+ )
104
+ assert.lengthOf(http.requests, 1)
105
+ const request = http.requests[0]!
106
+ assert.strictEqual(request.method, "POST")
107
+ assert.strictEqual(request.url, "https://api.x.ai/v1/responses")
108
+ assert.strictEqual(
109
+ request.headers.authorization,
110
+ "Bearer subscription-token",
111
+ )
112
+ assert.match(request.headers["user-agent"]!, /clanka/i)
113
+ assert.notMatch(request.headers["user-agent"]!, /opencode/i)
114
+ assert.deepInclude(body(request), {
115
+ model: "grok-4.6",
116
+ store: false,
117
+ stream: true,
118
+ })
119
+ assert.deepInclude(body(request).reasoning, {
120
+ effort: effort ?? "high",
121
+ })
122
+ assert.isFalse("max_output_tokens" in body(request))
123
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
124
+ )
125
+ }
126
+
127
+ it.effect(
128
+ "caps summaries at 4k without leaking the cap into subsequent requests",
129
+ () =>
130
+ Effect.gen(function* () {
131
+ yield* seed
132
+ const http = capture()
133
+ yield* Effect.gen(function* () {
134
+ const ai = yield* LanguageModel.LanguageModel
135
+ const summarize = yield* Compaction.SummarizerTransform
136
+ yield* summarize(
137
+ ai.streamText({ prompt: "summarize" }).pipe(Stream.runDrain),
138
+ ).pipe(Effect.exit)
139
+ yield* ai
140
+ .streamText({ prompt: "continue" })
141
+ .pipe(Stream.runDrain, Effect.exit)
142
+ }).pipe(
143
+ Effect.provide(
144
+ Xai.model("grok-4.6").pipe(
145
+ Layer.provide(Xai.layerClient),
146
+ Layer.provide(http.layer),
147
+ Layer.provide(noLogin),
148
+ ),
149
+ ),
150
+ )
151
+ assert.lengthOf(http.requests, 2)
152
+ assert.strictEqual(body(http.requests[0]!).max_output_tokens, 4000)
153
+ assert.isFalse("max_output_tokens" in body(http.requests[1]!))
154
+ for (const request of http.requests) {
155
+ assert.strictEqual(request.url, "https://api.x.ai/v1/responses")
156
+ assert.strictEqual(body(request).store, false)
157
+ }
158
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
159
+ )
160
+ })
package/src/Xai.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @since 1.0.0
3
+ */
4
+ import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai"
5
+ import * as Layer from "effect/Layer"
6
+ import * as Struct from "effect/Struct"
7
+ import { XaiAuth } from "./XaiAuth.ts"
8
+ import { AgentModelConfig } from "./Agent.ts"
9
+ import * as Compaction from "./Compaction.ts"
10
+ import * as Model from "effect/unstable/ai/Model"
11
+ import type * as LanguageModel from "effect/unstable/ai/LanguageModel"
12
+
13
+ /**
14
+ * @since 1.0.0
15
+ * @category Layers
16
+ */
17
+ export const layerClient = OpenAiClient.layer({
18
+ apiUrl: "https://api.x.ai/v1",
19
+ }).pipe(Layer.provide(XaiAuth.layerClient))
20
+
21
+ /**
22
+ * @since 1.0.0
23
+ * @category Layers
24
+ */
25
+ export const model = (
26
+ model: string,
27
+ options?:
28
+ | (OpenAiLanguageModel.Config["Service"] & typeof AgentModelConfig.Service)
29
+ | undefined,
30
+ ): Model.Model<"xai", LanguageModel.LanguageModel, OpenAiClient.OpenAiClient> =>
31
+ Model.make(
32
+ "xai",
33
+ model,
34
+ Layer.mergeAll(
35
+ OpenAiLanguageModel.layer({
36
+ model,
37
+ config: {
38
+ ...Struct.omit(options ?? {}, [
39
+ "systemPromptTransform",
40
+ "supportsImages",
41
+ ]),
42
+ store: false,
43
+ reasoning: { effort: "high", ...options?.reasoning },
44
+ },
45
+ }),
46
+ AgentModelConfig.layer({
47
+ systemPromptTransform: options?.systemPromptTransform,
48
+ supportsImages: options?.supportsImages,
49
+ }),
50
+ // Cap compaction summaries; xAI honours max_output_tokens.
51
+ Layer.succeed(Compaction.SummarizerTransform, (effect) =>
52
+ OpenAiLanguageModel.withConfigOverride(effect, {
53
+ max_output_tokens: Compaction.summarizerMaxOutputTokens,
54
+ }),
55
+ ),
56
+ ),
57
+ )