diff --git a/.env.example b/.env.example index bef0cafe..1e9567d9 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,23 @@ SENTRY_PROJECT=promisetracker NEXT_PUBLIC_DEFAULT_LOCALE=en NEXT_PUBLIC_LOCALES="en, fr" +# AI Providers (optional fallbacks when not set in admin Settings) +GOOGLE_GENERATIVE_AI_API_KEY= +XAI_API_KEY= +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +MISTRAL_API_KEY= +DEEPSEEK_API_KEY= +CEREBRAS_API_KEY= +GROQ_API_KEY= +TOGETHER_API_KEY= +COHERE_API_KEY= +FIREWORKS_API_KEY= +DEEPINFRA_API_KEY= +PERPLEXITY_API_KEY= +OLLAMA_API_KEY= +OLLAMA_BASE_URL=http://127.0.0.1:11434 + # S3 S3_BUCKET= S3_ACCESS_KEY_ID= diff --git a/README.md b/README.md index 8d653888..ab7f2ea1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 1. CheckDesk API Key and Team ID 2. Airtable API Key and Base ID -3. Google Gemini API Key. +3. At least one AI provider credential (Google, OpenAI, Anthropic, Grok, Mistral, DeepSeek, Cerebras, Groq, Together AI, Cohere, Fireworks, DeepInfra, Perplexity, or Ollama). 4. Docker ## Running the App. diff --git a/package.json b/package.json index b8b4d546..2aea6a95 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,20 @@ "generate:airtable": "airtable-ts-codegen --apiKey $AIRTABLE_API_KEY --baseId $AIRTABLE_BASE_ID --output ./src/airtable.ts" }, "dependencies": { - "@ai-sdk/google": "^2.0.11", + "@ai-sdk/anthropic": "^3.0.46", + "@ai-sdk/cerebras": "^2.0.34", + "@ai-sdk/cohere": "^3.0.21", + "@ai-sdk/deepinfra": "^2.0.34", + "@ai-sdk/deepseek": "^2.0.20", + "@ai-sdk/fireworks": "^2.0.34", + "@ai-sdk/google": "^3.0.30", + "@ai-sdk/groq": "^3.0.24", + "@ai-sdk/mistral": "^3.0.20", + "@ai-sdk/openai": "^3.0.30", + "@ai-sdk/openai-compatible": "^2.0.30", + "@ai-sdk/perplexity": "^3.0.19", + "@ai-sdk/togetherai": "^2.0.33", + "@ai-sdk/xai": "^3.0.57", "@ax-llm/ax": "^14.0.16", "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", @@ -46,7 +59,7 @@ "@payloadcms/ui": "3.77.0", "@sentry/nextjs": "^10.6.0", "@svgr/webpack": "^8.1.0", - "ai": "^5.0.52", + "ai": "^6.0.97", "airtable": "^0.12.2", "airtable-ts": "^1.6.0", "airtable-ts-formula": "^1.0.0", @@ -64,7 +77,7 @@ "require-in-the-middle": "^7.5.2", "sharp": "0.34.3", "tiktoken": "^1.0.22", - "zod": "^4.1.3" + "zod": "^4.3.6" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7aa3166..9f368f78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,48 @@ importers: .: dependencies: + '@ai-sdk/anthropic': + specifier: ^3.0.46 + version: 3.0.46(zod@4.3.6) + '@ai-sdk/cerebras': + specifier: ^2.0.34 + version: 2.0.34(zod@4.3.6) + '@ai-sdk/cohere': + specifier: ^3.0.21 + version: 3.0.21(zod@4.3.6) + '@ai-sdk/deepinfra': + specifier: ^2.0.34 + version: 2.0.34(zod@4.3.6) + '@ai-sdk/deepseek': + specifier: ^2.0.20 + version: 2.0.20(zod@4.3.6) + '@ai-sdk/fireworks': + specifier: ^2.0.34 + version: 2.0.34(zod@4.3.6) '@ai-sdk/google': - specifier: ^2.0.11 - version: 2.0.11(zod@4.1.3) + specifier: ^3.0.30 + version: 3.0.30(zod@4.3.6) + '@ai-sdk/groq': + specifier: ^3.0.24 + version: 3.0.24(zod@4.3.6) + '@ai-sdk/mistral': + specifier: ^3.0.20 + version: 3.0.20(zod@4.3.6) + '@ai-sdk/openai': + specifier: ^3.0.30 + version: 3.0.30(zod@4.3.6) + '@ai-sdk/openai-compatible': + specifier: ^2.0.30 + version: 2.0.30(zod@4.3.6) + '@ai-sdk/perplexity': + specifier: ^3.0.19 + version: 3.0.19(zod@4.3.6) + '@ai-sdk/togetherai': + specifier: ^2.0.33 + version: 2.0.33(zod@4.3.6) + '@ai-sdk/xai': + specifier: ^3.0.57 + version: 3.0.57(zod@4.3.6) '@ax-llm/ax': specifier: ^14.0.16 version: 14.0.16 @@ -84,8 +123,8 @@ importers: specifier: ^8.1.0 version: 8.1.0(typescript@5.9.2) ai: - specifier: ^5.0.52 - version: 5.0.52(zod@4.1.3) + specifier: ^6.0.97 + version: 6.0.97(zod@4.3.6) airtable: specifier: ^0.12.2 version: 0.12.2 @@ -138,8 +177,8 @@ importers: specifier: ^1.0.22 version: 1.0.22 zod: - specifier: ^4.1.3 - version: 4.1.3 + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@eslint/eslintrc': specifier: ^3.3.1 @@ -198,33 +237,105 @@ importers: packages: - '@ai-sdk/gateway@1.0.29': - resolution: {integrity: sha512-o9LtmBiG2WAgs3GAmL79F8idan/UupxHG8Tyr2gP4aUSOzflM0bsvfzozBp8x6WatQnOx+Pio7YNw45Y6I16iw==} + '@ai-sdk/anthropic@3.0.46': + resolution: {integrity: sha512-zXJPiNHaIiQ6XUqLeSYZ3ZbSzjqt1pNWEUf2hlkXlmmw8IF8KI0ruuGaDwKCExmtuNRf0E4TDxhsc9wRgWTzpw==} engines: {node: '>=18'} peerDependencies: - zod: ^3.25.76 || ^4 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/google@2.0.11': - resolution: {integrity: sha512-dnVIgSz1DZD/0gVau6ifYN3HZFN15HZwC9VjevTFfvrfSfbEvpXj5x/k/zk/0XuQrlQ5g8JiwJtxc9bx24x2xw==} + '@ai-sdk/cerebras@2.0.34': + resolution: {integrity: sha512-B/so5YrWypY3XXrtKoyfcWRtBX69kLsVDAaM1JGqay98DGGs8Ikh0i2P7UKOv/m0LsfOlff/mlYevPqGhwfuEw==} engines: {node: '>=18'} peerDependencies: - zod: ^3.25.76 || ^4 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@3.0.7': - resolution: {integrity: sha512-o3BS5/t8KnBL3ubP8k3w77AByOypLm+pkIL/DCw0qKkhDbvhCy+L3hRTGPikpdb8WHcylAeKsjgwOxhj4cqTUA==} + '@ai-sdk/cohere@3.0.21': + resolution: {integrity: sha512-HDUvNX2UhGpX0VyeAdOStFy07ZvjFtL+hXT5cXcwK/TBNiIhPFovTYHKVNSdNAxbH2eO7zRzygjMVUEX1X0Btw==} engines: {node: '>=18'} peerDependencies: - zod: ^3.25.76 || ^4 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@3.0.9': - resolution: {integrity: sha512-Pm571x5efqaI4hf9yW4KsVlDBDme8++UepZRnq+kqVBWWjgvGhQlzU8glaFq0YJEB9kkxZHbRRyVeHoV2sRYaQ==} + '@ai-sdk/deepinfra@2.0.34': + resolution: {integrity: sha512-nVZ/HgEHvLdx40aIJFvuTPQsGx2rHJSh8ZzSpx4E+fq6rL3D95QXWQ1xM81VXrj0uLCJp/eBhC9CZdMUpby7Ng==} engines: {node: '>=18'} peerDependencies: - zod: ^3.25.76 || ^4 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider@2.0.0': - resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} + '@ai-sdk/deepseek@2.0.20': + resolution: {integrity: sha512-MAL04sDTOWUiBjAGWaVgyeE4bYRb9QpKYRlIeCTZFga6I8yQs50XakhWEssrmvVihdpHGkqpDtCHsFqCydsWLA==} engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/fireworks@2.0.34': + resolution: {integrity: sha512-9SpijdfzyhS8XrutMu+hag4ST8LbDtoNaLuRMwfDR92JO4xyvSSVjhfuPvHsGQaXL+bcj0dUcQbB4bOyIv6z0A==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/gateway@3.0.53': + resolution: {integrity: sha512-QT3FEoNARMRlk8JJVR7L98exiK9C8AGfrEJVbRxBT1yIXKs/N19o/+PsjTRVsARgDJNcy9JbJp1FspKucEat0Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/google@3.0.30': + resolution: {integrity: sha512-ZzG6dU0XUSSXbxQJJTQUFpWeKkfzdpR7IykEZwaiaW5d+3u3RZ/zkRiGwAOcUpLp6k0eMd+IJF4looJv21ecxw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/groq@3.0.24': + resolution: {integrity: sha512-J6UMMVKBDf1vxYN8TS4nBzCEImhon1vuqpJYkRYdbxul6Hlf0r0pT5/+1AD1nbQ1SJsOPlDqMRSYJuBnNYrNfQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/mistral@3.0.20': + resolution: {integrity: sha512-oZcx2pE6nJ+Qj/U6HFV5mJ52jXJPBSpvki/NtIocZkI/rKxphKBaecOH1h0Y7yK3HIbBxsMqefB1pb72cAHGVg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai-compatible@2.0.30': + resolution: {integrity: sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai@3.0.30': + resolution: {integrity: sha512-YDht3t7TDyWKP+JYZp20VuYqSjyF2brHYh47GGFDUPf2wZiqNQ263ecL+quar2bP3GZ3BeQA8f0m2B7UwLPR+g==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/perplexity@3.0.19': + resolution: {integrity: sha512-7yxvyw0OFlHjCXgf+BDgmjefQmSk9FxSF5DPiFtLrow1zzLcvZvh6fkKEx+kgRQXNKhV9vvtH0U4NyVXgGMr0g==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.15': + resolution: {integrity: sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + + '@ai-sdk/togetherai@2.0.33': + resolution: {integrity: sha512-mVQxam47CESgt0PNEdPM+WrVLCCLRO2KDAxs+bUo+27EMH8ag5CbAYj4zNF1793guJmUluRH0JqwN0mX6exHjg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/xai@3.0.57': + resolution: {integrity: sha512-fY8MpcU1akfQStB/vDAAjJqJRWWGfHpRsNa31GNMlLLwHvwdyNhQVW8NtmIMrHDE+38pz/b0aMENJ4cb75qGPA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} @@ -3110,8 +3221,8 @@ packages: resolution: {integrity: sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==} engines: {node: '>=18.0.0'} - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} @@ -3513,6 +3624,10 @@ packages: cpu: [x64] os: [win32] + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@5.0.1': resolution: {integrity: sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3645,11 +3760,11 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ai@5.0.52: - resolution: {integrity: sha512-GLlRHjMlvN9+w7UYGxCpUQ8GgCRv5Z+JCprRH3Q8YbXJ/JyIc6EP9+YRUmQsyExX/qQsuehe7y/LLygarbSTOw==} + ai@6.0.97: + resolution: {integrity: sha512-eZIAcBymwGhBwncRH/v9pillZNMeRCDkc4BwcvwXerXd7sxjVxRis3ZNCNCpP02pVH4NLs81ljm4cElC4vbNcQ==} engines: {node: '>=18'} peerDependencies: - zod: ^3.25.76 || ^4 + zod: ^3.25.76 || ^4.1.8 airtable-ts-codegen@2.1.0: resolution: {integrity: sha512-HFsppNybQZq3jvkAeEOxo/156TGfbKNbBzVhUwLIgeF2CSM57beenlzaJfg0jTI/1YNuj7rTUPqgEtFkbWAanw==} @@ -4433,10 +4548,6 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.0.5: - resolution: {integrity: sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==} - engines: {node: '>=20.0.0'} - eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -6555,44 +6666,121 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@4.1.3: - resolution: {integrity: sha512-1neef4bMce1hNTrxvHVKxWjKfGDn0oAli3Wy1Uwb7TRO1+wEwoZUZNP1NXIEESybOBiFnBOhI6a4m6tCLE8dog==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@ai-sdk/gateway@1.0.29(zod@4.1.3)': + '@ai-sdk/anthropic@3.0.46(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/cerebras@2.0.34(zod@4.3.6)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.30(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/cohere@3.0.21(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/deepinfra@2.0.34(zod@4.3.6)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.30(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/deepseek@2.0.20(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/fireworks@2.0.34(zod@4.3.6)': dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.9(zod@4.1.3) - zod: 4.1.3 + '@ai-sdk/openai-compatible': 2.0.30(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 - '@ai-sdk/google@2.0.11(zod@4.1.3)': + '@ai-sdk/gateway@3.0.53(zod@4.3.6)': dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.7(zod@4.1.3) - zod: 4.1.3 + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + '@vercel/oidc': 3.1.0 + zod: 4.3.6 - '@ai-sdk/provider-utils@3.0.7(zod@4.1.3)': + '@ai-sdk/google@3.0.30(zod@4.3.6)': dependencies: - '@ai-sdk/provider': 2.0.0 - '@standard-schema/spec': 1.0.0 - eventsource-parser: 3.0.5 - zod: 4.1.3 + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 - '@ai-sdk/provider-utils@3.0.9(zod@4.1.3)': + '@ai-sdk/groq@3.0.24(zod@4.3.6)': dependencies: - '@ai-sdk/provider': 2.0.0 - '@standard-schema/spec': 1.0.0 + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/mistral@3.0.20(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/openai-compatible@2.0.30(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/openai@3.0.30(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/perplexity@3.0.19(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/provider-utils@4.0.15(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 4.1.3 + zod: 4.3.6 - '@ai-sdk/provider@2.0.0': + '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 + '@ai-sdk/togetherai@2.0.33(zod@4.3.6)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.30(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/xai@3.0.57(zod@4.3.6)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.30(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) + zod: 4.3.6 + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -10513,7 +10701,7 @@ snapshots: '@smithy/types': 4.5.0 tslib: 2.8.1 - '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.3)': dependencies: @@ -10949,6 +11137,8 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vercel/oidc@3.1.0': {} + '@vitejs/plugin-react@5.0.1(vite@7.1.3(@types/node@24.3.0)(sass@1.77.4)(terser@5.46.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.28.3 @@ -11115,13 +11305,13 @@ snapshots: agent-base@7.1.4: {} - ai@5.0.52(zod@4.1.3): + ai@6.0.97(zod@4.3.6): dependencies: - '@ai-sdk/gateway': 1.0.29(zod@4.1.3) - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.9(zod@4.1.3) + '@ai-sdk/gateway': 3.0.53(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) '@opentelemetry/api': 1.9.0 - zod: 4.1.3 + zod: 4.3.6 airtable-ts-codegen@2.1.0: dependencies: @@ -12135,8 +12325,6 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.5: {} - eventsource-parser@3.0.6: {} expand-tilde@2.0.2: @@ -14531,6 +14719,6 @@ snapshots: yocto-queue@0.1.0: {} - zod@4.1.3: {} + zod@4.3.6: {} zwitch@2.0.4: {} diff --git a/src/globals/Settings.ts b/src/globals/Settings.ts index cf74277b..d9e49db1 100644 --- a/src/globals/Settings.ts +++ b/src/globals/Settings.ts @@ -1,4 +1,53 @@ import { GlobalConfig } from "payload"; +import { + AI_PROVIDER_OPTIONS, + DEFAULT_MODEL_PRESET, + MODEL_PRESET_OPTIONS, + isProviderModelId, +} from "@/lib/ai/providerCatalog"; + +const validateProviderModelId = (value: string | null | undefined) => { + if (!value) { + return true; + } + + if (!isProviderModelId(value)) { + return 'Use format "provider:model", for example "openai:gpt-5".'; + } + + return true; +}; + +const validateUniqueProviderCredentials = (value: unknown) => { + if (!Array.isArray(value)) { + return true; + } + + const seenProviders = new Set(); + + for (const entry of value) { + if (typeof entry !== "object" || entry === null) { + continue; + } + + const provider = + "provider" in entry && typeof entry.provider === "string" + ? entry.provider.trim().toLowerCase() + : ""; + + if (!provider) { + continue; + } + + if (seenProviders.has(provider)) { + return `Provider "${provider}" appears more than once.`; + } + + seenProviders.add(provider); + } + + return true; +}; export const Settings: GlobalConfig = { slug: "settings", @@ -83,35 +132,114 @@ export const Settings: GlobalConfig = { type: "row", fields: [ { - //TODO: (@kelvinkipruto):(@kelvinkipruto): Explore adding more models. - name: "model", + name: "modelPreset", type: "select", label: { - en: "Model", - fr: "Modèle", + en: "Model Preset", + fr: "Modèle prédéfini", + }, + options: MODEL_PRESET_OPTIONS, + defaultValue: DEFAULT_MODEL_PRESET, + required: true, + }, + { + name: "customModelId", + label: { + en: "Custom Model ID", + fr: "ID de modèle personnalisé", + }, + type: "text", + validate: validateProviderModelId, + admin: { + description: + 'Optional override in "provider:model" format. Example: "openai:gpt-5".', }, - options: [ + }, + ], + }, + { + name: "providerCredentials", + type: "array", + label: { + en: "Provider Credentials", + fr: "Identifiants des fournisseurs", + }, + validate: validateUniqueProviderCredentials, + fields: [ + { + type: "row", + fields: [ + { + name: "provider", + type: "select", + required: true, + label: { + en: "Provider", + fr: "Fournisseur", + }, + options: AI_PROVIDER_OPTIONS, + }, { - value: "gemini-2.5-pro", - label: "Gemini 2.5 Pro", + name: "apiKey", + type: "text", + label: { + en: "API Key", + fr: "Clé API", + }, + admin: { + description: + "Optional if configured in environment variables.", + }, }, { - value: "gemini-2.5-flash-lite", - label: "Gemini 2.5 Flash Lite", + name: "baseURL", + type: "text", + label: { + en: "Base URL", + fr: "URL de base", + }, + admin: { + condition: (_, siblingData) => + siblingData?.provider === "ollama", + description: + "Optional Ollama base URL (e.g. http://localhost:11434).", + }, }, ], - required: true, + }, + ], + }, + { + name: "model", + type: "select", + label: { + en: "Legacy Model (Deprecated)", + fr: "Modèle hérité (déprécié)", + }, + options: [ + { + value: "gemini-2.5-pro", + label: "Gemini 2.5 Pro", }, { - name: "apiKey", - label: { - en: "API Key", - fr: "Clé API", - }, - type: "text", - required: true, + value: "gemini-2.5-flash-lite", + label: "Gemini 2.5 Flash Lite", }, ], + admin: { + condition: () => false, + }, + }, + { + name: "apiKey", + label: { + en: "Legacy API Key (Deprecated)", + fr: "Clé API héritée (dépréciée)", + }, + type: "text", + admin: { + condition: () => false, + }, }, ], }, diff --git a/src/lib/ai/providerCatalog.ts b/src/lib/ai/providerCatalog.ts new file mode 100644 index 00000000..bf9cf78a --- /dev/null +++ b/src/lib/ai/providerCatalog.ts @@ -0,0 +1,251 @@ +export const AI_PROVIDER_IDS = [ + "google", + "xai", + "openai", + "anthropic", + "mistral", + "deepseek", + "cerebras", + "groq", + "togetherai", + "cohere", + "fireworks", + "deepinfra", + "perplexity", + "ollama", +] as const; + +export type AIProviderId = (typeof AI_PROVIDER_IDS)[number]; +export type ProviderModelId = `${AIProviderId}:${string}`; + +export type CuratedModel = { + id: string; + label: string; +}; + +export type ProviderCatalogItem = { + id: AIProviderId; + label: string; + envApiKey?: string; + envBaseURL?: string; + requiresApiKey: boolean; + curatedModels: CuratedModel[]; +}; + +export const MODEL_ID_PATTERN = /^[a-z0-9-]+:\S+$/i; + +export const AI_PROVIDER_CATALOG: ProviderCatalogItem[] = [ + { + id: "google", + label: "Google", + envApiKey: "GOOGLE_GENERATIVE_AI_API_KEY", + requiresApiKey: true, + curatedModels: [ + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, + { id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" }, + ], + }, + { + id: "xai", + label: "Grok (xAI)", + envApiKey: "XAI_API_KEY", + requiresApiKey: true, + curatedModels: [ + { id: "grok-4", label: "Grok 4" }, + { id: "grok-3-mini", label: "Grok 3 Mini" }, + ], + }, + { + id: "openai", + label: "OpenAI", + envApiKey: "OPENAI_API_KEY", + requiresApiKey: true, + curatedModels: [ + { id: "gpt-5", label: "GPT-5" }, + { id: "gpt-5-mini", label: "GPT-5 Mini" }, + { id: "gpt-5-nano", label: "GPT-5 Nano" }, + ], + }, + { + id: "anthropic", + label: "Anthropic", + envApiKey: "ANTHROPIC_API_KEY", + requiresApiKey: true, + curatedModels: [ + { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + { id: "claude-opus-4-1", label: "Claude Opus 4.1" }, + { id: "claude-3-5-haiku-latest", label: "Claude 3.5 Haiku Latest" }, + ], + }, + { + id: "mistral", + label: "Mistral", + envApiKey: "MISTRAL_API_KEY", + requiresApiKey: true, + curatedModels: [ + { id: "mistral-large-latest", label: "Mistral Large Latest" }, + { id: "mistral-medium-latest", label: "Mistral Medium Latest" }, + { id: "mistral-small-latest", label: "Mistral Small Latest" }, + ], + }, + { + id: "deepseek", + label: "DeepSeek", + envApiKey: "DEEPSEEK_API_KEY", + requiresApiKey: true, + curatedModels: [ + { id: "deepseek-chat", label: "DeepSeek Chat" }, + { id: "deepseek-reasoner", label: "DeepSeek Reasoner" }, + ], + }, + { + id: "cerebras", + label: "Cerebras", + envApiKey: "CEREBRAS_API_KEY", + requiresApiKey: true, + curatedModels: [ + { id: "llama3.3-70b", label: "Llama 3.3 70B" }, + { id: "llama3.1-8b", label: "Llama 3.1 8B" }, + ], + }, + { + id: "groq", + label: "Groq", + envApiKey: "GROQ_API_KEY", + requiresApiKey: true, + curatedModels: [ + { id: "llama-3.3-70b-versatile", label: "Llama 3.3 70B Versatile" }, + { id: "llama-3.1-8b-instant", label: "Llama 3.1 8B Instant" }, + ], + }, + { + id: "togetherai", + label: "Together AI", + envApiKey: "TOGETHER_API_KEY", + requiresApiKey: true, + curatedModels: [ + { + id: "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", + label: "Meta Llama 3.1 70B Instruct Turbo", + }, + { + id: "Qwen/Qwen2.5-Coder-32B-Instruct", + label: "Qwen 2.5 Coder 32B Instruct", + }, + ], + }, + { + id: "cohere", + label: "Cohere", + envApiKey: "COHERE_API_KEY", + requiresApiKey: true, + curatedModels: [ + { id: "command-r-plus", label: "Command R Plus" }, + { id: "command-r", label: "Command R" }, + ], + }, + { + id: "fireworks", + label: "Fireworks", + envApiKey: "FIREWORKS_API_KEY", + requiresApiKey: true, + curatedModels: [ + { + id: "accounts/fireworks/models/llama-v3-8b-instruct", + label: "Llama V3 8B Instruct", + }, + { + id: "accounts/fireworks/models/llama-v3p1-70b-instruct", + label: "Llama 3.1 70B Instruct", + }, + ], + }, + { + id: "deepinfra", + label: "DeepInfra", + envApiKey: "DEEPINFRA_API_KEY", + requiresApiKey: true, + curatedModels: [ + { + id: "meta-llama/Meta-Llama-3.1-70B-Instruct", + label: "Meta Llama 3.1 70B Instruct", + }, + { id: "Qwen/Qwen2.5-72B-Instruct", label: "Qwen 2.5 72B Instruct" }, + ], + }, + { + id: "perplexity", + label: "Perplexity", + envApiKey: "PERPLEXITY_API_KEY", + requiresApiKey: true, + curatedModels: [ + { id: "sonar-pro", label: "Sonar Pro" }, + { id: "sonar", label: "Sonar" }, + ], + }, + { + id: "ollama", + label: "Ollama", + envApiKey: "OLLAMA_API_KEY", + envBaseURL: "OLLAMA_BASE_URL", + requiresApiKey: false, + curatedModels: [ + { id: "llama3.1:8b", label: "Llama 3.1 8B" }, + { id: "llama3.1:70b", label: "Llama 3.1 70B" }, + { id: "mistral:7b", label: "Mistral 7B" }, + ], + }, +]; + +export const DEFAULT_MODEL_PRESET: ProviderModelId = "google:gemini-2.5-pro"; + +export const AI_PROVIDER_OPTIONS = AI_PROVIDER_CATALOG.map((provider) => ({ + value: provider.id, + label: provider.label, +})); + +export const MODEL_PRESET_OPTIONS = AI_PROVIDER_CATALOG.flatMap((provider) => + provider.curatedModels.map((model) => ({ + value: `${provider.id}:${model.id}`, + label: `[${provider.label}] ${model.label}`, + })), +); + +export const getProviderCatalogItem = (providerId: string) => + AI_PROVIDER_CATALOG.find((provider) => provider.id === providerId); + +export const isProviderModelId = (value: string): value is ProviderModelId => { + if (!MODEL_ID_PATTERN.test(value)) { + return false; + } + + const separatorIndex = value.indexOf(":"); + if (separatorIndex < 1) { + return false; + } + + const providerId = value.slice(0, separatorIndex).toLowerCase(); + const modelId = value.slice(separatorIndex + 1).trim(); + return Boolean(modelId) && Boolean(getProviderCatalogItem(providerId)); +}; + +export const splitProviderModelId = ( + value: ProviderModelId | string, +): { providerId: AIProviderId; modelId: string } => { + const separatorIndex = value.indexOf(":"); + const providerId = value.slice(0, separatorIndex).toLowerCase(); + const modelId = value.slice(separatorIndex + 1).trim(); + const provider = getProviderCatalogItem(providerId); + + if (!provider || !modelId) { + throw new Error( + `Invalid model id "${value}". Expected format "provider:model".`, + ); + } + + return { + providerId: provider.id, + modelId, + }; +}; diff --git a/src/lib/ai/providerRegistry.ts b/src/lib/ai/providerRegistry.ts new file mode 100644 index 00000000..6b6c0c95 --- /dev/null +++ b/src/lib/ai/providerRegistry.ts @@ -0,0 +1,319 @@ +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createCerebras } from "@ai-sdk/cerebras"; +import { createCohere } from "@ai-sdk/cohere"; +import { createDeepInfra } from "@ai-sdk/deepinfra"; +import { createDeepSeek } from "@ai-sdk/deepseek"; +import { createFireworks } from "@ai-sdk/fireworks"; +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { createGroq } from "@ai-sdk/groq"; +import { createMistral } from "@ai-sdk/mistral"; +import { createOpenAI } from "@ai-sdk/openai"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { createPerplexity } from "@ai-sdk/perplexity"; +import { createTogetherAI } from "@ai-sdk/togetherai"; +import { createXai } from "@ai-sdk/xai"; +import { createProviderRegistry, type LanguageModel } from "ai"; +import { + AI_PROVIDER_CATALOG, + DEFAULT_MODEL_PRESET, + getProviderCatalogItem, + isProviderModelId, + splitProviderModelId, + type AIProviderId, + type ProviderModelId, +} from "./providerCatalog"; + +export type AIProviderCredentialInput = { + provider?: string | null; + apiKey?: string | null; + baseURL?: string | null; +}; + +export type AISettingsInput = { + modelPreset?: string | null; + customModelId?: string | null; + model?: string | null; // Legacy Google model field. + apiKey?: string | null; // Legacy Google API key field. + providerCredentials?: AIProviderCredentialInput[] | null; +}; + +export type ResolvedProviderCredentials = { + apiKey?: string; + baseURL?: string; +}; + +export type ResolvedLanguageModel = { + model: LanguageModel; + modelId: ProviderModelId; + providerId: AIProviderId; +}; + +const trimToUndefined = (value: unknown): string | undefined => { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const normalizeLegacyModelId = (legacyModel: string): ProviderModelId => { + if (isProviderModelId(legacyModel)) { + return legacyModel; + } + + return `google:${legacyModel}` as ProviderModelId; +}; + +export const resolveConfiguredModelId = ( + ai: AISettingsInput, +): ProviderModelId => { + const customModelId = trimToUndefined(ai.customModelId); + if (customModelId) { + if (!isProviderModelId(customModelId)) { + throw new Error( + `Invalid custom model "${customModelId}". Expected "provider:model".`, + ); + } + + return customModelId; + } + + const modelPreset = trimToUndefined(ai.modelPreset); + if (modelPreset) { + if (!isProviderModelId(modelPreset)) { + throw new Error( + `Invalid model preset "${modelPreset}". Expected "provider:model".`, + ); + } + + return modelPreset; + } + + const legacyModel = trimToUndefined(ai.model); + if (legacyModel) { + return normalizeLegacyModelId(legacyModel); + } + + return DEFAULT_MODEL_PRESET; +}; + +const credentialsByProvider = (ai: AISettingsInput) => { + const credentials = ai.providerCredentials ?? []; + const map = new Map(); + + for (const credential of credentials) { + const provider = trimToUndefined(credential?.provider)?.toLowerCase(); + if (!provider) { + continue; + } + + const providerCatalogItem = getProviderCatalogItem(provider); + if (!providerCatalogItem) { + continue; + } + + map.set(providerCatalogItem.id, { + apiKey: trimToUndefined(credential?.apiKey), + baseURL: trimToUndefined(credential?.baseURL), + }); + } + + return map; +}; + +export const resolveProviderCredentials = ( + ai: AISettingsInput, + providerId: AIProviderId, + options?: { + strict?: boolean; + }, +): ResolvedProviderCredentials => { + const provider = getProviderCatalogItem(providerId); + + if (!provider) { + throw new Error(`Unsupported AI provider "${providerId}".`); + } + + const providerCredentials = credentialsByProvider(ai).get(provider.id); + const envApiKey = provider.envApiKey + ? trimToUndefined(process.env[provider.envApiKey]) + : undefined; + const envBaseURL = provider.envBaseURL + ? trimToUndefined(process.env[provider.envBaseURL]) + : undefined; + + const apiKey = + providerCredentials?.apiKey ?? + envApiKey ?? + (provider.id === "google" ? trimToUndefined(ai.apiKey) : undefined); + const baseURL = providerCredentials?.baseURL ?? envBaseURL; + + if (options?.strict && provider.requiresApiKey && !apiKey) { + const keyName = provider.envApiKey ?? "an API key"; + throw new Error( + `Missing API key for provider "${provider.label}". Configure it in Settings or set ${keyName}.`, + ); + } + + return { + apiKey, + baseURL, + }; +}; + +const normalizeOllamaBaseURL = (baseURL?: string) => { + const defaultBaseURL = "http://127.0.0.1:11434/v1"; + if (!baseURL) { + return defaultBaseURL; + } + + const sanitized = baseURL.replace(/\/+$/, ""); + return sanitized.endsWith("/v1") ? sanitized : `${sanitized}/v1`; +}; + +export const buildLanguageModelRegistry = (ai: AISettingsInput) => { + const getCredentials = (providerId: AIProviderId) => { + const credentials = resolveProviderCredentials(ai, providerId); + return { + apiKey: trimToUndefined(credentials.apiKey), + baseURL: trimToUndefined(credentials.baseURL), + }; + }; + + const googleCredentials = getCredentials("google"); + const xaiCredentials = getCredentials("xai"); + const openaiCredentials = getCredentials("openai"); + const anthropicCredentials = getCredentials("anthropic"); + const mistralCredentials = getCredentials("mistral"); + const deepseekCredentials = getCredentials("deepseek"); + const cerebrasCredentials = getCredentials("cerebras"); + const groqCredentials = getCredentials("groq"); + const togetherCredentials = getCredentials("togetherai"); + const cohereCredentials = getCredentials("cohere"); + const fireworksCredentials = getCredentials("fireworks"); + const deepinfraCredentials = getCredentials("deepinfra"); + const perplexityCredentials = getCredentials("perplexity"); + const ollamaCredentials = getCredentials("ollama"); + + const providers = { + google: createGoogleGenerativeAI({ + ...(googleCredentials.apiKey ? { apiKey: googleCredentials.apiKey } : {}), + ...(googleCredentials.baseURL + ? { baseURL: googleCredentials.baseURL } + : {}), + }), + xai: createXai({ + ...(xaiCredentials.apiKey ? { apiKey: xaiCredentials.apiKey } : {}), + ...(xaiCredentials.baseURL ? { baseURL: xaiCredentials.baseURL } : {}), + }), + openai: createOpenAI({ + ...(openaiCredentials.apiKey ? { apiKey: openaiCredentials.apiKey } : {}), + ...(openaiCredentials.baseURL + ? { baseURL: openaiCredentials.baseURL } + : {}), + }), + anthropic: createAnthropic({ + ...(anthropicCredentials.apiKey + ? { apiKey: anthropicCredentials.apiKey } + : {}), + ...(anthropicCredentials.baseURL + ? { baseURL: anthropicCredentials.baseURL } + : {}), + }), + mistral: createMistral({ + ...(mistralCredentials.apiKey + ? { apiKey: mistralCredentials.apiKey } + : {}), + ...(mistralCredentials.baseURL + ? { baseURL: mistralCredentials.baseURL } + : {}), + }), + deepseek: createDeepSeek({ + ...(deepseekCredentials.apiKey + ? { apiKey: deepseekCredentials.apiKey } + : {}), + ...(deepseekCredentials.baseURL + ? { baseURL: deepseekCredentials.baseURL } + : {}), + }), + cerebras: createCerebras({ + ...(cerebrasCredentials.apiKey + ? { apiKey: cerebrasCredentials.apiKey } + : {}), + ...(cerebrasCredentials.baseURL + ? { baseURL: cerebrasCredentials.baseURL } + : {}), + }), + groq: createGroq({ + ...(groqCredentials.apiKey ? { apiKey: groqCredentials.apiKey } : {}), + ...(groqCredentials.baseURL ? { baseURL: groqCredentials.baseURL } : {}), + }), + togetherai: createTogetherAI({ + ...(togetherCredentials.apiKey + ? { apiKey: togetherCredentials.apiKey } + : {}), + ...(togetherCredentials.baseURL + ? { baseURL: togetherCredentials.baseURL } + : {}), + }), + cohere: createCohere({ + ...(cohereCredentials.apiKey ? { apiKey: cohereCredentials.apiKey } : {}), + ...(cohereCredentials.baseURL + ? { baseURL: cohereCredentials.baseURL } + : {}), + }), + fireworks: createFireworks({ + ...(fireworksCredentials.apiKey + ? { apiKey: fireworksCredentials.apiKey } + : {}), + ...(fireworksCredentials.baseURL + ? { baseURL: fireworksCredentials.baseURL } + : {}), + }), + deepinfra: createDeepInfra({ + ...(deepinfraCredentials.apiKey + ? { apiKey: deepinfraCredentials.apiKey } + : {}), + ...(deepinfraCredentials.baseURL + ? { baseURL: deepinfraCredentials.baseURL } + : {}), + }), + perplexity: createPerplexity({ + ...(perplexityCredentials.apiKey + ? { apiKey: perplexityCredentials.apiKey } + : {}), + ...(perplexityCredentials.baseURL + ? { baseURL: perplexityCredentials.baseURL } + : {}), + }), + ollama: createOpenAICompatible({ + name: "ollama", + baseURL: normalizeOllamaBaseURL(ollamaCredentials.baseURL), + ...(ollamaCredentials.apiKey ? { apiKey: ollamaCredentials.apiKey } : {}), + }), + }; + + return createProviderRegistry(providers as never); +}; + +export const resolveConfiguredLanguageModel = ( + ai: AISettingsInput, +): ResolvedLanguageModel => { + const modelId = resolveConfiguredModelId(ai); + const { providerId } = splitProviderModelId(modelId); + + if (!AI_PROVIDER_CATALOG.some((provider) => provider.id === providerId)) { + throw new Error(`Unsupported provider "${providerId}".`); + } + + resolveProviderCredentials(ai, providerId, { strict: true }); + + const registry = buildLanguageModelRegistry(ai); + + return { + model: registry.languageModel(modelId), + modelId, + providerId, + }; +}; diff --git a/src/payload-types.ts b/src/payload-types.ts index 2ff03574..86e67707 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1897,8 +1897,74 @@ export interface Setting { airtableBaseID: string; }; ai: { - model: 'gemini-2.5-pro' | 'gemini-2.5-flash-lite'; - apiKey: string; + modelPreset: + | 'google:gemini-2.5-pro' + | 'google:gemini-2.5-flash' + | 'google:gemini-2.5-flash-lite' + | 'xai:grok-4' + | 'xai:grok-3-mini' + | 'openai:gpt-5' + | 'openai:gpt-5-mini' + | 'openai:gpt-5-nano' + | 'anthropic:claude-sonnet-4-6' + | 'anthropic:claude-opus-4-1' + | 'anthropic:claude-3-5-haiku-latest' + | 'mistral:mistral-large-latest' + | 'mistral:mistral-medium-latest' + | 'mistral:mistral-small-latest' + | 'deepseek:deepseek-chat' + | 'deepseek:deepseek-reasoner' + | 'cerebras:llama3.3-70b' + | 'cerebras:llama3.1-8b' + | 'groq:llama-3.3-70b-versatile' + | 'groq:llama-3.1-8b-instant' + | 'togetherai:meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo' + | 'togetherai:Qwen/Qwen2.5-Coder-32B-Instruct' + | 'cohere:command-r-plus' + | 'cohere:command-r' + | 'fireworks:accounts/fireworks/models/llama-v3-8b-instruct' + | 'fireworks:accounts/fireworks/models/llama-v3p1-70b-instruct' + | 'deepinfra:meta-llama/Meta-Llama-3.1-70B-Instruct' + | 'deepinfra:Qwen/Qwen2.5-72B-Instruct' + | 'perplexity:sonar-pro' + | 'perplexity:sonar' + | 'ollama:llama3.1:8b' + | 'ollama:llama3.1:70b' + | 'ollama:mistral:7b'; + /** + * Optional override in "provider:model" format. Example: "openai:gpt-5". + */ + customModelId?: string | null; + providerCredentials?: + | { + provider: + | 'google' + | 'xai' + | 'openai' + | 'anthropic' + | 'mistral' + | 'deepseek' + | 'cerebras' + | 'groq' + | 'togetherai' + | 'cohere' + | 'fireworks' + | 'deepinfra' + | 'perplexity' + | 'ollama'; + /** + * Optional if configured in environment variables. + */ + apiKey?: string | null; + /** + * Optional Ollama base URL (e.g. http://localhost:11434). + */ + baseURL?: string | null; + id?: string | null; + }[] + | null; + model?: ('gemini-2.5-pro' | 'gemini-2.5-flash-lite') | null; + apiKey?: string | null; }; meedan: { meedanAPIKey: string; @@ -2213,6 +2279,16 @@ export interface SettingsSelect { ai?: | T | { + modelPreset?: T; + customModelId?: T; + providerCredentials?: + | T + | { + provider?: T; + apiKey?: T; + baseURL?: T; + id?: T; + }; model?: T; apiKey?: T; }; diff --git a/src/tasks/extractPromises.ts b/src/tasks/extractPromises.ts index 82a42d44..3e633bf3 100644 --- a/src/tasks/extractPromises.ts +++ b/src/tasks/extractPromises.ts @@ -1,12 +1,371 @@ -import { TaskConfig } from "payload"; -import { createGoogleGenerativeAI } from "@ai-sdk/google"; -import { generateObject } from "ai"; -import { z } from "zod"; import { randomUUID } from "node:crypto"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; +import { APICallError, NoObjectGeneratedError, Output, generateText, hasToolCall, stepCountIs, tool } from "ai"; +import { TaskConfig } from "payload"; +import { z } from "zod"; import { updateDocumentStatus } from "@/lib/airtable"; +import { + resolveConfiguredLanguageModel, + type AISettingsInput, +} from "@/lib/ai/providerRegistry"; import { getTaskLogger, withTaskTracing, type TaskInput } from "./utils"; +type TaskLogger = ReturnType; + +const MAX_CHUNK_CHARS = 14_000; +const CHUNK_OVERLAP_CHARS = 450; +const MAX_NORMALIZATION_CANDIDATES = 220; +const MAX_TOOL_STEPS = 40; + +const passOnePromiseSchema = z.object({ + category: z.string().min(1).describe("Thematic category for the promise"), + summary: z + .string() + .min(1) + .describe("Single, atomic campaign promise in one sentence"), + sourceQuotes: z + .array( + z + .string() + .min(1) + .describe("Exact quote copied from the chunk that supports the promise"), + ) + .min(1), +}); + +type PromiseCandidate = z.infer; + +const normalizeWhitespace = (value: string): string => + value.replace(/\s+/g, " ").trim(); + +const normalizeForLookup = (value: string): string => + normalizeWhitespace(value).toLowerCase(); + +const normalizeSummaryKey = (value: string): string => + normalizeForLookup(value) + .replace(/[^\p{L}\p{N}\s]/gu, " ") + .replace(/\s+/g, " ") + .trim(); + +const splitTextIntoChunks = (text: string): string[] => { + const source = text.trim(); + if (!source) { + return []; + } + + if (source.length <= MAX_CHUNK_CHARS) { + return [source]; + } + + const chunks: string[] = []; + let start = 0; + + while (start < source.length) { + let end = Math.min(start + MAX_CHUNK_CHARS, source.length); + + if (end < source.length) { + const preferredBreaks = [ + source.lastIndexOf("\n\n", end), + source.lastIndexOf(". ", end), + source.lastIndexOf("? ", end), + source.lastIndexOf("! ", end), + source.lastIndexOf("; ", end), + ]; + const bestBreak = Math.max(...preferredBreaks); + + if (bestBreak > start + Math.floor(MAX_CHUNK_CHARS * 0.55)) { + end = bestBreak + 1; + } + } + + const chunk = source.slice(start, end).trim(); + if (chunk) { + chunks.push(chunk); + } + + if (end >= source.length) { + break; + } + + start = Math.max(0, end - CHUNK_OVERLAP_CHARS); + } + + return chunks; +}; + +const dedupeQuotes = (quotes: string[]): string[] => { + const seen = new Set(); + const uniqueQuotes: string[] = []; + + for (const quote of quotes) { + const cleaned = normalizeWhitespace(quote); + if (!cleaned) { + continue; + } + + const key = normalizeForLookup(cleaned); + if (seen.has(key)) { + continue; + } + + seen.add(key); + uniqueQuotes.push(cleaned); + } + + return uniqueQuotes; +}; + +const hasVerifiableQuote = (quote: string, normalizedPlainText: string) => { + const normalizedQuote = normalizeForLookup(quote); + if (normalizedQuote.length < 8) { + return false; + } + + return normalizedPlainText.includes(normalizedQuote); +}; + +const mergeByAtomicPromise = ( + promises: PromiseCandidate[], + plainText: string, +): PromiseCandidate[] => { + const normalizedPlainText = normalizeForLookup(plainText); + const merged = new Map(); + + for (const promise of promises) { + const category = normalizeWhitespace(promise.category); + const summary = normalizeWhitespace(promise.summary); + if (!category || !summary) { + continue; + } + + const verifiableQuotes = dedupeQuotes(promise.sourceQuotes).filter((quote) => + hasVerifiableQuote(quote, normalizedPlainText), + ); + + if (verifiableQuotes.length === 0) { + continue; + } + + const key = `${normalizeSummaryKey(summary)}::${normalizeForLookup(category)}`; + const existing = merged.get(key); + + if (!existing) { + merged.set(key, { + category, + summary, + sourceQuotes: verifiableQuotes, + }); + continue; + } + + existing.sourceQuotes = dedupeQuotes([ + ...existing.sourceQuotes, + ...verifiableQuotes, + ]); + } + + return [...merged.values()]; +}; + +const formatSourceForStorage = (quotes: string[]) => + quotes.map((quote, index) => `${index + 1}: ${quote}\n`).join("\n"); + +const shouldForceReextractRun = ( + forceReextract: boolean, + documentIds: string[], +) => forceReextract && documentIds.length > 0; + +const buildPassOneSystemPrompt = () => ` +You extract campaign promises from one document chunk. + +Rules: +- Return only specific future commitments that can be verified later. +- Never merge multiple commitments into one summary. +- If a sentence contains two commitments, split into two promises. +- Do not group promises by theme. +- sourceQuotes must be exact quotes copied from the chunk text. +- Keep the output in the same language as the chunk. +`; + +const buildPassOnePrompt = (chunk: string, chunkIndex: number, chunkTotal: number) => ` +Document chunk ${chunkIndex + 1} of ${chunkTotal}. + +Extract all campaign promises in this chunk. +Each object must represent exactly one distinct promise. + +Chunk text: +${chunk} +`; + +const buildNormalizationSystemPrompt = () => ` +You normalize extracted campaign promises into atomic promises. + +You must use tools only: +1) Call recordPromise once per final promise. +2) Call finalizeExtraction exactly once when done. + +Rules: +- One tool call = one atomic promise. +- Never combine distinct commitments under one summary. +- Keep repeated mentions of the same exact pledge as one promise with multiple quotes. +- Do not include non-promises, opinions, or vague values. +- sourceQuotes must remain exact snippets from the document text. +`; + +const buildNormalizationPrompt = ({ + documentTitle, + candidates, +}: { + documentTitle: string; + candidates: PromiseCandidate[]; +}) => ` +Normalize the candidate promises below. +Return only atomic promises. +If there are no valid promises, call finalizeExtraction with the best title and do not call recordPromise. + +Candidate promises: +${JSON.stringify(candidates, null, 2)} + +Document title fallback: ${documentTitle} +`; + +const extractPromisesFromChunks = async ({ + model, + chunks, + logger, + documentId, + documentTitle, + documentAirtableID, +}: { + model: ReturnType["model"]; + chunks: string[]; + logger: TaskLogger; + documentId: string; + documentTitle: string; + documentAirtableID: string | null | undefined; +}) => { + const candidates: PromiseCandidate[] = []; + const output = Output.array({ + element: passOnePromiseSchema, + name: "campaign_promises", + description: + "Atomic campaign promises with category, summary, and direct supporting quotes.", + }); + + for (const [index, chunk] of chunks.entries()) { + try { + const { output: chunkPromises } = await generateText({ + model, + system: buildPassOneSystemPrompt(), + prompt: buildPassOnePrompt(chunk, index, chunks.length), + output, + maxRetries: 4, + }); + + candidates.push(...chunkPromises); + + logger.info({ + message: "extractPromises:: Chunk extracted", + documentId, + documentTitle, + documentAirtableID, + chunkIndex: index, + chunkCount: chunks.length, + promises: chunkPromises.length, + }); + } catch (error) { + logger.warn({ + message: "extractPromises:: Failed extracting one chunk", + documentId, + documentTitle, + documentAirtableID, + chunkIndex: index, + chunkCount: chunks.length, + error: error instanceof Error ? error.message : String(error), + }); + + if ( + APICallError.isInstance(error) || + NoObjectGeneratedError.isInstance(error) + ) { + continue; + } + + throw error; + } + } + + return candidates; +}; + +const normalizeCandidatesWithTools = async ({ + model, + documentTitle, + candidates, +}: { + model: ReturnType["model"]; + documentTitle: string; + candidates: PromiseCandidate[]; +}) => { + const normalizedPromises: PromiseCandidate[] = []; + let normalizedTitle = documentTitle; + + const recordPromise = tool({ + description: + "Store exactly one atomic campaign promise with supporting direct quotes.", + inputSchema: passOnePromiseSchema, + execute: async (input) => { + normalizedPromises.push({ + category: normalizeWhitespace(input.category), + summary: normalizeWhitespace(input.summary), + sourceQuotes: dedupeQuotes(input.sourceQuotes), + }); + + return { + recordedCount: normalizedPromises.length, + }; + }, + }); + + const finalizeExtraction = tool({ + description: "Finalize extraction and provide the best title for this document.", + inputSchema: z.object({ + title: z.string().min(1).describe("Best inferred title for this document"), + }), + execute: async ({ title }) => { + normalizedTitle = normalizeWhitespace(title) || documentTitle; + return { + done: true, + }; + }, + }); + + await generateText({ + model, + system: buildNormalizationSystemPrompt(), + prompt: buildNormalizationPrompt({ documentTitle, candidates }), + tools: { + recordPromise, + finalizeExtraction, + }, + toolChoice: "required", + stopWhen: [hasToolCall("finalizeExtraction"), stepCountIs(MAX_TOOL_STEPS)], + maxRetries: 3, + }); + + return { + title: normalizedTitle || documentTitle, + promises: normalizedPromises, + }; +}; + +export const __extractPromisesTestUtils = { + splitTextIntoChunks, + mergeByAtomicPromise, + formatSourceForStorage, + shouldForceReextractRun, +}; + export const ExtractPromises: TaskConfig<"extractPromises"> = { slug: "extractPromises", label: "Extract Promises", @@ -16,11 +375,21 @@ export const ExtractPromises: TaskConfig<"extractPromises"> = { logger.info("extractPromises:: Starting promise extraction"); try { + const settings = await payload.findGlobal({ + slug: "settings", + }); const { - ai: { model: defaultModel, apiKey }, airtable: { airtableAPIKey }, - } = await payload.findGlobal({ - slug: "settings", + } = settings; + + const aiSettings = (settings.ai ?? {}) as AISettingsInput; + const { model, modelId, providerId } = + resolveConfiguredLanguageModel(aiSettings); + + logger.info({ + message: "extractPromises:: Resolved AI model", + modelId, + providerId, }); const setDocumentStatus = async ( @@ -57,41 +426,21 @@ export const ExtractPromises: TaskConfig<"extractPromises"> = { await setDocumentStatus(airtableID, `Failed: ${reason}`); }; - const google = createGoogleGenerativeAI({ - apiKey: apiKey, - }); + const taskInput = input as TaskInput | undefined; + const documentIds = taskInput?.documentIds?.filter(Boolean) ?? []; + const forceReextract = Boolean(taskInput?.forceReextract); + const shouldForceReextract = shouldForceReextractRun( + forceReextract, + documentIds, + ); + + if (forceReextract && !documentIds.length) { + logger.warn({ + message: + "extractPromises:: forceReextract ignored because no explicit documentIds were provided", + }); + } - const model = google(defaultModel); - - const systemPrompt = ` - You are a helpful assistant that analyzes documents to extract campaign promises and provide structured summaries. - Your task is to identify and categorize campaign promises, providing direct quotations as sources. - `; - - const prompt = ` - Analyze the following document and extract campaign promises with the following structure: - - **Requirements:** - 1. **Title**: Infer an appropriate title from the document content. - 2. **Promises**: For each promise identified, provide: - - **Category**: The thematic category of the promise (e.g., "Economy," "Healthcare," "Infrastructure," "Education," "Social Policy," etc.) - - **Summary**: A concise summarization of the promise in your own words - - **Source**: Direct quotations from the text that support this promise. Include multiple quotes if the promise is mentioned in different parts of the document. - - **Guidelines:** - - Only include specific, actionable pledges about future actions - - Exclude political commentary, statements of fact, or general values - - Source field must contain exact quotes from the document text - - Each promise should have clear supporting evidence in the source quotes - - Group similar promises under appropriate categories - - Reply in the language the document is written in - - **Document:** - - `; - - const documentIds = - (input as TaskInput | undefined)?.documentIds?.filter(Boolean) ?? []; const documents = []; const limit = 50; let page = 1; @@ -111,7 +460,11 @@ export const ExtractPromises: TaskConfig<"extractPromises"> = { : undefined, }); - documents.push(...pageResult.docs.filter((doc) => !doc.fullyProcessed)); + documents.push( + ...pageResult.docs.filter((doc) => + shouldForceReextract ? true : !doc.fullyProcessed, + ), + ); hasNextPage = pageResult.hasNextPage; page += 1; } @@ -138,20 +491,18 @@ export const ExtractPromises: TaskConfig<"extractPromises"> = { equals: document.id, }, }, - limit: 1, + limit: 0, }); - const existingExtraction = existingExtractions[0]; - - if (existingExtraction) { + if (existingExtractions.length > 0 && !shouldForceReextract) { logger.info({ message: "extractPromises:: Skipping document with existing AI extraction", documentId: document.id, documentTitle: document.title, documentAirtableID: document.airtableID, - extractionId: existingExtraction.id, - extractionTitle: existingExtraction.title, + extractionId: existingExtractions[0]?.id, + extractionTitle: existingExtractions[0]?.title, }); if (!document.fullyProcessed) { @@ -168,6 +519,24 @@ export const ExtractPromises: TaskConfig<"extractPromises"> = { continue; } + if (existingExtractions.length > 0 && shouldForceReextract) { + for (const extraction of existingExtractions) { + await payload.delete({ + collection: "ai-extractions", + id: extraction.id, + }); + } + + logger.info({ + message: + "extractPromises:: Deleted existing extractions before force re-extract", + documentId: document.id, + documentTitle: document.title, + documentAirtableID: document.airtableID, + deleted: existingExtractions.length, + }); + } + const plainTextSegments = extractedText?.reduce((acc, textEntry) => { if (!textEntry?.text) { @@ -178,9 +547,9 @@ export const ExtractPromises: TaskConfig<"extractPromises"> = { return acc; }, []) ?? []; - const plainText = plainTextSegments.join("\n"); + const plainText = plainTextSegments.join("\n").trim(); - if (!plainText || plainText?.length === 0) { + if (!plainText) { await setDocumentFailedStatus( document.airtableID, "No extracted text available for AI analysis", @@ -195,57 +564,69 @@ export const ExtractPromises: TaskConfig<"extractPromises"> = { continue; } - const res = await generateObject({ + const chunks = splitTextIntoChunks(plainText); + const extractedCandidates = await extractPromisesFromChunks({ + model, + chunks, + logger, + documentId: document.id, + documentTitle: document.title, + documentAirtableID: document.airtableID, + }); + + const mergedCandidates = mergeByAtomicPromise( + extractedCandidates, + plainText, + ); + + const limitedCandidates = + mergedCandidates.length > MAX_NORMALIZATION_CANDIDATES + ? mergedCandidates.slice(0, MAX_NORMALIZATION_CANDIDATES) + : mergedCandidates; + + if (mergedCandidates.length > MAX_NORMALIZATION_CANDIDATES) { + logger.warn({ + message: + "extractPromises:: Candidate list truncated before normalization", + documentId: document.id, + documentTitle: document.title, + candidateCount: mergedCandidates.length, + limit: MAX_NORMALIZATION_CANDIDATES, + }); + } + + const normalized = await normalizeCandidatesWithTools({ model, - system: systemPrompt, - prompt: `${prompt} ${plainText}`, - schema: z.object({ - title: z - .string() - .describe( - "Inferred title from the document content, or the provided document title as fallback", - ), - promises: z.array( - z.object({ - category: z - .string() - .describe("The thematic category of the promise"), - summary: z - .string() - .describe("A concise summary of the promise"), - source: z - .array(z.string()) - .describe( - "Array of direct quotations from the text that support this promise", - ), - }), - ), - }), - maxRetries: 5, + documentTitle: document.title, + candidates: limitedCandidates, }); - const { object } = res; + const finalPromises = mergeByAtomicPromise( + normalized.promises, + plainText, + ); logger.info({ - message: "extractPromises:: AI response received", + message: "extractPromises:: AI normalization finished", documentId: document.id, documentTitle: document.title, documentAirtableID: document.airtableID, - promises: object.promises.length, + chunks: chunks.length, + candidates: extractedCandidates.length, + normalizedCandidates: mergedCandidates.length, + finalPromises: finalPromises.length, }); - if (object.promises.length > 0) { + if (finalPromises.length > 0) { await payload.create({ collection: "ai-extractions", data: { - title: object.title, - document: document, - extractions: object.promises.map((promise) => ({ + title: normalized.title || document.title, + document: document.id, + extractions: finalPromises.map((promise) => ({ category: promise.category, summary: promise.summary, - source: promise.source - .map((quote, index) => `${index + 1}: ${quote}\n`) - .join("\n"), + source: formatSourceForStorage(promise.sourceQuotes), uniqueId: randomUUID(), })), }, @@ -261,6 +642,13 @@ export const ExtractPromises: TaskConfig<"extractPromises"> = { await setDocumentStatus(document.airtableID, "Analysed by AI"); } else { + await payload.update({ + collection: "documents", + id: document.id, + data: { + fullyProcessed: true, + }, + }); await setDocumentStatus( document.airtableID, "Analysed by AI (No promises found)", @@ -269,8 +657,8 @@ export const ExtractPromises: TaskConfig<"extractPromises"> = { processedDocs.push({ id: document.id, - title: object.title, - promises: object.promises, + title: normalized.title || document.title, + promises: finalPromises, }); } catch (documentError) { failedDocs += 1; diff --git a/src/tasks/utils.ts b/src/tasks/utils.ts index 7f369183..945b41ff 100644 --- a/src/tasks/utils.ts +++ b/src/tasks/utils.ts @@ -9,6 +9,7 @@ export type RunContext = { export type TaskInput = { runContext?: RunContext; documentIds?: string[]; + forceReextract?: boolean; [key: string]: unknown; };