diff --git a/JS/edgechains/arakoodev/src/ai/src/index.ts b/JS/edgechains/arakoodev/src/ai/src/index.ts index 2c98f37dc..d2ccf0eb0 100644 --- a/JS/edgechains/arakoodev/src/ai/src/index.ts +++ b/JS/edgechains/arakoodev/src/ai/src/index.ts @@ -1,5 +1,6 @@ export { OpenAI } from "./lib/openai/openai.js"; export { GeminiAI } from "./lib/gemini/gemini.js"; +export { Palm2AI } from "./lib/palm2/palm2.js"; export { LlamaAI } from "./lib/llama/llama.js"; export { RetellAI } from "./lib/retell-ai/retell.js"; export { RetellWebClient } from "./lib/retell-ai/retellWebClient.js"; 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..1383289ca --- /dev/null +++ b/JS/edgechains/arakoodev/src/ai/src/lib/palm2/palm2.ts @@ -0,0 +1,192 @@ +import axios from "axios"; +import { retry } from "@lifeomic/attempt"; + +const baseUrl = "https://generativelanguage.googleapis.com/v1beta2/models"; + +type Palm2TextModel = "text-bison-001"; +type Palm2ChatModel = "chat-bison-001"; +type Palm2EmbeddingModel = "embedding-gecko-001"; + +interface Palm2ConstructionOptions { + apiKey?: string; +} + +type SafetyRating = { + category: string; + probability: "NEGLIGIBLE" | "LOW" | "MEDIUM" | "HIGH"; +}; + +type ContentFilter = { + reason: string; + message?: string; +}; + +type TextCompletion = { + output: string; + safetyRatings?: SafetyRating[]; +}; + +interface Palm2GenerateTextResponse { + candidates: TextCompletion[]; + filters?: ContentFilter[]; + safetyFeedback?: object[]; +} + +type Message = { + author?: string; + content: string; +}; + +type Example = { + input: Message; + output: Message; +}; + +interface Palm2GenerateMessageResponse { + candidates: Message[]; + messages: Message[]; + filters?: ContentFilter[]; +} + +interface Palm2EmbeddingResponse { + embedding: { + value: number[]; + }; +} + +interface Palm2GenerateTextOptions { + model?: Palm2TextModel; + prompt: string; + temperature?: number; + max_output_tokens?: number; + candidate_count?: number; + top_p?: number; + top_k?: number; + stop_sequences?: string[]; + max_retry?: number; + delay?: number; +} + +interface Palm2ChatOptions { + model?: Palm2ChatModel; + prompt?: string; + context?: string; + examples?: Example[]; + messages?: Message[]; + temperature?: number; + candidate_count?: number; + top_p?: number; + top_k?: number; + max_retry?: number; + delay?: number; +} + +interface Palm2EmbeddingOptions { + model?: Palm2EmbeddingModel; + text: string; + max_retry?: number; + delay?: number; +} + +// Strip undefined keys so optional params never leak onto the wire payload. +function removeUndefined>(obj: T): Partial { + return Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined) + ) as Partial; +} + +export class Palm2AI { + apiKey: string; + constructor(options: Palm2ConstructionOptions = {}) { + this.apiKey = options.apiKey || process.env.PALM_API_KEY || ""; + } + + private async request( + path: string, + body: object, + max_retry?: number, + delay?: number + ): Promise { + if (!this.apiKey) { + throw new Error( + "API key is missing. Please provide a valid Palm2 API key, or set it in the PALM_API_KEY environment variable." + ); + } + const config = { + method: "post", + maxBodyLength: Infinity, + url: `${baseUrl}/${path}`, + headers: { + "Content-Type": "application/json", + "x-goog-api-key": this.apiKey, + }, + data: JSON.stringify(body), + }; + return await retry( + async () => { + return (await axios.request(config)).data; + }, + { maxAttempts: max_retry || 3, delay: delay || 200 } + ); + } + + async generateText(options: Palm2GenerateTextOptions): Promise { + if (!options.prompt) { + throw new Error("prompt is required to generate text with Palm2."); + } + const body = removeUndefined({ + prompt: { text: options.prompt }, + temperature: options.temperature ?? 0.7, + candidateCount: options.candidate_count ?? 1, + maxOutputTokens: options.max_output_tokens ?? 1024, + topP: options.top_p, + topK: options.top_k, + stopSequences: options.stop_sequences, + }); + return this.request( + `${options.model || "text-bison-001"}:generateText`, + body, + options.max_retry, + options.delay + ); + } + + async chat(options: Palm2ChatOptions): Promise { + if (!options.prompt && (!options.messages || options.messages.length === 0)) { + throw new Error( + "Either 'prompt' or a non-empty 'messages' array is required to chat with Palm2." + ); + } + const messages = options.prompt ? [{ content: options.prompt }] : options.messages; + const body = removeUndefined({ + prompt: removeUndefined({ + context: options.context, + examples: options.examples, + messages, + }), + temperature: options.temperature ?? 0.7, + candidateCount: options.candidate_count ?? 1, + topP: options.top_p, + topK: options.top_k, + }); + return this.request( + `${options.model || "chat-bison-001"}:generateMessage`, + body, + options.max_retry, + options.delay + ); + } + + async generateEmbeddings(options: Palm2EmbeddingOptions): Promise { + if (!options.text) { + throw new Error("text is required to generate embeddings with Palm2."); + } + const body = { text: options.text }; + return this.request( + `${options.model || "embedding-gecko-001"}:embedText`, + body, + options.max_retry, + options.delay + ); + } +} diff --git a/JS/edgechains/arakoodev/src/ai/src/tests/palm2/palm2.test.ts b/JS/edgechains/arakoodev/src/ai/src/tests/palm2/palm2.test.ts new file mode 100644 index 000000000..dcd77209c --- /dev/null +++ b/JS/edgechains/arakoodev/src/ai/src/tests/palm2/palm2.test.ts @@ -0,0 +1,96 @@ +import axios from "axios"; +import { Palm2AI } from "../../lib/palm2/palm2"; + +jest.mock("axios"); +const mockedAxios = axios as jest.Mocked; + +describe("Palm2AI", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("generateText", () => { + test("should generate text completion from Palm2", async () => { + const mockResponse = { + candidates: [{ output: "Test response", safetyRatings: [] }], + }; + mockedAxios.request.mockResolvedValueOnce({ data: mockResponse } as any); + const palm2 = new Palm2AI({ apiKey: "test_api_key" }); + const response = await palm2.generateText({ prompt: "test prompt" }); + expect(response.candidates[0].output).toEqual("Test response"); + }); + + test("should send prompt, model and generation config in the request body", async () => { + mockedAxios.request.mockResolvedValueOnce({ data: { candidates: [] } } as any); + const palm2 = new Palm2AI({ apiKey: "test_api_key" }); + await palm2.generateText({ prompt: "hello", temperature: 0, max_output_tokens: 16 }); + const config = mockedAxios.request.mock.calls[0][0] as any; + const body = JSON.parse(config.data); + expect(config.url).toContain("text-bison-001:generateText"); + expect(config.headers["x-goog-api-key"]).toEqual("test_api_key"); + expect(body.prompt.text).toEqual("hello"); + // temperature: 0 is a valid value and must NOT fall back to the 0.7 default. + expect(body.temperature).toEqual(0); + expect(body.maxOutputTokens).toEqual(16); + // Untouched optional knobs must be stripped from the payload. + expect(body).not.toHaveProperty("topP"); + expect(body).not.toHaveProperty("topK"); + }); + + test("should reject when prompt is empty", async () => { + const palm2 = new Palm2AI({ apiKey: "test_api_key" }); + await expect(palm2.generateText({ prompt: "" })).rejects.toThrow("prompt is required"); + expect(mockedAxios.request).not.toHaveBeenCalled(); + }); + }); + + describe("chat", () => { + test("should generate a chat message from Palm2", async () => { + const mockResponse = { + candidates: [{ author: "1", content: "Hello there" }], + messages: [{ author: "0", content: "hi" }], + }; + mockedAxios.request.mockResolvedValueOnce({ data: mockResponse } as any); + const palm2 = new Palm2AI({ apiKey: "test_api_key" }); + const response = await palm2.chat({ prompt: "hi" }); + expect(response.candidates[0].content).toEqual("Hello there"); + }); + + test("should wrap prompt as a single message and target generateMessage", async () => { + mockedAxios.request.mockResolvedValueOnce({ + data: { candidates: [], messages: [] }, + } as any); + const palm2 = new Palm2AI({ apiKey: "test_api_key" }); + await palm2.chat({ prompt: "hi", context: "be terse" }); + const config = mockedAxios.request.mock.calls[0][0] as any; + const body = JSON.parse(config.data); + expect(config.url).toContain("chat-bison-001:generateMessage"); + expect(body.prompt.context).toEqual("be terse"); + expect(body.prompt.messages).toEqual([{ content: "hi" }]); + }); + + test("should reject when neither prompt nor messages are provided", async () => { + const palm2 = new Palm2AI({ apiKey: "test_api_key" }); + await expect(palm2.chat({})).rejects.toThrow(); + expect(mockedAxios.request).not.toHaveBeenCalled(); + }); + }); + + describe("generateEmbeddings", () => { + test("should generate embeddings from Palm2", async () => { + const mockResponse = { embedding: { value: [0.1, 0.2, 0.3] } }; + mockedAxios.request.mockResolvedValueOnce({ data: mockResponse } as any); + const palm2 = new Palm2AI({ apiKey: "test_api_key" }); + const response = await palm2.generateEmbeddings({ text: "embed me" }); + expect(response.embedding.value).toEqual([0.1, 0.2, 0.3]); + }); + }); + + describe("authentication", () => { + test("should reject when no api key is available", async () => { + const palm2 = new Palm2AI({ apiKey: "" }); + await expect(palm2.generateText({ prompt: "hi" })).rejects.toThrow("API key is missing"); + expect(mockedAxios.request).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/JS/edgechains/examples/palm2/.gitignore b/JS/edgechains/examples/palm2/.gitignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/JS/edgechains/examples/palm2/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/JS/edgechains/examples/palm2/jsonnet/main.jsonnet b/JS/edgechains/examples/palm2/jsonnet/main.jsonnet new file mode 100644 index 000000000..e50fea836 --- /dev/null +++ b/JS/edgechains/examples/palm2/jsonnet/main.jsonnet @@ -0,0 +1,16 @@ +local promptTemplate = ||| + You are a helpful assistant that can answer questions based on given question + Answer the following question: {question} +|||; + + +local key = std.extVar('palm2_api_key'); +local UserQuestion = std.extVar('question'); + +local promptWithQuestion = std.strReplace(promptTemplate, '{question}', UserQuestion + '\n'); + +local main() = + local response = arakoo.native('palm2Call')({ prompt: promptWithQuestion, palm2ApiKey: key }); + response; + +main() diff --git a/JS/edgechains/examples/palm2/jsonnet/secrets.jsonnet b/JS/edgechains/examples/palm2/jsonnet/secrets.jsonnet new file mode 100644 index 000000000..2c731c2f3 --- /dev/null +++ b/JS/edgechains/examples/palm2/jsonnet/secrets.jsonnet @@ -0,0 +1,6 @@ + +local PALM2_API_KEY = "***"; + +{ + "palm2_api_key":PALM2_API_KEY, +} diff --git a/JS/edgechains/examples/palm2/package.json b/JS/edgechains/examples/palm2/package.json new file mode 100644 index 000000000..45907c0e8 --- /dev/null +++ b/JS/edgechains/examples/palm2/package.json @@ -0,0 +1,23 @@ +{ + "name": "palm2-example", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "keywords": [], + "author": "", + "scripts": { + "start": "tsc && node --experimental-wasm-modules ./dist/index.js" + }, + "license": "ISC", + "dependencies": { + "@arakoodev/edgechains.js": "file:../../arakoodev", + "@arakoodev/jsonnet": "^0.24.0", + "file-uri-to-path": "^2.0.0", + "path": "^0.12.7" + }, + "devDependencies": { + "@types/node": "^22.7.4", + "typescript": "^5.7.0-dev.20241007" + } +} diff --git a/JS/edgechains/examples/palm2/readme.md b/JS/edgechains/examples/palm2/readme.md new file mode 100644 index 000000000..c20fee6d2 --- /dev/null +++ b/JS/edgechains/examples/palm2/readme.md @@ -0,0 +1,39 @@ +# Palm2 Example + +This example shows how to call Google's **Palm2** (`text-bison-001`) API through the +`Palm2AI` class from `@arakoodev/edgechains.js/ai`. The prompt lives in a jsonnet file +(`jsonnet/main.jsonnet`) and is **not** hardcoded in the TypeScript source. + +## Installation + +1. Install the required dependencies: + + ```bash + npm install + ``` + +## Configuration + +1. Add your Palm2 API key in `jsonnet/secrets.jsonnet`: + + ```jsonnet + local PALM2_API_KEY = "***"; + ``` + + You can also set it through the `PALM_API_KEY` environment variable. + +## Usage + +1. Start the server: + + ```bash + npm run start + ``` + +2. Hit the `POST` endpoint with a basic question at `http://localhost:3000/chat`. + + ```bash + body = { + "question": "hi" + } + ``` diff --git a/JS/edgechains/examples/palm2/src/index.ts b/JS/edgechains/examples/palm2/src/index.ts new file mode 100644 index 000000000..fae7be4db --- /dev/null +++ b/JS/edgechains/examples/palm2/src/index.ts @@ -0,0 +1,33 @@ +import { ArakooServer } from "@arakoodev/edgechains.js/arakooserver"; +import Jsonnet from "@arakoodev/jsonnet"; + +import { createSyncRPC } from "@arakoodev/edgechains.js/sync-rpc"; + +import fileURLToPath from "file-uri-to-path"; +import path from "path"; +const server = new ArakooServer(); + +const app = server.createApp(); + +const jsonnet = new Jsonnet(); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const palm2Call = createSyncRPC(path.join(__dirname, "./lib/generateResponse.cjs")); + +app.post("/chat", async (c: any) => { + try { + const { question } = await c.req.json(); + const key = JSON.parse( + jsonnet.evaluateFile(path.join(__dirname, "../jsonnet/secrets.jsonnet")) + ).palm2_api_key; + jsonnet.extString("palm2_api_key", key); + jsonnet.extString("question", question || ""); + jsonnet.javascriptCallback("palm2Call", palm2Call); + let response = jsonnet.evaluateFile(path.join(__dirname, "../jsonnet/main.jsonnet")); + return c.json(JSON.parse(response)); + } catch (error) { + console.log("error occured", error); + } +}); + +server.listen(3000); diff --git a/JS/edgechains/examples/palm2/src/lib/generateResponse.cts b/JS/edgechains/examples/palm2/src/lib/generateResponse.cts new file mode 100644 index 000000000..64230fe21 --- /dev/null +++ b/JS/edgechains/examples/palm2/src/lib/generateResponse.cts @@ -0,0 +1,13 @@ +const { Palm2AI } = require("@arakoodev/edgechains.js/ai"); + +async function palm2Call({ prompt, palm2ApiKey }: any) { + try { + const palm2 = new Palm2AI({ apiKey: palm2ApiKey }); + const res = await palm2.generateText({ prompt }); + return JSON.stringify({ answer: res.candidates[0].output }); + } catch (error) { + return error; + } +} + +module.exports = palm2Call; diff --git a/JS/edgechains/examples/palm2/tsconfig.json b/JS/edgechains/examples/palm2/tsconfig.json new file mode 100644 index 000000000..75cfffbfe --- /dev/null +++ b/JS/edgechains/examples/palm2/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2021", + "moduleResolution": "NodeNext", + "module": "NodeNext", + "rootDir": "./src", + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "exclude": ["./**/*.test.ts", "vitest.config.ts"] +}