From 6000e82763e459d1f0d9f87a86bd2062d6f25af4 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:26:28 -0500 Subject: [PATCH 01/15] feat(kernel-language-model-service): Create package from template --- .../CHANGELOG.md | 10 + .../kernel-language-model-service/README.md | 15 + .../package.json | 81 +++ .../src/index.test.ts | 11 + .../src/index.ts | 9 + .../tsconfig.build.json | 13 + .../tsconfig.json | 15 + .../typedoc.json | 8 + .../vitest.config.ts | 16 + tsconfig.build.json | 1 + tsconfig.json | 1 + yarn.lock | 460 +++++++++++++----- 12 files changed, 513 insertions(+), 127 deletions(-) create mode 100644 packages/kernel-language-model-service/CHANGELOG.md create mode 100644 packages/kernel-language-model-service/README.md create mode 100644 packages/kernel-language-model-service/package.json create mode 100644 packages/kernel-language-model-service/src/index.test.ts create mode 100644 packages/kernel-language-model-service/src/index.ts create mode 100644 packages/kernel-language-model-service/tsconfig.build.json create mode 100644 packages/kernel-language-model-service/tsconfig.json create mode 100644 packages/kernel-language-model-service/typedoc.json create mode 100644 packages/kernel-language-model-service/vitest.config.ts diff --git a/packages/kernel-language-model-service/CHANGELOG.md b/packages/kernel-language-model-service/CHANGELOG.md new file mode 100644 index 0000000000..0c82cb1ed6 --- /dev/null +++ b/packages/kernel-language-model-service/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/ocap-kernel/ diff --git a/packages/kernel-language-model-service/README.md b/packages/kernel-language-model-service/README.md new file mode 100644 index 0000000000..b006b56a7d --- /dev/null +++ b/packages/kernel-language-model-service/README.md @@ -0,0 +1,15 @@ +# `@ocap/kernel-language-model-service` + +A place for implementations providing language model services to the ocap kernel. + +## Installation + +`yarn add @ocap/kernel-language-model-service` + +or + +`npm install @ocap/kernel-language-model-service` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). diff --git a/packages/kernel-language-model-service/package.json b/packages/kernel-language-model-service/package.json new file mode 100644 index 0000000000..46411d097a --- /dev/null +++ b/packages/kernel-language-model-service/package.json @@ -0,0 +1,81 @@ +{ + "name": "@ocap/kernel-language-model-service", + "version": "0.0.0", + "private": true, + "description": "A place for implementations providing language model services to the ocap kernel", + "homepage": "https://github.com/MetaMask/ocap-kernel/tree/main/packages/kernel-language-model-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/ocap-kernel/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + }, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --clean", + "build:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh @ocap/kernel-language-model-service", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", + "lint:dependencies": "depcheck", + "lint:eslint": "eslint . --cache", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies", + "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore", + "publish:preview": "yarn npm publish --tag preview", + "test": "vitest run --config vitest.config.ts", + "test:clean": "yarn test --no-cache --coverage.clean", + "test:dev": "yarn test --mode development", + "test:verbose": "yarn test --reporter verbose", + "test:watch": "vitest --config vitest.config.ts" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.17.4", + "@metamask/auto-changelog": "^5.0.1", + "@metamask/eslint-config": "^14.0.0", + "@metamask/eslint-config-nodejs": "^14.0.0", + "@metamask/eslint-config-typescript": "^14.0.0", + "@ocap/test-utils": "workspace:^", + "@ts-bridge/cli": "^0.6.3", + "@ts-bridge/shims": "^0.1.1", + "@typescript-eslint/eslint-plugin": "^8.29.0", + "@typescript-eslint/parser": "^8.29.0", + "@typescript-eslint/utils": "^8.29.0", + "@vitest/eslint-plugin": "^1.1.44", + "depcheck": "^1.4.7", + "eslint": "^9.23.0", + "eslint-config-prettier": "^10.1.1", + "eslint-import-resolver-typescript": "^4.3.1", + "eslint-plugin-import-x": "^4.10.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-n": "^17.17.0", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-promise": "^7.2.1", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "typedoc": "^0.28.1", + "typescript": "~5.8.2", + "typescript-eslint": "^8.29.0", + "vite": "^6.3.5", + "vitest": "^3.2.4" + }, + "engines": { + "node": "^20 || >=22" + } +} diff --git a/packages/kernel-language-model-service/src/index.test.ts b/packages/kernel-language-model-service/src/index.test.ts new file mode 100644 index 0000000000..b019d6c617 --- /dev/null +++ b/packages/kernel-language-model-service/src/index.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import greet from './index.ts'; + +describe('Test', () => { + it('greets', () => { + const name = 'Huey'; + const result = greet(name); + expect(result).toBe('Hello, Huey!'); + }); +}); diff --git a/packages/kernel-language-model-service/src/index.ts b/packages/kernel-language-model-service/src/index.ts new file mode 100644 index 0000000000..f7250b9986 --- /dev/null +++ b/packages/kernel-language-model-service/src/index.ts @@ -0,0 +1,9 @@ +/** + * Example function that returns a greeting for the given name. + * + * @param name - The name to greet. + * @returns The greeting. + */ +export default function greet(name: string): string { + return `Hello, ${name}!`; +} diff --git a/packages/kernel-language-model-service/tsconfig.build.json b/packages/kernel-language-model-service/tsconfig.build.json new file mode 100644 index 0000000000..30d7991d1c --- /dev/null +++ b/packages/kernel-language-model-service/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "types": [] + }, + "references": [], + "files": [], + "include": ["./src"] +} diff --git a/packages/kernel-language-model-service/tsconfig.json b/packages/kernel-language-model-service/tsconfig.json new file mode 100644 index 0000000000..3e2399e8f0 --- /dev/null +++ b/packages/kernel-language-model-service/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "types": ["vitest"] + }, + "references": [{ "path": "../test-utils" }], + "include": [ + "../../vitest.config.ts", + "./src", + "./vite.config.ts", + "./vitest.config.ts" + ] +} diff --git a/packages/kernel-language-model-service/typedoc.json b/packages/kernel-language-model-service/typedoc.json new file mode 100644 index 0000000000..f8eb78ae1a --- /dev/null +++ b/packages/kernel-language-model-service/typedoc.json @@ -0,0 +1,8 @@ +{ + "entryPoints": [], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json", + "projectDocuments": ["documents/*.md"] +} diff --git a/packages/kernel-language-model-service/vitest.config.ts b/packages/kernel-language-model-service/vitest.config.ts new file mode 100644 index 0000000000..9bceba6430 --- /dev/null +++ b/packages/kernel-language-model-service/vitest.config.ts @@ -0,0 +1,16 @@ +import { mergeConfig } from '@ocap/test-utils/vitest-config'; +import { defineConfig, defineProject } from 'vitest/config'; + +import defaultConfig from '../../vitest.config.ts'; + +export default defineConfig((args) => { + return mergeConfig( + args, + defaultConfig, + defineProject({ + test: { + name: 'kernel-language-model-service', + }, + }), + ); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 32c1842122..34f9bdefe7 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -5,6 +5,7 @@ { "path": "./packages/cli/tsconfig.build.json" }, { "path": "./packages/kernel-browser-runtime/tsconfig.build.json" }, { "path": "./packages/kernel-errors/tsconfig.build.json" }, + { "path": "./packages/kernel-language-model-service/tsconfig.build.json" }, { "path": "./packages/kernel-rpc-methods/tsconfig.build.json" }, { "path": "./packages/kernel-store/tsconfig.build.json" }, { "path": "./packages/kernel-utils/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index fe0eb50cc6..e8cdce466d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ { "path": "./packages/extension" }, { "path": "./packages/kernel-browser-runtime" }, { "path": "./packages/kernel-errors" }, + { "path": "./packages/kernel-language-model-service" }, { "path": "./packages/kernel-rpc-methods" }, { "path": "./packages/kernel-shims" }, { "path": "./packages/kernel-store" }, diff --git a/yarn.lock b/yarn.lock index 230a11f578..d4d61b2990 100644 --- a/yarn.lock +++ b/yarn.lock @@ -558,31 +558,31 @@ __metadata: languageName: node linkType: hard -"@emnapi/core@npm:^1.4.0": - version: 1.4.3 - resolution: "@emnapi/core@npm:1.4.3" +"@emnapi/core@npm:^1.4.3": + version: 1.4.5 + resolution: "@emnapi/core@npm:1.4.5" dependencies: - "@emnapi/wasi-threads": "npm:1.0.2" + "@emnapi/wasi-threads": "npm:1.0.4" tslib: "npm:^2.4.0" - checksum: 10/b511f66b897d2019835391544fdf11f4fa0ce06cc1181abfa17c7d4cf03aaaa4fc8a64fcd30bb3f901de488d0a6f370b53a8de2215a898f5a4ac98015265b3b7 + checksum: 10/412322102dc861e8aa78123ae20560ac980362a220c736fe59ddea3228d490757780ea4cdc3bd54903a5ca2a92085f119e42f2c07f60e2aec2c0b8a69ea094c0 languageName: node linkType: hard -"@emnapi/runtime@npm:^1.4.0": - version: 1.4.3 - resolution: "@emnapi/runtime@npm:1.4.3" +"@emnapi/runtime@npm:^1.4.3": + version: 1.4.5 + resolution: "@emnapi/runtime@npm:1.4.5" dependencies: tslib: "npm:^2.4.0" - checksum: 10/4f90852a1a5912982cc4e176b6420556971bcf6a85ee23e379e2455066d616219751367dcf43e6a6eaf41ea7e95ba9dc830665a52b5d979dfe074237d19578f8 + checksum: 10/1d6f406ff116d2363e60aef3ed49eb8d577387f4941abea508ba376900d8831609d5cce92a58076b1a9613f8e83c75c2e3fea71e4fbcdbe06019876144c2559b languageName: node linkType: hard -"@emnapi/wasi-threads@npm:1.0.2": - version: 1.0.2 - resolution: "@emnapi/wasi-threads@npm:1.0.2" +"@emnapi/wasi-threads@npm:1.0.4": + version: 1.0.4 + resolution: "@emnapi/wasi-threads@npm:1.0.4" dependencies: tslib: "npm:^2.4.0" - checksum: 10/e82941776665eb958c2084728191d6b15a94383449975c4621b67a1c8217e1c0ec11056a693906c76863cb96f782f8be500510ecec6874e3f5da35a8e7968cfd + checksum: 10/86688f416095b59d8d3e5ea2d8b5574a7c180257fe0c067c7a492f3de2cf5ebc2c8b00af17d6341c7555c614266d3987f332015d7ce6e88b234a9a314e66f396 languageName: node linkType: hard @@ -1044,14 +1044,14 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.1.2, @eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.5.0": - version: 4.6.1 - resolution: "@eslint-community/eslint-utils@npm:4.6.1" +"@eslint-community/eslint-utils@npm:^4.1.2, @eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.5.0, @eslint-community/eslint-utils@npm:^4.7.0": + version: 4.7.0 + resolution: "@eslint-community/eslint-utils@npm:4.7.0" dependencies: eslint-visitor-keys: "npm:^3.4.3" peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10/9f1a91bddf0a68b2b8bb71b3390d0e665e842770ff4a0188d38199e8a66ac050608da14eb614d211535ed312633d9dc237bd297857bf0e78abac927029909e50 + checksum: 10/43ed5d391526d9f5bbe452aef336389a473026fca92057cf97c576db11401ce9bcf8ef0bf72625bbaf6207ed8ba6bf0dcf4d7e809c24f08faa68a28533c491a7 languageName: node linkType: hard @@ -1106,13 +1106,20 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.25.1, @eslint/js@npm:^9.11.0": +"@eslint/js@npm:9.25.1": version: 9.25.1 resolution: "@eslint/js@npm:9.25.1" checksum: 10/ad5812889598de32d674ef60c0e61468ac5c7f3b6ecf98b0e29d1e88d7af8ba3aab255b8c0a46bbaf654047bbd2ee5aa033db9b53e330f97615093fcccde4cbb languageName: node linkType: hard +"@eslint/js@npm:^9.11.0": + version: 9.32.0 + resolution: "@eslint/js@npm:9.32.0" + checksum: 10/a833083a74ed99486c9b72f9be3497ca744692feca12ade7e32119e4b29aba21592055422589a282ed64c46e86b595f147a5270011131a4ea5a2628892bfaf1d + languageName: node + linkType: hard + "@eslint/object-schema@npm:^2.1.6": version: 2.1.6 resolution: "@eslint/object-schema@npm:2.1.6" @@ -2830,14 +2837,14 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^0.2.9": - version: 0.2.9 - resolution: "@napi-rs/wasm-runtime@npm:0.2.9" +"@napi-rs/wasm-runtime@npm:^0.2.11": + version: 0.2.12 + resolution: "@napi-rs/wasm-runtime@npm:0.2.12" dependencies: - "@emnapi/core": "npm:^1.4.0" - "@emnapi/runtime": "npm:^1.4.0" - "@tybys/wasm-util": "npm:^0.9.0" - checksum: 10/8ebc7d85e11e1b8d71908d5615ff24b27ef7af8287d087fb5cff5a3e545915c7545998d976a9cd6a4315dab4ba0f609439fbe6408fec3afebd288efb0dbdc135 + "@emnapi/core": "npm:^1.4.3" + "@emnapi/runtime": "npm:^1.4.3" + "@tybys/wasm-util": "npm:^0.10.0" + checksum: 10/5fd518182427980c28bc724adf06c5f32f9a8915763ef560b5f7d73607d30cd15ac86d0cbd2eb80d4cfab23fc80d0876d89ca36a9daadcb864bc00917c94187c languageName: node linkType: hard @@ -3221,6 +3228,41 @@ __metadata: languageName: unknown linkType: soft +"@ocap/kernel-language-model-service@workspace:packages/kernel-language-model-service": + version: 0.0.0-use.local + resolution: "@ocap/kernel-language-model-service@workspace:packages/kernel-language-model-service" + dependencies: + "@arethetypeswrong/cli": "npm:^0.17.4" + "@metamask/auto-changelog": "npm:^5.0.1" + "@metamask/eslint-config": "npm:^14.0.0" + "@metamask/eslint-config-nodejs": "npm:^14.0.0" + "@metamask/eslint-config-typescript": "npm:^14.0.0" + "@ocap/test-utils": "workspace:^" + "@ts-bridge/cli": "npm:^0.6.3" + "@ts-bridge/shims": "npm:^0.1.1" + "@typescript-eslint/eslint-plugin": "npm:^8.29.0" + "@typescript-eslint/parser": "npm:^8.29.0" + "@typescript-eslint/utils": "npm:^8.29.0" + "@vitest/eslint-plugin": "npm:^1.1.44" + depcheck: "npm:^1.4.7" + eslint: "npm:^9.23.0" + eslint-config-prettier: "npm:^10.1.1" + eslint-import-resolver-typescript: "npm:^4.3.1" + eslint-plugin-import-x: "npm:^4.10.0" + eslint-plugin-jsdoc: "npm:^50.6.9" + eslint-plugin-n: "npm:^17.17.0" + eslint-plugin-prettier: "npm:^5.2.6" + eslint-plugin-promise: "npm:^7.2.1" + prettier: "npm:^3.5.3" + rimraf: "npm:^6.0.1" + typedoc: "npm:^0.28.1" + typescript: "npm:~5.8.2" + typescript-eslint: "npm:^8.29.0" + vite: "npm:^6.3.5" + vitest: "npm:^3.2.4" + languageName: unknown + linkType: soft + "@ocap/kernel-test@workspace:packages/kernel-test": version: 0.0.0-use.local resolution: "@ocap/kernel-test@workspace:packages/kernel-test" @@ -4385,12 +4427,12 @@ __metadata: languageName: node linkType: hard -"@tybys/wasm-util@npm:^0.9.0": - version: 0.9.0 - resolution: "@tybys/wasm-util@npm:0.9.0" +"@tybys/wasm-util@npm:^0.10.0": + version: 0.10.0 + resolution: "@tybys/wasm-util@npm:0.10.0" dependencies: tslib: "npm:^2.4.0" - checksum: 10/aa58e64753a420ad1eefaf7bacef3dda61d74f9336925943d9244132d5b48d9242f734f1e707fd5ccfa6dd1d8ec8e6debc234b4dedb3a5b0d8486d1f373350b2 + checksum: 10/779d047a77e8a619b6e26b6fe556f413316d846e9a35438668a15510a4d6e7294388c998f65911f6f1a13838745575d7793cb1d27182752f6f95991725b15d45 languageName: node linkType: hard @@ -4741,7 +4783,7 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.31.0, @typescript-eslint/eslint-plugin@npm:^8.29.0": +"@typescript-eslint/eslint-plugin@npm:8.31.0": version: 8.31.0 resolution: "@typescript-eslint/eslint-plugin@npm:8.31.0" dependencies: @@ -4762,7 +4804,28 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.31.0, @typescript-eslint/parser@npm:^8.29.0": +"@typescript-eslint/eslint-plugin@npm:^8.29.0": + version: 8.38.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.38.0" + dependencies: + "@eslint-community/regexpp": "npm:^4.10.0" + "@typescript-eslint/scope-manager": "npm:8.38.0" + "@typescript-eslint/type-utils": "npm:8.38.0" + "@typescript-eslint/utils": "npm:8.38.0" + "@typescript-eslint/visitor-keys": "npm:8.38.0" + graphemer: "npm:^1.4.0" + ignore: "npm:^7.0.0" + natural-compare: "npm:^1.4.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + "@typescript-eslint/parser": ^8.38.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/60a97f671d766bdd3d286e08e0fa46a6ac70d31ee03cde595307b11a9dd784c357d6ad4d3f5071d12ca5eab8cc420c174d2ae9eb491702f32cfcbd68e35d440f + languageName: node + linkType: hard + +"@typescript-eslint/parser@npm:8.31.0": version: 8.31.0 resolution: "@typescript-eslint/parser@npm:8.31.0" dependencies: @@ -4778,6 +4841,35 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/parser@npm:^8.29.0": + version: 8.38.0 + resolution: "@typescript-eslint/parser@npm:8.38.0" + dependencies: + "@typescript-eslint/scope-manager": "npm:8.38.0" + "@typescript-eslint/types": "npm:8.38.0" + "@typescript-eslint/typescript-estree": "npm:8.38.0" + "@typescript-eslint/visitor-keys": "npm:8.38.0" + debug: "npm:^4.3.4" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/c39e56a281540287dd96ca60782a7644283d8067a425062f07557f2e57e385f8cf2089e711c0a5e6755efa81bb81a58c1516f20bddfb906ca362b93e800f0479 + languageName: node + linkType: hard + +"@typescript-eslint/project-service@npm:8.38.0": + version: 8.38.0 + resolution: "@typescript-eslint/project-service@npm:8.38.0" + dependencies: + "@typescript-eslint/tsconfig-utils": "npm:^8.38.0" + "@typescript-eslint/types": "npm:^8.38.0" + debug: "npm:^4.3.4" + peerDependencies: + typescript: ">=4.8.4 <5.9.0" + checksum: 10/fe216046034e36a485de64d399b833ae8128c2976f462d03d1de5871905d77e9a953453880aa7f7f46cc343e9313f796f17d9a55caed41616bb677f4dbea2a58 + languageName: node + linkType: hard + "@typescript-eslint/scope-manager@npm:8.31.0": version: 8.31.0 resolution: "@typescript-eslint/scope-manager@npm:8.31.0" @@ -4788,6 +4880,25 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:8.38.0": + version: 8.38.0 + resolution: "@typescript-eslint/scope-manager@npm:8.38.0" + dependencies: + "@typescript-eslint/types": "npm:8.38.0" + "@typescript-eslint/visitor-keys": "npm:8.38.0" + checksum: 10/0809a4135a02c1451fbf44273b583e7e1be7038bd89740756fb8873a714d5cf0c6143c58f4bf5b8c6f215fa1df6024f3347c8ff03d425c0454109252fb820ea2 + languageName: node + linkType: hard + +"@typescript-eslint/tsconfig-utils@npm:8.38.0, @typescript-eslint/tsconfig-utils@npm:^8.38.0": + version: 8.38.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.38.0" + peerDependencies: + typescript: ">=4.8.4 <5.9.0" + checksum: 10/e1c80d2a4bd50edc5c1da418bd11cf67fc10e55fc9e2e937df2799c44de7f48739c7821c0579a3b92658e50eb75e341ab89610e952ea28b7deb901219235821c + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:8.31.0": version: 8.31.0 resolution: "@typescript-eslint/type-utils@npm:8.31.0" @@ -4803,6 +4914,22 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/type-utils@npm:8.38.0": + version: 8.38.0 + resolution: "@typescript-eslint/type-utils@npm:8.38.0" + dependencies: + "@typescript-eslint/types": "npm:8.38.0" + "@typescript-eslint/typescript-estree": "npm:8.38.0" + "@typescript-eslint/utils": "npm:8.38.0" + debug: "npm:^4.3.4" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/e28302119b500ef30d35e1e8d903a2868836f05d6c15889d0a361c33b8d853b55c2965a3b4fd3d761c35bc8746184624a42ef627ad15d3720b208801c0bfd551 + languageName: node + linkType: hard + "@typescript-eslint/types@npm:8.31.0": version: 8.31.0 resolution: "@typescript-eslint/types@npm:8.31.0" @@ -4810,6 +4937,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:8.38.0, @typescript-eslint/types@npm:^8.38.0": + version: 8.38.0 + resolution: "@typescript-eslint/types@npm:8.38.0" + checksum: 10/87ac2d199eeadd35157f08deab0929616f74f50a0ed8ec0d6b216bc33755b3fc41615b2386587569c723d6cfa74a3ada428bd31c8f00ea23520213750fd2d297 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:8.31.0": version: 8.31.0 resolution: "@typescript-eslint/typescript-estree@npm:8.31.0" @@ -4828,7 +4962,27 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.31.0, @typescript-eslint/utils@npm:^8.29.0, @typescript-eslint/utils@npm:^8.30.1": +"@typescript-eslint/typescript-estree@npm:8.38.0": + version: 8.38.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.38.0" + dependencies: + "@typescript-eslint/project-service": "npm:8.38.0" + "@typescript-eslint/tsconfig-utils": "npm:8.38.0" + "@typescript-eslint/types": "npm:8.38.0" + "@typescript-eslint/visitor-keys": "npm:8.38.0" + debug: "npm:^4.3.4" + fast-glob: "npm:^3.3.2" + is-glob: "npm:^4.0.3" + minimatch: "npm:^9.0.4" + semver: "npm:^7.6.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + typescript: ">=4.8.4 <5.9.0" + checksum: 10/4ff14184a9ad15fcb3c3c60e3e950659004b81458db14b84dda084b9108c390e1fa26611aee764935c7cb483f2701e63f54bcb668c9fec5278ecae5ef734417f + languageName: node + linkType: hard + +"@typescript-eslint/utils@npm:8.31.0": version: 8.31.0 resolution: "@typescript-eslint/utils@npm:8.31.0" dependencies: @@ -4843,6 +4997,21 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:8.38.0, @typescript-eslint/utils@npm:^8.29.0, @typescript-eslint/utils@npm:^8.30.1": + version: 8.38.0 + resolution: "@typescript-eslint/utils@npm:8.38.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.38.0" + "@typescript-eslint/types": "npm:8.38.0" + "@typescript-eslint/typescript-estree": "npm:8.38.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.9.0" + checksum: 10/5be4936796b0f1b1d3111e4544fddad03e38a792812cdbeca90ffdbb2048a2a593e7593feb4f8e9d59d2989208e6040988f3c9925275e7fb8c965eccc5a736b3 + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:8.31.0": version: 8.31.0 resolution: "@typescript-eslint/visitor-keys@npm:8.31.0" @@ -4853,123 +5022,147 @@ __metadata: languageName: node linkType: hard -"@unrs/resolver-binding-darwin-arm64@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-darwin-arm64@npm:1.7.0" +"@typescript-eslint/visitor-keys@npm:8.38.0": + version: 8.38.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.38.0" + dependencies: + "@typescript-eslint/types": "npm:8.38.0" + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10/a95a535146a1d4d7bdfd32bd678b21ab4c6856c9e9114338253e7181eac3663d399e0bf357c492f029a9069a531f6357acbcc710284fdfdac535ced9d5b95fbf + languageName: node + linkType: hard + +"@unrs/resolver-binding-android-arm-eabi@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-android-arm-eabi@npm:1.11.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@unrs/resolver-binding-android-arm64@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-android-arm64@npm:1.11.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@unrs/resolver-binding-darwin-arm64@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-darwin-arm64@npm:1.11.1" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@unrs/resolver-binding-darwin-x64@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-darwin-x64@npm:1.7.0" +"@unrs/resolver-binding-darwin-x64@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-darwin-x64@npm:1.11.1" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@unrs/resolver-binding-freebsd-x64@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-freebsd-x64@npm:1.7.0" +"@unrs/resolver-binding-freebsd-x64@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-freebsd-x64@npm:1.11.1" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.7.0" +"@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@unrs/resolver-binding-linux-arm-musleabihf@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-linux-arm-musleabihf@npm:1.7.0" +"@unrs/resolver-binding-linux-arm-musleabihf@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-arm-musleabihf@npm:1.11.1" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@unrs/resolver-binding-linux-arm64-gnu@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-linux-arm64-gnu@npm:1.7.0" +"@unrs/resolver-binding-linux-arm64-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-arm64-gnu@npm:1.11.1" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@unrs/resolver-binding-linux-arm64-musl@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-linux-arm64-musl@npm:1.7.0" +"@unrs/resolver-binding-linux-arm64-musl@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-arm64-musl@npm:1.11.1" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@unrs/resolver-binding-linux-ppc64-gnu@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-linux-ppc64-gnu@npm:1.7.0" +"@unrs/resolver-binding-linux-ppc64-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-ppc64-gnu@npm:1.11.1" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@unrs/resolver-binding-linux-riscv64-gnu@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-linux-riscv64-gnu@npm:1.7.0" +"@unrs/resolver-binding-linux-riscv64-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-riscv64-gnu@npm:1.11.1" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@unrs/resolver-binding-linux-riscv64-musl@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-linux-riscv64-musl@npm:1.7.0" +"@unrs/resolver-binding-linux-riscv64-musl@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-riscv64-musl@npm:1.11.1" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@unrs/resolver-binding-linux-s390x-gnu@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-linux-s390x-gnu@npm:1.7.0" +"@unrs/resolver-binding-linux-s390x-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-s390x-gnu@npm:1.11.1" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@unrs/resolver-binding-linux-x64-gnu@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-linux-x64-gnu@npm:1.7.0" +"@unrs/resolver-binding-linux-x64-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-x64-gnu@npm:1.11.1" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@unrs/resolver-binding-linux-x64-musl@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-linux-x64-musl@npm:1.7.0" +"@unrs/resolver-binding-linux-x64-musl@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-x64-musl@npm:1.11.1" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@unrs/resolver-binding-wasm32-wasi@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-wasm32-wasi@npm:1.7.0" +"@unrs/resolver-binding-wasm32-wasi@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-wasm32-wasi@npm:1.11.1" dependencies: - "@napi-rs/wasm-runtime": "npm:^0.2.9" + "@napi-rs/wasm-runtime": "npm:^0.2.11" conditions: cpu=wasm32 languageName: node linkType: hard -"@unrs/resolver-binding-win32-arm64-msvc@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-win32-arm64-msvc@npm:1.7.0" +"@unrs/resolver-binding-win32-arm64-msvc@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-win32-arm64-msvc@npm:1.11.1" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@unrs/resolver-binding-win32-ia32-msvc@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-win32-ia32-msvc@npm:1.7.0" +"@unrs/resolver-binding-win32-ia32-msvc@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-win32-ia32-msvc@npm:1.11.1" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@unrs/resolver-binding-win32-x64-msvc@npm:1.7.0": - version: 1.7.0 - resolution: "@unrs/resolver-binding-win32-x64-msvc@npm:1.7.0" +"@unrs/resolver-binding-win32-x64-msvc@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-win32-x64-msvc@npm:1.11.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -5339,12 +5532,12 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.14.0": - version: 8.14.1 - resolution: "acorn@npm:8.14.1" +"acorn@npm:^8.14.0, acorn@npm:^8.15.0": + version: 8.15.0 + resolution: "acorn@npm:8.15.0" bin: acorn: bin/acorn - checksum: 10/d1379bbee224e8d44c3c3946e6ba6973e999fbdd4e22e41c3455d7f9b6f72f7ce18d3dc218002e1e48eea789539cf1cb6d1430c81838c6744799c712fb557d92 + checksum: 10/77f2de5051a631cf1729c090e5759148459cdb76b5f5c70f890503d629cf5052357b0ce783c0f976dd8a93c5150f59f6d18df1def3f502396a20f81282482fa4 languageName: node linkType: hard @@ -7684,10 +7877,10 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^4.2.0": - version: 4.2.0 - resolution: "eslint-visitor-keys@npm:4.2.0" - checksum: 10/9651b3356b01760e586b4c631c5268c0e1a85236e3292bf754f0472f465bf9a856c0ddc261fceace155334118c0151778effafbab981413dbf9288349343fa25 +"eslint-visitor-keys@npm:^4.2.0, eslint-visitor-keys@npm:^4.2.1": + version: 4.2.1 + resolution: "eslint-visitor-keys@npm:4.2.1" + checksum: 10/3ee00fc6a7002d4b0ffd9dc99e13a6a7882c557329e6c25ab254220d71e5c9c4f89dca4695352949ea678eb1f3ba912a18ef8aac0a7fe094196fd92f441bfce2 languageName: node linkType: hard @@ -7742,13 +7935,13 @@ __metadata: linkType: hard "espree@npm:^10.0.1, espree@npm:^10.1.0, espree@npm:^10.3.0": - version: 10.3.0 - resolution: "espree@npm:10.3.0" + version: 10.4.0 + resolution: "espree@npm:10.4.0" dependencies: - acorn: "npm:^8.14.0" + acorn: "npm:^8.15.0" acorn-jsx: "npm:^5.3.2" - eslint-visitor-keys: "npm:^4.2.0" - checksum: 10/3412d44d4204c9e29d6b5dd0277400cfa0cd68495dc09eae1b9ce79d0c8985c1c5cc09cb9ba32a1cd963f48a49b0c46bdb7736afe395a300aa6bb1c0d86837e8 + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10/9b355b32dbd1cc9f57121d5ee3be258fab87ebeb7c83fc6c02e5af1a74fc8c5ba79fe8c663e69ea112c3e84a1b95e6a2067ac4443ee7813bb85ac7581acb8bf9 languageName: node linkType: hard @@ -8856,6 +9049,13 @@ __metadata: languageName: node linkType: hard +"ignore@npm:^7.0.0": + version: 7.0.5 + resolution: "ignore@npm:7.0.5" + checksum: 10/f134b96a4de0af419196f52c529d5c6120c4456ff8a6b5a14ceaaa399f883e15d58d2ce651c9b69b9388491d4669dda47285d307e827de9304a53a1824801bc6 + languageName: node + linkType: hard + "immer@npm:^9.0.6": version: 9.0.21 resolution: "immer@npm:9.0.21" @@ -10737,12 +10937,12 @@ __metadata: languageName: node linkType: hard -"napi-postinstall@npm:^0.1.6": - version: 0.1.6 - resolution: "napi-postinstall@npm:0.1.6" +"napi-postinstall@npm:^0.3.0": + version: 0.3.2 + resolution: "napi-postinstall@npm:0.3.2" bin: napi-postinstall: lib/cli.js - checksum: 10/991b296c666042452d9e3004746192f0a021fc1400ecafa254c0b586899d1525247f04af7613f08b1fc5b997e6b2787c607d7b6bf274e1c60e18c451872ec271 + checksum: 10/35b7edccbc21b6f2c6c3c9f38a3ae7fc72d34811a72e27287e603fae82da63c862139d404f7281886ddde1f1af0aa310a95617f0424a15a873ce9ae43866ddc0 languageName: node linkType: hard @@ -12497,11 +12697,11 @@ __metadata: linkType: hard "semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3, semver@npm:^7.7.1": - version: 7.7.1 - resolution: "semver@npm:7.7.1" + version: 7.7.2 + resolution: "semver@npm:7.7.2" bin: semver: bin/semver.js - checksum: 10/4cfa1eb91ef3751e20fc52e47a935a0118d56d6f15a837ab814da0c150778ba2ca4f1a4d9068b33070ea4273629e615066664c2cfcd7c272caf7a8a0f6518b2c + checksum: 10/7a24cffcaa13f53c09ce55e05efe25cd41328730b2308678624f8b9f5fc3093fc4d189f47950f0b811ff8f3c3039c24a2c36717ba7961615c682045bf03e1dda languageName: node linkType: hard @@ -13515,7 +13715,7 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^2.0.1": +"ts-api-utils@npm:^2.0.1, ts-api-utils@npm:^2.1.0": version: 2.1.0 resolution: "ts-api-utils@npm:2.1.0" peerDependencies: @@ -13909,28 +14109,34 @@ __metadata: linkType: hard "unrs-resolver@npm:^1.6.0, unrs-resolver@npm:^1.6.3": - version: 1.7.0 - resolution: "unrs-resolver@npm:1.7.0" - dependencies: - "@unrs/resolver-binding-darwin-arm64": "npm:1.7.0" - "@unrs/resolver-binding-darwin-x64": "npm:1.7.0" - "@unrs/resolver-binding-freebsd-x64": "npm:1.7.0" - "@unrs/resolver-binding-linux-arm-gnueabihf": "npm:1.7.0" - "@unrs/resolver-binding-linux-arm-musleabihf": "npm:1.7.0" - "@unrs/resolver-binding-linux-arm64-gnu": "npm:1.7.0" - "@unrs/resolver-binding-linux-arm64-musl": "npm:1.7.0" - "@unrs/resolver-binding-linux-ppc64-gnu": "npm:1.7.0" - "@unrs/resolver-binding-linux-riscv64-gnu": "npm:1.7.0" - "@unrs/resolver-binding-linux-riscv64-musl": "npm:1.7.0" - "@unrs/resolver-binding-linux-s390x-gnu": "npm:1.7.0" - "@unrs/resolver-binding-linux-x64-gnu": "npm:1.7.0" - "@unrs/resolver-binding-linux-x64-musl": "npm:1.7.0" - "@unrs/resolver-binding-wasm32-wasi": "npm:1.7.0" - "@unrs/resolver-binding-win32-arm64-msvc": "npm:1.7.0" - "@unrs/resolver-binding-win32-ia32-msvc": "npm:1.7.0" - "@unrs/resolver-binding-win32-x64-msvc": "npm:1.7.0" - napi-postinstall: "npm:^0.1.6" + version: 1.11.1 + resolution: "unrs-resolver@npm:1.11.1" + dependencies: + "@unrs/resolver-binding-android-arm-eabi": "npm:1.11.1" + "@unrs/resolver-binding-android-arm64": "npm:1.11.1" + "@unrs/resolver-binding-darwin-arm64": "npm:1.11.1" + "@unrs/resolver-binding-darwin-x64": "npm:1.11.1" + "@unrs/resolver-binding-freebsd-x64": "npm:1.11.1" + "@unrs/resolver-binding-linux-arm-gnueabihf": "npm:1.11.1" + "@unrs/resolver-binding-linux-arm-musleabihf": "npm:1.11.1" + "@unrs/resolver-binding-linux-arm64-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-arm64-musl": "npm:1.11.1" + "@unrs/resolver-binding-linux-ppc64-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-riscv64-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-riscv64-musl": "npm:1.11.1" + "@unrs/resolver-binding-linux-s390x-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-x64-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-x64-musl": "npm:1.11.1" + "@unrs/resolver-binding-wasm32-wasi": "npm:1.11.1" + "@unrs/resolver-binding-win32-arm64-msvc": "npm:1.11.1" + "@unrs/resolver-binding-win32-ia32-msvc": "npm:1.11.1" + "@unrs/resolver-binding-win32-x64-msvc": "npm:1.11.1" + napi-postinstall: "npm:^0.3.0" dependenciesMeta: + "@unrs/resolver-binding-android-arm-eabi": + optional: true + "@unrs/resolver-binding-android-arm64": + optional: true "@unrs/resolver-binding-darwin-arm64": optional: true "@unrs/resolver-binding-darwin-x64": @@ -13965,7 +14171,7 @@ __metadata: optional: true "@unrs/resolver-binding-win32-x64-msvc": optional: true - checksum: 10/2a182ab87a8119383ef2774e47a11229b9d38b068f729d91fd3567e2bf2b9c6bc21e18c985a0c5df0df4aca786abe76f840793235155fe449da80fc62884686f + checksum: 10/4de653508cbaae47883a896bd5cdfef0e5e87b428d62620d16fd35cd534beaebf08ebf0cf2f8b4922aa947b2fe745180facf6cc3f39ba364f7ce0f974cb06a70 languageName: node linkType: hard From 2e7910ad3b21af967d06e7b94aab2ef8d2455e04 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:56:13 -0400 Subject: [PATCH 02/15] feat(kernel-language-model-service): Add OllamaNodejsLanguageModelService --- .../package.json | 15 +- .../src/index.test.ts | 11 - .../src/index.ts | 9 - .../src/ollama/base.test.ts | 247 ++++++++++++++++++ .../src/ollama/base.ts | 79 ++++++ .../src/ollama/constants.ts | 3 + .../src/ollama/nodejs.test.ts | 74 ++++++ .../src/ollama/nodejs.ts | 24 ++ .../src/ollama/parse.test.ts | 50 ++++ .../src/ollama/parse.ts | 18 ++ .../src/ollama/types.ts | 27 ++ .../src/types.ts | 43 +++ .../test/e2e/ollama.test.ts | 72 +++++ .../test/utils.ts | 66 +++++ .../tsconfig.build.json | 2 +- .../tsconfig.json | 6 +- .../vitest.config.e2e.ts | 18 ++ .../vitest.config.ts | 1 + vitest.config.ts | 6 + yarn.lock | 18 ++ 20 files changed, 761 insertions(+), 28 deletions(-) delete mode 100644 packages/kernel-language-model-service/src/index.test.ts delete mode 100644 packages/kernel-language-model-service/src/index.ts create mode 100644 packages/kernel-language-model-service/src/ollama/base.test.ts create mode 100644 packages/kernel-language-model-service/src/ollama/base.ts create mode 100644 packages/kernel-language-model-service/src/ollama/constants.ts create mode 100644 packages/kernel-language-model-service/src/ollama/nodejs.test.ts create mode 100644 packages/kernel-language-model-service/src/ollama/nodejs.ts create mode 100644 packages/kernel-language-model-service/src/ollama/parse.test.ts create mode 100644 packages/kernel-language-model-service/src/ollama/parse.ts create mode 100644 packages/kernel-language-model-service/src/ollama/types.ts create mode 100644 packages/kernel-language-model-service/src/types.ts create mode 100644 packages/kernel-language-model-service/test/e2e/ollama.test.ts create mode 100644 packages/kernel-language-model-service/test/utils.ts create mode 100644 packages/kernel-language-model-service/vitest.config.e2e.ts diff --git a/packages/kernel-language-model-service/package.json b/packages/kernel-language-model-service/package.json index 46411d097a..0bc295c2d2 100644 --- a/packages/kernel-language-model-service/package.json +++ b/packages/kernel-language-model-service/package.json @@ -13,14 +13,14 @@ }, "type": "module", "exports": { - ".": { + "./ollama/nodejs": { "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" + "types": "./dist/ollama/nodejs.d.mts", + "default": "./dist/ollama/nodejs.mjs" }, "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" + "types": "./dist/ollama/nodejs.d.cts", + "default": "./dist/ollama/nodejs.cjs" } }, "./package.json": "./package.json" @@ -40,6 +40,7 @@ "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore", "publish:preview": "yarn npm publish --tag preview", "test": "vitest run --config vitest.config.ts", + "test:e2e": "vitest run --config vitest.config.e2e.ts", "test:clean": "yarn test --no-cache --coverage.clean", "test:dev": "yarn test --mode development", "test:verbose": "yarn test --reporter verbose", @@ -51,6 +52,7 @@ "@metamask/eslint-config": "^14.0.0", "@metamask/eslint-config-nodejs": "^14.0.0", "@metamask/eslint-config-typescript": "^14.0.0", + "@metamask/streams": "workspace:^", "@ocap/test-utils": "workspace:^", "@ts-bridge/cli": "^0.6.3", "@ts-bridge/shims": "^0.1.1", @@ -77,5 +79,8 @@ }, "engines": { "node": "^20 || >=22" + }, + "dependencies": { + "ollama": "^0.5.16" } } diff --git a/packages/kernel-language-model-service/src/index.test.ts b/packages/kernel-language-model-service/src/index.test.ts deleted file mode 100644 index b019d6c617..0000000000 --- a/packages/kernel-language-model-service/src/index.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import greet from './index.ts'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greet(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/kernel-language-model-service/src/index.ts b/packages/kernel-language-model-service/src/index.ts deleted file mode 100644 index f7250b9986..0000000000 --- a/packages/kernel-language-model-service/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greet(name: string): string { - return `Hello, ${name}!`; -} diff --git a/packages/kernel-language-model-service/src/ollama/base.test.ts b/packages/kernel-language-model-service/src/ollama/base.test.ts new file mode 100644 index 0000000000..449c9a54d5 --- /dev/null +++ b/packages/kernel-language-model-service/src/ollama/base.test.ts @@ -0,0 +1,247 @@ +import type { GenerateResponse, ListResponse } from 'ollama'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { OllamaClient, OllamaModelOptions } from './types.ts'; +import type { InstanceConfig, LanguageModel } from '../types.ts'; +import { OllamaBaseLanguageModelService } from './base.ts'; +import { makeMockAbortableAsyncIterator } from '../../test/utils.ts'; + +describe('OllamaBaseLanguageModelService', () => { + let mockClient: OllamaClient; + let mockMakeClient: () => Promise; + let service: OllamaBaseLanguageModelService; + + const archetypes = { + default: 'llama2:7b', + fast: 'llama2:3b', + accurate: 'llama2:13b', + } as const; + + beforeEach(() => { + mockClient = { + list: vi.fn(), + generate: vi.fn(), + }; + + mockMakeClient = vi.fn().mockResolvedValue(mockClient); + service = new OllamaBaseLanguageModelService(archetypes, mockMakeClient); + }); + + describe('constructor', () => { + it('should initialize with archetypes and makeClient function', () => { + expect(service).toBeInstanceOf(OllamaBaseLanguageModelService); + }); + + it('should handle empty archetypes', () => { + const emptyArchetypes = {}; + const serviceWithEmptyArchetypes = new OllamaBaseLanguageModelService( + emptyArchetypes, + mockMakeClient, + ); + expect(serviceWithEmptyArchetypes).toBeInstanceOf( + OllamaBaseLanguageModelService, + ); + }); + }); + + describe('getModels', () => { + it('should return models from client', async () => { + const mockListResponse = { + models: [ + { + name: 'llama2:7b', + }, + { + name: 'llama2:13b', + }, + ], + } as ListResponse; + + vi.mocked(mockClient.list).mockResolvedValue(mockListResponse); + + const result = await service.getModels(); + + expect(mockMakeClient).toHaveBeenCalledOnce(); + expect(mockClient.list).toHaveBeenCalledOnce(); + expect(result).toStrictEqual(mockListResponse); + }); + + it('should handle client creation errors', async () => { + const error = new Error('Failed to create client'); + mockMakeClient = vi.fn().mockRejectedValue(error); + service = new OllamaBaseLanguageModelService(archetypes, mockMakeClient); + + await expect(service.getModels()).rejects.toThrow( + 'Failed to create client', + ); + }); + + it('should handle list errors', async () => { + const error = new Error('Failed to list models'); + vi.mocked(mockClient.list).mockRejectedValue(error); + + await expect(service.getModels()).rejects.toThrow( + 'Failed to list models', + ); + }); + }); + + describe('makeInstance', () => { + it('should create instance with archetype model', async () => { + const config: InstanceConfig = { + archetype: 'default', + options: { temperature: 0.7 }, + }; + + const instance = await service.makeInstance(config); + + expect(mockMakeClient).toHaveBeenCalledOnce(); + expect(instance.model).toBe('llama2:7b'); + expect(instance).toHaveProperty('load'); + expect(instance).toHaveProperty('unload'); + expect(instance).toHaveProperty('sample'); + }); + + it('should create instance with direct model name', async () => { + const config: InstanceConfig = { + model: 'custom-model:latest', + options: { temperature: 0.8 }, + }; + + const instance = await service.makeInstance(config); + + expect(instance.model).toBe('custom-model:latest'); + }); + + it('should throw error for unknown archetype', async () => { + const config: InstanceConfig = { + archetype: 'unknown', + }; + + await expect(service.makeInstance(config)).rejects.toThrow( + /^Archetype .+ not found$/u, + ); + }); + + it('should handle config with no options', async () => { + const config: InstanceConfig = { + archetype: 'default', + }; + + const instance = await service.makeInstance(config); + + expect(instance.model).toBe('llama2:7b'); + }); + }); + + describe('instance methods', () => { + let instance: LanguageModel; + + beforeEach(async () => { + const config: InstanceConfig = { + archetype: 'default', + // eslint-disable-next-line @typescript-eslint/naming-convention + options: { temperature: 0.7, top_p: 0.9 }, + }; + instance = await service.makeInstance(config); + }); + + describe('load', () => { + it('should call generate with keep_alive: -1', async () => { + await instance.load(); + + expect(mockClient.generate).toHaveBeenCalledWith({ + model: 'llama2:7b', + // eslint-disable-next-line @typescript-eslint/naming-convention + keep_alive: -1, + }); + }); + + it('should handle load errors', async () => { + const error = new Error('Load failed'); + vi.mocked(mockClient.generate).mockRejectedValue(error); + + await expect(instance.load()).rejects.toThrow('Load failed'); + }); + }); + + describe('unload', () => { + it('should call generate with keep_alive: 0', async () => { + await instance.unload(); + + expect(mockClient.generate).toHaveBeenCalledWith({ + model: 'llama2:7b', + // eslint-disable-next-line @typescript-eslint/naming-convention + keep_alive: 0, + }); + }); + + it('should handle unload errors', async () => { + const error = new Error('Unload failed'); + vi.mocked(mockClient.generate).mockRejectedValue(error); + + await expect(instance.unload()).rejects.toThrow('Unload failed'); + }); + }); + + describe('sample', () => { + const mockResponse = { + thinking: 'Thinking...', + response: 'Hello world', + done: false, + } as GenerateResponse; + + it('should call generate with correct parameters and merge options', async () => { + const prompt = 'Hello, how are you?'; + const options = { temperature: 0.5 }; + + vi.mocked(mockClient.generate).mockResolvedValue( + makeMockAbortableAsyncIterator([mockResponse]), + ); + + const result = await instance.sample(prompt, options); + + for await (const chunk of result) { + expect(chunk).toMatchObject(mockResponse); + } + + expect(mockClient.generate).toHaveBeenCalledWith({ + temperature: 0.5, // from options (should override config) + // eslint-disable-next-line @typescript-eslint/naming-convention + top_p: 0.9, // from config + model: 'llama2:7b', + stream: true, + raw: true, + prompt, + }); + }); + + it('should use default options when none provided', async () => { + const prompt = 'Hello, how are you?'; + + await instance.sample(prompt); + + expect(mockClient.generate).toHaveBeenCalledWith({ + temperature: 0.7, + // eslint-disable-next-line @typescript-eslint/naming-convention + top_p: 0.9, + model: 'llama2:7b', + stream: true, + raw: true, + prompt, + }); + }); + + it('should handle generate errors', async () => { + const error = new Error('Generate failed'); + vi.mocked(mockClient.generate).mockRejectedValue(error); + + const prompt = 'Hello, how are you?'; + + await expect(instance.sample(prompt)).rejects.toThrow( + 'Generate failed', + ); + }); + }); + }); +}); diff --git a/packages/kernel-language-model-service/src/ollama/base.ts b/packages/kernel-language-model-service/src/ollama/base.ts new file mode 100644 index 0000000000..8527ecb2da --- /dev/null +++ b/packages/kernel-language-model-service/src/ollama/base.ts @@ -0,0 +1,79 @@ +import type { GenerateRequest, GenerateResponse } from 'ollama'; + +import type { + InstanceConfig, + LanguageModel, + LanguageModelService, +} from '../types.ts'; +import { parseModelConfig } from './parse.ts'; +import type { OllamaClient, OllamaModelOptions } from './types.ts'; + +/** + * It is recommended to create an Ollama client per model session. + */ +export class OllamaBaseLanguageModelService + implements + LanguageModelService< + OllamaModelOptions, + OllamaModelOptions, + GenerateResponse + > +{ + readonly #archetypes: Record; + + readonly #makeClient: () => Promise; + + constructor( + archetypes: Record, + makeClient: () => Promise, + ) { + this.#archetypes = archetypes; + this.#makeClient = makeClient; + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async getModels() { + const client = await this.#makeClient(); + return await client.list(); + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async makeInstance(config: InstanceConfig) { + const model = parseModelConfig(config, this.#archetypes); + const ollama = await this.#makeClient(); + const defaultOptions = { + ...(config.options ?? {}), + }; + const mandatoryOptions = { + model, + stream: true, + raw: true, + }; + + const instance = { + model, + load: async () => { + // eslint-disable-next-line @typescript-eslint/naming-convention + await ollama.generate({ model, keep_alive: -1 } as GenerateRequest); + }, + unload: async () => { + // eslint-disable-next-line @typescript-eslint/naming-convention + await ollama.generate({ model, keep_alive: 0 } as GenerateRequest); + }, + sample: async (prompt: string, options?: Partial) => { + const response = await ollama.generate({ + ...defaultOptions, + ...(options ?? {}), + ...mandatoryOptions, + prompt, + }); + return (async function* () { + for await (const chunk of response) { + yield chunk; + } + })(); + }, + }; + return instance as LanguageModel; + } +} diff --git a/packages/kernel-language-model-service/src/ollama/constants.ts b/packages/kernel-language-model-service/src/ollama/constants.ts new file mode 100644 index 0000000000..abf1e70672 --- /dev/null +++ b/packages/kernel-language-model-service/src/ollama/constants.ts @@ -0,0 +1,3 @@ +export const defaultConfig = { + host: 'http://localhost:11434', +}; diff --git a/packages/kernel-language-model-service/src/ollama/nodejs.test.ts b/packages/kernel-language-model-service/src/ollama/nodejs.test.ts new file mode 100644 index 0000000000..060515cc64 --- /dev/null +++ b/packages/kernel-language-model-service/src/ollama/nodejs.test.ts @@ -0,0 +1,74 @@ +import { fetchMock } from '@ocap/test-utils'; +import { expect, describe, it, beforeEach } from 'vitest'; + +import { OllamaNodejsLanguageModelService } from './nodejs.ts'; +import { mockReadableStream } from '../../test/utils.ts'; + +describe('OllamaNodejsLanguageModelService', () => { + let service: OllamaNodejsLanguageModelService; + const archetype = 'fast'; + + beforeEach(async () => { + service = new OllamaNodejsLanguageModelService( + { [archetype]: 'llama3.2:latest' }, + // For e2e tests, we need to run Ollama locally + { host: 'http://127.0.0.1:11434' }, + ); + }); + + describe('constructor', () => { + const testArchetypes = { fast: 'llama3.2:latest' }; + it.each([ + ['undefined config', testArchetypes, undefined], + ['empty config', testArchetypes, {}], + ['basic config', testArchetypes, { host: 'http://127.0.0.1:11434' }], + ])( + 'should create a service with the correct endowments: %s', + (_testName, archetypes, config) => { + const constructedService = new OllamaNodejsLanguageModelService( + archetypes, + config, + ); + expect(constructedService).toBeDefined(); + }, + ); + }); + + describe('makeInstance', () => { + it('should create a model instance', async () => { + const model = await service.makeInstance({ archetype }); + expect(model).toBeDefined(); + }); + }); + + describe('getModels', () => { + it('should return a list of models', async () => { + const response = { models: [{ name: 'llama3.2:latest' }] }; + fetchMock.mockResponse({ + body: JSON.stringify(response), + }); + const { models } = await service.getModels(); + expect(models).toMatchObject(response.models); + }); + }); + + describe('complete', () => { + it('should stream a response', async () => { + const response = 'world!'; + + fetchMock.mockResponse({ + body: mockReadableStream([{ response }]), + } as Parameters[0]); + + const instance = await service.makeInstance({ archetype }); + for await (const chunk of await instance.sample('Hello, ')) { + expect(chunk).toMatchObject({ + response, + done: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + done_reason: 'stop', + }); + } + }); + }); +}); diff --git a/packages/kernel-language-model-service/src/ollama/nodejs.ts b/packages/kernel-language-model-service/src/ollama/nodejs.ts new file mode 100644 index 0000000000..4e7cf0ab71 --- /dev/null +++ b/packages/kernel-language-model-service/src/ollama/nodejs.ts @@ -0,0 +1,24 @@ +import { Ollama } from 'ollama'; +import type { Config } from 'ollama'; + +import { OllamaBaseLanguageModelService } from './base.ts'; +import { defaultConfig } from './constants.ts'; +import type { OllamaClient } from './types.ts'; + +const makeOllamaClient = (config: typeof defaultConfig): OllamaClient => + new Ollama(config) as OllamaClient; + +export class OllamaNodejsLanguageModelService extends OllamaBaseLanguageModelService { + constructor( + archetypes: Record, + config: Partial = {}, + ) { + // We use ignore because this is only a ts-error in Node 20, not in Node 22. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore globalThis.fetch is untyped in Node 20, but proper in Node 22. + const endowments = { fetch }; + super(archetypes, async () => + makeOllamaClient({ ...defaultConfig, ...config, ...endowments }), + ); + } +} diff --git a/packages/kernel-language-model-service/src/ollama/parse.test.ts b/packages/kernel-language-model-service/src/ollama/parse.test.ts new file mode 100644 index 0000000000..bc6792993e --- /dev/null +++ b/packages/kernel-language-model-service/src/ollama/parse.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; + +import type { InstanceConfig } from '../types.ts'; +import { parseModelConfig } from './parse.ts'; +import type { OllamaModelOptions } from './types.ts'; + +describe('parseModelConfig', () => { + const mockArchetypes = { + default: 'llama2:7b', + fast: 'llama2:3b', + accurate: 'llama2:13b', + }; + + it.each([ + ['archetype', { archetype: 'fast' }, 'llama2:3b'], + ['model', { model: 'custom-model:latest' }, 'custom-model:latest'], + [ + 'archetype over model', + { archetype: 'accurate', model: 'custom-model:latest' }, + 'llama2:13b', + ], + ])('should return %s when %s is provided', (_, config, expected) => { + // @ts-expect-error - destructive testing + const result = parseModelConfig(config, mockArchetypes); + expect(result).toBe(expected); + }); + + it('should handle empty archetypes object', () => { + const archetype = 'nonexistent'; + const config: InstanceConfig = { archetype }; + expect(() => parseModelConfig(config, {})).toThrow( + `Archetype ${archetype} not found`, + ); + }); + + it('should throw when archetype not found in non-empty archetypes object', () => { + const archetype = 'nonexistent'; + const config: InstanceConfig = { archetype }; + expect(() => parseModelConfig(config, mockArchetypes)).toThrow( + `Archetype ${archetype} not found`, + ); + }); + + it('should throw when neither archetype nor model is provided', () => { + const config = {} as InstanceConfig; + expect(() => parseModelConfig(config, mockArchetypes)).toThrow( + 'No model or archetype provided', + ); + }); +}); diff --git a/packages/kernel-language-model-service/src/ollama/parse.ts b/packages/kernel-language-model-service/src/ollama/parse.ts new file mode 100644 index 0000000000..ac38f4dc4b --- /dev/null +++ b/packages/kernel-language-model-service/src/ollama/parse.ts @@ -0,0 +1,18 @@ +import type { InstanceConfig } from '../types.ts'; +import type { OllamaModelOptions } from './types.ts'; + +export const parseModelConfig = ( + config: InstanceConfig, + archetypes: Record, +): string => { + if (config.archetype) { + if (archetypes[config.archetype]) { + return archetypes[config.archetype] as string; + } + throw new Error(`Archetype ${config.archetype} not found`); + } else if (config.model) { + return config.model; + } else { + throw new Error('No model or archetype provided'); + } +}; diff --git a/packages/kernel-language-model-service/src/ollama/types.ts b/packages/kernel-language-model-service/src/ollama/types.ts new file mode 100644 index 0000000000..32c707c8ec --- /dev/null +++ b/packages/kernel-language-model-service/src/ollama/types.ts @@ -0,0 +1,27 @@ +import type { + GenerateRequest, + GenerateResponse, + ListResponse, + AbortableAsyncIterator, +} from 'ollama'; + +type OllamaClient = { + list: () => Promise; + generate: ( + request: GenerateRequest, + ) => Promise>; +}; +export type { GenerateRequest, GenerateResponse, OllamaClient }; + +export type OllamaModelOptions = { + // Ollama is pythonic, using snake_case for its options. + /* eslint-disable @typescript-eslint/naming-convention */ + temperature: number; + top_p: number; + top_k: number; + repeat_penalty: number; + repeat_last_n: number; + seed: number; + num_ctx: number; + /* eslint-enable @typescript-eslint/naming-convention */ +}; diff --git a/packages/kernel-language-model-service/src/types.ts b/packages/kernel-language-model-service/src/types.ts new file mode 100644 index 0000000000..fc503bd6cc --- /dev/null +++ b/packages/kernel-language-model-service/src/types.ts @@ -0,0 +1,43 @@ +export type LanguageModel = { + model: string; + /** + * Loads the model into memory and keeps it alive indefinitely. + * + * @returns A promise that resolves when the model is loaded. + */ + load: () => Promise; + /** + * Unloads the model from memory. + * + * @returns A promise that resolves when the model is unloaded. + */ + unload: () => Promise; + /** + * @param prompt - The prompt to complete. + * @param streams - The streams { internal, external } to write the response to. + * @param options - The options to pass to the model. + * @returns A promise that resolves when the response is complete, or rejects if an error occurs. + */ + sample: ( + prompt: string, + options?: Partial, + ) => Promise>; +}; + +export type InstanceConfig = + | { + archetype: string; + model?: never; + options?: Partial; + } + | { + archetype?: never; + model: string; + options?: Partial; + }; + +export type LanguageModelService = { + makeInstance: ( + config: InstanceConfig, + ) => Promise>; +}; diff --git a/packages/kernel-language-model-service/test/e2e/ollama.test.ts b/packages/kernel-language-model-service/test/e2e/ollama.test.ts new file mode 100644 index 0000000000..78b4aa5872 --- /dev/null +++ b/packages/kernel-language-model-service/test/e2e/ollama.test.ts @@ -0,0 +1,72 @@ +import { fetchMock } from '@ocap/test-utils'; +import { expect, describe, it, beforeEach } from 'vitest'; + +import { OllamaNodejsLanguageModelService } from '../../src/ollama/nodejs.ts'; + +// This test connects to a local Ollama instance to test sampling capabilities. +const testConfig = { + // Default model: 'llama3.2:latest' + model: 'llama3.2:latest', + // Default host: 'http://127.0.0.1:11434' + host: 'http://127.0.0.1:11434', +}; + +describe('OllamaNodejsLanguageModelService E2E', { timeout: 10_000 }, () => { + let service: OllamaNodejsLanguageModelService; + const archetype = 'default'; + + beforeEach(async () => { + // Disable fetch mocking for this test + fetchMock.disableMocks(); + service = new OllamaNodejsLanguageModelService( + { [archetype]: testConfig.model }, + { host: testConfig.host }, + ); + fetchMock.enableMocks(); + }); + + describe('makeInstance', () => { + it('should create a model instance', async () => { + const model = await service.makeInstance({ archetype }); + expect(model).toBeDefined(); + }); + }); + + describe('getModels', () => { + it('should return a list of models', async () => { + const { models } = await service.getModels(); + expect(models).toBeDefined(); + console.debug('@@@ models: ', models); + expect(models.length).toBeGreaterThan(0); + }); + }); + + describe('complete', () => { + it('should return a streaming result', async () => { + const prompt = 'A B C'; + let completion = prompt; + const instance = await service.makeInstance({ archetype }); + const response = await instance.sample(prompt); + let exitEarly = false; + await Promise.all([ + (async () => { + for await (const chunk of response) { + if (exitEarly) { + return; + } + completion += chunk.response; + } + })(), + new Promise((resolve) => + setTimeout(() => { + exitEarly = true; + resolve(undefined); + }), + ), + ]); + console.debug('@@@ completion: ', completion); + expect(completion).toContain(prompt); + expect(completion.length).toBeGreaterThan(prompt.length); + }); + }); +}); diff --git a/packages/kernel-language-model-service/test/utils.ts b/packages/kernel-language-model-service/test/utils.ts new file mode 100644 index 0000000000..206e14e5f0 --- /dev/null +++ b/packages/kernel-language-model-service/test/utils.ts @@ -0,0 +1,66 @@ +import type { Writer } from '@metamask/streams'; +import type { AbortableAsyncIterator } from 'ollama'; +import { vi } from 'vitest'; +import type { Mocked } from 'vitest'; + +export const mockStream = (): Mocked> => { + const stream: Mocked> = { + next: vi.fn().mockResolvedValue(undefined), + return: vi.fn().mockResolvedValue(undefined), + throw: vi.fn().mockResolvedValue(undefined), + [Symbol.asyncIterator]: vi.fn(() => stream), + }; + return stream; +}; + +export const makeMockAbortableAsyncIterator = ( + responses: Content[], + doneCallback?: () => void, +) => { + let didAbort = false; + const itr = (async function* mockGenerate() { + for (const response of responses) { + yield response; + if (didAbort) { + break; + } + } + doneCallback?.(); + })(); + return { + itr, + doneCallback, + abort: () => (didAbort = true), + [Symbol.asyncIterator]: () => itr, + } as unknown as AbortableAsyncIterator; +}; + +const encoder = new TextEncoder(); +export const mockReadableStream = (chunks: object[]) => + // ReadableStream is experimental in Node 20, but this case works. + // eslint-disable-next-line n/no-unsupported-features/node-builtins + new ReadableStream({ + start(controller) { + for (const chunk of chunks.slice(0, -1)) { + controller.enqueue( + encoder.encode( + JSON.stringify({ + ...chunk, + done: false, + }), + ), + ); + } + controller.enqueue( + encoder.encode( + JSON.stringify({ + ...chunks[chunks.length - 1], + done: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + done_reason: 'stop', + }), + ), + ); + controller.close(); + }, + }); diff --git a/packages/kernel-language-model-service/tsconfig.build.json b/packages/kernel-language-model-service/tsconfig.build.json index 30d7991d1c..7994f1e36b 100644 --- a/packages/kernel-language-model-service/tsconfig.build.json +++ b/packages/kernel-language-model-service/tsconfig.build.json @@ -7,7 +7,7 @@ "rootDir": "./src", "types": [] }, - "references": [], + "references": [{ "path": "../streams/tsconfig.build.json" }], "files": [], "include": ["./src"] } diff --git a/packages/kernel-language-model-service/tsconfig.json b/packages/kernel-language-model-service/tsconfig.json index 3e2399e8f0..fcffc48516 100644 --- a/packages/kernel-language-model-service/tsconfig.json +++ b/packages/kernel-language-model-service/tsconfig.json @@ -5,11 +5,13 @@ "lib": ["ES2022"], "types": ["vitest"] }, - "references": [{ "path": "../test-utils" }], + "references": [{ "path": "../test-utils" }, { "path": "../streams" }], "include": [ "../../vitest.config.ts", "./src", + "./test", "./vite.config.ts", - "./vitest.config.ts" + "./vitest.config.ts", + "./vitest.config.e2e.ts" ] } diff --git a/packages/kernel-language-model-service/vitest.config.e2e.ts b/packages/kernel-language-model-service/vitest.config.e2e.ts new file mode 100644 index 0000000000..1e9bf46246 --- /dev/null +++ b/packages/kernel-language-model-service/vitest.config.e2e.ts @@ -0,0 +1,18 @@ +import { mergeConfig } from '@ocap/test-utils/vitest-config'; +import { defineConfig, defineProject } from 'vitest/config'; + +import defaultConfig from '../../vitest.config.ts'; + +export default defineConfig((args) => { + return mergeConfig( + args, + defaultConfig, + defineProject({ + test: { + name: 'kernel-language-model-service:e2e', + include: ['./test/e2e/**/*.test.ts'], + exclude: ['./src/**/*'], + }, + }), + ); +}); diff --git a/packages/kernel-language-model-service/vitest.config.ts b/packages/kernel-language-model-service/vitest.config.ts index 9bceba6430..6360859946 100644 --- a/packages/kernel-language-model-service/vitest.config.ts +++ b/packages/kernel-language-model-service/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig((args) => { defineProject({ test: { name: 'kernel-language-model-service', + exclude: ['./test/e2e/**'], }, }), ); diff --git a/vitest.config.ts b/vitest.config.ts index 851b2dc58d..23791cd48b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -97,6 +97,12 @@ export default defineConfig({ branches: 92, lines: 98.73, }, + 'packages/kernel-language-model-service/**': { + statements: 100, + functions: 100, + branches: 100, + lines: 100, + }, 'packages/kernel-rpc-methods/**': { statements: 100, functions: 100, diff --git a/yarn.lock b/yarn.lock index d4d61b2990..a891c857ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3237,6 +3237,7 @@ __metadata: "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" "@metamask/eslint-config-typescript": "npm:^14.0.0" + "@metamask/streams": "workspace:^" "@ocap/test-utils": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" "@ts-bridge/shims": "npm:^0.1.1" @@ -3253,6 +3254,7 @@ __metadata: eslint-plugin-n: "npm:^17.17.0" eslint-plugin-prettier: "npm:^5.2.6" eslint-plugin-promise: "npm:^7.2.1" + ollama: "npm:^0.5.16" prettier: "npm:^3.5.3" rimraf: "npm:^6.0.1" typedoc: "npm:^0.28.1" @@ -11284,6 +11286,15 @@ __metadata: languageName: node linkType: hard +"ollama@npm:^0.5.16": + version: 0.5.16 + resolution: "ollama@npm:0.5.16" + dependencies: + whatwg-fetch: "npm:^3.6.20" + checksum: 10/40650813d7cc41058c25c9cb08374078b69f449e84c25dfa4b08176138db93af2f0546eb52431b538a055af30c84908330a8a5ac554595be3022e7f1fdac4a49 + languageName: node + linkType: hard + "on-finished@npm:2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -14605,6 +14616,13 @@ __metadata: languageName: node linkType: hard +"whatwg-fetch@npm:^3.6.20": + version: 3.6.20 + resolution: "whatwg-fetch@npm:3.6.20" + checksum: 10/2b4ed92acd6a7ad4f626a6cb18b14ec982bbcaf1093e6fe903b131a9c6decd14d7f9c9ca3532663c2759d1bdf01d004c77a0adfb2716a5105465c20755a8c57c + languageName: node + linkType: hard + "whatwg-mimetype@npm:^4.0.0": version: 4.0.0 resolution: "whatwg-mimetype@npm:4.0.0" From e83f88abf5471b1a235b06ce7174b20d4f87e3b3 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:09:22 -0400 Subject: [PATCH 03/15] feat(kernel-language-model-service): Construct Ollama with restricted fetch --- .../src/ollama/fetch.test.ts | 122 ++++++++++++++++++ .../src/ollama/fetch.ts | 37 ++++++ .../src/ollama/nodejs.test.ts | 50 +++++-- .../src/ollama/nodejs.ts | 32 ++--- .../src/ollama/types.ts | 7 + .../test/e2e/ollama.test.ts | 9 +- 6 files changed, 224 insertions(+), 33 deletions(-) create mode 100644 packages/kernel-language-model-service/src/ollama/fetch.test.ts create mode 100644 packages/kernel-language-model-service/src/ollama/fetch.ts diff --git a/packages/kernel-language-model-service/src/ollama/fetch.test.ts b/packages/kernel-language-model-service/src/ollama/fetch.test.ts new file mode 100644 index 0000000000..2051638a64 --- /dev/null +++ b/packages/kernel-language-model-service/src/ollama/fetch.test.ts @@ -0,0 +1,122 @@ +import '@ocap/test-utils/mock-endoify'; +import type { Config } from 'ollama'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { makeOriginRestrictedFetch } from './fetch.ts'; + +describe('makeOriginRestrictedFetch', () => { + const mockHost = 'http://localhost:8080'; + const mockConfig: Config = { host: mockHost }; + + const mockResponse = { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ success: true }), + }; + + let originalFetch: typeof fetch; + let restrictedFetch: typeof fetch; + let hardenSpy: ReturnType; + + beforeEach(() => { + hardenSpy = vi.spyOn(global, 'harden'); + originalFetch = global.fetch; + vi.spyOn(global, 'fetch').mockImplementation(); + restrictedFetch = makeOriginRestrictedFetch(mockConfig); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + describe('origin validation', () => { + it.each([ + ['root', []], + ['with path segment', ['test']], + ['with query parameters', ['test', '?foo=bar']], + ['with multiple path segments', ['test', 'test', '?foo=bar']], + ])( + 'should allow requests to the configured host with different paths: %s', + async (_case, path: string[]) => { + const url = [mockHost, ...path].join('/'); + + await restrictedFetch(url); + + expect(global.fetch).toHaveBeenCalledWith(url); + }, + ); + + it.each([ + ['wrong origin', 'http://malicious.com'], + ['subdomain', 'http://api.localhost:8080'], + ['different port', 'http://localhost:11434'], + ['different protocol', 'https://localhost:8080'], + ])( + 'should throw error for unauthorized requests: %s', + async (_case, origin: string) => { + assert(origin !== mockHost, 'test of test'); + const url = `${origin}/test/test`; + + await expect(restrictedFetch(url)).rejects.toThrow( + `Invalid origin: ${origin}, expected: ${mockHost}`, + ); + + expect(global.fetch).not.toHaveBeenCalled(); + }, + ); + }); + + describe('fetch behavior', () => { + it('should pass through fetch response', async () => { + (global.fetch as ReturnType).mockResolvedValue( + mockResponse, + ); + const url = `${mockHost}/api/generate`; + + const result = await restrictedFetch(url); + + expect(result).toBe(mockResponse); + }); + + it('should handle fetch errors', async () => { + const errorResponse = new Error('Network error'); + (global.fetch as ReturnType).mockRejectedValue( + errorResponse, + ); + + const url = `${mockHost}/api/generate`; + + await expect(restrictedFetch(url)).rejects.toThrow('Network error'); + }); + + it('should handle multiple arguments correctly', async () => { + const url = `${mockHost}/api/generate`; + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }; + + await restrictedFetch(url, options); + + expect(global.fetch).toHaveBeenCalledWith(url, options); + }); + + it('should handle Request objects correctly', async () => { + const url = `${mockHost}/api/generate`; + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const request = new Request(url); + + await restrictedFetch(request); + + expect(global.fetch).toHaveBeenCalledWith(request); + }); + }); + + describe('hardening', () => { + it('should return a hardened function', () => { + // The mock harden implementation is (x) => x. + expect(hardenSpy).toHaveBeenCalledWith(restrictedFetch); + }); + }); +}); diff --git a/packages/kernel-language-model-service/src/ollama/fetch.ts b/packages/kernel-language-model-service/src/ollama/fetch.ts new file mode 100644 index 0000000000..fb8138892c --- /dev/null +++ b/packages/kernel-language-model-service/src/ollama/fetch.ts @@ -0,0 +1,37 @@ +/** + * XXX To provide a complete object capability encapsulation, this code should + * be passed into a vat as an endowment, the Ollama constructor should be + * imported from ollama within the vat, the endowed fetch should be passed to + * the Ollama constructor, and the vat should expose a remotable presence to + * the ollama client. + * + * As is, the security model relies on the ollama library only using the fetch + * function provided to the constructor, but a malicious ollama library could + * use the fetch function from global scope to make requests to other hosts. + */ + +import type { Config } from 'ollama'; + +/** + * Creates a fetch function that only allows requests to the specified host. + * + * @param config - The configuration object containing the host to restrict requests to. + * @returns A fetch function that only allows requests to the specified host. + */ +export const makeOriginRestrictedFetch = (config: Config): typeof fetch => { + const { host: configuredOrigin } = config; + const restrictedFetch = async ( + ...[url, ...args]: Parameters + ): ReturnType => { + const { origin } = new URL(url); + if (origin !== configuredOrigin) { + throw new Error( + `Invalid origin: ${origin}, expected: ${configuredOrigin}`, + ); + } + const response = await fetch(url, ...args); + return response; + }; + harden(restrictedFetch); + return restrictedFetch as unknown as typeof fetch; +}; diff --git a/packages/kernel-language-model-service/src/ollama/nodejs.test.ts b/packages/kernel-language-model-service/src/ollama/nodejs.test.ts index 060515cc64..2cff06c5b1 100644 --- a/packages/kernel-language-model-service/src/ollama/nodejs.test.ts +++ b/packages/kernel-language-model-service/src/ollama/nodejs.test.ts @@ -1,37 +1,59 @@ +import '@ocap/test-utils/mock-endoify'; + import { fetchMock } from '@ocap/test-utils'; import { expect, describe, it, beforeEach } from 'vitest'; import { OllamaNodejsLanguageModelService } from './nodejs.ts'; +import type { OllamaNodejsConfig } from './types.ts'; import { mockReadableStream } from '../../test/utils.ts'; describe('OllamaNodejsLanguageModelService', () => { let service: OllamaNodejsLanguageModelService; const archetype = 'fast'; + const clientConfig = { host: 'http://127.0.0.1:11434' }; + const archetypes = { [archetype]: 'llama3.2:latest' }; + const endowments = { fetch: fetchMock }; beforeEach(async () => { - service = new OllamaNodejsLanguageModelService( - { [archetype]: 'llama3.2:latest' }, - // For e2e tests, we need to run Ollama locally - { host: 'http://127.0.0.1:11434' }, - ); + service = new OllamaNodejsLanguageModelService({ + archetypes, + endowments, + clientConfig, + }); }); describe('constructor', () => { - const testArchetypes = { fast: 'llama3.2:latest' }; it.each([ - ['undefined config', testArchetypes, undefined], - ['empty config', testArchetypes, {}], - ['basic config', testArchetypes, { host: 'http://127.0.0.1:11434' }], + ['no clientConfig', { archetypes, endowments }], + ['empty clientConfig', { archetypes, endowments, clientConfig: {} }], + ['basic clientConfig', { archetypes, endowments, clientConfig }], ])( 'should create a service with the correct endowments: %s', - (_testName, archetypes, config) => { - const constructedService = new OllamaNodejsLanguageModelService( - archetypes, - config, - ); + (_testName, config: OllamaNodejsConfig) => { + const constructedService = new OllamaNodejsLanguageModelService(config); expect(constructedService).toBeDefined(); }, ); + + it.each([ + ['no endowments', { archetypes }, 'Must endow a fetch implementation.'], + [ + 'no fetch', + { archetypes, endowments: {} }, + 'Must endow a fetch implementation.', + ], + ])( + 'should throw an error if misconfigured: %s', + (_testName, config, expectedError) => { + expect( + () => + new OllamaNodejsLanguageModelService( + // @ts-expect-error - Destructive test + config, + ), + ).toThrow(expectedError); + }, + ); }); describe('makeInstance', () => { diff --git a/packages/kernel-language-model-service/src/ollama/nodejs.ts b/packages/kernel-language-model-service/src/ollama/nodejs.ts index 4e7cf0ab71..c383250c21 100644 --- a/packages/kernel-language-model-service/src/ollama/nodejs.ts +++ b/packages/kernel-language-model-service/src/ollama/nodejs.ts @@ -1,24 +1,26 @@ import { Ollama } from 'ollama'; -import type { Config } from 'ollama'; import { OllamaBaseLanguageModelService } from './base.ts'; import { defaultConfig } from './constants.ts'; -import type { OllamaClient } from './types.ts'; - -const makeOllamaClient = (config: typeof defaultConfig): OllamaClient => - new Ollama(config) as OllamaClient; +import type { OllamaClient, OllamaNodejsConfig } from './types.ts'; export class OllamaNodejsLanguageModelService extends OllamaBaseLanguageModelService { - constructor( - archetypes: Record, - config: Partial = {}, - ) { - // We use ignore because this is only a ts-error in Node 20, not in Node 22. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore globalThis.fetch is untyped in Node 20, but proper in Node 22. - const endowments = { fetch }; - super(archetypes, async () => - makeOllamaClient({ ...defaultConfig, ...config, ...endowments }), + constructor({ + archetypes, + endowments, + clientConfig = {}, + }: OllamaNodejsConfig) { + if (!endowments?.fetch) { + throw new Error('Must endow a fetch implementation.'); + } + const resolvedConfig = { ...defaultConfig, ...clientConfig }; + super( + archetypes, + async () => + new Ollama({ + ...resolvedConfig, + fetch: endowments.fetch, + }) as OllamaClient, ); } } diff --git a/packages/kernel-language-model-service/src/ollama/types.ts b/packages/kernel-language-model-service/src/ollama/types.ts index 32c707c8ec..cb81ec2401 100644 --- a/packages/kernel-language-model-service/src/ollama/types.ts +++ b/packages/kernel-language-model-service/src/ollama/types.ts @@ -3,6 +3,7 @@ import type { GenerateResponse, ListResponse, AbortableAsyncIterator, + Config, } from 'ollama'; type OllamaClient = { @@ -25,3 +26,9 @@ export type OllamaModelOptions = { num_ctx: number; /* eslint-enable @typescript-eslint/naming-convention */ }; + +export type OllamaNodejsConfig = { + archetypes: Record; + endowments: { fetch: typeof fetch }; + clientConfig?: Partial>; +}; diff --git a/packages/kernel-language-model-service/test/e2e/ollama.test.ts b/packages/kernel-language-model-service/test/e2e/ollama.test.ts index 78b4aa5872..db94068739 100644 --- a/packages/kernel-language-model-service/test/e2e/ollama.test.ts +++ b/packages/kernel-language-model-service/test/e2e/ollama.test.ts @@ -18,10 +18,11 @@ describe('OllamaNodejsLanguageModelService E2E', { timeout: 10_000 }, () => { beforeEach(async () => { // Disable fetch mocking for this test fetchMock.disableMocks(); - service = new OllamaNodejsLanguageModelService( - { [archetype]: testConfig.model }, - { host: testConfig.host }, - ); + service = new OllamaNodejsLanguageModelService({ + archetypes: { [archetype]: testConfig.model }, + endowments: { fetch: global.fetch }, + clientConfig: { host: testConfig.host }, + }); fetchMock.enableMocks(); }); From cb031172c968bfcdeeb291015a928c1c15cce0c2 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:48:02 -0400 Subject: [PATCH 04/15] build(kernel-language-model-service): Link fetch type --- packages/kernel-language-model-service/package.json | 4 +++- packages/kernel-language-model-service/src/ollama/fetch.ts | 3 ++- packages/kernel-language-model-service/tsconfig.build.json | 4 ++-- packages/kernel-language-model-service/tsconfig.json | 4 ++-- yarn.lock | 2 ++ 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/kernel-language-model-service/package.json b/packages/kernel-language-model-service/package.json index 0bc295c2d2..5c99b0a476 100644 --- a/packages/kernel-language-model-service/package.json +++ b/packages/kernel-language-model-service/package.json @@ -56,6 +56,7 @@ "@ocap/test-utils": "workspace:^", "@ts-bridge/cli": "^0.6.3", "@ts-bridge/shims": "^0.1.1", + "@types/chrome": "^0.0.313", "@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/parser": "^8.29.0", "@typescript-eslint/utils": "^8.29.0", @@ -81,6 +82,7 @@ "node": "^20 || >=22" }, "dependencies": { - "ollama": "^0.5.16" + "ollama": "^0.5.16", + "ses": "^1.13.0" } } diff --git a/packages/kernel-language-model-service/src/ollama/fetch.ts b/packages/kernel-language-model-service/src/ollama/fetch.ts index fb8138892c..1c4d99b21f 100644 --- a/packages/kernel-language-model-service/src/ollama/fetch.ts +++ b/packages/kernel-language-model-service/src/ollama/fetch.ts @@ -23,7 +23,8 @@ export const makeOriginRestrictedFetch = (config: Config): typeof fetch => { const restrictedFetch = async ( ...[url, ...args]: Parameters ): ReturnType => { - const { origin } = new URL(url); + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const { origin } = new URL(url instanceof Request ? url.url : url); if (origin !== configuredOrigin) { throw new Error( `Invalid origin: ${origin}, expected: ${configuredOrigin}`, diff --git a/packages/kernel-language-model-service/tsconfig.build.json b/packages/kernel-language-model-service/tsconfig.build.json index 7994f1e36b..960e113284 100644 --- a/packages/kernel-language-model-service/tsconfig.build.json +++ b/packages/kernel-language-model-service/tsconfig.build.json @@ -2,10 +2,10 @@ "extends": "../../tsconfig.packages.build.json", "compilerOptions": { "baseUrl": "./", - "lib": ["ES2022"], + "lib": ["DOM", "ES2022"], "outDir": "./dist", "rootDir": "./src", - "types": [] + "types": ["chrome", "ses"] }, "references": [{ "path": "../streams/tsconfig.build.json" }], "files": [], diff --git a/packages/kernel-language-model-service/tsconfig.json b/packages/kernel-language-model-service/tsconfig.json index fcffc48516..d00014c0b9 100644 --- a/packages/kernel-language-model-service/tsconfig.json +++ b/packages/kernel-language-model-service/tsconfig.json @@ -2,8 +2,8 @@ "extends": "../../tsconfig.packages.json", "compilerOptions": { "baseUrl": "./", - "lib": ["ES2022"], - "types": ["vitest"] + "lib": ["ES2022", "DOM"], + "types": ["vitest", "chrome", "ses"] }, "references": [{ "path": "../test-utils" }, { "path": "../streams" }], "include": [ diff --git a/yarn.lock b/yarn.lock index a891c857ae..f749f7a8bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3241,6 +3241,7 @@ __metadata: "@ocap/test-utils": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" "@ts-bridge/shims": "npm:^0.1.1" + "@types/chrome": "npm:^0.0.313" "@typescript-eslint/eslint-plugin": "npm:^8.29.0" "@typescript-eslint/parser": "npm:^8.29.0" "@typescript-eslint/utils": "npm:^8.29.0" @@ -3257,6 +3258,7 @@ __metadata: ollama: "npm:^0.5.16" prettier: "npm:^3.5.3" rimraf: "npm:^6.0.1" + ses: "npm:^1.13.0" typedoc: "npm:^0.28.1" typescript: "npm:~5.8.2" typescript-eslint: "npm:^8.29.0" From 5e6e633eb7a546bfb9ba23016e3a19f82878d34e Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:19:18 -0400 Subject: [PATCH 05/15] feat(kernel-language-model-service): Eventualize LanguageModel interface --- .../src/ollama/base.test.ts | 8 ++++--- .../src/ollama/base.ts | 5 +++-- .../src/ollama/parse.test.ts | 10 ++++----- .../src/ollama/parse.ts | 22 ++++++++++--------- .../src/types.ts | 9 +++++++- 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/packages/kernel-language-model-service/src/ollama/base.test.ts b/packages/kernel-language-model-service/src/ollama/base.test.ts index 449c9a54d5..75cbd07252 100644 --- a/packages/kernel-language-model-service/src/ollama/base.test.ts +++ b/packages/kernel-language-model-service/src/ollama/base.test.ts @@ -96,7 +96,7 @@ describe('OllamaBaseLanguageModelService', () => { const instance = await service.makeInstance(config); expect(mockMakeClient).toHaveBeenCalledOnce(); - expect(instance.model).toBe('llama2:7b'); + expect(await instance.getInfo()).toMatchObject({ model: 'llama2:7b' }); expect(instance).toHaveProperty('load'); expect(instance).toHaveProperty('unload'); expect(instance).toHaveProperty('sample'); @@ -110,7 +110,9 @@ describe('OllamaBaseLanguageModelService', () => { const instance = await service.makeInstance(config); - expect(instance.model).toBe('custom-model:latest'); + expect(await instance.getInfo()).toMatchObject({ + model: 'custom-model:latest', + }); }); it('should throw error for unknown archetype', async () => { @@ -130,7 +132,7 @@ describe('OllamaBaseLanguageModelService', () => { const instance = await service.makeInstance(config); - expect(instance.model).toBe('llama2:7b'); + expect(await instance.getInfo()).toMatchObject({ model: 'llama2:7b' }); }); }); diff --git a/packages/kernel-language-model-service/src/ollama/base.ts b/packages/kernel-language-model-service/src/ollama/base.ts index 8527ecb2da..62748a1b6f 100644 --- a/packages/kernel-language-model-service/src/ollama/base.ts +++ b/packages/kernel-language-model-service/src/ollama/base.ts @@ -39,7 +39,8 @@ export class OllamaBaseLanguageModelService // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async makeInstance(config: InstanceConfig) { - const model = parseModelConfig(config, this.#archetypes); + const modelInfo = parseModelConfig(config, this.#archetypes); + const { model } = modelInfo; const ollama = await this.#makeClient(); const defaultOptions = { ...(config.options ?? {}), @@ -51,7 +52,7 @@ export class OllamaBaseLanguageModelService }; const instance = { - model, + getInfo: async () => modelInfo, load: async () => { // eslint-disable-next-line @typescript-eslint/naming-convention await ollama.generate({ model, keep_alive: -1 } as GenerateRequest); diff --git a/packages/kernel-language-model-service/src/ollama/parse.test.ts b/packages/kernel-language-model-service/src/ollama/parse.test.ts index bc6792993e..5d002203da 100644 --- a/packages/kernel-language-model-service/src/ollama/parse.test.ts +++ b/packages/kernel-language-model-service/src/ollama/parse.test.ts @@ -15,14 +15,14 @@ describe('parseModelConfig', () => { ['archetype', { archetype: 'fast' }, 'llama2:3b'], ['model', { model: 'custom-model:latest' }, 'custom-model:latest'], [ - 'archetype over model', + 'archetype and model (model takes precedence)', { archetype: 'accurate', model: 'custom-model:latest' }, - 'llama2:13b', + 'custom-model:latest', ], - ])('should return %s when %s is provided', (_, config, expected) => { + ])('should return expected model when provided %s', (_, config, expected) => { // @ts-expect-error - destructive testing - const result = parseModelConfig(config, mockArchetypes); - expect(result).toBe(expected); + const modelInfo = parseModelConfig(config, mockArchetypes); + expect(modelInfo).toMatchObject({ model: expected }); }); it('should handle empty archetypes object', () => { diff --git a/packages/kernel-language-model-service/src/ollama/parse.ts b/packages/kernel-language-model-service/src/ollama/parse.ts index ac38f4dc4b..1998d04a86 100644 --- a/packages/kernel-language-model-service/src/ollama/parse.ts +++ b/packages/kernel-language-model-service/src/ollama/parse.ts @@ -1,18 +1,20 @@ -import type { InstanceConfig } from '../types.ts'; +import type { InstanceConfig, ModelInfo } from '../types.ts'; import type { OllamaModelOptions } from './types.ts'; export const parseModelConfig = ( config: InstanceConfig, archetypes: Record, -): string => { - if (config.archetype) { - if (archetypes[config.archetype]) { - return archetypes[config.archetype] as string; +): ModelInfo => { + const { archetype, model } = config; + if (model) { + return { model }; + } + if (archetype) { + const resolvedModel = archetypes[archetype]; + if (!resolvedModel) { + throw new Error(`Archetype ${archetype} not found`); } - throw new Error(`Archetype ${config.archetype} not found`); - } else if (config.model) { - return config.model; - } else { - throw new Error('No model or archetype provided'); + return { archetype, model: resolvedModel }; } + throw new Error('No model or archetype provided'); }; diff --git a/packages/kernel-language-model-service/src/types.ts b/packages/kernel-language-model-service/src/types.ts index fc503bd6cc..d86192cb62 100644 --- a/packages/kernel-language-model-service/src/types.ts +++ b/packages/kernel-language-model-service/src/types.ts @@ -1,5 +1,12 @@ -export type LanguageModel = { +export type ModelInfo = { + archetype?: string; model: string; + options?: Options; +}; + +export type LanguageModel = { + getInfo: () => Promise>; + /** * Loads the model into memory and keeps it alive indefinitely. * From 65777e9eb14058db977dc3e6e6c5de7e68010d15 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:33:20 -0400 Subject: [PATCH 06/15] remove archetype abstraction --- .../src/ollama/base.test.ts | 48 +++-------------- .../src/ollama/base.ts | 10 +--- .../src/ollama/constants.ts | 2 +- .../src/ollama/nodejs.test.ts | 24 ++++----- .../src/ollama/nodejs.ts | 11 ++-- .../src/ollama/parse.test.ts | 52 ++++++------------- .../src/ollama/parse.ts | 16 ++---- .../src/ollama/types.ts | 1 - .../src/types.ts | 18 ++----- .../test/e2e/ollama.test.ts | 6 +-- 10 files changed, 50 insertions(+), 138 deletions(-) diff --git a/packages/kernel-language-model-service/src/ollama/base.test.ts b/packages/kernel-language-model-service/src/ollama/base.test.ts index 75cbd07252..790f7dcc05 100644 --- a/packages/kernel-language-model-service/src/ollama/base.test.ts +++ b/packages/kernel-language-model-service/src/ollama/base.test.ts @@ -11,12 +11,6 @@ describe('OllamaBaseLanguageModelService', () => { let mockMakeClient: () => Promise; let service: OllamaBaseLanguageModelService; - const archetypes = { - default: 'llama2:7b', - fast: 'llama2:3b', - accurate: 'llama2:13b', - } as const; - beforeEach(() => { mockClient = { list: vi.fn(), @@ -24,24 +18,13 @@ describe('OllamaBaseLanguageModelService', () => { }; mockMakeClient = vi.fn().mockResolvedValue(mockClient); - service = new OllamaBaseLanguageModelService(archetypes, mockMakeClient); + service = new OllamaBaseLanguageModelService(mockMakeClient); }); describe('constructor', () => { - it('should initialize with archetypes and makeClient function', () => { + it('should initialize with makeClient function', () => { expect(service).toBeInstanceOf(OllamaBaseLanguageModelService); }); - - it('should handle empty archetypes', () => { - const emptyArchetypes = {}; - const serviceWithEmptyArchetypes = new OllamaBaseLanguageModelService( - emptyArchetypes, - mockMakeClient, - ); - expect(serviceWithEmptyArchetypes).toBeInstanceOf( - OllamaBaseLanguageModelService, - ); - }); }); describe('getModels', () => { @@ -69,7 +52,7 @@ describe('OllamaBaseLanguageModelService', () => { it('should handle client creation errors', async () => { const error = new Error('Failed to create client'); mockMakeClient = vi.fn().mockRejectedValue(error); - service = new OllamaBaseLanguageModelService(archetypes, mockMakeClient); + service = new OllamaBaseLanguageModelService(mockMakeClient); await expect(service.getModels()).rejects.toThrow( 'Failed to create client', @@ -87,9 +70,9 @@ describe('OllamaBaseLanguageModelService', () => { }); describe('makeInstance', () => { - it('should create instance with archetype model', async () => { + it('should create instance with model', async () => { const config: InstanceConfig = { - archetype: 'default', + model: 'llama2:7b', options: { temperature: 0.7 }, }; @@ -115,19 +98,9 @@ describe('OllamaBaseLanguageModelService', () => { }); }); - it('should throw error for unknown archetype', async () => { - const config: InstanceConfig = { - archetype: 'unknown', - }; - - await expect(service.makeInstance(config)).rejects.toThrow( - /^Archetype .+ not found$/u, - ); - }); - it('should handle config with no options', async () => { const config: InstanceConfig = { - archetype: 'default', + model: 'llama2:7b', }; const instance = await service.makeInstance(config); @@ -141,9 +114,8 @@ describe('OllamaBaseLanguageModelService', () => { beforeEach(async () => { const config: InstanceConfig = { - archetype: 'default', - // eslint-disable-next-line @typescript-eslint/naming-convention - options: { temperature: 0.7, top_p: 0.9 }, + model: 'llama2:7b', + options: { temperature: 0.7 }, }; instance = await service.makeInstance(config); }); @@ -209,8 +181,6 @@ describe('OllamaBaseLanguageModelService', () => { expect(mockClient.generate).toHaveBeenCalledWith({ temperature: 0.5, // from options (should override config) - // eslint-disable-next-line @typescript-eslint/naming-convention - top_p: 0.9, // from config model: 'llama2:7b', stream: true, raw: true, @@ -225,8 +195,6 @@ describe('OllamaBaseLanguageModelService', () => { expect(mockClient.generate).toHaveBeenCalledWith({ temperature: 0.7, - // eslint-disable-next-line @typescript-eslint/naming-convention - top_p: 0.9, model: 'llama2:7b', stream: true, raw: true, diff --git a/packages/kernel-language-model-service/src/ollama/base.ts b/packages/kernel-language-model-service/src/ollama/base.ts index 62748a1b6f..3d67e80f3b 100644 --- a/packages/kernel-language-model-service/src/ollama/base.ts +++ b/packages/kernel-language-model-service/src/ollama/base.ts @@ -19,15 +19,9 @@ export class OllamaBaseLanguageModelService GenerateResponse > { - readonly #archetypes: Record; - readonly #makeClient: () => Promise; - constructor( - archetypes: Record, - makeClient: () => Promise, - ) { - this.#archetypes = archetypes; + constructor(makeClient: () => Promise) { this.#makeClient = makeClient; } @@ -39,7 +33,7 @@ export class OllamaBaseLanguageModelService // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async makeInstance(config: InstanceConfig) { - const modelInfo = parseModelConfig(config, this.#archetypes); + const modelInfo = parseModelConfig(config); const { model } = modelInfo; const ollama = await this.#makeClient(); const defaultOptions = { diff --git a/packages/kernel-language-model-service/src/ollama/constants.ts b/packages/kernel-language-model-service/src/ollama/constants.ts index abf1e70672..069b5cc98d 100644 --- a/packages/kernel-language-model-service/src/ollama/constants.ts +++ b/packages/kernel-language-model-service/src/ollama/constants.ts @@ -1,3 +1,3 @@ -export const defaultConfig = { +export const defaultClientConfig = { host: 'http://localhost:11434', }; diff --git a/packages/kernel-language-model-service/src/ollama/nodejs.test.ts b/packages/kernel-language-model-service/src/ollama/nodejs.test.ts index 2cff06c5b1..9e1332c256 100644 --- a/packages/kernel-language-model-service/src/ollama/nodejs.test.ts +++ b/packages/kernel-language-model-service/src/ollama/nodejs.test.ts @@ -9,14 +9,12 @@ import { mockReadableStream } from '../../test/utils.ts'; describe('OllamaNodejsLanguageModelService', () => { let service: OllamaNodejsLanguageModelService; - const archetype = 'fast'; + const model = 'llama3.2:latest'; const clientConfig = { host: 'http://127.0.0.1:11434' }; - const archetypes = { [archetype]: 'llama3.2:latest' }; const endowments = { fetch: fetchMock }; beforeEach(async () => { service = new OllamaNodejsLanguageModelService({ - archetypes, endowments, clientConfig, }); @@ -24,9 +22,9 @@ describe('OllamaNodejsLanguageModelService', () => { describe('constructor', () => { it.each([ - ['no clientConfig', { archetypes, endowments }], - ['empty clientConfig', { archetypes, endowments, clientConfig: {} }], - ['basic clientConfig', { archetypes, endowments, clientConfig }], + ['no clientConfig', { endowments }], + ['empty clientConfig', { endowments, clientConfig: {} }], + ['basic clientConfig', { endowments, clientConfig }], ])( 'should create a service with the correct endowments: %s', (_testName, config: OllamaNodejsConfig) => { @@ -36,12 +34,8 @@ describe('OllamaNodejsLanguageModelService', () => { ); it.each([ - ['no endowments', { archetypes }, 'Must endow a fetch implementation.'], - [ - 'no fetch', - { archetypes, endowments: {} }, - 'Must endow a fetch implementation.', - ], + ['no endowments', {}, 'Must endow a fetch implementation.'], + ['no fetch', { endowments: {} }, 'Must endow a fetch implementation.'], ])( 'should throw an error if misconfigured: %s', (_testName, config, expectedError) => { @@ -58,8 +52,8 @@ describe('OllamaNodejsLanguageModelService', () => { describe('makeInstance', () => { it('should create a model instance', async () => { - const model = await service.makeInstance({ archetype }); - expect(model).toBeDefined(); + const instance = await service.makeInstance({ model }); + expect(instance).toBeDefined(); }); }); @@ -82,7 +76,7 @@ describe('OllamaNodejsLanguageModelService', () => { body: mockReadableStream([{ response }]), } as Parameters[0]); - const instance = await service.makeInstance({ archetype }); + const instance = await service.makeInstance({ model }); for await (const chunk of await instance.sample('Hello, ')) { expect(chunk).toMatchObject({ response, diff --git a/packages/kernel-language-model-service/src/ollama/nodejs.ts b/packages/kernel-language-model-service/src/ollama/nodejs.ts index c383250c21..11582dae56 100644 --- a/packages/kernel-language-model-service/src/ollama/nodejs.ts +++ b/packages/kernel-language-model-service/src/ollama/nodejs.ts @@ -1,21 +1,16 @@ import { Ollama } from 'ollama'; import { OllamaBaseLanguageModelService } from './base.ts'; -import { defaultConfig } from './constants.ts'; +import { defaultClientConfig } from './constants.ts'; import type { OllamaClient, OllamaNodejsConfig } from './types.ts'; export class OllamaNodejsLanguageModelService extends OllamaBaseLanguageModelService { - constructor({ - archetypes, - endowments, - clientConfig = {}, - }: OllamaNodejsConfig) { + constructor({ endowments, clientConfig = {} }: OllamaNodejsConfig) { if (!endowments?.fetch) { throw new Error('Must endow a fetch implementation.'); } - const resolvedConfig = { ...defaultConfig, ...clientConfig }; + const resolvedConfig = { ...defaultClientConfig, ...clientConfig }; super( - archetypes, async () => new Ollama({ ...resolvedConfig, diff --git a/packages/kernel-language-model-service/src/ollama/parse.test.ts b/packages/kernel-language-model-service/src/ollama/parse.test.ts index 5d002203da..ed213dce03 100644 --- a/packages/kernel-language-model-service/src/ollama/parse.test.ts +++ b/packages/kernel-language-model-service/src/ollama/parse.test.ts @@ -5,46 +5,26 @@ import { parseModelConfig } from './parse.ts'; import type { OllamaModelOptions } from './types.ts'; describe('parseModelConfig', () => { - const mockArchetypes = { - default: 'llama2:7b', - fast: 'llama2:3b', - accurate: 'llama2:13b', - }; - - it.each([ - ['archetype', { archetype: 'fast' }, 'llama2:3b'], - ['model', { model: 'custom-model:latest' }, 'custom-model:latest'], - [ - 'archetype and model (model takes precedence)', - { archetype: 'accurate', model: 'custom-model:latest' }, - 'custom-model:latest', - ], - ])('should return expected model when provided %s', (_, config, expected) => { - // @ts-expect-error - destructive testing - const modelInfo = parseModelConfig(config, mockArchetypes); - expect(modelInfo).toMatchObject({ model: expected }); - }); - - it('should handle empty archetypes object', () => { - const archetype = 'nonexistent'; - const config: InstanceConfig = { archetype }; - expect(() => parseModelConfig(config, {})).toThrow( - `Archetype ${archetype} not found`, - ); + it('should return expected model', () => { + const config = { model: 'llama2:7b' } as InstanceConfig; + const modelInfo = parseModelConfig(config); + expect(modelInfo).toMatchObject({ model: 'llama2:7b' }); }); - it('should throw when archetype not found in non-empty archetypes object', () => { - const archetype = 'nonexistent'; - const config: InstanceConfig = { archetype }; - expect(() => parseModelConfig(config, mockArchetypes)).toThrow( - `Archetype ${archetype} not found`, - ); + it('should return expected model with options', () => { + const config = { + model: 'llama2:7b', + options: { temperature: 0.5 }, + } as InstanceConfig; + const modelInfo = parseModelConfig(config); + expect(modelInfo).toMatchObject({ + model: 'llama2:7b', + options: { temperature: 0.5 }, + }); }); - it('should throw when neither archetype nor model is provided', () => { + it('should throw when no model is provided', () => { const config = {} as InstanceConfig; - expect(() => parseModelConfig(config, mockArchetypes)).toThrow( - 'No model or archetype provided', - ); + expect(() => parseModelConfig(config)).toThrow('No model provided'); }); }); diff --git a/packages/kernel-language-model-service/src/ollama/parse.ts b/packages/kernel-language-model-service/src/ollama/parse.ts index 1998d04a86..c4e48c7d27 100644 --- a/packages/kernel-language-model-service/src/ollama/parse.ts +++ b/packages/kernel-language-model-service/src/ollama/parse.ts @@ -3,18 +3,10 @@ import type { OllamaModelOptions } from './types.ts'; export const parseModelConfig = ( config: InstanceConfig, - archetypes: Record, ): ModelInfo => { - const { archetype, model } = config; - if (model) { - return { model }; + const { model, options } = config; + if (!model) { + throw new Error('No model provided'); } - if (archetype) { - const resolvedModel = archetypes[archetype]; - if (!resolvedModel) { - throw new Error(`Archetype ${archetype} not found`); - } - return { archetype, model: resolvedModel }; - } - throw new Error('No model or archetype provided'); + return options ? { model, options } : { model }; }; diff --git a/packages/kernel-language-model-service/src/ollama/types.ts b/packages/kernel-language-model-service/src/ollama/types.ts index cb81ec2401..16f90ea991 100644 --- a/packages/kernel-language-model-service/src/ollama/types.ts +++ b/packages/kernel-language-model-service/src/ollama/types.ts @@ -28,7 +28,6 @@ export type OllamaModelOptions = { }; export type OllamaNodejsConfig = { - archetypes: Record; endowments: { fetch: typeof fetch }; clientConfig?: Partial>; }; diff --git a/packages/kernel-language-model-service/src/types.ts b/packages/kernel-language-model-service/src/types.ts index d86192cb62..d01487af0c 100644 --- a/packages/kernel-language-model-service/src/types.ts +++ b/packages/kernel-language-model-service/src/types.ts @@ -1,7 +1,6 @@ export type ModelInfo = { - archetype?: string; model: string; - options?: Options; + options?: Partial; }; export type LanguageModel = { @@ -31,17 +30,10 @@ export type LanguageModel = { ) => Promise>; }; -export type InstanceConfig = - | { - archetype: string; - model?: never; - options?: Partial; - } - | { - archetype?: never; - model: string; - options?: Partial; - }; +export type InstanceConfig = { + model: string; + options?: Partial; +}; export type LanguageModelService = { makeInstance: ( diff --git a/packages/kernel-language-model-service/test/e2e/ollama.test.ts b/packages/kernel-language-model-service/test/e2e/ollama.test.ts index db94068739..2bcc1e4e90 100644 --- a/packages/kernel-language-model-service/test/e2e/ollama.test.ts +++ b/packages/kernel-language-model-service/test/e2e/ollama.test.ts @@ -13,13 +13,11 @@ const testConfig = { describe('OllamaNodejsLanguageModelService E2E', { timeout: 10_000 }, () => { let service: OllamaNodejsLanguageModelService; - const archetype = 'default'; beforeEach(async () => { // Disable fetch mocking for this test fetchMock.disableMocks(); service = new OllamaNodejsLanguageModelService({ - archetypes: { [archetype]: testConfig.model }, endowments: { fetch: global.fetch }, clientConfig: { host: testConfig.host }, }); @@ -28,7 +26,7 @@ describe('OllamaNodejsLanguageModelService E2E', { timeout: 10_000 }, () => { describe('makeInstance', () => { it('should create a model instance', async () => { - const model = await service.makeInstance({ archetype }); + const model = await service.makeInstance({ model: testConfig.model }); expect(model).toBeDefined(); }); }); @@ -46,7 +44,7 @@ describe('OllamaNodejsLanguageModelService E2E', { timeout: 10_000 }, () => { it('should return a streaming result', async () => { const prompt = 'A B C'; let completion = prompt; - const instance = await service.makeInstance({ archetype }); + const instance = await service.makeInstance({ model: testConfig.model }); const response = await instance.sample(prompt); let exitEarly = false; await Promise.all([ From 0a0ee43a95f57819af8d34dd4d1c8a53da79fd15 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 12 Aug 2025 10:44:59 -0400 Subject: [PATCH 07/15] use superstruct parsing --- .../package.json | 1 + .../src/ollama/parse.test.ts | 2 +- .../src/ollama/parse.ts | 40 ++++++++++++++++--- .../src/types.ts | 1 - yarn.lock | 1 + 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/kernel-language-model-service/package.json b/packages/kernel-language-model-service/package.json index 5c99b0a476..497eea7f49 100644 --- a/packages/kernel-language-model-service/package.json +++ b/packages/kernel-language-model-service/package.json @@ -82,6 +82,7 @@ "node": "^20 || >=22" }, "dependencies": { + "@metamask/superstruct": "^3.2.1", "ollama": "^0.5.16", "ses": "^1.13.0" } diff --git a/packages/kernel-language-model-service/src/ollama/parse.test.ts b/packages/kernel-language-model-service/src/ollama/parse.test.ts index ed213dce03..7d153f22ca 100644 --- a/packages/kernel-language-model-service/src/ollama/parse.test.ts +++ b/packages/kernel-language-model-service/src/ollama/parse.test.ts @@ -25,6 +25,6 @@ describe('parseModelConfig', () => { it('should throw when no model is provided', () => { const config = {} as InstanceConfig; - expect(() => parseModelConfig(config)).toThrow('No model provided'); + expect(() => parseModelConfig(config)).toThrow(/model/u); }); }); diff --git a/packages/kernel-language-model-service/src/ollama/parse.ts b/packages/kernel-language-model-service/src/ollama/parse.ts index c4e48c7d27..159d0cfaff 100644 --- a/packages/kernel-language-model-service/src/ollama/parse.ts +++ b/packages/kernel-language-model-service/src/ollama/parse.ts @@ -1,12 +1,40 @@ -import type { InstanceConfig, ModelInfo } from '../types.ts'; -import type { OllamaModelOptions } from './types.ts'; +import { + object, + optional, + number, + size, + string, + assert, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +import type { ModelInfo } from '../types.ts'; + +export const OllamaModelOptionsStruct = object({ + // Ollama is pythonic, using snake_case for its options. + /* eslint-disable @typescript-eslint/naming-convention */ + temperature: optional(number()), + top_p: optional(number()), + top_k: optional(number()), + repeat_penalty: optional(number()), + repeat_last_n: optional(number()), + seed: optional(number()), + num_ctx: optional(number()), + /* eslint-enable @typescript-eslint/naming-convention */ +}); + +export const OllamaInstanceConfigStruct = object({ + model: size(string(), 1, Infinity), + options: optional(OllamaModelOptionsStruct), +}); + +export type OllamaModelOptions = Infer; +export type OllamaInstanceConfig = Infer; export const parseModelConfig = ( - config: InstanceConfig, + config: OllamaInstanceConfig, ): ModelInfo => { + assert(config, OllamaInstanceConfigStruct); const { model, options } = config; - if (!model) { - throw new Error('No model provided'); - } return options ? { model, options } : { model }; }; diff --git a/packages/kernel-language-model-service/src/types.ts b/packages/kernel-language-model-service/src/types.ts index d01487af0c..9f7d6893c3 100644 --- a/packages/kernel-language-model-service/src/types.ts +++ b/packages/kernel-language-model-service/src/types.ts @@ -20,7 +20,6 @@ export type LanguageModel = { unload: () => Promise; /** * @param prompt - The prompt to complete. - * @param streams - The streams { internal, external } to write the response to. * @param options - The options to pass to the model. * @returns A promise that resolves when the response is complete, or rejects if an error occurs. */ diff --git a/yarn.lock b/yarn.lock index f749f7a8bc..9f331b7718 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3238,6 +3238,7 @@ __metadata: "@metamask/eslint-config-nodejs": "npm:^14.0.0" "@metamask/eslint-config-typescript": "npm:^14.0.0" "@metamask/streams": "workspace:^" + "@metamask/superstruct": "npm:^3.2.1" "@ocap/test-utils": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" "@ts-bridge/shims": "npm:^0.1.1" From 795493a93ffcefb3da9cab3472697b65c712306a Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:00:02 -0400 Subject: [PATCH 08/15] test(kernel-language-model-service): setup (mock) endoify --- packages/kernel-language-model-service/vitest.config.e2e.ts | 2 ++ packages/kernel-language-model-service/vitest.config.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/kernel-language-model-service/vitest.config.e2e.ts b/packages/kernel-language-model-service/vitest.config.e2e.ts index 1e9bf46246..12a7be4afb 100644 --- a/packages/kernel-language-model-service/vitest.config.e2e.ts +++ b/packages/kernel-language-model-service/vitest.config.e2e.ts @@ -1,4 +1,5 @@ import { mergeConfig } from '@ocap/test-utils/vitest-config'; +import path from 'path'; import { defineConfig, defineProject } from 'vitest/config'; import defaultConfig from '../../vitest.config.ts'; @@ -10,6 +11,7 @@ export default defineConfig((args) => { defineProject({ test: { name: 'kernel-language-model-service:e2e', + setupFiles: path.resolve(__dirname, '../kernel-shims/src/endoify.js'), include: ['./test/e2e/**/*.test.ts'], exclude: ['./src/**/*'], }, diff --git a/packages/kernel-language-model-service/vitest.config.ts b/packages/kernel-language-model-service/vitest.config.ts index 6360859946..2dd5547e86 100644 --- a/packages/kernel-language-model-service/vitest.config.ts +++ b/packages/kernel-language-model-service/vitest.config.ts @@ -1,4 +1,5 @@ import { mergeConfig } from '@ocap/test-utils/vitest-config'; +import path from 'path'; import { defineConfig, defineProject } from 'vitest/config'; import defaultConfig from '../../vitest.config.ts'; @@ -10,6 +11,7 @@ export default defineConfig((args) => { defineProject({ test: { name: 'kernel-language-model-service', + setupFiles: path.resolve(__dirname, '../kernel-shims/src/endoify.js'), exclude: ['./test/e2e/**'], }, }), From 8412e8acc40770e872fc0fbbe7eaacad4dbdf93f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:16:33 -0400 Subject: [PATCH 09/15] feat(kernel-language-model-service): Apply type suggestions --- .../src/ollama/base.test.ts | 2 + .../src/ollama/base.ts | 27 ++++++------- .../src/ollama/parse.test.ts | 12 +++--- .../src/ollama/parse.ts | 39 +++++------------- .../src/ollama/types.ts | 40 +++++++++++++------ 5 files changed, 57 insertions(+), 63 deletions(-) diff --git a/packages/kernel-language-model-service/src/ollama/base.test.ts b/packages/kernel-language-model-service/src/ollama/base.test.ts index 790f7dcc05..e918357173 100644 --- a/packages/kernel-language-model-service/src/ollama/base.test.ts +++ b/packages/kernel-language-model-service/src/ollama/base.test.ts @@ -128,6 +128,7 @@ describe('OllamaBaseLanguageModelService', () => { model: 'llama2:7b', // eslint-disable-next-line @typescript-eslint/naming-convention keep_alive: -1, + prompt: '', }); }); @@ -147,6 +148,7 @@ describe('OllamaBaseLanguageModelService', () => { model: 'llama2:7b', // eslint-disable-next-line @typescript-eslint/naming-convention keep_alive: 0, + prompt: '', }); }); diff --git a/packages/kernel-language-model-service/src/ollama/base.ts b/packages/kernel-language-model-service/src/ollama/base.ts index 3d67e80f3b..583f66eb12 100644 --- a/packages/kernel-language-model-service/src/ollama/base.ts +++ b/packages/kernel-language-model-service/src/ollama/base.ts @@ -1,12 +1,13 @@ -import type { GenerateRequest, GenerateResponse } from 'ollama'; +import type { GenerateResponse, ListResponse } from 'ollama'; -import type { - InstanceConfig, - LanguageModel, - LanguageModelService, -} from '../types.ts'; +import type { LanguageModelService } from '../types.ts'; import { parseModelConfig } from './parse.ts'; -import type { OllamaClient, OllamaModelOptions } from './types.ts'; +import type { + OllamaInstanceConfig, + OllamaModel, + OllamaClient, + OllamaModelOptions, +} from './types.ts'; /** * It is recommended to create an Ollama client per model session. @@ -25,14 +26,12 @@ export class OllamaBaseLanguageModelService this.#makeClient = makeClient; } - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - async getModels() { + async getModels(): Promise { const client = await this.#makeClient(); return await client.list(); } - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - async makeInstance(config: InstanceConfig) { + async makeInstance(config: OllamaInstanceConfig): Promise { const modelInfo = parseModelConfig(config); const { model } = modelInfo; const ollama = await this.#makeClient(); @@ -49,11 +48,11 @@ export class OllamaBaseLanguageModelService getInfo: async () => modelInfo, load: async () => { // eslint-disable-next-line @typescript-eslint/naming-convention - await ollama.generate({ model, keep_alive: -1 } as GenerateRequest); + await ollama.generate({ model, keep_alive: -1, prompt: '' }); }, unload: async () => { // eslint-disable-next-line @typescript-eslint/naming-convention - await ollama.generate({ model, keep_alive: 0 } as GenerateRequest); + await ollama.generate({ model, keep_alive: 0, prompt: '' }); }, sample: async (prompt: string, options?: Partial) => { const response = await ollama.generate({ @@ -69,6 +68,6 @@ export class OllamaBaseLanguageModelService })(); }, }; - return instance as LanguageModel; + return harden(instance); } } diff --git a/packages/kernel-language-model-service/src/ollama/parse.test.ts b/packages/kernel-language-model-service/src/ollama/parse.test.ts index 7d153f22ca..75a80b9042 100644 --- a/packages/kernel-language-model-service/src/ollama/parse.test.ts +++ b/packages/kernel-language-model-service/src/ollama/parse.test.ts @@ -1,21 +1,20 @@ import { describe, it, expect } from 'vitest'; -import type { InstanceConfig } from '../types.ts'; import { parseModelConfig } from './parse.ts'; -import type { OllamaModelOptions } from './types.ts'; +import type { OllamaInstanceConfig } from './types.ts'; describe('parseModelConfig', () => { it('should return expected model', () => { - const config = { model: 'llama2:7b' } as InstanceConfig; + const config: OllamaInstanceConfig = { model: 'llama2:7b' }; const modelInfo = parseModelConfig(config); expect(modelInfo).toMatchObject({ model: 'llama2:7b' }); }); it('should return expected model with options', () => { - const config = { + const config: OllamaInstanceConfig = { model: 'llama2:7b', options: { temperature: 0.5 }, - } as InstanceConfig; + }; const modelInfo = parseModelConfig(config); expect(modelInfo).toMatchObject({ model: 'llama2:7b', @@ -24,7 +23,8 @@ describe('parseModelConfig', () => { }); it('should throw when no model is provided', () => { - const config = {} as InstanceConfig; + // @ts-expect-error - destructive test + const config: OllamaInstanceConfig = {}; expect(() => parseModelConfig(config)).toThrow(/model/u); }); }); diff --git a/packages/kernel-language-model-service/src/ollama/parse.ts b/packages/kernel-language-model-service/src/ollama/parse.ts index 159d0cfaff..8c873dae2e 100644 --- a/packages/kernel-language-model-service/src/ollama/parse.ts +++ b/packages/kernel-language-model-service/src/ollama/parse.ts @@ -1,36 +1,15 @@ -import { - object, - optional, - number, - size, - string, - assert, -} from '@metamask/superstruct'; -import type { Infer } from '@metamask/superstruct'; +import { assert } from '@metamask/superstruct'; import type { ModelInfo } from '../types.ts'; +import { OllamaInstanceConfigStruct } from './types.ts'; +import type { OllamaInstanceConfig, OllamaModelOptions } from './types.ts'; -export const OllamaModelOptionsStruct = object({ - // Ollama is pythonic, using snake_case for its options. - /* eslint-disable @typescript-eslint/naming-convention */ - temperature: optional(number()), - top_p: optional(number()), - top_k: optional(number()), - repeat_penalty: optional(number()), - repeat_last_n: optional(number()), - seed: optional(number()), - num_ctx: optional(number()), - /* eslint-enable @typescript-eslint/naming-convention */ -}); - -export const OllamaInstanceConfigStruct = object({ - model: size(string(), 1, Infinity), - options: optional(OllamaModelOptionsStruct), -}); - -export type OllamaModelOptions = Infer; -export type OllamaInstanceConfig = Infer; - +/** + * Parse the Ollama model configuration. + * + * @param config - The configuration to parse. + * @returns The model info struct describing an Ollama model. + */ export const parseModelConfig = ( config: OllamaInstanceConfig, ): ModelInfo => { diff --git a/packages/kernel-language-model-service/src/ollama/types.ts b/packages/kernel-language-model-service/src/ollama/types.ts index 16f90ea991..b911aebc82 100644 --- a/packages/kernel-language-model-service/src/ollama/types.ts +++ b/packages/kernel-language-model-service/src/ollama/types.ts @@ -1,3 +1,5 @@ +import { object, optional, number, size, string } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; import type { GenerateRequest, GenerateResponse, @@ -6,6 +8,8 @@ import type { Config, } from 'ollama'; +import type { LanguageModel } from '../types.ts'; + type OllamaClient = { list: () => Promise; generate: ( @@ -14,20 +18,30 @@ type OllamaClient = { }; export type { GenerateRequest, GenerateResponse, OllamaClient }; -export type OllamaModelOptions = { - // Ollama is pythonic, using snake_case for its options. - /* eslint-disable @typescript-eslint/naming-convention */ - temperature: number; - top_p: number; - top_k: number; - repeat_penalty: number; - repeat_last_n: number; - seed: number; - num_ctx: number; - /* eslint-enable @typescript-eslint/naming-convention */ -}; - export type OllamaNodejsConfig = { endowments: { fetch: typeof fetch }; clientConfig?: Partial>; }; + +export const OllamaModelOptionsStruct = object({ + // Ollama is pythonic, using snake_case for its options. + /* eslint-disable @typescript-eslint/naming-convention */ + temperature: optional(number()), + top_p: optional(number()), + top_k: optional(number()), + repeat_penalty: optional(number()), + repeat_last_n: optional(number()), + seed: optional(number()), + num_ctx: optional(number()), + /* eslint-enable @typescript-eslint/naming-convention */ +}); + +export const OllamaInstanceConfigStruct = object({ + model: size(string(), 1, Infinity), + options: optional(OllamaModelOptionsStruct), +}); + +export type OllamaModelOptions = Infer; +export type OllamaInstanceConfig = Infer; + +export type OllamaModel = LanguageModel; From 12890dc1cf83d14b28732d4a32c0b0f23d70d0d6 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:16:52 -0400 Subject: [PATCH 10/15] test(kernel-language-model-service): Improve e2e coverage --- .../test/e2e/ollama.test.ts | 81 ++++++++++++------- 1 file changed, 53 insertions(+), 28 deletions(-) diff --git a/packages/kernel-language-model-service/test/e2e/ollama.test.ts b/packages/kernel-language-model-service/test/e2e/ollama.test.ts index 2bcc1e4e90..5a58833200 100644 --- a/packages/kernel-language-model-service/test/e2e/ollama.test.ts +++ b/packages/kernel-language-model-service/test/e2e/ollama.test.ts @@ -2,6 +2,7 @@ import { fetchMock } from '@ocap/test-utils'; import { expect, describe, it, beforeEach } from 'vitest'; import { OllamaNodejsLanguageModelService } from '../../src/ollama/nodejs.ts'; +import type { OllamaModel } from '../../src/ollama/types.ts'; // This test connects to a local Ollama instance to test sampling capabilities. const testConfig = { @@ -13,21 +14,22 @@ const testConfig = { describe('OllamaNodejsLanguageModelService E2E', { timeout: 10_000 }, () => { let service: OllamaNodejsLanguageModelService; + const { model, host } = testConfig; beforeEach(async () => { // Disable fetch mocking for this test fetchMock.disableMocks(); service = new OllamaNodejsLanguageModelService({ endowments: { fetch: global.fetch }, - clientConfig: { host: testConfig.host }, + clientConfig: { host }, }); fetchMock.enableMocks(); }); describe('makeInstance', () => { it('should create a model instance', async () => { - const model = await service.makeInstance({ model: testConfig.model }); - expect(model).toBeDefined(); + const instance = await service.makeInstance({ model }); + expect(instance).toBeDefined(); }); }); @@ -40,32 +42,55 @@ describe('OllamaNodejsLanguageModelService E2E', { timeout: 10_000 }, () => { }); }); - describe('complete', () => { - it('should return a streaming result', async () => { - const prompt = 'A B C'; - let completion = prompt; - const instance = await service.makeInstance({ model: testConfig.model }); - const response = await instance.sample(prompt); - let exitEarly = false; - await Promise.all([ - (async () => { - for await (const chunk of response) { - if (exitEarly) { - return; + describe('instance', () => { + let instance: OllamaModel; + + beforeEach(async () => { + instance = await service.makeInstance({ model }); + }); + + describe('sample', () => { + it('should return a streaming result', async () => { + const prompt = 'A B C'; + let completion = prompt; + const response = await instance.sample(prompt); + let exitEarly = false; + await Promise.all([ + (async () => { + for await (const chunk of response) { + if (exitEarly) { + return; + } + completion += chunk.response; } - completion += chunk.response; - } - })(), - new Promise((resolve) => - setTimeout(() => { - exitEarly = true; - resolve(undefined); - }), - ), - ]); - console.debug('@@@ completion: ', completion); - expect(completion).toContain(prompt); - expect(completion.length).toBeGreaterThan(prompt.length); + })(), + new Promise((resolve) => + setTimeout(() => { + exitEarly = true; + resolve(undefined); + }), + ), + ]); + console.debug('@@@ sample: ', completion); + expect(completion).toContain(prompt); + expect(completion.length).toBeGreaterThan(prompt.length); + }); + }); + + describe('load', () => { + it('should load a model without generating a response', async () => { + const response = await instance.load(); + // ToDo: check that the model is loaded + expect(response).toBeUndefined(); + }); + }); + + describe('unload', () => { + it('should unload a model without generating a response', async () => { + const response = await instance.unload(); + // ToDo: check that the model is unloaded + expect(response).toBeUndefined(); + }); }); }); }); From 4f69c0fce1ba1a5b0d03eac244d83ca3aab177ce Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:19:38 -0400 Subject: [PATCH 11/15] shorten names --- .../src/ollama/base.test.ts | 12 ++++++------ .../kernel-language-model-service/src/ollama/base.ts | 2 +- .../src/ollama/nodejs.test.ts | 12 ++++++------ .../src/ollama/nodejs.ts | 4 ++-- .../test/e2e/ollama.test.ts | 8 ++++---- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/kernel-language-model-service/src/ollama/base.test.ts b/packages/kernel-language-model-service/src/ollama/base.test.ts index e918357173..6661e37b2e 100644 --- a/packages/kernel-language-model-service/src/ollama/base.test.ts +++ b/packages/kernel-language-model-service/src/ollama/base.test.ts @@ -3,13 +3,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { OllamaClient, OllamaModelOptions } from './types.ts'; import type { InstanceConfig, LanguageModel } from '../types.ts'; -import { OllamaBaseLanguageModelService } from './base.ts'; +import { OllamaBaseService } from './base.ts'; import { makeMockAbortableAsyncIterator } from '../../test/utils.ts'; -describe('OllamaBaseLanguageModelService', () => { +describe('OllamaBaseService', () => { let mockClient: OllamaClient; let mockMakeClient: () => Promise; - let service: OllamaBaseLanguageModelService; + let service: OllamaBaseService; beforeEach(() => { mockClient = { @@ -18,12 +18,12 @@ describe('OllamaBaseLanguageModelService', () => { }; mockMakeClient = vi.fn().mockResolvedValue(mockClient); - service = new OllamaBaseLanguageModelService(mockMakeClient); + service = new OllamaBaseService(mockMakeClient); }); describe('constructor', () => { it('should initialize with makeClient function', () => { - expect(service).toBeInstanceOf(OllamaBaseLanguageModelService); + expect(service).toBeInstanceOf(OllamaBaseService); }); }); @@ -52,7 +52,7 @@ describe('OllamaBaseLanguageModelService', () => { it('should handle client creation errors', async () => { const error = new Error('Failed to create client'); mockMakeClient = vi.fn().mockRejectedValue(error); - service = new OllamaBaseLanguageModelService(mockMakeClient); + service = new OllamaBaseService(mockMakeClient); await expect(service.getModels()).rejects.toThrow( 'Failed to create client', diff --git a/packages/kernel-language-model-service/src/ollama/base.ts b/packages/kernel-language-model-service/src/ollama/base.ts index 583f66eb12..bae7ae2b5b 100644 --- a/packages/kernel-language-model-service/src/ollama/base.ts +++ b/packages/kernel-language-model-service/src/ollama/base.ts @@ -12,7 +12,7 @@ import type { /** * It is recommended to create an Ollama client per model session. */ -export class OllamaBaseLanguageModelService +export class OllamaBaseService implements LanguageModelService< OllamaModelOptions, diff --git a/packages/kernel-language-model-service/src/ollama/nodejs.test.ts b/packages/kernel-language-model-service/src/ollama/nodejs.test.ts index 9e1332c256..da16fbda5a 100644 --- a/packages/kernel-language-model-service/src/ollama/nodejs.test.ts +++ b/packages/kernel-language-model-service/src/ollama/nodejs.test.ts @@ -3,18 +3,18 @@ import '@ocap/test-utils/mock-endoify'; import { fetchMock } from '@ocap/test-utils'; import { expect, describe, it, beforeEach } from 'vitest'; -import { OllamaNodejsLanguageModelService } from './nodejs.ts'; +import { OllamaNodejsService } from './nodejs.ts'; import type { OllamaNodejsConfig } from './types.ts'; import { mockReadableStream } from '../../test/utils.ts'; -describe('OllamaNodejsLanguageModelService', () => { - let service: OllamaNodejsLanguageModelService; +describe('OllamaNodejsService', () => { + let service: OllamaNodejsService; const model = 'llama3.2:latest'; const clientConfig = { host: 'http://127.0.0.1:11434' }; const endowments = { fetch: fetchMock }; beforeEach(async () => { - service = new OllamaNodejsLanguageModelService({ + service = new OllamaNodejsService({ endowments, clientConfig, }); @@ -28,7 +28,7 @@ describe('OllamaNodejsLanguageModelService', () => { ])( 'should create a service with the correct endowments: %s', (_testName, config: OllamaNodejsConfig) => { - const constructedService = new OllamaNodejsLanguageModelService(config); + const constructedService = new OllamaNodejsService(config); expect(constructedService).toBeDefined(); }, ); @@ -41,7 +41,7 @@ describe('OllamaNodejsLanguageModelService', () => { (_testName, config, expectedError) => { expect( () => - new OllamaNodejsLanguageModelService( + new OllamaNodejsService( // @ts-expect-error - Destructive test config, ), diff --git a/packages/kernel-language-model-service/src/ollama/nodejs.ts b/packages/kernel-language-model-service/src/ollama/nodejs.ts index 11582dae56..e9d94fd90f 100644 --- a/packages/kernel-language-model-service/src/ollama/nodejs.ts +++ b/packages/kernel-language-model-service/src/ollama/nodejs.ts @@ -1,10 +1,10 @@ import { Ollama } from 'ollama'; -import { OllamaBaseLanguageModelService } from './base.ts'; +import { OllamaBaseService } from './base.ts'; import { defaultClientConfig } from './constants.ts'; import type { OllamaClient, OllamaNodejsConfig } from './types.ts'; -export class OllamaNodejsLanguageModelService extends OllamaBaseLanguageModelService { +export class OllamaNodejsService extends OllamaBaseService { constructor({ endowments, clientConfig = {} }: OllamaNodejsConfig) { if (!endowments?.fetch) { throw new Error('Must endow a fetch implementation.'); diff --git a/packages/kernel-language-model-service/test/e2e/ollama.test.ts b/packages/kernel-language-model-service/test/e2e/ollama.test.ts index 5a58833200..6ba66021bc 100644 --- a/packages/kernel-language-model-service/test/e2e/ollama.test.ts +++ b/packages/kernel-language-model-service/test/e2e/ollama.test.ts @@ -1,7 +1,7 @@ import { fetchMock } from '@ocap/test-utils'; import { expect, describe, it, beforeEach } from 'vitest'; -import { OllamaNodejsLanguageModelService } from '../../src/ollama/nodejs.ts'; +import { OllamaNodejsService } from '../../src/ollama/nodejs.ts'; import type { OllamaModel } from '../../src/ollama/types.ts'; // This test connects to a local Ollama instance to test sampling capabilities. @@ -12,14 +12,14 @@ const testConfig = { host: 'http://127.0.0.1:11434', }; -describe('OllamaNodejsLanguageModelService E2E', { timeout: 10_000 }, () => { - let service: OllamaNodejsLanguageModelService; +describe('OllamaNodejsService E2E', { timeout: 10_000 }, () => { + let service: OllamaNodejsService; const { model, host } = testConfig; beforeEach(async () => { // Disable fetch mocking for this test fetchMock.disableMocks(); - service = new OllamaNodejsLanguageModelService({ + service = new OllamaNodejsService({ endowments: { fetch: global.fetch }, clientConfig: { host }, }); From 2ede7de18c9b07894d9af9977fecf848af1643b9 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:53:11 -0400 Subject: [PATCH 12/15] feat(kernel-language-model-service): Improve restricted fetch explicitness --- .../src/ollama/fetch.test.ts | 47 ++++++++----------- .../src/ollama/fetch.ts | 27 ++++++----- .../test/e2e/ollama.test.ts | 7 +-- 3 files changed, 38 insertions(+), 43 deletions(-) diff --git a/packages/kernel-language-model-service/src/ollama/fetch.test.ts b/packages/kernel-language-model-service/src/ollama/fetch.test.ts index 2051638a64..6abeacf947 100644 --- a/packages/kernel-language-model-service/src/ollama/fetch.test.ts +++ b/packages/kernel-language-model-service/src/ollama/fetch.test.ts @@ -1,12 +1,11 @@ import '@ocap/test-utils/mock-endoify'; -import type { Config } from 'ollama'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { makeOriginRestrictedFetch } from './fetch.ts'; +import { makeHostRestrictedFetch } from './fetch.ts'; -describe('makeOriginRestrictedFetch', () => { - const mockHost = 'http://localhost:8080'; - const mockConfig: Config = { host: mockHost }; +describe('makeHostRestrictedFetch', () => { + const mockHost = 'localhost:8080'; + const mockUrl = `http://${mockHost}/test/test`; const mockResponse = { ok: true, @@ -21,8 +20,8 @@ describe('makeOriginRestrictedFetch', () => { beforeEach(() => { hardenSpy = vi.spyOn(global, 'harden'); originalFetch = global.fetch; - vi.spyOn(global, 'fetch').mockImplementation(); - restrictedFetch = makeOriginRestrictedFetch(mockConfig); + vi.spyOn(global, 'fetch').mockImplementation(vi.fn()); + restrictedFetch = makeHostRestrictedFetch([mockHost]); }); afterEach(() => { @@ -30,7 +29,7 @@ describe('makeOriginRestrictedFetch', () => { vi.clearAllMocks(); }); - describe('origin validation', () => { + describe('host validation', () => { it.each([ ['root', []], ['with path segment', ['test']], @@ -39,7 +38,7 @@ describe('makeOriginRestrictedFetch', () => { ])( 'should allow requests to the configured host with different paths: %s', async (_case, path: string[]) => { - const url = [mockHost, ...path].join('/'); + const url = ['http:/', mockHost, ...path].join('/'); await restrictedFetch(url); @@ -48,18 +47,17 @@ describe('makeOriginRestrictedFetch', () => { ); it.each([ - ['wrong origin', 'http://malicious.com'], - ['subdomain', 'http://api.localhost:8080'], - ['different port', 'http://localhost:11434'], - ['different protocol', 'https://localhost:8080'], + ['wrong origin', 'malicious.com'], + ['subdomain', 'api.localhost:8080'], + ['different port', 'localhost:11434'], ])( 'should throw error for unauthorized requests: %s', - async (_case, origin: string) => { - assert(origin !== mockHost, 'test of test'); - const url = `${origin}/test/test`; + async (_case, host: string) => { + assert(host !== mockHost, 'test of test'); + const url = `http://${host}/test/test`; await expect(restrictedFetch(url)).rejects.toThrow( - `Invalid origin: ${origin}, expected: ${mockHost}`, + `Invalid host: ${host}, expected: ${mockHost}`, ); expect(global.fetch).not.toHaveBeenCalled(); @@ -72,9 +70,8 @@ describe('makeOriginRestrictedFetch', () => { (global.fetch as ReturnType).mockResolvedValue( mockResponse, ); - const url = `${mockHost}/api/generate`; - const result = await restrictedFetch(url); + const result = await restrictedFetch(mockUrl); expect(result).toBe(mockResponse); }); @@ -85,27 +82,23 @@ describe('makeOriginRestrictedFetch', () => { errorResponse, ); - const url = `${mockHost}/api/generate`; - - await expect(restrictedFetch(url)).rejects.toThrow('Network error'); + await expect(restrictedFetch(mockUrl)).rejects.toThrow('Network error'); }); it('should handle multiple arguments correctly', async () => { - const url = `${mockHost}/api/generate`; const options = { method: 'POST', headers: { 'Content-Type': 'application/json' }, }; - await restrictedFetch(url, options); + await restrictedFetch(mockUrl, options); - expect(global.fetch).toHaveBeenCalledWith(url, options); + expect(global.fetch).toHaveBeenCalledWith(mockUrl, options); }); it('should handle Request objects correctly', async () => { - const url = `${mockHost}/api/generate`; // eslint-disable-next-line n/no-unsupported-features/node-builtins - const request = new Request(url); + const request = new Request(mockUrl); await restrictedFetch(request); diff --git a/packages/kernel-language-model-service/src/ollama/fetch.ts b/packages/kernel-language-model-service/src/ollama/fetch.ts index 1c4d99b21f..ae717d330e 100644 --- a/packages/kernel-language-model-service/src/ollama/fetch.ts +++ b/packages/kernel-language-model-service/src/ollama/fetch.ts @@ -10,29 +10,30 @@ * use the fetch function from global scope to make requests to other hosts. */ -import type { Config } from 'ollama'; - /** - * Creates a fetch function that only allows requests to the specified host. + * Creates a fetch function that only allows requests to the specified origins. * - * @param config - The configuration object containing the host to restrict requests to. - * @returns A fetch function that only allows requests to the specified host. + * @param allowedHosts - The hosts to allow requests from. + * @param baseFetch - The fetch function to use as a base. Defaults to the global fetch function. + * @returns A fetch function that only allows requests to the specified hosts. */ -export const makeOriginRestrictedFetch = (config: Config): typeof fetch => { - const { host: configuredOrigin } = config; +export const makeHostRestrictedFetch = ( + allowedHosts: string[], + baseFetch: typeof fetch = global.fetch, +): typeof fetch => { const restrictedFetch = async ( ...[url, ...args]: Parameters ): ReturnType => { // eslint-disable-next-line n/no-unsupported-features/node-builtins - const { origin } = new URL(url instanceof Request ? url.url : url); - if (origin !== configuredOrigin) { + const { host } = new URL(url instanceof Request ? url.url : url); + if (!allowedHosts.includes(host)) { throw new Error( - `Invalid origin: ${origin}, expected: ${configuredOrigin}`, + `Invalid host: ${host}, expected: ${allowedHosts.join(', ')}`, + { cause: { url } }, ); } - const response = await fetch(url, ...args); + const response = await baseFetch(url, ...args); return response; }; - harden(restrictedFetch); - return restrictedFetch as unknown as typeof fetch; + return harden(restrictedFetch); }; diff --git a/packages/kernel-language-model-service/test/e2e/ollama.test.ts b/packages/kernel-language-model-service/test/e2e/ollama.test.ts index 6ba66021bc..3437f080c8 100644 --- a/packages/kernel-language-model-service/test/e2e/ollama.test.ts +++ b/packages/kernel-language-model-service/test/e2e/ollama.test.ts @@ -1,6 +1,7 @@ import { fetchMock } from '@ocap/test-utils'; import { expect, describe, it, beforeEach } from 'vitest'; +import { makeHostRestrictedFetch } from '../../src/ollama/fetch.ts'; import { OllamaNodejsService } from '../../src/ollama/nodejs.ts'; import type { OllamaModel } from '../../src/ollama/types.ts'; @@ -9,7 +10,7 @@ const testConfig = { // Default model: 'llama3.2:latest' model: 'llama3.2:latest', // Default host: 'http://127.0.0.1:11434' - host: 'http://127.0.0.1:11434', + host: '127.0.0.1:11434', }; describe('OllamaNodejsService E2E', { timeout: 10_000 }, () => { @@ -20,8 +21,8 @@ describe('OllamaNodejsService E2E', { timeout: 10_000 }, () => { // Disable fetch mocking for this test fetchMock.disableMocks(); service = new OllamaNodejsService({ - endowments: { fetch: global.fetch }, - clientConfig: { host }, + endowments: { fetch: makeHostRestrictedFetch([host], fetch) }, + clientConfig: { host: `http://${host}` }, }); fetchMock.enableMocks(); }); From 67d092a76c889fea86362885c13dcada25e5c39f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:13:27 -0400 Subject: [PATCH 13/15] docs(kernel-language-model-service): Audit docstrings --- .../src/ollama/base.ts | 24 ++++++++++- .../src/ollama/constants.ts | 6 +++ .../src/ollama/nodejs.ts | 17 +++++++- .../src/ollama/types.ts | 28 +++++++++++++ .../src/types.ts | 42 ++++++++++++++++++- .../test/utils.ts | 19 +++++++++ 6 files changed, 133 insertions(+), 3 deletions(-) diff --git a/packages/kernel-language-model-service/src/ollama/base.ts b/packages/kernel-language-model-service/src/ollama/base.ts index bae7ae2b5b..c972302119 100644 --- a/packages/kernel-language-model-service/src/ollama/base.ts +++ b/packages/kernel-language-model-service/src/ollama/base.ts @@ -10,7 +10,12 @@ import type { } from './types.ts'; /** - * It is recommended to create an Ollama client per model session. + * Base service for interacting with Ollama language models. + * Provides a generic interface for creating and managing Ollama model instances. + * This class implements the LanguageModelService interface and handles the + * creation of hardened model instances that can be safely passed between vats. + * + * @template Ollama - The type of Ollama client to use */ export class OllamaBaseService implements @@ -22,15 +27,32 @@ export class OllamaBaseService { readonly #makeClient: () => Promise; + /** + * Creates a new Ollama base service. + * + * @param makeClient - Factory function that creates an Ollama client instance + */ constructor(makeClient: () => Promise) { this.#makeClient = makeClient; } + /** + * Retrieves a list of available models from the Ollama server. + * + * @returns A promise that resolves to the list of available models + */ async getModels(): Promise { const client = await this.#makeClient(); return await client.list(); } + /** + * Creates a new language model instance with the specified configuration. + * The returned instance is hardened for object capability security. + * + * @param config - The configuration for the model instance + * @returns A promise that resolves to a hardened language model instance + */ async makeInstance(config: OllamaInstanceConfig): Promise { const modelInfo = parseModelConfig(config); const { model } = modelInfo; diff --git a/packages/kernel-language-model-service/src/ollama/constants.ts b/packages/kernel-language-model-service/src/ollama/constants.ts index 069b5cc98d..bd9ae0356b 100644 --- a/packages/kernel-language-model-service/src/ollama/constants.ts +++ b/packages/kernel-language-model-service/src/ollama/constants.ts @@ -1,3 +1,9 @@ +/** + * Default configuration for Ollama client connections. + * Points to the standard Ollama server endpoint running on localhost. + * This is the default endpoint when Ollama is installed and running locally. + * Note that the argument designated 'host' includes the protocol and port. + */ export const defaultClientConfig = { host: 'http://localhost:11434', }; diff --git a/packages/kernel-language-model-service/src/ollama/nodejs.ts b/packages/kernel-language-model-service/src/ollama/nodejs.ts index e9d94fd90f..f0bdc2e14d 100644 --- a/packages/kernel-language-model-service/src/ollama/nodejs.ts +++ b/packages/kernel-language-model-service/src/ollama/nodejs.ts @@ -4,8 +4,23 @@ import { OllamaBaseService } from './base.ts'; import { defaultClientConfig } from './constants.ts'; import type { OllamaClient, OllamaNodejsConfig } from './types.ts'; +/** + * Node.js-specific implementation of the Ollama service. + * Extends OllamaBaseService to provide a concrete implementation for Node.js environments. + * Requires an explicit fetch endowment. + */ export class OllamaNodejsService extends OllamaBaseService { - constructor({ endowments, clientConfig = {} }: OllamaNodejsConfig) { + /** + * Creates a new Ollama Node.js service. + * + * @param config - The configuration for the service + * @param config.endowments - Required endowments for the service + * @param config.endowments.fetch - The fetch implementation to use for HTTP requests + * @param config.clientConfig - Optional configuration for the Ollama client + * @throws {Error} When fetch is not provided in endowments + */ + constructor(config: OllamaNodejsConfig) { + const { endowments, clientConfig = {} } = config; if (!endowments?.fetch) { throw new Error('Must endow a fetch implementation.'); } diff --git a/packages/kernel-language-model-service/src/ollama/types.ts b/packages/kernel-language-model-service/src/ollama/types.ts index b911aebc82..7df905da10 100644 --- a/packages/kernel-language-model-service/src/ollama/types.ts +++ b/packages/kernel-language-model-service/src/ollama/types.ts @@ -10,6 +10,10 @@ import type { import type { LanguageModel } from '../types.ts'; +/** + * Interface for an Ollama client that can list models and generate responses. + * Provides the minimal interface required for Ollama operations. + */ type OllamaClient = { list: () => Promise; generate: ( @@ -18,11 +22,21 @@ type OllamaClient = { }; export type { GenerateRequest, GenerateResponse, OllamaClient }; +/** + * Configuration for creating an Ollama service in a Node.js environment. + * Requires a fetch implementation to be provided as an endowment for security. + */ export type OllamaNodejsConfig = { endowments: { fetch: typeof fetch }; clientConfig?: Partial>; }; +/** + * Superstruct schema for Ollama model options. + * Defines the validation rules for model generation parameters. + * + * Note: Uses snake_case to match Ollama's Python-style API. + */ export const OllamaModelOptionsStruct = object({ // Ollama is pythonic, using snake_case for its options. /* eslint-disable @typescript-eslint/naming-convention */ @@ -36,12 +50,26 @@ export const OllamaModelOptionsStruct = object({ /* eslint-enable @typescript-eslint/naming-convention */ }); +/** + * Superstruct schema for Ollama instance configuration. + * Validates that the model name is a non-empty string. + */ export const OllamaInstanceConfigStruct = object({ model: size(string(), 1, Infinity), options: optional(OllamaModelOptionsStruct), }); +/** + * Type representing valid Ollama model options. + */ export type OllamaModelOptions = Infer; + +/** + * Type representing valid Ollama instance configuration. + */ export type OllamaInstanceConfig = Infer; +/** + * Type representing an Ollama language model instance. + */ export type OllamaModel = LanguageModel; diff --git a/packages/kernel-language-model-service/src/types.ts b/packages/kernel-language-model-service/src/types.ts index 9f7d6893c3..b5ed239f53 100644 --- a/packages/kernel-language-model-service/src/types.ts +++ b/packages/kernel-language-model-service/src/types.ts @@ -1,9 +1,27 @@ +/** + * Configuration information for a language model. + * Contains the model identifier and optional configuration parameters. + * + * @template Options - The type of options supported by the model + */ export type ModelInfo = { model: string; options?: Partial; }; +/** + * Interface for a language model that can be loaded, unloaded, and used for text generation. + * Provides a standardized interface for interacting with different language model implementations. + * + * @template Options - The type of options supported by the model + * @template Response - The type of response generated by the model + */ export type LanguageModel = { + /** + * Retrieves information about the model configuration. + * + * @returns A promise that resolves to the model information + */ getInfo: () => Promise>; /** @@ -19,9 +37,11 @@ export type LanguageModel = { */ unload: () => Promise; /** + * Generates a response from the model based on the provided prompt. + * * @param prompt - The prompt to complete. * @param options - The options to pass to the model. - * @returns A promise that resolves when the response is complete, or rejects if an error occurs. + * @returns A promise that resolves to an async iterable of response chunks, or rejects if an error occurs. */ sample: ( prompt: string, @@ -29,12 +49,32 @@ export type LanguageModel = { ) => Promise>; }; +/** + * Configuration for creating a language model instance. + * Specifies which model to use and any model-specific options. + * + * @template Options - The type of options supported by the model + */ export type InstanceConfig = { model: string; options?: Partial; }; +/** + * Service interface for creating language model instances. + * Provides a factory pattern for instantiating language models with specific configurations. + * + * @template Config - The type of configuration accepted by the service + * @template Options - The type of options supported by created models + * @template Response - The type of response generated by created models + */ export type LanguageModelService = { + /** + * Creates a new language model instance with the specified configuration. + * + * @param config - The configuration for the model instance + * @returns A promise that resolves to a language model instance + */ makeInstance: ( config: InstanceConfig, ) => Promise>; diff --git a/packages/kernel-language-model-service/test/utils.ts b/packages/kernel-language-model-service/test/utils.ts index 206e14e5f0..4d17987133 100644 --- a/packages/kernel-language-model-service/test/utils.ts +++ b/packages/kernel-language-model-service/test/utils.ts @@ -3,6 +3,11 @@ import type { AbortableAsyncIterator } from 'ollama'; import { vi } from 'vitest'; import type { Mocked } from 'vitest'; +/** + * Creates a mock @metamask/streams Writer that can be used to test the stream functionality. + * + * @returns A mock stream that can async iterate and always yields undefined. + */ export const mockStream = (): Mocked> => { const stream: Mocked> = { next: vi.fn().mockResolvedValue(undefined), @@ -13,6 +18,13 @@ export const mockStream = (): Mocked> => { return stream; }; +/** + * Creates a mock @ollama/AbortableAsyncIterator that can be used to test the stream functionality. + * + * @param responses - The responses to yield. + * @param doneCallback - The callback to call when the iterator is done. + * @returns A mock abortable async iterator that yields each response one by one, and then calls the done callback. + */ export const makeMockAbortableAsyncIterator = ( responses: Content[], doneCallback?: () => void, @@ -36,6 +48,13 @@ export const makeMockAbortableAsyncIterator = ( }; const encoder = new TextEncoder(); + +/** + * Creates a mock ReadableStream for testing. + * + * @param chunks - The chunks to enqueue. + * @returns A mock readable stream that yields each chunk one by one, and then closes. + */ export const mockReadableStream = (chunks: object[]) => // ReadableStream is experimental in Node 20, but this case works. // eslint-disable-next-line n/no-unsupported-features/node-builtins From 065a442d5994acf3e4ccb3837e18da342d6e0556 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:13:58 -0400 Subject: [PATCH 14/15] docs(kernel-language-model-service): Update README --- .../kernel-language-model-service/README.md | 101 +++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/kernel-language-model-service/README.md b/packages/kernel-language-model-service/README.md index b006b56a7d..d5843f5702 100644 --- a/packages/kernel-language-model-service/README.md +++ b/packages/kernel-language-model-service/README.md @@ -1,6 +1,25 @@ # `@ocap/kernel-language-model-service` -A place for implementations providing language model services to the ocap kernel. +A package providing language model service implementations for the ocap kernel. This package defines interfaces and implementations for integrating various language model providers (like Ollama) into the kernel's object capability system. + +## Overview + +This package provides: + +- **Generic interfaces** for language model services that can be implemented by different providers +- **Ollama integration** for local language model inference +- **Object capability security** through hardened instances and endowment patterns +- **Type-safe configuration** using Superstruct validation + +## Architecture + +The package follows the object capability pattern with clear separation of concerns: + +- `LanguageModelService` - Factory interface for creating model instances +- `LanguageModel` - Interface for individual model instances +- Provider-specific implementations (e.g., `OllamaNodejsService`) + +All model instances are hardened using `harden()` from `@endo/ses` for security. ## Installation @@ -10,6 +29,86 @@ or `npm install @ocap/kernel-language-model-service` +## Usage + +### Basic Ollama Integration + +```typescript +import { OllamaNodejsService } from '@ocap/kernel-language-model-service/ollama/nodejs'; + +// Create a service instance with required endowments +const service = new OllamaNodejsService({ + endowments: { fetch: global.fetch }, +}); + +// Create a model instance +const model = await service.makeInstance({ + model: 'llama2', + options: { temperature: 0.7 }, +}); + +// (Optional) Load the model into memory +await model.load(); + +// Generate a response +const response = await model.sample('Hello, world!'); +for await (const chunk of response) { + console.log(chunk.response); +} + +// (Optional) Unload the model when done +await model.unload(); +``` + +### Using Host-Restricted Fetch + +For enhanced security, you can use the host-restricted fetch utility: + +```typescript +import { makeHostRestrictedFetch } from '@ocap/kernel-language-model-service/ollama/fetch'; + +const restrictedFetch = makeHostRestrictedFetch( + ['localhost:11434'], + global.fetch, +); + +const service = new OllamaNodejsService({ + endowments: { fetch: restrictedFetch }, +}); +``` + +### Listing Available Models + +```typescript +const models = await service.getModels(); +console.log( + 'Available models:', + models.models.map((m) => m.name), +); +``` + +## Security Considerations + +- **Object Capabilities**: All model instances are hardened and can be safely passed between vats +- **Endowment Pattern**: External dependencies (like `fetch`) must be explicitly provided +- **Host Restrictions**: Use `makeHostRestrictedFetch` to limit network access +- **Validation**: All configurations are validated using Superstruct schemas + +## API Reference + +### Core Types + +- `LanguageModelService` - Factory for creating model instances +- `LanguageModel` - Interface for model instances +- `ModelInfo` - Configuration information for a model +- `InstanceConfig` - Configuration for creating model instances + +### Ollama Types + +- `OllamaNodejsService` - Node.js implementation of Ollama service +- `OllamaModelOptions` - Valid options for Ollama model generation +- `OllamaInstanceConfig` - Configuration for Ollama model instances + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). From 01c28540675fa2e4b141d5dfb6d409754c799a9a Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:18:36 -0400 Subject: [PATCH 15/15] fix fetch default argument --- packages/kernel-language-model-service/src/ollama/fetch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kernel-language-model-service/src/ollama/fetch.ts b/packages/kernel-language-model-service/src/ollama/fetch.ts index ae717d330e..cf0ae0ba0c 100644 --- a/packages/kernel-language-model-service/src/ollama/fetch.ts +++ b/packages/kernel-language-model-service/src/ollama/fetch.ts @@ -19,7 +19,7 @@ */ export const makeHostRestrictedFetch = ( allowedHosts: string[], - baseFetch: typeof fetch = global.fetch, + baseFetch: typeof fetch = globalThis.fetch, ): typeof fetch => { const restrictedFetch = async ( ...[url, ...args]: Parameters