From 514052a694789c251028c7124e259ec53e49794f Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Fri, 15 May 2026 10:51:45 +0800 Subject: [PATCH 1/9] feat: install aisdk --- apps/backend/package.json | 3 ++- pnpm-lock.yaml | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 581bc06a2f..b717e0ab21 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -17,7 +17,7 @@ "lint-ci": "pnpm exec eslint src/ --quiet" }, "dependencies": { - "formsg-shared": "workspace:*", + "@ai-sdk/openai": "^3.0.63", "@aws-sdk/client-cloudwatch-logs": "^3.758.0", "@aws-sdk/client-lambda": "^3.693.0", "@aws-sdk/client-s3": "^3.775.0", @@ -64,6 +64,7 @@ "express-session": "^1.18.2", "express-winston": "^4.2.0", "file-saver": "^2.0.5", + "formsg-shared": "workspace:*", "fp-ts": "^2.16.9", "helmet": "^8.1.0", "hot-shots": "^10.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c17857705..5492531b35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ importers: apps/backend: dependencies: + '@ai-sdk/openai': + specifier: ^3.0.63 + version: 3.0.63(zod@3.25.34) '@aws-sdk/client-cloudwatch-logs': specifier: ^3.758.0 version: 3.758.0 @@ -1485,6 +1488,22 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/openai@3.0.63': + resolution: {integrity: sha512-4yY/m8a57MNNVoJCsXuNblKf6BO4yuAuLKRX4tzSNffBEBSp1FlcWdPE0Z4FkqUeS0AJhYSSqp0GIiA/cIcDNA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.27': + resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.10': + resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} + engines: {node: '>=18'} + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} @@ -4876,6 +4895,9 @@ packages: '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@storybook/addon-a11y@8.6.18': resolution: {integrity: sha512-LFvudttdIfDTNWprA8/N1vbiWbJRrNscyt2OP9Qwi85E1d3LKLy+e8AWiqY08gpy2OUYujK7AjxfpKtNeddrxw==} peerDependencies: @@ -8162,6 +8184,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + evp_bytestokey@1.0.3: resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} @@ -9898,6 +9924,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -14219,6 +14248,23 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@ai-sdk/openai@3.0.63(zod@3.25.34)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.27(zod@3.25.34) + zod: 3.25.34 + + '@ai-sdk/provider-utils@4.0.27(zod@3.25.34)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.8 + zod: 3.25.34 + + '@ai-sdk/provider@3.0.10': + dependencies: + json-schema: 0.4.0 + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 @@ -20035,6 +20081,8 @@ snapshots: '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + '@storybook/addon-a11y@8.6.18(storybook@8.6.18(prettier@3.8.1))': dependencies: '@storybook/addon-highlight': 8.6.18(storybook@8.6.18(prettier@3.8.1)) @@ -24011,6 +24059,8 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.8: {} + evp_bytestokey@1.0.3: dependencies: md5.js: 1.3.5 @@ -26530,6 +26580,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} From 996b7903135e8c74a73249fec4dea0b1b0e85a85 Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Fri, 15 May 2026 10:52:02 +0800 Subject: [PATCH 2/9] feat: add aisdk config --- .../src/app/config/features/aisdk.config.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 apps/backend/src/app/config/features/aisdk.config.ts diff --git a/apps/backend/src/app/config/features/aisdk.config.ts b/apps/backend/src/app/config/features/aisdk.config.ts new file mode 100644 index 0000000000..293f916eb1 --- /dev/null +++ b/apps/backend/src/app/config/features/aisdk.config.ts @@ -0,0 +1,41 @@ +import convict, { Schema } from 'convict' + +export interface IAiSdk { + providerName: string + apiKey: string + baseUrl: string + modelName: string +} + +const aisdkSchema: Schema = { + providerName: { + doc: 'Name of the engine to use', + format: String, + default: '', + env: 'AI_SDK_PROVIDER_NAME', + }, + baseUrl: { + doc: 'Base URL of the engine', + format: String, + default: '', + env: 'AI_SDK_BASE_URL', + }, + apiKey: { + doc: 'API key of the engine', + format: String, + default: '', + env: 'AI_SDK_API_KEY', + }, + modelName: { + doc: 'Name of the model to use', + format: String, + default: '', + env: 'AI_SDK_MODEL_NAME', + }, +} + +export const aisdkConfig = convict(aisdkSchema) + .validate({ + allowed: 'strict', + }) + .getProperties() From 0667d1ce6341aa2f67ff6c376e569b822c81d220 Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Sat, 16 May 2026 15:46:06 +0000 Subject: [PATCH 3/9] feat(backend): add ai package dependency Adds the Vercel AI SDK (`ai`) to the backend so the MFB LLM client can be migrated off the Azure OpenAI client onto `@ai-sdk/openai`'s `createOpenAI` provider pointed at Pair Foundry's PX Engine. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/backend/package.json | 1 + pnpm-lock.yaml | 120 +++++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index b717e0ab21..8bbf696f16 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -34,6 +34,7 @@ "@smithy/util-stream": "^3.3.4", "@stablelib/base64": "^1.0.1", "JSONStream": "^1.3.5", + "ai": "^6.0.184", "aws-info": "^1.2.0", "aws-sdk": "^2.1692.0", "axios": "^1.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5492531b35..efae4beb90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,9 @@ importers: JSONStream: specifier: ^1.3.5 version: 1.3.5 + ai: + specifier: ^6.0.184 + version: 6.0.184(zod@3.25.34) aws-info: specifier: ^1.2.0 version: 1.2.0 @@ -1212,7 +1215,7 @@ importers: version: 18.3.1 react-email: specifier: ^4.0.11 - version: 4.0.15(@babel/core@7.29.0)(@opentelemetry/api@1.8.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.0.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@storybook/react': specifier: 8.6.18 @@ -1488,6 +1491,12 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/gateway@3.0.115': + resolution: {integrity: sha512-xonmGfN9pt54WdKqMzWe68BRYS3rsYvraBzioyA0gfNcecHs8Ir5qk/X8grJSyZ95hghjWiOphrK6bAc11E6SA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai@3.0.63': resolution: {integrity: sha512-4yY/m8a57MNNVoJCsXuNblKf6BO4yuAuLKRX4tzSNffBEBSp1FlcWdPE0Z4FkqUeS0AJhYSSqp0GIiA/cIcDNA==} engines: {node: '>=18'} @@ -3923,6 +3932,10 @@ packages: resolution: {integrity: sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==} engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@opentelemetry/core@1.17.0': resolution: {integrity: sha512-tfnl3h+UefCgx1aeN2xtrmr6BmdWGKXypk0pflQR0urFS40aE88trnkOMc2HTJZbMrqEEl4HsaBeFhwLVXsrJg==} engines: {node: '>=14'} @@ -4021,95 +4034,111 @@ packages: '@react-email/body@0.0.10': resolution: {integrity: sha512-dMJyL9aU25ieatdPtVjCyQ/WHZYHwNc+Hy/XpF8Cc18gu21cUynVEeYQzFSeigDRMeBQ3PGAyjVDPIob7YlGwA==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/button@0.0.18': resolution: {integrity: sha512-uNUnpeDzz1o9HAky47JSTsUN/Ih0A3Az165AAOgAy8XOVzQJPrltUBRzHkScSVJTwRqKLASkie1yZbtNGIcRdA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-block@0.0.9': resolution: {integrity: sha512-Zrhc71VYrSC1fVXJuaViKoB/dBjxLw6nbE53Bm/eUuZPdnnZ1+ZUIh8jfaRKC5MzMjgnLGQTweGXVnfIrhyxtQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-inline@0.0.4': resolution: {integrity: sha512-zj3oMQiiUCZbddSNt3k0zNfIBFK0ZNDIzzDyBaJKy6ZASTtWfB+1WFX0cpTX8q0gUiYK+A94rk5Qp68L6YXjXQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/column@0.0.12': resolution: {integrity: sha512-Rsl7iSdDaeHZO938xb+0wR5ud0Z3MVfdtPbNKJNojZi2hApwLAQXmDrnn/AcPDM5Lpl331ZljJS8vHTWxxkvKw==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/components@0.0.26': resolution: {integrity: sha512-FqxCGnQiI4zztEBAXPfjovIQ9e1l7NJNMgE8hSaH7slWySFn/PpPRQFYpxyCFNr9DqPVHtKYtpo8xvUYx2LdTg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/container@0.0.14': resolution: {integrity: sha512-NgoaJJd9tTtsrveL86Ocr/AYLkGyN3prdXKd/zm5fQpfDhy/NXezyT3iF6VlwAOEUIu64ErHpAJd+P6ygR+vjg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/font@0.0.8': resolution: {integrity: sha512-fSBEqYyVPAyyACBBHcs3wEYzNknpHMuwcSAAKE8fOoDfGqURr/vSxKPdh4tOa9z7G4hlcEfgGrCYEa2iPT22cw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/head@0.0.11': resolution: {integrity: sha512-skw5FUgyamIMK+LN+fZQ5WIKQYf0dPiRAvsUAUR2eYoZp9oRsfkIpFHr0GWPkKAYjFEj+uJjaxQ/0VzQH7svVg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/heading@0.0.14': resolution: {integrity: sha512-jZM7IVuZOXa0G110ES8OkxajPTypIKlzlO1K1RIe1auk76ukQRiCg1IRV4HZlWk1GGUbec5hNxsvZa2kU8cb9w==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/hr@0.0.10': resolution: {integrity: sha512-3AA4Yjgl3zEid/KVx6uf6TuLJHVZvUc2cG9Wm9ZpWeAX4ODA+8g9HyuC0tfnjbRsVMhMcCGiECuWWXINi+60vA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/html@0.0.10': resolution: {integrity: sha512-06uiuSKJBWQJfhCKv4MPupELei4Lepyz9Sth7Yq7Fq29CAeB1ejLgKkGqn1I+FZ72hQxPLdYF4iq4yloKv3JCg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/img@0.0.10': resolution: {integrity: sha512-pJ8glJjDNaJ53qoM95pvX9SK05yh0bNQY/oyBKmxlBDdUII6ixuMc3SCwYXPMl+tgkQUyDgwEBpSTrLAnjL3hA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/link@0.0.11': resolution: {integrity: sha512-o1/BgPn2Fi+bN4Nh+P64t4tulaOyPhkBNSpNmiYL1Ar+ilw8q0BmUAqM+lvHy8Qr/4K7BjkgFoc4GoYkoEjOig==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/markdown@0.0.12': resolution: {integrity: sha512-wsuvj1XAb6O63aizCLNEeqVgKR3oFjAwt9vjfg2y2oh4G1dZeo8zonZM2x1fmkEkBZhzwSHraNi70jSXhA3A9w==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/preview@0.0.11': resolution: {integrity: sha512-7O/CT4b16YlSGrj18htTPx3Vbhu2suCGv/cSe5c+fuSrIM/nMiBSZ3Js16Vj0XJbAmmmlVmYFZw9L20wXJ+LjQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -4130,24 +4159,28 @@ packages: '@react-email/row@0.0.11': resolution: {integrity: sha512-ra09h7BMoGa14ds3vh7KVuj1N3astTstEC1YbMdCiHcx/nxylglNaT7qJXU74ZTzyHiGabyiNuyabTS+HLoMCA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/section@0.0.15': resolution: {integrity: sha512-xfM3Qy5eU7fbkwvktlTeQgad7uo+1Z7YVh1aowSZaRBvKbkEXgoH/XssRYQmQL8ZrZGXbEJMujwtf4fsQL6vrg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/tailwind@1.0.0': resolution: {integrity: sha512-LV0SflR0aI5Sjxyp8upyPL8Ctwj+7aqwTgCDO9yZuOI6KpXbBGaYz8bSofe8oaVc/BmymZ5O3+/7FjQexbW+Yg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/text@0.0.10': resolution: {integrity: sha512-wNAnxeEAiFs6N+SxS0y6wTJWfewEzUETuyS2aZmT00xk50VijwyFRuhm4sYSjusMyshevomFwz5jNISCxRsGWw==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -5711,6 +5744,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -5815,6 +5849,10 @@ packages: cpu: [x64] os: [win32] + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@virtuoso.dev/react-urx@0.2.13': resolution: {integrity: sha512-MY0ugBDjFb5Xt8v2HY7MKcRGqw/3gTpMlLXId2EwQvYJoC8sP7nnXjAxcBtTB50KTZhO0SbzsFimaZ7pSdApwA==} engines: {node: '>=10'} @@ -6037,6 +6075,12 @@ packages: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} + ai@6.0.184: + resolution: {integrity: sha512-j//zHkKvj5ra27l8izHco8cj1g1Pr7vx1ZK+hrzrkHvndgIRmdfZKOb6+RAPpvbk42qGIsuYvlYbGlVAu3erNQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-errors@1.0.1: resolution: {integrity: sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==} peerDependencies: @@ -13757,6 +13801,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uzip@0.20201231.0: @@ -14248,6 +14293,13 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@ai-sdk/gateway@3.0.115(zod@3.25.34)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.27(zod@3.25.34) + '@vercel/oidc': 3.2.0 + zod: 3.25.34 + '@ai-sdk/openai@3.0.63(zod@3.25.34)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -18734,6 +18786,8 @@ snapshots: '@opentelemetry/api@1.8.0': {} + '@opentelemetry/api@1.9.1': {} + '@opentelemetry/core@1.17.0(@opentelemetry/api@1.8.0)': dependencies: '@opentelemetry/api': 1.8.0 @@ -21123,6 +21177,8 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vercel/oidc@3.2.0': {} + '@virtuoso.dev/react-urx@0.2.13(react@18.3.1)': dependencies: '@virtuoso.dev/urx': 0.2.13 @@ -21395,6 +21451,14 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 + ai@6.0.184(zod@3.25.34): + dependencies: + '@ai-sdk/gateway': 3.0.115(zod@3.25.34) + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.27(zod@3.25.34) + '@opentelemetry/api': 1.9.1 + zod: 3.25.34 + ajv-errors@1.0.1(ajv@6.15.0): dependencies: ajv: 6.15.0 @@ -27877,6 +27941,31 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@15.5.18(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@next/env': 15.5.18 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001776 + postcss: 8.5.14 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.18 + '@next/swc-darwin-x64': 15.5.18 + '@next/swc-linux-arm64-gnu': 15.5.18 + '@next/swc-linux-arm64-musl': 15.5.18 + '@next/swc-linux-x64-gnu': 15.5.18 + '@next/swc-linux-x64-musl': 15.5.18 + '@next/swc-win32-arm64-msvc': 15.5.18 + '@next/swc-win32-x64-msvc': 15.5.18 + '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.58.2 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + nice-try@1.0.5: {} no-case@3.0.4: @@ -29008,6 +29097,35 @@ snapshots: - supports-color - utf-8-validate + react-email@4.0.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/parser': 7.29.0 + '@babel/traverse': 7.29.0 + chalk: 5.4.1 + chokidar: 4.0.3 + commander: 13.1.0 + debounce: 2.0.0 + esbuild: 0.25.9 + glob: 11.1.0 + log-symbols: 7.0.0 + mime-types: 3.0.1 + next: 15.5.18(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + normalize-path: 3.0.0 + ora: 8.2.0 + socket.io: 4.8.1 + transitivePeerDependencies: + - '@babel/core' + - '@opentelemetry/api' + - '@playwright/test' + - babel-plugin-macros + - babel-plugin-react-compiler + - bufferutil + - react + - react-dom + - sass + - supports-color + - utf-8-validate + react-error-overlay@6.1.0: {} react-fast-compare@3.2.2: {} From db0cec6deffccddf8af407130d47d25ccc65c9f6 Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Sat, 16 May 2026 15:46:37 +0000 Subject: [PATCH 4/9] refactor(admin-form): migrate MFB LLM client to ai-sdk + Pair Foundry Replaces the Azure OpenAI client in `ai-model.ts` with a Vercel AI SDK wrapper built on `createOpenAI` (`@ai-sdk/openai`) plus `generateText` (`ai`), targeting Pair Foundry's PX Engine via the existing `aisdkConfig` (providerName, apiKey, baseUrl, modelName from env/SSM). Behaviour-preserving from the caller's perspective: - `sendPromptToModel({ messages, options, formId })` keeps its signature and `ResultAsync` return type. - Provider/client construction failures still surface as `ModelGetClientFailureError`; request failures as `ModelResponseFailureError` (with `formId` in log meta); empty/missing text in the model response still returns `null`. - `temperature` stays unpinned; callers control it via `options`. Internal type and shape changes: - `Message` is now ai-sdk's `ModelMessage`; the local `Role` enum is removed (callers use the string literals `'user'`/`'system'`). - Vision-flow image parts move from the OpenAI shape `{ type: 'image_url', image_url: { url } }` to ai-sdk's `{ type: 'image', image: }`. - JSON-mode is plumbed per-call via `options.providerOptions.openai.responseFormat`, replacing the OpenAI-SDK-native `response_format` option. Downstream `JSON.parse` + zod validation in the assistance service is unchanged. Existing `admin-form.assistance.service.spec.ts` continues to mock `sendPromptToModel` wholesale and passes unchanged through the swap. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin-form.assistance.service.ts | 22 ++-- .../app/modules/form/admin-form/ai-model.ts | 107 ++++++------------ 2 files changed, 43 insertions(+), 86 deletions(-) diff --git a/apps/backend/src/app/modules/form/admin-form/admin-form.assistance.service.ts b/apps/backend/src/app/modules/form/admin-form/admin-form.assistance.service.ts index c3f1da4c98..4a4f5c46f1 100644 --- a/apps/backend/src/app/modules/form/admin-form/admin-form.assistance.service.ts +++ b/apps/backend/src/app/modules/form/admin-form/admin-form.assistance.service.ts @@ -25,7 +25,7 @@ import { ModelResponseInvalidSyntaxError, } from './admin-form.errors' import { createFormFields, updateFormMetadata } from './admin-form.service' -import { Message, Role, sendPromptToModel } from './ai-model' +import { Message, sendPromptToModel } from './ai-model' const logger = createLoggerWithLabel(module) @@ -127,7 +127,7 @@ const VERIFICATION_PROMPT = { } const FORM_RULES_TEXT_PROMPT = { - role: Role.System, + role: 'system', content: // Provide context to model on when to use each field type 'You are to generate a JSON output that is a list of form fields that are to be used to create a form. The JSON output must contain a single key named "fields" which is an array of form fields. Every form field must follow the rules and guidelines provided.' + @@ -162,7 +162,7 @@ const generateFormCreationPrompt = (userPrompt: string) => { FORM_RULES_TEXT_PROMPT, { // Provide general topic + example fields that user wants to collect. - role: Role.User, + role: 'user', content: `Create a form that collects ${userPrompt}. The JSON object containing "fields" key which contains an array of form fields that definitely follows all the given rules and guidelines is `, }, VERIFICATION_PROMPT, @@ -275,8 +275,8 @@ const generateAndsendTextPromptToModel = ({ messages: messages, formId, options: { - response_format: { - type: 'json_object', + providerOptions: { + openai: { responseFormat: { type: 'json_object' } }, }, }, }).mapErr((error) => { @@ -373,7 +373,7 @@ export const createFormFieldsUsingTextPrompt = ({ } const FORM_DETAILS_VISION_PROMPT = { - role: Role.System, + role: 'system', content: // Provide context to model on when to use each field type 'You are to generate a JSON output that is a list of form fields that are to be used to create a form. The JSON output must contain a single key named "fields" which is an array of form fields. Every form field must follow the rules and guidelines provided.' + @@ -422,10 +422,8 @@ const generateFormCreationVisionPrompt = ({ }) => { const imageUrlContents = imageDataUrls.map((dataUrl) => { return { - type: 'image_url', - image_url: { - url: dataUrl, - }, + type: 'image', + image: dataUrl, } }) const userPrompt = { @@ -457,8 +455,8 @@ const generateAndSendVisionPromptToModel = ({ messages, formId, options: { - response_format: { - type: 'json_object', + providerOptions: { + openai: { responseFormat: { type: 'json_object' } }, }, }, }).mapErr((error) => { diff --git a/apps/backend/src/app/modules/form/admin-form/ai-model.ts b/apps/backend/src/app/modules/form/admin-form/ai-model.ts index 440f5381c9..517e5311d1 100644 --- a/apps/backend/src/app/modules/form/admin-form/ai-model.ts +++ b/apps/backend/src/app/modules/form/admin-form/ai-model.ts @@ -1,12 +1,8 @@ -import { err, errAsync, ok, Result, ResultAsync } from 'neverthrow' -import { AzureOpenAI } from 'openai' -import { OpenAIError } from 'openai/error' -import type { - ChatCompletionCreateParamsNonStreaming, - ChatCompletionMessageParam, -} from 'openai/resources/index' +import { createOpenAI } from '@ai-sdk/openai' +import { generateText, ModelMessage } from 'ai' +import { errAsync, ResultAsync } from 'neverthrow' -import { azureOpenAIConfig } from '../../../config/features/azureopenai.config' +import { aisdkConfig } from '../../../config/features/aisdk.config' import { createLoggerWithLabel } from '../../../config/logger' import { @@ -14,100 +10,63 @@ import { ModelResponseFailureError, } from './admin-form.errors' -const { endpoint, apiKey, apiVersion, deploymentName, model } = - azureOpenAIConfig - const logger = createLoggerWithLabel(module) -const getLlmClient = (): Result => { - try { - const client = new AzureOpenAI({ - endpoint, - apiKey, - apiVersion, - deployment: deploymentName, - }) - return ok(client) - } catch (error) { - logger.error({ - message: 'Error occurred when getting Llm client', - meta: { - action: 'getLlmClient', - }, - error, - }) - return err(new ModelGetClientFailureError()) - } -} - -export enum Role { - User = 'user', - System = 'system', -} +export type Message = ModelMessage -export type Message = ChatCompletionMessageParam +type GenerateTextArgs = Parameters[0] +export type SendPromptOptions = Omit< + GenerateTextArgs, + 'model' | 'messages' | 'prompt' +> -/** - * Sends prompt to the AI LLM and returns the response. - * @param {Message[]} params.messages - An array of message objects to send to the AI. - * @param {Object} [params.options] - Optional parameters for the chat completion. - * @param {string} params.formId - The ID of the form associated with this request. Used for logging. - * @returns {ResultAsync} A Result containing the AI's response or null if no response, or an error if the request fails. - */ export const sendPromptToModel = ({ messages, options, formId, }: { messages: Message[] - options?: Omit + options?: SendPromptOptions formId: string }): ResultAsync< string | null, ModelGetClientFailureError | ModelResponseFailureError > => { - const logMeta = { - action: 'sendUserTextPrompt', - formId, - } - const getLlmClientResult = getLlmClient() + const { providerName, apiKey, baseUrl, modelName } = aisdkConfig - if (getLlmClientResult.isErr()) { + let model + try { + const provider = createOpenAI({ + name: providerName, + apiKey, + baseURL: baseUrl, + }) + model = provider.chat(modelName) + } catch (error) { logger.error({ - message: 'Failed to get Llm client', - meta: logMeta, - error: getLlmClientResult.error, + message: 'Failed to construct ai-sdk provider client', + meta: { action: 'sendPromptToModel', formId }, + error, }) - return errAsync(getLlmClientResult.error) - } - - const llmClient = getLlmClientResult.value - - const chatCompletionPrompt: ChatCompletionCreateParamsNonStreaming = { - messages, - model, - ...options, + return errAsync(new ModelGetClientFailureError()) } return ResultAsync.fromPromise( - llmClient.chat.completions.create(chatCompletionPrompt), + generateText({ + ...options, + model, + messages, + }), (err) => { logger.error({ message: 'Failed to generate model response', - meta: logMeta, + meta: { action: 'sendPromptToModel', formId }, error: err, }) return new ModelResponseFailureError() }, ).map((response) => { - const isLlmResponseMissing = - !response.choices || - response.choices.length <= 0 || - !response.choices[0].message?.content - - if (isLlmResponseMissing) { - return null - } - return response.choices[0].message?.content + if (!response.text) return null + return response.text }) } From 2ef827e44b529a3ff9710d56a404fbe0d5c3365a Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Sat, 16 May 2026 15:47:56 +0000 Subject: [PATCH 5/9] test(ai-model): add unit tests for new ai-sdk wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a wrapper-level spec for `sendPromptToModel` that mocks the ai-sdk boundary (`generateText`, `createOpenAI`) rather than going deeper, so it stays valid if internals are later refactored (e.g. swapping `generateText` for `generateObject` in the structured-outputs follow-up). Covers: - Happy path: messages forwarded to `generateText` with the model returned by the OpenAI provider, and `result.text` is returned. - Null response: `generateText` resolves with empty `text` → wrapper returns `ok(null)`. - Client construction failure: `createOpenAI` throws → wrapper returns `err(ModelGetClientFailureError)` and never calls `generateText`. - Request failure: `generateText` rejects → wrapper returns `err(ModelResponseFailureError)` and logs include `formId` in meta. - Options pass-through: caller-supplied `temperature` and `providerOptions.openai.responseFormat` reach `generateText`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin-form/__tests__/ai-model.spec.ts | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 apps/backend/src/app/modules/form/admin-form/__tests__/ai-model.spec.ts diff --git a/apps/backend/src/app/modules/form/admin-form/__tests__/ai-model.spec.ts b/apps/backend/src/app/modules/form/admin-form/__tests__/ai-model.spec.ts new file mode 100644 index 0000000000..9c653a4e47 --- /dev/null +++ b/apps/backend/src/app/modules/form/admin-form/__tests__/ai-model.spec.ts @@ -0,0 +1,161 @@ +import * as AiSdkOpenai from '@ai-sdk/openai' +import * as AiSdk from 'ai' + +import { + ModelGetClientFailureError, + ModelResponseFailureError, +} from '../admin-form.errors' +import { sendPromptToModel } from '../ai-model' + +const mockLoggerError = jest.fn() + +jest.mock('ai') +jest.mock('@ai-sdk/openai') +jest.mock('src/app/config/logger', () => ({ + createLoggerWithLabel: () => ({ + info: () => undefined, + warn: () => undefined, + error: (...args: unknown[]) => mockLoggerError(...args), + }), +})) + +const mockedAiSdk = jest.mocked(AiSdk) +const mockedAiSdkOpenai = jest.mocked(AiSdkOpenai) + +const FAKE_MODEL = { __brand: 'fake-language-model' } as never +const FAKE_PROVIDER = { + chat: jest.fn().mockReturnValue(FAKE_MODEL), +} as unknown as ReturnType + +describe('ai-model', () => { + beforeEach(() => { + jest.resetAllMocks() + ;(FAKE_PROVIDER.chat as jest.Mock).mockReturnValue(FAKE_MODEL) + mockedAiSdkOpenai.createOpenAI = jest + .fn() + .mockReturnValue(FAKE_PROVIDER) as never + mockLoggerError.mockReset() + }) + + describe('sendPromptToModel', () => { + it('forwards messages to generateText and returns the resulting text', async () => { + // Arrange + mockedAiSdk.generateText = jest.fn().mockResolvedValue({ + text: 'model output text', + }) as never + const messages = [ + { role: 'system', content: 'system text' }, + { role: 'user', content: 'user text' }, + ] as AiSdk.ModelMessage[] + + // Act + const result = await sendPromptToModel({ + messages, + formId: 'form-id-123', + }) + + // Assert + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toBe('model output text') + expect(AiSdk.generateText).toHaveBeenCalledTimes(1) + expect(AiSdk.generateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: FAKE_MODEL, + messages, + }), + ) + }) + + it('returns ModelGetClientFailureError when the provider cannot be constructed', async () => { + // Arrange + mockedAiSdkOpenai.createOpenAI = jest.fn().mockImplementation(() => { + throw new Error('boom: provider construction failed') + }) as never + mockedAiSdk.generateText = jest.fn() as never + + // Act + const result = await sendPromptToModel({ + messages: [{ role: 'user', content: 'hi' }], + formId: 'form-id-123', + }) + + // Assert + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + ModelGetClientFailureError, + ) + expect(AiSdk.generateText).not.toHaveBeenCalled() + }) + + it('returns ModelResponseFailureError and logs with formId when generateText rejects', async () => { + // Arrange + mockedAiSdk.generateText = jest + .fn() + .mockRejectedValue(new Error('upstream 500')) as never + + // Act + const result = await sendPromptToModel({ + messages: [{ role: 'user', content: 'hi' }], + formId: 'form-id-xyz', + }) + + // Assert + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + ModelResponseFailureError, + ) + expect(mockLoggerError).toHaveBeenCalledWith( + expect.objectContaining({ + meta: expect.objectContaining({ formId: 'form-id-xyz' }), + }), + ) + }) + + it('forwards caller options including temperature and providerOptions.openai.responseFormat to generateText', async () => { + // Arrange + mockedAiSdk.generateText = jest + .fn() + .mockResolvedValue({ text: 'ok' }) as never + + // Act + await sendPromptToModel({ + messages: [{ role: 'user', content: 'hi' }], + formId: 'form-id-123', + options: { + temperature: 0.5, + providerOptions: { + openai: { responseFormat: { type: 'json_object' } }, + }, + }, + }) + + // Assert + expect(AiSdk.generateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: FAKE_MODEL, + temperature: 0.5, + providerOptions: { + openai: { responseFormat: { type: 'json_object' } }, + }, + }), + ) + }) + + it('returns null when the model response has no text content', async () => { + // Arrange + mockedAiSdk.generateText = jest.fn().mockResolvedValue({ + text: '', + }) as never + + // Act + const result = await sendPromptToModel({ + messages: [{ role: 'user', content: 'hi' }], + formId: 'form-id-123', + }) + + // Assert + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toBeNull() + }) + }) +}) From 41b7a65096e9c2a6d6cd8b5b2f9bbba731c165ec Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Sat, 16 May 2026 16:13:22 +0000 Subject: [PATCH 6/9] chore: remove openai azure --- .env.example | 6 --- apps/backend/package.json | 1 - .../app/config/features/azureopenai.config.ts | 48 ------------------- pnpm-lock.yaml | 23 ++++----- 4 files changed, 10 insertions(+), 68 deletions(-) delete mode 100644 apps/backend/src/app/config/features/azureopenai.config.ts diff --git a/.env.example b/.env.example index 67b105e916..307018ff82 100644 --- a/.env.example +++ b/.env.example @@ -117,12 +117,6 @@ FORMSG_SDK_MODE= # POSTMAN_INTERNAL_CAMPAIGN_API_KEY= # POSTMAN_BASE_URL=https://test.postman.gov.sg/api/v2 -## Azure OpenAI -# AZURE_OPENAI_API_KEY= -# AZURE_OPENAI_ENDPOINT= -# AZURE_OPENAI_DEPLOYMENT_NAME= -# AZURE_OPENAI_API_VERSION= - ## Kill email mode configs, provide a valid storage form id # KILL_EMAIL_MODE_FEEDBACK_FORMID= diff --git a/apps/backend/package.json b/apps/backend/package.json index 8bbf696f16..ef10eec9e2 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -97,7 +97,6 @@ "node-cache": "^5.1.2", "nodemailer": "^6.9.16", "oauth4webapi": "^3.6.0", - "openai": "^4.70.3", "openid-client": "^6.6.2", "openid-client-legacy": "npm:openid-client@^5.7.1", "opossum": "^8.1.4", diff --git a/apps/backend/src/app/config/features/azureopenai.config.ts b/apps/backend/src/app/config/features/azureopenai.config.ts deleted file mode 100644 index ad5625a900..0000000000 --- a/apps/backend/src/app/config/features/azureopenai.config.ts +++ /dev/null @@ -1,48 +0,0 @@ -import convict, { Schema } from 'convict' - -export interface IAzureOpenAi { - apiKey: string - endpoint: string - deploymentName: string - apiVersion: string - model: string -} - -const azureOpenAISchema: Schema = { - apiKey: { - doc: 'Azure OpenAI API key', - format: String, - default: '', - env: 'AZURE_OPENAI_API_KEY', - }, - endpoint: { - doc: 'Azure OpenAI endpoint', - format: String, - default: '', - env: 'AZURE_OPENAI_ENDPOINT', - }, - deploymentName: { - doc: 'Azure OpenAI deployment name', - format: String, - default: '', - env: 'AZURE_OPENAI_DEPLOYMENT_NAME', - }, - apiVersion: { - doc: 'Azure OpenAI API version', - format: String, - default: '', - env: 'AZURE_OPENAI_API_VERSION', - }, - model: { - doc: 'Name of model to use', - format: String, - default: 'gpt-4o-mini', - env: 'AZURE_OPENAI_MODEL', - }, -} - -export const azureOpenAIConfig = convict(azureOpenAISchema) - .validate({ - allowed: 'strict', - }) - .getProperties() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efae4beb90..d8dae4e127 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,9 +404,6 @@ importers: oauth4webapi: specifier: ^3.6.0 version: 3.6.0 - openai: - specifier: ^4.70.3 - version: 4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.34) openid-client: specifier: ^6.6.2 version: 6.6.2 @@ -14797,8 +14794,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.577.0(@aws-sdk/client-sts@3.577.0) - '@aws-sdk/client-sts': 3.577.0 + '@aws-sdk/client-sso-oidc': 3.577.0 + '@aws-sdk/client-sts': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) '@aws-sdk/core': 3.576.0 '@aws-sdk/credential-provider-node': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -14931,11 +14928,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0)': + '@aws-sdk/client-sso-oidc@3.577.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.577.0 + '@aws-sdk/client-sts': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) '@aws-sdk/core': 3.576.0 '@aws-sdk/credential-provider-node': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -14974,7 +14971,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.693.0(@aws-sdk/client-sts@3.693.0)': @@ -15151,11 +15147,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.577.0': + '@aws-sdk/client-sts@3.577.0(@aws-sdk/client-sso-oidc@3.577.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.577.0(@aws-sdk/client-sts@3.577.0) + '@aws-sdk/client-sso-oidc': 3.577.0 '@aws-sdk/core': 3.576.0 '@aws-sdk/credential-provider-node': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -15194,6 +15190,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.693.0': @@ -15422,7 +15419,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0)': dependencies: - '@aws-sdk/client-sts': 3.577.0 + '@aws-sdk/client-sts': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) '@aws-sdk/credential-provider-env': 3.577.0 '@aws-sdk/credential-provider-process': 3.577.0 '@aws-sdk/credential-provider-sso': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) @@ -15739,7 +15736,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.577.0(@aws-sdk/client-sts@3.577.0)': dependencies: - '@aws-sdk/client-sts': 3.577.0 + '@aws-sdk/client-sts': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -16253,7 +16250,7 @@ snapshots: '@aws-sdk/token-providers@3.577.0(@aws-sdk/client-sso-oidc@3.577.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.577.0(@aws-sdk/client-sts@3.577.0) + '@aws-sdk/client-sso-oidc': 3.577.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 From b7a09c756f9e58f134c7639231ba7a7ffb1cec51 Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Sat, 16 May 2026 16:16:56 +0000 Subject: [PATCH 7/9] feat(abackend): add ai sdk params to env example --- .env.example | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 307018ff82..cd53929e8a 100644 --- a/.env.example +++ b/.env.example @@ -120,8 +120,14 @@ FORMSG_SDK_MODE= ## Kill email mode configs, provide a valid storage form id # KILL_EMAIL_MODE_FEEDBACK_FORMID= -# For WOG AD Login +# For WOG AD Login # WOGAD_AUTHORITY= # WOGAD_CLIENT_ID= # WOGAD_CLIENT_SECRET= -# WOGAD_REDIRECT_URI= \ No newline at end of file +# WOGAD_REDIRECT_URI= + +## AI SDK +# AI_SDK_PROVIDER_NAME= +# AI_SDK_BASE_URL= +# AI_SDK_API_KEY= +# AI_SDK_MODEL_NAME= \ No newline at end of file From e53adf3472fc7bf494f21be116ec993547d4e716 Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Sat, 16 May 2026 16:17:27 +0000 Subject: [PATCH 8/9] feat: update ecs task definition to load aisdk ssm params --- deploy/ecs-task-definition.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/deploy/ecs-task-definition.json b/deploy/ecs-task-definition.json index 117e9fde8d..c5a4dcc69b 100644 --- a/deploy/ecs-task-definition.json +++ b/deploy/ecs-task-definition.json @@ -64,10 +64,6 @@ "name": "PAYMENT_STRIPE_WEBHOOK_SECRET", "valueFrom": "//PAYMENT_STRIPE_WEBHOOK_SECRET" }, - { - "name": "AZURE_OPENAI_API_KEY", - "valueFrom": "//AZURE_OPENAI_API_KEY" - }, { "name": "SINGPASS_ESRVC_ID", "valueFrom": "//SINGPASS_ESRVC_ID" @@ -202,16 +198,20 @@ "valueFrom": "//PAYMENT_LANDING_GUIDE_LINK" }, { - "name": "AZURE_OPENAI_ENDPOINT", - "valueFrom": "//AZURE_OPENAI_ENDPOINT" + "name": "AI_SDK_PROVIDER_NAME", + "valueFrom": "//AI_SDK_PROVIDER_NAME" + }, + { + "name": "AI_SDK_BASE_URL", + "valueFrom": "//AI_SDK_BASE_URL" }, { - "name": "AZURE_OPENAI_DEPLOYMENT_NAME", - "valueFrom": "//AZURE_OPENAI_DEPLOYMENT_NAME" + "name": "AI_SDK_API_KEY", + "valueFrom": "//AI_SDK_API_KEY" }, { - "name": "AZURE_OPENAI_API_VERSION", - "valueFrom": "//AZURE_OPENAI_API_VERSION" + "name": "AI_SDK_MODEL_NAME", + "valueFrom": "//AI_SDK_MODEL_NAME" }, { "name": "SPCP_COOKIE_MAX_AGE_PRESERVED", From d37c0cdb30747508ba8024431955d3bbaf10aaae Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Sat, 16 May 2026 16:19:46 +0000 Subject: [PATCH 9/9] chore: save experiemntal files --- .../01-ai-sdk-wrapper-migration.md | 44 +++++++ .../migrate-pair-foundry/02-azure-cleanup.md | 32 +++++ .scratch/migrate-pair-foundry/PRD.md | 113 ++++++++++++++++++ docs/adr/0001-pair-foundry-llm-provider.md | 24 ++++ migrate-ai-model-to-pair-foundry.md | 30 +++++ 5 files changed, 243 insertions(+) create mode 100644 .scratch/migrate-pair-foundry/01-ai-sdk-wrapper-migration.md create mode 100644 .scratch/migrate-pair-foundry/02-azure-cleanup.md create mode 100644 .scratch/migrate-pair-foundry/PRD.md create mode 100644 docs/adr/0001-pair-foundry-llm-provider.md create mode 100644 migrate-ai-model-to-pair-foundry.md diff --git a/.scratch/migrate-pair-foundry/01-ai-sdk-wrapper-migration.md b/.scratch/migrate-pair-foundry/01-ai-sdk-wrapper-migration.md new file mode 100644 index 0000000000..b834262810 --- /dev/null +++ b/.scratch/migrate-pair-foundry/01-ai-sdk-wrapper-migration.md @@ -0,0 +1,44 @@ +# Migrate `ai-model` wrapper + both MFB flows to Pair Foundry via Vercel AI SDK + +**Type:** AFK +**Triage:** ready-for-agent + +## Parent + +PRD: `.scratch/migrate-pair-foundry/PRD.md` +ADR: `docs/adr/0001-pair-foundry-llm-provider.md` + +## What to build + +Replace the Azure-OpenAI-backed Magic Form Builder (MFB) LLM client with a Vercel AI SDK client pointed at Pair Foundry's PX Engine (`engine.pair.gov.sg`). The single application-facing seam — `sendPromptToModel({ messages, options, formId })` — keeps its function signature, but its internals are fully rewritten on top of ai-sdk's `generateText` (using `@ai-sdk/openai`'s `createOpenAI` provider). The `Message` and options types exposed to callers become ai-sdk native (`CoreMessage` / `ModelMessage`). + +Both MFB flows (text-prompt and vision-prompt) are switched over together because changing the exported `Message` type forces both callers to update in the same change. The vision flow rewrites its content parts from the OpenAI shape (`{ type: 'image_url', image_url: { url } }`) to ai-sdk's (`{ type: 'image', image: }`). JSON-mode is preserved per-call via `providerOptions.openai.responseFormat`, so downstream `JSON.parse` + zod validation in the assistance service is unchanged. + +Connection settings (`providerName`, `apiKey`, `baseUrl`, `modelName`) are read from the existing `aisdkConfig` (env-var-driven, SSM-supplied in prod). The default model is `claude-x` — verified to support both image content parts and JSON mode on PX Engine — and serves both text and vision flows. + +Errors from ai-sdk are mapped onto the existing `ModelGetClientFailureError` (provider/client construction failure) and `ModelResponseFailureError` (request failure or empty response) so that error-handling code paths in MFB controllers and the assistance service remain untouched. A null return for missing message content stays the contract for "model responded but produced nothing usable." + +New unit tests for the wrapper cover message/options forwarding, JSON-mode plumbing, error mapping, and null-response handling. The wrapper mocks ai-sdk at the `generateText` boundary, not deeper. Existing `admin-form.assistance.service` tests continue to mock `sendPromptToModel` directly and remain valid through the swap. + +The PR is structured as conventional commits (one logical step per commit: dependency add, wrapper rewrite, prompt rewrite, wrapper tests) so reviewers can step through it commit-by-commit, per the ADR's implementation considerations. + +## Acceptance criteria + +- [ ] `ai` package added to `apps/backend/package.json` and pnpm lockfile; `@ai-sdk/openai` already present. +- [ ] `ai-model.ts` no longer imports anything from the `openai` npm package; uses `createOpenAI` from `@ai-sdk/openai` plus ai-sdk's `generateText`. +- [ ] `ai-model.ts` reads `providerName`, `apiKey`, `baseUrl`, `modelName` from `aisdkConfig`; passes `providerName` as the provider `name`, `baseUrl` as `baseURL`, `apiKey` as `apiKey`, and `.chat(modelName)` selects the model. +- [ ] `sendPromptToModel({ messages, options, formId })` retains its three named parameters and its `ResultAsync` return type. +- [ ] `Message` re-exported by `ai-model.ts` is ai-sdk's `CoreMessage` (or `ModelMessage`); the `Role` enum is removed; callers in `admin-form.assistance.service.ts` are updated accordingly. +- [ ] JSON-mode (`response_format: { type: 'json_object' }`) is plumbed via `providerOptions: { openai: { responseFormat: { type: 'json_object' } } }` on each call from the text and vision prompt flows. +- [ ] Vision-flow content parts use ai-sdk's `{ type: 'image', image: }` shape; the `image_url` shape no longer appears in the prompt builders. +- [ ] ai-sdk client-construction failures surface as `ModelGetClientFailureError`; ai-sdk request failures surface as `ModelResponseFailureError`; empty/missing model responses return `null`. +- [ ] `temperature` is not pinned inside the wrapper; callers can still pass it via `options`. +- [ ] New unit tests added for the `ai-model` wrapper covering: happy-path forwarding of messages and `providerOptions.openai.responseFormat`; null/empty response → `null`; provider construction failure → `ModelGetClientFailureError`; ai-sdk request failure → `ModelResponseFailureError` with `formId` in the log meta; options pass-through (caller options layered correctly with the wrapper's own). +- [ ] Existing `admin-form.assistance.service.spec.ts` continues to pass without modification to its mocks. +- [ ] Backend type-checks, lints, and tests pass. +- [ ] Manual smoke: text-prompt MFB generates expected JSON form fields end-to-end against PX Engine; vision-prompt MFB does the same with an uploaded image. +- [ ] Commits follow conventional commits and are reviewable in sequence. + +## Blocked by + +None — can start immediately. diff --git a/.scratch/migrate-pair-foundry/02-azure-cleanup.md b/.scratch/migrate-pair-foundry/02-azure-cleanup.md new file mode 100644 index 0000000000..fb9648d2c3 --- /dev/null +++ b/.scratch/migrate-pair-foundry/02-azure-cleanup.md @@ -0,0 +1,32 @@ +# Remove orphaned Azure AI Foundry artifacts + +**Type:** AFK +**Triage:** ready-for-agent + +## Parent + +PRD: `.scratch/migrate-pair-foundry/PRD.md` +ADR: `docs/adr/0001-pair-foundry-llm-provider.md` + +## What to build + +Once the Magic Form Builder LLM wrapper is fully migrated to Pair Foundry (slice 1), the Azure AI Foundry remnants are dead weight: the Azure-specific convict config, the `openai` npm package, and `AZURE_OPENAI_*` env-var references in the repo. This slice deletes them so SSM / IaC stops carrying ghost parameters and the lockfile stops carrying an unused dependency. + +The scope is in-repo only. If FormSG's deployment-side IaC lives in a separate repository, those `AZURE_OPENAI_*` parameter definitions are out of scope for this slice and handled in a follow-up PR against that repo. + +After this slice the only LLM config surface in the codebase is `aisdk.config.ts` and the `AI_SDK_*` env vars. + +## Acceptance criteria + +- [ ] `apps/backend/src/app/config/features/azureopenai.config.ts` is deleted. +- [ ] `openai` is removed from `apps/backend/package.json` dependencies; pnpm lockfile is regenerated and no longer contains the `openai` package. +- [ ] Repo-wide grep confirms no remaining imports from `'openai'`, `'openai/error'`, or `'openai/resources/...'` in `apps/backend/src`. +- [ ] Repo-wide grep confirms no remaining references to `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_VERSION`, or `AZURE_OPENAI_MODEL` in `.env` examples, docs, or in-repo IaC. +- [ ] Repo-wide grep confirms no remaining references to `azureOpenAIConfig` or `azureopenai.config`. +- [ ] Backend type-checks, lints, and tests pass. +- [ ] Manual smoke: text-prompt MFB and vision-prompt MFB continue to work end-to-end against PX Engine (regression check after dep removal). +- [ ] Commits follow conventional commits. + +## Blocked by + +- Slice 1: `.scratch/migrate-pair-foundry/01-ai-sdk-wrapper-migration.md` — the Azure config and `openai` package can only be safely removed once nothing imports them. diff --git a/.scratch/migrate-pair-foundry/PRD.md b/.scratch/migrate-pair-foundry/PRD.md new file mode 100644 index 0000000000..65ecda31dd --- /dev/null +++ b/.scratch/migrate-pair-foundry/PRD.md @@ -0,0 +1,113 @@ +# Migrate LLM provider from Azure AI Foundry to Pair Foundry + +## Problem Statement + +FormSG's Magic Form Builder (MFB) — the feature that lets admins generate form fields from a text prompt or images of a paper form — currently calls Azure AI Foundry via the `openai` npm package's `AzureOpenAI` client. As a Singapore Government product, FormSG should be using Pair Foundry's PX Engine (`engine.pair.gov.sg`), the whole-of-government LLM gateway, instead of a tenant-specific Azure deployment. + +Staying on Azure AI Foundry also blocks adoption of newer models exposed through Pair Foundry, which we want for an upcoming follow-up that introduces structured outputs (via ai-sdk `generateText` since `generateObject` will be deprecated) to reduce flakiness in MFB output parsing. + +## Solution + +Swap the underlying LLM provider for MFB (and any future in-app LLM features) from Azure AI Foundry to Pair Foundry's PX Engine. All connection settings — provider name, API key, base URL, model name — are parameterised in the backend's convict config and supplied via env vars sourced from AWS SSM Parameter Store in production, so future model/endpoint changes don't require code edits. + +Under the hood, the LLM client wrapper switches from the `openai` npm package's `AzureOpenAI` client to the Vercel AI SDK (`ai` package + `@ai-sdk/openai`'s `createOpenAI` provider pointed at PX Engine's OpenAI-compatible HTTP surface). This is a behaviour-preserving swap from the user's perspective: MFB continues to generate form fields from text and image prompts; output quality may shift due to the underlying model change but the feature contract is unchanged. + +## User Stories + +1. As a FormSG admin using Magic Form Builder, I want to generate form fields from a text prompt, so that I can build forms faster without manually adding each field. +2. As a FormSG admin using Magic Form Builder, I want to upload images of a paper form and have form fields generated from them, so that I can digitise existing paper forms quickly. +3. As a FormSG admin, I want MFB to continue working without interruption after the provider migration, so that my workflow is not disrupted. +4. As a FormSG admin, I want MFB output to follow the same form-field structure (titles, field types, required flags, options, table columns, statements, etc.), so that the generated form remains immediately editable. +5. As a FormSG admin, I want vision-based MFB to keep generating sectioned, ordered fields matching the source paper form, so that the generated form is faithful to the original. +6. As a FormSG operator/SRE, I want the LLM provider's API key, base URL, model name, and provider name to be configurable via AWS SSM Parameter Store, so that we can rotate credentials and change endpoints without redeploying code. +7. As a FormSG operator/SRE, I want a clear rollback path if Pair Foundry misbehaves, so that I can revert the change and redeploy quickly. +8. As a FormSG operator/SRE, I want stale Azure-specific config and env vars removed in the same change, so that SSM / IaC stops carrying dead parameters. +9. As a FormSG developer, I want the internal LLM client API to use Vercel AI SDK native types (`CoreMessage`, ai-sdk options), so that future LLM features (streaming, tool use, structured outputs) plug in cleanly. +10. As a FormSG developer, I want `sendPromptToModel` to remain the single seam between application code and the LLM provider, so that future provider swaps stay isolated to one file. +11. As a FormSG developer, I want JSON-mode (`response_format: { type: 'json_object' }`) preserved for both text and vision MFB flows, so that downstream `JSON.parse` + zod validation continues to work without flakiness regression. +12. As a FormSG developer, I want LLM-call errors to map to the existing `ModelGetClientFailureError` / `ModelResponseFailureError` domain errors, so that error-handling code paths in MFB controllers and the assistance service don't need to change. +13. As a FormSG developer, I want the `openai` npm package removed from `apps/backend` once it's no longer imported anywhere, so that the lockfile and bundle don't carry dead weight. +14. As a FormSG developer, I want the ADR for this migration documented under `docs/adr/`, so that future contributors understand why we point at a non-OpenAI base URL and use a specific model variant. +15. As a FormSG developer reviewing this change, I want the PR split into reviewable conventional commits, so that each step of the migration (config, wrapper rewrite, prompt rewrite, cleanup, ADR) can be reviewed and reasoned about independently. +16. As a FormSG developer, I want unit tests for the new `ai-model` wrapper covering message/options forwarding, JSON-mode plumbing, error mapping, and null-response handling, so that the migration has a safety net even though existing assistance-service tests mock the wrapper. +17. As a FormSG developer working on a follow-up PR, I want the wrapper structured so that swapping `generateText` for `generateObject` (structured outputs) requires minimal additional change, so that we can land the structured-outputs improvement next without revisiting the migration scaffolding. + +## Implementation Decisions + +### Modules + +- **`ai-model` LLM client wrapper** (`apps/backend/src/app/modules/form/admin-form/ai-model.ts`) is a *deep module*: a single exported function `sendPromptToModel({ messages, options, formId })` encapsulates Vercel AI SDK provider construction, the `providerOptions.openai.responseFormat` plumbing, and translation of ai-sdk errors into the existing `ModelGetClientFailureError` / `ModelResponseFailureError` domain errors. Internals are fully rewritten; the function signature stays a stable seam for `admin-form.assistance.service.ts`. +- **AI SDK config** (`apps/backend/src/app/config/features/aisdk.config.ts`) stays as-is: `providerName`, `apiKey`, `baseUrl`, `modelName`, all defaulting to `''`, all wired through env vars (`AI_SDK_PROVIDER_NAME`, `AI_SDK_API_KEY`, `AI_SDK_BASE_URL`, `AI_SDK_MODEL_NAME`). SSM Parameter Store supplies these env vars in prod. +- **Form-fields assistance service** (`admin-form.assistance.service.ts`) is modified, not extracted. Prompt builders (`generateFormCreationPrompt`, `generateFormCreationVisionPrompt`) are rewritten to emit ai-sdk `CoreMessage[]`. The vision-flow message rewrites `{ type: 'image_url', image_url: { url } }` content parts to ai-sdk's `{ type: 'image', image: }` shape. +- **Cleanup**: delete `apps/backend/src/app/config/features/azureopenai.config.ts`; remove `openai` from `apps/backend/package.json` and the pnpm lockfile; scrub `AZURE_OPENAI_*` env-var references in repo `.env` examples, docs, and any IaC manifests that live in this repo (the deployment-IaC repo, if separate, is handled in a follow-up). + +### Vercel AI SDK adoption + +- The `ai` package is added as a new backend dependency. `@ai-sdk/openai` is already installed. +- `sendPromptToModel` internally calls ai-sdk's `generateText`. We do not adopt `generateObject` in this PR — structured outputs are a separate follow-up. +- Internal types exposed to `admin-form.assistance.service.ts` become ai-sdk native (`CoreMessage`, `ModelMessage`), replacing the current `ChatCompletionMessageParam` (from the `openai` SDK) and the local `Role` enum / `Message` alias. Application code builds prompts directly in ai-sdk shape. +- `temperature` is left unset on the call — the wrapper inherits model defaults. Callers retain the ability to pass it via the `options` pass-through. + +### Provider configuration + +- The `@ai-sdk/openai` provider is constructed once per call with `createOpenAI({ name: aisdkConfig.providerName, baseURL: aisdkConfig.baseUrl, apiKey: aisdkConfig.apiKey })` and `.chat(aisdkConfig.modelName)` selects the model. +- Model: default and prod target is `claude-sonnet-4-5-20250929-v1:rsn` (Claude Sonnet 4.5 reasoning variant exposed by PX Engine; verified to support both image content parts and `response_format: json_object`). The same model serves both the text-prompt and the vision-prompt MFB flows. +- JSON mode is preserved per-call via `providerOptions: { openai: { responseFormat: { type: 'json_object' } } }`. Downstream `JSON.parse` + zod validation in `admin-form.assistance.service.ts` is unchanged. + +### Rollout + +- Hard swap, no feature flag. Rollback is via revert + redeploy + SSM, consistent with how FormSG handles other LLM/provider configuration today. +- No staged rollout: 100% of MFB users move to Pair Foundry at deploy. + +### Error contract + +- The wrapper continues to surface `ModelGetClientFailureError` (provider/client construction failure) and `ModelResponseFailureError` (request failure or empty response). Returning `null` on missing message content remains the contract for "model responded but produced nothing usable". + +### Review and commit hygiene + +- The PR is split into conventional commits (one logical step per commit: config defaults, wrapper rewrite, prompt rewrite, cleanup, ADR) so that the reviewer can step through it commit-by-commit. This came out of the grilling session and is recorded in the ADR's "Implementation considerations" section. + +### Documentation + +- `docs/adr/0001-pair-foundry-llm-provider.md` records the decision (context, decision points, consequences, implementation considerations). Already written. + +## Testing Decisions + +### What makes a good test here + +Tests should assert observable external behaviour of the `ai-model` wrapper — what messages and options were passed to the underlying ai-sdk call, what errors callers see, what return values they get for happy and degenerate model responses — without coupling to the structure of internal helpers. Mocking happens at the ai-sdk boundary (`generateText`), not deeper. Tests should remain valid if we later swap `generateText` for `generateObject` provided the externally observable contract of `sendPromptToModel` is preserved. + +### Module under test + +- **`ai-model` wrapper** (`sendPromptToModel`). New spec colocated with the existing assistance tests. Coverage targets: + - Happy path: messages and `providerOptions.openai.responseFormat` are forwarded to the mocked ai-sdk call; the text payload from the model is returned to the caller. + - Null/empty response: a model response with no content returns `null` to the caller (matches today's contract used by `admin-form.assistance.service.ts`). + - Client construction failure: provider creation throws → wrapper returns a `ResultAsync` err of `ModelGetClientFailureError`. + - Request failure: ai-sdk `generateText` rejects → wrapper returns a `ResultAsync` err of `ModelResponseFailureError` and logs with the supplied `formId`. + - Options pass-through: caller-supplied options (e.g. `temperature`, `providerOptions`) reach the ai-sdk call, with the wrapper's own response-format option layered correctly. + +### Prior art + +- `apps/backend/src/app/modules/form/admin-form/__tests__/admin-form.assistance.service.spec.ts` is the closest neighbour — it uses `jest.mock` + `jest.mocked(AiModel).sendPromptToModel` to stub the wrapper from the outside. The new wrapper spec mirrors this style but mocks one level deeper (the ai-sdk `generateText`/provider import) instead. + +### Out-of-scope tests + +- No new tests for `admin-form.assistance.service.ts`: existing tests already mock `sendPromptToModel` entirely and remain valid through the swap (the mock surface doesn't care about the new ai-sdk message types). +- No live integration test against `engine.pair.gov.sg` in this PR — env-gated end-to-end coverage against Pair Foundry is a separate decision and would carry credential/CI maintenance cost. + +## Out of Scope + +- **Structured outputs via `generateObject`**: deferred to a follow-up PR. That PR will replace `JSON.parse` + zod safeParse with a schema-enforced `generateObject` call against `suggestedFormFieldsSchema`. +- **Streaming**: today's flows are non-streaming; this PR stays non-streaming. +- **Feature flag / staged rollout**: explicitly rejected in favour of a hard swap. +- **Splitting the LLM model by use case** (separate text vs vision models): single shared model only. +- **Refactoring prompt builders or the response parser** into separate modules: out of scope for this migration PR. +- **Cleanup of deployment-side IaC** (if it lives in a separate repo) referencing `AZURE_OPENAI_*` env vars: handled in a follow-up PR against that repo. The in-repo `.env` examples and docs are cleaned up here. +- **Adding a custom `headers` field to `aisdk.config.ts`** for Pair Foundry tracing or auditing: not needed today. + +## Further Notes + +- Pair Foundry's PX Engine is LiteLLM-backed and exposes an OpenAI-compatible HTTP surface. We deliberately keep the `@ai-sdk/openai` provider — not `@ai-sdk/anthropic` — because the underlying gateway speaks the OpenAI wire protocol regardless of which underlying model (`claude-sonnet-4-5-20250929-v1:rsn` here) is routed to behind it. +- The wrapper's signature (`sendPromptToModel({ messages, options, formId })`) is the single seam between application code and any LLM provider. Future provider swaps stay isolated to `ai-model.ts`. +- The `formId` parameter is logging-only — it flows into log metadata for traceability of MFB calls against forms. This is preserved. +- All env vars on the new path are namespaced `AI_SDK_*` (generic) rather than `PAIR_FOUNDRY_*` (provider-specific), so that pointing the wrapper at a different OpenAI-compatible engine in the future requires only SSM value changes, not code or config-schema changes. diff --git a/docs/adr/0001-pair-foundry-llm-provider.md b/docs/adr/0001-pair-foundry-llm-provider.md new file mode 100644 index 0000000000..b1d186eb17 --- /dev/null +++ b/docs/adr/0001-pair-foundry-llm-provider.md @@ -0,0 +1,24 @@ +# Use Pair Foundry as the LLM provider for Magic Form Builder + +## Context + +Magic Form Builder (MFB) and other in-app LLM features previously called Azure AI Foundry via the `openai` npm package's `AzureOpenAI` client. We are migrating to Pair Foundry's PX Engine (`engine.pair.gov.sg`), the Singapore Government whole-of-government LLM gateway. PX Engine is LiteLLM-backed and OpenAI-compatible. + +## Decision + +- LLM access goes through Pair Foundry's PX Engine, accessed via the Vercel AI SDK (`ai` + `@ai-sdk/openai`'s `createOpenAI` provider pointed at `engine.pair.gov.sg`). +- All connection settings (`providerName`, `apiKey`, `baseUrl`, `modelName`) are parameterised in `apps/backend/src/app/config/features/aisdk.config.ts` and supplied via env vars (sourced from AWS SSM Parameter Store in prod). +- `sendPromptToModel` in `apps/backend/src/app/modules/form/admin-form/ai-model.ts` wraps ai-sdk's `generateText`. Internal types are ai-sdk-native (`CoreMessage`, etc.); callers in `admin-form.assistance.service.ts` build prompts in ai-sdk shape. +- JSON-mode is preserved per-call via `providerOptions.openai.responseFormat`. Downstream code continues to `JSON.parse` and zod-validate the response. +- Default model: `claude-sonnet-4-5-20250929-v1:rsn` (verified to support both image input and JSON mode on PX Engine). Same model serves both text-prompt and vision-prompt flows. +- Hard swap, no feature flag. Azure code (`azureopenai.config.ts`, `openai` npm package, `AZURE_OPENAI_*` env-var references) is removed in the same change. Rollback is via revert + redeploy + SSM. + +## Consequences + +- The application is now coupled to an OpenAI-compatible HTTP surface, but not to any specific provider — the same wrapper can point at any PX-Engine-style endpoint by changing SSM values. +- `temperature` is left unset (inherits model defaults). Reasoning models often ignore it anyway. +- Structured outputs via ai-sdk's `generateObject` (replacing the current `JSON.parse` + zod step) are explicitly deferred to a follow-up PR. This change is behaviour-preserving on the response-handling side. +- No staged rollout: a regression on Pair Foundry affects 100% of MFB users until reverted. + +## Implementation considerations +- Do a review by commit and follow conventional commits to make it easy to review. \ No newline at end of file diff --git a/migrate-ai-model-to-pair-foundry.md b/migrate-ai-model-to-pair-foundry.md new file mode 100644 index 0000000000..3d7e88b66c --- /dev/null +++ b/migrate-ai-model-to-pair-foundry.md @@ -0,0 +1,30 @@ +For magic form builder and other LLM usages in FormSG application, we are currently using Azure AI foundry. Migrate over to Pair foundry https://docs.foundry.pair.gov.sg/getting-started. + +This is from the pair foundry docs: +PX Engine is currently using LiteLLM under the hood. The following are some examples of how you can use your api keys to get started + +npm install --save @ai-sdk/openai +Proceed to then initialise the client with your api key like so: + + + import { createOpenAI } from '@ai-sdk/openai' + const engineProvider = createOpenAI({ + name: 'pair-engine', + baseURL: 'https://engine.pair.gov.sg', + apiKey: "sk-1234567890", + }) + // In this example, we're using the gpt4o:rsn model. + const model = engineProvider.chat('gpt4o:rsn') + // Use the sdk as per normal + const result = streamText({ + model, + temperature: 0, + messages, + }) +It is highly recommended to use vercel's ai sdk. Personally found this to be the fastest way to get things done when prototyping. Using NestJS? See here on how to get started. + +Note: The @ai-sdk/openai provider works with any OpenAI-compatible API. If you prefer, you can also use other AI SDK providers like @ai-sdk/anthropic or @ai-sdk/amazon-bedrock with a custom baseURL pointing to the engine. + +I want the AI settings to be parameterized and configurable via AWS SSM parameter store. + +Shifting to pair foundry allows us to update our model to the latest LLM models which option support structured outputs to reduce flakiness in a subsequent PR. \ No newline at end of file