import { randomUUID } from "node:crypto"; import { TASK_CONTRACTS, aamosResponseEnvelopeSchema, type AamosRequestEnvelope, type AamosTaskType, type TaskInput, type TaskOutput, } from "./tasks.js"; import { mockOutputFor } from "./mock.js"; import { GeminiAamosClient, type BudgetStore } from "./gemini.js"; import type { LocaleContext } from "@app/shared-types"; export interface AamosCallOptions { correlationId?: string; localeContext?: LocaleContext; subjectRef?: string | null; priority?: "low" | "normal" | "high"; consentFlags?: { personalization: boolean; anonymizedImprovement: boolean; imageTraining: boolean; }; /** Systeminstruktion som skickas med till AAMOS (t.ex. anti-påhitt-regler). */ systemInstruction?: string; } export interface AamosResult { status: "ok" | "uncertain" | "failed"; output: TaskOutput | null; error?: string | undefined; modelVersion?: string | undefined; promptVersion?: string | undefined; latencyMs?: number | undefined; costUsd?: number | undefined; inputTokens?: number | undefined; outputTokens?: number | undefined; totalTokens?: number | undefined; } /** * Klientinterface mot AAMOS. Mobilappen anropar ALDRIG detta – bara * Food API/worker (spec §31, §61.13). */ export interface AamosClient { runTask( taskType: T, input: TaskInput, options?: AamosCallOptions, ): Promise>; healthCheck(): Promise<{ ok: boolean; detail?: string }>; } export interface HttpAamosClientConfig { baseUrl: string; apiKey: string; timeoutMs?: number; maxRetries?: number; fetchImpl?: typeof fetch; } /** * HTTP-klient mot den befintliga AAMOS-plattformen. * * Antaget API (justeras mot AAMOS faktiska dokumentation – endast denna fil * och endpointkonstanterna behöver ändras, inga kontrakt läcker vidare): * POST {baseUrl}/v1/tasks body: AamosRequestEnvelope → AamosResponseEnvelope * GET {baseUrl}/v1/health * Auth: Authorization: Bearer */ export class HttpAamosClient implements AamosClient { private readonly cfg: Required>; private readonly fetchImpl: typeof fetch; constructor(config: HttpAamosClientConfig) { this.cfg = { baseUrl: config.baseUrl.replace(/\/$/, ""), apiKey: config.apiKey, timeoutMs: config.timeoutMs ?? 60_000, maxRetries: config.maxRetries ?? 2, }; this.fetchImpl = config.fetchImpl ?? fetch; } async runTask( taskType: T, input: TaskInput, options: AamosCallOptions = {}, ): Promise> { // Validera input mot kontraktet INNAN nätverksanropet – fail fast. const contract = TASK_CONTRACTS[taskType]; const parsedInput = contract.input.safeParse(input); if (!parsedInput.success) { return { status: "failed", output: null, error: `Kontraktsfel (input) för ${taskType}: ${parsedInput.error.message}`, }; } const envelope: AamosRequestEnvelope = { taskId: randomUUID(), taskType, contractVersion: "1.0.0", input: parsedInput.data, metadata: { correlationId: options.correlationId ?? randomUUID(), localeContext: options.localeContext ?? { languageTag: "sv-SE", regionCode: "SE", timeZone: "Europe/Stockholm", measurementSystem: "METRIC" as const, temperatureUnit: "CELSIUS" as const, currencyCode: "SEK", }, subjectRef: options.subjectRef ?? null, priority: options.priority ?? "normal", consentFlags: options.consentFlags ?? { personalization: true, anonymizedImprovement: false, imageTraining: false, }, ...(options.systemInstruction ? { systemInstruction: options.systemInstruction } : {}), }, }; let lastError = ""; for (let attempt = 0; attempt <= this.cfg.maxRetries; attempt++) { if (attempt > 0) await sleep(500 * 2 ** attempt); try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.cfg.timeoutMs); const res = await this.fetchImpl(`${this.cfg.baseUrl}/v1/tasks`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${this.cfg.apiKey}`, "x-correlation-id": envelope.metadata.correlationId, "x-contract-version": envelope.contractVersion, }, body: JSON.stringify(envelope), signal: controller.signal, }); clearTimeout(timer); if (res.status === 429 || res.status >= 500) { lastError = `AAMOS svarade ${res.status}`; continue; // retry } if (!res.ok) { const body = await safeText(res); return { status: "failed", output: null, error: `AAMOS ${res.status}: ${body}` }; } const json: unknown = await res.json(); const parsed = aamosResponseEnvelopeSchema.safeParse(json); if (!parsed.success) { return { status: "failed", output: null, error: `Ogiltigt svarskuvert från AAMOS: ${parsed.error.message}`, }; } const env = parsed.data; if (env.status === "failed" || env.output == null) { return { status: env.status === "failed" ? "failed" : "uncertain", output: null, error: env.error ?? undefined, modelVersion: env.modelVersion ?? undefined, promptVersion: env.promptVersion ?? undefined, latencyMs: env.latencyMs ?? undefined, costUsd: env.costUsd ?? undefined, }; } // Validera output mot kontraktet – ett brutet kontrakt är ett fel, // aldrig något som tyst släpps vidare till användardata. const parsedOutput = contract.output.safeParse(env.output); if (!parsedOutput.success) { return { status: "failed", output: null, error: `Kontraktsfel (output) för ${taskType}: ${parsedOutput.error.message}`, modelVersion: env.modelVersion ?? undefined, }; } return { status: env.status, output: parsedOutput.data as TaskOutput, modelVersion: env.modelVersion ?? undefined, promptVersion: env.promptVersion ?? undefined, latencyMs: env.latencyMs ?? undefined, costUsd: env.costUsd ?? undefined, }; } catch (err) { lastError = err instanceof Error ? err.message : String(err); } } return { status: "failed", output: null, error: `AAMOS onåbart: ${lastError}` }; } async healthCheck(): Promise<{ ok: boolean; detail?: string }> { try { const res = await this.fetchImpl(`${this.cfg.baseUrl}/v1/health`, { headers: { authorization: `Bearer ${this.cfg.apiKey}` }, signal: AbortSignal.timeout(5_000), }); return { ok: res.ok, detail: `status ${res.status}` }; } catch (err) { return { ok: false, detail: err instanceof Error ? err.message : String(err) }; } } } /** * Deterministisk mock för lokal utveckling och tester (AAMOS_MODE=mock). * Produktionsmiljöer ska ALLTID köra AAMOS_MODE=http. */ export class MockAamosClient implements AamosClient { async runTask( taskType: T, input: TaskInput, _options?: AamosCallOptions, ): Promise> { const contract = TASK_CONTRACTS[taskType]; const parsedInput = contract.input.safeParse(input); if (!parsedInput.success) { return { status: "failed", output: null, error: `Kontraktsfel (input) för ${taskType}: ${parsedInput.error.message}`, }; } const output = mockOutputFor(taskType, parsedInput.data); const validated = contract.output.parse(output) as TaskOutput; return { status: "ok", output: validated, modelVersion: "mock-1.0", promptVersion: "mock", latencyMs: 42, costUsd: 0, }; } async healthCheck(): Promise<{ ok: boolean; detail?: string }> { return { ok: true, detail: "mock" }; } } export interface AamosEnv { AAMOS_MODE?: string | undefined; AAMOS_API_URL?: string | undefined; AAMOS_API_KEY?: string | undefined; AAMOS_TIMEOUT_MS?: string | undefined; GEMINI_API_KEY?: string | undefined; GEMINI_MODEL?: string | undefined; GEMINI_TIMEOUT_MS?: string | undefined; GEMINI_DAILY_BUDGET_USD?: string | undefined; } export interface CreateAamosClientOptions { budgetStore?: BudgetStore; fetchImpl?: typeof fetch; } /** Fabrik: http mot riktiga AAMOS, gemini-lärar-tier, eller mock för dev/test. */ export function createAamosClient( env: AamosEnv = process.env, options: CreateAamosClientOptions = {}, ): AamosClient { const mode = env.AAMOS_MODE ?? "http"; if (mode === "mock") return new MockAamosClient(); if (mode === "gemini") { const apiKey = env.GEMINI_API_KEY; if (!apiKey) { throw new Error( "AAMOS_MODE=gemini kräver GEMINI_API_KEY. " + "Sätt AAMOS_MODE=mock för lokal utveckling utan Gemini-åtkomst.", ); } return new GeminiAamosClient({ apiKey, model: env.GEMINI_MODEL, timeoutMs: env.GEMINI_TIMEOUT_MS ? Number(env.GEMINI_TIMEOUT_MS) : undefined, dailyBudgetUsd: env.GEMINI_DAILY_BUDGET_USD ? Number(env.GEMINI_DAILY_BUDGET_USD) : undefined, budgetStore: options.budgetStore, fetchImpl: options.fetchImpl, }); } const baseUrl = env.AAMOS_API_URL; const apiKey = env.AAMOS_API_KEY; if (!baseUrl || !apiKey) { throw new Error( "AAMOS_MODE=http kräver AAMOS_API_URL och AAMOS_API_KEY. " + "Sätt AAMOS_MODE=mock för lokal utveckling utan AAMOS-åtkomst.", ); } return new HttpAamosClient({ baseUrl, apiKey, timeoutMs: env.AAMOS_TIMEOUT_MS ? Number(env.AAMOS_TIMEOUT_MS) : undefined, fetchImpl: options.fetchImpl, }); } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } async function safeText(res: Response): Promise { try { return (await res.text()).slice(0, 500); } catch { return ""; } }