diff --git a/JS/edgechains/arakoodev/src/ai/src/lib/palm2/palm2.ts b/JS/edgechains/arakoodev/src/ai/src/lib/palm2/palm2.ts new file mode 100644 index 000000000..bb14cc21f --- /dev/null +++ b/JS/edgechains/arakoodev/src/ai/src/lib/palm2/palm2.ts @@ -0,0 +1,93 @@ +import axios, { AxiosInstance } from "axios"; +import { retry } from "@lifeomic/attempt"; + +export interface Palm2AIConstructionOptions { + apiKey?: string; + model?: string; + apiVersion?: string; + httpClient?: AxiosInstance; +} + +export interface Palm2ChatOptions { + prompt: string; + model?: string; + temperature?: number; + maxOutputTokens?: number; + topP?: number; + topK?: number; + candidateCount?: number; + maxRetry?: number; + delay?: number; +} + +export interface Palm2Candidate { + output?: string; + safetyRatings?: Array>; + finishReason?: string; +} + +export interface Palm2Response { + candidates?: Palm2Candidate[]; + filters?: Array>; + safetyFeedback?: Array>; +} + +/** + * Google PaLM 2 text generation adapter. + * + * PaLM 2 uses the `generateText` REST endpoint and a `text-bison` model. The + * model and API version are configurable because Google may expose different + * model aliases across regions or transition endpoints over time. + */ +export class Palm2AI { + apiKey: string; + model: string; + apiVersion: string; + private readonly httpClient: AxiosInstance; + + constructor(options: Palm2AIConstructionOptions = {}) { + this.apiKey = options.apiKey || process.env.PALM2_API_KEY || process.env.GEMINI_API_KEY || ""; + this.model = options.model || process.env.PALM2_MODEL || "text-bison-001"; + this.apiVersion = options.apiVersion || "v1beta2"; + this.httpClient = options.httpClient || axios; + } + + private endpoint(model: string): string { + return `https://generativelanguage.googleapis.com/${this.apiVersion}/models/${encodeURIComponent( + model + )}:generateText`; + } + + async chat(chatOptions: Palm2ChatOptions): Promise { + if (!chatOptions.prompt?.trim()) throw new Error("prompt is required"); + const model = chatOptions.model || this.model; + const body = { + prompt: { text: chatOptions.prompt }, + ...(chatOptions.temperature !== undefined + ? { temperature: chatOptions.temperature } + : {}), + ...(chatOptions.maxOutputTokens !== undefined + ? { maxOutputTokens: chatOptions.maxOutputTokens } + : {}), + ...(chatOptions.topP !== undefined ? { topP: chatOptions.topP } : {}), + ...(chatOptions.topK !== undefined ? { topK: chatOptions.topK } : {}), + ...(chatOptions.candidateCount !== undefined + ? { candidateCount: chatOptions.candidateCount } + : {}), + }; + return retry( + async () => + (await this.httpClient.post(this.endpoint(model), body, { + headers: { + "Content-Type": "application/json", + "x-goog-api-key": this.apiKey, + }, + })).data, + { maxAttempts: chatOptions.maxRetry || 3, delay: chatOptions.delay || 200 } + ); + } + + async generateText(chatOptions: Palm2ChatOptions): Promise { + return this.chat(chatOptions); + } +} diff --git a/JS/edgechains/arakoodev/src/ai/src/tests/palm2.test.ts b/JS/edgechains/arakoodev/src/ai/src/tests/palm2.test.ts new file mode 100644 index 000000000..16ec09966 --- /dev/null +++ b/JS/edgechains/arakoodev/src/ai/src/tests/palm2.test.ts @@ -0,0 +1,40 @@ +import axios from "axios"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Palm2AI } from "../lib/palm2/palm2.js"; + +vi.mock("axios"); + +describe("Palm2AI", () => { + beforeEach(() => vi.clearAllMocks()); + + it("calls the PaLM 2 generateText endpoint", async () => { + vi.mocked(axios.post).mockResolvedValueOnce({ + data: { candidates: [{ output: "hello" }] }, + } as any); + + const client = new Palm2AI({ apiKey: "test-key" }); + const result = await client.chat({ + prompt: "Say hello", + temperature: 0.2, + maxOutputTokens: 64, + }); + + expect(axios.post).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com/v1beta2/models/text-bison-001:generateText", + { + prompt: { text: "Say hello" }, + temperature: 0.2, + maxOutputTokens: 64, + }, + expect.objectContaining({ + headers: expect.objectContaining({ "x-goog-api-key": "test-key" }), + }) + ); + expect(result.candidates?.[0]?.output).toBe("hello"); + }); + + it("rejects empty prompts", async () => { + const client = new Palm2AI({ apiKey: "test-key" }); + await expect(client.generateText({ prompt: " " })).rejects.toThrow("prompt is required"); + }); +}); diff --git a/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts new file mode 100644 index 000000000..49ded5445 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,237 @@ +export type QdrantDistance = "Cosine" | "Dot" | "Euclid" | "Manhattan"; + +export type QdrantVector = number[] | Record; + +export interface QdrantPoint { + id: string | number; + vector: QdrantVector; + payload?: Record; +} + +export interface QdrantCreateCollectionArgs { + collectionName: string; + vectorSize: number; + distance?: QdrantDistance; +} + +export interface QdrantSearchArgs { + collectionName: string; + vector: QdrantVector; + limit?: number; + filter?: Record; + withPayload?: boolean | string[]; + withVector?: boolean; + scoreThreshold?: number; +} + +export interface QdrantScrollArgs { + collectionName: string; + limit?: number; + offset?: string | number; + filter?: Record; + withPayload?: boolean | string[]; + withVector?: boolean; +} + +type QdrantFetcher = ( + input: string | URL | Request, + init?: RequestInit +) => Promise; + +/** + * Small REST client for Qdrant's HTTP API. + * + * The client intentionally uses fetch instead of the Qdrant SDK so it can be + * used in Node, browsers, and EdgeChains' WASM-oriented examples without an + * additional database dependency. + */ +export class Qdrant { + QDRANT_URL: string; + QDRANT_API_KEY: string; + private readonly fetcher: QdrantFetcher; + + constructor( + QDRANT_URL = process.env.QDRANT_URL || "http://localhost:6333", + QDRANT_API_KEY = process.env.QDRANT_API_KEY || "", + fetcher: QdrantFetcher = globalThis.fetch.bind(globalThis) + ) { + this.QDRANT_URL = QDRANT_URL.replace(/\/$/, ""); + this.QDRANT_API_KEY = QDRANT_API_KEY; + this.fetcher = fetcher; + } + + /** Return this client, mirroring the Supabase adapter's createClient API. */ + createClient(): Qdrant { + return this; + } + + private collectionPath(collectionName: string, suffix = ""): string { + return `/collections/${encodeURIComponent(collectionName)}${suffix}`; + } + + private async request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set("content-type", "application/json"); + if (this.QDRANT_API_KEY) headers.set("api-key", this.QDRANT_API_KEY); + + const response = await this.fetcher(`${this.QDRANT_URL}${path}`, { + ...init, + headers, + }); + const text = await response.text(); + let body: any; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + + if (!response.ok) { + const message = + body?.status?.error || body?.error || body || `${response.status} ${response.statusText}`; + throw new Error(`Qdrant request failed (${response.status}): ${message}`); + } + + return (body && Object.prototype.hasOwnProperty.call(body, "result") ? body.result : body) as T; + } + + async createCollection( + collectionName: string, + vectorSize: number, + distance?: QdrantDistance + ): Promise; + async createCollection(args: QdrantCreateCollectionArgs): Promise; + async createCollection( + collectionOrArgs: string | QdrantCreateCollectionArgs, + vectorSize?: number, + distance: QdrantDistance = "Cosine" + ): Promise { + const args = + typeof collectionOrArgs === "string" + ? { collectionName: collectionOrArgs, vectorSize, distance } + : { distance: "Cosine" as QdrantDistance, ...collectionOrArgs }; + const size = args.vectorSize; + if (!args.collectionName || !Number.isInteger(size) || size === undefined || size <= 0) { + throw new Error("collectionName and a positive integer vectorSize are required"); + } + return this.request(this.collectionPath(args.collectionName), { + method: "PUT", + body: JSON.stringify({ vectors: { size, distance: args.distance } }), + }); + } + + async getCollectionInfo(collectionName: string): Promise { + return this.request(this.collectionPath(collectionName)); + } + + async deleteCollection(collectionName: string): Promise { + return this.request(this.collectionPath(collectionName), { method: "DELETE" }); + } + + async upsertPoints( + collectionName: string, + points: QdrantPoint[], + wait?: boolean + ): Promise; + async upsertPoints(args: { + collectionName: string; + points: QdrantPoint[]; + wait?: boolean; + }): Promise; + async upsertPoints( + collectionOrArgs: string | { collectionName: string; points: QdrantPoint[]; wait?: boolean }, + points?: QdrantPoint[], + wait = true + ): Promise { + const args = + typeof collectionOrArgs === "string" + ? { collectionName: collectionOrArgs, points: points || [], wait } + : { wait: true, ...collectionOrArgs }; + if (!args.collectionName || args.points.length === 0) { + throw new Error("collectionName and at least one point are required"); + } + const query = args.wait === undefined ? "" : `?wait=${args.wait}`; + return this.request(this.collectionPath(args.collectionName, `/points${query}`), { + method: "PUT", + body: JSON.stringify({ points: args.points }), + }); + } + + async search( + collectionName: string, + vector: QdrantVector, + limit?: number, + options?: Omit + ): Promise; + async search(args: QdrantSearchArgs): Promise; + async search( + collectionOrArgs: string | QdrantSearchArgs, + vector?: QdrantVector, + limit = 10, + options: Omit = {} + ): Promise { + const args = + typeof collectionOrArgs === "string" + ? { collectionName: collectionOrArgs, vector, limit, ...options } + : { limit: 10, ...collectionOrArgs }; + if (!args.collectionName || !args.vector) { + throw new Error("collectionName and vector are required"); + } + return this.request(this.collectionPath(args.collectionName, "/points/search"), { + method: "POST", + body: JSON.stringify({ + vector: args.vector, + limit: args.limit, + ...(args.filter ? { filter: args.filter } : {}), + ...(args.withPayload !== undefined ? { with_payload: args.withPayload } : {}), + ...(args.withVector !== undefined ? { with_vector: args.withVector } : {}), + ...(args.scoreThreshold !== undefined ? { score_threshold: args.scoreThreshold } : {}), + }), + }); + } + + async scroll(args: QdrantScrollArgs): Promise { + const { collectionName, limit = 10, offset, filter, withPayload, withVector } = args; + return this.request(this.collectionPath(collectionName, "/points/scroll"), { + method: "POST", + body: JSON.stringify({ + limit, + ...(offset !== undefined ? { offset } : {}), + ...(filter ? { filter } : {}), + ...(withPayload !== undefined ? { with_payload: withPayload } : {}), + ...(withVector !== undefined ? { with_vector: withVector } : {}), + }), + }); + } + + async getDataById({ + collectionName, + id, + withPayload = true, + withVector = false, + }: { + collectionName: string; + id: string | number; + withPayload?: boolean | string[]; + withVector?: boolean; + }): Promise { + return this.request(this.collectionPath(collectionName, `/points/${encodeURIComponent(String(id))}`) + + `?with_payload=${withPayload}&with_vector=${withVector}`); + } + + async deleteById({ + collectionName, + id, + wait = true, + }: { + collectionName: string; + id: string | number; + wait?: boolean; + }): Promise { + return this.request(this.collectionPath(collectionName, `/points/${encodeURIComponent(String(id))}?wait=${wait}`), { + method: "DELETE", + }); + } +} diff --git a/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant.test.ts b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant.test.ts new file mode 100644 index 000000000..98d6c8b63 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant.test.ts @@ -0,0 +1,60 @@ +import { Qdrant } from "../lib/qdrant/qdrant.js"; +import { describe, expect, it } from "vitest"; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("Qdrant REST client", () => { + it("creates collections and sends the api key", async () => { + const calls: Array<[string, RequestInit | undefined]> = []; + const client = new Qdrant("https://qdrant.example/", "secret", async (url, init) => { + calls.push([String(url), init]); + return response({ result: true }); + }); + + await client.createCollection({ collectionName: "docs", vectorSize: 3, distance: "Dot" }); + + expect(calls[0][0]).toBe("https://qdrant.example/collections/docs"); + expect(calls[0][1]?.method).toBe("PUT"); + expect((calls[0][1]?.headers as Headers).get("api-key")).toBe("secret"); + expect(JSON.parse(String(calls[0][1]?.body))).toEqual({ + vectors: { size: 3, distance: "Dot" }, + }); + }); + + it("upserts points and searches with the Qdrant field names", async () => { + const requests: string[] = []; + const client = new Qdrant("http://localhost:6333", "", async (url, init) => { + requests.push(`${String(url)} ${String(init?.body)}`); + return response({ result: [{ id: 1, score: 0.9 }] }); + }); + + await client.upsertPoints("docs", [{ id: 1, vector: [1, 0], payload: { text: "hello" } }]); + const results = await client.search({ + collectionName: "docs", + vector: [1, 0], + limit: 5, + withPayload: true, + withVector: false, + }); + + expect(requests[0]).toContain("/collections/docs/points?wait=true"); + expect(requests[1]).toContain("/collections/docs/points/search"); + expect(requests[1]).toContain('"with_payload":true'); + expect(results).toEqual([{ id: 1, score: 0.9 }]); + }); + + it("surfaces Qdrant API errors", async () => { + const client = new Qdrant("http://localhost:6333", "", async () => + response({ status: { error: "collection not found" } }, 404) + ); + + await expect(client.getCollectionInfo("missing")).rejects.toThrow( + "Qdrant request failed (404): collection not found" + ); + }); +}); diff --git a/JS/edgechains/examples/palm2/jsonnet/main.jsonnet b/JS/edgechains/examples/palm2/jsonnet/main.jsonnet new file mode 100644 index 000000000..968080ed6 --- /dev/null +++ b/JS/edgechains/examples/palm2/jsonnet/main.jsonnet @@ -0,0 +1,4 @@ +local prompt = std.extVar('prompt'); +local apiKey = std.extVar('palm2_api_key'); + +arakoo.native('palm2Call')({ prompt: prompt, apiKey: apiKey }) diff --git a/JS/edgechains/examples/palm2/jsonnet/secrets.jsonnet b/JS/edgechains/examples/palm2/jsonnet/secrets.jsonnet new file mode 100644 index 000000000..1d8e23393 --- /dev/null +++ b/JS/edgechains/examples/palm2/jsonnet/secrets.jsonnet @@ -0,0 +1,5 @@ +local PALM2_API_KEY = "your-google-api-key"; + +{ + "palm2_api_key": PALM2_API_KEY, +} diff --git a/JS/edgechains/examples/palm2/src/index.ts b/JS/edgechains/examples/palm2/src/index.ts new file mode 100644 index 000000000..e5024f600 --- /dev/null +++ b/JS/edgechains/examples/palm2/src/index.ts @@ -0,0 +1,22 @@ +import Jsonnet from "@arakoodev/jsonnet"; +import { Palm2AI } from "@arakoodev/edgechains.js/ai"; +import fileURLToPath from "file-uri-to-path"; +import path from "path"; + +const jsonnet = new Jsonnet(); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const palm2Call = async ({ prompt, apiKey }: { prompt: string; apiKey: string }) => { + const client = new Palm2AI({ apiKey }); + return JSON.stringify(await client.generateText({ prompt })); +}; + +export async function generate(prompt: string): Promise { + const secrets = JSON.parse( + jsonnet.evaluateFile(path.join(__dirname, "../jsonnet/secrets.jsonnet")) + ); + jsonnet.extString("palm2_api_key", secrets.palm2_api_key); + jsonnet.extString("prompt", prompt); + jsonnet.javascriptCallback("palm2Call", palm2Call); + return jsonnet.evaluateFile(path.join(__dirname, "../jsonnet/main.jsonnet")); +}