From 1b358815e00f072d0c653365219f332924cb63b9 Mon Sep 17 00:00:00 2001 From: Jeff Man Date: Thu, 30 Jul 2026 20:25:01 +0100 Subject: [PATCH] feat: support more AI providers for BYOK (JEF-52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends JEF-6's bring-your-own-key support beyond OpenRouter/Google AI to OpenAI, Anthropic, Mistral, Groq, xAI, DeepSeek, and a custom OpenAI-compatible endpoint (own base URL + key + model) for anything else. - OpenAICompatibleLLMProvider generalizes the old OpenRouterLLMProvider (same request/response shape covers OpenAI, Mistral, Groq, xAI, DeepSeek, OpenRouter, and custom); AnthropicLLMProvider is new and bespoke (Messages API, x-api-key/anthropic-version headers, separate system field). - PROVIDER_REGISTRY replaces the old if/else in UserLLMProviderFactory with a lookup table — adding a provider is a registry entry now. - New llmModel/llmBaseUrl columns: model is an optional override for named providers (falls back to a per-provider default), and required for the custom provider along with a validated http(s) base URL. - Fixed a bug found during manual verification: ClearLlmApiKeyUseCase only nulled llmProvider/llmApiKey, leaving a stale model/baseUrl behind after clearing. - Frontend: Account settings' provider dropdown now lists all supported providers, with Base URL/Model fields that appear only for the custom provider. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N2PBmsuzPhrmNnfZf6C3BM --- apps/api/drizzle/0002_silly_umar.sql | 2 + apps/api/drizzle/meta/0002_snapshot.json | 1301 +++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + .../user/ClearLlmApiKeyUseCase.test.ts | 11 +- .../user/GetLlmKeyStatusUseCase.test.ts | 28 +- .../user/SaveLlmApiKeyUseCase.test.ts | 114 ++ .../api/src/__tests__/helpers/createTestDb.ts | 2 + apps/api/src/__tests__/helpers/mocks.ts | 2 + .../llm/AnthropicLLMProvider.test.ts | 158 ++ .../llm/OpenAICompatibleLLMProvider.test.ts | 99 ++ .../llm/OpenRouterLLMProvider.test.ts | 126 -- .../llm/UserLLMProviderFactory.test.ts | 74 +- .../llm/providerRegistry.test.ts | 41 + .../resolvers/UserResolver.test.ts | 22 + apps/api/src/constants.ts | 28 +- apps/api/src/domain/user/User.ts | 6 +- .../http/schema/mutations/userMutations.ts | 10 +- .../src/http/schema/types/LlmKeyStatusType.ts | 2 + .../db/repositories/DrizzleUserRepository.ts | 4 + apps/api/src/infrastructure/db/schema.ts | 6 +- .../llm/AnthropicLLMProvider.ts | 49 + ...ider.ts => OpenAICompatibleLLMProvider.ts} | 19 +- .../llm/UserLLMProviderFactory.ts | 11 +- .../infrastructure/llm/providerRegistry.ts | 84 ++ .../resolvers/UserResolver.ts | 10 +- .../src/use-cases/ports/IUserRepository.ts | 2 + .../use-cases/user/ClearLlmApiKeyUseCase.ts | 7 +- .../use-cases/user/GetLlmKeyStatusUseCase.ts | 2 + .../use-cases/user/IGetLlmKeyStatusUseCase.ts | 2 + .../use-cases/user/ISaveLlmApiKeyUseCase.ts | 4 + .../use-cases/user/SaveLlmApiKeyUseCase.ts | 37 + .../web/src/routes/_authenticated/account.tsx | 168 ++- 32 files changed, 2262 insertions(+), 176 deletions(-) create mode 100644 apps/api/drizzle/0002_silly_umar.sql create mode 100644 apps/api/drizzle/meta/0002_snapshot.json create mode 100644 apps/api/src/__tests__/infrastructure/llm/AnthropicLLMProvider.test.ts create mode 100644 apps/api/src/__tests__/infrastructure/llm/OpenAICompatibleLLMProvider.test.ts delete mode 100644 apps/api/src/__tests__/infrastructure/llm/OpenRouterLLMProvider.test.ts create mode 100644 apps/api/src/__tests__/infrastructure/llm/providerRegistry.test.ts create mode 100644 apps/api/src/infrastructure/llm/AnthropicLLMProvider.ts rename apps/api/src/infrastructure/llm/{OpenRouterLLMProvider.ts => OpenAICompatibleLLMProvider.ts} (51%) create mode 100644 apps/api/src/infrastructure/llm/providerRegistry.ts diff --git a/apps/api/drizzle/0002_silly_umar.sql b/apps/api/drizzle/0002_silly_umar.sql new file mode 100644 index 00000000..8571db0c --- /dev/null +++ b/apps/api/drizzle/0002_silly_umar.sql @@ -0,0 +1,2 @@ +ALTER TABLE `User` ADD `llmModel` text;--> statement-breakpoint +ALTER TABLE `User` ADD `llmBaseUrl` text; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0002_snapshot.json b/apps/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..34454922 --- /dev/null +++ b/apps/api/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1301 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cbace4ce-ba02-41fb-afcc-c86688d3524d", + "prevId": "5f04d418-c588-486e-8ffa-ffbd4314a0e0", + "tables": { + "ActivityLog": { + "name": "ActivityLog", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actorId": { + "name": "actorId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "eventType": { + "name": "eventType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ActivityLog_applicationId_idx": { + "name": "ActivityLog_applicationId_idx", + "columns": ["applicationId"], + "isUnique": false + } + }, + "foreignKeys": { + "ActivityLog_applicationId_JobApplication_id_fk": { + "name": "ActivityLog_applicationId_JobApplication_id_fk", + "tableFrom": "ActivityLog", + "tableTo": "JobApplication", + "columnsFrom": ["applicationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ApiToken": { + "name": "ApiToken", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ApiToken_tokenHash_unique": { + "name": "ApiToken_tokenHash_unique", + "columns": ["tokenHash"], + "isUnique": true + }, + "ApiToken_userId_idx": { + "name": "ApiToken_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "ApiToken_userId_User_id_fk": { + "name": "ApiToken_userId_User_id_fk", + "tableFrom": "ApiToken", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ApplicationTag": { + "name": "ApplicationTag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ApplicationTag_applicationId_name_key": { + "name": "ApplicationTag_applicationId_name_key", + "columns": ["applicationId", "name"], + "isUnique": true + }, + "ApplicationTag_applicationId_idx": { + "name": "ApplicationTag_applicationId_idx", + "columns": ["applicationId"], + "isUnique": false + } + }, + "foreignKeys": { + "ApplicationTag_applicationId_JobApplication_id_fk": { + "name": "ApplicationTag_applicationId_JobApplication_id_fk", + "tableFrom": "ApplicationTag", + "tableTo": "JobApplication", + "columnsFrom": ["applicationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Contact": { + "name": "Contact", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linkedinUrl": { + "name": "linkedinUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Contact_applicationId_idx": { + "name": "Contact_applicationId_idx", + "columns": ["applicationId"], + "isUnique": false + } + }, + "foreignKeys": { + "Contact_applicationId_JobApplication_id_fk": { + "name": "Contact_applicationId_JobApplication_id_fk", + "tableFrom": "Contact", + "tableTo": "JobApplication", + "columnsFrom": ["applicationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Document": { + "name": "Document", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mimeType": { + "name": "mimeType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sizeBytes": { + "name": "sizeBytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storageKey": { + "name": "storageKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "documentType": { + "name": "documentType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Document_storageKey_unique": { + "name": "Document_storageKey_unique", + "columns": ["storageKey"], + "isUnique": true + }, + "Document_applicationId_idx": { + "name": "Document_applicationId_idx", + "columns": ["applicationId"], + "isUnique": false + } + }, + "foreignKeys": { + "Document_applicationId_JobApplication_id_fk": { + "name": "Document_applicationId_JobApplication_id_fk", + "tableFrom": "Document", + "tableTo": "JobApplication", + "columnsFrom": ["applicationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "EmailVerificationToken": { + "name": "EmailVerificationToken", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "newEmail": { + "name": "newEmail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "EmailVerificationToken_tokenHash_unique": { + "name": "EmailVerificationToken_tokenHash_unique", + "columns": ["tokenHash"], + "isUnique": true + }, + "EmailVerificationToken_userId_idx": { + "name": "EmailVerificationToken_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "EmailVerificationToken_userId_User_id_fk": { + "name": "EmailVerificationToken_userId_User_id_fk", + "tableFrom": "EmailVerificationToken", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "InterviewRound": { + "name": "InterviewRound", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "scheduledAt": { + "name": "scheduledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interviewerName": { + "name": "interviewerName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "InterviewRound_applicationId_idx": { + "name": "InterviewRound_applicationId_idx", + "columns": ["applicationId"], + "isUnique": false + } + }, + "foreignKeys": { + "InterviewRound_applicationId_JobApplication_id_fk": { + "name": "InterviewRound_applicationId_JobApplication_id_fk", + "tableFrom": "InterviewRound", + "tableTo": "JobApplication", + "columnsFrom": ["applicationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "JobApplication": { + "name": "JobApplication", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "jobUrl": { + "name": "jobUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salaryRange": { + "name": "salaryRange", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "appliedAt": { + "name": "appliedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "starred": { + "name": "starred", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "followUpAt": { + "name": "followUpAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reminderSentAt": { + "name": "reminderSentAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "JobApplication_userId_idx": { + "name": "JobApplication_userId_idx", + "columns": ["userId"], + "isUnique": false + }, + "JobApplication_userId_status_idx": { + "name": "JobApplication_userId_status_idx", + "columns": ["userId", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "JobApplication_userId_User_id_fk": { + "name": "JobApplication_userId_User_id_fk", + "tableFrom": "JobApplication", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "LoginEvent": { + "name": "LoginEvent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "LoginEvent_userId_idx": { + "name": "LoginEvent_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "LoginEvent_userId_User_id_fk": { + "name": "LoginEvent_userId_User_id_fk", + "tableFrom": "LoginEvent", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Note": { + "name": "Note", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Note_applicationId_idx": { + "name": "Note_applicationId_idx", + "columns": ["applicationId"], + "isUnique": false + } + }, + "foreignKeys": { + "Note_applicationId_JobApplication_id_fk": { + "name": "Note_applicationId_JobApplication_id_fk", + "tableFrom": "Note", + "tableTo": "JobApplication", + "columnsFrom": ["applicationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "OAuthAccount": { + "name": "OAuthAccount", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "OAuthAccount_provider_providerAccountId_key": { + "name": "OAuthAccount_provider_providerAccountId_key", + "columns": ["provider", "providerAccountId"], + "isUnique": true + }, + "OAuthAccount_userId_idx": { + "name": "OAuthAccount_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "OAuthAccount_userId_User_id_fk": { + "name": "OAuthAccount_userId_User_id_fk", + "tableFrom": "OAuthAccount", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "PasswordResetToken": { + "name": "PasswordResetToken", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "PasswordResetToken_tokenHash_unique": { + "name": "PasswordResetToken_tokenHash_unique", + "columns": ["tokenHash"], + "isUnique": true + }, + "PasswordResetToken_userId_idx": { + "name": "PasswordResetToken_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "PasswordResetToken_userId_User_id_fk": { + "name": "PasswordResetToken_userId_User_id_fk", + "tableFrom": "PasswordResetToken", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Session": { + "name": "Session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "Session_userId_idx": { + "name": "Session_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "Session_userId_User_id_fk": { + "name": "Session_userId_User_id_fk", + "tableFrom": "Session", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "TotpBackupCode": { + "name": "TotpBackupCode", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "TotpBackupCode_codeHash_unique": { + "name": "TotpBackupCode_codeHash_unique", + "columns": ["codeHash"], + "isUnique": true + }, + "TotpBackupCode_userId_idx": { + "name": "TotpBackupCode_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "TotpBackupCode_userId_User_id_fk": { + "name": "TotpBackupCode_userId_User_id_fk", + "tableFrom": "TotpBackupCode", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "User": { + "name": "User", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "targetRole": { + "name": "targetRole", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerifiedAt": { + "name": "emailVerifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatarKey": { + "name": "avatarKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weeklyDigestEnabled": { + "name": "weeklyDigestEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastDigestSentAt": { + "name": "lastDigestSentAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "followUpRemindersEnabled": { + "name": "followUpRemindersEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "totpSecret": { + "name": "totpSecret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totpEnabled": { + "name": "totpEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "llmProvider": { + "name": "llmProvider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "llmApiKey": { + "name": "llmApiKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "llmModel": { + "name": "llmModel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "llmBaseUrl": { + "name": "llmBaseUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "User_email_unique": { + "name": "User_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 155fa815..9418895c 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1785423723883, "tag": "0001_curved_the_order", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1785438546726, + "tag": "0002_silly_umar", + "breakpoints": true } ] } diff --git a/apps/api/src/__tests__/application/user/ClearLlmApiKeyUseCase.test.ts b/apps/api/src/__tests__/application/user/ClearLlmApiKeyUseCase.test.ts index 1de6ed8a..992e321c 100644 --- a/apps/api/src/__tests__/application/user/ClearLlmApiKeyUseCase.test.ts +++ b/apps/api/src/__tests__/application/user/ClearLlmApiKeyUseCase.test.ts @@ -13,8 +13,13 @@ describe('ClearLlmApiKeyUseCase', () => { expect((err as { code: string }).code).toBe('NOT_FOUND'); }); - it('clears the provider and key', async () => { - const user = makeUser({ llmProvider: 'openrouter', llmApiKey: 'encrypted:key' }); + it('clears the provider, key, model, and base URL', async () => { + const user = makeUser({ + llmProvider: 'custom', + llmApiKey: 'encrypted:key', + llmModel: 'my-model', + llmBaseUrl: 'https://my-llm.example.com/v1/chat/completions', + }); const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user), update: vi.fn().mockResolvedValue(user), @@ -25,6 +30,8 @@ describe('ClearLlmApiKeyUseCase', () => { expect(userRepository.update).toHaveBeenCalledWith('user-1', { llmProvider: null, llmApiKey: null, + llmModel: null, + llmBaseUrl: null, }); }); }); diff --git a/apps/api/src/__tests__/application/user/GetLlmKeyStatusUseCase.test.ts b/apps/api/src/__tests__/application/user/GetLlmKeyStatusUseCase.test.ts index 5ee041fe..b2497e43 100644 --- a/apps/api/src/__tests__/application/user/GetLlmKeyStatusUseCase.test.ts +++ b/apps/api/src/__tests__/application/user/GetLlmKeyStatusUseCase.test.ts @@ -19,7 +19,7 @@ describe('GetLlmKeyStatusUseCase', () => { const result = await new GetLlmKeyStatusUseCase({ userRepository }).execute('user-1'); - expect(result).toEqual({ configured: false, provider: null }); + expect(result).toEqual({ configured: false, provider: null, model: null, baseUrl: null }); }); it('reports configured with the provider when a key is set', async () => { @@ -28,6 +28,30 @@ describe('GetLlmKeyStatusUseCase', () => { const result = await new GetLlmKeyStatusUseCase({ userRepository }).execute('user-1'); - expect(result).toEqual({ configured: true, provider: 'openrouter' }); + expect(result).toEqual({ + configured: true, + provider: 'openrouter', + model: null, + baseUrl: null, + }); + }); + + it('reports the stored model and base URL for a custom provider', async () => { + const user = makeUser({ + llmProvider: 'custom', + llmApiKey: 'encrypted:key', + llmModel: 'my-model', + llmBaseUrl: 'https://my-llm.example.com/v1/chat/completions', + }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + const result = await new GetLlmKeyStatusUseCase({ userRepository }).execute('user-1'); + + expect(result).toEqual({ + configured: true, + provider: 'custom', + model: 'my-model', + baseUrl: 'https://my-llm.example.com/v1/chat/completions', + }); }); }); diff --git a/apps/api/src/__tests__/application/user/SaveLlmApiKeyUseCase.test.ts b/apps/api/src/__tests__/application/user/SaveLlmApiKeyUseCase.test.ts index c4db93ee..aed37e5c 100644 --- a/apps/api/src/__tests__/application/user/SaveLlmApiKeyUseCase.test.ts +++ b/apps/api/src/__tests__/application/user/SaveLlmApiKeyUseCase.test.ts @@ -54,6 +54,120 @@ describe('SaveLlmApiKeyUseCase', () => { expect(userRepository.update).toHaveBeenCalledWith('user-1', { llmProvider: 'googleai', llmApiKey: 'encrypted:sk-123', + llmModel: null, + llmBaseUrl: null, + }); + }); + + it('persists an optional model override for a named provider', async () => { + const user = makeUser(); + const userRepository = makeUserRepository({ + findById: vi.fn().mockResolvedValue(user), + update: vi.fn().mockResolvedValue(user), + }); + const llmApiKeyCipher = makeLlmApiKeyCipher(); + + await new SaveLlmApiKeyUseCase({ userRepository, llmApiKeyCipher }).execute({ + userId: 'user-1', + provider: 'openai', + apiKey: 'sk-123', + model: 'gpt-4o', + }); + + expect(userRepository.update).toHaveBeenCalledWith('user-1', { + llmProvider: 'openai', + llmApiKey: 'encrypted:sk-123', + llmModel: 'gpt-4o', + llmBaseUrl: null, + }); + }); + + it('throws VALIDATION when a baseUrl is given for a named (non-custom) provider', async () => { + const userRepository = makeUserRepository({ + findById: vi.fn().mockResolvedValue(makeUser()), + }); + const llmApiKeyCipher = makeLlmApiKeyCipher(); + + const err = await new SaveLlmApiKeyUseCase({ userRepository, llmApiKeyCipher }) + .execute({ + userId: 'user-1', + provider: 'openai', + apiKey: 'sk-123', + baseUrl: 'https://example.com', + }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('VALIDATION'); + }); + + it('throws VALIDATION when the custom provider is missing a base URL', async () => { + const userRepository = makeUserRepository(); + const llmApiKeyCipher = makeLlmApiKeyCipher(); + + const err = await new SaveLlmApiKeyUseCase({ userRepository, llmApiKeyCipher }) + .execute({ userId: 'user-1', provider: 'custom', apiKey: 'sk-123', model: 'some-model' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('VALIDATION'); + expect((err as Error).message).toMatch(/base URL is required/); + }); + + it('throws VALIDATION when the custom provider base URL is malformed', async () => { + const userRepository = makeUserRepository(); + const llmApiKeyCipher = makeLlmApiKeyCipher(); + + const err = await new SaveLlmApiKeyUseCase({ userRepository, llmApiKeyCipher }) + .execute({ + userId: 'user-1', + provider: 'custom', + apiKey: 'sk-123', + model: 'some-model', + baseUrl: 'not-a-url', + }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('VALIDATION'); + expect((err as Error).message).toMatch(/valid http/); + }); + + it('throws VALIDATION when the custom provider is missing a model', async () => { + const userRepository = makeUserRepository(); + const llmApiKeyCipher = makeLlmApiKeyCipher(); + + const err = await new SaveLlmApiKeyUseCase({ userRepository, llmApiKeyCipher }) + .execute({ + userId: 'user-1', + provider: 'custom', + apiKey: 'sk-123', + baseUrl: 'https://my-llm.example.com/v1/chat/completions', + }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('VALIDATION'); + expect((err as Error).message).toMatch(/model is required/); + }); + + it('persists baseUrl and model for a valid custom provider', async () => { + const user = makeUser(); + const userRepository = makeUserRepository({ + findById: vi.fn().mockResolvedValue(user), + update: vi.fn().mockResolvedValue(user), + }); + const llmApiKeyCipher = makeLlmApiKeyCipher(); + + await new SaveLlmApiKeyUseCase({ userRepository, llmApiKeyCipher }).execute({ + userId: 'user-1', + provider: 'custom', + apiKey: 'sk-123', + model: 'my-model', + baseUrl: 'https://my-llm.example.com/v1/chat/completions', + }); + + expect(userRepository.update).toHaveBeenCalledWith('user-1', { + llmProvider: 'custom', + llmApiKey: 'encrypted:sk-123', + llmModel: 'my-model', + llmBaseUrl: 'https://my-llm.example.com/v1/chat/completions', }); }); }); diff --git a/apps/api/src/__tests__/helpers/createTestDb.ts b/apps/api/src/__tests__/helpers/createTestDb.ts index 01c71fb2..40e91ce7 100644 --- a/apps/api/src/__tests__/helpers/createTestDb.ts +++ b/apps/api/src/__tests__/helpers/createTestDb.ts @@ -24,6 +24,8 @@ const SCHEMA_STATEMENTS = [ "totpEnabled" INTEGER NOT NULL DEFAULT 0, "llmProvider" TEXT, "llmApiKey" TEXT, + "llmModel" TEXT, + "llmBaseUrl" TEXT, "createdAt" INTEGER NOT NULL, "updatedAt" INTEGER NOT NULL )`, diff --git a/apps/api/src/__tests__/helpers/mocks.ts b/apps/api/src/__tests__/helpers/mocks.ts index 61ac863e..095ebac2 100644 --- a/apps/api/src/__tests__/helpers/mocks.ts +++ b/apps/api/src/__tests__/helpers/mocks.ts @@ -274,6 +274,8 @@ export const makeUser = (overrides?: Partial): User => ({ totpEnabled: false, llmProvider: null, llmApiKey: null, + llmModel: null, + llmBaseUrl: null, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), ...overrides, diff --git a/apps/api/src/__tests__/infrastructure/llm/AnthropicLLMProvider.test.ts b/apps/api/src/__tests__/infrastructure/llm/AnthropicLLMProvider.test.ts new file mode 100644 index 00000000..16028a4a --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/llm/AnthropicLLMProvider.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AnthropicLLMProvider } from '#src/infrastructure/llm/AnthropicLLMProvider.js'; +import { LLM } from '#src/constants.js'; +import type { LLMMessage } from '#src/use-cases/ports/ILLMProvider.js'; + +const jsonResponse = (body: unknown, ok = true, status = 200) => ({ + ok, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), +}); + +describe('AnthropicLLMProvider', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('constructor', () => { + it('falls back to the default model when none is given', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ content: [{ type: 'text', text: 'ok' }] }) as never, + ); + + const provider = new AnthropicLLMProvider('test-key'); + await provider.complete([{ role: 'user', content: 'hi' }]); + + const [, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(options.body as string); + expect(body.model).toBe(LLM.ANTHROPIC_DEFAULT_MODEL); + }); + + it('uses the given model when provided', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ content: [{ type: 'text', text: 'ok' }] }) as never, + ); + + const provider = new AnthropicLLMProvider('test-key', 'claude-custom'); + await provider.complete([{ role: 'user', content: 'hi' }]); + + const [, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(options.body as string); + expect(body.model).toBe('claude-custom'); + }); + }); + + describe('complete', () => { + it('throws when the API key is empty', async () => { + const provider = new AnthropicLLMProvider(''); + + await expect(provider.complete([{ role: 'user', content: 'hi' }])).rejects.toThrow( + 'Anthropic API key is not set', + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('sends the API key and version as headers', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ content: [{ type: 'text', text: 'ok' }] }) as never, + ); + + const provider = new AnthropicLLMProvider('secret-key'); + await provider.complete([{ role: 'user', content: 'hi' }], 256); + + const [url, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + expect(url).toBe(LLM.ANTHROPIC_API_URL); + const headers = options.headers as Record; + expect(headers['x-api-key']).toBe('secret-key'); + expect(headers['anthropic-version']).toBe(LLM.ANTHROPIC_VERSION); + + const body = JSON.parse(options.body as string); + expect(body.max_tokens).toBe(256); + }); + + it('moves the system message to a top-level `system` field', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ content: [{ type: 'text', text: 'ok' }] }) as never, + ); + + const provider = new AnthropicLLMProvider('secret-key'); + const messages: LLMMessage[] = [ + { role: 'system', content: 'be helpful' }, + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi there' }, + ]; + await provider.complete(messages); + + const [, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(options.body as string); + expect(body.system).toBe('be helpful'); + expect(body.messages).toEqual([ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi there' }, + ]); + }); + + it('omits the `system` field when there is no system message', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ content: [{ type: 'text', text: 'ok' }] }) as never, + ); + + const provider = new AnthropicLLMProvider('secret-key'); + await provider.complete([{ role: 'user', content: 'hello' }]); + + const [, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(options.body as string); + expect(body.system).toBeUndefined(); + }); + + it('defaults maxTokens to 512 when not provided', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ content: [{ type: 'text', text: 'ok' }] }) as never, + ); + + const provider = new AnthropicLLMProvider('secret-key'); + await provider.complete([{ role: 'user', content: 'hi' }]); + + const [, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(options.body as string); + expect(body.max_tokens).toBe(512); + }); + + it('returns the text of the first text content block', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ content: [{ type: 'text', text: 'generated response' }] }) as never, + ); + + const provider = new AnthropicLLMProvider('secret-key'); + const result = await provider.complete([{ role: 'user', content: 'hi' }]); + + expect(result).toBe('generated response'); + }); + + it('returns an empty string when there is no text content block', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({ content: [] }) as never); + + const provider = new AnthropicLLMProvider('secret-key'); + const result = await provider.complete([{ role: 'user', content: 'hi' }]); + + expect(result).toBe(''); + }); + + it('throws with the status and body when the response is not ok', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ error: 'rate limited' }, false, 429) as never, + ); + + const provider = new AnthropicLLMProvider('secret-key'); + + await expect(provider.complete([{ role: 'user', content: 'hi' }])).rejects.toThrow( + /Anthropic error 429/, + ); + }); + }); +}); diff --git a/apps/api/src/__tests__/infrastructure/llm/OpenAICompatibleLLMProvider.test.ts b/apps/api/src/__tests__/infrastructure/llm/OpenAICompatibleLLMProvider.test.ts new file mode 100644 index 00000000..17812f44 --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/llm/OpenAICompatibleLLMProvider.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { OpenAICompatibleLLMProvider } from '#src/infrastructure/llm/OpenAICompatibleLLMProvider.js'; +import type { LLMMessage } from '#src/use-cases/ports/ILLMProvider.js'; + +const BASE_URL = 'https://api.example.com/v1/chat/completions'; +const MODEL = 'example-model'; + +const jsonResponse = (body: unknown, ok = true, status = 200) => ({ + ok, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), +}); + +describe('OpenAICompatibleLLMProvider', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('throws when the API key is empty', async () => { + const provider = new OpenAICompatibleLLMProvider('', BASE_URL, MODEL); + + await expect(provider.complete([{ role: 'user', content: 'hi' }])).rejects.toThrow( + 'API key is not set', + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('posts to the given base URL with a Bearer auth header, model, and messages', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ choices: [{ message: { content: 'ok' } }] }) as never, + ); + + const provider = new OpenAICompatibleLLMProvider('secret-key', BASE_URL, MODEL); + const messages: LLMMessage[] = [ + { role: 'system', content: 'be helpful' }, + { role: 'user', content: 'hello' }, + ]; + await provider.complete(messages, 256); + + const [url, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + expect(url).toBe(BASE_URL); + expect((options.headers as Record).Authorization).toBe('Bearer secret-key'); + + const body = JSON.parse(options.body as string); + expect(body.model).toBe(MODEL); + expect(body.messages).toEqual(messages); + expect(body.max_tokens).toBe(256); + }); + + it('defaults maxTokens to 512 when not provided', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ choices: [{ message: { content: 'ok' } }] }) as never, + ); + + const provider = new OpenAICompatibleLLMProvider('secret-key', BASE_URL, MODEL); + await provider.complete([{ role: 'user', content: 'hi' }]); + + const [, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(options.body as string); + expect(body.max_tokens).toBe(512); + }); + + it('returns the content of the first choice', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ choices: [{ message: { content: 'generated response' } }] }) as never, + ); + + const provider = new OpenAICompatibleLLMProvider('secret-key', BASE_URL, MODEL); + const result = await provider.complete([{ role: 'user', content: 'hi' }]); + + expect(result).toBe('generated response'); + }); + + it('returns an empty string when choices are missing', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({ choices: [] }) as never); + + const provider = new OpenAICompatibleLLMProvider('secret-key', BASE_URL, MODEL); + const result = await provider.complete([{ role: 'user', content: 'hi' }]); + + expect(result).toBe(''); + }); + + it('throws with the status and body when the response is not ok', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ error: 'rate limited' }, false, 429) as never, + ); + + const provider = new OpenAICompatibleLLMProvider('secret-key', BASE_URL, MODEL); + + await expect(provider.complete([{ role: 'user', content: 'hi' }])).rejects.toThrow( + /LLM provider error 429/, + ); + }); +}); diff --git a/apps/api/src/__tests__/infrastructure/llm/OpenRouterLLMProvider.test.ts b/apps/api/src/__tests__/infrastructure/llm/OpenRouterLLMProvider.test.ts deleted file mode 100644 index 1a2806be..00000000 --- a/apps/api/src/__tests__/infrastructure/llm/OpenRouterLLMProvider.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { OpenRouterLLMProvider } from '#src/infrastructure/llm/OpenRouterLLMProvider.js'; -import { LLM } from '#src/constants.js'; -import type { LLMMessage } from '#src/use-cases/ports/ILLMProvider.js'; - -const jsonResponse = (body: unknown, ok = true, status = 200) => ({ - ok, - status, - json: () => Promise.resolve(body), - text: () => Promise.resolve(JSON.stringify(body)), -}); - -describe('OpenRouterLLMProvider', () => { - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn()); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - describe('constructor', () => { - it('falls back to the default model when none is given', async () => { - vi.mocked(fetch).mockResolvedValue( - jsonResponse({ choices: [{ message: { content: 'ok' } }] }) as never, - ); - - const provider = new OpenRouterLLMProvider('test-key'); - await provider.complete([{ role: 'user', content: 'hi' }]); - - const [, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; - const body = JSON.parse(options.body as string); - expect(body.model).toBe(LLM.OPENROUTER_DEFAULT_MODEL); - }); - - it('uses the given model when provided', async () => { - vi.mocked(fetch).mockResolvedValue( - jsonResponse({ choices: [{ message: { content: 'ok' } }] }) as never, - ); - - const provider = new OpenRouterLLMProvider('test-key', 'anthropic/claude-3-haiku'); - await provider.complete([{ role: 'user', content: 'hi' }]); - - const [, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; - const body = JSON.parse(options.body as string); - expect(body.model).toBe('anthropic/claude-3-haiku'); - }); - }); - - describe('complete', () => { - it('throws when the API key is empty', async () => { - const provider = new OpenRouterLLMProvider(''); - - await expect(provider.complete([{ role: 'user', content: 'hi' }])).rejects.toThrow( - 'OpenRouter API key is not set', - ); - expect(fetch).not.toHaveBeenCalled(); - }); - - it('posts to the OpenRouter API URL with a Bearer auth header and the messages', async () => { - vi.mocked(fetch).mockResolvedValue( - jsonResponse({ choices: [{ message: { content: 'ok' } }] }) as never, - ); - - const provider = new OpenRouterLLMProvider('secret-key'); - const messages: LLMMessage[] = [ - { role: 'system', content: 'be helpful' }, - { role: 'user', content: 'hello' }, - ]; - await provider.complete(messages, 256); - - const [url, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; - expect(url).toBe(LLM.OPENROUTER_API_URL); - expect((options.headers as Record).Authorization).toBe('Bearer secret-key'); - - const body = JSON.parse(options.body as string); - expect(body.messages).toEqual(messages); - expect(body.max_tokens).toBe(256); - }); - - it('defaults maxTokens to 512 when not provided', async () => { - vi.mocked(fetch).mockResolvedValue( - jsonResponse({ choices: [{ message: { content: 'ok' } }] }) as never, - ); - - const provider = new OpenRouterLLMProvider('secret-key'); - await provider.complete([{ role: 'user', content: 'hi' }]); - - const [, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; - const body = JSON.parse(options.body as string); - expect(body.max_tokens).toBe(512); - }); - - it('returns the content of the first choice', async () => { - vi.mocked(fetch).mockResolvedValue( - jsonResponse({ choices: [{ message: { content: 'generated response' } }] }) as never, - ); - - const provider = new OpenRouterLLMProvider('secret-key'); - const result = await provider.complete([{ role: 'user', content: 'hi' }]); - - expect(result).toBe('generated response'); - }); - - it('returns an empty string when choices are missing', async () => { - vi.mocked(fetch).mockResolvedValue(jsonResponse({ choices: [] }) as never); - - const provider = new OpenRouterLLMProvider('secret-key'); - const result = await provider.complete([{ role: 'user', content: 'hi' }]); - - expect(result).toBe(''); - }); - - it('throws with the status and body when the response is not ok', async () => { - vi.mocked(fetch).mockResolvedValue( - jsonResponse({ error: 'rate limited' }, false, 429) as never, - ); - - const provider = new OpenRouterLLMProvider('secret-key'); - - await expect(provider.complete([{ role: 'user', content: 'hi' }])).rejects.toThrow( - /OpenRouter error 429/, - ); - }); - }); -}); diff --git a/apps/api/src/__tests__/infrastructure/llm/UserLLMProviderFactory.test.ts b/apps/api/src/__tests__/infrastructure/llm/UserLLMProviderFactory.test.ts index 5fe0af00..7bb986f4 100644 --- a/apps/api/src/__tests__/infrastructure/llm/UserLLMProviderFactory.test.ts +++ b/apps/api/src/__tests__/infrastructure/llm/UserLLMProviderFactory.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { UserLLMProviderFactory } from '#src/infrastructure/llm/UserLLMProviderFactory.js'; -import { OpenRouterLLMProvider } from '#src/infrastructure/llm/OpenRouterLLMProvider.js'; +import { OpenAICompatibleLLMProvider } from '#src/infrastructure/llm/OpenAICompatibleLLMProvider.js'; +import { AnthropicLLMProvider } from '#src/infrastructure/llm/AnthropicLLMProvider.js'; import { GoogleAILLMProvider } from '#src/infrastructure/llm/GoogleAILLMProvider.js'; import { LLM_PROVIDER } from '#src/constants.js'; import { makeUserRepository, makeUser, makeLlmApiKeyCipher } from '#src/__tests__/helpers/mocks.js'; @@ -28,12 +29,26 @@ describe('UserLLMProviderFactory', () => { expect(await factory.forUser('missing')).toBeNull(); }); - it('returns an OpenRouterLLMProvider with the decrypted key for the openrouter provider', async () => { + it('returns null when the stored provider is not in the registry', async () => { + const userRepository = makeUserRepository({ + findById: vi + .fn() + .mockResolvedValue(makeUser({ llmProvider: 'not-a-real-provider', llmApiKey: 'enc' })), + }); + const factory = new UserLLMProviderFactory({ + userRepository, + llmApiKeyCipher: makeLlmApiKeyCipher(), + }); + + expect(await factory.forUser('user-1')).toBeNull(); + }); + + it('returns an OpenAICompatibleLLMProvider with the decrypted key for openai', async () => { const userRepository = makeUserRepository({ findById: vi .fn() .mockResolvedValue( - makeUser({ llmProvider: LLM_PROVIDER.OPENROUTER, llmApiKey: 'encrypted:my-key' }), + makeUser({ llmProvider: LLM_PROVIDER.OPENAI, llmApiKey: 'encrypted:my-key' }), ), }); const llmApiKeyCipher = makeLlmApiKeyCipher(); @@ -41,10 +56,42 @@ describe('UserLLMProviderFactory', () => { const provider = await factory.forUser('user-1'); - expect(provider).toBeInstanceOf(OpenRouterLLMProvider); + expect(provider).toBeInstanceOf(OpenAICompatibleLLMProvider); expect(llmApiKeyCipher.decrypt).toHaveBeenCalledWith('encrypted:my-key'); }); + it('returns an OpenAICompatibleLLMProvider for openrouter', async () => { + const userRepository = makeUserRepository({ + findById: vi + .fn() + .mockResolvedValue( + makeUser({ llmProvider: LLM_PROVIDER.OPENROUTER, llmApiKey: 'encrypted:my-key' }), + ), + }); + const factory = new UserLLMProviderFactory({ + userRepository, + llmApiKeyCipher: makeLlmApiKeyCipher(), + }); + + expect(await factory.forUser('user-1')).toBeInstanceOf(OpenAICompatibleLLMProvider); + }); + + it('returns an AnthropicLLMProvider for anthropic', async () => { + const userRepository = makeUserRepository({ + findById: vi + .fn() + .mockResolvedValue( + makeUser({ llmProvider: LLM_PROVIDER.ANTHROPIC, llmApiKey: 'encrypted:my-key' }), + ), + }); + const factory = new UserLLMProviderFactory({ + userRepository, + llmApiKeyCipher: makeLlmApiKeyCipher(), + }); + + expect(await factory.forUser('user-1')).toBeInstanceOf(AnthropicLLMProvider); + }); + it('returns a GoogleAILLMProvider with the decrypted key for the googleai provider', async () => { const userRepository = makeUserRepository({ findById: vi @@ -62,4 +109,23 @@ describe('UserLLMProviderFactory', () => { expect(provider).toBeInstanceOf(GoogleAILLMProvider); }); + + it('builds an OpenAICompatibleLLMProvider from the stored baseUrl/model for the custom provider', async () => { + const userRepository = makeUserRepository({ + findById: vi.fn().mockResolvedValue( + makeUser({ + llmProvider: LLM_PROVIDER.CUSTOM, + llmApiKey: 'encrypted:my-key', + llmBaseUrl: 'https://my-llm.example.com/v1/chat/completions', + llmModel: 'my-custom-model', + }), + ), + }); + const factory = new UserLLMProviderFactory({ + userRepository, + llmApiKeyCipher: makeLlmApiKeyCipher(), + }); + + expect(await factory.forUser('user-1')).toBeInstanceOf(OpenAICompatibleLLMProvider); + }); }); diff --git a/apps/api/src/__tests__/infrastructure/llm/providerRegistry.test.ts b/apps/api/src/__tests__/infrastructure/llm/providerRegistry.test.ts new file mode 100644 index 00000000..d9dcaf8a --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/llm/providerRegistry.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { PROVIDER_REGISTRY } from '#src/infrastructure/llm/providerRegistry.js'; +import { LLM_PROVIDER } from '#src/constants.js'; + +describe('PROVIDER_REGISTRY', () => { + it('has an entry for every LLM_PROVIDER value', () => { + for (const provider of Object.values(LLM_PROVIDER)) { + expect(PROVIDER_REGISTRY[provider]).toBeDefined(); + expect(PROVIDER_REGISTRY[provider].label).toBeTruthy(); + } + }); + + it('throws when creating the custom provider without a base URL', () => { + expect(() => + PROVIDER_REGISTRY[LLM_PROVIDER.CUSTOM].create({ + apiKey: 'key', + model: 'some-model', + baseUrl: null, + }), + ).toThrow('Custom provider requires both a base URL and a model'); + }); + + it('throws when creating the custom provider without a model', () => { + expect(() => + PROVIDER_REGISTRY[LLM_PROVIDER.CUSTOM].create({ + apiKey: 'key', + model: null, + baseUrl: 'https://example.com/v1/chat/completions', + }), + ).toThrow('Custom provider requires both a base URL and a model'); + }); + + it('creates a provider for custom when both are supplied', () => { + const provider = PROVIDER_REGISTRY[LLM_PROVIDER.CUSTOM].create({ + apiKey: 'key', + model: 'some-model', + baseUrl: 'https://example.com/v1/chat/completions', + }); + expect(provider).toBeDefined(); + }); +}); diff --git a/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts b/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts index e6a63d54..c958c658 100644 --- a/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts +++ b/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts @@ -278,6 +278,28 @@ describe('UserResolver', () => { userId: 'user-1', provider: 'openrouter', apiKey: 'sk-123', + model: undefined, + baseUrl: undefined, + }); + }); + + it('passes through model and baseUrl when provided', async () => { + const deps = makeDeps(); + + await new UserResolver(deps).saveLlmApiKey( + 'user-1', + 'custom', + 'sk-123', + 'my-model', + 'https://my-llm.example.com/v1/chat/completions', + ); + + expect(deps.saveLlmApiKeyUseCase.execute).toHaveBeenCalledWith({ + userId: 'user-1', + provider: 'custom', + apiKey: 'sk-123', + model: 'my-model', + baseUrl: 'https://my-llm.example.com/v1/chat/completions', }); }); }); diff --git a/apps/api/src/constants.ts b/apps/api/src/constants.ts index 8648ab9e..1b1f2146 100644 --- a/apps/api/src/constants.ts +++ b/apps/api/src/constants.ts @@ -204,16 +204,36 @@ export const OAUTH = { /** `LLM_PROVIDER` values. */ export const LLM_PROVIDER = { - OPENROUTER: 'openrouter', + OPENAI: 'openai', + ANTHROPIC: 'anthropic', GOOGLEAI: 'googleai', + OPENROUTER: 'openrouter', + MISTRAL: 'mistral', + GROQ: 'groq', + XAI: 'xai', + DEEPSEEK: 'deepseek', + CUSTOM: 'custom', } as const; -/** LLM provider defaults. */ +/** LLM provider API URLs, default models, and other per-provider settings. */ export const LLM = { - OPENROUTER_API_URL: 'https://openrouter.ai/api/v1/chat/completions', - OPENROUTER_DEFAULT_MODEL: 'openai/gpt-4o-mini', + OPENAI_API_URL: 'https://api.openai.com/v1/chat/completions', + OPENAI_DEFAULT_MODEL: 'gpt-4o-mini', + ANTHROPIC_API_URL: 'https://api.anthropic.com/v1/messages', + ANTHROPIC_DEFAULT_MODEL: 'claude-3-5-haiku-latest', + ANTHROPIC_VERSION: '2023-06-01', GOOGLEAI_API_URL: 'https://generativelanguage.googleapis.com/v1beta/models', GOOGLEAI_DEFAULT_MODEL: 'gemini-2.0-flash', + OPENROUTER_API_URL: 'https://openrouter.ai/api/v1/chat/completions', + OPENROUTER_DEFAULT_MODEL: 'openai/gpt-4o-mini', + MISTRAL_API_URL: 'https://api.mistral.ai/v1/chat/completions', + MISTRAL_DEFAULT_MODEL: 'mistral-small-latest', + GROQ_API_URL: 'https://api.groq.com/openai/v1/chat/completions', + GROQ_DEFAULT_MODEL: 'llama-3.3-70b-versatile', + XAI_API_URL: 'https://api.x.ai/v1/chat/completions', + XAI_DEFAULT_MODEL: 'grok-2-latest', + DEEPSEEK_API_URL: 'https://api.deepseek.com/chat/completions', + DEEPSEEK_DEFAULT_MODEL: 'deepseek-chat', } as const; /** diff --git a/apps/api/src/domain/user/User.ts b/apps/api/src/domain/user/User.ts index 35630918..6d78d57c 100644 --- a/apps/api/src/domain/user/User.ts +++ b/apps/api/src/domain/user/User.ts @@ -13,10 +13,14 @@ export interface User { followUpRemindersEnabled: boolean; totpSecret: string | null; totpEnabled: boolean; - /** 'openrouter' | 'googleai' — which provider llmApiKey below is for. */ + /** Which provider llmApiKey below is for — see LLM_PROVIDER in constants.ts. */ llmProvider: string | null; /** User's own LLM API key, encrypted at rest. */ llmApiKey: string | null; + /** Model override; required when llmProvider is 'custom', optional elsewhere. */ + llmModel: string | null; + /** Base URL; only used (and required) when llmProvider is 'custom'. */ + llmBaseUrl: string | null; createdAt: Date; updatedAt: Date; } diff --git a/apps/api/src/http/schema/mutations/userMutations.ts b/apps/api/src/http/schema/mutations/userMutations.ts index 0b5fa05f..8ad8b2d2 100644 --- a/apps/api/src/http/schema/mutations/userMutations.ts +++ b/apps/api/src/http/schema/mutations/userMutations.ts @@ -168,13 +168,21 @@ builder.mutationField('saveLlmApiKey', (t) => args: { provider: t.arg.string({ required: true }), apiKey: t.arg.string({ required: true }), + model: t.arg.string({ required: false }), + baseUrl: t.arg.string({ required: false }), }, resolve: async (_root, args, ctx) => { if (!ctx.user) throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); const { userResolver } = ctx.diScope.cradle; try { - await userResolver.saveLlmApiKey(ctx.user.sub, args.provider, args.apiKey); + await userResolver.saveLlmApiKey( + ctx.user.sub, + args.provider, + args.apiKey, + args.model, + args.baseUrl, + ); return true; } catch (err) { throw fromCodedError(err); diff --git a/apps/api/src/http/schema/types/LlmKeyStatusType.ts b/apps/api/src/http/schema/types/LlmKeyStatusType.ts index 09556c7d..905ace42 100644 --- a/apps/api/src/http/schema/types/LlmKeyStatusType.ts +++ b/apps/api/src/http/schema/types/LlmKeyStatusType.ts @@ -6,5 +6,7 @@ LlmKeyStatusRef.implement({ fields: (t) => ({ configured: t.exposeBoolean('configured'), provider: t.exposeString('provider', { nullable: true }), + model: t.exposeString('model', { nullable: true }), + baseUrl: t.exposeString('baseUrl', { nullable: true }), }), }); diff --git a/apps/api/src/infrastructure/db/repositories/DrizzleUserRepository.ts b/apps/api/src/infrastructure/db/repositories/DrizzleUserRepository.ts index 2623c17c..ab38d09e 100644 --- a/apps/api/src/infrastructure/db/repositories/DrizzleUserRepository.ts +++ b/apps/api/src/infrastructure/db/repositories/DrizzleUserRepository.ts @@ -58,6 +58,8 @@ export class DrizzleUserRepository implements IUserRepository { totpEnabled?: boolean; llmProvider?: string | null; llmApiKey?: string | null; + llmModel?: string | null; + llmBaseUrl?: string | null; }, ): Promise { const [row] = await this.db @@ -93,6 +95,8 @@ export class DrizzleUserRepository implements IUserRepository { totpEnabled: row.totpEnabled, llmProvider: row.llmProvider, llmApiKey: row.llmApiKey, + llmModel: row.llmModel, + llmBaseUrl: row.llmBaseUrl, createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/apps/api/src/infrastructure/db/schema.ts b/apps/api/src/infrastructure/db/schema.ts index c58ef2ee..b4942a5b 100644 --- a/apps/api/src/infrastructure/db/schema.ts +++ b/apps/api/src/infrastructure/db/schema.ts @@ -16,10 +16,14 @@ export const user = sqliteTable('User', { .default(true), totpSecret: text('totpSecret'), totpEnabled: integer('totpEnabled', { mode: 'boolean' }).notNull().default(false), - /** 'openrouter' | 'googleai' — which provider the user's own key below is for. */ + /** Which provider the user's own key below is for — see LLM_PROVIDER in constants.ts. */ llmProvider: text('llmProvider'), /** User's own LLM API key, encrypted at rest (never returned to the client). */ llmApiKey: text('llmApiKey'), + /** Model override; required when llmProvider is 'custom', optional elsewhere. */ + llmModel: text('llmModel'), + /** Base URL; only used (and required) when llmProvider is 'custom'. */ + llmBaseUrl: text('llmBaseUrl'), createdAt: integer('createdAt', { mode: 'timestamp_ms' }) .notNull() .$defaultFn(() => new Date()), diff --git a/apps/api/src/infrastructure/llm/AnthropicLLMProvider.ts b/apps/api/src/infrastructure/llm/AnthropicLLMProvider.ts new file mode 100644 index 00000000..e7365010 --- /dev/null +++ b/apps/api/src/infrastructure/llm/AnthropicLLMProvider.ts @@ -0,0 +1,49 @@ +import type { ILLMProvider, LLMMessage } from '#src/use-cases/ports/ILLMProvider.js'; +import { LLM } from '#src/constants.js'; + +export class AnthropicLLMProvider implements ILLMProvider { + constructor( + private readonly apiKey: string, + private readonly model: string = LLM.ANTHROPIC_DEFAULT_MODEL, + ) {} + + async complete(messages: LLMMessage[], maxTokens = 512): Promise { + if (!this.apiKey) throw new Error('Anthropic API key is not set'); + + // Anthropic's Messages API takes the system prompt as a separate + // top-level field rather than a message with role "system". + const system = messages + .filter((m) => m.role === 'system') + .map((m) => m.content) + .join('\n\n'); + const conversation = messages + .filter((m) => m.role !== 'system') + .map((m) => ({ role: m.role, content: m.content })); + + const response = await fetch(LLM.ANTHROPIC_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': LLM.ANTHROPIC_VERSION, + }, + body: JSON.stringify({ + model: this.model, + max_tokens: maxTokens, + ...(system ? { system } : {}), + messages: conversation, + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Anthropic error ${response.status}: ${body}`); + } + + const json = (await response.json()) as { + content?: Array<{ type: string; text?: string }>; + }; + + return json.content?.find((block) => block.type === 'text')?.text ?? ''; + } +} diff --git a/apps/api/src/infrastructure/llm/OpenRouterLLMProvider.ts b/apps/api/src/infrastructure/llm/OpenAICompatibleLLMProvider.ts similarity index 51% rename from apps/api/src/infrastructure/llm/OpenRouterLLMProvider.ts rename to apps/api/src/infrastructure/llm/OpenAICompatibleLLMProvider.ts index 3a8a79b4..63936620 100644 --- a/apps/api/src/infrastructure/llm/OpenRouterLLMProvider.ts +++ b/apps/api/src/infrastructure/llm/OpenAICompatibleLLMProvider.ts @@ -1,16 +1,23 @@ import type { ILLMProvider, LLMMessage } from '#src/use-cases/ports/ILLMProvider.js'; -import { AUTH_HEADER, LLM } from '#src/constants.js'; +import { AUTH_HEADER } from '#src/constants.js'; -export class OpenRouterLLMProvider implements ILLMProvider { +/** + * Covers every provider that implements OpenAI's `/chat/completions` request + * and response shape — OpenAI itself, OpenRouter, Mistral, Groq, xAI, + * DeepSeek, and any user-supplied custom endpoint. Only the base URL and + * default model differ between them, which the caller supplies. + */ +export class OpenAICompatibleLLMProvider implements ILLMProvider { constructor( private readonly apiKey: string, - private readonly model: string = LLM.OPENROUTER_DEFAULT_MODEL, + private readonly baseUrl: string, + private readonly model: string, ) {} async complete(messages: LLMMessage[], maxTokens = 512): Promise { - if (!this.apiKey) throw new Error('OpenRouter API key is not set'); + if (!this.apiKey) throw new Error('API key is not set'); - const response = await fetch(LLM.OPENROUTER_API_URL, { + const response = await fetch(this.baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -21,7 +28,7 @@ export class OpenRouterLLMProvider implements ILLMProvider { if (!response.ok) { const body = await response.text(); - throw new Error(`OpenRouter error ${response.status}: ${body}`); + throw new Error(`LLM provider error ${response.status}: ${body}`); } const json = (await response.json()) as { diff --git a/apps/api/src/infrastructure/llm/UserLLMProviderFactory.ts b/apps/api/src/infrastructure/llm/UserLLMProviderFactory.ts index 4b64ec8f..bd796e53 100644 --- a/apps/api/src/infrastructure/llm/UserLLMProviderFactory.ts +++ b/apps/api/src/infrastructure/llm/UserLLMProviderFactory.ts @@ -1,6 +1,4 @@ -import { GoogleAILLMProvider } from '#src/infrastructure/llm/GoogleAILLMProvider.js'; -import { OpenRouterLLMProvider } from '#src/infrastructure/llm/OpenRouterLLMProvider.js'; -import { LLM_PROVIDER } from '#src/constants.js'; +import { PROVIDER_REGISTRY } from '#src/infrastructure/llm/providerRegistry.js'; import type { IUserRepository } from '#src/use-cases/ports/IUserRepository.js'; import type { ILlmApiKeyCipher } from '#src/use-cases/ports/ILlmApiKeyCipher.js'; import type { ILLMProvider } from '#src/use-cases/ports/ILLMProvider.js'; @@ -18,9 +16,10 @@ export class UserLLMProviderFactory implements ILLMProviderFactory { const user = await this.deps.userRepository.findById(userId); if (!user?.llmProvider || !user.llmApiKey) return null; + const entry = PROVIDER_REGISTRY[user.llmProvider]; + if (!entry) return null; + const apiKey = this.deps.llmApiKeyCipher.decrypt(user.llmApiKey); - return user.llmProvider === LLM_PROVIDER.GOOGLEAI - ? new GoogleAILLMProvider(apiKey) - : new OpenRouterLLMProvider(apiKey); + return entry.create({ apiKey, model: user.llmModel, baseUrl: user.llmBaseUrl }); } } diff --git a/apps/api/src/infrastructure/llm/providerRegistry.ts b/apps/api/src/infrastructure/llm/providerRegistry.ts new file mode 100644 index 00000000..bc748c23 --- /dev/null +++ b/apps/api/src/infrastructure/llm/providerRegistry.ts @@ -0,0 +1,84 @@ +import { GoogleAILLMProvider } from '#src/infrastructure/llm/GoogleAILLMProvider.js'; +import { AnthropicLLMProvider } from '#src/infrastructure/llm/AnthropicLLMProvider.js'; +import { OpenAICompatibleLLMProvider } from '#src/infrastructure/llm/OpenAICompatibleLLMProvider.js'; +import { LLM, LLM_PROVIDER } from '#src/constants.js'; +import type { ILLMProvider } from '#src/use-cases/ports/ILLMProvider.js'; + +export interface LLMProviderRegistryEntry { + label: string; + /** `model`/`baseUrl` are the user's stored overrides — null unless explicitly set. */ + create(params: { apiKey: string; model: string | null; baseUrl: string | null }): ILLMProvider; +} + +/** + * One entry per supported provider — adding a new one is a registry entry, + * not new branching logic in the factory that consumes this. Most entries + * reuse `OpenAICompatibleLLMProvider` since they all implement OpenAI's + * `/chat/completions` shape; only Anthropic and Google AI need bespoke + * provider classes. + */ +export const PROVIDER_REGISTRY: Record = { + [LLM_PROVIDER.OPENAI]: { + label: 'OpenAI', + create: ({ apiKey, model }) => + new OpenAICompatibleLLMProvider( + apiKey, + LLM.OPENAI_API_URL, + model ?? LLM.OPENAI_DEFAULT_MODEL, + ), + }, + [LLM_PROVIDER.ANTHROPIC]: { + label: 'Anthropic (Claude)', + create: ({ apiKey, model }) => new AnthropicLLMProvider(apiKey, model ?? undefined), + }, + [LLM_PROVIDER.GOOGLEAI]: { + label: 'Google AI', + create: ({ apiKey, model }) => new GoogleAILLMProvider(apiKey, model ?? undefined), + }, + [LLM_PROVIDER.OPENROUTER]: { + label: 'OpenRouter', + create: ({ apiKey, model }) => + new OpenAICompatibleLLMProvider( + apiKey, + LLM.OPENROUTER_API_URL, + model ?? LLM.OPENROUTER_DEFAULT_MODEL, + ), + }, + [LLM_PROVIDER.MISTRAL]: { + label: 'Mistral', + create: ({ apiKey, model }) => + new OpenAICompatibleLLMProvider( + apiKey, + LLM.MISTRAL_API_URL, + model ?? LLM.MISTRAL_DEFAULT_MODEL, + ), + }, + [LLM_PROVIDER.GROQ]: { + label: 'Groq', + create: ({ apiKey, model }) => + new OpenAICompatibleLLMProvider(apiKey, LLM.GROQ_API_URL, model ?? LLM.GROQ_DEFAULT_MODEL), + }, + [LLM_PROVIDER.XAI]: { + label: 'xAI (Grok)', + create: ({ apiKey, model }) => + new OpenAICompatibleLLMProvider(apiKey, LLM.XAI_API_URL, model ?? LLM.XAI_DEFAULT_MODEL), + }, + [LLM_PROVIDER.DEEPSEEK]: { + label: 'DeepSeek', + create: ({ apiKey, model }) => + new OpenAICompatibleLLMProvider( + apiKey, + LLM.DEEPSEEK_API_URL, + model ?? LLM.DEEPSEEK_DEFAULT_MODEL, + ), + }, + [LLM_PROVIDER.CUSTOM]: { + label: 'Custom (OpenAI-compatible)', + create: ({ apiKey, model, baseUrl }) => { + if (!baseUrl || !model) { + throw new Error('Custom provider requires both a base URL and a model'); + } + return new OpenAICompatibleLLMProvider(apiKey, baseUrl, model); + }, + }, +}; diff --git a/apps/api/src/interface-adapters/resolvers/UserResolver.ts b/apps/api/src/interface-adapters/resolvers/UserResolver.ts index 305a4b53..60668c1c 100644 --- a/apps/api/src/interface-adapters/resolvers/UserResolver.ts +++ b/apps/api/src/interface-adapters/resolvers/UserResolver.ts @@ -114,8 +114,14 @@ export class UserResolver { return this.deps.getTotpStatusUseCase.execute(userId); } - async saveLlmApiKey(userId: string, provider: string, apiKey: string): Promise { - await this.deps.saveLlmApiKeyUseCase.execute({ userId, provider, apiKey }); + async saveLlmApiKey( + userId: string, + provider: string, + apiKey: string, + model?: string | null, + baseUrl?: string | null, + ): Promise { + await this.deps.saveLlmApiKeyUseCase.execute({ userId, provider, apiKey, model, baseUrl }); } async clearLlmApiKey(userId: string): Promise { diff --git a/apps/api/src/use-cases/ports/IUserRepository.ts b/apps/api/src/use-cases/ports/IUserRepository.ts index e116a442..44294b34 100644 --- a/apps/api/src/use-cases/ports/IUserRepository.ts +++ b/apps/api/src/use-cases/ports/IUserRepository.ts @@ -27,6 +27,8 @@ export interface IUserRepository { totpEnabled?: boolean; llmProvider?: string | null; llmApiKey?: string | null; + llmModel?: string | null; + llmBaseUrl?: string | null; }, ): Promise; delete(id: string): Promise; diff --git a/apps/api/src/use-cases/user/ClearLlmApiKeyUseCase.ts b/apps/api/src/use-cases/user/ClearLlmApiKeyUseCase.ts index bd57ca00..55144a37 100644 --- a/apps/api/src/use-cases/user/ClearLlmApiKeyUseCase.ts +++ b/apps/api/src/use-cases/user/ClearLlmApiKeyUseCase.ts @@ -13,6 +13,11 @@ export class ClearLlmApiKeyUseCase implements IClearLlmApiKeyUseCase { const user = await this.deps.userRepository.findById(userId); if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); - await this.deps.userRepository.update(userId, { llmProvider: null, llmApiKey: null }); + await this.deps.userRepository.update(userId, { + llmProvider: null, + llmApiKey: null, + llmModel: null, + llmBaseUrl: null, + }); } } diff --git a/apps/api/src/use-cases/user/GetLlmKeyStatusUseCase.ts b/apps/api/src/use-cases/user/GetLlmKeyStatusUseCase.ts index 5e2b6f4f..90b82faa 100644 --- a/apps/api/src/use-cases/user/GetLlmKeyStatusUseCase.ts +++ b/apps/api/src/use-cases/user/GetLlmKeyStatusUseCase.ts @@ -19,6 +19,8 @@ export class GetLlmKeyStatusUseCase implements IGetLlmKeyStatusUseCase { return { configured: Boolean(user.llmProvider && user.llmApiKey), provider: user.llmProvider, + model: user.llmModel, + baseUrl: user.llmBaseUrl, }; } } diff --git a/apps/api/src/use-cases/user/IGetLlmKeyStatusUseCase.ts b/apps/api/src/use-cases/user/IGetLlmKeyStatusUseCase.ts index 0969a01a..dfb95b6c 100644 --- a/apps/api/src/use-cases/user/IGetLlmKeyStatusUseCase.ts +++ b/apps/api/src/use-cases/user/IGetLlmKeyStatusUseCase.ts @@ -1,6 +1,8 @@ export interface LlmKeyStatus { configured: boolean; provider: string | null; + model: string | null; + baseUrl: string | null; } export interface IGetLlmKeyStatusUseCase { diff --git a/apps/api/src/use-cases/user/ISaveLlmApiKeyUseCase.ts b/apps/api/src/use-cases/user/ISaveLlmApiKeyUseCase.ts index 40a9e413..49673508 100644 --- a/apps/api/src/use-cases/user/ISaveLlmApiKeyUseCase.ts +++ b/apps/api/src/use-cases/user/ISaveLlmApiKeyUseCase.ts @@ -2,6 +2,10 @@ export interface SaveLlmApiKeyInput { userId: string; provider: string; apiKey: string; + /** Optional model override for named providers; required when provider is 'custom'. */ + model?: string | null; + /** Base URL; only valid (and required) when provider is 'custom'. */ + baseUrl?: string | null; } export interface ISaveLlmApiKeyUseCase { diff --git a/apps/api/src/use-cases/user/SaveLlmApiKeyUseCase.ts b/apps/api/src/use-cases/user/SaveLlmApiKeyUseCase.ts index 9f30bc1b..8d3ec8bc 100644 --- a/apps/api/src/use-cases/user/SaveLlmApiKeyUseCase.ts +++ b/apps/api/src/use-cases/user/SaveLlmApiKeyUseCase.ts @@ -13,6 +13,15 @@ interface Deps { const VALID_PROVIDERS: string[] = Object.values(LLM_PROVIDER); +function isValidUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + export class SaveLlmApiKeyUseCase implements ISaveLlmApiKeyUseCase { constructor(private readonly deps: Deps) {} @@ -24,12 +33,40 @@ export class SaveLlmApiKeyUseCase implements ISaveLlmApiKeyUseCase { throw Object.assign(new Error('API key is required'), { code: ERROR_CODES.VALIDATION }); } + const isCustom = input.provider === LLM_PROVIDER.CUSTOM; + const baseUrl = input.baseUrl?.trim() || null; + const model = input.model?.trim() || null; + + if (isCustom) { + if (!baseUrl) { + throw Object.assign(new Error('A base URL is required for a custom provider'), { + code: ERROR_CODES.VALIDATION, + }); + } + if (!isValidUrl(baseUrl)) { + throw Object.assign(new Error('Base URL must be a valid http(s) URL'), { + code: ERROR_CODES.VALIDATION, + }); + } + if (!model) { + throw Object.assign(new Error('A model is required for a custom provider'), { + code: ERROR_CODES.VALIDATION, + }); + } + } else if (baseUrl) { + throw Object.assign(new Error('A base URL can only be set for a custom provider'), { + code: ERROR_CODES.VALIDATION, + }); + } + const user = await this.deps.userRepository.findById(input.userId); if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); await this.deps.userRepository.update(input.userId, { llmProvider: input.provider, llmApiKey: this.deps.llmApiKeyCipher.encrypt(input.apiKey.trim()), + llmModel: model, + llmBaseUrl: isCustom ? baseUrl : null, }); } } diff --git a/apps/web/src/routes/_authenticated/account.tsx b/apps/web/src/routes/_authenticated/account.tsx index 0e6237fa..803aa8c5 100644 --- a/apps/web/src/routes/_authenticated/account.tsx +++ b/apps/web/src/routes/_authenticated/account.tsx @@ -185,13 +185,15 @@ const LLM_KEY_STATUS_QUERY = ` llmKeyStatus { configured provider + model + baseUrl } } `; const SAVE_LLM_API_KEY = ` - mutation SaveLlmApiKey($provider: String!, $apiKey: String!) { - saveLlmApiKey(provider: $provider, apiKey: $apiKey) + mutation SaveLlmApiKey($provider: String!, $apiKey: String!, $model: String, $baseUrl: String) { + saveLlmApiKey(provider: $provider, apiKey: $apiKey, model: $model, baseUrl: $baseUrl) } `; @@ -257,10 +259,56 @@ const totpDisableSchema = z.object({ password: z.string().min(1, 'Required'), }); -const llmApiKeySchema = z.object({ - provider: z.enum(['openrouter', 'googleai']), - apiKey: z.string().min(1, 'Required'), -}); +const CUSTOM_LLM_PROVIDER = 'custom'; + +const llmApiKeySchema = z + .object({ + provider: z.enum([ + 'openai', + 'anthropic', + 'googleai', + 'openrouter', + 'mistral', + 'groq', + 'xai', + 'deepseek', + 'custom', + ]), + apiKey: z.string().min(1, 'Required'), + model: z.string().optional(), + baseUrl: z.string().optional(), + }) + .superRefine((data, ctx) => { + const isCustom = data.provider === CUSTOM_LLM_PROVIDER; + if (isCustom && !data.model?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Required for a custom provider', + path: ['model'], + }); + } + if (isCustom) { + if (!data.baseUrl?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Required for a custom provider', + path: ['baseUrl'], + }); + } else if (!/^https?:\/\//.test(data.baseUrl.trim())) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Must start with http:// or https://', + path: ['baseUrl'], + }); + } + } else if (data.baseUrl?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Only valid for a custom provider', + path: ['baseUrl'], + }); + } + }); type ProfileForm = z.infer; type EmailForm = z.infer; @@ -273,13 +321,29 @@ type LlmApiKeyForm = z.infer; type TotpSetup = { secret: string; otpauthUrl: string; qrCodeDataUrl: string }; -type LlmKeyStatus = { configured: boolean; provider: string | null }; - -const LLM_PROVIDER_LABEL: Record = { - openrouter: 'OpenRouter', - googleai: 'Google AI', +type LlmKeyStatus = { + configured: boolean; + provider: string | null; + model: string | null; + baseUrl: string | null; }; +const LLM_PROVIDER_OPTIONS: { value: string; label: string }[] = [ + { value: 'openai', label: 'OpenAI' }, + { value: 'anthropic', label: 'Anthropic (Claude)' }, + { value: 'googleai', label: 'Google AI' }, + { value: 'openrouter', label: 'OpenRouter' }, + { value: 'mistral', label: 'Mistral' }, + { value: 'groq', label: 'Groq' }, + { value: 'xai', label: 'xAI (Grok)' }, + { value: 'deepseek', label: 'DeepSeek' }, + { value: CUSTOM_LLM_PROVIDER, label: 'Custom (OpenAI-compatible)' }, +]; + +const LLM_PROVIDER_LABEL: Record = Object.fromEntries( + LLM_PROVIDER_OPTIONS.map((o) => [o.value, o.label]), +); + interface LoginEvent { id: string; ipAddress: string | null; @@ -588,12 +652,19 @@ export function AccountPage() { const llmApiKeyForm = useForm({ resolver: zodResolver(llmApiKeySchema), - defaultValues: { provider: 'openrouter', apiKey: '' }, + defaultValues: { provider: 'openrouter', apiKey: '', model: '', baseUrl: '' }, }); + const llmApiKeyProvider = llmApiKeyForm.watch('provider'); + const isCustomLlmProvider = llmApiKeyProvider === CUSTOM_LLM_PROVIDER; const onSaveLlmApiKey = async (data: LlmApiKeyForm) => { try { - await gqlClient.request(SAVE_LLM_API_KEY, data); - llmApiKeyForm.reset({ provider: data.provider, apiKey: '' }); + await gqlClient.request(SAVE_LLM_API_KEY, { + provider: data.provider, + apiKey: data.apiKey, + model: data.model?.trim() || undefined, + baseUrl: data.baseUrl?.trim() || undefined, + }); + llmApiKeyForm.reset({ provider: data.provider, apiKey: '', model: '', baseUrl: '' }); await qc.invalidateQueries({ queryKey: ['llmKeyStatus'] }); } catch (err) { llmApiKeyForm.setError('root', { @@ -609,7 +680,7 @@ export function AccountPage() { setClearLlmKeyError(null); try { await gqlClient.request(CLEAR_LLM_API_KEY); - llmApiKeyForm.reset({ provider: 'openrouter', apiKey: '' }); + llmApiKeyForm.reset({ provider: 'openrouter', apiKey: '', model: '', baseUrl: '' }); await qc.invalidateQueries({ queryKey: ['llmKeyStatus'] }); } catch (err) { setClearLlmKeyError(extractGqlError(err) ?? 'Failed to remove API key.'); @@ -1180,8 +1251,9 @@ export function AccountPage() {

AI features

- Add your own OpenRouter or Google AI API key to enable cover letter generation and job - description auto-fill. job-finder doesn't provide a shared key — these features + Add your own API key from OpenAI, Anthropic, Google AI, OpenRouter, Mistral, Groq, xAI, + DeepSeek, or any other OpenAI-compatible endpoint to enable cover letter generation and + job description auto-fill. job-finder doesn't provide a shared key — these features stay off until you add one.

@@ -1190,8 +1262,14 @@ export function AccountPage() {

AI features are enabled using{' '} - {LLM_PROVIDER_LABEL[llmKeyStatus.provider ?? ''] ?? llmKeyStatus.provider}. + {LLM_PROVIDER_LABEL[llmKeyStatus.provider ?? ''] ?? llmKeyStatus.provider} + {llmKeyStatus.model ? ` (${llmKeyStatus.model})` : ''}.

+ {llmKeyStatus.provider === CUSTOM_LLM_PROVIDER && llmKeyStatus.baseUrl && ( +

+ {llmKeyStatus.baseUrl} +

+ )} {clearLlmKeyError && (

{clearLlmKeyError} @@ -1211,8 +1289,11 @@ export function AccountPage() {

@@ -1229,6 +1310,53 @@ export function AccountPage() {

)}
+ {isCustomLlmProvider ? ( + <> +
+ + + {llmApiKeyForm.formState.errors.baseUrl && ( +

+ {llmApiKeyForm.formState.errors.baseUrl.message} +

+ )} +
+
+ + + {llmApiKeyForm.formState.errors.model && ( +

+ {llmApiKeyForm.formState.errors.model.message} +

+ )} +
+ + ) : ( +
+ + +
+ )} {llmApiKeyForm.formState.errors.root && (

{llmApiKeyForm.formState.errors.root.message}