From dfc94b58866bf4c3dc3ef787a1777508b78bf235 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:56:42 +0000 Subject: [PATCH] feat: implement and serve the local v1/embeddings endpoint with full tests - Integrate @huggingface/transformers for in-process feature extraction using Xenova/bge-small-en-v1.5 - Add Zod validation schemas and type interfaces for embeddings request/response - Support L2 normalization, dimensions truncation (Matryoshka), and Base64 format derivation - Fully integrate the endpoint in app routes and application entrypoint - Implement unit tests and E2E integration tests to guarantee correctness --- bun.lock | 145 +++++++++++++++++++++ flake.nix | 5 + package.json | 1 + src/app.ts | 4 + src/config.test.ts | 39 +++++- src/config.ts | 17 +++ src/main.test.ts | 201 ++++++++++++++++++++++++++---- src/main.ts | 34 ++++- src/openai/embeddings.test.ts | 47 +++++++ src/openai/embeddings.ts | 28 +++++ src/routes/v1/embeddings.test.ts | 143 +++++++++++++++++++++ src/routes/v1/embeddings.ts | 25 ++++ src/server.test.ts | 8 +- src/services/embeddings.test.ts | 154 +++++++++++++++++++++++ src/services/embeddings.ts | 145 +++++++++++++++++++++ src/testing/recordingPipeline.ts | 31 +++++ src/utils/parse.test.ts | 27 ++++ src/utils/parse.ts | 10 ++ test/e2e/chat-completions.test.ts | 4 +- test/e2e/embeddings.test.ts | 152 ++++++++++++++++++++++ test/e2e/main.embedded.test.ts | 9 +- test/e2e/models.test.ts | 4 +- test/e2e/support/server.test.ts | 27 +++- test/e2e/support/server.ts | 20 ++- 24 files changed, 1235 insertions(+), 45 deletions(-) create mode 100644 src/openai/embeddings.test.ts create mode 100644 src/openai/embeddings.ts create mode 100644 src/routes/v1/embeddings.test.ts create mode 100644 src/routes/v1/embeddings.ts create mode 100644 src/services/embeddings.test.ts create mode 100644 src/services/embeddings.ts create mode 100644 src/testing/recordingPipeline.ts create mode 100644 src/utils/parse.test.ts create mode 100644 src/utils/parse.ts create mode 100644 test/e2e/embeddings.test.ts diff --git a/bun.lock b/bun.lock index c7a7da4..15f7b3e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "opencode-to-openai", "dependencies": { + "@huggingface/transformers": "^4.2.0", "@opencode-ai/sdk": "^1.18.11", "zod": "^4.4.3", }, @@ -17,6 +18,64 @@ }, }, "packages": { + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], + + "@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="], + + "@huggingface/transformers": ["@huggingface/transformers@4.2.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.6", "@huggingface/tokenizers": "^0.1.3", "onnxruntime-node": "1.24.3", "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", "sharp": "^0.34.5" } }, "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.11", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-yDImmNv4PhxdMgtiHVNWQWEVwQlAm7Dr0y4XU7CT4dOIbzgO+VP+9I02lAP7Zva1FhGeyI7oKMI2tzB9RUsWaQ=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.61.0", "", { "os": "android", "cpu": "arm" }, "sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA=="], @@ -95,28 +154,112 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.76.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], + "adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="], + + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], + + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="], + + "onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="], + + "onnxruntime-web": ["onnxruntime-web@1.26.0-dev.20260416-b7804b056c", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw=="], + "oxfmt": ["oxfmt@0.61.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.61.0", "@oxfmt/binding-android-arm64": "0.61.0", "@oxfmt/binding-darwin-arm64": "0.61.0", "@oxfmt/binding-darwin-x64": "0.61.0", "@oxfmt/binding-freebsd-x64": "0.61.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.61.0", "@oxfmt/binding-linux-arm-musleabihf": "0.61.0", "@oxfmt/binding-linux-arm64-gnu": "0.61.0", "@oxfmt/binding-linux-arm64-musl": "0.61.0", "@oxfmt/binding-linux-ppc64-gnu": "0.61.0", "@oxfmt/binding-linux-riscv64-gnu": "0.61.0", "@oxfmt/binding-linux-riscv64-musl": "0.61.0", "@oxfmt/binding-linux-s390x-gnu": "0.61.0", "@oxfmt/binding-linux-x64-gnu": "0.61.0", "@oxfmt/binding-linux-x64-musl": "0.61.0", "@oxfmt/binding-openharmony-arm64": "0.61.0", "@oxfmt/binding-win32-arm64-msvc": "0.61.0", "@oxfmt/binding-win32-ia32-msvc": "0.61.0", "@oxfmt/binding-win32-x64-msvc": "0.61.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ=="], "oxlint": ["oxlint@1.76.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.76.0", "@oxlint/binding-android-arm64": "1.76.0", "@oxlint/binding-darwin-arm64": "1.76.0", "@oxlint/binding-darwin-x64": "1.76.0", "@oxlint/binding-freebsd-x64": "1.76.0", "@oxlint/binding-linux-arm-gnueabihf": "1.76.0", "@oxlint/binding-linux-arm-musleabihf": "1.76.0", "@oxlint/binding-linux-arm64-gnu": "1.76.0", "@oxlint/binding-linux-arm64-musl": "1.76.0", "@oxlint/binding-linux-ppc64-gnu": "1.76.0", "@oxlint/binding-linux-riscv64-gnu": "1.76.0", "@oxlint/binding-linux-riscv64-musl": "1.76.0", "@oxlint/binding-linux-s390x-gnu": "1.76.0", "@oxlint/binding-linux-x64-gnu": "1.76.0", "@oxlint/binding-linux-x64-musl": "1.76.0", "@oxlint/binding-openharmony-arm64": "1.76.0", "@oxlint/binding-win32-arm64-msvc": "1.76.0", "@oxlint/binding-win32-ia32-msvc": "1.76.0", "@oxlint/binding-win32-x64-msvc": "1.76.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], @@ -124,5 +267,7 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="], } } diff --git a/flake.nix b/flake.nix index ba736f0..ba2ae31 100644 --- a/flake.nix +++ b/flake.nix @@ -25,8 +25,13 @@ devShells.default = pkgs.mkShell { packages = with pkgs; [ bun + gcc typescript-language-server ]; + + shellHook = '' + export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + ''; }; } ); diff --git a/package.json b/package.json index e5f9bd0..f869f2b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@huggingface/transformers": "^4.2.0", "@opencode-ai/sdk": "^1.18.11", "zod": "^4.4.3" }, diff --git a/src/app.ts b/src/app.ts index 1fa900e..68104d1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,15 +1,19 @@ import { Router } from "./router.ts"; import { chatCompletionsHandler } from "./routes/v1/chat/completions.ts"; +import { embeddingsHandler } from "./routes/v1/embeddings.ts"; import { modelsHandler } from "./routes/v1/models.ts"; import type { ChatCompletionsService } from "./services/chat-completions.ts"; +import type { EmbeddingsService } from "./services/embeddings.ts"; import type { ModelsService } from "./services/models.ts"; export function buildRouter( chatCompletions: ChatCompletionsService, models: ModelsService, + embeddings: EmbeddingsService, ): Router { const router = new Router(); router.register("POST", "/v1/chat/completions", chatCompletionsHandler(chatCompletions)); router.register("GET", "/v1/models", modelsHandler(models)); + router.register("POST", "/v1/embeddings", embeddingsHandler(embeddings)); return router; } diff --git a/src/config.test.ts b/src/config.test.ts index 5d2d926..b9e5b6e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test"; import { loadConfig } from "./config.ts"; -const DEFAULTS = { host: "127.0.0.1", port: 8000, opencodeUrl: null }; +const DEFAULTS = { + host: "127.0.0.1", + port: 8000, + opencodeUrl: null, + embeddingsModel: "Xenova/bge-small-en-v1.5", + embeddingsPreload: false, +}; describe("loadConfig", () => { test("defaults host, port, and opencode url", () => { @@ -19,11 +25,15 @@ describe("loadConfig", () => { PORT: "9000", HOST: "0.0.0.0", OPENCODE_URL: "http://opencode.example:7777", + EMBEDDINGS_MODEL: "Xenova/all-MiniLM-L6-v2", + EMBEDDINGS_PRELOAD: "true", }), ).toEqual({ host: "0.0.0.0", port: 9000, opencodeUrl: "http://opencode.example:7777", + embeddingsModel: "Xenova/all-MiniLM-L6-v2", + embeddingsPreload: true, }); }); @@ -33,11 +43,15 @@ describe("loadConfig", () => { PORT: " 8123 ", HOST: " localhost ", OPENCODE_URL: " http://opencode.example:7777 ", + EMBEDDINGS_MODEL: " Xenova/all-MiniLM-L6-v2 ", + EMBEDDINGS_PRELOAD: " true ", }), ).toEqual({ host: "localhost", port: 8123, opencodeUrl: "http://opencode.example:7777", + embeddingsModel: "Xenova/all-MiniLM-L6-v2", + embeddingsPreload: true, }); }); @@ -62,4 +76,27 @@ describe("loadConfig", () => { test("throws for a non-http(s) OPENCODE_URL", () => { expect(() => loadConfig({ OPENCODE_URL: "ftp://example.com" })).toThrow(/invalid OPENCODE_URL/); }); + + test("reads EMBEDDINGS_PRELOAD false", () => { + expect(loadConfig({ EMBEDDINGS_PRELOAD: "false" }).embeddingsPreload).toBe(false); + }); + + test("throws for non-boolean EMBEDDINGS_PRELOAD values", () => { + for (const value of ["1", "0", "yes", "no", "on", "y", "n", " random "]) { + expect(() => loadConfig({ EMBEDDINGS_PRELOAD: value })).toThrow(/invalid EMBEDDINGS_PRELOAD/); + } + }); + + test("throws for empty or whitespace-only EMBEDDINGS_PRELOAD", () => { + for (const value of ["", " ", "\t\n "]) { + expect(() => loadConfig({ EMBEDDINGS_PRELOAD: value })).toThrow( + /invalid EMBEDDINGS_PRELOAD/, + ); + } + }); + + test("accepts case-insensitive EMBEDDINGS_PRELOAD booleans", () => { + expect(loadConfig({ EMBEDDINGS_PRELOAD: "TRUE" }).embeddingsPreload).toBe(true); + expect(loadConfig({ EMBEDDINGS_PRELOAD: "False" }).embeddingsPreload).toBe(false); + }); }); diff --git a/src/config.ts b/src/config.ts index 90bbf60..fc8ab3b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,11 +1,16 @@ import { parseHttpUrl } from "./utils/net.ts"; +import { parseBoolean } from "./utils/parse.ts"; export interface Config { host: string; port: number; opencodeUrl: string | null; + embeddingsModel: string; + embeddingsPreload: boolean; } +const DEFAULT_EMBEDDINGS_MODEL = "Xenova/bge-small-en-v1.5"; + export function loadConfig(env: Record = process.env): Config { const port = Number(env.PORT?.trim() || 8000); if (!Number.isInteger(port) || port < 0 || port > 65_535) { @@ -16,9 +21,21 @@ export function loadConfig(env: Record = process.env if (opencodeUrl && !parseHttpUrl(opencodeUrl)) { throw new Error(`invalid OPENCODE_URL "${opencodeUrl}": expected an absolute http(s) URL`); } + const embeddingsModel = env.EMBEDDINGS_MODEL?.trim() || DEFAULT_EMBEDDINGS_MODEL; + const rawPreload = env.EMBEDDINGS_PRELOAD?.trim(); + const parsedPreload = rawPreload === undefined ? false : parseBoolean(rawPreload); + if (parsedPreload === undefined) { + throw new Error( + `invalid EMBEDDINGS_PRELOAD "${env.EMBEDDINGS_PRELOAD}": expected "true" or "false"`, + ); + } + const embeddingsPreload = parsedPreload; + return { host: env.HOST?.trim() || "127.0.0.1", port, opencodeUrl, + embeddingsModel, + embeddingsPreload, }; } diff --git a/src/main.test.ts b/src/main.test.ts index 5a102b0..7477e23 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,4 +1,21 @@ import { describe, expect, test } from "bun:test"; +import type { StartOptions } from "./main.ts"; +import { createRecordingPipeline } from "./testing/recordingPipeline.ts"; +import type { FeatureExtractor, FeatureExtractionPipeline } from "./services/embeddings.ts"; + +const stubPipelineExtractor: FeatureExtractor = async (inputs) => ({ + tolist: () => (Array.isArray(inputs) ? inputs : [inputs]).map(() => [0.5, 0.5, 0.5, 0.5]), +}); +stubPipelineExtractor.tokenizer = { + encode: (text: string) => Array.from({ length: text.split(" ").length }, () => 0), +}; + +const stubEmbeddingsPipeline: FeatureExtractionPipeline = async () => stubPipelineExtractor; + +const stubEmbeddedServer: StartOptions["createOpencodeServer"] = async () => ({ + url: "http://127.0.0.1:4096", + close: () => {}, +}); describe("src/main.ts", () => { test("boot() skips the server when not run as the entrypoint", async () => { @@ -15,14 +32,15 @@ describe("src/main.ts", () => { process.env.OPENCODE_URL = "http://localhost:4096"; try { const { boot } = await import("./main.ts"); - const server = await boot(true); - expect(server).not.toBeNull(); + const result = await boot(true); + expect(result).not.toBeNull(); + const server = result!.server; try { - expect(server!.port).toBeGreaterThan(0); - const response = await fetch(`http://127.0.0.1:${server!.port}/v1/does-not-exist`); + expect(server.port).toBeGreaterThan(0); + const response = await fetch(`http://127.0.0.1:${server.port}/v1/does-not-exist`); expect(response.status).toBe(404); } finally { - server!.stop(); + server.stop(); } } finally { if (previousPort === undefined) delete process.env.PORT; @@ -34,29 +52,106 @@ describe("src/main.ts", () => { } }); - test.serial("boot() embeds a mocked server for a whitespace-only OPENCODE_URL and closes it on stop", async () => { + test.serial( + "boot() embeds a mocked server for a whitespace-only OPENCODE_URL and closes it on stop", + async () => { + const previousPort = process.env.PORT; + const previousHost = process.env.HOST; + const previousOpencodeUrl = process.env.OPENCODE_URL; + let closeCalls = 0; + try { + process.env.PORT = "0"; + process.env.HOST = "127.0.0.1"; + process.env.OPENCODE_URL = " "; + const { boot } = await import("./main.ts"); + const result = await boot(true, { + createOpencodeServer: async () => ({ + url: "http://127.0.0.1:4096", + close: () => { + closeCalls += 1; + }, + }), + }); + expect(result).not.toBeNull(); + const server = result!.server; + expect(server.port).toBeGreaterThan(0); + server.stop(); + expect(closeCalls).toBe(1); + } finally { + if (previousPort === undefined) delete process.env.PORT; + else process.env.PORT = previousPort; + if (previousHost === undefined) delete process.env.HOST; + else process.env.HOST = previousHost; + if (previousOpencodeUrl === undefined) delete process.env.OPENCODE_URL; + else process.env.OPENCODE_URL = previousOpencodeUrl; + } + }, + ); + + test.serial( + "start() closes the embedded server and rethrows when a later step fails", + async () => { + const previousPort = process.env.PORT; + const previousHost = process.env.HOST; + const previousOpencodeUrl = process.env.OPENCODE_URL; + let closeCalls = 0; + try { + process.env.PORT = "0"; + process.env.HOST = "127.0.0.1"; + process.env.OPENCODE_URL = " "; + const { start } = await import("./main.ts"); + const error = await start({ + createOpencodeServer: async () => ({ + url: "invalid-url", + close: () => { + closeCalls += 1; + }, + }), + }).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect(closeCalls).toBe(1); + } finally { + if (previousPort === undefined) delete process.env.PORT; + else process.env.PORT = previousPort; + if (previousHost === undefined) delete process.env.HOST; + else process.env.HOST = previousHost; + if (previousOpencodeUrl === undefined) delete process.env.OPENCODE_URL; + else process.env.OPENCODE_URL = previousOpencodeUrl; + } + }, + ); + + test.serial("start() closes the Bun server when a later step fails", async () => { const previousPort = process.env.PORT; const previousHost = process.env.HOST; const previousOpencodeUrl = process.env.OPENCODE_URL; - let closeCalls = 0; + const originalServe = Bun.serve; + let stopCalls = 0; try { process.env.PORT = "0"; process.env.HOST = "127.0.0.1"; process.env.OPENCODE_URL = " "; - const { boot } = await import("./main.ts"); - const server = await boot(true, { - createOpencodeServer: async () => ({ - url: "http://127.0.0.1:4096", - close: () => { - closeCalls += 1; - }, + Bun.serve = ((options: Parameters[0]) => { + const server = originalServe(options); + const originalStop = server.stop; + server.stop = ((closeActiveConnections?: boolean) => { + stopCalls += 1; + return originalStop.call(server, closeActiveConnections); + }) as typeof server.stop; + return server; + }) as typeof Bun.serve; + const { start } = await import("./main.ts"); + expect( + start({ + createOpencodeServer: async () => ({ + url: "invalid-url", + close: () => {}, + }), }), - }); - expect(server).not.toBeNull(); - expect(server!.port).toBeGreaterThan(0); - server!.stop(); - expect(closeCalls).toBe(1); + ).rejects.toThrow(); + expect(stopCalls).toBe(1); } finally { + Bun.serve = originalServe; if (previousPort === undefined) delete process.env.PORT; else process.env.PORT = previousPort; if (previousHost === undefined) delete process.env.HOST; @@ -66,33 +161,85 @@ describe("src/main.ts", () => { } }); - test.serial("start() closes the embedded server and rethrows when a later step fails", async () => { + test.serial( + "start() preloads the embeddings model before exposing the server when EMBEDDINGS_PRELOAD is enabled", + async () => { + const previousPort = process.env.PORT; + const previousHost = process.env.HOST; + const previousModel = process.env.EMBEDDINGS_MODEL; + const previousPreload = process.env.EMBEDDINGS_PRELOAD; + process.env.PORT = "0"; + process.env.HOST = "127.0.0.1"; + process.env.EMBEDDINGS_MODEL = "test-model"; + process.env.EMBEDDINGS_PRELOAD = "true"; + try { + const recording = createRecordingPipeline(stubEmbeddingsPipeline); + const { start } = await import("./main.ts"); + const { server } = await start({ + createOpencodeServer: stubEmbeddedServer, + buildEmbeddingsPipeline: recording.pipeline, + }); + try { + expect(server.port).toBeGreaterThan(0); + expect(recording.calls).toHaveLength(1); + expect(recording.calls[0]).toEqual({ + task: "feature-extraction", + model: "test-model", + options: { dtype: "q8" }, + }); + } finally { + server.stop(); + } + } finally { + if (previousPort === undefined) delete process.env.PORT; + else process.env.PORT = previousPort; + if (previousHost === undefined) delete process.env.HOST; + else process.env.HOST = previousHost; + if (previousModel === undefined) delete process.env.EMBEDDINGS_MODEL; + else process.env.EMBEDDINGS_MODEL = previousModel; + if (previousPreload === undefined) delete process.env.EMBEDDINGS_PRELOAD; + else process.env.EMBEDDINGS_PRELOAD = previousPreload; + } + }, + ); + + test.serial("start() rejects and closes the embedded server when the preload fails", async () => { const previousPort = process.env.PORT; const previousHost = process.env.HOST; - const previousOpencodeUrl = process.env.OPENCODE_URL; + const previousModel = process.env.EMBEDDINGS_MODEL; + const previousPreload = process.env.EMBEDDINGS_PRELOAD; let closeCalls = 0; + process.env.PORT = "0"; + process.env.HOST = "127.0.0.1"; + process.env.EMBEDDINGS_MODEL = "test-model"; + process.env.EMBEDDINGS_PRELOAD = "true"; try { - process.env.PORT = "0"; - process.env.HOST = "127.0.0.1"; - process.env.OPENCODE_URL = " "; + const recording = createRecordingPipeline(async () => { + throw new Error("download failed"); + }); const { start } = await import("./main.ts"); const error = await start({ createOpencodeServer: async () => ({ - url: "invalid-url", + url: "http://127.0.0.1:4096", close: () => { closeCalls += 1; }, }), + buildEmbeddingsPipeline: recording.pipeline, }).catch((cause: unknown) => cause); expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("download failed"); + expect(recording.calls).toHaveLength(1); expect(closeCalls).toBe(1); } finally { if (previousPort === undefined) delete process.env.PORT; else process.env.PORT = previousPort; if (previousHost === undefined) delete process.env.HOST; else process.env.HOST = previousHost; - if (previousOpencodeUrl === undefined) delete process.env.OPENCODE_URL; - else process.env.OPENCODE_URL = previousOpencodeUrl; + if (previousModel === undefined) delete process.env.EMBEDDINGS_MODEL; + else process.env.EMBEDDINGS_MODEL = previousModel; + if (previousPreload === undefined) delete process.env.EMBEDDINGS_PRELOAD; + else process.env.EMBEDDINGS_PRELOAD = previousPreload; } }); }); diff --git a/src/main.ts b/src/main.ts index f7a973a..f215890 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,20 +3,30 @@ import { buildRouter } from "./app.ts"; import { loadConfig } from "./config.ts"; import { createOpencodeHttpClient } from "./opencode/client.ts"; import { createServer } from "./server.ts"; +import { + HuggingFaceEmbeddingsService, + type FeatureExtractionPipeline, +} from "./services/embeddings.ts"; import { OpencodeModelsService } from "./services/opencode/models.ts"; import { OpencodeChatCompletionsService } from "./services/opencode/service.ts"; import { displayAddress } from "./utils/net.ts"; export interface StartOptions { createOpencodeServer?: (options: ServerOptions) => Promise<{ url: string; close(): void }>; + buildEmbeddingsPipeline?: FeatureExtractionPipeline; } -export async function start(options: StartOptions = {}): Promise> { +export interface StartResult { + server: ReturnType; +} + +export async function start(options: StartOptions = {}): Promise { const config = loadConfig(); const createEmbeddedServer = options.createOpencodeServer ?? createOpencodeServer; let opencodeUrl = config.opencodeUrl; let opencodeServer: { url: string; close(): void } | null = null; + let server: ReturnType | null = null; if (!opencodeUrl) { opencodeServer = await createEmbeddedServer({ port: 0 }); @@ -28,8 +38,19 @@ export async function start(options: StartOptions = {}): Promise | null> { +): Promise { if (!main) { return null; } - return await start(options); + return start(options); } await boot(); diff --git a/src/openai/embeddings.test.ts b/src/openai/embeddings.test.ts new file mode 100644 index 0000000..3e2d037 --- /dev/null +++ b/src/openai/embeddings.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { embeddingsRequestSchema } from "./embeddings.ts"; + +describe("embeddingsRequestSchema input validation", () => { + test("accepts a non-empty string input", () => { + const result = embeddingsRequestSchema.parse({ + model: "model", + input: "hello", + }); + expect(result.input).toBe("hello"); + }); + + test("accepts a non-empty array of non-empty strings", () => { + const result = embeddingsRequestSchema.parse({ + model: "model", + input: ["hello", "world"], + }); + expect(result.input).toEqual(["hello", "world"]); + }); + + test("rejects an empty string input", () => { + expect(() => + embeddingsRequestSchema.parse({ + model: "model", + input: "", + }), + ).toThrow(); + }); + + test("rejects an empty array input", () => { + expect(() => + embeddingsRequestSchema.parse({ + model: "model", + input: [], + }), + ).toThrow(); + }); + + test("rejects an array containing an empty string", () => { + expect(() => + embeddingsRequestSchema.parse({ + model: "model", + input: ["hello", ""], + }), + ).toThrow(); + }); +}); diff --git a/src/openai/embeddings.ts b/src/openai/embeddings.ts new file mode 100644 index 0000000..ee3d094 --- /dev/null +++ b/src/openai/embeddings.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +export const embeddingsRequestSchema = z.object({ + model: z.string().min(1), + input: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]), + encoding_format: z.enum(["float", "base64"]).default("float"), + dimensions: z.number().int().positive().optional(), +}); + +export type EmbeddingsRequest = z.infer; + +export interface EmbeddingObject { + object: "embedding"; + index: number; + embedding: number[] | string; +} + +export interface EmbeddingUsage { + prompt_tokens: number; + total_tokens: number; +} + +export interface EmbeddingsList { + object: "list"; + data: EmbeddingObject[]; + model: string; + usage: EmbeddingUsage; +} diff --git a/src/routes/v1/embeddings.test.ts b/src/routes/v1/embeddings.test.ts new file mode 100644 index 0000000..91c8df5 --- /dev/null +++ b/src/routes/v1/embeddings.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { Router, type TimeoutConfigurableServer } from "../../router.ts"; +import type { EmbeddingsService } from "../../services/embeddings.ts"; +import type { EmbeddingsList, EmbeddingsRequest } from "../../openai/embeddings.ts"; +import type { ErrorBody } from "../../openai/error.ts"; +import { embeddingsHandler } from "./embeddings.ts"; + +class FakeEmbeddingsService implements EmbeddingsService { + request: EmbeddingsRequest | undefined; + + constructor( + private readonly result: () => Promise | EmbeddingsList, + readonly modelName: string = "test-model", + ) {} + + async create(request: EmbeddingsRequest): Promise { + this.request = request; + return this.result(); + } + + async preload(): Promise {} +} + +function handle( + request: Request, + service: EmbeddingsService, + server: TimeoutConfigurableServer = { timeout: () => {} }, +): Promise { + const router = new Router(); + router.register("POST", "/v1/embeddings", embeddingsHandler(service)); + return router.handle(request, server); +} + +describe("POST /v1/embeddings route", () => { + test("returns 400 for an invalid JSON body", async () => { + const service = new FakeEmbeddingsService(() => { + throw new Error("should not be called"); + }); + const response = await handle( + new Request("http://localhost/v1/embeddings", { + method: "POST", + body: "{", + }), + service, + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: { + message: "the request body is not valid JSON", + type: "invalid_request_error", + param: null, + code: null, + }, + }); + }); + + test("returns 400 when model or input are missing", async () => { + const service = new FakeEmbeddingsService(() => { + throw new Error("should not be called"); + }); + const response = await handle( + new Request("http://localhost/v1/embeddings", { + method: "POST", + body: JSON.stringify({ + encoding_format: "float", + }), + }), + service, + ); + + expect(response.status).toBe(400); + const body = (await response.json()) as ErrorBody; + expect(body.error.message).toContain("model"); + expect(body.error.message).toContain("input"); + }); + + test("returns 400 when the request model does not match the configured model", async () => { + const service = new FakeEmbeddingsService(() => { + throw new Error("should not be called"); + }, "Xenova/bge-small-en-v1.5"); + const response = await handle( + new Request("http://localhost/v1/embeddings", { + method: "POST", + body: JSON.stringify({ + model: "text-embedding-3-small", + input: "hello", + }), + }), + service, + ); + + expect(response.status).toBe(400); + const body = (await response.json()) as ErrorBody; + expect(body.error.message).toContain("text-embedding-3-small"); + expect(body.error.message).toContain("Xenova/bge-small-en-v1.5"); + }); + + test("returns 200 with the embeddings list and disables the request timeout", async () => { + const timeouts: number[] = []; + const server: TimeoutConfigurableServer = { + timeout: (_request, seconds) => timeouts.push(seconds), + }; + const mockOutput: EmbeddingsList = { + object: "list", + data: [ + { + object: "embedding", + index: 0, + embedding: [0.1, 0.2, 0.3], + }, + ], + model: "test-model", + usage: { + prompt_tokens: 5, + total_tokens: 5, + }, + }; + + const service = new FakeEmbeddingsService(() => mockOutput); + const response = await handle( + new Request("http://localhost/v1/embeddings", { + method: "POST", + body: JSON.stringify({ + model: "test-model", + input: "hello", + }), + }), + service, + server, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.json()).toEqual(mockOutput); + expect(timeouts).toEqual([0]); + expect(service.request).toEqual({ + model: "test-model", + input: "hello", + encoding_format: "float", + }); + }); +}); diff --git a/src/routes/v1/embeddings.ts b/src/routes/v1/embeddings.ts new file mode 100644 index 0000000..07946b3 --- /dev/null +++ b/src/routes/v1/embeddings.ts @@ -0,0 +1,25 @@ +import { BadRequestError } from "../../http/errors.ts"; +import { sendJson } from "../../http/json.ts"; +import { embeddingsRequestSchema } from "../../openai/embeddings.ts"; +import type { RouteHandler } from "../../router.ts"; +import type { EmbeddingsService } from "../../services/embeddings.ts"; +import { formatValidationError, parseJsonBody } from "../../utils/http.ts"; + +export function embeddingsHandler(service: EmbeddingsService): RouteHandler { + return async (request, server) => { + const parsed = embeddingsRequestSchema.safeParse(await parseJsonBody(request)); + if (!parsed.success) { + throw new BadRequestError(formatValidationError(parsed.error.issues)); + } + + if (parsed.data.model !== service.modelName) { + throw new BadRequestError( + `model "${parsed.data.model}" is not supported: configured embeddings model is "${service.modelName}"`, + ); + } + + server.timeout(request, 0); + const result = await service.create(parsed.data); + return sendJson(200, result); + }; +} diff --git a/src/server.test.ts b/src/server.test.ts index f43b094..9cdf7b5 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -8,7 +8,13 @@ describe("createServer", () => { const router = new Router(); router.register("GET", "/ping", () => sendJson(200, { ok: true })); const server = createServer( - { host: "127.0.0.1", port: 0, opencodeUrl: "http://localhost:4096" }, + { + host: "127.0.0.1", + port: 0, + opencodeUrl: "http://localhost:4096", + embeddingsModel: "Xenova/bge-small-en-v1.5", + embeddingsPreload: false, + }, router, ); try { diff --git a/src/services/embeddings.test.ts b/src/services/embeddings.test.ts new file mode 100644 index 0000000..d5a4b7b --- /dev/null +++ b/src/services/embeddings.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; +import { BadRequestError } from "../http/errors.ts"; +import { HuggingFaceEmbeddingsService, type FeatureExtractor } from "./embeddings.ts"; +import { createRecordingPipeline, type RecordingPipeline } from "../testing/recordingPipeline.ts"; + +// Stub the "feature-extraction" pipeline. It is injected via the constructor +// so the module under test always uses the real "@huggingface/transformers". +const stubTokenizerEncode = (text: string): number[] => { + return Array.from({ length: text.split(" ").length }, () => 0); +}; + +const stubExtractor: FeatureExtractor = async (inputs, _options) => { + // Return dummy embeddings (e.g., all 0.5, normalized) + const stubList = (Array.isArray(inputs) ? inputs : [inputs]).map(() => { + // Generate a vector of length 4: [0.5, 0.5, 0.5, 0.5] which L2 normalizes to [0.5, 0.5, 0.5, 0.5] + return [0.5, 0.5, 0.5, 0.5]; + }); + return { + tolist: () => stubList, + }; +}; +stubExtractor.tokenizer = { + encode: stubTokenizerEncode, +}; + +function makeService(modelName = "mock-model"): { + service: HuggingFaceEmbeddingsService; + recording: RecordingPipeline; +} { + const recording = createRecordingPipeline(async () => stubExtractor); + return { + service: new HuggingFaceEmbeddingsService(modelName, recording.pipeline), + recording, + }; +} + +describe("HuggingFaceEmbeddingsService", () => { + test("preload() initializes the shared extractor", async () => { + const { service, recording } = makeService(); + expect(service.modelName).toBe("mock-model"); + await service.preload(); + + expect(recording.calls).toHaveLength(1); + expect(recording.calls[0]).toEqual({ + task: "feature-extraction", + model: "mock-model", + options: { dtype: "q8" }, + }); + }); + + test("retries initialization after a failed first attempt", async () => { + const { service, recording } = makeService(); + let attempts = 0; + recording.setImplementation(async () => { + attempts++; + if (attempts === 1) throw new Error("model download failed"); + return stubExtractor; + }); + + expect(service.preload()).rejects.toThrow("model download failed"); + expect(service.preload()).resolves.toBeUndefined(); + expect(attempts).toBe(2); + }); + + test("concurrent creates share a single in-flight initialization", async () => { + const { service, recording } = makeService(); + const [first, second] = await Promise.all([ + service.create({ model: "mock-model", input: "first", encoding_format: "float" }), + service.create({ model: "mock-model", input: "second", encoding_format: "float" }), + ]); + + expect(first.data).toHaveLength(1); + expect(second.data).toHaveLength(1); + expect(recording.calls).toHaveLength(1); + }); + + test("successfully processes string input and computes correct token counts", async () => { + const { service } = makeService(); + const result = await service.create({ + model: "mock-model", + input: "hello world", + encoding_format: "float", + }); + + expect(result.object).toBe("list"); + expect(result.model).toBe("mock-model"); + expect(result.data).toHaveLength(1); + expect(result.data[0]!.index).toBe(0); + expect(result.data[0]!.object).toBe("embedding"); + // "hello world" has 2 words -> 2 tokens + expect(result.usage.prompt_tokens).toBe(2); + expect(result.usage.total_tokens).toBe(2); + expect(result.data[0]!.embedding).toEqual([0.5, 0.5, 0.5, 0.5]); + }); + + test("processes array of strings input", async () => { + const { service } = makeService(); + const result = await service.create({ + model: "mock-model", + input: ["one", "two three"], + encoding_format: "float", + }); + + expect(result.data).toHaveLength(2); + expect(result.data[0]!.index).toBe(0); + expect(result.data[1]!.index).toBe(1); + // "one" (1) + "two three" (2) = 3 tokens + expect(result.usage.prompt_tokens).toBe(3); + }); + + test("supports dimensions truncation and L2 re-normalization", async () => { + const { service } = makeService(); + const result = await service.create({ + model: "mock-model", + input: "truncate me", + encoding_format: "float", + dimensions: 2, + }); + + // Original [0.5, 0.5, 0.5, 0.5] sliced to length 2: [0.5, 0.5] + // L2 normalized: [1/sqrt(2), 1/sqrt(2)] = [0.7071067811865475, 0.7071067811865475] + const vec = result.data[0]!.embedding as number[]; + expect(vec).toHaveLength(2); + expect(vec[0]!).toBeCloseTo(Math.SQRT1_2, 4); + expect(vec[1]!).toBeCloseTo(Math.SQRT1_2, 4); + }); + + test("rejects dimensions that exceed the native embedding size", async () => { + const { service } = makeService(); + expect( + service.create({ + model: "mock-model", + input: "too small", + encoding_format: "float", + dimensions: 6, + }), + ).rejects.toThrow(BadRequestError); + }); + + test("supports base64 encoding format", async () => { + const { service } = makeService(); + const result = await service.create({ + model: "mock-model", + input: "encode me", + encoding_format: "base64", + }); + + expect(typeof result.data[0]!.embedding).toBe("string"); + // Convert base64 back to Float32Array to check values + const buffer = Buffer.from(result.data[0]!.embedding as string, "base64"); + const arr = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.length / 4); + expect(Array.from(arr)).toEqual([0.5, 0.5, 0.5, 0.5]); + }); +}); diff --git a/src/services/embeddings.ts b/src/services/embeddings.ts new file mode 100644 index 0000000..91d795d --- /dev/null +++ b/src/services/embeddings.ts @@ -0,0 +1,145 @@ +import { pipeline } from "@huggingface/transformers"; +import { BadRequestError } from "../http/errors.ts"; +import type { EmbeddingsRequest, EmbeddingsList, EmbeddingObject } from "../openai/embeddings.ts"; + +export interface EmbeddingsService { + readonly modelName: string; + create(request: EmbeddingsRequest): Promise; + preload(): Promise; +} + +export interface FeatureExtractorOutput { + tolist(): number[][]; +} + +/** + * The subset of the transformers "feature-extraction" pipeline API used by + * this service. Stubs in tests can implement this shape without pulling in + * the concrete pipeline classes from `@huggingface/transformers`. + */ +export interface FeatureExtractor { + ( + texts: string | string[], + options?: { + pooling?: "none" | "mean" | "cls" | "first_token" | "eos" | "last_token"; + normalize?: boolean; + }, + ): Promise; + tokenizer: { encode(text: string): number[] }; +} + +/** + * Builds the feature-extraction pipeline. Injected so tests can stub the + * model without patching the module registry (which would leak across files). + */ +export type FeatureExtractionPipeline = ( + task: string, + model: string, + options: { dtype: string }, +) => Promise; + +function l2Normalize(vector: number[]): number[] { + const sumSq = vector.reduce((acc, val) => acc + val * val, 0); + const magnitude = Math.sqrt(sumSq); + if (magnitude === 0) return vector; + return vector.map((v) => v / magnitude); +} + +function float32ArrayToBase64(vector: number[]): string { + const floatArray = new Float32Array(vector); + const buffer = Buffer.from(floatArray.buffer, floatArray.byteOffset, floatArray.byteLength); + return buffer.toString("base64"); +} + +export class HuggingFaceEmbeddingsService implements EmbeddingsService { + readonly #modelName: string; + readonly #buildPipeline: FeatureExtractionPipeline; + #extractor: FeatureExtractor | null = null; + #initPromise: Promise | null = null; + + constructor( + modelName: string, + buildPipeline: FeatureExtractionPipeline = pipeline as FeatureExtractionPipeline, + ) { + this.#modelName = modelName; + this.#buildPipeline = buildPipeline; + } + + get modelName(): string { + return this.#modelName; + } + + async #getExtractor(): Promise { + if (this.#extractor) { + return this.#extractor; + } + if (!this.#initPromise) { + this.#initPromise = this.#buildPipeline("feature-extraction", this.#modelName, { + dtype: "q8", + }); + } + + try { + const extractor = await this.#initPromise; + this.#extractor = extractor; + this.#initPromise = null; + return extractor; + } catch (error) { + this.#initPromise = null; + throw error; + } + } + + async preload(): Promise { + await this.#getExtractor(); + } + + async create(request: EmbeddingsRequest): Promise { + const extractor = await this.#getExtractor(); + const inputs = Array.isArray(request.input) ? request.input : [request.input]; + + // Compute token count + let prompt_tokens = 0; + for (const text of inputs) { + const tokens = extractor.tokenizer.encode(text); + prompt_tokens += tokens.length; + } + + // Run inference + const output = await extractor(inputs, { pooling: "mean", normalize: true }); + + // output can be a Tensor. We convert it to a nested JavaScript array + const rawEmbeddings: number[][] = output.tolist(); + + const data: EmbeddingObject[] = rawEmbeddings.map((rawVec, index) => { + let vec = rawVec; + if (request.dimensions !== undefined) { + if (request.dimensions > rawVec.length) { + throw new BadRequestError( + `requested ${request.dimensions} dimensions but the "${this.modelName}" model produces ${rawVec.length} dimensions`, + ); + } + vec = rawVec.slice(0, request.dimensions); + vec = l2Normalize(vec); + } + + const embeddingVal = request.encoding_format === "base64" ? float32ArrayToBase64(vec) : vec; + + return { + object: "embedding", + index, + embedding: embeddingVal, + }; + }); + + return { + object: "list", + data, + model: this.modelName, + usage: { + prompt_tokens, + total_tokens: prompt_tokens, + }, + }; + } +} diff --git a/src/testing/recordingPipeline.ts b/src/testing/recordingPipeline.ts new file mode 100644 index 0000000..fdc55b2 --- /dev/null +++ b/src/testing/recordingPipeline.ts @@ -0,0 +1,31 @@ +import type { FeatureExtractionPipeline } from "../services/embeddings.ts"; + +export interface PipelineCall { + task: string; + model: string; + options: { dtype: string }; +} + +export interface RecordingPipeline { + readonly pipeline: FeatureExtractionPipeline; + readonly calls: readonly PipelineCall[]; + setImplementation(impl: FeatureExtractionPipeline): void; +} + +export function createRecordingPipeline(initialImpl: FeatureExtractionPipeline): RecordingPipeline { + const calls: PipelineCall[] = []; + let implementation = initialImpl; + + const pipeline: FeatureExtractionPipeline = (task, model, options) => { + calls.push({ task, model, options }); + return implementation(task, model, options); + }; + + return { + pipeline, + calls, + setImplementation(impl) { + implementation = impl; + }, + }; +} diff --git a/src/utils/parse.test.ts b/src/utils/parse.test.ts new file mode 100644 index 0000000..fae7f86 --- /dev/null +++ b/src/utils/parse.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { parseBoolean } from "./parse.ts"; + +describe("parseBoolean", () => { + test("reads true and false", () => { + expect(parseBoolean("true")).toBe(true); + expect(parseBoolean("false")).toBe(false); + }); + + test("is case-insensitive", () => { + expect(parseBoolean("TRUE")).toBe(true); + expect(parseBoolean("True")).toBe(true); + expect(parseBoolean("FALSE")).toBe(false); + expect(parseBoolean("False")).toBe(false); + }); + + test("trims surrounding whitespace", () => { + expect(parseBoolean(" true ")).toBe(true); + expect(parseBoolean(" false ")).toBe(false); + }); + + test("returns undefined for non-boolean values", () => { + for (const value of ["", " ", "1", "0", "yes", "no", "on", "y", "n", " random "]) { + expect(parseBoolean(value)).toBeUndefined(); + } + }); +}); diff --git a/src/utils/parse.ts b/src/utils/parse.ts new file mode 100644 index 0000000..012d429 --- /dev/null +++ b/src/utils/parse.ts @@ -0,0 +1,10 @@ +export function parseBoolean(value: string): boolean | undefined { + switch (value.trim().toLowerCase()) { + case "true": + return true; + case "false": + return false; + default: + return undefined; + } +} diff --git a/test/e2e/chat-completions.test.ts b/test/e2e/chat-completions.test.ts index 5352c4f..b4c716b 100644 --- a/test/e2e/chat-completions.test.ts +++ b/test/e2e/chat-completions.test.ts @@ -8,7 +8,7 @@ import { createOpencodeHttpClient } from "../../src/opencode/client.ts"; import { OpencodeChatCompletionsService } from "../../src/services/opencode/service.ts"; import { E2E_MODEL, startOpencode, type TestOpencode } from "./support/opencode.ts"; import { postJson } from "./support/requests.ts"; -import { startServer, stubModels } from "./support/server.ts"; +import { startServer, stubEmbeddings, stubModels } from "./support/server.ts"; const PROMPT = "Reply with exactly one word: pong"; function completionBody(extra: Partial = {}): RequestInit { @@ -41,6 +41,7 @@ describe("e2e POST /v1/chat/completions (real opencode server)", () => { const proxy = startServer({ chatCompletions: new OpencodeChatCompletionsService(createOpencodeHttpClient(opencode.url)), models: stubModels, + embeddings: stubEmbeddings, }); return proxy.baseUrl; } @@ -116,6 +117,7 @@ describe("e2e POST /v1/chat/completions (real opencode server)", () => { const baseUrl = startServer({ chatCompletions: new OpencodeChatCompletionsService(createOpencodeHttpClient(deadUrl)), models: stubModels, + embeddings: stubEmbeddings, }).baseUrl; const response = await fetch(`${baseUrl}/v1/chat/completions`, completionBody()); diff --git a/test/e2e/embeddings.test.ts b/test/e2e/embeddings.test.ts new file mode 100644 index 0000000..4cb5857 --- /dev/null +++ b/test/e2e/embeddings.test.ts @@ -0,0 +1,152 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import type { EmbeddingsList } from "../../src/openai/embeddings.ts"; +import { HuggingFaceEmbeddingsService } from "../../src/services/embeddings.ts"; +import { startServer, stubChatCompletions, stubModels } from "./support/server.ts"; +import { postJson } from "./support/requests.ts"; + +/** + * The real embedding model downloaded from Hugging Face Hub on first run. + * Override with E2E_EMBEDDINGS_MODEL to test against a different model, + * and with E2E_EMBEDDINGS_DIMENSIONS to match that model's output size. + */ +const EMBEDDING_MODEL = process.env.E2E_EMBEDDINGS_MODEL ?? "Xenova/bge-small-en-v1.5"; +// Must match the model above; override along with E2E_EMBEDDINGS_MODEL. +const EMBEDDING_DIMENSIONS = Number(process.env.E2E_EMBEDDINGS_DIMENSIONS ?? 384); + +function cosineSimilarity(a: number[], b: number[]): number { + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i]! * b[i]!; + normA += a[i]! * a[i]!; + normB += b[i]! * b[i]!; + } + return dot / Math.sqrt(normA * normB); +} + +describe("e2e POST /v1/embeddings (real HuggingFace model)", () => { + let embeddings: HuggingFaceEmbeddingsService; + + beforeAll(async () => { + embeddings = new HuggingFaceEmbeddingsService(EMBEDDING_MODEL); + await embeddings.preload(); + }, 180_000); + + function proxyBaseUrl(): string { + return startServer({ + chatCompletions: stubChatCompletions, + models: stubModels, + embeddings, + }).baseUrl; + } + + async function embed(input: string | string[]): Promise { + const response = await fetch( + `${proxyBaseUrl()}/v1/embeddings`, + postJson({ model: EMBEDDING_MODEL, input }), + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + const body = (await response.json()) as EmbeddingsList; + expect(body.object).toBe("list"); + expect(body.model).toBe(EMBEDDING_MODEL); + return body.data.map((item) => item.embedding as number[]); + } + + test("serves real embeddings for a single string input", async () => { + const response = await fetch( + `${proxyBaseUrl()}/v1/embeddings`, + postJson({ model: EMBEDDING_MODEL, input: "hello world" }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const body = (await response.json()) as EmbeddingsList; + expect(body.object).toBe("list"); + expect(body.model).toBe(EMBEDDING_MODEL); + expect(body.data).toHaveLength(1); + expect(body.data[0]!.index).toBe(0); + expect(body.data[0]!.object).toBe("embedding"); + + const vector = body.data[0]!.embedding as number[]; + expect(vector).toHaveLength(EMBEDDING_DIMENSIONS); + expect(vector.every((value) => Number.isFinite(value))).toBe(true); + + expect(body.usage.prompt_tokens).toBeGreaterThan(0); + expect(body.usage.total_tokens).toBe(body.usage.prompt_tokens); + }); + + test("serves real embeddings for batched array inputs", async () => { + const response = await fetch( + `${proxyBaseUrl()}/v1/embeddings`, + postJson({ model: EMBEDDING_MODEL, input: ["foo", "bar baz"] }), + ); + + expect(response.status).toBe(200); + + const body = (await response.json()) as EmbeddingsList; + expect(body.data).toHaveLength(2); + expect(body.data[0]!.index).toBe(0); + expect(body.data[1]!.index).toBe(1); + for (const item of body.data) { + const vector = item.embedding as number[]; + expect(vector).toHaveLength(EMBEDDING_DIMENSIONS); + expect(vector.every((value) => Number.isFinite(value))).toBe(true); + } + expect(body.usage.prompt_tokens).toBeGreaterThan(0); + expect(body.usage.total_tokens).toBe(body.usage.prompt_tokens); + }); + + test("embeds deterministically and ranks similar texts closer than unrelated ones", async () => { + const similar = "the quick brown fox jumps over the lazy dog"; + const nearDuplicate = "a quick brown fox leaps over the sleeping dog"; + const unrelated = "quantum chromodynamics in lattice gauge theory"; + + const [first, second] = await Promise.all([embed(similar), embed(similar)]); + expect(first[0]!).toEqual(second[0]!); + + const [fox, sleeping, quantum] = (await embed([similar, nearDuplicate, unrelated])) as [ + number[], + number[], + number[], + ]; + const similarCosine = cosineSimilarity(fox, sleeping); + const unrelatedCosine = cosineSimilarity(fox, quantum); + expect(similarCosine).toBeGreaterThan(0.5); + expect(unrelatedCosine).toBeLessThan(0.5); + expect(similarCosine).toBeGreaterThan(unrelatedCosine); + }); + + test("truncates and re-normalizes to requested dimensions", async () => { + const response = await fetch( + `${proxyBaseUrl()}/v1/embeddings`, + postJson({ model: EMBEDDING_MODEL, input: "l2 normalized", dimensions: 2 }), + ); + + expect(response.status).toBe(200); + + const body = (await response.json()) as EmbeddingsList; + const vec = body.data[0]!.embedding as number[]; + expect(vec).toHaveLength(2); + const magnitude = Math.hypot(vec[0]!, vec[1]!); + expect(magnitude).toBeCloseTo(1, 3); + }); + + test("supports base64 encoding format", async () => { + const response = await fetch( + `${proxyBaseUrl()}/v1/embeddings`, + postJson({ model: EMBEDDING_MODEL, input: "base64 output", encoding_format: "base64" }), + ); + + expect(response.status).toBe(200); + + const body = (await response.json()) as EmbeddingsList; + expect(typeof body.data[0]!.embedding).toBe("string"); + const buffer = Buffer.from(body.data[0]!.embedding as string, "base64"); + const arr = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.length / 4); + expect(arr.length).toBe(EMBEDDING_DIMENSIONS); + expect(Array.from(arr).every((value) => Number.isFinite(value))).toBe(true); + }); +}); diff --git a/test/e2e/main.embedded.test.ts b/test/e2e/main.embedded.test.ts index c0dd83d..7acc17a 100644 --- a/test/e2e/main.embedded.test.ts +++ b/test/e2e/main.embedded.test.ts @@ -14,17 +14,18 @@ describe("src/main.ts", () => { try { let embedded: { url: string; close(): void } | null = null; const { boot } = await import("../../src/main.ts"); - const server = await boot(true, { + const result = await boot(true, { createOpencodeServer: async (options: ServerOptions) => { const real = await createOpencodeServer(options); embedded = real; return real; }, }); - expect(server).not.toBeNull(); + expect(result).not.toBeNull(); expect(embedded).not.toBeNull(); - expect(server!.port).toBeGreaterThan(0); - server!.stop(); + const server = result!.server; + expect(server.port).toBeGreaterThan(0); + server.stop(); await expectConnectionClosed(embedded!.url); } finally { if (previousPort === undefined) delete process.env.PORT; diff --git a/test/e2e/models.test.ts b/test/e2e/models.test.ts index 44bf411..f9df9a2 100644 --- a/test/e2e/models.test.ts +++ b/test/e2e/models.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { createOpencodeHttpClient } from "../../src/opencode/client.ts"; import { OpencodeModelsService } from "../../src/services/opencode/models.ts"; import { E2E_MODEL, startOpencode, type TestOpencode } from "./support/opencode.ts"; -import { startServer, stubChatCompletions } from "./support/server.ts"; +import { startServer, stubChatCompletions, stubEmbeddings } from "./support/server.ts"; describe("e2e GET /v1/models (real opencode server)", () => { let opencode: TestOpencode; @@ -19,6 +19,7 @@ describe("e2e GET /v1/models (real opencode server)", () => { const proxy = startServer({ chatCompletions: stubChatCompletions, models: new OpencodeModelsService(createOpencodeHttpClient(opencode.url)), + embeddings: stubEmbeddings, }); return proxy.baseUrl; } @@ -47,6 +48,7 @@ describe("e2e GET /v1/models (real opencode server)", () => { const baseUrl = startServer({ chatCompletions: stubChatCompletions, models: new OpencodeModelsService(createOpencodeHttpClient(deadUrl)), + embeddings: stubEmbeddings, }).baseUrl; const response = await fetch(`${baseUrl}/v1/models`); diff --git a/test/e2e/support/server.test.ts b/test/e2e/support/server.test.ts index add0e8a..5db13bd 100644 --- a/test/e2e/support/server.test.ts +++ b/test/e2e/support/server.test.ts @@ -1,10 +1,14 @@ import { describe, expect, test } from "bun:test"; import { postJson } from "./requests.ts"; -import { startServer, stubChatCompletions, stubModels } from "./server.ts"; +import { startServer, stubChatCompletions, stubEmbeddings, stubModels } from "./server.ts"; describe("startServer service stubs", () => { test("fails loudly when an unexpected service is exercised", async () => { - const server = startServer({ chatCompletions: stubChatCompletions, models: stubModels }); + const server = startServer({ + chatCompletions: stubChatCompletions, + models: stubModels, + embeddings: stubEmbeddings, + }); expect(server.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); const chatResponse = await fetch( @@ -16,4 +20,23 @@ describe("startServer service stubs", () => { const modelsResponse = await fetch(`${server.baseUrl}/v1/models`); expect(modelsResponse.status).toBe(500); }); + + test("fails loudly when the embeddings service is exercised", async () => { + const server = startServer({ + chatCompletions: stubChatCompletions, + models: stubModels, + embeddings: stubEmbeddings, + }); + expect(server.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + + const embeddingsResponse = await fetch( + `${server.baseUrl}/v1/embeddings`, + postJson({ model: "Xenova/bge-small-en-v1.5", input: "hi" }), + ); + expect(embeddingsResponse.status).toBe(500); + }); + + test("stubEmbeddings.preload() resolves without errors", async () => { + await stubEmbeddings.preload(); + }); }); diff --git a/test/e2e/support/server.ts b/test/e2e/support/server.ts index 9faf8ee..8b883ab 100644 --- a/test/e2e/support/server.ts +++ b/test/e2e/support/server.ts @@ -2,6 +2,7 @@ import { afterEach } from "bun:test"; import { buildRouter } from "../../../src/app.ts"; import { createServer } from "../../../src/server.ts"; import type { ChatCompletionsService } from "../../../src/services/chat-completions.ts"; +import type { EmbeddingsService } from "../../../src/services/embeddings.ts"; import type { ModelsService } from "../../../src/services/models.ts"; export interface TestServer { @@ -12,6 +13,7 @@ export interface TestServer { export interface StartServerOptions { chatCompletions: ChatCompletionsService; models: ModelsService; + embeddings: EmbeddingsService; } function unusedError(name: string): never { @@ -30,6 +32,14 @@ export const stubModels: ModelsService = { }, }; +export const stubEmbeddings: EmbeddingsService = { + modelName: "Xenova/bge-small-en-v1.5", + create: async () => { + throw unusedError("embeddings"); + }, + preload: async () => {}, +}; + const runningServers: TestServer[] = []; afterEach(() => { @@ -43,9 +53,15 @@ afterEach(() => { * ephemeral port and registers it for automatic shutdown after each test. */ export function startServer(options: StartServerOptions): TestServer { - const router = buildRouter(options.chatCompletions, options.models); + const router = buildRouter(options.chatCompletions, options.models, options.embeddings); const server = createServer( - { host: "127.0.0.1", port: 0, opencodeUrl: "http://localhost:4096" }, + { + host: "127.0.0.1", + port: 0, + opencodeUrl: "http://localhost:4096", + embeddingsModel: "Xenova/bge-small-en-v1.5", + embeddingsPreload: false, + }, router, ); const testServer: TestServer = {