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

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