323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
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<T extends AamosTaskType> {
|
||
status: "ok" | "uncertain" | "failed";
|
||
output: TaskOutput<T> | 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<T extends AamosTaskType>(
|
||
taskType: T,
|
||
input: TaskInput<T>,
|
||
options?: AamosCallOptions,
|
||
): Promise<AamosResult<T>>;
|
||
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 <AAMOS_API_KEY>
|
||
*/
|
||
export class HttpAamosClient implements AamosClient {
|
||
private readonly cfg: Required<Omit<HttpAamosClientConfig, "fetchImpl">>;
|
||
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<T extends AamosTaskType>(
|
||
taskType: T,
|
||
input: TaskInput<T>,
|
||
options: AamosCallOptions = {},
|
||
): Promise<AamosResult<T>> {
|
||
// 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<T>,
|
||
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<T extends AamosTaskType>(
|
||
taskType: T,
|
||
input: TaskInput<T>,
|
||
_options?: AamosCallOptions,
|
||
): Promise<AamosResult<T>> {
|
||
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<T>;
|
||
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<void> {
|
||
return new Promise((r) => setTimeout(r, ms));
|
||
}
|
||
async function safeText(res: Response): Promise<string> {
|
||
try {
|
||
return (await res.text()).slice(0, 500);
|
||
} catch {
|
||
return "<no body>";
|
||
}
|
||
}
|