diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..f7d6c60 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,10 @@ +version: 2 +updates: + + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + timezone: "Europe/Kyiv" + day: "friday" + time: "18:00" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..40abc91 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,40 @@ +name: Build + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + strategy: + matrix: + os: [ ubuntu-latest ] + version: [ 20 ] + runs-on: ${{ matrix.os }} + steps: + + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.version }} + cache: 'npm' + cache-dependency-path: ./package.json + + - name: Run Npm:install + run: npm install + + - name: Run Npm:format + run: npm run format + + - name: Run Npm:lint + run: npm run lint + + - name: Run Npm:test + run: npm run test diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..356ee32 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,27 @@ +name: Publish + +on: + workflow_dispatch: + +jobs: + publish: + strategy: + matrix: + os: [ ubuntu-latest ] + version: [ 20 ] + runs-on: ${{ matrix.os }} + steps: + + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.version }} + cache: 'npm' + + - name: Publish to npm + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 1edf665..2929ea3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ # OS Thumbs.db .DS_Store -*.pdb # Editors .vs/ @@ -11,6 +10,8 @@ Thumbs.db # Lang: Typescript node_modules/ +tsconfig.tsbuildinfo +package-lock.json # Output dist/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..bd780e1 --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "@einstack/glide", + "version": "0.1.0", + "type": "module", + "license": "Apache-2.0", + "author": "EinStack ", + "keywords": ["llm", "ai", "gateway"], + "description": "A minimal Glide client", + "repository": "git+https://github.com/EinStack/glide-ts.git", + "bugs": "https://github.com/EinStack/glide-ts/issues", + "homepage": "https://www.einstack.ai/", + "exports": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "scripts": { + "build": "rimraf ./dist && tsup", + "dev": "rimraf ./dist && tsup --watch", + "test": "node --import tsx --test ./src/*.spec.ts", + "format": "biome format --write .", + "lint": "biome lint .", + "check": "biome check --apply .", + "ci": "biome ci ." + }, + "dependencies": {}, + "devDependencies": { + "@biomejs/biome": "^1.7.3", + "@types/node": "^20.12.10", + "@types/ws": "^8.5.10", + "esbuild": "^0.20.2", + "rimraf": "^5.0.5", + "tsup": "^8.0.2", + "tsx": "^4.9.3", + "typescript": "^5.4.5" + } +} diff --git a/src/client.spec.ts b/src/client.spec.ts new file mode 100644 index 0000000..e246199 --- /dev/null +++ b/src/client.spec.ts @@ -0,0 +1,30 @@ +import { describe, it } from "node:test"; +import { ok } from "node:assert"; +import { GlideClient } from "./client"; + +describe("client", () => { + it("should be constructable without options", () => { + const client = new GlideClient(); + + ok(client.baseUrl); + ok(client.userAgent); + }); + + it("should be constructable with options", () => { + const client = new GlideClient({ + apiKey: "testing", + userAgent: "Einstack/1.0", + }); + + ok(client.baseUrl); + ok(client.apiKey); + ok(client.userAgent); + }); + + it("should be healthy", async () => { + const client = new GlideClient(); + const healthy = await client.health(); + + ok(healthy); + }); +}); diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..ddf1d8e --- /dev/null +++ b/src/client.ts @@ -0,0 +1,67 @@ +import { ClientConfig, type GlideClientOptions } from "./config"; +import { type Language, LanguageService } from "./language"; + +/** + * A minimal `EinStack` `Glide` client. + * + * @link https://www.einstack.ai/ + */ +export class GlideClient { + readonly #config: ClientConfig; + readonly #language: Language; + + /** + * Constructs a new `EinStack` `Glide` client. + * + * @param options Client options, override environment variables. + * + * @link https://www.einstack.ai/ + */ + constructor(options?: GlideClientOptions) { + this.#config = new ClientConfig(options); + this.#language = new LanguageService(this.#config); + } + + /** + * Returns the provided `API key`. + */ + get apiKey(): string | null { + return this.#config.apiKey; + } + + /** + * Returns the used base `URL`. + */ + get baseUrl(): URL { + return this.#config.baseUrl; + } + + /** + * Returns the used `User-Agent` header value. + */ + get userAgent(): string { + return this.#config.userAgent; + } + + /** + * APIs for `/v1/language` endpoints. + */ + get language(): Language { + return this.#language; + } + + /** + * Returns `true` if the service is healthy. + * + * `GET /v1/health` + * + * @throws GlideError + */ + async health(): Promise { + const response = await this.#config.fetch<{ + healthy: boolean; + }>("GET", "/v1/health"); + + return response.healthy; + } +} diff --git a/src/config.spec.ts b/src/config.spec.ts new file mode 100644 index 0000000..eb00ac4 --- /dev/null +++ b/src/config.spec.ts @@ -0,0 +1,12 @@ +import { describe, it } from "node:test"; +import { ok } from "node:assert"; +import { type GlideClientOptions, tryEnvironment } from "./config"; + +describe("config", () => { + it("should return valid options", () => { + const options: GlideClientOptions = tryEnvironment(); + + ok(options.baseUrl); + ok(options.userAgent); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6c3cf27 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,125 @@ +import { type ErrorResponse, GlideError } from "./error"; + +/** + * The current version of this client. + */ +export const clientVersion = "0.1.0"; + +// TODO: Read runtime version. + +/** + * The used version of the runtime. + */ +export const runtimeVersion = "16.0"; + +/** + * Options for {@link GlideClient }. + * + * Overrides environment variables. + */ +export interface GlideClientOptions { + /** + * Attaches an optional `API key`. + * + * Overrides environment variable `GLIDE_API_KEY`. + */ + apiKey?: string | null; + + /** + * Overrides the default `base url`: `http://127.0.0.1:9099/`. + * or environment variable `GLIDE_BASE_URL`. + */ + baseUrl?: string | URL; + + /** + * Overrides the default `User-Agent` header: `Glide/0.1.0 (TS; Ver. 16.0)`. + * or environment variable `GLIDE_USER_AGENT`. + */ + userAgent?: string; +} + +/** + * Attempts to construct {@link GlideClientOptions} from environment variables. + * + * Returns default {@link GlideClientOptions} otherwise. + */ +export function tryEnvironment(): Required { + const options: Required = { + apiKey: null, + baseUrl: "http://127.0.0.1:9099/", + userAgent: `Glide/${clientVersion} (TS; Ver. ${runtimeVersion})`, + }; + + const env = (globalThis as any).process?.env; + if (typeof env !== "object" || env === null) { + return options; + } + + if (typeof env.GLIDE_API_KEY === "string") { + options.apiKey = env.GLIDE_API_KEY; + } + + if (typeof env.GLIDE_BASE_URL === "string") { + options.baseUrl = env.GLIDE_BASE_URL; + } + + if (typeof env.GLIDE_USER_AGENT === "string") { + options.apiKey = env.GLIDE_USER_AGENT; + } + + return options; +} + +/** + * TODO. + */ +export class ClientConfig { + readonly apiKey: string | null; + readonly baseUrl: URL; + readonly userAgent: string; + + /** + * Instantiates a new {@link ClientConfig} with provided options. + */ + constructor(options?: GlideClientOptions) { + const env = tryEnvironment(); + this.apiKey = options?.apiKey || env.apiKey; + this.baseUrl = new URL(options?.baseUrl || env.baseUrl); + this.userAgent = options?.userAgent || env.userAgent; + } + + /** + * Sends serialized and requests and returns deserialized response. + * + * @throws GlideError + */ + async fetch( + method: string, + path: string, + data?: unknown, + ): Promise { + const input = new URL(path, this.baseUrl); + const headers = new Headers({ + Accept: "application/json", + "Content-Type": "application/json", + userAgent: this.userAgent, + }); + + if (this.apiKey !== null) { + headers.set("Authorization", `Bearer ${this.apiKey}`); + } + + const response = await fetch(input, { + body: JSON.stringify(data), + method, + headers, + }); + + if (response.ok) { + return (await response.json()) as T; + } + + const content = (await response.json()) as ErrorResponse; + throw new GlideError(content, response.status); + } +} diff --git a/src/error.spec.ts b/src/error.spec.ts new file mode 100644 index 0000000..1e1b455 --- /dev/null +++ b/src/error.spec.ts @@ -0,0 +1,34 @@ +import { describe, it } from "node:test"; +import { type ErrorResponse, GlideError } from "./error"; +import { equal, ok } from "node:assert"; + +const throwUnknownError = (): never => { + const response: ErrorResponse = { + name: "unknown", + message: "reason", + }; + + throw new GlideError(response, 500); +}; + +describe("error", () => { + it("should be constructable", () => { + try { + throwUnknownError(); + } catch (error: unknown) { + ok(error instanceof Error); + ok(error instanceof GlideError); + } + }); + + it("should include response data", () => { + try { + throwUnknownError(); + } catch (error: unknown) { + ok(error instanceof GlideError); + equal(error.kind, "unknown"); + equal(error.message, "reason"); + equal(error.status, 500); + } + }); +}); diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..feada1c --- /dev/null +++ b/src/error.ts @@ -0,0 +1,38 @@ +/** + * Payload of the client (400-499) or server (500-599) error. + */ +export interface ErrorResponse { + name: string; + message: string; +} + +/** + * Error that may occur during the processing of API request. + */ +export class GlideError extends Error { + readonly #name: string; + readonly #statusCode: number; + + /** + * Instantiates a new error. + */ + constructor(response: ErrorResponse, statusCode: number) { + super(response.message); + this.#name = response.name; + this.#statusCode = statusCode; + } + + /** + * Returns the error name. + */ + get kind(): string { + return this.#name; + } + + /** + * Returns the retrieved HTTP status code. + */ + get status(): number { + return this.#statusCode; + } +} diff --git a/src/index.spec.ts b/src/index.spec.ts new file mode 100644 index 0000000..7fad381 --- /dev/null +++ b/src/index.spec.ts @@ -0,0 +1,22 @@ +import { describe, it } from "node:test"; +import type { GlideClientOptions } from "./index"; +import { GlideClient, GlideError } from "./index"; + +describe("index", () => { + it("should re-export everything", async () => { + const options: GlideClientOptions = { + apiKey: "1234567890", + }; + + const client = new GlideClient(options); + try { + await client.health(); + } catch (error: unknown) { + if (error instanceof GlideError) { + console.error(`${error.kind}: ${error.message}`); + } else if (error instanceof Error) { + console.error(`${error.name}: ${error.message}`); + } + } + }); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c34a185 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,6 @@ +export { type GlideClientOptions } from "./config"; +export { GlideClient } from "./client"; +export { GlideError } from "./error"; + +export type { Language } from "./language"; +export type * from "./language.type"; diff --git a/src/language.spec.ts b/src/language.spec.ts new file mode 100644 index 0000000..8fb43aa --- /dev/null +++ b/src/language.spec.ts @@ -0,0 +1,39 @@ +import { describe, it } from "node:test"; +import { ok } from "node:assert"; +import type { ChatRequest, ChatStream } from "./index"; +import { GlideClient } from "./index"; + +describe("language service", () => { + const router = "myrouter"; + + it("should be constructable", () => { + const client = new GlideClient(); + ok(client.language); + }); + + it("should correctly list routers", async () => { + const client = new GlideClient(); + const routerList = await client.language.list(); + ok(routerList.routers.length > 1); + }); + + it("should correctly chat", async () => { + const client = new GlideClient(); + const request: ChatRequest = { + message: { + content: "Hello there!", + role: "user", + }, + }; + + const response = await client.language.chat(router, request); + ok(response.model_response); + ok(response.model_response.message); + }); + + it("should correctly stream chat", async () => { + const client = new GlideClient(); + const callbacks: ChatStream = {}; + await client.language.chatStream(router, callbacks); + }); +}); diff --git a/src/language.ts b/src/language.ts new file mode 100644 index 0000000..b920adc --- /dev/null +++ b/src/language.ts @@ -0,0 +1,58 @@ +import type { ClientConfig } from "./config"; +import type { + ChatRequest, + ChatResponse, + ChatStream, + RouterConfigs, +} from "./language.type"; + +/** + * APIs for `/v1/language` endpoints. + */ +export interface Language { + /** + * Retrieves a list of all router configs. + * + * `GET /v1/language` + */ + list(): Promise; + + /** + * Sends a single chat request to a specified router and retrieves the response. + * + * `POST /v1/language/{router}/chat` + */ + chat(router: string, request: ChatRequest): Promise; + + /** + * Establishes a WebSocket connection for streaming chat messages from a specified router. + * + * `GET /v1/language/{router}/chatStream` + */ + chatStream(router: string, callbacks: ChatStream): Promise; +} + +export class LanguageService implements Language { + #client: ClientConfig; + + constructor(client: ClientConfig) { + this.#client = client; + } + + async list(): Promise { + return await this.#client.fetch("GET", "/v1/list"); + } + + async chat(router: string, data: ChatRequest): Promise { + const path = `/v1/language/${router}/chat`; + return await this.#client.fetch("POST", path, data); + } + + async chatStream(router: string, callbacks: ChatStream): Promise { + const path = `/v1/language/${router}/chatStream`; + const _ = await this.#client.fetch("GET", path); + + // TODO: chatStream(). + throw new Error("Not implemented."); + } +} diff --git a/src/language.type.ts b/src/language.type.ts new file mode 100644 index 0000000..ecf69ef --- /dev/null +++ b/src/language.type.ts @@ -0,0 +1,77 @@ +/** All router configurations. */ +export interface RouterConfigs { + /* List of all available routers. */ + routers: RouterConfig[]; +} + +/** Single router configuration. */ +// TODO: Type it. +export type RouterConfig = unknown; + +/** + * Unified chat request across all language models. + */ +export interface ChatRequest { + message: ChatMessage; + message_history?: ChatMessage[]; + override_params?: ChatRequestOverride; +} + +/** Content and role of the message. */ +export interface ChatMessage { + /** The content of the message. */ + content: string; + + /** + * The name of the author of this message. + * + * May contain a-z, A-Z, 0-9, and underscores, + * with a maximum length of 64 characters. + */ + name?: string; + + /** + * The role of the author of this message. + * + * One of system, user, or assistant. + * TODO: Make it optional. + */ + role: "user" | "system" | "assistant"; +} + +/** Override of a single chat request. */ +export interface ChatRequestOverride { + message: ChatMessage; + model_id: string; +} + +/** Unified chat response across all language models. */ +export interface ChatResponse { + cached?: boolean; + created_at?: number; + id?: string; + model_id?: string; + model_name?: string; + model_response?: ModelResponse; + provider_id?: string; + router_id?: string; +} + +/** Unified response from the provider. */ +export interface ModelResponse { + message: ChatMessage; + metadata?: Record; + token_count: TokenUsage; +} + +/** Prompt, response and total token usage. */ +export interface TokenUsage { + prompt_tokens: number; + response_tokens: number; + total_tokens: number; +} + +/** + * TODO. Streaming callbacks. + */ +export interface ChatStream {} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3f689c0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, + // "composite": true, + + /* Language and Environment */ + "target": "ESNext", + // "lib": [], + + /* Modules */ + // "module": "Node16", + "moduleResolution": "Node", + "rootDir": "./src", + "baseUrl": "./src", + + /* Emit */ + "declaration": true, + "outDir": "./dist", + "removeComments": true, + + /* Interop Constraints */ + // "isolatedModules": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + + /* Type Checking */ + "strict": true, + "skipLibCheck": true + } +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..61608ed --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["./src/**/*.ts", "!src/**/*.spec.ts"], + format: ["esm", "cjs"], + target: "node18", + + dts: true, + treeshake: true, + bundle: false, +});