From c7de5ed8b05ed4d7444d8b461702ef06a95e431f Mon Sep 17 00:00:00 2001 From: Arvind Arikatla Date: Sun, 16 Aug 2026 21:53:56 -0700 Subject: [PATCH] feat: migrate AI harness to Vercel AI SDK with reliability and context fixes Replace the five hand-written provider classes with one engine (server/ai/engine.js) built on the Vercel AI SDK v7. All model calls now go through runText, runStructured, and chatStream. Fixes: - Remove sampling parameters everywhere (temperature broke Claude Opus 4.8) - Stop injecting the full flashcard/guide database into the system prompt; the model now retrieves notes just-in-time via search tools - Drop the end-of-life @google/generative-ai SDK and the per-request Gemini explicit-cache creation (implicit caching covers it) - Add retries with backoff, per-call timeouts, and client-disconnect abort propagation on every model call - Cap tool-calling loops at 8 steps - Stream all providers through one path with SSE heartbeats - Validate structured output with Zod against provider-native JSON modes; raise a clear error on truncation instead of a JSON-parse failure - Fail clearly on unknown model IDs instead of silently routing to Gemini - Log model, token usage, finish reason, and duration for every call - Send real message arrays in non-streaming chat instead of flattened text Net deletion of ~1,300 lines. Adds engine routing tests; suite is 167 passing. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- docs/AGENTS.md | 2 +- package-lock.json | 635 +++++------------- package.json | 9 +- server/__tests__/engine.test.js | 94 +++ server/__tests__/openai_compatible.test.js | 262 -------- server/ai/engine.js | 215 ++++++ server/providers/base.js | 249 ------- server/providers/catalog.js | 2 +- server/providers/claude.js | 314 --------- server/providers/custom.js | 65 -- server/providers/defs.js | 234 +++++++ server/providers/gemini.js | 304 --------- server/providers/index.js | 285 +++----- server/providers/openai.js | 56 -- server/providers/openai_compatible.js | 407 ------------ server/routes/chat.js | 727 +++++++++------------ server/routes/config.js | 5 +- server/routes/endpoints.js | 8 +- server/utils/embeddings.js | 85 +-- 20 files changed, 1182 insertions(+), 2778 deletions(-) create mode 100644 server/__tests__/engine.test.js delete mode 100644 server/__tests__/openai_compatible.test.js create mode 100644 server/ai/engine.js delete mode 100644 server/providers/base.js delete mode 100644 server/providers/claude.js delete mode 100644 server/providers/custom.js create mode 100644 server/providers/defs.js delete mode 100644 server/providers/gemini.js delete mode 100644 server/providers/openai.js delete mode 100644 server/providers/openai_compatible.js diff --git a/README.md b/README.md index 143573e..443c42c 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ All user data lives in a single SQLite file β€” back it up by copying that file. ## πŸ› οΈ Tech Stack -React 19 Β· Vite Β· React Router Β· Zustand Β· Vanilla CSS Β· Node.js Β· Express Β· SQLite (`better-sqlite3`) Β· Google Gemini API Β· Docker +React 19 Β· Vite Β· React Router Β· Zustand Β· Vanilla CSS Β· Node.js Β· Express Β· SQLite (`better-sqlite3`) Β· Vercel AI SDK (Gemini / Claude / OpenAI / BYOM) Β· Docker --- diff --git a/docs/AGENTS.md b/docs/AGENTS.md index b51c76b..a8094ad 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -23,7 +23,7 @@ | Icons | `lucide-react` only | Consistent icon set | | Backend | Node.js + Express | Simple, matches dev experience | | Database | SQLite (`better-sqlite3`) | Self-hostable, zero-config | -| AI | Google Gemini API | Via `@google/generative-ai` SDK | +| AI | Vercel AI SDK (`ai`) | Multi-provider: Gemini, Claude, OpenAI, and custom OpenAI-compatible endpoints | --- diff --git a/package-lock.json b/package-lock.json index c9710a2..b95a445 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "toolbox", "version": "1.3.2", "dependencies": { - "@anthropic-ai/sdk": "^0.107.0", - "@google/genai": "^2.8.0", - "@google/generative-ai": "^0.24.1", + "@ai-sdk/anthropic": "^4.0.39", + "@ai-sdk/google": "^4.0.44", + "@ai-sdk/openai": "^4.0.42", + "@ai-sdk/openai-compatible": "^3.0.30", + "ai": "^7.0.66", "better-sqlite3": "^12.10.0", "cors": "^2.8.6", "dotenv": "^17.4.2", @@ -28,6 +30,7 @@ "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "uuid": "^14.0.0", + "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { @@ -55,6 +58,118 @@ "dev": true, "license": "MIT" }, + "node_modules/@ai-sdk/anthropic": { + "version": "4.0.39", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-4.0.39.tgz", + "integrity": "sha512-JAMGtYeEuaBzqbsPO4fkho6vQyNoVhsHASM4o59wmJRU6Vh7prjOp490Kmc7YQTY+ioU1/xYzXvWOtxZBup0Xw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.52", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.52.tgz", + "integrity": "sha512-SXUM8jzzuTUJRq+EOgPd5to6DSx0EKslVn+IVZHbUEX6k/3vCPNrvjckbK26HnNxHU/STxm+zTSJteqrO+7Z0w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "4.0.44", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-4.0.44.tgz", + "integrity": "sha512-bmRTDg06jQD+eX8nf214pET9+Oe8O1+lUIRGbWsGXj9IN2UJkpl1O1x7cvtiboyTtKSLvSRdVtItUfSl8sQ2GA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "4.0.42", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.42.tgz", + "integrity": "sha512-ZxDca6jJalYuXrIGVrw6dnkpz1Io9AWy+/b/wVWIbjigHCbd+zWLpPi8NnK0OFU+U3YCpP+KWfUvEnG5pFhltA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "3.0.30", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-3.0.30.tgz", + "integrity": "sha512-BB35G4fS/Ey5OHbWrVLxRLX1gkTlO+9I4YhlmdH1skNVoPaFXZlR6bQ+1C76d/ug7O6ocbIw4qcd2G4+GPdWEA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.7.tgz", + "integrity": "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "5.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.27.tgz", + "integrity": "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.28.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", @@ -68,27 +183,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.107.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.107.0.tgz", - "integrity": "sha512-RWDWyvIeZnatUTzyX8+ayFzAqqLyoDHKnDEODFyW8H89zH+qEsh5h6XAmnbHY5DCoa58o3rjuNe3F3Hg851ayA==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1", - "standardwebhooks": "^1.0.0" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -734,39 +828,6 @@ } } }, - "node_modules/@google/genai": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.8.0.tgz", - "integrity": "sha512-pc2ayxqO5+O7AvnHBqpNHIk7PAZkHZgL31tbyx0gJZBSS9qPYiQoqwK7oYOw/ePmG6QY4EMSu+304vD5QlhXAw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "node_modules/@google/generative-ai": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", - "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -945,63 +1006,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause" - }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -1284,17 +1288,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@testing-library/dom": { @@ -1737,15 +1734,6 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -1771,12 +1759,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1806,6 +1788,15 @@ "d3-transition": "^3.0.1" } }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -1967,6 +1958,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2003,13 +2000,21 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", + "node_modules/ai": { + "version": "7.0.66", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.66.tgz", + "integrity": "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.52", + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" + }, "engines": { - "node": ">= 14" + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/ajv": { @@ -2152,15 +2157,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -2276,12 +2272,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3118,15 +3108,6 @@ "lodash-es": "^4.17.21" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -3307,15 +3288,6 @@ "node": ">= 0.4" } }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3654,6 +3626,15 @@ "node": ">= 0.6" } }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -3752,12 +3733,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-sha256": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense" - }, "node_modules/fault": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", @@ -3789,29 +3764,6 @@ } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/fflate": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", @@ -3905,18 +3857,6 @@ "node": ">=0.4.x" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3965,34 +3905,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gaxios": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", - "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4095,32 +4007,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/google-auth-library": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz", - "integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4430,19 +4316,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -4731,15 +4604,6 @@ "node": ">=6" } }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4747,18 +4611,11 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" }, "node_modules/json-schema-traverse": { "version": "0.4.1", @@ -4787,27 +4644,6 @@ "node": ">=6" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/katex": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", @@ -5154,12 +4990,6 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -6386,44 +6216,6 @@ "node": ">=10" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/node-releases": { "version": "2.0.47", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", @@ -6540,19 +6332,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -6811,29 +6590,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/protobufjs": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", - "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "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" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7206,15 +6962,6 @@ "node": ">=0.10.0" } }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -7613,16 +7360,6 @@ "dev": true, "license": "MIT" }, - "node_modules/standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", - "license": "MIT", - "dependencies": { - "@stablelib/base64": "^1.0.0", - "fast-sha256": "^1.3.0" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7928,12 +7665,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, "node_modules/ts-dedent": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", @@ -8007,21 +7738,14 @@ } }, "node_modules/undici": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", - "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", - "dev": true, + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" } }, - "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "license": "MIT" - }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -8448,15 +8172,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -8559,27 +8274,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -8659,7 +8353,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 2fab981..9b90fe5 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,11 @@ "preview": "vite preview" }, "dependencies": { - "@anthropic-ai/sdk": "^0.107.0", - "@google/genai": "^2.8.0", - "@google/generative-ai": "^0.24.1", + "@ai-sdk/anthropic": "^4.0.39", + "@ai-sdk/google": "^4.0.44", + "@ai-sdk/openai": "^4.0.42", + "@ai-sdk/openai-compatible": "^3.0.30", + "ai": "^7.0.66", "better-sqlite3": "^12.10.0", "cors": "^2.8.6", "dotenv": "^17.4.2", @@ -35,6 +37,7 @@ "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "uuid": "^14.0.0", + "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/server/__tests__/engine.test.js b/server/__tests__/engine.test.js new file mode 100644 index 0000000..690107a --- /dev/null +++ b/server/__tests__/engine.test.js @@ -0,0 +1,94 @@ +/** + * Tests for the AI engine's model routing and the custom-endpoint + * model ID namespace. The tests run against a throwaway SQLite + * database (DB_PATH is set before any server module loads). + */ +import { describe, it, expect } from 'vitest' +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import path from 'path' + +process.env.DB_PATH = path.join(mkdtempSync(path.join(tmpdir(), 'toolbox-test-')), 'test.db') + +const { customModelId, parseCustomModelId, DEFAULT_MODEL_ID } = await import('../providers/defs.js') +const { resolveModel } = await import('../ai/engine.js') +const { getProviderIdForModel } = await import('../providers/index.js') +const { default: db } = await import('../db.js') + +describe('custom model ID namespace', () => { + it('round-trips endpoint and model IDs', () => { + const id = customModelId('ep-1', 'llama3:8b') + expect(id).toBe('custom:ep-1:llama3:8b') + expect(parseCustomModelId(id)).toEqual({ endpointId: 'ep-1', upstreamModelId: 'llama3:8b' }) + }) + + it('rejects non-custom and malformed IDs', () => { + expect(parseCustomModelId('gemini-3.5-flash')).toBeNull() + expect(parseCustomModelId('custom:only-endpoint')).toBeNull() + expect(parseCustomModelId(null)).toBeNull() + }) +}) + +describe('getProviderIdForModel', () => { + it('matches static catalog entries exactly', () => { + expect(getProviderIdForModel('gemini-3.5-flash')).toBe('gemini') + expect(getProviderIdForModel('claude-sonnet-4-6')).toBe('claude') + expect(getProviderIdForModel('gpt-5.6-sol')).toBe('openai') + }) + + it('infers the provider from the model ID namespace', () => { + expect(getProviderIdForModel('gemini-99-ultra')).toBe('gemini') + expect(getProviderIdForModel('gemma-3-27b')).toBe('gemini') + expect(getProviderIdForModel('claude-future-9')).toBe('claude') + expect(getProviderIdForModel('gpt-99')).toBe('openai') + expect(getProviderIdForModel('o4-mini')).toBe('openai') + }) + + it('returns null for unknown models instead of guessing', () => { + expect(getProviderIdForModel('llama3:8b')).toBeNull() + expect(getProviderIdForModel('totally-unknown')).toBeNull() + }) +}) + +describe('resolveModel', () => { + it('throws a clear error for unknown models', () => { + expect(() => resolveModel('clade-typo-1')).toThrow(/Unknown model "clade-typo-1"/) + }) + + it('throws a clear error when the provider key is missing', () => { + db.prepare("DELETE FROM config WHERE key = 'claude_api_key'").run() + expect(() => resolveModel('claude-sonnet-4-6')).toThrow(/API key not configured/) + }) + + it('throws a clear error when a custom endpoint was removed', () => { + expect(() => resolveModel('custom:gone-endpoint:llama3')).toThrow(/Custom endpoint not found/) + }) + + it('builds a model instance when a key is configured', () => { + db.prepare(` + INSERT INTO config (key, value, updated_at) VALUES ('claude_api_key', 'sk-test', datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run() + const { model, modelId } = resolveModel('claude-sonnet-4-6') + expect(modelId).toBe('claude-sonnet-4-6') + expect(model.modelId).toBe('claude-sonnet-4-6') + }) + + it('builds a custom-endpoint model from a stored endpoint row', () => { + db.prepare(` + INSERT INTO custom_endpoints (id, name, base_url, api_key) VALUES ('ep-1', 'Local Ollama', 'http://localhost:11434/v1', '') + `).run() + const { model, modelId } = resolveModel('custom:ep-1:llama3:8b') + expect(modelId).toBe('custom:ep-1:llama3:8b') + expect(model.modelId).toBe('llama3:8b') + }) + + it('falls back to the default model when none is given', () => { + db.prepare(` + INSERT INTO config (key, value, updated_at) VALUES ('gemini_api_key', 'test-key', datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run() + const { modelId } = resolveModel(undefined) + expect(modelId).toBe(DEFAULT_MODEL_ID) + }) +}) diff --git a/server/__tests__/openai_compatible.test.js b/server/__tests__/openai_compatible.test.js deleted file mode 100644 index c57337c..0000000 --- a/server/__tests__/openai_compatible.test.js +++ /dev/null @@ -1,262 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { OpenAICompatibleProvider, _resetJsonModeCache } from '../providers/openai_compatible.js' -import { CustomEndpointProvider, customModelId, parseCustomModelId } from '../providers/custom.js' - -const BASE = 'http://localhost:11434/v1' - -/** Build a fetch Response-like object with a JSON body. */ -const jsonResponse = (body, ok = true, status = 200) => ({ - ok, - status, - json: async () => body, -}) - -/** Build a fetch Response-like object with an SSE stream body. */ -function sseResponse(lines) { - const encoder = new TextEncoder() - const chunks = lines.map((l) => encoder.encode(l)) - let i = 0 - return { - ok: true, - status: 200, - body: { - getReader: () => ({ - read: async () => - i < chunks.length ? { done: false, value: chunks[i++] } : { done: true, value: undefined }, - }), - }, - } -} - -const chatResponse = (content, extra = {}) => - jsonResponse({ choices: [{ message: { content, ...extra } }] }) - -beforeEach(() => { - _resetJsonModeCache() - fetch.mockReset() -}) - -describe('custom endpoint connectivity (fetchModels)', () => { - it('lists models from an OpenAI-compatible /models endpoint', async () => { - fetch.mockResolvedValueOnce( - jsonResponse({ data: [{ id: 'llama3.2:3b', created: 1750000000 }, { id: 'qwen2.5' }] }) - ) - const models = await OpenAICompatibleProvider.fetchModels('', BASE) - expect(models).toEqual([ - { id: 'llama3.2:3b', releasedAt: 1750000000000 }, - { id: 'qwen2.5', releasedAt: null }, - ]) - expect(fetch).toHaveBeenCalledWith(`${BASE}/models`, { headers: {} }) - }) - - it('sends a Bearer header when an API key is set', async () => { - fetch.mockResolvedValueOnce(jsonResponse({ data: [] })) - await OpenAICompatibleProvider.fetchModels('sk-test', BASE) - expect(fetch).toHaveBeenCalledWith(`${BASE}/models`, { - headers: { Authorization: 'Bearer sk-test' }, - }) - }) - - it('reports unreachable servers with a friendly message', async () => { - fetch.mockRejectedValueOnce(new TypeError('fetch failed')) - await expect(OpenAICompatibleProvider.fetchModels('', BASE)).rejects.toThrow( - `Could not reach ${BASE}` - ) - }) - - it('surfaces the server error message on HTTP failures', async () => { - fetch.mockResolvedValueOnce(jsonResponse({ error: { message: 'Invalid API key' } }, false, 401)) - await expect(OpenAICompatibleProvider.fetchModels('bad', BASE)).rejects.toThrow('Invalid API key') - }) - - it('verifies keys through testApiKey without spending tokens', async () => { - fetch.mockResolvedValueOnce(jsonResponse({ data: [] })) - const provider = new OpenAICompatibleProvider('sk-test', BASE) - await expect(provider.testApiKey('sk-test')).resolves.toBe(true) - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch.mock.calls[0][0]).toBe(`${BASE}/models`) - }) -}) - -describe('adaptive structured output (generateJSON)', () => { - const schema = { - type: 'object', - properties: { cards: { type: 'array', items: { type: 'string' } } }, - } - - it('uses strict json_schema mode when the server supports it', async () => { - fetch.mockResolvedValueOnce(chatResponse('{"cards":["a"]}')) - const provider = new OpenAICompatibleProvider('', BASE) - const result = await provider.generateJSON('make cards', schema, { model: 'llama3.2:3b' }) - expect(result).toEqual({ cards: ['a'] }) - - const body = JSON.parse(fetch.mock.calls[0][1].body) - expect(body.response_format.type).toBe('json_schema') - expect(body.response_format.json_schema.schema).toEqual(schema) - }) - - it('falls back json_schema β†’ json_object β†’ prompt when the engine lacks support', async () => { - fetch - .mockResolvedValueOnce(jsonResponse({ error: { message: 'response_format json_schema is not supported' } }, false, 400)) - .mockResolvedValueOnce(jsonResponse({ error: { message: 'response_format json_object is not supported' } }, false, 400)) - .mockResolvedValueOnce(chatResponse('```json\n{"cards":["a","b"]}\n```')) - - const provider = new OpenAICompatibleProvider('', BASE) - const result = await provider.generateJSON('make cards', schema, { model: 'llama3.2:3b' }) - expect(result).toEqual({ cards: ['a', 'b'] }) - expect(fetch).toHaveBeenCalledTimes(3) - - const bodies = fetch.mock.calls.map(([, init]) => JSON.parse(init.body)) - expect(bodies[0].response_format.type).toBe('json_schema') - expect(bodies[1].response_format.type).toBe('json_object') - expect(bodies[2].response_format).toBeUndefined() - }) - - it('remembers the working mode per endpoint+model (no repeated failures)', async () => { - fetch - .mockResolvedValueOnce(jsonResponse({ error: { message: 'json_schema not supported' } }, false, 400)) - .mockResolvedValueOnce(jsonResponse({ error: { message: 'json_object not supported' } }, false, 400)) - .mockResolvedValueOnce(chatResponse('{"cards":[]}')) - - const provider = new OpenAICompatibleProvider('', BASE) - await provider.generateJSON('make cards', schema, { model: 'llama3.2:3b' }) - expect(fetch).toHaveBeenCalledTimes(3) - - // Second call starts directly in prompt mode - fetch.mockResolvedValueOnce(chatResponse('{"cards":["c"]}')) - const result = await provider.generateJSON('more cards', schema, { model: 'llama3.2:3b' }) - expect(result).toEqual({ cards: ['c'] }) - expect(fetch).toHaveBeenCalledTimes(4) - const lastBody = JSON.parse(fetch.mock.calls[3][1].body) - expect(lastBody.response_format).toBeUndefined() - }) - - it('falls back when the model returns unparseable JSON', async () => { - fetch - .mockResolvedValueOnce(chatResponse('Sure! Here are your cards: a, b, c')) - .mockResolvedValueOnce(chatResponse('{"cards":["a"]}')) - - const provider = new OpenAICompatibleProvider('', BASE) - const result = await provider.generateJSON('make cards', schema, { model: 'm' }) - expect(result).toEqual({ cards: ['a'] }) - expect(fetch).toHaveBeenCalledTimes(2) - }) - - it('wraps array-root schemas for json_schema mode and unwraps the result', async () => { - const arraySchema = { type: 'array', items: { type: 'string' } } - fetch.mockResolvedValueOnce(chatResponse('{"items":["q1","q2"]}')) - - const provider = new OpenAICompatibleProvider('', BASE) - const result = await provider.generateJSON('make questions', arraySchema, { model: 'm' }) - expect(result).toEqual(['q1', 'q2']) - - const body = JSON.parse(fetch.mock.calls[0][1].body) - expect(body.response_format.json_schema.schema.type).toBe('object') - expect(body.response_format.json_schema.schema.properties.items).toEqual(arraySchema) - }) - - it('throws the last error when every mode fails', async () => { - fetch - .mockResolvedValueOnce(jsonResponse({ error: { message: 'nope 1' } }, false, 400)) - .mockResolvedValueOnce(jsonResponse({ error: { message: 'nope 2' } }, false, 400)) - .mockResolvedValueOnce(jsonResponse({ error: { message: 'nope 3' } }, false, 500)) - - const provider = new OpenAICompatibleProvider('', BASE) - await expect(provider.generateJSON('x', schema, { model: 'm' })).rejects.toThrow('nope 3') - }) -}) - -describe('streaming chat', () => { - it('yields text chunks from the SSE stream', async () => { - fetch.mockResolvedValueOnce( - sseResponse([ - 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n', - 'data: {"choices":[{"delta":{"content":" world"}}]}\n\n', - 'data: [DONE]\n\n', - ]) - ) - const provider = new OpenAICompatibleProvider('', BASE) - const chunks = [] - for await (const chunk of provider.streamChat('be brief', [], 'hi', { model: 'm' })) { - chunks.push(chunk) - } - expect(chunks).toEqual([ - { type: 'text', text: 'Hello' }, - { type: 'text', text: ' world' }, - ]) - }) - - it('degrades to plain chat when the engine rejects tools', async () => { - fetch - .mockResolvedValueOnce(jsonResponse({ error: { message: 'tools is not supported by this model' } }, false, 400)) - .mockResolvedValueOnce( - sseResponse(['data: {"choices":[{"delta":{"content":"plain answer"}}]}\n\n', 'data: [DONE]\n\n']) - ) - - const provider = new OpenAICompatibleProvider('', BASE) - const tools = [{ name: 'lookup', description: 'd', parameters: { type: 'object' } }] - const chunks = [] - for await (const chunk of provider.streamChatWithTools('sys', [], 'hi', tools, async () => ({}), { model: 'm' })) { - chunks.push(chunk) - } - expect(chunks).toEqual([{ type: 'text', text: 'plain answer' }]) - }) - - it('executes tool calls and loops until the model answers', async () => { - fetch - .mockResolvedValueOnce( - jsonResponse({ - choices: [{ - message: { - content: null, - tool_calls: [{ id: 'c1', function: { name: 'lookup', arguments: '{"q":"cap"}' } }], - }, - }], - }) - ) - .mockResolvedValueOnce(chatResponse('CAP theorem says...')) - - const executed = [] - const provider = new OpenAICompatibleProvider('', BASE) - const tools = [{ name: 'lookup', description: 'd', parameters: { type: 'object' } }] - const chunks = [] - for await (const chunk of provider.streamChatWithTools( - 'sys', [], 'hi', tools, - async (name, args) => { executed.push([name, args]); return { found: true } }, - { model: 'm' } - )) { - chunks.push(chunk) - } - - expect(executed).toEqual([['lookup', { q: 'cap' }]]) - expect(chunks).toEqual([ - { type: 'tool', name: 'lookup' }, - { type: 'text', text: 'CAP theorem says...' }, - ]) - // Second request carries the tool result back to the model - const secondBody = JSON.parse(fetch.mock.calls[1][1].body) - expect(secondBody.messages.at(-1)).toMatchObject({ role: 'tool', tool_call_id: 'c1' }) - }) -}) - -describe('custom endpoint model namespacing', () => { - it('builds and parses namespaced model IDs (colons in upstream IDs survive)', () => { - const id = customModelId('ep-1', 'llama3.2:3b') - expect(id).toBe('custom:ep-1:llama3.2:3b') - expect(parseCustomModelId(id)).toEqual({ endpointId: 'ep-1', upstreamModelId: 'llama3.2:3b' }) - expect(parseCustomModelId('gemini-3.5-flash')).toBe(null) - }) - - it('strips the namespace before sending requests upstream', async () => { - fetch.mockResolvedValueOnce(chatResponse('ok')) - const provider = new CustomEndpointProvider({ - id: 'ep-1', - name: 'Homelab', - base_url: BASE, - api_key: '', - }) - await provider.generateText('hi', { model: 'custom:ep-1:llama3.2:3b' }) - const body = JSON.parse(fetch.mock.calls[0][1].body) - expect(body.model).toBe('llama3.2:3b') - }) -}) diff --git a/server/ai/engine.js b/server/ai/engine.js new file mode 100644 index 0000000..077c245 --- /dev/null +++ b/server/ai/engine.js @@ -0,0 +1,215 @@ +/** + * @fileoverview AI engine β€” one thin layer over the Vercel AI SDK. + * + * Every model call in the app goes through the three functions here: + * + * runText(...) β†’ plain text (non-streaming) + * runStructured(...) β†’ schema-validated object or array (non-streaming) + * chatStream(...) β†’ streaming chat with tool calling + * + * The engine resolves a model ID (e.g. 'gemini-3.5-flash', + * 'claude-sonnet-4-6', 'custom::') to an AI SDK + * model instance using the provider registry, then delegates the call. + * The AI SDK supplies retries with backoff (default 2), provider-native + * structured output, schema validation, and the tool-calling loop. + * + * Cross-cutting policy enforced here: + * - No sampling parameters. Newer Claude/OpenAI models reject + * `temperature`; prompts steer behavior instead. + * - Every call has an abort signal (client disconnect and/or timeout). + * - Every call logs model, token usage, finish reason, and duration. + * - Truncated structured output raises a clear error, never a + * JSON-parse failure. + */ + +import { generateText, streamText, Output, stepCountIs } from 'ai' +import { createAnthropic } from '@ai-sdk/anthropic' +import { createGoogleGenerativeAI } from '@ai-sdk/google' +import { createOpenAI } from '@ai-sdk/openai' +import { createOpenAICompatible } from '@ai-sdk/openai-compatible' +import { + getApiKeyForProvider, + getProviderIdForModel, + getProviderDef, +} from '../providers/index.js' +import { parseCustomModelId, DEFAULT_MODEL_ID } from '../providers/defs.js' +import { getCustomEndpoint } from '../providers/catalog.js' +import logger from '../utils/logger.js' + +/** Timeout for non-streaming calls. */ +const CALL_TIMEOUT_MS = 120_000 +/** Timeout ceiling for streaming calls. */ +const STREAM_TIMEOUT_MS = 600_000 +/** Cap on model↔tool round trips in one chat turn. */ +const MAX_TOOL_STEPS = 8 + +/** Map: providerId β†’ AI SDK model factory. */ +const MODEL_FACTORIES = { + gemini: (apiKey, modelId) => createGoogleGenerativeAI({ apiKey })(modelId), + claude: (apiKey, modelId) => createAnthropic({ apiKey })(modelId), + // .chat = the Chat Completions surface (the cross-vendor standard) + openai: (apiKey, modelId) => createOpenAI({ apiKey }).chat(modelId), +} + +/** + * Resolve a model ID to an AI SDK model instance. + * + * @param {string} [modelId] - Model ID; defaults to DEFAULT_MODEL_ID + * @returns {{ model: import('ai').LanguageModel, modelId: string }} + * @throws {Error} When the model is unknown or its key/endpoint is missing + */ +export function resolveModel(modelId) { + const id = modelId || DEFAULT_MODEL_ID + + const custom = parseCustomModelId(id) + if (custom) { + const endpoint = getCustomEndpoint(custom.endpointId) + if (!endpoint) { + throw new Error('Custom endpoint not found. It may have been removed β€” pick another model in Settings.') + } + const provider = createOpenAICompatible({ + name: endpoint.name, + baseURL: endpoint.base_url, + apiKey: endpoint.api_key || undefined, + }) + return { model: provider.chatModel(custom.upstreamModelId), modelId: id } + } + + const providerId = getProviderIdForModel(id) + if (!providerId) { + throw new Error(`Unknown model "${id}". Pick a model in Settings.`) + } + const apiKey = getApiKeyForProvider(providerId) + if (!apiKey) { + const def = getProviderDef(providerId) + throw new Error(`${def?.name || providerId} API key not configured. Please add your key in Settings.`) + } + return { model: MODEL_FACTORIES[providerId](apiKey, id), modelId: id } +} + +/** Log one line per model call: feature, model, tokens, finish reason, duration. */ +function logCall(feature, modelId, usage, finishReason, startedAt) { + logger.info( + `[ai] ${feature} model=${modelId} finish=${finishReason || 'unknown'} ` + + `in=${usage?.inputTokens ?? '?'} out=${usage?.outputTokens ?? '?'} ms=${Date.now() - startedAt}` + ) +} + +/** + * Generate plain text (non-streaming). + * + * @param {Object} options + * @param {string} [options.model] - Model ID + * @param {string} [options.system] - System prompt + * @param {string} [options.prompt] - Single user prompt (use prompt OR messages) + * @param {Array<{role: string, content: string}>} [options.messages] - Chat messages + * @param {number} [options.maxOutputTokens] + * @param {string} [options.feature] - Label for the usage log + * @returns {Promise} The generated text + */ +export async function runText({ model, system, prompt, messages, maxOutputTokens = 8192, feature = 'text' }) { + const { model: languageModel, modelId } = resolveModel(model) + const startedAt = Date.now() + + const result = await generateText({ + model: languageModel, + system, + ...(messages ? { messages } : { prompt }), + maxOutputTokens, + abortSignal: AbortSignal.timeout(CALL_TIMEOUT_MS), + }) + + logCall(feature, modelId, result.usage, result.finishReason, startedAt) + if (result.finishReason === 'length') { + logger.warn(`[ai] ${feature} output was truncated at ${maxOutputTokens} tokens (model=${modelId})`) + } + return result.text +} + +/** + * Generate a schema-validated object or array (non-streaming). + * Uses the provider's native structured-output mode; the AI SDK + * validates the result against the schema before it returns. + * + * Pass exactly one of: + * - schema: a zod object schema β†’ returns the object + * - element: a zod schema for array items β†’ returns the array + * + * @param {Object} options + * @param {string} [options.model] - Model ID + * @param {string} [options.system] - System prompt + * @param {string} options.prompt - The user prompt + * @param {import('zod').ZodTypeAny} [options.schema] - Object schema + * @param {import('zod').ZodTypeAny} [options.element] - Array element schema + * @param {number} [options.maxOutputTokens] + * @param {string} [options.feature] - Label for the usage log + * @returns {Promise} The validated result + * @throws {Error} With a clear message when the output was truncated + */ +export async function runStructured({ model, system, prompt, schema, element, maxOutputTokens = 8192, feature = 'structured' }) { + const { model: languageModel, modelId } = resolveModel(model) + const startedAt = Date.now() + const output = element ? Output.array({ element }) : Output.object({ schema }) + + let result + try { + result = await generateText({ + model: languageModel, + system, + prompt, + output, + maxOutputTokens, + abortSignal: AbortSignal.timeout(CALL_TIMEOUT_MS), + }) + } catch (err) { + logger.error(`[ai] ${feature} structured generation failed (model=${modelId}): ${err.message}`) + throw err + } + + logCall(feature, modelId, result.usage, result.finishReason, startedAt) + if (result.finishReason === 'length') { + throw new Error( + `The model response was cut off at ${maxOutputTokens} output tokens. ` + + 'Retry with a shorter input or a higher output limit.' + ) + } + return result.output +} + +/** + * Stream a chat response with tool calling. + * The AI SDK runs the model↔tool loop; MAX_TOOL_STEPS caps it. + * + * @param {Object} options + * @param {string} [options.model] - Model ID + * @param {string} options.system - System prompt + * @param {Array<{role: string, content: string}>} options.messages - Chat messages + * @param {Object} [options.tools] - AI SDK tool set (from `tool()`) + * @param {AbortSignal} [options.abortSignal] - Client-disconnect signal + * @param {number} [options.maxOutputTokens] + * @returns {{ result: import('ai').StreamTextResult, modelId: string }} + */ +export function chatStream({ model, system, messages, tools, abortSignal, maxOutputTokens = 8192 }) { + const { model: languageModel, modelId } = resolveModel(model) + + const signals = [AbortSignal.timeout(STREAM_TIMEOUT_MS)] + if (abortSignal) signals.push(abortSignal) + + const result = streamText({ + model: languageModel, + system, + messages, + tools, + stopWhen: stepCountIs(MAX_TOOL_STEPS), + maxOutputTokens, + abortSignal: AbortSignal.any(signals), + onError: ({ error }) => { + logger.error(`[ai] chat stream error (model=${modelId}): ${error?.message || error}`) + }, + onAbort: () => { + logger.info(`[ai] chat stream aborted (model=${modelId})`) + }, + }) + + return { result, modelId } +} diff --git a/server/providers/base.js b/server/providers/base.js deleted file mode 100644 index 5948991..0000000 --- a/server/providers/base.js +++ /dev/null @@ -1,249 +0,0 @@ -/* eslint-disable no-unused-vars, require-yield */ -/** - * @fileoverview Abstract base class for AI providers. - * - * Each concrete provider (Gemini, Claude, OpenAI, Ollama, etc.) extends this - * class and implements both the instance methods (for AI operations) and the - * static metadata getters (for self-description and auto-registration). - * - * Architecture: - * β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - * β”‚ AIProvider (abstract) β”‚ - * β”‚ β”œβ”€ Static metadata β†’ providerId, models, capabilities β”‚ - * β”‚ └─ Instance methods β†’ generateText, streamChat, etc. β”‚ - * β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ - * β”‚ GeminiProvider β”‚ ClaudeProvider β”‚ Future... β”‚ - * β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - * - * Adding a new provider requires: - * 1. Create server/providers/.js extending AIProvider - * 2. Override all static getters (providerId, displayName, models, etc.) - * 3. Implement all instance methods (generateText, streamChat, etc.) - * 4. Add the class to PROVIDER_CLASSES in server/providers/index.js - * - * The registry, routes, settings UI, and sidebar automatically discover - * the new provider from its static metadata β€” no other files need changes. - */ - -export class AIProvider { - // ═══════════════════════════════════════════════════════════ - // Static metadata β€” providers MUST override these - // ═══════════════════════════════════════════════════════════ - - /** - * Unique identifier for this provider (e.g. 'gemini', 'claude', 'openai'). - * Used as the key in the provider registry and in API responses. - * @returns {string} - */ - static get providerId() { - throw new Error('AIProvider subclass must define static get providerId()') - } - - /** - * Human-readable display name (e.g. 'Google Gemini', 'Anthropic Claude'). - * Shown in the settings page and model selector. - * @returns {string} - */ - static get displayName() { - throw new Error('AIProvider subclass must define static get displayName()') - } - - /** - * Short display name for compact UI elements (e.g. 'Gemini', 'Claude'). - * @returns {string} - */ - static get shortName() { - throw new Error('AIProvider subclass must define static get shortName()') - } - - /** - * Brand color for UI grouping (hex string). - * @returns {string} - */ - static get brandColor() { - return '#888888' - } - - /** - * Database config key used to store/retrieve this provider's API key. - * e.g. 'gemini_api_key', 'claude_api_key' - * @returns {string} - */ - static get configKey() { - throw new Error('AIProvider subclass must define static get configKey()') - } - - /** - * Environment variable name to seed the API key from on first run. - * e.g. 'GEMINI_API_KEY', 'CLAUDE_API_KEY' - * @returns {string} - */ - static get envKey() { - throw new Error('AIProvider subclass must define static get envKey()') - } - - /** - * Placeholder text for the API key input field (e.g. 'AIza...', 'sk-ant-...'). - * @returns {string} - */ - static get keyPlaceholder() { - return '...' - } - - /** - * URL where users can get an API key for this provider. - * @returns {string|null} - */ - static get keyHelpUrl() { - return null - } - - /** - * Display text for the key help link (e.g. 'Google AI Studio'). - * @returns {string|null} - */ - static get keyHelpLabel() { - return null - } - - /** - * Model catalog: array of models this provider supports. - * Each entry: { id: string, name: string, description: string } - * - * The `id` is the model identifier passed to the SDK (e.g. 'gemini-3.5-flash'). - * The `name` is the human-readable label for the UI. - * - * @returns {Array<{ id: string, name: string, description: string }>} - */ - static get models() { - return [] - } - - /** - * The default model ID to use when none is specified. - * Must be one of the IDs from models(). - * @returns {string} - */ - static get defaultModel() { - const models = this.models - return models.length > 0 ? models[0].id : '' - } - - /** - * Check if a model ID belongs to this provider's namespace. - * Used as a fallback when a model is not in the static or discovered - * catalogs (e.g. a brand-new model typed in manually). - * @param {string} modelId - * @returns {boolean} - */ - static ownsModelId(modelId) { - return typeof modelId === 'string' && modelId.startsWith(this.providerId) - } - - /** - * Capability flags for feature-gating. - * Routes and UI components can check these to gracefully degrade - * for providers that lack certain features (e.g. local models - * without tool calling, or providers without embedding support). - * - * @returns {{ streaming: boolean, toolCalling: boolean, jsonMode: boolean, embeddings: boolean }} - */ - static get capabilities() { - return { - streaming: true, - toolCalling: true, - jsonMode: true, - embeddings: false, - } - } - - // ═══════════════════════════════════════════════════════════ - // Instance methods β€” providers MUST implement these - // ═══════════════════════════════════════════════════════════ - - /** - * @param {string} apiKey - The API key for this provider - */ - constructor(apiKey) { - if (new.target === AIProvider) { - throw new Error('AIProvider is abstract and cannot be instantiated directly.') - } - this.apiKey = apiKey - } - - /** - * Generate a plain text response (non-streaming). - * @param {string} prompt - The user prompt - * @param {Object} options - * @param {string} [options.systemPrompt] - System instruction - * @param {string} [options.model] - Model ID override - * @param {number} [options.temperature] - Sampling temperature - * @param {number} [options.maxOutputTokens] - Max tokens to generate - * @returns {Promise} The generated text - */ - async generateText(prompt, options = {}) { - throw new Error('generateText() must be implemented by subclass') - } - - /** - * Generate a structured JSON response (non-streaming). - * Provider implementations should enforce JSON output as best they can - * (native schema for Gemini, prompt-based for Claude). - * - * @param {string} prompt - The user prompt - * @param {Object} schema - JSON Schema describing the expected output shape - * @param {Object} options - * @param {string} [options.systemPrompt] - System instruction - * @param {string} [options.model] - Model ID override - * @param {number} [options.temperature] - Sampling temperature - * @returns {Promise} The parsed JSON response - */ - async generateJSON(prompt, schema, options = {}) { - throw new Error('generateJSON() must be implemented by subclass') - } - - /** - * Stream a chat response with history support. - * Yields text chunks as they arrive. - * - * @param {string} systemPrompt - System instruction - * @param {Array<{role: string, content: string}>} history - Chat history (role: 'user'|'ai') - * @param {string} message - The current user message - * @param {Object} options - * @param {string} [options.model] - Model ID override - * @param {number} [options.temperature] - Sampling temperature - * @param {number} [options.maxOutputTokens] - Max tokens to generate - * @yields {{ type: 'text', text: string }} Text chunks - */ - async *streamChat(systemPrompt, history, message, options = {}) { - throw new Error('streamChat() must be implemented by subclass') - } - - /** - * Stream a chat response with tool/function calling support. - * Handles the multi-turn tool calling loop internally. - * - * @param {string} systemPrompt - System instruction - * @param {Array<{role: string, content: string}>} history - Chat history - * @param {string} message - The current user message - * @param {Array} tools - Tool definitions (provider-agnostic format) - * @param {Function} toolExecutor - async (toolName, args) => result - * @param {Object} options - * @param {string} [options.model] - Model ID override - * @param {number} [options.temperature] - Sampling temperature - * @param {number} [options.maxOutputTokens] - Max tokens to generate - * @yields {{ type: 'text', text: string } | { type: 'tool', name: string }} Chunks - */ - async *streamChatWithTools(systemPrompt, history, message, tools, toolExecutor, options = {}) { - throw new Error('streamChatWithTools() must be implemented by subclass') - } - - /** - * Test if an API key is valid by making a minimal API call. - * @param {string} apiKey - The key to test - * @returns {Promise} True if valid - */ - async testApiKey(apiKey) { - throw new Error('testApiKey() must be implemented by subclass') - } -} diff --git a/server/providers/catalog.js b/server/providers/catalog.js index 06dd9cb..b5220aa 100644 --- a/server/providers/catalog.js +++ b/server/providers/catalog.js @@ -20,7 +20,7 @@ import { inferModelFamily, prettyModelName, } from './model_filter.js' -import { customModelId } from './custom.js' +import { customModelId } from './defs.js' /** * Persist a discovered model list for a catalog ID. diff --git a/server/providers/claude.js b/server/providers/claude.js deleted file mode 100644 index 041426c..0000000 --- a/server/providers/claude.js +++ /dev/null @@ -1,314 +0,0 @@ -/** - * @fileoverview Claude AI Provider implementation. - * Wraps the Anthropic SDK (@anthropic-ai/sdk) behind the unified AIProvider interface. - * - * Self-describing: all metadata (models, capabilities, config keys) is declared - * via static getters so the registry and UI auto-discover this provider. - * - * Key differences from Gemini: - * - System prompt is a top-level parameter, not part of messages - * - No native JSON schema enforcement β€” uses prompt instructions + parsing - * - No embedding model β€” embeddings remain Gemini-only - * - No context caching equivalent - * - Different tool calling format (tool_use content blocks) - * - Role mapping: 'ai' β†’ 'assistant' (not 'model') - */ - -import { AIProvider } from './base.js' - -export class ClaudeProvider extends AIProvider { - // ═══════════════════════════════════════════════════════════ - // Static metadata (self-describing) - // ═══════════════════════════════════════════════════════════ - - static get providerId() { return 'claude' } - static get displayName() { return 'Anthropic Claude' } - static get shortName() { return 'Claude' } - static get brandColor() { return '#D97757' } - static get configKey() { return 'claude_api_key' } - static get envKey() { return 'CLAUDE_API_KEY' } - static get keyPlaceholder() { return 'sk-ant-...' } - static get keyHelpUrl() { return 'https://console.anthropic.com/settings/keys' } - static get keyHelpLabel() { return 'Anthropic Console' } - - static get models() { - return [ - { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Best balance of speed and intelligence', family: 'Sonnet' }, - { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', description: 'Fastest, most cost-effective', family: 'Haiku' }, - { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', description: 'Most capable, complex reasoning', family: 'Opus' }, - ] - } - - static get capabilities() { - return { - streaming: true, - toolCalling: true, - jsonMode: true, // via prompt-based enforcement - embeddings: false, // Claude has no embedding model - } - } - - // ═══════════════════════════════════════════════════════════ - // Instance implementation - // ═══════════════════════════════════════════════════════════ - - constructor(apiKey) { - super(apiKey) - this._client = null - } - - /** - * Lazy-init the Anthropic client - */ - async _getClient() { - if (this._client) return this._client - const Anthropic = (await import('@anthropic-ai/sdk')).default - this._client = new Anthropic({ apiKey: this.apiKey }) - return this._client - } - - /** - * Map internal history roles to Claude roles. - * Ensures message alternation (Claude requires user/assistant alternation). - */ - _mapHistory(history) { - const mapped = history.map((msg) => ({ - role: msg.role === 'ai' || msg.role === 'model' ? 'assistant' : 'user', - content: msg.content, - })) - - // Claude requires strict user/assistant alternation. - // Merge consecutive same-role messages. - const merged = [] - for (const msg of mapped) { - if (merged.length > 0 && merged[merged.length - 1].role === msg.role) { - merged[merged.length - 1].content += '\n\n' + msg.content - } else { - merged.push({ ...msg }) - } - } - return merged - } - - async generateText(prompt, options = {}) { - const client = await this._getClient() - const modelId = options.model || ClaudeProvider.defaultModel - - const params = { - model: modelId, - max_tokens: options.maxOutputTokens || 8192, - messages: [{ role: 'user', content: prompt }], - } - if (options.systemPrompt) { - params.system = options.systemPrompt - } - if (options.temperature !== undefined) { - params.temperature = options.temperature - } - - const response = await client.messages.create(params) - - // Extract text from content blocks - return response.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') - } - - async generateJSON(prompt, schema, options = {}) { - const client = await this._getClient() - const modelId = options.model || ClaudeProvider.defaultModel - - // Build a system prompt that enforces JSON output - let systemPrompt = options.systemPrompt || '' - const schemaStr = schema ? JSON.stringify(schema, null, 2) : '' - - systemPrompt += `\n\nIMPORTANT: You MUST respond with valid JSON only. No markdown, no code fences, no explanation β€” just raw JSON.` - if (schemaStr) { - systemPrompt += `\n\nThe response must conform to this JSON schema:\n${schemaStr}` - } - - const params = { - model: modelId, - max_tokens: options.maxOutputTokens || 8192, - system: systemPrompt.trim(), - messages: [{ role: 'user', content: prompt }], - } - if (options.temperature !== undefined) { - params.temperature = options.temperature - } - - const response = await client.messages.create(params) - - const text = response.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') - - // Strip any markdown code fences that Claude sometimes adds despite instructions - const cleaned = text.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim() - - return JSON.parse(cleaned) - } - - async *streamChat(systemPrompt, history, message, options = {}) { - const client = await this._getClient() - const modelId = options.model || ClaudeProvider.defaultModel - - const messages = [ - ...this._mapHistory(history), - { role: 'user', content: message }, - ] - - // Ensure messages start with 'user' role (Claude requirement) - if (messages.length > 0 && messages[0].role !== 'user') { - messages.unshift({ role: 'user', content: '(continued conversation)' }) - } - - const stream = client.messages.stream({ - model: modelId, - max_tokens: options.maxOutputTokens ?? 8192, - system: systemPrompt, - messages, - temperature: options.temperature ?? 0.5, - }) - - for await (const event of stream) { - if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') { - yield { type: 'text', text: event.delta.text } - } - } - } - - async *streamChatWithTools(systemPrompt, history, message, tools, toolExecutor, options = {}) { - const client = await this._getClient() - const modelId = options.model || ClaudeProvider.defaultModel - - // Convert generic tool definitions to Claude's tool format - const claudeTools = tools.map(t => ({ - name: t.name, - description: t.description, - input_schema: t.parameters, - })) - - const messages = [ - ...this._mapHistory(history), - { role: 'user', content: message }, - ] - - // Ensure messages start with 'user' role - if (messages.length > 0 && messages[0].role !== 'user') { - messages.unshift({ role: 'user', content: '(continued conversation)' }) - } - - let continueLoop = true - - while (continueLoop) { - continueLoop = false - - // Use non-streaming create for tool-calling turns to simplify the loop, - // then stream the final text-only turn. - const response = await client.messages.create({ - model: modelId, - max_tokens: options.maxOutputTokens ?? 8192, - system: systemPrompt, - messages, - tools: claudeTools, - temperature: options.temperature ?? 0.5, - }) - - // Process response content blocks - const assistantContent = response.content - const toolUseBlocks = assistantContent.filter(b => b.type === 'tool_use') - const textBlocks = assistantContent.filter(b => b.type === 'text') - - // Yield any text that came before/alongside tool calls - for (const block of textBlocks) { - if (block.text) { - yield { type: 'text', text: block.text } - } - } - - if (toolUseBlocks.length > 0 && response.stop_reason === 'tool_use') { - // Process tool calls - const toolResults = [] - - for (const toolBlock of toolUseBlocks) { - yield { type: 'tool', name: toolBlock.name } - const result = await toolExecutor(toolBlock.name, toolBlock.input) - toolResults.push({ - type: 'tool_result', - tool_use_id: toolBlock.id, - content: JSON.stringify(result), - }) - } - - // Add assistant message with tool_use and user message with tool_result - messages.push({ role: 'assistant', content: assistantContent }) - messages.push({ role: 'user', content: toolResults }) - - continueLoop = true - } else if (response.stop_reason === 'end_turn' && textBlocks.length === 0) { - // Edge case: no text was produced - break - } - } - } - - /** - * Lightweight key verification: list the model catalog. - * Costs no tokens and fails fast on an invalid key. - */ - async testApiKey(apiKey) { - await ClaudeProvider.fetchModels(apiKey) - return true - } - - /** - * Live model discovery via the Anthropic model listing API. - * Returns release timestamps (created_at) for the recency guardrail. - * - * @param {string} apiKey - * @returns {Promise>} - */ - static async fetchModels(apiKey) { - const models = [] - let afterId = '' - let pages = 0 - do { - const url = new URL('https://api.anthropic.com/v1/models') - url.searchParams.set('limit', '100') - if (afterId) url.searchParams.set('after_id', afterId) - - const res = await fetch(url, { - headers: { - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - }) - if (!res.ok) { - let detail = '' - try { - const body = await res.json() - detail = body?.error?.message || '' - } catch { /* non-JSON body */ } - throw new Error(detail || `Anthropic model listing failed with status ${res.status}`) - } - - const data = await res.json() - for (const m of data.data || []) { - const releasedAt = m.created_at ? Date.parse(m.created_at) : null - models.push({ - id: m.id, - name: m.display_name || m.id, - description: '', - releasedAt: Number.isNaN(releasedAt) ? null : releasedAt, - }) - } - afterId = data.has_more ? data.last_id : '' - pages += 1 - } while (afterId && pages < 10) - - return models - } -} diff --git a/server/providers/custom.js b/server/providers/custom.js deleted file mode 100644 index 048a5b3..0000000 --- a/server/providers/custom.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @fileoverview Custom endpoint provider ("Bring Your Own Model"). - * - * One instance per user-registered OpenAI-compatible endpoint - * (Ollama, LM Studio, vLLM, OpenRouter, Groq, ...). The endpoint row - * from the `custom_endpoints` table supplies the base URL, optional - * API key, and display name. - * - * Models from custom endpoints are namespaced in the global picker as - * custom:: - * The inherited _resolveModel() strips that prefix before requests. - */ - -import { OpenAICompatibleProvider } from './openai_compatible.js' - -/** Brand color used for all custom endpoint groups in the UI. */ -export const CUSTOM_ENDPOINT_COLOR = '#8B5CF6' - -/** - * Build the namespaced picker ID for a model on a custom endpoint. - * @param {string} endpointId - * @param {string} upstreamModelId - * @returns {string} - */ -export function customModelId(endpointId, upstreamModelId) { - return `custom:${endpointId}:${upstreamModelId}` -} - -/** - * Parse a namespaced custom model ID. - * @param {string} modelId - * @returns {{ endpointId: string, upstreamModelId: string }|null} - */ -export function parseCustomModelId(modelId) { - if (typeof modelId !== 'string' || !modelId.startsWith('custom:')) return null - const parts = modelId.split(':') - if (parts.length < 3) return null - return { endpointId: parts[1], upstreamModelId: parts.slice(2).join(':') } -} - -export class CustomEndpointProvider extends OpenAICompatibleProvider { - static get providerId() { return 'custom' } - static get displayName() { return 'Custom Endpoint' } - static get shortName() { return 'Custom' } - static get brandColor() { return CUSTOM_ENDPOINT_COLOR } - - static get capabilities() { - return { - streaming: true, - toolCalling: true, // degrades to plain chat when the engine rejects tools - jsonMode: true, // adaptive: json_schema β†’ json_object β†’ prompt - embeddings: false, - } - } - - /** - * @param {{ id: string, name: string, base_url: string, api_key?: string }} endpoint - * A row from the custom_endpoints table - */ - constructor(endpoint) { - super(endpoint.api_key || '', endpoint.base_url) - this.endpointId = endpoint.id - this.endpointName = endpoint.name - } -} diff --git a/server/providers/defs.js b/server/providers/defs.js new file mode 100644 index 0000000..921ec17 --- /dev/null +++ b/server/providers/defs.js @@ -0,0 +1,234 @@ +/** + * @fileoverview Provider definitions and model-listing fetchers. + * + * Each cloud provider is a plain metadata object. The AI SDK + * (server/ai/engine.js) makes the actual model calls; this module only + * describes providers (branding, config keys, static model fallbacks) + * and lists their live model catalogs over plain fetch. + * + * Adding a new key-based provider requires: + * 1. Add a definition object to PROVIDER_DEFS below. + * 2. Add a model factory to server/ai/engine.js. + * The registry, routes, and settings UI discover the rest. + */ + +/** Timeout for model-listing requests. */ +const LIST_TIMEOUT_MS = 15_000 + +// ═══════════════════════════════════════════════════════════════ +// Custom endpoint model ID namespace +// ═══════════════════════════════════════════════════════════════ + +/** Brand color used for all custom endpoint groups in the UI. */ +export const CUSTOM_ENDPOINT_COLOR = '#8B5CF6' + +/** + * Build the namespaced picker ID for a model on a custom endpoint. + * @param {string} endpointId + * @param {string} upstreamModelId + * @returns {string} + */ +export function customModelId(endpointId, upstreamModelId) { + return `custom:${endpointId}:${upstreamModelId}` +} + +/** + * Parse a namespaced custom model ID (`custom::`). + * @param {string} modelId + * @returns {{ endpointId: string, upstreamModelId: string }|null} + */ +export function parseCustomModelId(modelId) { + if (typeof modelId !== 'string' || !modelId.startsWith('custom:')) return null + const parts = modelId.split(':') + if (parts.length < 3) return null + return { endpointId: parts[1], upstreamModelId: parts.slice(2).join(':') } +} + +// ═══════════════════════════════════════════════════════════════ +// Model-listing fetchers +// ═══════════════════════════════════════════════════════════════ + +/** Pull a useful error message out of a failed HTTP response. */ +async function extractErrorMessage(res) { + let detail = '' + try { + const body = await res.json() + detail = body?.error?.message || body?.message || '' + } catch { + // Non-JSON error body + } + return detail || `Request failed with status ${res.status}` +} + +/** + * Live model discovery via the Gemini model listing API. + * Keeps only models that support generateContent (chat-capable). + * @param {string} apiKey + * @returns {Promise>} + */ +async function fetchGeminiModels(apiKey) { + const models = [] + let pageToken = '' + do { + const url = new URL('https://generativelanguage.googleapis.com/v1beta/models') + url.searchParams.set('key', apiKey) + url.searchParams.set('pageSize', '200') + if (pageToken) url.searchParams.set('pageToken', pageToken) + + const res = await fetch(url, { signal: AbortSignal.timeout(LIST_TIMEOUT_MS) }) + if (!res.ok) throw new Error(await extractErrorMessage(res)) + + const data = await res.json() + for (const m of data.models || []) { + const methods = m.supportedGenerationMethods || [] + if (!methods.includes('generateContent')) continue + models.push({ + id: (m.name || '').replace(/^models\//, ''), + name: m.displayName || (m.name || '').replace(/^models\//, ''), + description: (m.description || '').slice(0, 140), + releasedAt: null, + }) + } + pageToken = data.nextPageToken || '' + } while (pageToken) + + return models +} + +/** + * Live model discovery via the Anthropic model listing API. + * Returns release timestamps (created_at) for the recency guardrail. + * @param {string} apiKey + * @returns {Promise>} + */ +async function fetchClaudeModels(apiKey) { + const models = [] + let afterId = '' + let pages = 0 + do { + const url = new URL('https://api.anthropic.com/v1/models') + url.searchParams.set('limit', '100') + if (afterId) url.searchParams.set('after_id', afterId) + + const res = await fetch(url, { + headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, + signal: AbortSignal.timeout(LIST_TIMEOUT_MS), + }) + if (!res.ok) throw new Error(await extractErrorMessage(res)) + + const data = await res.json() + for (const m of data.data || []) { + const releasedAt = m.created_at ? Date.parse(m.created_at) : null + models.push({ + id: m.id, + name: m.display_name || m.id, + description: '', + releasedAt: Number.isNaN(releasedAt) ? null : releasedAt, + }) + } + afterId = data.has_more ? data.last_id : '' + pages += 1 + } while (afterId && pages < 10) + + return models +} + +/** + * GET /models from an OpenAI-compatible server (api.openai.com, Ollama, + * LM Studio, vLLM, OpenRouter, Groq, ...). + * + * @param {string} apiKey - Bearer token (may be empty for local servers) + * @param {string} baseUrl - The server base URL (e.g. https://api.openai.com/v1) + * @returns {Promise>} + */ +export async function fetchEndpointModels(apiKey, baseUrl) { + const base = String(baseUrl || '').replace(/\/+$/, '') + const headers = {} + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` + + let res + try { + res = await fetch(`${base}/models`, { headers, signal: AbortSignal.timeout(LIST_TIMEOUT_MS) }) + } catch (err) { + throw new Error(`Could not reach ${base} β€” ${err.message}`, { cause: err }) + } + if (!res.ok) throw new Error(await extractErrorMessage(res)) + + const data = await res.json() + const rawModels = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : [] + return rawModels + .filter((m) => m && (m.id || m.name)) + .map((m) => ({ + id: m.id || m.name, + // OpenAI-style `created` is unix seconds + releasedAt: typeof m.created === 'number' ? m.created * 1000 : null, + })) +} + +// ═══════════════════════════════════════════════════════════════ +// Provider definitions +// ═══════════════════════════════════════════════════════════════ + +export const PROVIDER_DEFS = [ + { + id: 'gemini', + name: 'Google Gemini', + shortName: 'Gemini', + color: '#4285F4', + configKey: 'gemini_api_key', + envKey: 'GEMINI_API_KEY', + keyPlaceholder: 'AIza...', + keyHelpUrl: 'https://aistudio.google.com/apikey', + keyHelpLabel: 'Google AI Studio', + capabilities: { streaming: true, toolCalling: true, jsonMode: true, embeddings: true }, + // Static fallback catalog, replaced by live discovery once a key is verified. + models: [ + { id: 'gemini-3.5-flash', name: 'Gemini 3.5 Flash', description: 'Fast and efficient β€” best for most tasks', family: 'Flash' }, + { id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', description: 'Advanced reasoning, stable and reliable', family: 'Pro' }, + { id: 'gemini-3.1-flash-lite', name: 'Gemini 3.1 Flash Lite', description: 'Budget-friendly, high-speed for simple tasks', family: 'Flash-Lite' }, + ], + ownsModelId: (modelId) => /^(gemini|gemma)/i.test(modelId || ''), + fetchModels: fetchGeminiModels, + }, + { + id: 'claude', + name: 'Anthropic Claude', + shortName: 'Claude', + color: '#D97757', + configKey: 'claude_api_key', + envKey: 'CLAUDE_API_KEY', + keyPlaceholder: 'sk-ant-...', + keyHelpUrl: 'https://console.anthropic.com/settings/keys', + keyHelpLabel: 'Anthropic Console', + capabilities: { streaming: true, toolCalling: true, jsonMode: true, embeddings: false }, + models: [ + { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Best balance of speed and intelligence', family: 'Sonnet' }, + { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', description: 'Fastest, most cost-effective', family: 'Haiku' }, + { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', description: 'Most capable, complex reasoning', family: 'Opus' }, + ], + ownsModelId: (modelId) => /^claude/i.test(modelId || ''), + fetchModels: fetchClaudeModels, + }, + { + id: 'openai', + name: 'OpenAI', + shortName: 'OpenAI', + color: '#10A37F', + configKey: 'openai_api_key', + envKey: 'OPENAI_API_KEY', + keyPlaceholder: 'sk-...', + keyHelpUrl: 'https://platform.openai.com/api-keys', + keyHelpLabel: 'OpenAI Platform', + capabilities: { streaming: true, toolCalling: true, jsonMode: true, embeddings: false }, + models: [ + { id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', description: 'Flagship β€” hardest coding, agents, and research', family: 'Flagship' }, + { id: 'gpt-5.6-terra', name: 'GPT-5.6 Terra', description: 'Balanced quality and cost for most tasks', family: 'Balanced' }, + { id: 'gpt-5.6-luna', name: 'GPT-5.6 Luna', description: 'Fastest and most cost-effective', family: 'Fast' }, + ], + ownsModelId: (modelId) => /^(gpt|o\d|chatgpt|codex)/i.test(modelId || ''), + fetchModels: (apiKey) => fetchEndpointModels(apiKey, 'https://api.openai.com/v1'), + }, +] + +/** The model the app uses when a request names no model. */ +export const DEFAULT_MODEL_ID = PROVIDER_DEFS[0].models[0].id diff --git a/server/providers/gemini.js b/server/providers/gemini.js deleted file mode 100644 index e4356b9..0000000 --- a/server/providers/gemini.js +++ /dev/null @@ -1,304 +0,0 @@ -/** - * @fileoverview Gemini AI Provider implementation. - * Wraps the Google Generative AI SDK (@google/generative-ai and @google/genai) - * behind the unified AIProvider interface. - * - * Self-describing: all metadata (models, capabilities, config keys) is declared - * via static getters so the registry and UI auto-discover this provider. - */ - -import { AIProvider } from './base.js' -import logger from '../utils/logger.js' - -export class GeminiProvider extends AIProvider { - // ═══════════════════════════════════════════════════════════ - // Static metadata (self-describing) - // ═══════════════════════════════════════════════════════════ - - static get providerId() { return 'gemini' } - static get displayName() { return 'Google Gemini' } - static get shortName() { return 'Gemini' } - static get brandColor() { return '#4285F4' } - static get configKey() { return 'gemini_api_key' } - static get envKey() { return 'GEMINI_API_KEY' } - static get keyPlaceholder() { return 'AIza...' } - static get keyHelpUrl() { return 'https://aistudio.google.com/apikey' } - static get keyHelpLabel() { return 'Google AI Studio' } - - static get models() { - return [ - { id: 'gemini-3.5-flash', name: 'Gemini 3.5 Flash', description: 'Fast and efficient β€” best for most tasks', family: 'Flash' }, - { id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', description: 'Advanced reasoning, stable and reliable', family: 'Pro' }, - { id: 'gemini-3.1-flash-lite', name: 'Gemini 3.1 Flash Lite', description: 'Budget-friendly, high-speed for simple tasks', family: 'Flash-Lite' }, - ] - } - - static get capabilities() { - return { - streaming: true, - toolCalling: true, - jsonMode: true, - embeddings: true, - } - } - - static ownsModelId(modelId) { - return /^(gemini|gemma)/i.test(modelId || '') - } - - // ═══════════════════════════════════════════════════════════ - // Instance implementation - // ═══════════════════════════════════════════════════════════ - - constructor(apiKey) { - super(apiKey) - this._genAI = null - this._genAIClient = null - } - - /** - * Lazy-init the @google/generative-ai client (used for streaming, chat, SchemaType) - */ - async _getGenAI() { - if (this._genAI) return this._genAI - const { GoogleGenerativeAI } = await import('@google/generative-ai') - this._genAI = new GoogleGenerativeAI(this.apiKey) - return this._genAI - } - - /** - * Lazy-init the @google/genai client (used for structured JSON output with responseSchema) - */ - async _getGenAIClient() { - if (this._genAIClient) return this._genAIClient - const { GoogleGenAI } = await import('@google/genai') - this._genAIClient = new GoogleGenAI({ apiKey: this.apiKey }) - return this._genAIClient - } - - async generateText(prompt, options = {}) { - const genAI = await this._getGenAI() - const modelId = options.model || GeminiProvider.defaultModel - const modelOpts = { model: modelId } - if (options.systemPrompt) { - modelOpts.systemInstruction = options.systemPrompt - } - const model = genAI.getGenerativeModel(modelOpts) - const result = await model.generateContent(prompt) - return result.response.text() - } - - async generateJSON(prompt, schema, options = {}) { - const client = await this._getGenAIClient() - const modelId = options.model || GeminiProvider.defaultModel - - const config = { - responseMimeType: 'application/json', - } - if (schema) { - config.responseSchema = schema - } - if (options.temperature !== undefined) { - config.temperature = options.temperature - } - - const result = await client.models.generateContent({ - model: modelId, - contents: prompt, - config, - }) - - return JSON.parse(result.text) - } - - async *streamChat(systemPrompt, history, message, options = {}) { - const genAI = await this._getGenAI() - const modelId = options.model || GeminiProvider.defaultModel - - // Attempt context caching for large system prompts - let model - try { - const { GoogleAICacheManager } = await import('@google/generative-ai/server') - const cacheManager = new GoogleAICacheManager(this.apiKey) - const cache = await cacheManager.create({ - model: 'models/' + modelId, - systemInstruction: systemPrompt, - contents: [ - { role: 'user', parts: [{ text: 'Understood. I am ready to help the student.' }] }, - { role: 'model', parts: [{ text: 'Ready.' }] } - ], - ttlSeconds: 600 - }) - model = genAI.getGenerativeModelFromCachedContent(cache) - } catch (cacheErr) { - logger.warn(`[GeminiProvider] Context caching skipped (${cacheErr.message}). Using standard init.`) - model = genAI.getGenerativeModel({ - model: modelId, - systemInstruction: systemPrompt - }) - } - - const chatHistory = history.map((msg) => ({ - role: msg.role === 'ai' ? 'model' : 'user', - parts: [{ text: msg.content }], - })) - - const chat = model.startChat({ - history: chatHistory, - generationConfig: { - temperature: options.temperature ?? 0.5, - maxOutputTokens: options.maxOutputTokens ?? 8192, - }, - }) - - const result = await chat.sendMessageStream(message) - for await (const chunk of result.stream) { - const text = chunk.text() - if (text) { - yield { type: 'text', text } - } - } - } - - async *streamChatWithTools(systemPrompt, history, message, tools, toolExecutor, options = {}) { - const genAI = await this._getGenAI() - const modelId = options.model || GeminiProvider.defaultModel - - // Convert generic tool definitions to Gemini function declarations - const geminiTools = [{ - functionDeclarations: tools.map(t => ({ - name: t.name, - description: t.description, - parameters: t.parameters, - })) - }] - - // Attempt context caching - let model - try { - const { GoogleAICacheManager } = await import('@google/generative-ai/server') - const cacheManager = new GoogleAICacheManager(this.apiKey) - const cache = await cacheManager.create({ - model: 'models/' + modelId, - systemInstruction: systemPrompt, - contents: [ - { role: 'user', parts: [{ text: 'Understood. I am ready to help the student.' }] }, - { role: 'model', parts: [{ text: 'Ready.' }] } - ], - ttlSeconds: 600 - }) - model = genAI.getGenerativeModelFromCachedContent(cache, { tools: geminiTools }) - } catch (cacheErr) { - logger.warn(`[GeminiProvider] Context caching skipped (${cacheErr.message}). Using standard init.`) - model = genAI.getGenerativeModel({ - model: modelId, - tools: geminiTools, - systemInstruction: systemPrompt - }) - } - - const chatHistory = history.map((msg) => ({ - role: msg.role === 'ai' ? 'model' : 'user', - parts: [{ text: msg.content }], - })) - - const chat = model.startChat({ - history: chatHistory, - generationConfig: { - temperature: options.temperature ?? 0.5, - maxOutputTokens: options.maxOutputTokens ?? 8192, - }, - }) - - let currentMessage = message - let isFunctionCall - - do { - isFunctionCall = false - const result = await chat.sendMessageStream(currentMessage) - - for await (const chunk of result.stream) { - const chunkFunctionCalls = typeof chunk.functionCalls === 'function' - ? chunk.functionCalls() - : chunk.functionCalls - - if (chunkFunctionCalls && chunkFunctionCalls.length > 0) { - isFunctionCall = true - const functionResponses = [] - - for (const call of chunkFunctionCalls) { - yield { type: 'tool', name: call.name } - const toolResult = await toolExecutor(call.name, call.args) - functionResponses.push({ - functionResponse: { - name: call.name, - response: { result: toolResult } - } - }) - } - currentMessage = functionResponses - break - } else { - const text = chunk.text() - if (text) { - yield { type: 'text', text } - } - } - } - } while (isFunctionCall) - } - - /** - * Lightweight key verification: list the model catalog. - * Costs no tokens and fails fast on an invalid key. - */ - async testApiKey(apiKey) { - await GeminiProvider.fetchModels(apiKey) - return true - } - - /** - * Live model discovery via the Gemini model listing API. - * Keeps only models that support generateContent (chat-capable). - * The listing exposes no release timestamps, so releasedAt is null - * and the recency guardrail passes these models through. - * - * @param {string} apiKey - * @returns {Promise>} - */ - static async fetchModels(apiKey) { - const models = [] - let pageToken = '' - do { - const url = new URL('https://generativelanguage.googleapis.com/v1beta/models') - url.searchParams.set('key', apiKey) - url.searchParams.set('pageSize', '200') - if (pageToken) url.searchParams.set('pageToken', pageToken) - - const res = await fetch(url) - if (!res.ok) { - let detail = '' - try { - const body = await res.json() - detail = body?.error?.message || '' - } catch { /* non-JSON body */ } - throw new Error(detail || `Gemini model listing failed with status ${res.status}`) - } - - const data = await res.json() - for (const m of data.models || []) { - const methods = m.supportedGenerationMethods || [] - if (!methods.includes('generateContent')) continue - models.push({ - id: (m.name || '').replace(/^models\//, ''), - name: m.displayName || (m.name || '').replace(/^models\//, ''), - description: (m.description || '').slice(0, 140), - releasedAt: null, - }) - } - pageToken = data.nextPageToken || '' - } while (pageToken) - - return models - } -} diff --git a/server/providers/index.js b/server/providers/index.js index ec9d4e6..6a74fbe 100644 --- a/server/providers/index.js +++ b/server/providers/index.js @@ -1,31 +1,23 @@ /** - * @fileoverview AI Provider Registry and Factory. + * @fileoverview Provider registry. * - * Central entry point for the provider abstraction layer. - * Resolves model IDs to provider instances, manages the discovered - * model catalog, and orchestrates catalog refreshes. + * Central lookup layer over the provider definitions (defs.js): + * resolves model IDs to provider IDs, stores/reads API keys, and + * orchestrates model-catalog refreshes. The AI SDK engine + * (server/ai/engine.js) uses this registry to build model instances. * * β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - * β”‚ Model resolution order (getProvider) β”‚ + * β”‚ Model β†’ provider resolution (getProviderIdForModel) β”‚ * β”‚ β”‚ - * β”‚ 1. `custom::` β†’ CustomEndpointProvider β”‚ - * β”‚ 2. Static catalog exact match β”‚ - * β”‚ 3. Discovered (cached) catalog match β”‚ - * β”‚ 4. Provider namespace heuristics (ownsModelId) β”‚ - * β”‚ 5. First registered provider (backward compatibility) β”‚ + * β”‚ 1. Static catalog exact match β”‚ + * β”‚ 2. Discovered (cached) catalog match β”‚ + * β”‚ 3. Provider namespace heuristics (ownsModelId) β”‚ + * β”‚ 4. null (callers surface a clear "unknown model" error) β”‚ * β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - * - * Catalogs: each provider ships a small static fallback list, replaced - * by live discovery (the provider's model listing API) as soon as a key - * is verified or a refresh runs. Discovered lists persist in SQLite via - * providers/catalog.js. */ import db from '../db.js' -import { GeminiProvider } from './gemini.js' -import { ClaudeProvider } from './claude.js' -import { OpenAIProvider } from './openai.js' -import { CustomEndpointProvider, CUSTOM_ENDPOINT_COLOR, parseCustomModelId } from './custom.js' +import { PROVIDER_DEFS, fetchEndpointModels, CUSTOM_ENDPOINT_COLOR } from './defs.js' import { saveCatalog, loadCatalog, @@ -33,160 +25,87 @@ import { buildProviderCatalogEntries, buildEndpointCatalogEntries, listCustomEndpoints, - getCustomEndpoint, } from './catalog.js' import logger from '../utils/logger.js' -// ═══════════════════════════════════════════════════════════════ -// Provider Registration -// -// To add a new key-based provider: -// 1. Create server/providers/.js extending AIProvider -// 2. Add the class to this array -// 3. Done β€” everything else auto-discovers it -// ═══════════════════════════════════════════════════════════════ - -const PROVIDER_CLASSES = [ - GeminiProvider, - ClaudeProvider, - OpenAIProvider, -] - -// ═══════════════════════════════════════════════════════════════ -// Auto-built lookup maps (derived from provider static metadata) -// ═══════════════════════════════════════════════════════════════ - -/** Map: providerId β†’ ProviderClass */ -const PROVIDERS = Object.fromEntries( - PROVIDER_CLASSES.map(P => [P.providerId, P]) -) +/** Map: providerId β†’ definition */ +const PROVIDERS = Object.fromEntries(PROVIDER_DEFS.map((p) => [p.id, p])) /** Map: modelId β†’ providerId (static fallback catalogs) */ const MODEL_TO_PROVIDER = {} -for (const ProviderClass of PROVIDER_CLASSES) { - for (const model of ProviderClass.models) { - MODEL_TO_PROVIDER[model.id] = ProviderClass.providerId +for (const def of PROVIDER_DEFS) { + for (const model of def.models) { + MODEL_TO_PROVIDER[model.id] = def.id } } -// Cache provider instances per API key to avoid re-creating on every request -const providerCache = new Map() - // ═══════════════════════════════════════════════════════════════ -// Core API +// Core lookups // ═══════════════════════════════════════════════════════════════ +/** + * Get a provider definition by ID. + * @param {string} providerId + * @returns {Object|undefined} + */ +export function getProviderDef(providerId) { + return PROVIDERS[providerId] +} + /** * Get the API key for a given provider from the database config. * @param {string} providerId - e.g. 'gemini', 'claude', 'openai' * @returns {string|null} The API key or null if not configured */ export function getApiKeyForProvider(providerId) { - const ProviderClass = PROVIDERS[providerId] - if (!ProviderClass) return null - - const config = db.prepare("SELECT value FROM config WHERE key = ?").get(ProviderClass.configKey) + const def = PROVIDERS[providerId] + if (!def) return null + const config = db.prepare('SELECT value FROM config WHERE key = ?').get(def.configKey) return config?.value || null } /** * Determine which provider a model ID belongs to. * Checks static catalogs, then discovered catalogs, then namespace - * heuristics. + * heuristics. Returns null when no provider matches β€” callers must + * surface a clear error instead of guessing. * * @param {string} modelId - e.g. 'gemini-3.5-flash' or 'claude-sonnet-4-6' - * @returns {string} Provider ID + * @returns {string|null} Provider ID or null */ export function getProviderIdForModel(modelId) { - // Exact match from the static model catalogs if (MODEL_TO_PROVIDER[modelId]) { return MODEL_TO_PROVIDER[modelId] } - // Match against discovered (cached) catalogs - for (const ProviderClass of PROVIDER_CLASSES) { - const cached = loadCatalog(ProviderClass.providerId) - if (cached?.models?.some(m => m.id === modelId)) { - return ProviderClass.providerId + for (const def of PROVIDER_DEFS) { + const cached = loadCatalog(def.id) + if (cached?.models?.some((m) => m.id === modelId)) { + return def.id } } - // Namespace heuristics (e.g. 'gpt-*' β†’ openai, 'gemma-*' β†’ gemini) - for (const ProviderClass of PROVIDER_CLASSES) { - if (ProviderClass.ownsModelId(modelId)) { - return ProviderClass.providerId + for (const def of PROVIDER_DEFS) { + if (def.ownsModelId(modelId)) { + return def.id } } - // Default to first registered provider for backward compatibility - return PROVIDER_CLASSES[0].providerId + return null } /** - * Get a provider instance for a given model ID. - * Resolves the correct provider class and API key, returns a ready-to-use - * instance. Custom endpoint models (`custom::`) - * resolve to a CustomEndpointProvider bound to that endpoint. - * - * @param {string} modelId - The model ID - * @returns {AIProvider} A provider instance - * @throws {Error} If the provider's API key or endpoint is not configured - */ -export function getProvider(modelId) { - // Custom endpoint models - const custom = parseCustomModelId(modelId) - if (custom) { - const endpoint = getCustomEndpoint(custom.endpointId) - if (!endpoint) { - throw new Error('Custom endpoint not found. It may have been removed β€” pick another model in Settings.') - } - const cacheKey = `custom:${endpoint.id}:${endpoint.base_url}:${(endpoint.api_key || '').slice(0, 8)}` - if (providerCache.has(cacheKey)) { - return providerCache.get(cacheKey) - } - const instance = new CustomEndpointProvider(endpoint) - providerCache.set(cacheKey, instance) - logger.info(`[providers] Created custom endpoint provider for "${endpoint.name}"`) - return instance - } - - const providerId = getProviderIdForModel(modelId) - const ProviderClass = PROVIDERS[providerId] - - if (!ProviderClass) { - throw new Error(`Unknown provider for model "${modelId}"`) - } - - const apiKey = getApiKeyForProvider(providerId) - if (!apiKey) { - throw new Error( - `${ProviderClass.displayName} API key not configured. Please add your key in Settings.` - ) - } - - // Cache by provider + key hash to handle key changes - const cacheKey = `${providerId}:${apiKey.slice(0, 8)}` - if (providerCache.has(cacheKey)) { - return providerCache.get(cacheKey) - } - - const instance = new ProviderClass(apiKey) - providerCache.set(cacheKey, instance) - logger.info(`[providers] Created ${ProviderClass.displayName} provider instance`) - return instance -} - -/** - * Get a provider instance by provider ID (not model ID). - * Used for key testing where we know the provider but not a specific model. - * - * @param {string} providerId - e.g. 'gemini', 'claude', 'openai' - * @param {string} apiKey - The API key to use - * @returns {AIProvider} A provider instance (not cached) + * Verify an API key by listing the provider's model catalog. + * Costs no tokens and fails fast on an invalid key. + * @param {string} providerId + * @param {string} apiKey + * @returns {Promise} + * @throws {Error} When the provider is unknown or the key is invalid */ -export function getProviderByIdWithKey(providerId, apiKey) { - const ProviderClass = PROVIDERS[providerId] - if (!ProviderClass) { +export async function testProviderKey(providerId, apiKey) { + const def = PROVIDERS[providerId] + if (!def) { throw new Error(`Unknown provider: "${providerId}"`) } - return new ProviderClass(apiKey) + await def.fetchModels(apiKey) + return true } // ═══════════════════════════════════════════════════════════════ @@ -204,19 +123,19 @@ export function getProviderByIdWithKey(providerId, apiKey) { * @throws {Error} When no key is configured or discovery fails */ export async function refreshProviderCatalog(providerId, apiKeyOverride) { - const ProviderClass = PROVIDERS[providerId] - if (!ProviderClass) { + const def = PROVIDERS[providerId] + if (!def) { throw new Error(`Unknown provider: "${providerId}"`) } const apiKey = apiKeyOverride || getApiKeyForProvider(providerId) if (!apiKey) { - throw new Error(`${ProviderClass.displayName} API key not configured.`) + throw new Error(`${def.name} API key not configured.`) } - const rawModels = await ProviderClass.fetchModels(apiKey) + const rawModels = await def.fetchModels(apiKey) const entries = buildProviderCatalogEntries(providerId, rawModels) saveCatalog(providerId, entries) - logger.info(`[providers] Discovered ${entries.length} ${ProviderClass.displayName} models`) + logger.info(`[providers] Discovered ${entries.length} ${def.name} models`) return entries } @@ -228,7 +147,7 @@ export async function refreshProviderCatalog(providerId, apiKeyOverride) { * @throws {Error} When the endpoint is unreachable */ export async function refreshEndpointCatalog(endpoint) { - const rawModels = await CustomEndpointProvider.fetchModels(endpoint.api_key || '', endpoint.base_url) + const rawModels = await fetchEndpointModels(endpoint.api_key || '', endpoint.base_url) const entries = buildEndpointCatalogEntries(endpoint, rawModels) saveCatalog(`custom:${endpoint.id}`, entries) logger.info(`[providers] Discovered ${entries.length} models on custom endpoint "${endpoint.name}"`) @@ -245,15 +164,14 @@ export async function refreshAllCatalogs() { const refreshed = [] const errors = {} - for (const ProviderClass of PROVIDER_CLASSES) { - const providerId = ProviderClass.providerId - if (!getApiKeyForProvider(providerId)) continue + for (const def of PROVIDER_DEFS) { + if (!getApiKeyForProvider(def.id)) continue try { - await refreshProviderCatalog(providerId) - refreshed.push(providerId) + await refreshProviderCatalog(def.id) + refreshed.push(def.id) } catch (err) { - errors[providerId] = err.message - logger.warn(`[providers] Catalog refresh failed for ${providerId}: ${err.message}`) + errors[def.id] = err.message + logger.warn(`[providers] Catalog refresh failed for ${def.id}: ${err.message}`) } } @@ -272,27 +190,20 @@ export async function refreshAllCatalogs() { } /** - * Clean up after a provider's API key is removed: - * drop its discovered catalog and cached provider instances. + * Clean up after a provider's API key is removed: drop its discovered catalog. * @param {string} providerId */ export function handleProviderKeyRemoved(providerId) { clearCatalog(providerId) - for (const key of providerCache.keys()) { - if (key.startsWith(`${providerId}:`)) providerCache.delete(key) - } } /** * Clean up after a custom endpoint is removed or edited: - * drop its discovered catalog and cached provider instances. + * drop its discovered catalog. * @param {string} endpointId */ export function handleEndpointRemoved(endpointId) { clearCatalog(`custom:${endpointId}`) - for (const key of providerCache.keys()) { - if (key.startsWith(`custom:${endpointId}:`)) providerCache.delete(key) - } } // ═══════════════════════════════════════════════════════════════ @@ -309,24 +220,19 @@ export function handleEndpointRemoved(endpointId) { export function getAvailableModels() { const result = [] - for (const ProviderClass of PROVIDER_CLASSES) { - const providerId = ProviderClass.providerId - const apiKey = getApiKeyForProvider(providerId) + for (const def of PROVIDER_DEFS) { + const apiKey = getApiKeyForProvider(def.id) if (!apiKey) continue - const cached = loadCatalog(providerId) - const models = (cached?.models?.length ? cached.models : ProviderClass.models) + const cached = loadCatalog(def.id) + const models = cached?.models?.length ? cached.models : def.models result.push({ - provider: { - id: providerId, - name: ProviderClass.displayName, - color: ProviderClass.brandColor, - }, - models: models.map(m => ({ + provider: { id: def.id, name: def.name, color: def.color }, + models: models.map((m) => ({ ...m, - providerId, - providerName: ProviderClass.displayName, + providerId: def.id, + providerName: def.name, })), }) } @@ -340,7 +246,7 @@ export function getAvailableModels() { color: CUSTOM_ENDPOINT_COLOR, isCustom: true, }, - models: (cached?.models || []).map(m => ({ + models: (cached?.models || []).map((m) => ({ ...m, providerId: `custom:${endpoint.id}`, providerName: endpoint.name, @@ -357,9 +263,8 @@ export function getAvailableModels() { */ export function getApiKeyStatus() { const status = {} - for (const ProviderClass of PROVIDER_CLASSES) { - const config = db.prepare("SELECT value FROM config WHERE key = ?").get(ProviderClass.configKey) - status[ProviderClass.providerId] = !!(config?.value) + for (const def of PROVIDER_DEFS) { + status[def.id] = !!getApiKeyForProvider(def.id) } return status } @@ -371,16 +276,16 @@ export function getApiKeyStatus() { * @returns {Array<{ id, name, shortName, configKey, color, keyPlaceholder, keyHelpUrl, keyHelpLabel, capabilities }>} */ export function getProviderDefinitions() { - return PROVIDER_CLASSES.map(P => ({ - id: P.providerId, - name: P.displayName, - shortName: P.shortName, - configKey: P.configKey, - color: P.brandColor, - keyPlaceholder: P.keyPlaceholder, - keyHelpUrl: P.keyHelpUrl, - keyHelpLabel: P.keyHelpLabel, - capabilities: P.capabilities, + return PROVIDER_DEFS.map((def) => ({ + id: def.id, + name: def.name, + shortName: def.shortName, + configKey: def.configKey, + color: def.color, + keyPlaceholder: def.keyPlaceholder, + keyHelpUrl: def.keyHelpUrl, + keyHelpLabel: def.keyHelpLabel, + capabilities: def.capabilities, })) } @@ -390,7 +295,7 @@ export function getProviderDefinitions() { * @returns {string[]} */ export function getApiKeyFields() { - return PROVIDER_CLASSES.map(P => P.configKey) + return PROVIDER_DEFS.map((def) => def.configKey) } /** @@ -399,26 +304,26 @@ export function getApiKeyFields() { * @returns {string|null} */ export function getProviderIdForConfigKey(configKey) { - const ProviderClass = PROVIDER_CLASSES.find(P => P.configKey === configKey) - return ProviderClass ? ProviderClass.providerId : null + const def = PROVIDER_DEFS.find((p) => p.configKey === configKey) + return def ? def.id : null } /** * Seed API keys from environment variables for all registered providers. - * Called during database initialization. + * Called during server startup. */ export function seedApiKeysFromEnv() { - for (const ProviderClass of PROVIDER_CLASSES) { - const envValue = process.env[ProviderClass.envKey] + for (const def of PROVIDER_DEFS) { + const envValue = process.env[def.envKey] if (envValue) { - const existing = db.prepare("SELECT value FROM config WHERE key = ?").get(ProviderClass.configKey) + const existing = db.prepare('SELECT value FROM config WHERE key = ?').get(def.configKey) if (!existing?.value) { db.prepare(` INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at - `).run(ProviderClass.configKey, envValue) - logger.info(`[db] Seeded ${ProviderClass.displayName} API key from environment`) + `).run(def.configKey, envValue) + logger.info(`[db] Seeded ${def.name} API key from environment`) } } } diff --git a/server/providers/openai.js b/server/providers/openai.js deleted file mode 100644 index 365b982..0000000 --- a/server/providers/openai.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @fileoverview OpenAI provider (api.openai.com). - * - * All wire-format logic lives in OpenAICompatibleProvider β€” this class - * adds the branding, config metadata, and the static fallback catalog - * shown before the first live model discovery completes. - */ - -import { OpenAICompatibleProvider } from './openai_compatible.js' - -export class OpenAIProvider extends OpenAICompatibleProvider { - // ═══════════════════════════════════════════════════════════ - // Static metadata (self-describing) - // ═══════════════════════════════════════════════════════════ - - static get providerId() { return 'openai' } - static get displayName() { return 'OpenAI' } - static get shortName() { return 'OpenAI' } - static get brandColor() { return '#10A37F' } - static get configKey() { return 'openai_api_key' } - static get envKey() { return 'OPENAI_API_KEY' } - static get keyPlaceholder() { return 'sk-...' } - static get keyHelpUrl() { return 'https://platform.openai.com/api-keys' } - static get keyHelpLabel() { return 'OpenAI Platform' } - - /** - * Static fallback catalog: the GPT-5.6 family (July 2026). - * Live discovery (GET /v1/models) replaces this list as soon as a - * key is verified. - */ - static get models() { - return [ - { id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', description: 'Flagship β€” hardest coding, agents, and research', family: 'Flagship' }, - { id: 'gpt-5.6-terra', name: 'GPT-5.6 Terra', description: 'Balanced quality and cost for most tasks', family: 'Balanced' }, - { id: 'gpt-5.6-luna', name: 'GPT-5.6 Luna', description: 'Fastest and most cost-effective', family: 'Fast' }, - ] - } - - static get capabilities() { - return { - streaming: true, - toolCalling: true, - jsonMode: true, - embeddings: false, - } - } - - static ownsModelId(modelId) { - return /^(gpt|o\d|chatgpt|codex)/i.test(modelId || '') - } - - /** Reasoning models on api.openai.com require the modern parameter. */ - get maxTokensParam() { - return 'max_completion_tokens' - } -} diff --git a/server/providers/openai_compatible.js b/server/providers/openai_compatible.js deleted file mode 100644 index 5d180a7..0000000 --- a/server/providers/openai_compatible.js +++ /dev/null @@ -1,407 +0,0 @@ -/** - * @fileoverview OpenAI-compatible provider core. - * - * Speaks the standard OpenAI wire format (`/chat/completions`, `/models`) - * over plain fetch β€” no SDK dependency. Two concrete providers build on - * this class: - * - * - OpenAIProvider β†’ api.openai.com (branded, static metadata) - * - CustomEndpointProvider β†’ user-configured servers (Ollama, LM Studio, - * vLLM, OpenRouter, Groq, ...) - * - * Structured output (generateJSON) adapts to the server's capability: - * - * json_schema β†’ json_object β†’ prompt-enforced JSON - * - * The first mode that succeeds is cached per (baseUrl, model), so later - * calls skip the failing modes. - */ - -import { AIProvider } from './base.js' -import logger from '../utils/logger.js' - -/** Ordered list of structured-output modes, most strict first. */ -const JSON_MODES = ['json_schema', 'json_object', 'prompt'] - -/** Cache: `${baseUrl}|${model}` β†’ index into JSON_MODES that last worked. */ -const jsonModeCache = new Map() - -export class OpenAICompatibleProvider extends AIProvider { - // ═══════════════════════════════════════════════════════════ - // Static metadata β€” subclasses override the branding - // ═══════════════════════════════════════════════════════════ - - static get apiBaseUrl() { return 'https://api.openai.com/v1' } - - /** - * @param {string} apiKey - Bearer token (may be empty for local servers) - * @param {string} [baseUrl] - Override the API base URL - */ - constructor(apiKey, baseUrl) { - super(apiKey) - this.baseUrl = (baseUrl || new.target.apiBaseUrl).replace(/\/+$/, '') - } - - // ═══════════════════════════════════════════════════════════ - // HTTP plumbing - // ═══════════════════════════════════════════════════════════ - - _headers() { - const headers = { 'Content-Type': 'application/json' } - if (this.apiKey) headers['Authorization'] = `Bearer ${this.apiKey}` - return headers - } - - /** - * Name of the max-tokens request parameter. - * Local engines expect the classic `max_tokens`; the branded OpenAI - * provider overrides this with `max_completion_tokens` (required by - * reasoning models). - */ - get maxTokensParam() { - return 'max_tokens' - } - - /** - * Resolve the model ID to send upstream. - * Strips the `custom::` namespace prefix that the global - * model picker uses for custom endpoint models. - */ - _resolveModel(model) { - const id = model || this.constructor.defaultModel - if (id && id.startsWith('custom:')) { - return id.split(':').slice(2).join(':') - } - return id - } - - /** - * POST /chat/completions (non-streaming). - * @returns {Promise} The parsed response body - * @throws {Error} With the server's error message on failure - */ - async _chatCompletion(body) { - const res = await fetch(`${this.baseUrl}/chat/completions`, { - method: 'POST', - headers: this._headers(), - body: JSON.stringify(body), - }) - if (!res.ok) { - throw new Error(await extractErrorMessage(res)) - } - return res.json() - } - - // ═══════════════════════════════════════════════════════════ - // AIProvider implementation - // ═══════════════════════════════════════════════════════════ - - async generateText(prompt, options = {}) { - const messages = [] - if (options.systemPrompt) messages.push({ role: 'system', content: options.systemPrompt }) - messages.push({ role: 'user', content: prompt }) - - const body = { - model: this._resolveModel(options.model), - messages, - [this.maxTokensParam]: options.maxOutputTokens ?? 8192, - } - if (options.temperature !== undefined) body.temperature = options.temperature - - const data = await this._chatCompletion(body) - return data.choices?.[0]?.message?.content ?? '' - } - - /** - * Structured JSON generation with adaptive downgrade. - * Tries `json_schema`, then `json_object`, then prompt-only enforcement. - * A mode fails on an API error or an unparseable response; the next - * mode then runs. The first working mode is cached per (baseUrl, model). - */ - async generateJSON(prompt, schema, options = {}) { - const model = this._resolveModel(options.model) - const cacheKey = `${this.baseUrl}|${model}` - const startIndex = jsonModeCache.get(cacheKey) ?? 0 - - let lastError = null - for (let i = startIndex; i < JSON_MODES.length; i++) { - const mode = JSON_MODES[i] - try { - const result = await this._generateJSONWithMode(mode, prompt, schema, model, options) - jsonModeCache.set(cacheKey, i) - return result - } catch (err) { - lastError = err - logger.warn( - `[OpenAICompatibleProvider] JSON mode "${mode}" failed for ${model} at ${this.baseUrl}: ${err.message}` - ) - } - } - throw lastError || new Error('Structured JSON generation failed') - } - - async _generateJSONWithMode(mode, prompt, schema, model, options) { - let systemPrompt = options.systemPrompt || '' - systemPrompt += - '\n\nIMPORTANT: Respond with valid JSON only. No markdown, no code fences, no explanation β€” just raw JSON.' - - const body = { - model, - [this.maxTokensParam]: options.maxOutputTokens ?? 8192, - } - if (options.temperature !== undefined) body.temperature = options.temperature - - // OpenAI json_schema mode requires an object at the schema root. - // Wrap array/scalar roots into { items: ... } and unwrap after. - const needsWrap = schema && schema.type !== 'object' - - if (mode === 'json_schema') { - const effectiveSchema = needsWrap - ? { type: 'object', properties: { items: schema }, required: ['items'] } - : (schema || { type: 'object' }) - body.response_format = { - type: 'json_schema', - json_schema: { name: 'structured_response', schema: effectiveSchema }, - } - if (needsWrap) { - systemPrompt += '\n\nReturn a JSON object with a single "items" key holding the requested data.' - } - } else { - if (mode === 'json_object') { - body.response_format = { type: 'json_object' } - } - if (schema) { - systemPrompt += `\n\nThe response must conform to this JSON schema:\n${JSON.stringify(schema, null, 2)}` - if (schema.type === 'array') { - systemPrompt += '\n\nReturn the JSON array directly (or an object with an "items" array).' - } - } - } - - body.messages = [ - { role: 'system', content: systemPrompt.trim() }, - { role: 'user', content: prompt }, - ] - - const data = await this._chatCompletion(body) - const text = data.choices?.[0]?.message?.content ?? '' - const parsed = parseJSONResponse(text) - - // Unwrap { items: [...] } when the root schema was not an object - if (needsWrap && parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'items' in parsed) { - return parsed.items - } - return parsed - } - - async *streamChat(systemPrompt, history, message, options = {}) { - const messages = [ - { role: 'system', content: systemPrompt }, - ...mapHistory(history), - { role: 'user', content: message }, - ] - - const res = await fetch(`${this.baseUrl}/chat/completions`, { - method: 'POST', - headers: this._headers(), - body: JSON.stringify({ - model: this._resolveModel(options.model), - messages, - [this.maxTokensParam]: options.maxOutputTokens ?? 8192, - temperature: options.temperature ?? 0.5, - stream: true, - }), - }) - if (!res.ok) { - throw new Error(await extractErrorMessage(res)) - } - - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - for (const line of lines) { - if (!line.startsWith('data: ')) continue - const payload = line.slice(6).trim() - if (payload === '[DONE]') return - try { - const parsed = JSON.parse(payload) - const text = parsed.choices?.[0]?.delta?.content - if (text) yield { type: 'text', text } - } catch { - // Skip malformed SSE lines - } - } - } - } - - async *streamChatWithTools(systemPrompt, history, message, tools, toolExecutor, options = {}) { - const model = this._resolveModel(options.model) - const openaiTools = tools.map((t) => ({ - type: 'function', - function: { name: t.name, description: t.description, parameters: t.parameters }, - })) - - const messages = [ - { role: 'system', content: systemPrompt }, - ...mapHistory(history), - { role: 'user', content: message }, - ] - - let firstTurn = true - let continueLoop = true - - while (continueLoop) { - continueLoop = false - - let data - try { - data = await this._chatCompletion({ - model, - messages, - tools: openaiTools, - [this.maxTokensParam]: options.maxOutputTokens ?? 8192, - temperature: options.temperature ?? 0.5, - }) - } catch (err) { - // Some local engines reject the `tools` parameter entirely. - // Degrade gracefully to a plain streaming chat on the first turn. - if (firstTurn && /tool|function/i.test(err.message || '')) { - logger.warn( - `[OpenAICompatibleProvider] ${this.baseUrl} rejected tools (${err.message}). Falling back to plain chat.` - ) - yield* this.streamChat(systemPrompt, history, message, options) - return - } - throw err - } - firstTurn = false - - const choice = data.choices?.[0] - const msg = choice?.message || {} - - if (msg.content) { - yield { type: 'text', text: msg.content } - } - - const toolCalls = msg.tool_calls || [] - if (toolCalls.length > 0) { - messages.push({ role: 'assistant', content: msg.content ?? null, tool_calls: toolCalls }) - - for (const call of toolCalls) { - const name = call.function?.name - yield { type: 'tool', name } - let args = {} - try { - args = JSON.parse(call.function?.arguments || '{}') - } catch { - // Malformed arguments from the model β€” execute with empty args - } - const result = await toolExecutor(name, args) - messages.push({ - role: 'tool', - tool_call_id: call.id, - content: JSON.stringify(result), - }) - } - continueLoop = true - } - } - } - - /** - * Verify the credential/endpoint by listing models. - * Cheap (no token spend) and works on every OpenAI-compatible server. - */ - async testApiKey(apiKey) { - await this.constructor.fetchModels(apiKey, this.baseUrl) - return true - } - - /** - * Ping this instance's endpoint and return its live model list. - * @returns {Promise>} - */ - async listModels() { - return this.constructor.fetchModels(this.apiKey, this.baseUrl) - } - - /** - * GET /models from an OpenAI-compatible server. - * - * @param {string} apiKey - Bearer token (may be empty) - * @param {string} [baseUrl] - The server base URL - * @returns {Promise>} Raw models - * @throws {Error} With a connectivity-friendly message on failure - */ - static async fetchModels(apiKey, baseUrl) { - const base = (baseUrl || this.apiBaseUrl).replace(/\/+$/, '') - const headers = {} - if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` - - let res - try { - res = await fetch(`${base}/models`, { headers }) - } catch (err) { - throw new Error(`Could not reach ${base} β€” ${err.message}`, { cause: err }) - } - if (!res.ok) { - throw new Error(await extractErrorMessage(res)) - } - - const data = await res.json() - const rawModels = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : [] - return rawModels - .filter((m) => m && (m.id || m.name)) - .map((m) => ({ - id: m.id || m.name, - // OpenAI-style `created` is unix seconds - releasedAt: typeof m.created === 'number' ? m.created * 1000 : null, - })) - } -} - -// ═══════════════════════════════════════════════════════════════ -// Helpers -// ═══════════════════════════════════════════════════════════════ - -/** Map internal chat roles ('ai'/'model') to OpenAI roles. */ -function mapHistory(history) { - return (history || []).map((msg) => ({ - role: msg.role === 'ai' || msg.role === 'model' ? 'assistant' : 'user', - content: msg.content, - })) -} - -/** Parse a JSON response, stripping markdown code fences if present. */ -function parseJSONResponse(text) { - const cleaned = String(text) - .replace(/^```(?:json)?\s*\n?/i, '') - .replace(/\n?```\s*$/i, '') - .trim() - return JSON.parse(cleaned) -} - -/** Pull a useful error message out of a failed HTTP response. */ -async function extractErrorMessage(res) { - let detail = '' - try { - const body = await res.json() - detail = body?.error?.message || body?.message || '' - } catch { - // Non-JSON error body - } - return detail || `Request failed with status ${res.status}` -} - -/** Test-only: reset the structured-output mode cache. */ -export function _resetJsonModeCache() { - jsonModeCache.clear() -} diff --git a/server/routes/chat.js b/server/routes/chat.js index 5726a9f..60248a2 100644 --- a/server/routes/chat.js +++ b/server/routes/chat.js @@ -1,22 +1,66 @@ import { Router } from 'express' import crypto from 'crypto' +import { tool } from 'ai' +import { z } from 'zod' import db from '../db.js' import { PILLARS, BLUEPRINT_SECTIONS } from '../../src/utils/constants.js' import logger from '../utils/logger.js' import { generateEmbedding, cosineSimilarity } from '../utils/embeddings.js' -import { getProvider } from '../providers/index.js' +import { runText, runStructured, chatStream } from '../ai/engine.js' const router = Router() +// ═══════════════════════════════════════════════════════════════ +// Shared helpers +// ═══════════════════════════════════════════════════════════════ +/** Map stored chat history roles ('ai'/'model') to AI SDK messages. */ +function toModelMessages(history) { + return (history || []) + .filter((msg) => typeof msg?.content === 'string' && msg.content.trim() !== '') + .map((msg) => ({ + role: msg.role === 'ai' || msg.role === 'model' ? 'assistant' : 'user', + content: msg.content, + })) +} + +/** + * Rank rows by cosine similarity between a query embedding and each + * row's stored embedding (a JSON array string). Returns the top N rows. + */ +function rankBySimilarity(queryEmbedding, rows, topN) { + return rows + .map((row) => { + let sim = 0 + try { + if (row.embedding) sim = cosineSimilarity(queryEmbedding, JSON.parse(row.embedding)) + } catch { + // Unreadable embedding β€” treat as unrelated + } + return { ...row, sim } + }) + .sort((a, b) => b.sim - a.sim) + .slice(0, topN) +} + +/** Base tutor persona shared by the chat routes. */ +function tutorSystemPrompt(context) { + return context + ? `You are an expert system design interview tutor. Context: ${context}` + : 'You are an expert system design interview tutor helping a student prepare for system design interviews.' +} + +// ═══════════════════════════════════════════════════════════════ +// Routes +// ═══════════════════════════════════════════════════════════════ /** * GET /api/chat/starters * Generate dynamic chat starters based on a specific topic's guide content, blueprint, and user profile. - * Query: ?pillarId=X&topicId=Y&topicName=Z&model=gemini-3.5-flash + * Query: ?pillarId=X&topicId=Y&topicName=Z&model=... */ router.get('/starters', async (req, res) => { - const { pillarId, topicId, topicName, model: requestedModel } = req.query + const { pillarId, topicId, topicName, model } = req.query if (!pillarId || !topicId) { return res.status(400).json({ message: 'pillarId and topicId are required' }) @@ -27,14 +71,14 @@ router.get('/starters', async (req, res) => { const pillar = PILLARS.find(p => p.id === pillarId) || { name: pillarId, topics: [] } const topic = pillar.topics.find(t => t.id === topicId) || { name: topicName || topicId } const blueprint = BLUEPRINT_SECTIONS[pillarId] || [] - + // 2. Fetch current guide content for this topic const rows = db.prepare('SELECT section_id, content FROM guide_content WHERE pillar_id = ? AND topic_id = ?').all(pillarId, topicId) - + // 3. Separate completed vs missing sections const completedSections = [] const missingSections = [] - + blueprint.forEach(sec => { const row = rows.find(r => r.section_id === sec.id) if (row && row.content && row.content.trim().length > 0) { @@ -43,7 +87,7 @@ router.get('/starters', async (req, res) => { missingSections.push({ name: sec.name }) } }) - + // If no blueprint is defined, just use raw content if (blueprint.length === 0) { rows.forEach(r => completedSections.push({ name: r.section_id, content: r.content })) @@ -64,7 +108,7 @@ router.get('/starters', async (req, res) => { // 6. Check cache const cached = db.prepare('SELECT suggestions, content_hash FROM chat_starters WHERE pillar_id = ? AND topic_id = ?').get(pillarId, topicId) - + if (cached && cached.content_hash === contentHash && cached.suggestions !== '[]') { try { const parsed = JSON.parse(cached.suggestions) @@ -78,9 +122,6 @@ router.get('/starters', async (req, res) => { } // 7. Cache missing or stale β€” generate new ones - const modelId = requestedModel || 'gemini-3.5-flash' - const provider = getProvider(modelId) - const prompt = `You are an expert system design tutor creating contextual chat starters. The user is studying the topic: "${topic.name}" (Part of the "${pillar.name}" pillar). @@ -102,37 +143,36 @@ Based on this state, generate 12 to 15 highly targeted starter questions the use - Tailor the questions to their user profile if relevant (e.g. focusing on their weak points or upcoming interviews). - Format as short, actionable questions they would ask YOU (e.g. "Can you quiz me on [X]?", "How does [X] handle [Y] failure mode?").` - const schema = { - type: 'array', - items: { type: 'string' }, - description: 'An array of 12 to 15 short, engaging study questions for the user to ask the AI.' - } - - let parsedSuggestions + let suggestions try { - parsedSuggestions = await provider.generateJSON(prompt, schema, { model: modelId }) - if (!Array.isArray(parsedSuggestions)) { - parsedSuggestions = ["Let's do a deep dive on this topic", "Test my knowledge on this topic"] + suggestions = await runStructured({ + model, + prompt, + element: z.string().describe('A short, engaging study question for the user to ask the AI'), + feature: 'chat/starters', + }) + if (!Array.isArray(suggestions) || suggestions.length === 0) { + throw new Error('Empty suggestions') } } catch { - parsedSuggestions = ["Let's do a deep dive on this topic", "Test my knowledge on this topic"] + suggestions = ["Let's do a deep dive on this topic", "Test my knowledge on this topic"] } // Save to DB (save all generated prompts) db.prepare(` INSERT INTO chat_starters (pillar_id, topic_id, suggestions, content_hash, updated_at) VALUES (?, ?, ?, ?, datetime('now')) - ON CONFLICT(pillar_id, topic_id) DO UPDATE SET + ON CONFLICT(pillar_id, topic_id) DO UPDATE SET suggestions = excluded.suggestions, content_hash = excluded.content_hash, updated_at = excluded.updated_at - `).run(pillarId, topicId, JSON.stringify(parsedSuggestions), contentHash) + `).run(pillarId, topicId, JSON.stringify(suggestions), contentHash) // Return a random selection of 6 prompts - const shuffled = [...parsedSuggestions].sort(() => 0.5 - Math.random()) + const shuffled = [...suggestions].sort(() => 0.5 - Math.random()) res.json({ suggestions: shuffled.slice(0, 6) }) } catch (err) { - logger.error('[chat/starters] Error:', err.message) + logger.error('[chat/starters] Error:', { error: err.message }) res.status(500).json({ message: 'Failed to generate starters.' }) } }) @@ -140,67 +180,129 @@ Based on this state, generate 12 to 15 highly targeted starter questions the use /** * POST /api/chat * Send a message and get a response (non-streaming). - * Body: { message, context, history } + * Body: { message, context, history, model } */ router.post('/', async (req, res) => { - const { message, context, history = [], model: requestedModel } = req.body + const { message, context, history = [], model } = req.body if (!message) { return res.status(400).json({ message: 'Message is required' }) } try { - const modelId = requestedModel || 'gemini-3.5-flash' - const provider = getProvider(modelId) - - // Build system context based on the page - const systemContext = context - ? `You are an expert system design interview tutor. Context: ${context}\n\n` - : 'You are an expert system design interview tutor helping a student prepare for system design interviews.\n\n' - - const historyFormatted = history.map((msg) => ({ - role: msg.role === 'ai' ? 'model' : 'user', - content: msg.content, - })) - - // Use generateText with the full prompt including history context - const historyText = historyFormatted.map(m => `${m.role}: ${m.content}`).join('\n') - const fullPrompt = historyText ? `${historyText}\nuser: ${message}` : message - - const response = await provider.generateText(fullPrompt, { - model: modelId, - systemPrompt: systemContext, - temperature: 0.5, - maxOutputTokens: 8192, + const response = await runText({ + model, + system: tutorSystemPrompt(context), + messages: [...toModelMessages(history), { role: 'user', content: message }], + feature: 'chat', }) - res.json({ response }) } catch (err) { - logger.error('[chat] Error:', err.message) - res.status(500).json({ message: 'Failed to get AI response. Please check your API key.' }) + logger.error('[chat] Error:', { error: err.message }) + res.status(500).json({ message: err.message || 'Failed to get AI response.' }) } }) +// ─── Streaming chat with tools ──────────────────────────────────────────────── + +/** Tool set for the streaming chat: just-in-time retrieval from the DB. */ +function buildChatTools() { + return { + search_flashcards: tool({ + description: + "Search the user's flashcards by meaning. Use this to see what the user has learned, quiz them on their own cards, or check what they struggle with.", + inputSchema: z.object({ + query: z.string().describe('Search query for flashcards'), + }), + execute: async ({ query }) => { + const queryEmbedding = await generateEmbedding(query) + const rows = db.prepare('SELECT front, back, state, ease_factor, embedding FROM flashcards').all() + if (queryEmbedding.length === 0) { + return rows.slice(0, 5).map(({ front, back, state, ease_factor }) => ({ front, back, state, ease_factor })) + } + const top = rankBySimilarity(queryEmbedding, rows, 5) + .map(({ front, back, state, ease_factor }) => ({ front, back, state, ease_factor })) + return top.length ? top : 'No flashcards found.' + }, + }), + search_guide: tool({ + description: + "Search the user's system design guide notes by meaning. Use this to ground answers in what the user has already written.", + inputSchema: z.object({ + query: z.string().describe('Topic to search for in the guide'), + }), + execute: async ({ query }) => { + const queryEmbedding = await generateEmbedding(query) + const rows = db.prepare("SELECT content, embedding FROM guide_content WHERE content != ''").all() + if (queryEmbedding.length === 0) { + return rows.slice(0, 3).map((r) => r.content) + } + const top = rankBySimilarity(queryEmbedding, rows, 3).map((r) => r.content) + return top.length ? top : 'No guide content found.' + }, + }), + } +} + +/** + * Extract long-term episodic memories from a finished chat turn. + * Runs after the response streamed; failures only log. + */ +async function extractEpisodicMemories({ model, history, message, generatedText }) { + const transcript = toModelMessages(history) + .map((m) => `${m.role}: ${m.content}`) + .join('\n') + + const prompt = `You are the autonomous memory manager for this user. +Analyze the following latest interaction. Extract ANY new, highly important episodic learning events (struggles, analogies that clicked, specific facts mastered). +Only return events that are worth remembering long-term. Return an empty list when nothing qualifies. + +Chat History: +${transcript} +user: ${message} +model: ${generatedText}` + + const { events } = await runStructured({ + model, + prompt, + schema: z.object({ + events: z.array( + z.object({ + memory_text: z.string().describe('A concise description of the learning event'), + importance_score: z.number().describe('Importance score from 1 to 10'), + }) + ), + }), + feature: 'chat/memory', + }) + + for (const ev of events || []) { + const embedding = await generateEmbedding(ev.memory_text) + db.prepare('INSERT INTO episodic_memory (memory_text, importance_score, embedding, created_at) VALUES (?, ?, ?, datetime(\'now\'))') + .run(ev.memory_text, ev.importance_score, JSON.stringify(embedding)) + } + if (events?.length) { + logger.info(`[chat/memory] Extracted ${events.length} episodic memories.`) + } +} + /** * POST /api/chat/stream * Stream a response via Server-Sent Events (SSE). - * Body: { message, context, history } + * Body: { message, context, history, model } + * + * The system prompt stays small: persona, user profile, and the most + * relevant episodic memories. The model retrieves flashcards and guide + * notes just-in-time through tools instead of receiving the whole + * database up front. */ router.post('/stream', async (req, res) => { - const { message, context, history = [], model: requestedModel } = req.body + const { message, context, history = [], model } = req.body if (!message) { return res.status(400).json({ message: 'Message is required' }) } - const modelId = requestedModel || 'gemini-3.5-flash' - let provider - try { - provider = getProvider(modelId) - } catch (err) { - return res.status(400).json({ message: err.message }) - } - // Set SSE headers res.setHeader('Content-Type', 'text/event-stream') res.setHeader('Cache-Control', 'no-cache') @@ -208,218 +310,85 @@ router.post('/stream', async (req, res) => { res.setHeader('X-Accel-Buffering', 'no') res.flushHeaders() - let isAborted = false; - res.on('close', () => { - isAborted = true; - }); + // Cancel the upstream model call when the client disconnects. + const abortController = new AbortController() + res.on('close', () => abortController.abort()) + + // Comment heartbeat keeps proxies from closing an idle stream. + const heartbeat = setInterval(() => { + if (!res.writableEnded) res.write(': ping\n\n') + }, 15_000) try { - // ─── 1. Infinite Working Memory (No Compaction) ───────────────────────── - const fullHistory = history; - - // Fetch all flashcards and guide notes to build the ultimate context - const allFlashcards = db.prepare("SELECT id, front, back, state, interval FROM flashcards").all(); - const allGuides = db.prepare("SELECT section_id, content FROM guide_content WHERE content != ''").all(); - - let globalKnowledgeContext = `\n\n--- GLOBAL FLASHCARD DATABASE ---\n`; - allFlashcards.forEach(f => { - globalKnowledgeContext += `Card [${f.id}]: Q: ${f.front} | A: ${f.back} (State: ${f.state}, Interval: ${f.interval})\n`; - }); - globalKnowledgeContext += `\n--- GLOBAL GUIDE NOTES ---\n`; - allGuides.forEach(g => { - globalKnowledgeContext += `Section [${g.section_id}]: ${g.content}\n`; - }); - - // ─── 2. Autonomous Episodic Memory Injection ──────────────────────────── - const profileRow = db.prepare("SELECT profile_text FROM user_profile WHERE id = 1").get(); - const userProfile = profileRow?.profile_text || ""; - - // Semantic search for episodic memories related to current message - const msgEmbedding = await generateEmbedding(message); - const episodes = db.prepare("SELECT memory_text, embedding FROM episodic_memory ORDER BY created_at DESC").all(); - - let topEpisodes = []; - if (msgEmbedding.length > 0) { - const scored = episodes.map(ep => { - let sim = 0; - try { - const epEmb = JSON.parse(ep.embedding); - sim = cosineSimilarity(msgEmbedding, epEmb); - } catch (e) { void e; } - return { text: ep.memory_text, sim }; - }).sort((a, b) => b.sim - a.sim); - topEpisodes = scored.slice(0, 5).map(e => e.text); + // Relevant long-term context only: profile + top episodic memories. + const profileRow = db.prepare('SELECT profile_text FROM user_profile WHERE id = 1').get() + const userProfile = profileRow?.profile_text || '' + + const messageEmbedding = await generateEmbedding(message) + let topEpisodes = [] + if (messageEmbedding.length > 0) { + const episodes = db.prepare('SELECT memory_text, embedding FROM episodic_memory ORDER BY created_at DESC').all() + topEpisodes = rankBySimilarity(messageEmbedding, episodes, 5).map((e) => e.memory_text) } - let systemContext = context - ? `You are an expert system design interview tutor. Context: ${context}\n\n` - : 'You are an expert system design interview tutor helping a student prepare for system design interviews.\n\n'; - + let system = tutorSystemPrompt(context) + system += `\n\nThe user's flashcards and guide notes live in a database. Use the search_flashcards and search_guide tools whenever an answer should build on what the user has already studied or written.` if (userProfile) { - systemContext += `\n\n[Shadow Memory / User Profile]:\n${userProfile}`; + system += `\n\n[User Profile]:\n${userProfile}` } if (topEpisodes.length > 0) { - systemContext += `\n\n[Relevant Past Learning Episodes]:\n` + topEpisodes.map(t => `- ${t}`).join('\n'); - } - - // Append the massive knowledge base - systemContext += globalKnowledgeContext; - - // ─── 3. Tool definitions ──────────────────────────────────────────────── - const tools = [ - { - name: "search_flashcards", - description: "Search the user's flashcards. Useful to see what concepts they have learned or are struggling with.", - parameters: { - type: "object", - properties: { - query: { type: "string", description: "Search query for flashcards" } - }, - required: ["query"] - } - }, - { - name: "search_guide", - description: "Search the global system design guide content.", - parameters: { - type: "object", - properties: { - query: { type: "string", description: "Topic to search for in the guide" } - }, - required: ["query"] - } - } - ]; - - // ─── 4. Tool executor ─────────────────────────────────────────────────── - const toolExecutor = async (fnName, args) => { - if (fnName === 'search_flashcards') { - const q = args.query || ''; - const qEmb = await generateEmbedding(q); - const rows = db.prepare("SELECT front, back, state, ease_factor, embedding FROM flashcards").all(); - - if (qEmb.length > 0) { - const scored = rows.map(r => { - let sim = 0; - try { - if (r.embedding) sim = cosineSimilarity(qEmb, JSON.parse(r.embedding)); - } catch (e) { void e; } - return { ...r, sim }; - }).sort((a, b) => b.sim - a.sim); - const result = scored.slice(0, 5).map(r => ({ front: r.front, back: r.back, state: r.state, ease_factor: r.ease_factor })); - return result.length ? result : "No flashcards found."; - } else { - return rows.slice(0, 5); - } - } else if (fnName === 'search_guide') { - const q = args.query || ''; - const qEmb = await generateEmbedding(q); - const rows = db.prepare("SELECT content, embedding FROM guide_content").all(); - - if (qEmb.length > 0) { - const scored = rows.map(r => { - let sim = 0; - try { - if (r.embedding) sim = cosineSimilarity(qEmb, JSON.parse(r.embedding)); - } catch (e) { void e; } - return { ...r, sim }; - }).sort((a, b) => b.sim - a.sim); - const result = scored.slice(0, 3).map(r => r.content); - return result.length ? result : "No guide content found."; - } else { - return rows.slice(0, 3).map(r => r.content); - } - } - return null; - }; - - // ─── 5. Stream with tools ─────────────────────────────────────────────── - let generatedText = ''; - - const stream = provider.streamChatWithTools( - systemContext, - fullHistory, - message, - tools, - toolExecutor, - { model: modelId, temperature: 0.5, maxOutputTokens: 8192 } - ); - - for await (const chunk of stream) { - if (isAborted) break; - - if (chunk.type === 'tool') { - res.write(`data: ${JSON.stringify({ tool: "Running " + chunk.name + "..." })}\n\n`); - } else if (chunk.type === 'text') { - generatedText += chunk.text; - res.write(`data: ${JSON.stringify({ text: chunk.text })}\n\n`); - } + system += `\n\n[Relevant Past Learning Episodes]:\n` + topEpisodes.map((t) => `- ${t}`).join('\n') } - res.write('data: [DONE]\n\n') - res.end() + const messages = [...toModelMessages(history), { role: 'user', content: message }] - // ─── 6. Autonomous Memory Management ──────────────────────────────────── - const chatHistory = fullHistory.map((msg) => ({ - role: msg.role === 'ai' ? 'model' : 'user', - parts: [{ text: msg.content }], - })) - - setTimeout(async () => { - try { - if (chatHistory.length > 0) { - // Use the same provider for memory extraction - const extractionPrompt = `You are the autonomous memory manager for this user. -Analyze the following latest interaction. Extract ANY new, highly important episodic learning events (struggles, analogies that clicked, specific facts mastered). -Only return events that are worth remembering long-term. + const { result, modelId } = chatStream({ + model, + system, + messages, + tools: buildChatTools(), + abortSignal: abortController.signal, + }) -Chat History: -${chatHistory.map(m => `${m.role}: ${m.parts[0].text}`).join('\n')} -user: ${message} -model: ${generatedText}`; - - const schema = { - type: 'object', - properties: { - events: { - type: 'array', - items: { - type: 'object', - properties: { - memory_text: { type: 'string', description: 'A concise description of the learning event' }, - importance_score: { type: 'number', description: 'Importance score from 1 to 10' } - }, - required: ['memory_text', 'importance_score'] - } - } - }, - required: ['events'] - } - - let data; - try { - data = await provider.generateJSON(extractionPrompt, schema, { model: modelId }); - } catch(e) { - logger.error('[Memory Manager] JSON Parse error', e.message); - } - - if (data && data.events && data.events.length > 0) { - for (const ev of data.events) { - const emb = await generateEmbedding(ev.memory_text); - db.prepare("INSERT INTO episodic_memory (memory_text, importance_score, embedding, created_at) VALUES (?, ?, ?, datetime('now'))") - .run(ev.memory_text, ev.importance_score, JSON.stringify(emb)); - } - logger.info(`[Memory Manager] Extracted ${data.events.length} episodic memories.`); - } - } - } catch (err) { - logger.error('[Memory Manager] Error:', err.message); + let generatedText = '' + for await (const part of result.fullStream) { + if (res.writableEnded) break + if (part.type === 'text-delta' && part.text) { + generatedText += part.text + res.write(`data: ${JSON.stringify({ text: part.text })}\n\n`) + } else if (part.type === 'tool-call') { + res.write(`data: ${JSON.stringify({ tool: `Running ${part.toolName}...` })}\n\n`) + } else if (part.type === 'error') { + const detail = part.error?.message || String(part.error) + res.write(`data: ${JSON.stringify({ error: detail })}\n\n`) + } else if (part.type === 'finish') { + const usage = part.totalUsage + logger.info( + `[ai] chat/stream model=${modelId} finish=${part.finishReason} ` + + `in=${usage?.inputTokens ?? '?'} out=${usage?.outputTokens ?? '?'}` + ) } - }, 0); + } + + if (!res.writableEnded) { + res.write('data: [DONE]\n\n') + res.end() + } + + // Post-response memory extraction (fire and forget). + if (!abortController.signal.aborted && generatedText) { + extractEpisodicMemories({ model, history, message, generatedText }).catch((err) => { + logger.error('[chat/memory] Error:', { error: err.message }) + }) + } } catch (err) { - logger.error('[chat/stream] Error:', err.message) - res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`) - res.end() + logger.error('[chat/stream] Error:', { error: err.message }) + if (!res.writableEnded) { + res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`) + res.end() + } + } finally { + clearInterval(heartbeat) } }) @@ -430,16 +399,13 @@ model: ${generatedText}`; * When `sections` is provided, generates cards per-section with source tagging. */ router.post('/generate-flashcards', async (req, res) => { - const { text, topicName, model: requestedModel, sessionContext, sections, pillarId, topicId } = req.body + const { text, topicName, model, sessionContext, sections, pillarId, topicId } = req.body if (!text && (!sections || sections.length === 0)) { return res.status(400).json({ message: 'Text or sections are required to generate flashcards.' }) } try { - const modelId = requestedModel || 'gemini-3.5-flash' - const provider = getProvider(modelId) - // Build context section if available let contextBlock = '' if (sessionContext) { @@ -447,16 +413,12 @@ router.post('/generate-flashcards', async (req, res) => { } // Build source text β€” either section-aware or flat - let sourceBlock = '' - if (sections && sections.length > 0) { - sourceBlock = sections.map(s => - `=== SECTION: "${s.sectionName}" (id: "${s.sectionId}") ===\n${s.content}` - ).join('\n\n---\n\n') - } else { - sourceBlock = text - } + const sectionAware = sections && sections.length > 0 + const sourceBlock = sectionAware + ? sections.map(s => `=== SECTION: "${s.sectionName}" (id: "${s.sectionId}") ===\n${s.content}`).join('\n\n---\n\n') + : text - const sectionAwareInstructions = sections && sections.length > 0 + const sectionAwareInstructions = sectionAware ? `\n7. TAG EACH CARD with the section it came from using sourceSectionId and sourceSectionName fields. Use the exact section id and name provided above. 8. Generate cards from EACH section that has substantive content. Aim for 2-4 cards per section, but skip sections with insufficient depth.` : '' @@ -479,46 +441,35 @@ CARD FORMAT RULES (strictly follow): Source material: ${sourceBlock}` - // Build schema β€” with or without section tagging - const cardProperties = { - front: { type: 'string', description: 'The front of the flashcard: a concise 1-2 line question' }, - back: { type: 'string', description: 'The back of the flashcard: a dense 2-3 line answer' }, + const cardShape = { + front: z.string().describe('The front of the flashcard: a concise 1-2 line question'), + back: z.string().describe('The back of the flashcard: a dense 2-3 line answer'), } - const requiredFields = ['front', 'back'] - - if (sections && sections.length > 0) { - cardProperties.sourceSectionId = { - type: 'string', - description: 'The section id this card was generated from', - } - cardProperties.sourceSectionName = { type: 'string', description: 'The section name this card was generated from' } - requiredFields.push('sourceSectionId', 'sourceSectionName') - } - - const schema = { - type: 'array', - items: { - type: 'object', - properties: cardProperties, - required: requiredFields, - }, + if (sectionAware) { + cardShape.sourceSectionId = z.string().describe('The section id this card was generated from') + cardShape.sourceSectionName = z.string().describe('The section name this card was generated from') } - let generatedCards = await provider.generateJSON(prompt, schema, { model: modelId }) + let cards = await runStructured({ + model, + prompt, + element: z.object(cardShape), + feature: 'chat/flashcards', + }) // Attach source metadata if available if (pillarId || topicId) { - generatedCards = generatedCards.map(card => ({ + cards = cards.map(card => ({ ...card, sourcePillarId: pillarId || null, sourceTopicId: topicId || null, })) } - res.json({ cards: generatedCards }) + res.json({ cards }) } catch (err) { - logger.error('[chat/generate-flashcards] Error:', err.message) - res.status(500).json({ message: 'Failed to generate flashcards.' }) + logger.error('[chat/generate-flashcards] Error:', { error: err.message }) + res.status(500).json({ message: err.message || 'Failed to generate flashcards.' }) } }) @@ -528,15 +479,12 @@ ${sourceBlock}` * Body: { cards: [{ front, back }], model } */ router.post('/generate-reverse-cards', async (req, res) => { - const { cards, model: requestedModel } = req.body + const { cards, model } = req.body if (!cards || !Array.isArray(cards) || cards.length === 0) { return res.status(400).json({ message: 'Cards array is required.' }) } try { - const modelId = requestedModel || 'gemini-3.5-flash' - const provider = getProvider(modelId) - const cardList = cards.map((c, i) => `[Card ${i}]\nQ: ${c.front}\nA: ${c.back}` ).join('\n\n') @@ -558,24 +506,21 @@ ${cardList} Generate one reverse card per input card.` - const schema = { - type: 'array', - items: { - type: 'object', - properties: { - front: { type: 'string', description: 'Reverse question' }, - back: { type: 'string', description: 'Answer for the reverse question' }, - originalIndex: { type: 'integer', description: 'Index of the original card this reverses' }, - }, - required: ['front', 'back', 'originalIndex'], - }, - } + const reverseCards = await runStructured({ + model, + prompt, + element: z.object({ + front: z.string().describe('Reverse question'), + back: z.string().describe('Answer for the reverse question'), + originalIndex: z.number().int().describe('Index of the original card this reverses'), + }), + feature: 'chat/reverse-cards', + }) - const reverseCards = await provider.generateJSON(prompt, schema, { model: modelId }) res.json({ reverseCards }) } catch (err) { - logger.error('[chat/generate-reverse-cards] Error:', err.message) - res.status(500).json({ message: 'Failed to generate reverse cards.' }) + logger.error('[chat/generate-reverse-cards] Error:', { error: err.message }) + res.status(500).json({ message: err.message || 'Failed to generate reverse cards.' }) } }) @@ -585,22 +530,13 @@ Generate one reverse card per input card.` * Body: { excerpts, pillarId, topicId, sectionId, sectionName, topicName, model } */ router.post('/summarize', async (req, res) => { - const { - excerpts = [], - sectionId, - sectionName, - topicName, - model: requestedModel, - } = req.body + const { excerpts = [], sectionId, sectionName, topicName, model } = req.body if (!excerpts.length || !sectionId) { return res.status(400).json({ message: 'excerpts and sectionId are required' }) } try { - const modelId = requestedModel || 'gemini-3.5-flash' - const provider = getProvider(modelId) - const excerptText = excerpts.join('\n\n---\n\n') const prompt = `You are a technical writing assistant helping compile a system design study guide. @@ -623,11 +559,11 @@ ${excerptText} Now write the guide section content:` - const content = await provider.generateText(prompt, { model: modelId }) + const content = await runText({ model, prompt, feature: 'chat/summarize' }) res.json({ content }) } catch (err) { - logger.error('[chat/summarize] Error:', err.message) - res.status(500).json({ message: 'Failed to summarize. Please check your API key.' }) + logger.error('[chat/summarize] Error:', { error: err.message }) + res.status(500).json({ message: err.message || 'Failed to summarize.' }) } }) @@ -638,16 +574,13 @@ Now write the guide section content:` * Body: { explanation, front, back, model } */ router.post('/evaluate-interceptor', async (req, res) => { - const { explanation, front, back, model: requestedModel } = req.body + const { explanation, front, back, model } = req.body if (!explanation || !front || !back) { return res.status(400).json({ message: 'explanation, front, and back are required' }) } try { - const modelId = requestedModel || 'gemini-3.5-flash' - const provider = getProvider(modelId) - const prompt = `You are a strict learning evaluator. The user was asked a flashcard question and must explain WHY the answer is true to prove they aren't just pattern-matching. Question: ${front} @@ -657,33 +590,26 @@ User's Explanation: "${explanation}" Evaluate their explanation.` - const schema = { - type: 'object', - properties: { - pass: { - type: 'boolean', - description: "True if the user's explanation demonstrates an understanding of the underlying principle. False if they fail to explain the 'why', are too vague, or are incorrect." - }, - feedback: { - type: 'string', - description: "1-2 sentences of feedback explaining why they passed or failed, and reinforcing the correct concept." - } - }, - required: ["pass", "feedback"], - } - let evaluation try { - evaluation = await provider.generateJSON(prompt, schema, { model: modelId }) - } catch (e) { - logger.error('[chat/evaluate-interceptor] Failed to parse JSON:', e.message) - evaluation = { pass: false, feedback: "Error evaluating response format." } + evaluation = await runStructured({ + model, + prompt, + schema: z.object({ + pass: z.boolean().describe("True if the user's explanation demonstrates an understanding of the underlying principle. False if they fail to explain the 'why', are too vague, or are incorrect."), + feedback: z.string().describe('1-2 sentences of feedback explaining why they passed or failed, and reinforcing the correct concept.'), + }), + feature: 'chat/evaluate', + }) + } catch (err) { + logger.error('[chat/evaluate-interceptor] Evaluation failed:', { error: err.message }) + evaluation = { pass: false, feedback: 'Error evaluating response format.' } } res.json(evaluation) } catch (err) { - logger.error('[chat/evaluate-interceptor] Error:', err.message) - res.status(500).json({ message: 'Failed to evaluate. Please check your API key.' }) + logger.error('[chat/evaluate-interceptor] Error:', { error: err.message }) + res.status(500).json({ message: 'Failed to evaluate.' }) } }) @@ -692,19 +618,16 @@ Evaluate their explanation.` * @description Generates a Mermaid concept map from a session history. */ router.post('/concept-map', async (req, res) => { - const { history = [], model: requestedModel } = req.body + const { history = [], model } = req.body if (!history || history.length === 0) { return res.status(400).json({ message: 'History is required to generate a map.' }) } try { - const modelId = requestedModel || 'gemini-3.5-flash' - const provider = getProvider(modelId) - const historyText = history.map(msg => `${msg.role}: ${msg.content}`).join('\n\n') - const prompt = `You are an expert educational visualizer. Extract the key concepts, entities, and their relationships from the following chat history. + const prompt = `You are an expert educational visualizer. Extract the key concepts, entities, and their relationships from the following chat history. Your output MUST be a valid, syntactically correct \`mermaid\` graph definition (e.g. \`graph TD\`). Use concise node labels and relationship labels. Do not use complex mermaid syntax that might break rendering. Only output the markdown block containing the mermaid code. Do not output anything else. @@ -718,16 +641,16 @@ graph TD Chat History: ${historyText}` - let responseText = await provider.generateText(prompt, { model: modelId }) - + let responseText = await runText({ model, prompt, feature: 'chat/concept-map' }) + if (!responseText.includes('```mermaid')) { responseText = `\`\`\`mermaid\n${responseText.replace(/```/g, '')}\n\`\`\`` } res.json({ response: responseText }) } catch (err) { - logger.error('[chat/concept-map] Error:', err.message) - res.status(500).json({ message: 'Failed to generate concept map.' }) + logger.error('[chat/concept-map] Error:', { error: err.message }) + res.status(500).json({ message: err.message || 'Failed to generate concept map.' }) } }) @@ -738,7 +661,7 @@ ${historyText}` * Analyze a chat session against a guide topic's sections. */ router.post('/commit', async (req, res) => { - const { messages = [], pillarId, topicId, topicName, model: requestedModel, targetSectionIds = [] } = req.body + const { messages = [], pillarId, topicId, topicName, model, targetSectionIds = [] } = req.body if (!messages.length || !pillarId || !topicId) { return res.status(400).json({ message: 'messages, pillarId, and topicId are required' }) @@ -754,9 +677,6 @@ router.post('/commit', async (req, res) => { } try { - const modelId = requestedModel || 'gemini-3.5-flash' - const provider = getProvider(modelId) - logger.info(`[chat/commit] Analyzing session for topic "${topicName}" (${pillarId}/${topicId}). ${messages.length} messages.`) // Number each message for reference @@ -836,35 +756,28 @@ ${conversationText} Return the targeted sections with their complete updated content.` - const schema = { - type: 'object', - properties: { - sections: { - type: 'array', - items: { - type: 'object', - properties: { - sectionId: { - type: 'string', - description: 'The section id from the provided list', - }, - reason: { type: 'string', description: 'Brief reason why this section was covered in the conversation' }, - newContent: { type: 'string', description: 'The complete updated section content in markdown format' } - }, - required: ['sectionId', 'reason', 'newContent'] - } - } - }, - required: ['sections'] - } - let identifiedSections try { - const parsed = await provider.generateJSON(prompt, schema, { model: modelId }) + const parsed = await runStructured({ + model, + prompt, + schema: z.object({ + sections: z.array( + z.object({ + sectionId: z.string().describe('The section id from the provided list'), + reason: z.string().describe('Brief reason why this section was covered in the conversation'), + newContent: z.string().describe('The complete updated section content in markdown format'), + }) + ), + }), + // Merged guide sections can be long β€” give the output extra room. + maxOutputTokens: 16384, + feature: 'chat/commit', + }) identifiedSections = parsed.sections || [] } catch (parseErr) { - logger.error('[chat/commit] Failed to parse response:', parseErr.message) - return res.status(500).json({ message: 'Failed to analyze conversation sections.' }) + logger.error('[chat/commit] Analysis failed:', { error: parseErr.message }) + return res.status(500).json({ message: parseErr.message || 'Failed to analyze conversation sections.' }) } // Validate section IDs against the actual section list @@ -894,7 +807,7 @@ Return the targeted sections with their complete updated content.` res.json({ updates }) } catch (err) { - logger.error('[chat/commit] Error:', err.message) + logger.error('[chat/commit] Error:', { error: err.message }) res.status(500).json({ message: 'Failed to analyze session. Please try again.' }) } }) @@ -938,7 +851,7 @@ router.post('/commit/save', async (req, res) => { logger.info(`[chat/commit/save] Saved ${savedCount} sections for ${pillarId}/${topicId}`) res.json({ ok: true, savedCount }) } catch (err) { - logger.error('[chat/commit/save] Error:', err.message) + logger.error('[chat/commit/save] Error:', { error: err.message }) res.status(500).json({ message: 'Failed to save guide updates.' }) } }) @@ -964,7 +877,7 @@ router.get('/sessions', (req, res) => { }) res.json(sessions) } catch (err) { - logger.error('[chat/sessions] Error:', err.message) + logger.error('[chat/sessions] Error:', { error: err.message }) res.status(500).json({ message: 'Failed to fetch sessions.' }) } }) @@ -976,7 +889,7 @@ router.get('/sessions', (req, res) => { router.post('/sessions', (req, res) => { const { session } = req.body if (!session || !session.id) return res.status(400).json({message: 'Session required'}) - + try { db.prepare(` INSERT INTO chat_sessions (id, name, messages, pillar_id, topic_id, topic_name, created_at, updated_at) @@ -989,8 +902,8 @@ router.post('/sessions', (req, res) => { topic_name = excluded.topic_name, updated_at = datetime('now') `).run( - session.id, - session.name || 'Session', + session.id, + session.name || 'Session', JSON.stringify(session.messages || []), session.pillarId || null, session.topicId || null, @@ -999,7 +912,7 @@ router.post('/sessions', (req, res) => { ) res.json({ success: true }) } catch (err) { - logger.error('[chat/sessions] POST Error:', err.message) + logger.error('[chat/sessions] POST Error:', { error: err.message }) res.status(500).json({ message: 'Failed to save session.' }) } }) @@ -1011,7 +924,7 @@ router.post('/sessions', (req, res) => { router.post('/sessions/bulk', (req, res) => { const { sessions } = req.body if (!sessions || typeof sessions !== 'object') return res.status(400).json({message: 'Sessions object required'}) - + try { const insert = db.prepare(` INSERT INTO chat_sessions (id, name, messages, pillar_id, topic_id, topic_name, created_at, updated_at) @@ -1037,7 +950,7 @@ router.post('/sessions/bulk', (req, res) => { transaction(sessions) res.json({ success: true }) } catch (err) { - logger.error('[chat/sessions/bulk] Error:', err.message) + logger.error('[chat/sessions/bulk] Error:', { error: err.message }) res.status(500).json({ message: 'Failed to save sessions in bulk.' }) } }) @@ -1051,7 +964,7 @@ router.delete('/sessions/:id', (req, res) => { db.prepare("DELETE FROM chat_sessions WHERE id = ?").run(req.params.id) res.json({ success: true }) } catch (err) { - logger.error('[chat/sessions] DELETE Error:', err.message) + logger.error('[chat/sessions] DELETE Error:', { error: err.message }) res.status(500).json({ message: 'Failed to delete session.' }) } }) diff --git a/server/routes/config.js b/server/routes/config.js index 74e9d49..e16a1e2 100644 --- a/server/routes/config.js +++ b/server/routes/config.js @@ -3,7 +3,7 @@ import db from '../db.js' import { getAvailableModels, getApiKeyStatus, - getProviderByIdWithKey, + testProviderKey, getProviderDefinitions, getApiKeyFields, getProviderIdForConfigKey, @@ -94,8 +94,7 @@ router.post('/test-key', async (req, res) => { } try { - const providerInstance = getProviderByIdWithKey(provider, key) - await providerInstance.testApiKey(key) + await testProviderKey(provider, key) // Derive the config key from the provider's static metadata const providerDefs = getProviderDefinitions() diff --git a/server/routes/endpoints.js b/server/routes/endpoints.js index 87fcd23..ad6d26a 100644 --- a/server/routes/endpoints.js +++ b/server/routes/endpoints.js @@ -10,7 +10,7 @@ import { Router } from 'express' import { randomUUID } from 'crypto' import db from '../db.js' -import { CustomEndpointProvider } from '../providers/custom.js' +import { fetchEndpointModels } from '../providers/defs.js' import { getCustomEndpoint, listCustomEndpoints, loadCatalog } from '../providers/catalog.js' import { refreshEndpointCatalog, handleEndpointRemoved } from '../providers/index.js' import { maskSecret, isMaskedValue } from '../utils/mask.js' @@ -74,7 +74,7 @@ router.post('/test', async (req, res) => { } try { - const models = await CustomEndpointProvider.fetchModels(effectiveKey, normalized) + const models = await fetchEndpointModels(effectiveKey, normalized) res.json({ ok: true, modelCount: models.length }) } catch (err) { res.status(400).json({ ok: false, message: err.message || 'Could not reach the endpoint' }) @@ -107,7 +107,7 @@ router.post('/', async (req, res) => { try { // Pre-flight: the endpoint must respond before we save it - await CustomEndpointProvider.fetchModels(key, normalized) + await fetchEndpointModels(key, normalized) } catch (err) { return res.status(400).json({ message: err.message || 'Could not reach the endpoint' }) } @@ -151,7 +151,7 @@ router.put('/:id', async (req, res) => { } try { - await CustomEndpointProvider.fetchModels(nextKey, nextUrl) + await fetchEndpointModels(nextKey, nextUrl) } catch (err) { return res.status(400).json({ message: err.message || 'Could not reach the endpoint' }) } diff --git a/server/utils/embeddings.js b/server/utils/embeddings.js index dda75db..6fd4c94 100644 --- a/server/utils/embeddings.js +++ b/server/utils/embeddings.js @@ -1,56 +1,61 @@ -import { GoogleGenerativeAI } from '@google/generative-ai'; -import db from '../db.js'; -import logger from './logger.js'; +/** + * @fileoverview Text embeddings via the AI SDK. + * + * Embeddings are always Gemini-based β€” the other providers in this app + * offer no embedding model. The model must stay `gemini-embedding-2`: + * stored vectors come from that model, and vectors from different + * embedding models are not comparable. + */ -let genAI = null; +import { embed, cosineSimilarity as aiCosineSimilarity } from 'ai' +import { createGoogleGenerativeAI } from '@ai-sdk/google' +import db from '../db.js' +import logger from './logger.js' -function getGenAI() { - if (genAI) return genAI; - const config = db.prepare("SELECT value FROM config WHERE key = 'gemini_api_key'").get(); - if (config?.value) { - genAI = new GoogleGenerativeAI(config.value); - return genAI; - } - return null; -} +const EMBEDDING_MODEL_ID = 'gemini-embedding-2' +const EMBED_TIMEOUT_MS = 20_000 /** - * Generates an embedding for a given text using gemini-embedding-2. - * - * NOTE: Embeddings are always Gemini-only. Claude does not offer an embedding model. - * This function always uses the Gemini API key regardless of which chat model is selected. + * Generate an embedding vector for a text. + * Returns an empty array when no Gemini key is configured or the call + * fails β€” callers treat an empty vector as "no embedding". + * + * @param {string} text + * @returns {Promise} */ export async function generateEmbedding(text) { - if (!text || text.trim() === '') return []; - const ai = getGenAI(); - if (!ai) { - logger.warn('[embeddings] No API key, cannot generate embedding.'); - return []; + if (!text || text.trim() === '') return [] + + const config = db.prepare("SELECT value FROM config WHERE key = 'gemini_api_key'").get() + if (!config?.value) { + logger.warn('[embeddings] No Gemini API key β€” semantic search and episodic memory are disabled.') + return [] } - + try { - const model = ai.getGenerativeModel({ model: 'gemini-embedding-2' }); - const result = await model.embedContent(text); - return result.embedding.values; + const google = createGoogleGenerativeAI({ apiKey: config.value }) + const { embedding } = await embed({ + model: google.textEmbedding(EMBEDDING_MODEL_ID), + value: text, + abortSignal: AbortSignal.timeout(EMBED_TIMEOUT_MS), + }) + return embedding } catch (err) { - logger.error('[embeddings] Error generating embedding:', err.message); - return []; + logger.error(`[embeddings] Embedding call failed: ${err.message}`) + return [] } } /** - * Computes cosine similarity between two vectors. + * Cosine similarity between two vectors. + * Returns 0 for empty or mismatched vectors (e.g. rows embedded while + * no key was configured). + * + * @param {number[]} vecA + * @param {number[]} vecB + * @returns {number} */ export function cosineSimilarity(vecA, vecB) { - if (!vecA || !vecB || vecA.length !== vecB.length || vecA.length === 0) return 0; - let dotProduct = 0; - let normA = 0; - let normB = 0; - for (let i = 0; i < vecA.length; i++) { - dotProduct += vecA[i] * vecB[i]; - normA += vecA[i] * vecA[i]; - normB += vecB[i] * vecB[i]; - } - if (normA === 0 || normB === 0) return 0; - return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); + if (!vecA?.length || !vecB?.length || vecA.length !== vecB.length) return 0 + return aiCosineSimilarity(vecA, vecB) }