@fraza/module-ai 0.0.0-stage → 0.4.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -2
  4. package/THIRD-PARTY-NOTICES.md +11 -0
  5. package/changelog.json +459 -0
  6. package/dist/admin/AiActions.client.js +166 -0
  7. package/dist/admin/AiChoices.client.js +67 -0
  8. package/dist/admin/AiInputForm.client.js +57 -0
  9. package/dist/admin/AiPage.client.js +181 -0
  10. package/dist/admin/AiResults.client.js +76 -0
  11. package/dist/admin/AiStudio.client.js +453 -0
  12. package/dist/admin/AiUsageWidget.client.js +28 -0
  13. package/dist/admin/StudioBar.client.js +33 -0
  14. package/dist/admin/StudioCompose.client.js +457 -0
  15. package/dist/admin/StudioComposer.client.js +217 -0
  16. package/dist/admin/StudioFields.client.js +124 -0
  17. package/dist/admin/StudioPlan.client.js +94 -0
  18. package/dist/admin/StudioWorking.client.js +157 -0
  19. package/dist/admin/choices.js +22 -0
  20. package/dist/admin/format.js +19 -0
  21. package/dist/admin/input-form.js +59 -0
  22. package/dist/admin/paths.js +4 -0
  23. package/dist/admin/studio-compose.js +94 -0
  24. package/dist/admin/studio-diff.js +240 -0
  25. package/dist/admin/widgets.js +14 -0
  26. package/dist/admin.js +9 -0
  27. package/dist/api.server.js +155 -0
  28. package/dist/config.server.js +113 -0
  29. package/dist/cron.server.js +26 -0
  30. package/dist/db.server.js +65 -0
  31. package/dist/index.js +41 -0
  32. package/dist/integration.server.js +28 -0
  33. package/dist/migrations.js +4 -0
  34. package/dist/permissions.js +9 -0
  35. package/dist/prompts.server.js +149 -0
  36. package/dist/providers/anthropic.server.js +42 -0
  37. package/dist/providers/chat-completions.server.js +63 -0
  38. package/dist/providers/google.server.js +52 -0
  39. package/dist/providers/http.server.js +98 -0
  40. package/dist/providers/index.server.js +22 -0
  41. package/dist/providers/ollama.server.js +45 -0
  42. package/dist/register-server.js +34 -0
  43. package/dist/results.server.js +74 -0
  44. package/dist/run.server.js +166 -0
  45. package/dist/runs.server.js +106 -0
  46. package/dist/schema.js +190 -0
  47. package/dist/server.js +19 -0
  48. package/dist/source.js +4 -0
  49. package/dist/studio.js +205 -0
  50. package/dist/test-prompt.server.js +24 -0
  51. package/dist/version.js +4 -0
  52. package/dist/widgets.server.js +29 -0
  53. package/package.json +67 -4
@@ -0,0 +1,98 @@
1
+ const MAX_DETAIL = 300;
2
+ class AiProviderError extends Error {
3
+ constructor(code, message, { status = null, detail = null, cause } = {}) {
4
+ super(message, cause ? { cause } : void 0);
5
+ this.name = "AiProviderError";
6
+ this.code = code;
7
+ this.status = status;
8
+ this.detail = detail;
9
+ }
10
+ /** A later attempt may succeed: a durable subscriber retries on these. */
11
+ get retryable() {
12
+ return ["timeout", "unreachable", "rate_limited", "provider"].includes(this.code);
13
+ }
14
+ }
15
+ function trimDetail(text) {
16
+ if (typeof text !== "string" || text === "") return null;
17
+ return text.length > MAX_DETAIL ? `${text.slice(0, MAX_DETAIL)}\u2026` : text;
18
+ }
19
+ function providerMessage(body) {
20
+ if (!body || typeof body !== "object") return null;
21
+ const error = body.error;
22
+ if (typeof error === "string") return error;
23
+ if (error && typeof error.message === "string") return error.message;
24
+ if (typeof body.message === "string") return body.message;
25
+ if (typeof body.detail === "string") return body.detail;
26
+ return null;
27
+ }
28
+ function errorForStatus(status, detail) {
29
+ if (status === 401 || status === 403) {
30
+ return new AiProviderError("auth", "Le fournisseur refuse la cl\xE9 d'API. V\xE9rifier FRAZA_MODULE_AI_API_KEY.", { status, detail });
31
+ }
32
+ if (status === 429) {
33
+ return new AiProviderError("rate_limited", "Le fournisseur limite les appels pour l'instant. R\xE9essayer dans un moment.", { status, detail });
34
+ }
35
+ if (status >= 500) {
36
+ return new AiProviderError("provider", "Le fournisseur n'a pas pu r\xE9pondre. R\xE9essayer plus tard.", { status, detail });
37
+ }
38
+ return new AiProviderError("rejected", "Le fournisseur refuse la demande. V\xE9rifier le mod\xE8le et la configuration.", { status, detail });
39
+ }
40
+ async function readBody(response) {
41
+ const text = await response.text();
42
+ try {
43
+ return { json: JSON.parse(text), text };
44
+ } catch {
45
+ return { json: null, text };
46
+ }
47
+ }
48
+ async function postJson(url, body, { headers = {}, timeoutMs, signal } = {}) {
49
+ const timeout = AbortSignal.timeout(timeoutMs);
50
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
51
+ let response;
52
+ try {
53
+ response = await fetch(url, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json", ...headers },
56
+ body: JSON.stringify(body),
57
+ signal: combined
58
+ });
59
+ } catch (err) {
60
+ if (signal == null ? void 0 : signal.aborted) throw new AiProviderError("cancelled", "L'appel a \xE9t\xE9 annul\xE9.", { cause: err });
61
+ if (timeout.aborted) throw new AiProviderError("timeout", "Le fournisseur n'a pas r\xE9pondu \xE0 temps.", { cause: err });
62
+ throw new AiProviderError("unreachable", "Le fournisseur est injoignable. V\xE9rifier l'adresse et le r\xE9seau.", { cause: err });
63
+ }
64
+ let parsed;
65
+ try {
66
+ parsed = await readBody(response);
67
+ } catch (err) {
68
+ if (timeout.aborted) throw new AiProviderError("timeout", "Le fournisseur n'a pas r\xE9pondu \xE0 temps.", { cause: err });
69
+ throw new AiProviderError("provider", "La r\xE9ponse du fournisseur n'a pas pu \xEAtre lue.", { cause: err });
70
+ }
71
+ if (!response.ok) {
72
+ throw errorForStatus(response.status, trimDetail(providerMessage(parsed.json) ?? parsed.text));
73
+ }
74
+ if (!parsed.json || typeof parsed.json !== "object") {
75
+ throw new AiProviderError("provider", "La r\xE9ponse du fournisseur n'est pas du JSON.", { status: response.status });
76
+ }
77
+ return parsed.json;
78
+ }
79
+ function emptyAnswer() {
80
+ return new AiProviderError("provider", "Le fournisseur a r\xE9pondu sans contenu.");
81
+ }
82
+ function truncatedAnswer() {
83
+ return new AiProviderError("truncated", "La r\xE9ponse a \xE9t\xE9 coup\xE9e : elle d\xE9passe la limite de jetons du prompt.");
84
+ }
85
+ function refusedAnswer(detail = null) {
86
+ return new AiProviderError("refused", "Le mod\xE8le a refus\xE9 de r\xE9pondre \xE0 cette demande.", { detail: trimDetail(detail) });
87
+ }
88
+ function tokens(value) {
89
+ return Number.isInteger(value) && value >= 0 ? value : null;
90
+ }
91
+ export {
92
+ AiProviderError,
93
+ emptyAnswer,
94
+ postJson,
95
+ refusedAnswer,
96
+ tokens,
97
+ truncatedAnswer
98
+ };
@@ -0,0 +1,22 @@
1
+ import { generate as anthropic } from "./anthropic.server.js";
2
+ import { generate as chatCompletions } from "./chat-completions.server.js";
3
+ import { generate as google } from "./google.server.js";
4
+ import { generate as ollama } from "./ollama.server.js";
5
+ const GENERATORS = Object.freeze({
6
+ anthropic,
7
+ openai: chatCompletions,
8
+ mistral: chatCompletions,
9
+ "openai-compatible": chatCompletions,
10
+ google,
11
+ ollama
12
+ });
13
+ function generatorFor(provider) {
14
+ const generate = GENERATORS[provider];
15
+ if (!generate) throw new TypeError(`No AI provider named ${JSON.stringify(provider)}`);
16
+ return generate;
17
+ }
18
+ import { AiProviderError } from "./http.server.js";
19
+ export {
20
+ AiProviderError,
21
+ generatorFor
22
+ };
@@ -0,0 +1,45 @@
1
+ import { emptyAnswer, postJson, tokens, truncatedAnswer } from "./http.server.js";
2
+ function messageOf(message) {
3
+ const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n\n");
4
+ const images = message.content.filter((part) => part.type === "image").map((part) => part.data);
5
+ return { role: message.role, content: text, ...images.length > 0 ? { images } : {} };
6
+ }
7
+ function ollamaRequest({ model, system, messages, schema, maxTokens }) {
8
+ return {
9
+ model,
10
+ stream: false,
11
+ messages: [
12
+ ...system ? [{ role: "system", content: system }] : [],
13
+ ...messages.map(messageOf)
14
+ ],
15
+ format: schema,
16
+ options: { num_predict: maxTokens }
17
+ };
18
+ }
19
+ function ollamaAnswer(body) {
20
+ var _a;
21
+ if (body.done_reason === "length") throw truncatedAnswer();
22
+ const text = (_a = body.message) == null ? void 0 : _a.content;
23
+ if (typeof text !== "string" || text.trim() === "") throw emptyAnswer();
24
+ return {
25
+ text,
26
+ model: typeof body.model === "string" ? body.model : null,
27
+ usage: {
28
+ inputTokens: tokens(body.prompt_eval_count),
29
+ outputTokens: tokens(body.eval_count)
30
+ }
31
+ };
32
+ }
33
+ async function generate(config, request, { signal } = {}) {
34
+ const body = await postJson(`${config.baseUrl}/api/chat`, ollamaRequest({ ...request, model: config.model }), {
35
+ headers: config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {},
36
+ timeoutMs: config.timeoutMs,
37
+ signal
38
+ });
39
+ return ollamaAnswer(body);
40
+ }
41
+ export {
42
+ generate,
43
+ ollamaAnswer,
44
+ ollamaRequest
45
+ };
@@ -0,0 +1,34 @@
1
+ import { registerApiRoutes } from "@fraza/core/api";
2
+ import { registerNavItem, registerPageMeta } from "@fraza/core/features/admin";
3
+ import { registerRateLimitPreset } from "@fraza/core/shared/rate-limit";
4
+ import { routes, RUN_RATE_LIMIT } from "./api.server.js";
5
+ import { isAiEnabled } from "./config.server.js";
6
+ import { registerCronJobs } from "./cron.server.js";
7
+ import { provideAiIntegration } from "./integration.server.js";
8
+ import { AI_PERMISSIONS } from "./permissions.js";
9
+ import { SOURCE } from "./source.js";
10
+ import { defineTestPrompt } from "./test-prompt.server.js";
11
+ import { registerWidgetFetchers } from "./widgets.server.js";
12
+ if (isAiEnabled()) {
13
+ provideAiIntegration();
14
+ defineTestPrompt();
15
+ registerNavItem({
16
+ id: "ai",
17
+ label: "IA",
18
+ icon: "BrainIcon",
19
+ href: "ai",
20
+ sectionId: "platform",
21
+ order: 30,
22
+ permission: AI_PERMISSIONS.VIEW
23
+ }, { source: SOURCE });
24
+ registerPageMeta({
25
+ slug: "ai",
26
+ title: "IA",
27
+ description: "Fournisseur d'IA, prompts, historique des appels et jetons consomm\xE9s",
28
+ permission: AI_PERMISSIONS.VIEW
29
+ }, { source: SOURCE });
30
+ registerRateLimitPreset(RUN_RATE_LIMIT, { maxAttempts: 10, windowMs: 10 * 6e4, blockMs: 10 * 6e4 });
31
+ registerApiRoutes(routes, { source: SOURCE });
32
+ registerWidgetFetchers();
33
+ await registerCronJobs();
34
+ }
@@ -0,0 +1,74 @@
1
+ import { deleteInBatches, query, queryAll, queryOne } from "@fraza/core/database";
2
+ const TARGET_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
3
+ const MAX_ID = 120;
4
+ function checkTarget(target, id) {
5
+ if (typeof target !== "string" || !TARGET_PATTERN.test(target) || target.length > 120) {
6
+ throw new TypeError(`AI results: the target must be lowercase words, e.g. 'medias.media' (got ${JSON.stringify(target)})`);
7
+ }
8
+ const text = typeof id === "number" && Number.isSafeInteger(id) ? String(id) : id;
9
+ if (typeof text !== "string" || text === "" || text.length > MAX_ID) {
10
+ throw new TypeError("AI results: the id must be an integer or a string of at most 120 characters");
11
+ }
12
+ return text;
13
+ }
14
+ function toResult(row) {
15
+ return {
16
+ id: String(row.id),
17
+ target: row.target_type,
18
+ targetId: row.target_id,
19
+ prompt: row.prompt,
20
+ data: row.data,
21
+ model: row.model,
22
+ createdAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at
23
+ };
24
+ }
25
+ async function saveResult({ target, id, prompt, data, model = null }) {
26
+ const targetId = checkTarget(target, id);
27
+ if (typeof prompt !== "string" || prompt === "") throw new TypeError("saveResult: the prompt is required");
28
+ if (!data || typeof data !== "object") throw new TypeError("saveResult: data must be the answer, an object");
29
+ const row = await queryOne(
30
+ `INSERT INTO fraza_ai_result (target_type, target_id, prompt, data, model)
31
+ VALUES ($1, $2, $3, $4::jsonb, $5)
32
+ ON CONFLICT (target_type, target_id, prompt)
33
+ DO UPDATE SET data = EXCLUDED.data, model = EXCLUDED.model, updated_at = now(), dismissed_at = NULL
34
+ RETURNING *`,
35
+ [target, targetId, prompt, JSON.stringify(data), model]
36
+ );
37
+ return toResult(row);
38
+ }
39
+ async function getResults(target, id) {
40
+ const targetId = checkTarget(target, id);
41
+ const rows = await queryAll(
42
+ `SELECT * FROM fraza_ai_result
43
+ WHERE target_type = $1 AND target_id = $2 AND dismissed_at IS NULL
44
+ ORDER BY updated_at DESC`,
45
+ [target, targetId]
46
+ );
47
+ return rows.map(toResult);
48
+ }
49
+ async function dismissResult(resultId) {
50
+ if (!/^\d{1,18}$/.test(String(resultId))) return false;
51
+ const { rowCount } = await query(
52
+ `UPDATE fraza_ai_result SET dismissed_at = now() WHERE id = $1 AND dismissed_at IS NULL`,
53
+ [String(resultId)]
54
+ );
55
+ return rowCount > 0;
56
+ }
57
+ async function deleteResults(target, id) {
58
+ const targetId = checkTarget(target, id);
59
+ const { rowCount } = await query(
60
+ `DELETE FROM fraza_ai_result WHERE target_type = $1 AND target_id = $2`,
61
+ [target, targetId]
62
+ );
63
+ return rowCount;
64
+ }
65
+ async function purgeDismissedResults({ days, signal }) {
66
+ return deleteInBatches("fraza_ai_result", "dismissed_at < now() - make_interval(days => $1)", [days], { signal });
67
+ }
68
+ export {
69
+ deleteResults,
70
+ dismissResult,
71
+ getResults,
72
+ purgeDismissedResults,
73
+ saveResult
74
+ };
@@ -0,0 +1,166 @@
1
+ import { fail } from "@fraza/core/shared/logger";
2
+ import { AiConfigError, getAiConfig, isAiEnabled } from "./config.server.js";
3
+ import { resolvePrompt } from "./prompts.server.js";
4
+ import { AiProviderError, generatorFor } from "./providers/index.server.js";
5
+ import { recordRun } from "./runs.server.js";
6
+ import { checkSchema, normalizeForProvider, validate } from "./schema.js";
7
+ import { hasHtmlField, planOutput, readTurn, studioFollowUp, studioOutput } from "./studio.js";
8
+ const TRIGGERS = /* @__PURE__ */ new Set(["button", "code", "test"]);
9
+ const IMAGE_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
10
+ const MAX_IMAGE_CHARS = 20 * 1024 * 1024;
11
+ function failure(code, message, retryable = false) {
12
+ return { success: false, error: { code, message, retryable } };
13
+ }
14
+ function readParts(built) {
15
+ if (typeof built === "string") return [{ type: "text", text: built }];
16
+ if (!Array.isArray(built) || built.length === 0) throw new TypeError("build() must return a string or an array of parts");
17
+ return built.map((part, index) => {
18
+ if ((part == null ? void 0 : part.type) === "text" && typeof part.text === "string") return { type: "text", text: part.text };
19
+ if ((part == null ? void 0 : part.type) === "image") {
20
+ if (!IMAGE_TYPES.has(part.mimeType)) throw new TypeError(`build(): part ${index} is an image of an unsupported type (${part.mimeType})`);
21
+ if (typeof part.data !== "string" || part.data === "" || part.data.length > MAX_IMAGE_CHARS) {
22
+ throw new TypeError(`build(): part ${index} needs its data in base64, 20 MB at most`);
23
+ }
24
+ return { type: "image", mimeType: part.mimeType, data: part.data };
25
+ }
26
+ throw new TypeError(`build(): part ${index} must be { type: 'text', text } or { type: 'image', data, mimeType }`);
27
+ });
28
+ }
29
+ function parseAnswer(text) {
30
+ const unfenced = text.trim().replace(/^```(?:json)?\s*([\s\S]*?)\s*```$/, "$1");
31
+ try {
32
+ return { value: JSON.parse(unfenced), problems: [] };
33
+ } catch {
34
+ return { value: null, problems: ["$: the answer is not valid JSON"] };
35
+ }
36
+ }
37
+ function addUsage(total, usage) {
38
+ const add = (a, b) => a === null && b === null ? null : (a ?? 0) + (b ?? 0);
39
+ return {
40
+ inputTokens: add(total.inputTokens, usage.inputTokens),
41
+ outputTokens: add(total.outputTokens, usage.outputTokens)
42
+ };
43
+ }
44
+ function retryMessage(problems) {
45
+ return {
46
+ role: "user",
47
+ content: [{
48
+ type: "text",
49
+ text: `Your answer does not match the expected JSON schema:
50
+ - ${problems.slice(0, 20).join("\n- ")}
51
+ Answer again with the corrected JSON only.`
52
+ }]
53
+ };
54
+ }
55
+ async function callUntilValid(config, request, schema, signal) {
56
+ const generate = generatorFor(config.provider);
57
+ let messages = request.messages;
58
+ let usage = { inputTokens: null, outputTokens: null };
59
+ let model = null;
60
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
61
+ const answer = await generate(config, { ...request, messages }, { signal });
62
+ usage = addUsage(usage, answer.usage);
63
+ model = answer.model ?? model;
64
+ const { value, problems } = parseAnswer(answer.text);
65
+ const issues = problems.length > 0 ? problems : validate(schema, value);
66
+ if (issues.length === 0) return { data: value, usage, model, attempts: attempt };
67
+ if (attempt === 2) return { data: null, usage, model, attempts: attempt, problems: issues };
68
+ messages = [...messages, { role: "assistant", content: [{ type: "text", text: answer.text }] }, retryMessage(issues)];
69
+ }
70
+ throw new Error("unreachable");
71
+ }
72
+ async function writeHistory(run) {
73
+ try {
74
+ await recordRun(run);
75
+ } catch (err) {
76
+ fail(`AI: the call of ${run.prompt} could not be written to the history`, err);
77
+ }
78
+ }
79
+ async function outputSchemaOf(prompt, scope) {
80
+ const schema = typeof prompt.output === "function" ? await prompt.output(scope) : prompt.output;
81
+ if (schema === null) return null;
82
+ checkSchema(schema, `${prompt.name}.output`);
83
+ return schema;
84
+ }
85
+ async function prepare(prompt, { input, context, scope, turn }) {
86
+ if (prompt.input) {
87
+ const problems = validate(prompt.input, input ?? null);
88
+ if (problems.length > 0) return { failure: failure("invalid_input", "Les donn\xE9es envoy\xE9es au prompt sont invalides.") };
89
+ }
90
+ try {
91
+ const schema = await outputSchemaOf(prompt, scope);
92
+ if (schema === null) return { failure: failure("not_applicable", "Ce prompt ne s'applique pas \xE0 ce contenu.") };
93
+ const parts = readParts(await prompt.build({ input: input ?? null, context: context ?? null, scope, instructions: prompt.instructions }));
94
+ if (prompt.mode === "studio") {
95
+ if (turn.phase === "plan" && !hasHtmlField(schema)) {
96
+ return { failure: failure("invalid_input", "Ce prompt n'a pas de contenu \xE0 structurer : pas de plan.") };
97
+ }
98
+ const answer = turn.phase === "plan" ? planOutput() : studioOutput(schema, turn.variants);
99
+ return { schema: answer, parts: [...parts, { type: "text", text: studioFollowUp(turn) }] };
100
+ }
101
+ return { schema, parts };
102
+ } catch (err) {
103
+ fail(`AI: the prompt ${prompt.name} could not be prepared`, err);
104
+ return { failure: failure("prompt_error", "Le prompt n'a pas pu \xEAtre pr\xE9par\xE9. Le d\xE9tail est dans le journal du serveur.") };
105
+ }
106
+ }
107
+ async function runPrompt(name, { input, context, scope = {}, turn, userId = null, trigger = "code", signal } = {}) {
108
+ if (!isAiEnabled()) return failure("not_configured", "Le module IA est d\xE9sactiv\xE9 : FRAZA_MODULE_AI ne vaut pas true.");
109
+ const prompt = resolvePrompt(name);
110
+ if (!prompt) return failure("unknown_prompt", "Ce prompt n'existe pas ou a \xE9t\xE9 d\xE9sactiv\xE9.");
111
+ let config;
112
+ try {
113
+ config = getAiConfig();
114
+ } catch (err) {
115
+ if (err instanceof AiConfigError) return failure("not_configured", `L'IA n'est pas configur\xE9e : ${err.message}`);
116
+ throw err;
117
+ }
118
+ const checkedTurn = prompt.mode === "studio" ? readTurn(turn ?? {}) : null;
119
+ if (prompt.mode === "studio" && !checkedTurn) return failure("invalid_input", "Le tour du studio est invalide : asks, previous, variants, phase ou plan.");
120
+ const prepared = await prepare(prompt, { input, context, scope: scope ?? {}, turn: checkedTurn });
121
+ if (prepared.failure) return prepared.failure;
122
+ const request = {
123
+ system: prompt.system,
124
+ messages: [{ role: "user", content: prepared.parts }],
125
+ schema: normalizeForProvider(prepared.schema, config.provider),
126
+ maxTokens: prompt.maxTokens
127
+ };
128
+ const run = {
129
+ prompt: prompt.name,
130
+ source: prompt.source,
131
+ trigger: TRIGGERS.has(trigger) ? trigger : "code",
132
+ provider: config.provider,
133
+ model: config.model,
134
+ userId
135
+ };
136
+ const started = Date.now();
137
+ try {
138
+ const result = await callUntilValid(config, request, prepared.schema, signal);
139
+ const durationMs = Date.now() - started;
140
+ const model = result.model ?? config.model;
141
+ if (result.problems) {
142
+ await writeHistory({
143
+ ...run,
144
+ ...result.usage,
145
+ model,
146
+ durationMs,
147
+ attempts: result.attempts,
148
+ status: "error",
149
+ errorCode: "invalid_answer",
150
+ errorMessage: `R\xE9ponse hors sch\xE9ma : ${result.problems.slice(0, 5).join(" ; ")}`
151
+ });
152
+ return failure("invalid_answer", "La r\xE9ponse de l'IA ne correspond pas \xE0 la forme attendue. R\xE9essayer.", true);
153
+ }
154
+ await writeHistory({ ...run, ...result.usage, model, durationMs, attempts: result.attempts, status: "success" });
155
+ return { success: true, data: result.data, model, usage: result.usage };
156
+ } catch (err) {
157
+ if (!(err instanceof AiProviderError)) throw err;
158
+ const errorMessage = err.detail && err.code !== "refused" ? `${err.message} (${err.detail})` : err.message;
159
+ await writeHistory({ ...run, durationMs: Date.now() - started, status: "error", errorCode: err.code, errorMessage });
160
+ return failure(err.code, err.message, err.retryable);
161
+ }
162
+ }
163
+ export {
164
+ outputSchemaOf,
165
+ runPrompt
166
+ };
@@ -0,0 +1,106 @@
1
+ import { deleteInBatches, query, queryAll, queryOne } from "@fraza/core/database";
2
+ const MAX_ERROR = 500;
3
+ function clip(text, max) {
4
+ if (typeof text !== "string") return null;
5
+ return text.length > max ? `${text.slice(0, max)}\u2026` : text;
6
+ }
7
+ async function recordRun(run) {
8
+ await query(
9
+ `INSERT INTO fraza_ai_run
10
+ (prompt, source, trigger, provider, model, status, error_code, error_message,
11
+ input_tokens, output_tokens, attempts, duration_ms, user_id)
12
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
13
+ [
14
+ clip(run.prompt, 120),
15
+ clip(run.source, 214),
16
+ run.trigger,
17
+ run.provider,
18
+ clip(run.model, 200),
19
+ run.status,
20
+ run.errorCode ?? null,
21
+ clip(run.errorMessage, MAX_ERROR),
22
+ run.inputTokens ?? null,
23
+ run.outputTokens ?? null,
24
+ run.attempts ?? 1,
25
+ run.durationMs ?? null,
26
+ run.userId ?? null
27
+ ]
28
+ );
29
+ }
30
+ function toRun(row) {
31
+ return {
32
+ id: String(row.id),
33
+ prompt: row.prompt,
34
+ source: row.source,
35
+ trigger: row.trigger,
36
+ provider: row.provider,
37
+ model: row.model,
38
+ status: row.status,
39
+ errorCode: row.error_code,
40
+ errorMessage: row.error_message,
41
+ inputTokens: row.input_tokens,
42
+ outputTokens: row.output_tokens,
43
+ attempts: row.attempts,
44
+ durationMs: row.duration_ms,
45
+ createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at
46
+ };
47
+ }
48
+ async function listRuns({ limit, offset, status = null }) {
49
+ const where = status ? "WHERE status = $1" : "";
50
+ const params = status ? [status] : [];
51
+ const total = await queryOne(`SELECT count(*)::int AS n FROM fraza_ai_run ${where}`, params);
52
+ const rows = await queryAll(
53
+ `SELECT * FROM fraza_ai_run ${where} ORDER BY created_at DESC, id DESC
54
+ LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
55
+ [...params, limit, offset]
56
+ );
57
+ return { runs: rows.map(toRun), total: (total == null ? void 0 : total.n) ?? 0 };
58
+ }
59
+ function checkRange(from, to) {
60
+ if (!(from instanceof Date) || Number.isNaN(from.getTime()) || !(to instanceof Date) || Number.isNaN(to.getTime())) {
61
+ throw new TypeError("getUsage: from and to must be dates");
62
+ }
63
+ if (from > to) throw new TypeError("getUsage: from is after to");
64
+ }
65
+ async function getUsage({ from, to }) {
66
+ checkRange(from, to);
67
+ const params = [from, to];
68
+ const range = "created_at >= $1 AND created_at < $2";
69
+ const byTokens = "coalesce(sum(input_tokens), 0) + coalesce(sum(output_tokens), 0) DESC";
70
+ const sums = `count(*)::int AS calls,
71
+ count(*) FILTER (WHERE status = 'error')::int AS errors,
72
+ coalesce(sum(input_tokens), 0)::bigint AS input_tokens,
73
+ coalesce(sum(output_tokens), 0)::bigint AS output_tokens`;
74
+ const [totals, byDay, byPrompt, byModel] = await Promise.all([
75
+ queryOne(`SELECT ${sums} FROM fraza_ai_run WHERE ${range}`, params),
76
+ queryAll(`SELECT to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day, ${sums}
77
+ FROM fraza_ai_run WHERE ${range} GROUP BY day ORDER BY day`, params),
78
+ queryAll(`SELECT prompt, ${sums} FROM fraza_ai_run WHERE ${range}
79
+ GROUP BY prompt ORDER BY ${byTokens}, prompt`, params),
80
+ queryAll(`SELECT provider, model, ${sums} FROM fraza_ai_run WHERE ${range}
81
+ GROUP BY provider, model ORDER BY ${byTokens}, model`, params)
82
+ ]);
83
+ const counts = (row) => ({
84
+ calls: (row == null ? void 0 : row.calls) ?? 0,
85
+ errors: (row == null ? void 0 : row.errors) ?? 0,
86
+ inputTokens: Number((row == null ? void 0 : row.input_tokens) ?? 0),
87
+ outputTokens: Number((row == null ? void 0 : row.output_tokens) ?? 0)
88
+ });
89
+ return {
90
+ from: from.toISOString(),
91
+ to: to.toISOString(),
92
+ totals: counts(totals),
93
+ byDay: byDay.map((row) => ({ day: row.day, ...counts(row) })),
94
+ byPrompt: byPrompt.map((row) => ({ prompt: row.prompt, ...counts(row) })),
95
+ byModel: byModel.map((row) => ({ provider: row.provider, model: row.model, ...counts(row) }))
96
+ };
97
+ }
98
+ async function purgeRuns({ days, signal }) {
99
+ return deleteInBatches("fraza_ai_run", "created_at < now() - make_interval(days => $1)", [days], { signal });
100
+ }
101
+ export {
102
+ getUsage,
103
+ listRuns,
104
+ purgeRuns,
105
+ recordRun
106
+ };