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.
- package/dist/ExaSearch.d.ts +0 -3
- package/dist/ExaSearch.d.ts.map +1 -1
- package/dist/ExaSearch.js +4 -1
- package/dist/ExaSearch.js.map +1 -1
- package/dist/ExaSearch.test.d.ts +2 -0
- package/dist/ExaSearch.test.d.ts.map +1 -0
- package/dist/ExaSearch.test.js +75 -0
- package/dist/ExaSearch.test.js.map +1 -0
- package/dist/Xai.d.ts +19 -0
- package/dist/Xai.d.ts.map +1 -0
- package/dist/Xai.js +40 -0
- package/dist/Xai.js.map +1 -0
- package/dist/Xai.test.d.ts +2 -0
- package/dist/Xai.test.d.ts.map +1 -0
- package/dist/Xai.test.js +105 -0
- package/dist/Xai.test.js.map +1 -0
- package/dist/XaiAuth.d.ts +43 -0
- package/dist/XaiAuth.d.ts.map +1 -0
- package/dist/XaiAuth.js +172 -0
- package/dist/XaiAuth.js.map +1 -0
- package/dist/XaiAuth.test.d.ts +2 -0
- package/dist/XaiAuth.test.d.ts.map +1 -0
- package/dist/XaiAuth.test.js +354 -0
- package/dist/XaiAuth.test.js.map +1 -0
- package/dist/cli.js +23 -14
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/ExaSearch.test.ts +106 -0
- package/src/ExaSearch.ts +7 -1
- package/src/Xai.test.ts +160 -0
- package/src/Xai.ts +57 -0
- package/src/XaiAuth.test.ts +495 -0
- package/src/XaiAuth.ts +272 -0
- package/src/cli.ts +30 -22
- package/src/index.ts +5 -0
package/src/XaiAuth.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @since 1.0.0
|
|
3
|
+
*/
|
|
4
|
+
/** @effect-diagnostics schemaNumber:off */
|
|
5
|
+
import * as Context from "effect/Context"
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import * as Layer from "effect/Layer"
|
|
8
|
+
import * as Option from "effect/Option"
|
|
9
|
+
import * as Schedule from "effect/Schedule"
|
|
10
|
+
import * as Schema from "effect/Schema"
|
|
11
|
+
import * as Semaphore from "effect/Semaphore"
|
|
12
|
+
import * as HttpClient from "effect/unstable/http/HttpClient"
|
|
13
|
+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
14
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
|
|
15
|
+
import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
|
|
16
|
+
import { DeviceCodeHandler } from "./DeviceCodeHandler.ts"
|
|
17
|
+
|
|
18
|
+
const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
|
19
|
+
const ISSUER = "https://auth.x.ai"
|
|
20
|
+
const TOKEN_URL = ISSUER + "/oauth2/token"
|
|
21
|
+
const DEFAULT_TOKEN_EXPIRY_SECONDS = 3600
|
|
22
|
+
const DEFAULT_DEVICE_EXPIRY_SECONDS = 600
|
|
23
|
+
|
|
24
|
+
export class TokenData extends Schema.Class<TokenData>(
|
|
25
|
+
"clanka/XaiAuth/TokenData",
|
|
26
|
+
)({
|
|
27
|
+
access: Schema.String,
|
|
28
|
+
refresh: Schema.String,
|
|
29
|
+
expires: Schema.Number,
|
|
30
|
+
}) {
|
|
31
|
+
isExpired(): boolean {
|
|
32
|
+
return this.expires < Date.now() + 30_000
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class XaiAuthError extends Schema.TaggedError<XaiAuthError>()(
|
|
37
|
+
"XaiAuthError",
|
|
38
|
+
{
|
|
39
|
+
reason: Schema.Literals(["DeviceFlowFailed", "RefreshFailed"]),
|
|
40
|
+
message: Schema.String,
|
|
41
|
+
cause: Schema.optional(Schema.Defect()),
|
|
42
|
+
},
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
const DeviceCodeResponse = Schema.Struct({
|
|
46
|
+
device_code: Schema.String,
|
|
47
|
+
user_code: Schema.String,
|
|
48
|
+
verification_uri: Schema.String,
|
|
49
|
+
expires_in: Schema.Number,
|
|
50
|
+
interval: Schema.optional(Schema.Number),
|
|
51
|
+
})
|
|
52
|
+
const TokenResponse = Schema.Struct({
|
|
53
|
+
access_token: Schema.String,
|
|
54
|
+
refresh_token: Schema.optional(Schema.String),
|
|
55
|
+
expires_in: Schema.optional(Schema.Number),
|
|
56
|
+
})
|
|
57
|
+
const TokenError = Schema.Struct({
|
|
58
|
+
error: Schema.String,
|
|
59
|
+
interval: Schema.optional(Schema.Number),
|
|
60
|
+
})
|
|
61
|
+
const PollResponse = Schema.Union([TokenResponse, TokenError])
|
|
62
|
+
const toTokenData = (token: typeof TokenResponse.Type, refresh = "") =>
|
|
63
|
+
new TokenData({
|
|
64
|
+
access: token.access_token,
|
|
65
|
+
refresh: token.refresh_token || refresh,
|
|
66
|
+
expires:
|
|
67
|
+
Date.now() + (token.expires_in ?? DEFAULT_TOKEN_EXPIRY_SECONDS) * 1000,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
export const toTokenStore = (store: KeyValueStore.KeyValueStore) =>
|
|
71
|
+
KeyValueStore.toSchemaStore(
|
|
72
|
+
KeyValueStore.prefix(store, "xai.auth/"),
|
|
73
|
+
TokenData,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
export class XaiAuth extends Context.Service<
|
|
77
|
+
XaiAuth,
|
|
78
|
+
{
|
|
79
|
+
readonly get: Effect.Effect<TokenData, XaiAuthError>
|
|
80
|
+
readonly authenticate: Effect.Effect<TokenData, XaiAuthError>
|
|
81
|
+
readonly logout: Effect.Effect<void>
|
|
82
|
+
}
|
|
83
|
+
>()("clanka/XaiAuth") {
|
|
84
|
+
static readonly make = Effect.gen(function* () {
|
|
85
|
+
const verification = yield* DeviceCodeHandler
|
|
86
|
+
const tokenStore = toTokenStore(yield* KeyValueStore.KeyValueStore)
|
|
87
|
+
const httpClient = (yield* HttpClient.HttpClient).pipe(
|
|
88
|
+
HttpClient.mapRequest(
|
|
89
|
+
HttpClientRequest.setHeader("User-Agent", "clanka"),
|
|
90
|
+
),
|
|
91
|
+
HttpClient.retryTransient({
|
|
92
|
+
retryOn: "errors-and-responses",
|
|
93
|
+
times: 5,
|
|
94
|
+
schedule: Schedule.min([
|
|
95
|
+
Schedule.exponential(150),
|
|
96
|
+
Schedule.spaced(5000),
|
|
97
|
+
]),
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
const semaphore = Semaphore.makeUnsafe(1)
|
|
101
|
+
let currentToken = yield* tokenStore.get("token").pipe(
|
|
102
|
+
Effect.catchTag("SchemaError", () =>
|
|
103
|
+
tokenStore.remove("token").pipe(Effect.as(Option.none())),
|
|
104
|
+
),
|
|
105
|
+
Effect.orDie,
|
|
106
|
+
)
|
|
107
|
+
const saveToken = (token: TokenData) =>
|
|
108
|
+
tokenStore.set("token", token).pipe(
|
|
109
|
+
Effect.orDie,
|
|
110
|
+
Effect.tap(() =>
|
|
111
|
+
Effect.sync(() => {
|
|
112
|
+
currentToken = Option.some(token)
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
Effect.as(token),
|
|
116
|
+
)
|
|
117
|
+
const clearToken = tokenStore.remove("token").pipe(
|
|
118
|
+
Effect.orDie,
|
|
119
|
+
Effect.tap(() =>
|
|
120
|
+
Effect.sync(() => {
|
|
121
|
+
currentToken = Option.none()
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const authenticateWithDeviceFlow = Effect.gen(function* () {
|
|
127
|
+
const response = yield* HttpClientRequest.post(
|
|
128
|
+
ISSUER + "/oauth2/device/code",
|
|
129
|
+
).pipe(
|
|
130
|
+
HttpClientRequest.bodyUrlParams({
|
|
131
|
+
client_id: CLIENT_ID,
|
|
132
|
+
scope:
|
|
133
|
+
"openid profile email offline_access grok-cli:access api:access",
|
|
134
|
+
referrer: "clanka",
|
|
135
|
+
}),
|
|
136
|
+
httpClient.execute,
|
|
137
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
138
|
+
)
|
|
139
|
+
const device =
|
|
140
|
+
yield* HttpClientResponse.schemaBodyJson(DeviceCodeResponse)(response)
|
|
141
|
+
yield* verification.onCode({
|
|
142
|
+
verifyUrl: device.verification_uri,
|
|
143
|
+
deviceCode: device.user_code,
|
|
144
|
+
})
|
|
145
|
+
const request = HttpClientRequest.post(TOKEN_URL).pipe(
|
|
146
|
+
HttpClientRequest.bodyUrlParams({
|
|
147
|
+
client_id: CLIENT_ID,
|
|
148
|
+
device_code: device.device_code,
|
|
149
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
let delayMs = Math.max(device.interval ?? 5, 1) * 1000
|
|
153
|
+
const poll = Effect.gen(function* () {
|
|
154
|
+
while (true) {
|
|
155
|
+
// OAuth errors arrive as HTTP 400; decode their bodies before deciding whether to retry.
|
|
156
|
+
const response = yield* httpClient.execute(request)
|
|
157
|
+
const payload =
|
|
158
|
+
yield* HttpClientResponse.schemaBodyJson(PollResponse)(response)
|
|
159
|
+
if ("access_token" in payload) {
|
|
160
|
+
yield* HttpClientResponse.filterStatusOk(response)
|
|
161
|
+
return toTokenData(payload)
|
|
162
|
+
}
|
|
163
|
+
if (payload.error === "slow_down") {
|
|
164
|
+
delayMs = Math.max(delayMs, (payload.interval ?? 0) * 1000) + 5000
|
|
165
|
+
} else if (payload.error !== "authorization_pending") {
|
|
166
|
+
return yield* new XaiAuthError({
|
|
167
|
+
reason: "DeviceFlowFailed",
|
|
168
|
+
message: `xAI device authorization failed: ${payload.error}`,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
yield* Effect.sleep(delayMs + 3000)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
return yield* poll.pipe(
|
|
175
|
+
Effect.timeoutOrElse({
|
|
176
|
+
duration:
|
|
177
|
+
(device.expires_in > 0
|
|
178
|
+
? device.expires_in
|
|
179
|
+
: DEFAULT_DEVICE_EXPIRY_SECONDS) * 1000,
|
|
180
|
+
orElse: () =>
|
|
181
|
+
Effect.fail(
|
|
182
|
+
new XaiAuthError({
|
|
183
|
+
reason: "DeviceFlowFailed",
|
|
184
|
+
message: "xAI device authorization failed: expired_token",
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
}),
|
|
188
|
+
)
|
|
189
|
+
}).pipe(
|
|
190
|
+
Effect.mapError((cause) =>
|
|
191
|
+
cause instanceof XaiAuthError
|
|
192
|
+
? cause
|
|
193
|
+
: new XaiAuthError({
|
|
194
|
+
reason: "DeviceFlowFailed",
|
|
195
|
+
message: "Failed to authorize xAI device",
|
|
196
|
+
cause,
|
|
197
|
+
}),
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const refreshToken = Effect.fn("XaiAuth.refreshToken")(
|
|
202
|
+
function* (refresh: string) {
|
|
203
|
+
const response = yield* HttpClientRequest.post(TOKEN_URL).pipe(
|
|
204
|
+
HttpClientRequest.bodyUrlParams({
|
|
205
|
+
client_id: CLIENT_ID,
|
|
206
|
+
grant_type: "refresh_token",
|
|
207
|
+
refresh_token: refresh,
|
|
208
|
+
}),
|
|
209
|
+
httpClient.execute,
|
|
210
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
211
|
+
)
|
|
212
|
+
return toTokenData(
|
|
213
|
+
yield* HttpClientResponse.schemaBodyJson(TokenResponse)(response),
|
|
214
|
+
refresh,
|
|
215
|
+
)
|
|
216
|
+
},
|
|
217
|
+
Effect.mapError(
|
|
218
|
+
(cause) =>
|
|
219
|
+
new XaiAuthError({
|
|
220
|
+
reason: "RefreshFailed",
|
|
221
|
+
message: "Failed to refresh xAI access token",
|
|
222
|
+
cause,
|
|
223
|
+
}),
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
const authenticate = Effect.uninterruptibleMask(
|
|
228
|
+
Effect.fnUntraced(function* (restore) {
|
|
229
|
+
return yield* saveToken(yield* restore(authenticateWithDeviceFlow))
|
|
230
|
+
}),
|
|
231
|
+
)
|
|
232
|
+
const get = Effect.uninterruptibleMask(
|
|
233
|
+
Effect.fnUntraced(function* (restore) {
|
|
234
|
+
if (Option.isSome(currentToken)) {
|
|
235
|
+
if (!currentToken.value.isExpired()) return currentToken.value
|
|
236
|
+
const refreshed = yield* restore(
|
|
237
|
+
refreshToken(currentToken.value.refresh).pipe(Effect.option),
|
|
238
|
+
)
|
|
239
|
+
if (Option.isSome(refreshed)) return yield* saveToken(refreshed.value)
|
|
240
|
+
yield* clearToken
|
|
241
|
+
}
|
|
242
|
+
return yield* saveToken(yield* restore(authenticateWithDeviceFlow))
|
|
243
|
+
}),
|
|
244
|
+
)
|
|
245
|
+
return XaiAuth.of({
|
|
246
|
+
get: semaphore.withPermit(get),
|
|
247
|
+
authenticate: semaphore.withPermit(authenticate),
|
|
248
|
+
logout: semaphore.withPermit(Effect.uninterruptible(clearToken)),
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
static readonly layer = Layer.effect(XaiAuth, XaiAuth.make)
|
|
253
|
+
static readonly layerClient = Layer.effect(
|
|
254
|
+
HttpClient.HttpClient,
|
|
255
|
+
Effect.gen(function* () {
|
|
256
|
+
const auth = yield* XaiAuth
|
|
257
|
+
return (yield* HttpClient.HttpClient).pipe(
|
|
258
|
+
HttpClient.mapRequestEffect((request) =>
|
|
259
|
+
auth.get.pipe(
|
|
260
|
+
Effect.map((token) =>
|
|
261
|
+
request.pipe(
|
|
262
|
+
HttpClientRequest.bearerToken(token.access),
|
|
263
|
+
HttpClientRequest.setHeader("User-Agent", "clanka"),
|
|
264
|
+
),
|
|
265
|
+
),
|
|
266
|
+
Effect.orDie,
|
|
267
|
+
),
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
}),
|
|
271
|
+
).pipe(Layer.provide(XaiAuth.layer))
|
|
272
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -11,6 +11,7 @@ import * as Acp from "./Acp.ts"
|
|
|
11
11
|
import * as AgentExecutor from "./AgentExecutor.ts"
|
|
12
12
|
import * as Codex from "./Codex.ts"
|
|
13
13
|
import * as Copilot from "./Copilot.ts"
|
|
14
|
+
import * as Xai from "./Xai.ts"
|
|
14
15
|
import * as Agent from "./Agent.ts"
|
|
15
16
|
import * as Compaction from "./Compaction.ts"
|
|
16
17
|
import * as Stream from "effect/Stream"
|
|
@@ -32,9 +33,9 @@ import packageJson from "../package.json" with { type: "json" }
|
|
|
32
33
|
|
|
33
34
|
const version = packageJson.version
|
|
34
35
|
|
|
35
|
-
type Provider = "openai" | "copilot"
|
|
36
|
+
type Provider = "openai" | "copilot" | "xai"
|
|
36
37
|
|
|
37
|
-
const providers: ReadonlyArray<Provider> = ["openai", "copilot"]
|
|
38
|
+
const providers: ReadonlyArray<Provider> = ["openai", "copilot", "xai"]
|
|
38
39
|
|
|
39
40
|
const withSubagentModel = <E, R>(
|
|
40
41
|
model: Layer.Layer<
|
|
@@ -49,9 +50,13 @@ const modelLayer = (provider: Provider, model: string, effort: string) =>
|
|
|
49
50
|
? withSubagentModel(
|
|
50
51
|
Codex.modelWebSocket(model, { reasoning: { effort: effort as any } }),
|
|
51
52
|
).pipe(Layer.provide(Codex.layerClient))
|
|
52
|
-
:
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
: provider === "xai"
|
|
54
|
+
? withSubagentModel(
|
|
55
|
+
Xai.model(model, { reasoning: { effort: effort as any } }),
|
|
56
|
+
).pipe(Layer.provide(Xai.layerClient))
|
|
57
|
+
: withSubagentModel(Copilot.model(model, { reasoning: { effort } })).pipe(
|
|
58
|
+
Layer.provide(Copilot.layerClient),
|
|
59
|
+
)
|
|
55
60
|
|
|
56
61
|
const parseModelId = (modelId: string) => {
|
|
57
62
|
const [provider, model, effort, ...rest] = modelId.split("/")
|
|
@@ -89,27 +94,16 @@ const provider = Flag.Literals("provider", providers).pipe(
|
|
|
89
94
|
title: "copilot",
|
|
90
95
|
value: "copilot",
|
|
91
96
|
},
|
|
97
|
+
{
|
|
98
|
+
title: "xai",
|
|
99
|
+
value: "xai",
|
|
100
|
+
},
|
|
92
101
|
],
|
|
93
102
|
}),
|
|
94
103
|
),
|
|
95
104
|
)
|
|
96
105
|
|
|
97
|
-
const model = Flag.String("model").pipe(
|
|
98
|
-
Flag.withAlias("m"),
|
|
99
|
-
Flag.withFallbackPrompt(
|
|
100
|
-
Prompt.String({
|
|
101
|
-
message: "Enter a model",
|
|
102
|
-
default: "gpt-6-astra/medium",
|
|
103
|
-
validate(value) {
|
|
104
|
-
const parts = value.split("/")
|
|
105
|
-
if (parts.length !== 2) {
|
|
106
|
-
return Effect.fail("Invalid model")
|
|
107
|
-
}
|
|
108
|
-
return Effect.succeed(value)
|
|
109
|
-
},
|
|
110
|
-
}),
|
|
111
|
-
),
|
|
112
|
-
)
|
|
106
|
+
const model = Flag.String("model").pipe(Flag.withAlias("m"), Flag.optional)
|
|
113
107
|
|
|
114
108
|
const semantic = Flag.Directory("search").pipe(
|
|
115
109
|
Flag.withDescription(
|
|
@@ -237,7 +231,21 @@ Command.make("clanka", {
|
|
|
237
231
|
prompt: nonInteractivePrompt,
|
|
238
232
|
}) {
|
|
239
233
|
const stdio = yield* Stdio.Stdio
|
|
240
|
-
const
|
|
234
|
+
const selectedModel = yield* Option.match(modelRaw, {
|
|
235
|
+
onSome: Effect.succeed,
|
|
236
|
+
onNone: () =>
|
|
237
|
+
Prompt.String({
|
|
238
|
+
message: "Enter a model",
|
|
239
|
+
default:
|
|
240
|
+
provider === "xai" ? "grok-4.6/high" : "gpt-6-astra/medium",
|
|
241
|
+
validate(value) {
|
|
242
|
+
return value.split("/").length === 2
|
|
243
|
+
? Effect.succeed(value)
|
|
244
|
+
: Effect.fail("Invalid model")
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
})
|
|
248
|
+
const [model, reasoning] = selectedModel.split("/") as [string, string]
|
|
241
249
|
const Model = modelLayer(provider, model, reasoning)
|
|
242
250
|
|
|
243
251
|
return yield* Effect.gen(function* () {
|