Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07, B-01..B-07, R-01..R-02, O-03. Security (28 deletions + 10 auth gates): - Delete 28 unauthenticated debug/cursor/firebase/test routes - Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth - Add HMAC-SHA256 signature verification to webhooks/coolify - Switch all admin secret comparisons to timingSafeStringEq Foundations (lib/server/*): - api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit - logger.ts: structured request-scoped logging with turnId - audit-log.ts: writeAuditLog helper + audit_log table - rate-limit.ts: Postgres sliding window rate limiter - coolify-webhook.ts: verifyCoolifySignature - timing-safe.ts: timingSafeStringEq Chat hardening (chat/route.ts): - MAX_TOOL_ROUNDS 15 → 8 (C-01) - Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02) - Add 6-consecutive-tool-call hard-break (C-02) - Mode: respond first, act second prompt block (C-03) - SSE heartbeat every 25s via setInterval (C-04) - Per-tool 45s timeout via Promise.race (C-05) - turnId per-turn UUID for log correlation (C-06) - Recovery fires when roundsSinceText >= 4 (C-07) - SSE plan event on plan_task_add/edit (B-05) Beta features: - invites table + GET/POST /api/invites (P4.8) - invites/[token] validate + redeem (P4.8) - fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1) - fs_project_secrets table + CRUD routes (P6.D2) - lib/integrations/brief-extract.ts (P3.7) Documentation: - app/api/ROUTES.md: full route map with auth + tenant
155 lines
5.1 KiB
TypeScript
155 lines
5.1 KiB
TypeScript
import type { LlmClient, StructuredCallArgs } from '@/lib/ai/llm-client';
|
|
import { GoogleGenAI } from '@google/genai';
|
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
|
|
// Use the new Google GenAI SDK (replacing the deprecated VertexAI SDK)
|
|
// Since Vertex AI gemini-3.1-pro-preview threw a 404 in your region, we use the standard AI Studio endpoint natively.
|
|
const DEFAULT_MODEL = process.env.GEMINI_MODEL || 'gemini-3.1-pro-preview';
|
|
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || '';
|
|
|
|
if (!GOOGLE_API_KEY) {
|
|
console.warn(`[GeminiLlmClient] WARNING: GOOGLE_API_KEY is not set. API calls will fail with a 403 Forbidden.`);
|
|
}
|
|
|
|
const ai = new GoogleGenAI({ apiKey: GOOGLE_API_KEY });
|
|
|
|
class JsonValidationError extends Error {
|
|
constructor(message: string, public readonly rawResponse: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
function extractJsonPayload(raw: string): string {
|
|
const trimmed = raw.trim();
|
|
if (trimmed.startsWith('```')) {
|
|
return trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
export class GeminiLlmClient implements LlmClient {
|
|
private readonly model: string;
|
|
|
|
constructor() {
|
|
this.model = DEFAULT_MODEL;
|
|
console.log(`[GoogleGenAI] Initialized — model: ${this.model}`);
|
|
}
|
|
|
|
async structuredCall<TOutput>(args: StructuredCallArgs<TOutput>): Promise<TOutput> {
|
|
if (args.model !== 'gemini') {
|
|
throw new Error(`GeminiLlmClient only supports model "gemini" (got ${args.model})`);
|
|
}
|
|
|
|
const rawJsonSchema = zodToJsonSchema(args.schema, 'responseSchema') as any;
|
|
let actualSchema: any = rawJsonSchema;
|
|
if (rawJsonSchema.$ref && rawJsonSchema.definitions) {
|
|
const refName = rawJsonSchema.$ref.replace('#/definitions/', '');
|
|
actualSchema = rawJsonSchema.definitions[refName];
|
|
}
|
|
|
|
const convertToGoogleSchema = (schema: any): any => {
|
|
if (!schema || typeof schema !== 'object') return schema;
|
|
const out: any = {};
|
|
if (schema.type) out.type = schema.type.toUpperCase();
|
|
if (schema.properties) {
|
|
out.properties = {};
|
|
for (const [k, v] of Object.entries(schema.properties)) {
|
|
out.properties[k] = convertToGoogleSchema(v);
|
|
}
|
|
}
|
|
if (schema.items) out.items = convertToGoogleSchema(schema.items);
|
|
if (schema.required) out.required = schema.required;
|
|
if (schema.description) out.description = schema.description;
|
|
if (schema.enum) out.enum = schema.enum;
|
|
return out;
|
|
};
|
|
|
|
const googleSchema = convertToGoogleSchema(actualSchema);
|
|
|
|
const contents = args.messages.map((m) => ({
|
|
role: m.role === 'assistant' ? 'model' : 'user',
|
|
parts: [{ text: m.content }],
|
|
}));
|
|
|
|
const config: any = {
|
|
temperature: args.temperature ?? 1.0,
|
|
responseMimeType: 'application/json',
|
|
responseSchema: googleSchema,
|
|
maxOutputTokens: 8192,
|
|
};
|
|
|
|
if (args.systemPrompt) {
|
|
const exampleJson: any = {};
|
|
for (const key of Object.keys(googleSchema.properties || {})) {
|
|
exampleJson[key] = key === 'reply' ? 'Your response here' : null;
|
|
}
|
|
config.systemInstruction = `${args.systemPrompt}\n\nIMPERATIVE: Respond ONLY with this exact JSON format:\n${JSON.stringify(exampleJson)}\n\nDo NOT add any other fields.`;
|
|
}
|
|
|
|
if (args.thinking_config) {
|
|
config.thinkingConfig = {
|
|
thinkingBudgetTokens: 1024,
|
|
};
|
|
}
|
|
|
|
const run = async (currentContents: any[]) => {
|
|
console.log(`[GoogleGenAI] generateContent with ${this.model}`);
|
|
|
|
const response = await ai.models.generateContent({
|
|
model: this.model,
|
|
contents: currentContents,
|
|
config,
|
|
});
|
|
|
|
const text = response.text || '';
|
|
|
|
if (text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) {
|
|
throw new Error('GoogleGenAI returned HTML. Check API key permissions.');
|
|
}
|
|
if (!text) {
|
|
throw new Error('Empty response from GoogleGenAI');
|
|
}
|
|
|
|
console.log('[GoogleGenAI] Response preview:', text.slice(0, 200));
|
|
|
|
const cleaned = extractJsonPayload(text);
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(cleaned);
|
|
} catch (error) {
|
|
throw new JsonValidationError(
|
|
`Failed to parse JSON: ${(error as Error).message}`,
|
|
text,
|
|
);
|
|
}
|
|
|
|
const validation = args.schema.safeParse(parsed);
|
|
if (!validation.success) {
|
|
console.error('[GoogleGenAI] Schema validation failed:', validation.error.errors);
|
|
throw new JsonValidationError(validation.error.message, text);
|
|
}
|
|
|
|
return validation.data;
|
|
};
|
|
|
|
try {
|
|
return await run(contents);
|
|
} catch (error) {
|
|
if (!(error instanceof JsonValidationError)) throw error;
|
|
|
|
console.warn(`[GoogleGenAI] JSON Validation failed. Retrying...`);
|
|
const retryContents = [
|
|
...contents,
|
|
{
|
|
role: 'user',
|
|
parts: [{
|
|
text: `Your previous response was not valid JSON. Error: ${error.message}\nRespond again with ONLY valid JSON matching the schema. No code fences or comments.`,
|
|
}],
|
|
},
|
|
];
|
|
return run(retryContents);
|
|
}
|
|
}
|
|
}
|