clanka 0.6.2 → 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,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
+ )