symmetry-cli 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,322 @@
1
+ import { PassThrough, Readable } from "node:stream";
2
+ import { pipeline } from "stream/promises";
3
+ import chalk from "chalk";
4
+ import Hyperswarm from "hyperswarm";
5
+ import crypto from "hypercore-crypto";
6
+ import fs from "node:fs";
7
+
8
+ import { ConfigManager } from "./config";
9
+ import {
10
+ createMessage,
11
+ getChatDataFromProvider,
12
+ safeParseJson,
13
+ safeParseStreamResponse,
14
+ } from "./utils";
15
+ import { logger } from "./logger";
16
+ import { Peer, ProviderMessage, InferenceRequest } from "./types";
17
+ import {
18
+ serverMessageKeys,
19
+ } from "./constants";
20
+
21
+ export class SymmetryProvider {
22
+ private _challenge: Buffer | null = null;
23
+ private _config: ConfigManager;
24
+ private _conversationIndex = 0;
25
+ private _discoveryKey: Buffer | null = null;
26
+ private _isPublic = false;
27
+ private _providerConnections: number = 0;
28
+ private _providerSwarm: Hyperswarm | null = null;
29
+ private _serverPeer: Peer | null = null;
30
+
31
+ constructor(configPath: string) {
32
+ logger.info(`🔗 Initializing client using config file: ${configPath}`);
33
+ this._config = new ConfigManager(configPath);
34
+ this._isPublic = this._config.get("public");
35
+ }
36
+
37
+ async init(): Promise<void> {
38
+ this._providerSwarm = new Hyperswarm({
39
+ maxConnections: this._config.get("maxConnections"),
40
+ });
41
+ const keyPair = crypto.keyPair(
42
+ Buffer.alloc(32).fill(this._config.get("name"))
43
+ );
44
+ this._discoveryKey = crypto.discoveryKey(keyPair.publicKey);
45
+ const discovery = this._providerSwarm.join(this._discoveryKey, {
46
+ server: true,
47
+ client: true,
48
+ });
49
+ await discovery.flushed();
50
+
51
+ this._providerSwarm.on("error", (err: Error) => {
52
+ logger.error(chalk.red("🚨 Swarm Error:"), err);
53
+ });
54
+
55
+ this._providerSwarm.on("connection", (peer: Peer) => {
56
+ logger.info(`⚡️ New connection from peer: ${peer.rawStream.remoteHost}`);
57
+ this.listeners(peer);
58
+ });
59
+
60
+ logger.info(`📁 Symmetry client initialized.`);
61
+ logger.info(`🔑 Discovery key: ${this._discoveryKey.toString("hex")}`);
62
+
63
+ if (this._isPublic) {
64
+ logger.info(
65
+ chalk.white(`🔑 Server key: ${this._config.get("serverKey")}`)
66
+ );
67
+ logger.info(chalk.white("🔗 Joining server, please wait."));
68
+ await this.joinServer();
69
+ }
70
+
71
+ process.on("SIGINT", async () => {
72
+ await this._providerSwarm?.destroy();
73
+ process.exit(0);
74
+ });
75
+
76
+ process.on("uncaughtException", (err) => {
77
+ if (err.message === "connection reset by peer") {
78
+ this._providerConnections = Math.max(0, this._providerConnections - 1);
79
+ }
80
+ });
81
+ }
82
+
83
+ async joinServer(): Promise<void> {
84
+ const serverSwarm = new Hyperswarm();
85
+ const serverKey = Buffer.from(this._config.get("serverKey"));
86
+ serverSwarm.join(crypto.discoveryKey(serverKey), {
87
+ client: true,
88
+ server: false,
89
+ });
90
+ serverSwarm.flush();
91
+ serverSwarm.on("connection", (peer: Peer) => {
92
+ this._serverPeer = peer;
93
+ logger.info(chalk.green("🔗 Connected to server."));
94
+
95
+ this._challenge = crypto.randomBytes(32);
96
+
97
+ this._serverPeer.write(
98
+ createMessage(serverMessageKeys.challenge, {
99
+ challenge: this._challenge,
100
+ })
101
+ );
102
+
103
+ this._serverPeer.write(
104
+ createMessage(serverMessageKeys.join, {
105
+ ...this._config.getAll(),
106
+ discoveryKey: this._discoveryKey?.toString("hex"),
107
+ })
108
+ );
109
+
110
+ this._serverPeer.on("data", async (buffer: Buffer) => {
111
+ if (!buffer) return;
112
+
113
+ const data = safeParseJson<
114
+ ProviderMessage<{ message: string; signature: { data: string } }>
115
+ >(buffer.toString());
116
+
117
+ if (data && data.key) {
118
+ switch (data.key) {
119
+ case serverMessageKeys.challenge:
120
+ this.handleServerVerification(
121
+ data.data as { message: string; signature: { data: string } }
122
+ );
123
+ break;
124
+ case serverMessageKeys.ping:
125
+ this._serverPeer?.write(createMessage(serverMessageKeys.pong));
126
+ break;
127
+ }
128
+ }
129
+ });
130
+ });
131
+ }
132
+
133
+ getServerPublicKey(serverKeyHex: string): Buffer {
134
+ const publicKey = Buffer.from(serverKeyHex, "hex");
135
+ if (publicKey.length !== 32) {
136
+ throw new Error(
137
+ `Expected a 32-byte public key, but got ${publicKey.length} bytes`
138
+ );
139
+ }
140
+ return publicKey;
141
+ }
142
+
143
+ handleServerVerification(data: {
144
+ message: string;
145
+ signature: { data: string };
146
+ }) {
147
+ if (!this._challenge) {
148
+ console.log("No challenge set. Cannot verify.");
149
+ return;
150
+ }
151
+
152
+ const serverKeyHex = this._config.get("serverKey");
153
+ try {
154
+ const publicKey = this.getServerPublicKey(serverKeyHex);
155
+ const signatureBuffer = Buffer.from(data.signature.data, "base64");
156
+
157
+ const verified = crypto.verify(
158
+ this._challenge,
159
+ signatureBuffer,
160
+ publicKey
161
+ );
162
+
163
+ if (verified) {
164
+ logger.info(chalk.greenBright(`✅ Verification successful.`));
165
+ } else {
166
+ logger.error(`❌ Verification failed!`);
167
+ }
168
+ } catch (error) {
169
+ console.error("Error during verification:", error);
170
+ }
171
+ }
172
+
173
+ private listeners(peer: Peer): void {
174
+ peer.on("data", async (buffer: Buffer) => {
175
+ if (!buffer) return;
176
+ const data = safeParseJson<ProviderMessage<InferenceRequest>>(
177
+ buffer.toString()
178
+ );
179
+ if (data && data.key) {
180
+ switch (data.key) {
181
+ case serverMessageKeys.newConversation:
182
+ this._conversationIndex = this._conversationIndex + 1;
183
+ break;
184
+ case serverMessageKeys.inference:
185
+ logger.info(
186
+ `📦 Inference message received from ${peer.rawStream.remoteHost}`
187
+ );
188
+ await this.handleInferenceRequest(data, peer);
189
+ break;
190
+ }
191
+ }
192
+ });
193
+ }
194
+
195
+ private async handleInferenceRequest(
196
+ data: ProviderMessage<InferenceRequest>,
197
+ peer: Peer
198
+ ): Promise<void> {
199
+ const emitterKey = data.data.key;
200
+
201
+ const req = this.buildStreamRequest(data?.data.messages);
202
+
203
+ if (!req) return;
204
+
205
+ const { requestOptions, requestBody } = req;
206
+ const { protocol, hostname, port, path, method, headers } = requestOptions;
207
+ const url = `${protocol}://${hostname}:${port}${path}`;
208
+
209
+ try {
210
+ const response = await fetch(url, {
211
+ method,
212
+ headers,
213
+ body: JSON.stringify(requestBody),
214
+ });
215
+
216
+ if (!response.ok) {
217
+ throw new Error(
218
+ `Server responded with status code: ${response.status}`
219
+ );
220
+ }
221
+
222
+ if (!response.body) {
223
+ throw new Error("Failed to get a ReadableStream from the response");
224
+ }
225
+
226
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
227
+ const responseStream = Readable.fromWeb(response.body as any);
228
+ const peerStream = new PassThrough();
229
+ responseStream.pipe(peerStream);
230
+ let completion = "";
231
+
232
+ const provider = this._config.get("apiProvider");
233
+
234
+ peer.write(
235
+ JSON.stringify({
236
+ symmetryEmitterKey: emitterKey,
237
+ })
238
+ );
239
+
240
+ const peerPipeline = pipeline(peerStream, async function (source) {
241
+ for await (const chunk of source) {
242
+ if (peer.writable) {
243
+ completion += getChatDataFromProvider(
244
+ provider,
245
+ safeParseStreamResponse(chunk.toString())
246
+ );
247
+
248
+ const write = peer.write(chunk);
249
+
250
+ if (!write) {
251
+ await new Promise((resolve) => peer.once("drain", resolve));
252
+ }
253
+ } else {
254
+ break;
255
+ }
256
+ }
257
+ });
258
+ await Promise.resolve(peerPipeline);
259
+
260
+ peer.write(
261
+ createMessage(serverMessageKeys.inferenceEnded, data?.data.key)
262
+ );
263
+
264
+ if (
265
+ this._config.get("dataCollectionEnabled") &&
266
+ data.data.key === serverMessageKeys.inference
267
+ ) {
268
+ this.saveCompletion(completion, peer, data.data.messages);
269
+ }
270
+ } catch (error) {
271
+ let errorMessage = "An error occurred during inference";
272
+ if (error instanceof Error) errorMessage = error.message;
273
+ logger.error(`🚨 ${errorMessage}`);
274
+ }
275
+ }
276
+
277
+ private async saveCompletion(
278
+ completion: string,
279
+ peer: Peer,
280
+ messages: { role: string; content: string }[]
281
+ ) {
282
+ fs.writeFile(
283
+ `${this._config.get("path")}/${peer.publicKey.toString("hex")}-${
284
+ this._conversationIndex
285
+ }.json`,
286
+ JSON.stringify([
287
+ ...messages,
288
+ {
289
+ role: "assistant",
290
+ content: completion,
291
+ },
292
+ ]),
293
+ () => {
294
+ logger.info(`📝 Completion saved to file`);
295
+ }
296
+ );
297
+ }
298
+
299
+ private buildStreamRequest(messages: { role: string; content: string }[]) {
300
+ const requestOptions = {
301
+ hostname: this._config.get("apiHostname"),
302
+ port: Number(this._config.get("apiPort")),
303
+ path: this._config.get("apiPath"),
304
+ protocol: this._config.get("apiProtocol"),
305
+ method: "POST",
306
+ headers: {
307
+ "Content-Type": "application/json",
308
+ Authorization: `Bearer ${this._config.get("apiKey")}`,
309
+ },
310
+ };
311
+
312
+ const requestBody = {
313
+ model: this._config.get("modelName"),
314
+ messages: messages || undefined,
315
+ stream: true,
316
+ };
317
+
318
+ return { requestOptions, requestBody };
319
+ }
320
+ }
321
+
322
+ export default SymmetryProvider;
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import os from "os";
4
+ import path from "path";
5
+
6
+ import { SymmetryProvider } from "./provider";
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .version("1.0.0")
12
+ .description("symmetry cli")
13
+ .option(
14
+ "-c, --config <path>",
15
+ "Path to config file",
16
+ path.join(os.homedir(), ".config", "symmetry", "provider.yaml")
17
+ )
18
+ .action(async () => {
19
+ const client = new SymmetryProvider(program.opts().config);
20
+ await client.init();
21
+ });
22
+
23
+ program.parse(process.argv);
package/src/types.ts ADDED
@@ -0,0 +1,242 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { serverMessageKeys } from "./constants";
3
+
4
+ export interface ProviderConfig {
5
+ apiHostname: string;
6
+ dataCollectionEnabled: boolean;
7
+ apiKey?: string;
8
+ apiPath: string;
9
+ apiPort: number;
10
+ apiProtocol: string;
11
+ apiProvider: string;
12
+ discoveryKey: string;
13
+ key: string;
14
+ maxConnections: number;
15
+ modelName: string;
16
+ name: string;
17
+ path: string;
18
+ port: number;
19
+ public: boolean;
20
+ serverKey: string;
21
+ }
22
+
23
+ export interface ProviderMessage<T = unknown> {
24
+ key: string;
25
+ data: T;
26
+ }
27
+
28
+ export interface InferenceRequest {
29
+ key: string;
30
+ messages: { role: string; content: string }[];
31
+ }
32
+
33
+ interface ReadableState {
34
+ highWaterMark: number;
35
+ buffer: any;
36
+ length: number;
37
+ pipes: any[];
38
+ flowing: boolean | null;
39
+ ended: boolean;
40
+ endEmitted: boolean;
41
+ reading: boolean;
42
+ sync: boolean;
43
+ needReadable: boolean;
44
+ emittedReadable: boolean;
45
+ readableListening: boolean;
46
+ resumeScheduled: boolean;
47
+ paused: boolean;
48
+ emitClose: boolean;
49
+ autoDestroy: boolean;
50
+ destroyed: boolean;
51
+ closed: boolean;
52
+ closeEmitted: boolean;
53
+ defaultEncoding: string;
54
+ awaitDrainWriters: any;
55
+ multiAwaitDrain: boolean;
56
+ readingMore: boolean;
57
+ decoder: null | any;
58
+ encoding: null | string;
59
+ }
60
+
61
+ interface WritableState {
62
+ highWaterMark: number;
63
+ objectMode: boolean;
64
+ finalCalled: boolean;
65
+ needDrain: boolean;
66
+ ending: boolean;
67
+ ended: boolean;
68
+ finished: boolean;
69
+ destroyed: boolean;
70
+ decodeStrings: boolean;
71
+ defaultEncoding: string;
72
+ length: number;
73
+ writing: boolean;
74
+ corked: number;
75
+ sync: boolean;
76
+ bufferProcessing: boolean;
77
+ writecb: () => void;
78
+ writelen: number;
79
+ afterWriteTickInfo: null | any;
80
+ bufferedRequest: null | any;
81
+ lastBufferedRequest: null | any;
82
+ pendingcb: number;
83
+ prefinished: boolean;
84
+ errorEmitted: boolean;
85
+ emitClose: boolean;
86
+ autoDestroy: boolean;
87
+ bufferedRequestCount: number;
88
+ corkedRequestsFree: any;
89
+ }
90
+
91
+ interface UDXStream {
92
+ udx: UDX;
93
+ socket: UDXSocket;
94
+ id: number;
95
+ remoteId: number;
96
+ remoteHost: string;
97
+ remoteFamily: number;
98
+ remotePort: number;
99
+ userData: any;
100
+ }
101
+
102
+ interface UDX {
103
+ _handle: Buffer;
104
+ _watchers: Set<any>;
105
+ _buffer: Buffer;
106
+ }
107
+
108
+ interface UDXSocket {
109
+ udx: UDX;
110
+ _handle: Buffer;
111
+ _inited: boolean;
112
+ _host: string;
113
+ _family: number;
114
+ _ipv6Only: boolean;
115
+ _port: number;
116
+ _reqs: any[];
117
+ _free: any[];
118
+ _closing: null | any;
119
+ _closed: boolean;
120
+ streams: Set<any>;
121
+ userData: any;
122
+ }
123
+
124
+ export interface Peer {
125
+ publicKey: Buffer;
126
+ remotePublicKey: Buffer;
127
+ handshakeHash: Buffer;
128
+ write: (value: string) => boolean;
129
+ on: (event: string, listener: (...args: any[]) => void) => this;
130
+ once: (event: string, listener: (...args: any[]) => void) => this;
131
+ writable: boolean;
132
+ key: string;
133
+ discovery_key: string;
134
+
135
+ _duplexState: number;
136
+ _readableState: ReadableState;
137
+ _writableState: WritableState;
138
+
139
+ noiseStream: Peer;
140
+ isInitiator: boolean;
141
+ rawStream: UDXStream;
142
+
143
+ connected: boolean;
144
+ keepAlive: number;
145
+ timeout: number;
146
+ userData: any;
147
+ opened: Promise<void>;
148
+ rawBytesWritten: number;
149
+ rawBytesRead: number;
150
+ relay: null | any;
151
+ puncher: null | any;
152
+ _rawStream: UDXStream;
153
+ _handshake: null | any;
154
+ _handshakePattern: null | any;
155
+ _handshakeDone: null | any;
156
+ _state: number;
157
+ _len: number;
158
+ _tmp: number;
159
+ _message: null | any;
160
+ _openedDone: () => void;
161
+ _startDone: () => void;
162
+ _drainDone: () => void;
163
+ _outgoingPlain: null | any;
164
+ _outgoingWrapped: null | any;
165
+ _utp: null | any;
166
+ _setup: boolean;
167
+ _ended: number;
168
+ _encrypt: {
169
+ key: Buffer;
170
+ state: Buffer;
171
+ header: Buffer;
172
+ };
173
+ _decrypt: {
174
+ key: Buffer;
175
+ state: Buffer;
176
+ final: boolean;
177
+ };
178
+ _timeoutTimer: null | NodeJS.Timeout;
179
+ _keepAliveTimer: null | NodeJS.Timeout;
180
+ }
181
+
182
+ export interface Session {
183
+ id: string;
184
+ providerId: string;
185
+ createdAt: Date;
186
+ expiresAt: Date;
187
+ }
188
+
189
+ export interface PeerSessionRequest {
190
+ modelName: string;
191
+ preferredProviderId?: string;
192
+ }
193
+
194
+ export interface PeerWithSession extends Session {
195
+ peer_key: string | null;
196
+ discovery_key: string | null;
197
+ model_name: string | null;
198
+ }
199
+
200
+ export interface PeerUpsert {
201
+ key: string;
202
+ discoveryKey: string;
203
+ config: {
204
+ modelName?: string;
205
+ public?: boolean;
206
+ serverKey?: string;
207
+ };
208
+ }
209
+
210
+ export interface Message {
211
+ role: string;
212
+ content: string | undefined;
213
+ }
214
+
215
+ export type ServerMessageKey = keyof typeof serverMessageKeys;
216
+
217
+ export interface StreamResponse {
218
+ model: string
219
+ created_at: string
220
+ response: string
221
+ content: string
222
+ message: {
223
+ content: string
224
+ }
225
+ done: boolean
226
+ context: number[]
227
+ total_duration: number
228
+ load_duration: number
229
+ prompt_eval_count: number
230
+ prompt_eval_duration: number
231
+ eval_count: number
232
+ eval_duration: number
233
+ type? : string
234
+ choices: [
235
+ {
236
+ text: string
237
+ delta: {
238
+ content: string
239
+ }
240
+ }
241
+ ]
242
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { apiProviders } from "./constants";
2
+ import { ServerMessageKey, StreamResponse } from "./types";
3
+
4
+ export function safeParseJson<T>(data: string): T | undefined {
5
+ try {
6
+ return JSON.parse(data) as T;
7
+ } catch (e) {
8
+ return undefined;
9
+ }
10
+ }
11
+
12
+ export function createMessage<T>(key: ServerMessageKey, data?: T): string {
13
+ return JSON.stringify({ key, data });
14
+ }
15
+
16
+ export function isStreamWithDataPrefix(stringBuffer: string) {
17
+ return stringBuffer.startsWith('data:')
18
+ }
19
+
20
+ export function safeParseStreamResponse(
21
+ stringBuffer: string
22
+ ): StreamResponse | undefined {
23
+ try {
24
+ if (isStreamWithDataPrefix(stringBuffer)) {
25
+ return JSON.parse(stringBuffer.split('data:')[1])
26
+ }
27
+ return JSON.parse(stringBuffer)
28
+ } catch (e) {
29
+ return undefined
30
+ }
31
+ }
32
+
33
+ export const getChatDataFromProvider = (
34
+ provider: string,
35
+ data: StreamResponse | undefined
36
+ ) => {
37
+ switch (provider) {
38
+ case apiProviders.Ollama:
39
+ case apiProviders.OpenWebUI:
40
+ return data?.choices[0].delta?.content
41
+ ? data?.choices[0].delta.content
42
+ : ''
43
+ case apiProviders.LlamaCpp:
44
+ return data?.content
45
+ case apiProviders.LiteLLM:
46
+ default:
47
+ if (data?.choices[0].delta.content === 'undefined') return ''
48
+ return data?.choices[0].delta?.content
49
+ ? data?.choices[0].delta.content
50
+ : ''
51
+ }
52
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2018",
4
+ "module": "CommonJS",
5
+ "outDir": "./dist",
6
+ "rootDir": "src",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "lib": ["ES2018", "DOM", "DOM.Iterable"],
12
+ "types": ["node", "jest"],
13
+ "typeRoots": ["./node_modules/@types", "."],
14
+ "moduleResolution": "node"
15
+ },
16
+ "ts-node": {
17
+ "files": true
18
+ },
19
+ "files": [
20
+ "global.d.ts"
21
+ ],
22
+ "jest": {
23
+ "preset": "ts-jest",
24
+ "testEnvironment": "node"
25
+ },
26
+ "include": ["src/**/*", "__tests__/**/*", "global.d.ts"],
27
+ "exclude": ["node_modules"],
28
+ }