feat(core-api): wire real gcs upload and vertex llm in dev

This commit is contained in:
zouantchaw
2026-02-24 09:58:22 -05:00
parent d3aec0da0b
commit e733f36d28
7 changed files with 223 additions and 59 deletions

View File

@@ -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,

View File

@@ -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,
},
};
}
}

View File

@@ -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(),
};
}