Files
Cibello-app/packages/ai-contracts/src/client.ts
T
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

323 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>";
}
}