From e733f36d286b28bc56d0e7d0407e83b11be7f080 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:58:22 -0500 Subject: [PATCH] feat(core-api): wire real gcs upload and vertex llm in dev --- CHANGELOG.md | 1 + backend/core-api/package-lock.json | 62 +++------------- backend/core-api/package.json | 2 + backend/core-api/src/routes/core.js | 46 ++++++++++-- backend/core-api/src/services/llm.js | 93 ++++++++++++++++++++++++ backend/core-api/src/services/storage.js | 59 +++++++++++++++ makefiles/backend.mk | 19 ++++- 7 files changed, 223 insertions(+), 59 deletions(-) create mode 100644 backend/core-api/src/services/llm.js create mode 100644 backend/core-api/src/services/storage.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 82bec66f..05e74d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,4 @@ | 2026-02-24 | 0.1.6 | Added Cloud SQL-backed idempotency storage, migration script, and command API test coverage. | | 2026-02-24 | 0.1.7 | Added `/health` endpoints and switched smoke checks to `/health` for Cloud Run compatibility. | | 2026-02-24 | 0.1.8 | Enabled dev frontend reachability and made deploy auth mode environment-aware (`dev` public, `staging` private). | +| 2026-02-24 | 0.1.9 | Switched core API from mock behavior to real GCS upload/signed URLs and real Vertex model calls in dev deployment. | diff --git a/backend/core-api/package-lock.json b/backend/core-api/package-lock.json index ba4fc6a6..87370c92 100644 --- a/backend/core-api/package-lock.json +++ b/backend/core-api/package-lock.json @@ -8,8 +8,10 @@ "name": "@krow/core-api", "version": "0.1.0", "dependencies": { + "@google-cloud/storage": "^7.16.0", "express": "^4.21.2", "firebase-admin": "^13.0.2", + "google-auth-library": "^9.15.1", "multer": "^2.0.2", "pino": "^9.6.0", "pino-http": "^10.3.0", @@ -151,7 +153,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", "license": "Apache-2.0", - "optional": true, "dependencies": { "arrify": "^2.0.0", "extend": "^3.0.2" @@ -165,7 +166,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=14.0.0" } @@ -175,7 +175,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=14" } @@ -185,7 +184,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@google-cloud/paginator": "^5.0.0", "@google-cloud/projectify": "^4.0.0", @@ -212,7 +210,6 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -398,7 +395,6 @@ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "license": "MIT", - "optional": true, "engines": { "node": ">= 10" } @@ -407,8 +403,7 @@ "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", @@ -447,7 +442,6 @@ "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", "license": "MIT", - "optional": true, "dependencies": { "@types/caseless": "*", "@types/node": "*", @@ -459,15 +453,13 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", - "optional": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -540,7 +532,6 @@ "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -557,7 +548,6 @@ "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", "license": "MIT", - "optional": true, "dependencies": { "retry": "0.13.1" } @@ -566,7 +556,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "devOptional": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -731,7 +720,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "devOptional": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -821,7 +809,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -876,7 +863,6 @@ "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", "license": "MIT", - "optional": true, "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", @@ -920,7 +906,6 @@ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -959,7 +944,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1001,7 +985,6 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=6" } @@ -1091,7 +1074,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "strnum": "^2.1.2" }, @@ -1160,7 +1142,6 @@ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", "license": "MIT", - "optional": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -1419,7 +1400,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "devOptional": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -1457,8 +1437,7 @@ "url": "https://patreon.com/mdevils" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/http-errors": { "version": "2.0.1", @@ -1491,7 +1470,6 @@ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", "license": "MIT", - "optional": true, "dependencies": { "@tootallnate/once": "2", "agent-base": "6", @@ -1506,7 +1484,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -1519,7 +1496,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -1536,8 +1512,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/https-proxy-agent": { "version": "7.0.6", @@ -1860,7 +1835,6 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", "license": "MIT", - "optional": true, "bin": { "mime": "cli.js" }, @@ -2028,7 +2002,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -2039,7 +2012,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "license": "MIT", - "optional": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -2264,7 +2236,6 @@ "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 4" } @@ -2274,7 +2245,6 @@ "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", "license": "MIT", - "optional": true, "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", @@ -2498,7 +2468,6 @@ "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", "license": "MIT", - "optional": true, "dependencies": { "stubs": "^3.0.0" } @@ -2507,8 +2476,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/streamsearch": { "version": "1.1.0", @@ -2565,15 +2533,13 @@ "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/superagent": { "version": "10.3.0", @@ -2681,7 +2647,6 @@ "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", "license": "Apache-2.0", - "optional": true, "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", @@ -2698,7 +2663,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -2711,7 +2675,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -2729,7 +2692,6 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", - "optional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -2742,8 +2704,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/teeny-request/node_modules/uuid": { "version": "9.0.1", @@ -2754,7 +2715,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -2921,7 +2881,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "devOptional": true, "license": "ISC" }, "node_modules/xtend": { @@ -2983,7 +2942,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "license": "MIT", - "optional": true, "engines": { "node": ">=10" }, diff --git a/backend/core-api/package.json b/backend/core-api/package.json index b287621a..0e9b2f6d 100644 --- a/backend/core-api/package.json +++ b/backend/core-api/package.json @@ -11,8 +11,10 @@ "test": "node --test" }, "dependencies": { + "@google-cloud/storage": "^7.16.0", "express": "^4.21.2", "firebase-admin": "^13.0.2", + "google-auth-library": "^9.15.1", "multer": "^2.0.2", "pino": "^9.6.0", "pino-http": "^10.3.0", diff --git a/backend/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js index 72e1b93c..a753ae22 100644 --- a/backend/core-api/src/routes/core.js +++ b/backend/core-api/src/routes/core.js @@ -5,6 +5,8 @@ import { AppError } from '../lib/errors.js'; import { requireAuth, requirePolicy } from '../middleware/auth.js'; import { createSignedUrlSchema } from '../contracts/core/create-signed-url.js'; import { invokeLlmSchema } from '../contracts/core/invoke-llm.js'; +import { invokeVertexModel } from '../services/llm.js'; +import { generateReadSignedUrl, uploadToGcs } from '../services/storage.js'; const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; const ALLOWED_FILE_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']); @@ -30,6 +32,14 @@ function mockSignedUrl(fileUri, expiresInSeconds) { }; } +function useMockSignedUrl() { + return process.env.SIGNED_URL_MOCK !== 'false'; +} + +function useMockUpload() { + return process.env.UPLOAD_MOCK !== 'false'; +} + function parseBody(schema, body) { const parsed = schema.safeParse(body); if (!parsed.success) { @@ -64,9 +74,19 @@ async function handleUploadFile(req, res, next) { const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); const objectPath = `uploads/${req.actor.uid}/${Date.now()}_${safeName}`; + const fileUri = `gs://${bucket}/${objectPath}`; + + if (!useMockUpload()) { + await uploadToGcs({ + bucket, + objectPath, + contentType: file.mimetype, + buffer: file.buffer, + }); + } res.status(200).json({ - fileUri: `gs://${bucket}/${objectPath}`, + fileUri, contentType: file.mimetype, size: file.size, bucket, @@ -85,8 +105,12 @@ async function handleCreateSignedUrl(req, res, next) { try { const payload = parseBody(createSignedUrlSchema, req.body || {}); const expiresInSeconds = payload.expiresInSeconds || 300; - - const signed = mockSignedUrl(payload.fileUri, expiresInSeconds); + const signed = useMockSignedUrl() + ? mockSignedUrl(payload.fileUri, expiresInSeconds) + : await generateReadSignedUrl({ + fileUri: payload.fileUri, + expiresInSeconds, + }); res.status(200).json({ ...signed, @@ -101,12 +125,22 @@ async function handleInvokeLlm(req, res, next) { try { const payload = parseBody(invokeLlmSchema, req.body || {}); + const startedAt = Date.now(); if (process.env.LLM_MOCK === 'false') { - throw new AppError('MODEL_FAILED', 'Real model integration not wired yet', 501); + const llmResult = await invokeVertexModel({ + prompt: payload.prompt, + responseJsonSchema: payload.responseJsonSchema, + fileUrls: payload.fileUrls, + }); + return res.status(200).json({ + result: llmResult.result, + model: llmResult.model, + latencyMs: Date.now() - startedAt, + requestId: req.requestId, + }); } - const startedAt = Date.now(); - res.status(200).json({ + return res.status(200).json({ result: { summary: 'Mock model response. Replace with Vertex AI integration.', inputPromptSize: payload.prompt.length, diff --git a/backend/core-api/src/services/llm.js b/backend/core-api/src/services/llm.js new file mode 100644 index 00000000..31d8b17e --- /dev/null +++ b/backend/core-api/src/services/llm.js @@ -0,0 +1,93 @@ +import { GoogleAuth } from 'google-auth-library'; +import { AppError } from '../lib/errors.js'; + +function buildVertexConfig() { + const project = process.env.GCP_PROJECT_ID || process.env.GOOGLE_CLOUD_PROJECT; + const location = process.env.LLM_LOCATION || process.env.BACKEND_REGION || 'us-central1'; + if (!project) { + throw new AppError('MODEL_FAILED', 'GCP project is required for model invocation', 500); + } + + return { + project, + location, + }; +} + +function withTimeout(promise, timeoutMs) { + return Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => { + reject(new AppError('MODEL_TIMEOUT', `Model request exceeded ${timeoutMs}ms`, 504)); + }, timeoutMs); + }), + ]); +} + +function toTextFromCandidate(candidate) { + if (!candidate?.content?.parts) { + return ''; + } + return candidate.content.parts + .map((part) => part?.text || '') + .join('') + .trim(); +} + +export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = [] }) { + const { project, location } = buildVertexConfig(); + const model = process.env.LLM_MODEL || 'gemini-2.0-flash-001'; + const timeoutMs = Number.parseInt(process.env.LLM_TIMEOUT_MS || '20000', 10); + const schemaText = JSON.stringify(responseJsonSchema); + const fileContext = fileUrls.length > 0 ? `\nFiles:\n${fileUrls.join('\n')}` : ''; + const instruction = `Respond with strict JSON only. Follow this schema exactly:\n${schemaText}`; + const textPrompt = `${prompt}\n\n${instruction}${fileContext}`; + const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/publishers/google/models/${model}:generateContent`; + const auth = new GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + + let response; + try { + const client = await auth.getClient(); + response = await withTimeout( + client.request({ + url, + method: 'POST', + data: { + contents: [{ role: 'user', parts: [{ text: textPrompt }] }], + generationConfig: { + temperature: 0.2, + responseMimeType: 'application/json', + }, + }, + }), + timeoutMs + ); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + throw new AppError('MODEL_FAILED', 'Model invocation failed', 502); + } + + const text = toTextFromCandidate(response?.data?.candidates?.[0]); + if (!text) { + throw new AppError('MODEL_FAILED', 'Model returned empty response', 502); + } + + try { + return { + model, + result: JSON.parse(text), + }; + } catch { + return { + model, + result: { + raw: text, + }, + }; + } +} diff --git a/backend/core-api/src/services/storage.js b/backend/core-api/src/services/storage.js new file mode 100644 index 00000000..4e4b2f5c --- /dev/null +++ b/backend/core-api/src/services/storage.js @@ -0,0 +1,59 @@ +import { Storage } from '@google-cloud/storage'; +import { AppError } from '../lib/errors.js'; + +const storage = new Storage(); + +function parseGsUri(fileUri) { + if (!fileUri.startsWith('gs://')) { + throw new AppError('VALIDATION_ERROR', 'fileUri must start with gs://', 400); + } + + const raw = fileUri.replace('gs://', ''); + const slashIndex = raw.indexOf('/'); + if (slashIndex <= 0 || slashIndex >= raw.length - 1) { + throw new AppError('VALIDATION_ERROR', 'fileUri must include bucket and object path', 400); + } + + return { + bucket: raw.slice(0, slashIndex), + path: raw.slice(slashIndex + 1), + }; +} + +function allowedBuckets() { + return new Set([ + process.env.PUBLIC_BUCKET || 'krow-workforce-dev-public', + process.env.PRIVATE_BUCKET || 'krow-workforce-dev-private', + ]); +} + +export async function uploadToGcs({ bucket, objectPath, contentType, buffer }) { + const file = storage.bucket(bucket).file(objectPath); + await file.save(buffer, { + resumable: false, + contentType, + metadata: { + cacheControl: 'private, max-age=0', + }, + }); +} + +export async function generateReadSignedUrl({ fileUri, expiresInSeconds }) { + const { bucket, path } = parseGsUri(fileUri); + if (!allowedBuckets().has(bucket)) { + throw new AppError('FORBIDDEN', `Bucket not allowed for signing: ${bucket}`, 403); + } + + const file = storage.bucket(bucket).file(path); + const expiresAtMs = Date.now() + expiresInSeconds * 1000; + const [signedUrl] = await file.getSignedUrl({ + version: 'v4', + action: 'read', + expires: expiresAtMs, + }); + + return { + signedUrl, + expiresAt: new Date(expiresAtMs).toISOString(), + }; +} diff --git a/makefiles/backend.mk b/makefiles/backend.mk index 6706b224..77b9d6f7 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -30,6 +30,7 @@ endif BACKEND_CORE_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/core-api:latest BACKEND_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/command-api:latest BACKEND_LOG_LIMIT ?= 100 +BACKEND_LLM_MODEL ?= gemini-2.0-flash-001 .PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core @@ -54,8 +55,10 @@ backend-enable-apis: secretmanager.googleapis.com \ cloudfunctions.googleapis.com \ eventarc.googleapis.com \ + aiplatform.googleapis.com \ storage.googleapis.com \ iam.googleapis.com \ + iamcredentials.googleapis.com \ serviceusage.googleapis.com \ firebase.googleapis.com; do \ echo " - $$api"; \ @@ -83,6 +86,20 @@ backend-bootstrap-dev: backend-enable-apis else \ echo " - Runtime service account already exists."; \ fi + @echo "--> Ensuring runtime service account IAM roles..." + @gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \ + --member="serviceAccount:$(BACKEND_RUNTIME_SA_EMAIL)" \ + --role="roles/storage.objectAdmin" \ + --quiet >/dev/null + @gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \ + --member="serviceAccount:$(BACKEND_RUNTIME_SA_EMAIL)" \ + --role="roles/aiplatform.user" \ + --quiet >/dev/null + @gcloud iam service-accounts add-iam-policy-binding $(BACKEND_RUNTIME_SA_EMAIL) \ + --member="serviceAccount:$(BACKEND_RUNTIME_SA_EMAIL)" \ + --role="roles/iam.serviceAccountTokenCreator" \ + --project=$(GCP_PROJECT_ID) \ + --quiet >/dev/null @echo "--> Ensuring storage buckets exist..." @if ! gcloud storage buckets describe gs://$(BACKEND_PUBLIC_BUCKET) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ gcloud storage buckets create gs://$(BACKEND_PUBLIC_BUCKET) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID); \ @@ -112,7 +129,7 @@ backend-deploy-core: --region=$(BACKEND_REGION) \ --project=$(GCP_PROJECT_ID) \ --service-account=$(BACKEND_RUNTIME_SA_EMAIL) \ - --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET) \ + --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000 \ $(BACKEND_RUN_AUTH_FLAG) @echo "✅ Core backend service deployed."