@things-factory/ai-client-base 10.1.129 → 10.1.155
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.
- package/config/config.development.js +2 -68
- package/config/config.production.js +2 -20
- package/dist-server/service/agent/agent-directory.d.ts +71 -0
- package/dist-server/service/agent/agent-directory.js +129 -0
- package/dist-server/service/agent/agent-directory.js.map +1 -0
- package/dist-server/service/agent/agent-settings-save.d.ts +38 -0
- package/dist-server/service/agent/agent-settings-save.js +153 -0
- package/dist-server/service/agent/agent-settings-save.js.map +1 -0
- package/dist-server/service/agent/agent-spec.d.ts +43 -0
- package/dist-server/service/agent/agent-spec.js +83 -0
- package/dist-server/service/agent/agent-spec.js.map +1 -0
- package/dist-server/service/agent/ai-client-for.d.ts +53 -0
- package/dist-server/service/agent/ai-client-for.js +166 -0
- package/dist-server/service/agent/ai-client-for.js.map +1 -0
- package/dist-server/service/agent/ai-usage-summary.d.ts +37 -0
- package/dist-server/service/agent/ai-usage-summary.js +108 -0
- package/dist-server/service/agent/ai-usage-summary.js.map +1 -0
- package/dist-server/service/agent/ai-usage.d.ts +35 -0
- package/dist-server/service/agent/ai-usage.js +118 -0
- package/dist-server/service/agent/ai-usage.js.map +1 -0
- package/dist-server/service/agent/index.d.ts +7 -0
- package/dist-server/service/agent/index.js +13 -0
- package/dist-server/service/agent/index.js.map +1 -0
- package/dist-server/service/agent/provider-models.d.ts +12 -0
- package/dist-server/service/agent/provider-models.js +37 -0
- package/dist-server/service/agent/provider-models.js.map +1 -0
- package/dist-server/service/capability.d.ts +21 -10
- package/dist-server/service/capability.js +20 -49
- package/dist-server/service/capability.js.map +1 -1
- package/dist-server/service/factory.d.ts +7 -12
- package/dist-server/service/factory.js +0 -45
- package/dist-server/service/factory.js.map +1 -1
- package/dist-server/service/index.d.ts +3 -0
- package/dist-server/service/index.js +6 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/providers/anthropic.d.ts +2 -0
- package/dist-server/service/providers/anthropic.js +16 -0
- package/dist-server/service/providers/anthropic.js.map +1 -1
- package/dist-server/service/providers/gemini.d.ts +13 -0
- package/dist-server/service/providers/gemini.js +52 -12
- package/dist-server/service/providers/gemini.js.map +1 -1
- package/dist-server/service/providers/openai.d.ts +6 -0
- package/dist-server/service/providers/openai.js +34 -8
- package/dist-server/service/providers/openai.js.map +1 -1
- package/dist-server/service/types.d.ts +16 -2
- package/dist-server/service/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -3
- package/server/service/agent/agent-directory.test.ts +206 -0
- package/server/service/agent/agent-directory.ts +198 -0
- package/server/service/agent/agent-settings-save.test.ts +151 -0
- package/server/service/agent/agent-settings-save.ts +205 -0
- package/server/service/agent/agent-spec.ts +103 -0
- package/server/service/agent/ai-client-for.test.ts +141 -0
- package/server/service/agent/ai-client-for.ts +201 -0
- package/server/service/agent/ai-usage-summary.test.ts +65 -0
- package/server/service/agent/ai-usage-summary.ts +146 -0
- package/server/service/agent/ai-usage.ts +97 -0
- package/server/service/agent/index.ts +7 -0
- package/server/service/agent/one-door.test.ts +57 -0
- package/server/service/agent/provider-models.ts +43 -0
- package/server/service/capability.test.ts +26 -55
- package/server/service/capability.ts +25 -42
- package/server/service/factory.ts +4 -44
- package/server/service/index.ts +6 -1
- package/server/service/providers/anthropic.ts +17 -0
- package/server/service/providers/gemini.ts +54 -12
- package/server/service/providers/openai-models.test.ts +30 -0
- package/server/service/providers/openai.ts +34 -8
- package/server/service/types.ts +18 -2
- package/translations/en.json +13 -0
- package/translations/es.json +13 -0
- package/translations/ja.json +13 -0
- package/translations/ko.json +13 -0
- package/translations/ms.json +13 -0
- package/translations/zh.json +13 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* An AI agent - a provider, a model and a key that together can answer (design/ai/agent-settings.md §3).
|
|
4
|
+
*
|
|
5
|
+
* A domain keeps its agents in one Setting row (`ai-agents`) and each key in an EnvVar of its own, so the
|
|
6
|
+
* definition and the secret live where the framework already keeps domain settings and secrets (ADR-0112).
|
|
7
|
+
* This file is the shape and its parsing - no database.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.AI_AGENT_KEY_PREFIX = exports.AI_AGENTS_SETTING_CATEGORY = exports.AI_AGENTS_SETTING = exports.AI_AGENT_PROVIDERS = void 0;
|
|
11
|
+
exports.aiAgentKeyName = aiAgentKeyName;
|
|
12
|
+
exports.isAiAgentProvider = isAiAgentProvider;
|
|
13
|
+
exports.parseAiAgentsSetting = parseAiAgentsSetting;
|
|
14
|
+
exports.stringifyAiAgentsSetting = stringifyAiAgentsSetting;
|
|
15
|
+
/** The providers an agent may name. Three, by decision (2026-10-12). */
|
|
16
|
+
exports.AI_AGENT_PROVIDERS = ['openai', 'anthropic', 'gemini'];
|
|
17
|
+
/** The Setting row that holds a domain's agents. */
|
|
18
|
+
exports.AI_AGENTS_SETTING = 'ai-agents';
|
|
19
|
+
exports.AI_AGENTS_SETTING_CATEGORY = 'ai';
|
|
20
|
+
/** The EnvVar that holds an agent's key. Named by the agent id, so renaming an agent keeps its key. */
|
|
21
|
+
function aiAgentKeyName(agentId) {
|
|
22
|
+
return `AiAgent::${agentId}::apiKey`;
|
|
23
|
+
}
|
|
24
|
+
exports.AI_AGENT_KEY_PREFIX = 'AiAgent::';
|
|
25
|
+
function isAiAgentProvider(value) {
|
|
26
|
+
return typeof value === 'string' && exports.AI_AGENT_PROVIDERS.includes(value);
|
|
27
|
+
}
|
|
28
|
+
const EMPTY = { agents: [] };
|
|
29
|
+
/**
|
|
30
|
+
* Reads a stored `ai-agents` value. The row can also be written through the generic setting doors, so a value that
|
|
31
|
+
* does not parse reads as no agents, and an entry without an id, a name, a known provider or a model is left out -
|
|
32
|
+
* a broken row never becomes an agent that fails at the provider.
|
|
33
|
+
*/
|
|
34
|
+
function parseAiAgentsSetting(value) {
|
|
35
|
+
if (!value)
|
|
36
|
+
return EMPTY;
|
|
37
|
+
let raw;
|
|
38
|
+
try {
|
|
39
|
+
raw = JSON.parse(value);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return EMPTY;
|
|
43
|
+
}
|
|
44
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.agents))
|
|
45
|
+
return EMPTY;
|
|
46
|
+
const agents = raw.agents.flatMap((one) => {
|
|
47
|
+
if (!one || typeof one !== 'object')
|
|
48
|
+
return [];
|
|
49
|
+
const id = text(one.id);
|
|
50
|
+
const name = text(one.name);
|
|
51
|
+
const model = text(one.model);
|
|
52
|
+
if (!id || !name || !model || !isAiAgentProvider(one.provider))
|
|
53
|
+
return [];
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
id,
|
|
57
|
+
name,
|
|
58
|
+
provider: one.provider,
|
|
59
|
+
model,
|
|
60
|
+
...(text(one.baseURL) && { baseURL: text(one.baseURL) }),
|
|
61
|
+
...(one.provider === 'openai' && text(one.organization) && { organization: text(one.organization) }),
|
|
62
|
+
active: one.active !== false
|
|
63
|
+
}
|
|
64
|
+
];
|
|
65
|
+
});
|
|
66
|
+
const turnedOff = Array.isArray(raw.turnedOff) ? raw.turnedOff.filter((id) => typeof id === 'string') : [];
|
|
67
|
+
return {
|
|
68
|
+
agents,
|
|
69
|
+
...(text(raw.defaultAgentId) && { defaultAgentId: text(raw.defaultAgentId) }),
|
|
70
|
+
...(turnedOff.length && { turnedOff })
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function stringifyAiAgentsSetting(setting) {
|
|
74
|
+
return JSON.stringify({
|
|
75
|
+
agents: setting.agents,
|
|
76
|
+
...(setting.defaultAgentId && { defaultAgentId: setting.defaultAgentId }),
|
|
77
|
+
...(setting.turnedOff?.length && { turnedOff: setting.turnedOff })
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function text(value) {
|
|
81
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=agent-spec.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-spec.js","sourceRoot":"","sources":["../../../server/service/agent/agent-spec.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * An AI agent - a provider, a model and a key that together can answer (design/ai/agent-settings.md §3).\n *\n * A domain keeps its agents in one Setting row (`ai-agents`) and each key in an EnvVar of its own, so the\n * definition and the secret live where the framework already keeps domain settings and secrets (ADR-0112).\n * This file is the shape and its parsing - no database.\n */\n\n/** The providers an agent may name. Three, by decision (2026-10-12). */\nexport const AI_AGENT_PROVIDERS = ['openai', 'anthropic', 'gemini'] as const\nexport type AiAgentProvider = (typeof AI_AGENT_PROVIDERS)[number]\n\n/** The Setting row that holds a domain's agents. */\nexport const AI_AGENTS_SETTING = 'ai-agents'\nexport const AI_AGENTS_SETTING_CATEGORY = 'ai'\n\nexport interface AiAgentSpec {\n id: string\n name: string\n provider: AiAgentProvider\n model: string\n baseURL?: string\n /** OpenAI only. */\n organization?: string\n /** Off takes the agent out of this domain and every domain below it. */\n active: boolean\n}\n\n/** What the `ai-agents` Setting row holds for one domain. */\nexport interface AiAgentsSetting {\n agents: AiAgentSpec[]\n /** This domain's default - one of its own agents or an inherited one. */\n defaultAgentId?: string\n /** Inherited agents this domain does not use. */\n turnedOff?: string[]\n}\n\n/** The EnvVar that holds an agent's key. Named by the agent id, so renaming an agent keeps its key. */\nexport function aiAgentKeyName(agentId: string): string {\n return `AiAgent::${agentId}::apiKey`\n}\n\nexport const AI_AGENT_KEY_PREFIX = 'AiAgent::'\n\nexport function isAiAgentProvider(value: unknown): value is AiAgentProvider {\n return typeof value === 'string' && (AI_AGENT_PROVIDERS as readonly string[]).includes(value)\n}\n\nconst EMPTY: AiAgentsSetting = { agents: [] }\n\n/**\n * Reads a stored `ai-agents` value. The row can also be written through the generic setting doors, so a value that\n * does not parse reads as no agents, and an entry without an id, a name, a known provider or a model is left out -\n * a broken row never becomes an agent that fails at the provider.\n */\nexport function parseAiAgentsSetting(value: string | null | undefined): AiAgentsSetting {\n if (!value) return EMPTY\n let raw: any\n try {\n raw = JSON.parse(value)\n } catch {\n return EMPTY\n }\n if (!raw || typeof raw !== 'object' || !Array.isArray(raw.agents)) return EMPTY\n\n const agents: AiAgentSpec[] = raw.agents.flatMap((one: any) => {\n if (!one || typeof one !== 'object') return []\n const id = text(one.id)\n const name = text(one.name)\n const model = text(one.model)\n if (!id || !name || !model || !isAiAgentProvider(one.provider)) return []\n return [\n {\n id,\n name,\n provider: one.provider,\n model,\n ...(text(one.baseURL) && { baseURL: text(one.baseURL) }),\n ...(one.provider === 'openai' && text(one.organization) && { organization: text(one.organization) }),\n active: one.active !== false\n }\n ]\n })\n\n const turnedOff = Array.isArray(raw.turnedOff) ? raw.turnedOff.filter((id: unknown) => typeof id === 'string') : []\n return {\n agents,\n ...(text(raw.defaultAgentId) && { defaultAgentId: text(raw.defaultAgentId) }),\n ...(turnedOff.length && { turnedOff })\n }\n}\n\nexport function stringifyAiAgentsSetting(setting: AiAgentsSetting): string {\n return JSON.stringify({\n agents: setting.agents,\n ...(setting.defaultAgentId && { defaultAgentId: setting.defaultAgentId }),\n ...(setting.turnedOff?.length && { turnedOff: setting.turnedOff })\n })\n}\n\nfunction text(value: unknown): string {\n return typeof value === 'string' ? value.trim() : ''\n}\n"]}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Domain } from '@things-factory/shell';
|
|
2
|
+
import type { AIClient } from '../types.js';
|
|
3
|
+
import { type AiAgentEntry } from './agent-directory.js';
|
|
4
|
+
/**
|
|
5
|
+
* **The one door to a model** (design/ai/agent-settings.md §4).
|
|
6
|
+
*
|
|
7
|
+
* Every call that reaches a provider gets its client here, for a domain: the agent is the one asked for, or the
|
|
8
|
+
* domain's default, and its key is the closest one on the domain chain. There is no deployment-wide client - a domain
|
|
9
|
+
* with no usable agent has no AI (user decision 2026-10-12).
|
|
10
|
+
*
|
|
11
|
+
* The client handed back is metered: each provider request writes one AiUsage row with the domain, the person, the
|
|
12
|
+
* agent and what was used (ADR-0061 decision 6).
|
|
13
|
+
*/
|
|
14
|
+
export interface AiClientRequest {
|
|
15
|
+
domain: Domain;
|
|
16
|
+
/** The person calling. Empty for a call with no person, such as a webhook. */
|
|
17
|
+
user?: {
|
|
18
|
+
id: string;
|
|
19
|
+
} | null;
|
|
20
|
+
/** The agent the person picked. Empty takes the domain's default. */
|
|
21
|
+
agentId?: string | null;
|
|
22
|
+
/** The conversation mode, when the call is part of a conversation. */
|
|
23
|
+
modeKind?: string | null;
|
|
24
|
+
/** The conversation, by id. */
|
|
25
|
+
sessionId?: string | null;
|
|
26
|
+
}
|
|
27
|
+
/** The client of one agent, as a call got it. */
|
|
28
|
+
export interface AgentClient extends AIClient {
|
|
29
|
+
readonly agent: {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
provider: string;
|
|
33
|
+
model: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export declare function aiClientFor(request: AiClientRequest): Promise<AgentClient>;
|
|
37
|
+
/**
|
|
38
|
+
* The agent a request is answered by. A named agent that this domain cannot use is refused, never swapped for
|
|
39
|
+
* another; no agent at all is "not configured".
|
|
40
|
+
*/
|
|
41
|
+
export declare function pickAgent(entries: AiAgentEntry[], agentId: string | null | undefined, defaultAgentId?: string): AiAgentEntry;
|
|
42
|
+
/** The UTC hour a moment falls in. */
|
|
43
|
+
export declare function hourOf(moment: Date): Date;
|
|
44
|
+
/** Why a provider request failed, as the usage row keeps it. */
|
|
45
|
+
export declare function outcomeOf(error: any): string;
|
|
46
|
+
type TestClientResolver = (request: AiClientRequest) => AgentClient;
|
|
47
|
+
export declare function isAIClientForTesting(): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Tests answer aiClientFor with a client of their own - no database, no provider, no usage rows. A client is wrapped
|
|
50
|
+
* as an agent named after it; a function decides per request. `undefined` puts the real door back.
|
|
51
|
+
*/
|
|
52
|
+
export declare function setAIClientForTesting(client: AIClient | TestClientResolver | undefined): void;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.aiClientFor = aiClientFor;
|
|
4
|
+
exports.pickAgent = pickAgent;
|
|
5
|
+
exports.hourOf = hourOf;
|
|
6
|
+
exports.outcomeOf = outcomeOf;
|
|
7
|
+
exports.isAIClientForTesting = isAIClientForTesting;
|
|
8
|
+
exports.setAIClientForTesting = setAIClientForTesting;
|
|
9
|
+
const shell_1 = require("@things-factory/shell");
|
|
10
|
+
const factory_js_1 = require("../factory.js");
|
|
11
|
+
const agent_directory_js_1 = require("./agent-directory.js");
|
|
12
|
+
async function aiClientFor(request) {
|
|
13
|
+
if (_testing)
|
|
14
|
+
return _testing(request);
|
|
15
|
+
if (!request.domain)
|
|
16
|
+
throw new Error('aiClientFor needs the domain the call is made in');
|
|
17
|
+
const directory = await (0, agent_directory_js_1.readAiAgentDirectory)(request.domain);
|
|
18
|
+
const entry = pickAgent(directory.entries, request.agentId, directory.defaultAgentId);
|
|
19
|
+
return meter(providerClientOf(entry), entry, request);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The agent a request is answered by. A named agent that this domain cannot use is refused, never swapped for
|
|
23
|
+
* another; no agent at all is "not configured".
|
|
24
|
+
*/
|
|
25
|
+
function pickAgent(entries, agentId, defaultAgentId) {
|
|
26
|
+
if (agentId) {
|
|
27
|
+
const named = entries.find(one => one.spec.id === agentId);
|
|
28
|
+
if (!named?.usable) {
|
|
29
|
+
throw new shell_1.Refusal('ai-agent-not-available', { agentId }, 'The AI agent you picked is not available here.');
|
|
30
|
+
}
|
|
31
|
+
return named;
|
|
32
|
+
}
|
|
33
|
+
const fallback = entries.find(one => one.spec.id === defaultAgentId && one.usable);
|
|
34
|
+
if (!fallback) {
|
|
35
|
+
throw new shell_1.Refusal('ai-not-configured', {}, 'AI is not set up for this domain.');
|
|
36
|
+
}
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
/* Provider clients are kept per agent and rebuilt when its definition or its key changes. */
|
|
40
|
+
const _clients = new Map();
|
|
41
|
+
function providerClientOf(entry) {
|
|
42
|
+
const { spec } = entry;
|
|
43
|
+
const version = JSON.stringify([spec.provider, spec.model, spec.baseURL, spec.organization, entry.keyVersion]);
|
|
44
|
+
const cached = _clients.get(spec.id);
|
|
45
|
+
if (cached?.version === version)
|
|
46
|
+
return cached.client;
|
|
47
|
+
const client = (0, factory_js_1.createAIClient)({
|
|
48
|
+
provider: spec.provider,
|
|
49
|
+
model: spec.model,
|
|
50
|
+
apiKey: entry.apiKey,
|
|
51
|
+
...(spec.baseURL && { baseURL: spec.baseURL }),
|
|
52
|
+
...(spec.organization && { organization: spec.organization })
|
|
53
|
+
});
|
|
54
|
+
_clients.set(spec.id, { version, client });
|
|
55
|
+
return client;
|
|
56
|
+
}
|
|
57
|
+
function meter(client, entry, request) {
|
|
58
|
+
const { spec } = entry;
|
|
59
|
+
async function measured(operation, options, call) {
|
|
60
|
+
const started = new Date();
|
|
61
|
+
const reported = [];
|
|
62
|
+
const withUsage = {
|
|
63
|
+
...options,
|
|
64
|
+
onUsage: usage => {
|
|
65
|
+
reported.push(usage);
|
|
66
|
+
options?.onUsage?.(usage);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
const result = await call(withUsage);
|
|
71
|
+
record(operation, started, reported, 'ok');
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
record(operation, started, reported, outcomeOf(error));
|
|
76
|
+
/* A failure must say why somewhere: the loop above turns it into a sentence for the person, and the usage row
|
|
77
|
+
keeps only its code. The provider's own message names no key. */
|
|
78
|
+
console.error(`[ai-client-base] ${spec.provider}:${spec.model} (${spec.name}) ${operation} failed:`, error?.status ?? '', error?.message ?? error);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function record(operation, started, reported, outcome) {
|
|
83
|
+
const durationMs = Date.now() - started.valueOf();
|
|
84
|
+
/* A failed request reports nothing, and is still one row - a failure costs time and often money. */
|
|
85
|
+
const rows = (reported.length ? reported : [{ model: spec.model }]).map((usage, index, all) => ({
|
|
86
|
+
domain: { id: request.domain.id },
|
|
87
|
+
...(request.user?.id && { creator: { id: request.user.id } }),
|
|
88
|
+
agentId: spec.id,
|
|
89
|
+
agentDomainId: entry.ownerDomainId,
|
|
90
|
+
keyDomainId: entry.keyDomainId,
|
|
91
|
+
provider: spec.provider,
|
|
92
|
+
model: usage.model || spec.model,
|
|
93
|
+
modeKind: request.modeKind ?? undefined,
|
|
94
|
+
operation,
|
|
95
|
+
sessionId: request.sessionId ?? undefined,
|
|
96
|
+
inputTokens: usage.inputTokens,
|
|
97
|
+
outputTokens: usage.outputTokens,
|
|
98
|
+
/* The whole call's time goes on its last request; the rows of one call still add up to it. */
|
|
99
|
+
durationMs: index === all.length - 1 ? durationMs : 0,
|
|
100
|
+
outcome: index === all.length - 1 ? outcome : 'ok',
|
|
101
|
+
hourStart: hourOf(started)
|
|
102
|
+
}));
|
|
103
|
+
/* Outside the caller's transaction: a rolled-back write still spent the tokens. By name, so the decorated entity
|
|
104
|
+
stays out of this module's imports. */
|
|
105
|
+
(0, shell_1.getRepository)('AiUsage')
|
|
106
|
+
.save(rows)
|
|
107
|
+
.catch(error => console.error('[ai-client-base] could not record AI usage:', error?.message ?? error));
|
|
108
|
+
}
|
|
109
|
+
const metered = {
|
|
110
|
+
id: `${spec.provider}:${spec.model}`,
|
|
111
|
+
agent: { id: spec.id, name: spec.name, provider: spec.provider, model: spec.model },
|
|
112
|
+
chat: (messages, options) => measured('chat', options, o => client.chat(messages, o)),
|
|
113
|
+
generateJSON: (prompt, options) => measured('generateJSON', options, o => client.generateJSON(prompt, o)),
|
|
114
|
+
...(client.vision && {
|
|
115
|
+
vision: (image, prompt, options) => measured('vision', options, o => client.vision(image, prompt, o))
|
|
116
|
+
}),
|
|
117
|
+
...(client.chatWithTools && {
|
|
118
|
+
chatWithTools: (messages, tools, options) => measured('chatWithTools', options, o => client.chatWithTools(messages, tools, o))
|
|
119
|
+
}),
|
|
120
|
+
listModels: () => client.listModels()
|
|
121
|
+
};
|
|
122
|
+
return metered;
|
|
123
|
+
}
|
|
124
|
+
/** The UTC hour a moment falls in. */
|
|
125
|
+
function hourOf(moment) {
|
|
126
|
+
const hour = new Date(moment);
|
|
127
|
+
hour.setUTCMinutes(0, 0, 0);
|
|
128
|
+
return hour;
|
|
129
|
+
}
|
|
130
|
+
/** Why a provider request failed, as the usage row keeps it. */
|
|
131
|
+
function outcomeOf(error) {
|
|
132
|
+
const status = Number(error?.status ?? error?.statusCode ?? error?.response?.status);
|
|
133
|
+
if (status === 401 || status === 403)
|
|
134
|
+
return 'key-rejected';
|
|
135
|
+
if (status === 429)
|
|
136
|
+
return 'rate-limited';
|
|
137
|
+
return 'provider-error';
|
|
138
|
+
}
|
|
139
|
+
let _testing;
|
|
140
|
+
function isAIClientForTesting() {
|
|
141
|
+
return !!_testing;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Tests answer aiClientFor with a client of their own - no database, no provider, no usage rows. A client is wrapped
|
|
145
|
+
* as an agent named after it; a function decides per request. `undefined` puts the real door back.
|
|
146
|
+
*/
|
|
147
|
+
function setAIClientForTesting(client) {
|
|
148
|
+
if (!client) {
|
|
149
|
+
_testing = undefined;
|
|
150
|
+
}
|
|
151
|
+
else if (typeof client === 'function') {
|
|
152
|
+
_testing = client;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
/* The test's own object answers - its counters and `this` stay its own; it only gains the agent it is named as. */
|
|
156
|
+
if (!client.agent) {
|
|
157
|
+
const [provider, model] = String(client.id ?? 'test:test').split(':');
|
|
158
|
+
Object.defineProperty(client, 'agent', {
|
|
159
|
+
value: { id: 'test-agent', name: client.id ?? 'test', provider, model },
|
|
160
|
+
configurable: true
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
_testing = () => client;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=ai-client-for.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ai-client-for.js","sourceRoot":"","sources":["../../../server/service/agent/ai-client-for.ts"],"names":[],"mappings":"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","sourcesContent":["import { Domain, getRepository, Refusal } from '@things-factory/shell'\n\nimport { createAIClient } from '../factory.js'\nimport type { AIChatOptions, AIClient, AIProviderUsage } from '../types.js'\nimport { type AiAgentEntry, readAiAgentDirectory } from './agent-directory.js'\nimport type { AiUsage } from './ai-usage.js'\n\n/**\n * **The one door to a model** (design/ai/agent-settings.md §4).\n *\n * Every call that reaches a provider gets its client here, for a domain: the agent is the one asked for, or the\n * domain's default, and its key is the closest one on the domain chain. There is no deployment-wide client - a domain\n * with no usable agent has no AI (user decision 2026-10-12).\n *\n * The client handed back is metered: each provider request writes one AiUsage row with the domain, the person, the\n * agent and what was used (ADR-0061 decision 6).\n */\nexport interface AiClientRequest {\n domain: Domain\n /** The person calling. Empty for a call with no person, such as a webhook. */\n user?: { id: string } | null\n /** The agent the person picked. Empty takes the domain's default. */\n agentId?: string | null\n /** The conversation mode, when the call is part of a conversation. */\n modeKind?: string | null\n /** The conversation, by id. */\n sessionId?: string | null\n}\n\n/** The client of one agent, as a call got it. */\nexport interface AgentClient extends AIClient {\n readonly agent: { id: string; name: string; provider: string; model: string }\n}\n\nexport async function aiClientFor(request: AiClientRequest): Promise<AgentClient> {\n if (_testing) return _testing(request)\n if (!request.domain) throw new Error('aiClientFor needs the domain the call is made in')\n\n const directory = await readAiAgentDirectory(request.domain)\n const entry = pickAgent(directory.entries, request.agentId, directory.defaultAgentId)\n return meter(providerClientOf(entry), entry, request)\n}\n\n/**\n * The agent a request is answered by. A named agent that this domain cannot use is refused, never swapped for\n * another; no agent at all is \"not configured\".\n */\nexport function pickAgent(entries: AiAgentEntry[], agentId: string | null | undefined, defaultAgentId?: string): AiAgentEntry {\n if (agentId) {\n const named = entries.find(one => one.spec.id === agentId)\n if (!named?.usable) {\n throw new Refusal('ai-agent-not-available', { agentId }, 'The AI agent you picked is not available here.')\n }\n return named\n }\n const fallback = entries.find(one => one.spec.id === defaultAgentId && one.usable)\n if (!fallback) {\n throw new Refusal('ai-not-configured', {}, 'AI is not set up for this domain.')\n }\n return fallback\n}\n\n/* Provider clients are kept per agent and rebuilt when its definition or its key changes. */\nconst _clients = new Map<string, { version: string; client: AIClient }>()\n\nfunction providerClientOf(entry: AiAgentEntry): AIClient {\n const { spec } = entry\n const version = JSON.stringify([spec.provider, spec.model, spec.baseURL, spec.organization, entry.keyVersion])\n const cached = _clients.get(spec.id)\n if (cached?.version === version) return cached.client\n\n const client = createAIClient({\n provider: spec.provider,\n model: spec.model,\n apiKey: entry.apiKey!,\n ...(spec.baseURL && { baseURL: spec.baseURL }),\n ...(spec.organization && { organization: spec.organization })\n })\n _clients.set(spec.id, { version, client })\n return client\n}\n\nfunction meter(client: AIClient, entry: AiAgentEntry, request: AiClientRequest): AgentClient {\n const { spec } = entry\n\n async function measured<T>(operation: string, options: AIChatOptions | undefined, call: (options: AIChatOptions) => Promise<T>): Promise<T> {\n const started = new Date()\n const reported: AIProviderUsage[] = []\n const withUsage: AIChatOptions = {\n ...options,\n onUsage: usage => {\n reported.push(usage)\n options?.onUsage?.(usage)\n }\n }\n try {\n const result = await call(withUsage)\n record(operation, started, reported, 'ok')\n return result\n } catch (error: any) {\n record(operation, started, reported, outcomeOf(error))\n /* A failure must say why somewhere: the loop above turns it into a sentence for the person, and the usage row\n keeps only its code. The provider's own message names no key. */\n console.error(\n `[ai-client-base] ${spec.provider}:${spec.model} (${spec.name}) ${operation} failed:`,\n error?.status ?? '',\n error?.message ?? error\n )\n throw error\n }\n }\n\n function record(operation: string, started: Date, reported: AIProviderUsage[], outcome: string) {\n const durationMs = Date.now() - started.valueOf()\n /* A failed request reports nothing, and is still one row - a failure costs time and often money. */\n const rows = (reported.length ? reported : [{ model: spec.model } as AIProviderUsage]).map((usage, index, all) => ({\n domain: { id: request.domain.id },\n ...(request.user?.id && { creator: { id: request.user.id } }),\n agentId: spec.id,\n agentDomainId: entry.ownerDomainId,\n keyDomainId: entry.keyDomainId,\n provider: spec.provider,\n model: usage.model || spec.model,\n modeKind: request.modeKind ?? undefined,\n operation,\n sessionId: request.sessionId ?? undefined,\n inputTokens: usage.inputTokens,\n outputTokens: usage.outputTokens,\n /* The whole call's time goes on its last request; the rows of one call still add up to it. */\n durationMs: index === all.length - 1 ? durationMs : 0,\n outcome: index === all.length - 1 ? outcome : 'ok',\n hourStart: hourOf(started)\n }))\n /* Outside the caller's transaction: a rolled-back write still spent the tokens. By name, so the decorated entity\n stays out of this module's imports. */\n getRepository<AiUsage>('AiUsage')\n .save(rows as any)\n .catch(error => console.error('[ai-client-base] could not record AI usage:', error?.message ?? error))\n }\n\n const metered: AgentClient = {\n id: `${spec.provider}:${spec.model}`,\n agent: { id: spec.id, name: spec.name, provider: spec.provider, model: spec.model },\n chat: (messages, options) => measured('chat', options, o => client.chat(messages, o)),\n generateJSON: (prompt, options) => measured('generateJSON', options, o => client.generateJSON(prompt, o)),\n ...(client.vision && {\n vision: (image, prompt, options) => measured('vision', options, o => client.vision!(image, prompt, o))\n }),\n ...(client.chatWithTools && {\n chatWithTools: (messages, tools, options) =>\n measured('chatWithTools', options, o => client.chatWithTools!(messages, tools, o))\n }),\n listModels: () => client.listModels()\n }\n return metered\n}\n\n/** The UTC hour a moment falls in. */\nexport function hourOf(moment: Date): Date {\n const hour = new Date(moment)\n hour.setUTCMinutes(0, 0, 0)\n return hour\n}\n\n/** Why a provider request failed, as the usage row keeps it. */\nexport function outcomeOf(error: any): string {\n const status = Number(error?.status ?? error?.statusCode ?? error?.response?.status)\n if (status === 401 || status === 403) return 'key-rejected'\n if (status === 429) return 'rate-limited'\n return 'provider-error'\n}\n\ntype TestClientResolver = (request: AiClientRequest) => AgentClient\n\nlet _testing: TestClientResolver | undefined\n\nexport function isAIClientForTesting(): boolean {\n return !!_testing\n}\n\n/**\n * Tests answer aiClientFor with a client of their own - no database, no provider, no usage rows. A client is wrapped\n * as an agent named after it; a function decides per request. `undefined` puts the real door back.\n */\nexport function setAIClientForTesting(client: AIClient | TestClientResolver | undefined): void {\n if (!client) {\n _testing = undefined\n } else if (typeof client === 'function') {\n _testing = client\n } else {\n /* The test's own object answers - its counters and `this` stay its own; it only gains the agent it is named as. */\n if (!(client as AgentClient).agent) {\n const [provider, model] = String(client.id ?? 'test:test').split(':')\n Object.defineProperty(client, 'agent', {\n value: { id: 'test-agent', name: client.id ?? 'test', provider, model },\n configurable: true\n })\n }\n _testing = () => client as AgentClient\n }\n}\n"]}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Domain } from '@things-factory/shell';
|
|
2
|
+
export type AiUsageBucketSize = 'hour' | 'day' | 'month';
|
|
3
|
+
export interface AiUsageFilter {
|
|
4
|
+
from: Date;
|
|
5
|
+
to: Date;
|
|
6
|
+
bucket: AiUsageBucketSize;
|
|
7
|
+
agentIds?: string[];
|
|
8
|
+
userIds?: string[];
|
|
9
|
+
/** Also count what domains below did with this domain's agents - per domain, without their people. */
|
|
10
|
+
includeDescendants?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface AiUsageBucketRow {
|
|
13
|
+
/** The bucket in the domain's time zone: `2026-10-12 14:00`, `2026-10-12` or `2026-10`. */
|
|
14
|
+
bucket: string;
|
|
15
|
+
agentId?: string;
|
|
16
|
+
agentName?: string;
|
|
17
|
+
provider: string;
|
|
18
|
+
model: string;
|
|
19
|
+
/** The domain the calls were made in. */
|
|
20
|
+
domainId: string;
|
|
21
|
+
domainName: string;
|
|
22
|
+
/** Empty on a row of a domain below, and for calls with no person. */
|
|
23
|
+
userId?: string;
|
|
24
|
+
userName?: string;
|
|
25
|
+
calls: number;
|
|
26
|
+
inputTokens: number;
|
|
27
|
+
outputTokens: number;
|
|
28
|
+
failures: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Usage per bucket × agent × person (design/ai/agent-settings.md §6-2).
|
|
32
|
+
*
|
|
33
|
+
* The database groups by the stored UTC hour, which every database can do; the hours are then rolled up into the
|
|
34
|
+
* domain's days or months here. People of a domain below are never named to the domain above.
|
|
35
|
+
*/
|
|
36
|
+
export declare function summarizeAiUsage(given: Domain, filter: AiUsageFilter): Promise<AiUsageBucketRow[]>;
|
|
37
|
+
export declare function rollUp(hours: any[], bucket: AiUsageBucketSize, timezone: string | undefined, hereId: string, agentNameOf: Map<string, string>): AiUsageBucketRow[];
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.summarizeAiUsage = summarizeAiUsage;
|
|
4
|
+
exports.rollUp = rollUp;
|
|
5
|
+
const shell_1 = require("@things-factory/shell");
|
|
6
|
+
const agent_directory_js_1 = require("./agent-directory.js");
|
|
7
|
+
/**
|
|
8
|
+
* Usage per bucket × agent × person (design/ai/agent-settings.md §6-2).
|
|
9
|
+
*
|
|
10
|
+
* The database groups by the stored UTC hour, which every database can do; the hours are then rolled up into the
|
|
11
|
+
* domain's days or months here. People of a domain below are never named to the domain above.
|
|
12
|
+
*/
|
|
13
|
+
async function summarizeAiUsage(given, filter) {
|
|
14
|
+
/* The time zone and the chain are the domain row's. */
|
|
15
|
+
const domain = await (0, agent_directory_js_1.fullDomain)(given);
|
|
16
|
+
const below = filter.includeDescendants ? await (0, agent_directory_js_1.readDescendantDomains)(domain) : [];
|
|
17
|
+
const query = (0, shell_1.getRepository)('AiUsage')
|
|
18
|
+
.createQueryBuilder('usage')
|
|
19
|
+
.leftJoin('usage.domain', 'domain')
|
|
20
|
+
.leftJoin('usage.creator', 'creator')
|
|
21
|
+
.select('usage.hourStart', 'hourStart')
|
|
22
|
+
.addSelect('usage.agentId', 'agentId')
|
|
23
|
+
.addSelect('usage.provider', 'provider')
|
|
24
|
+
.addSelect('usage.model', 'model')
|
|
25
|
+
.addSelect('domain.id', 'domainId')
|
|
26
|
+
.addSelect('domain.name', 'domainName')
|
|
27
|
+
.addSelect('creator.id', 'userId')
|
|
28
|
+
.addSelect('creator.name', 'userName')
|
|
29
|
+
.addSelect('COUNT(*)', 'calls')
|
|
30
|
+
.addSelect('SUM(COALESCE(usage.inputTokens, 0))', 'inputTokens')
|
|
31
|
+
.addSelect('SUM(COALESCE(usage.outputTokens, 0))', 'outputTokens')
|
|
32
|
+
.addSelect(`SUM(CASE WHEN usage.outcome = 'ok' THEN 0 ELSE 1 END)`, 'failures')
|
|
33
|
+
.where('usage.hourStart >= :from AND usage.hourStart < :to', { from: filter.from, to: filter.to })
|
|
34
|
+
.groupBy('usage.hourStart')
|
|
35
|
+
.addGroupBy('usage.agentId')
|
|
36
|
+
.addGroupBy('usage.provider')
|
|
37
|
+
.addGroupBy('usage.model')
|
|
38
|
+
.addGroupBy('domain.id')
|
|
39
|
+
.addGroupBy('domain.name')
|
|
40
|
+
.addGroupBy('creator.id')
|
|
41
|
+
.addGroupBy('creator.name');
|
|
42
|
+
if (below.length) {
|
|
43
|
+
query.andWhere('(domain.id = :here OR (domain.id IN (:...below) AND usage.agentDomainId = :here))', {
|
|
44
|
+
here: domain.id,
|
|
45
|
+
below: below.map(one => one.id)
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
query.andWhere('domain.id = :here', { here: domain.id });
|
|
50
|
+
}
|
|
51
|
+
if (filter.agentIds?.length)
|
|
52
|
+
query.andWhere('usage.agentId IN (:...agentIds)', { agentIds: filter.agentIds });
|
|
53
|
+
if (filter.userIds?.length)
|
|
54
|
+
query.andWhere('creator.id IN (:...userIds)', { userIds: filter.userIds });
|
|
55
|
+
const [hours, directory] = await Promise.all([query.getRawMany(), (0, agent_directory_js_1.readAiAgentDirectory)(domain)]);
|
|
56
|
+
const agentNameOf = new Map(directory.entries.map(one => [one.spec.id, one.spec.name]));
|
|
57
|
+
return rollUp(hours, filter.bucket, domain.timezone, domain.id, agentNameOf);
|
|
58
|
+
}
|
|
59
|
+
function rollUp(hours, bucket, timezone, hereId, agentNameOf) {
|
|
60
|
+
const label = bucketLabeller(bucket, timezone);
|
|
61
|
+
const rows = new Map();
|
|
62
|
+
for (const hour of hours) {
|
|
63
|
+
const below = hour.domainId !== hereId;
|
|
64
|
+
const userId = below ? undefined : hour.userId ?? undefined;
|
|
65
|
+
const key = [label(new Date(hour.hourStart)), hour.agentId, hour.provider, hour.model, hour.domainId, userId].join('\u0000');
|
|
66
|
+
const row = rows.get(key) ??
|
|
67
|
+
rows
|
|
68
|
+
.set(key, {
|
|
69
|
+
bucket: label(new Date(hour.hourStart)),
|
|
70
|
+
...(hour.agentId && { agentId: hour.agentId, agentName: agentNameOf.get(hour.agentId) }),
|
|
71
|
+
provider: hour.provider,
|
|
72
|
+
model: hour.model,
|
|
73
|
+
domainId: hour.domainId,
|
|
74
|
+
domainName: hour.domainName,
|
|
75
|
+
...(userId && { userId, userName: hour.userName }),
|
|
76
|
+
calls: 0,
|
|
77
|
+
inputTokens: 0,
|
|
78
|
+
outputTokens: 0,
|
|
79
|
+
failures: 0
|
|
80
|
+
})
|
|
81
|
+
.get(key);
|
|
82
|
+
row.calls += Number(hour.calls) || 0;
|
|
83
|
+
row.inputTokens += Number(hour.inputTokens) || 0;
|
|
84
|
+
row.outputTokens += Number(hour.outputTokens) || 0;
|
|
85
|
+
row.failures += Number(hour.failures) || 0;
|
|
86
|
+
}
|
|
87
|
+
return [...rows.values()].sort((a, b) => b.bucket.localeCompare(a.bucket) || b.calls - a.calls);
|
|
88
|
+
}
|
|
89
|
+
/** Names the bucket a UTC moment falls in, in the domain's time zone (the server's when the domain has none). */
|
|
90
|
+
function bucketLabeller(bucket, timezone) {
|
|
91
|
+
const format = new Intl.DateTimeFormat('en-CA', {
|
|
92
|
+
timeZone: timezone || undefined,
|
|
93
|
+
year: 'numeric',
|
|
94
|
+
month: '2-digit',
|
|
95
|
+
day: '2-digit',
|
|
96
|
+
hour: '2-digit',
|
|
97
|
+
hourCycle: 'h23'
|
|
98
|
+
});
|
|
99
|
+
return moment => {
|
|
100
|
+
const part = Object.fromEntries(format.formatToParts(moment).map(one => [one.type, one.value]));
|
|
101
|
+
if (bucket === 'month')
|
|
102
|
+
return `${part.year}-${part.month}`;
|
|
103
|
+
if (bucket === 'day')
|
|
104
|
+
return `${part.year}-${part.month}-${part.day}`;
|
|
105
|
+
return `${part.year}-${part.month}-${part.day} ${part.hour}:00`;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=ai-usage-summary.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ai-usage-summary.js","sourceRoot":"","sources":["../../../server/service/agent/ai-usage-summary.ts"],"names":[],"mappings":"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","sourcesContent":["import { Domain, getRepository } from '@things-factory/shell'\n\nimport { fullDomain, readAiAgentDirectory, readDescendantDomains } from './agent-directory.js'\nimport type { AiUsage } from './ai-usage.js'\n\nexport type AiUsageBucketSize = 'hour' | 'day' | 'month'\n\nexport interface AiUsageFilter {\n from: Date\n to: Date\n bucket: AiUsageBucketSize\n agentIds?: string[]\n userIds?: string[]\n /** Also count what domains below did with this domain's agents - per domain, without their people. */\n includeDescendants?: boolean\n}\n\nexport interface AiUsageBucketRow {\n /** The bucket in the domain's time zone: `2026-10-12 14:00`, `2026-10-12` or `2026-10`. */\n bucket: string\n agentId?: string\n agentName?: string\n provider: string\n model: string\n /** The domain the calls were made in. */\n domainId: string\n domainName: string\n /** Empty on a row of a domain below, and for calls with no person. */\n userId?: string\n userName?: string\n calls: number\n inputTokens: number\n outputTokens: number\n failures: number\n}\n\n/**\n * Usage per bucket × agent × person (design/ai/agent-settings.md §6-2).\n *\n * The database groups by the stored UTC hour, which every database can do; the hours are then rolled up into the\n * domain's days or months here. People of a domain below are never named to the domain above.\n */\nexport async function summarizeAiUsage(given: Domain, filter: AiUsageFilter): Promise<AiUsageBucketRow[]> {\n /* The time zone and the chain are the domain row's. */\n const domain = await fullDomain(given)\n const below = filter.includeDescendants ? await readDescendantDomains(domain) : []\n\n const query = getRepository<AiUsage>('AiUsage')\n .createQueryBuilder('usage')\n .leftJoin('usage.domain', 'domain')\n .leftJoin('usage.creator', 'creator')\n .select('usage.hourStart', 'hourStart')\n .addSelect('usage.agentId', 'agentId')\n .addSelect('usage.provider', 'provider')\n .addSelect('usage.model', 'model')\n .addSelect('domain.id', 'domainId')\n .addSelect('domain.name', 'domainName')\n .addSelect('creator.id', 'userId')\n .addSelect('creator.name', 'userName')\n .addSelect('COUNT(*)', 'calls')\n .addSelect('SUM(COALESCE(usage.inputTokens, 0))', 'inputTokens')\n .addSelect('SUM(COALESCE(usage.outputTokens, 0))', 'outputTokens')\n .addSelect(`SUM(CASE WHEN usage.outcome = 'ok' THEN 0 ELSE 1 END)`, 'failures')\n .where('usage.hourStart >= :from AND usage.hourStart < :to', { from: filter.from, to: filter.to })\n .groupBy('usage.hourStart')\n .addGroupBy('usage.agentId')\n .addGroupBy('usage.provider')\n .addGroupBy('usage.model')\n .addGroupBy('domain.id')\n .addGroupBy('domain.name')\n .addGroupBy('creator.id')\n .addGroupBy('creator.name')\n\n if (below.length) {\n query.andWhere('(domain.id = :here OR (domain.id IN (:...below) AND usage.agentDomainId = :here))', {\n here: domain.id,\n below: below.map(one => one.id)\n })\n } else {\n query.andWhere('domain.id = :here', { here: domain.id })\n }\n if (filter.agentIds?.length) query.andWhere('usage.agentId IN (:...agentIds)', { agentIds: filter.agentIds })\n if (filter.userIds?.length) query.andWhere('creator.id IN (:...userIds)', { userIds: filter.userIds })\n\n const [hours, directory] = await Promise.all([query.getRawMany(), readAiAgentDirectory(domain)])\n const agentNameOf = new Map(directory.entries.map(one => [one.spec.id, one.spec.name]))\n return rollUp(hours, filter.bucket, domain.timezone, domain.id, agentNameOf)\n}\n\nexport function rollUp(\n hours: any[],\n bucket: AiUsageBucketSize,\n timezone: string | undefined,\n hereId: string,\n agentNameOf: Map<string, string>\n): AiUsageBucketRow[] {\n const label = bucketLabeller(bucket, timezone)\n const rows = new Map<string, AiUsageBucketRow>()\n\n for (const hour of hours) {\n const below = hour.domainId !== hereId\n const userId = below ? undefined : hour.userId ?? undefined\n const key = [label(new Date(hour.hourStart)), hour.agentId, hour.provider, hour.model, hour.domainId, userId].join('\\u0000')\n const row =\n rows.get(key) ??\n rows\n .set(key, {\n bucket: label(new Date(hour.hourStart)),\n ...(hour.agentId && { agentId: hour.agentId, agentName: agentNameOf.get(hour.agentId) }),\n provider: hour.provider,\n model: hour.model,\n domainId: hour.domainId,\n domainName: hour.domainName,\n ...(userId && { userId, userName: hour.userName }),\n calls: 0,\n inputTokens: 0,\n outputTokens: 0,\n failures: 0\n })\n .get(key)!\n row.calls += Number(hour.calls) || 0\n row.inputTokens += Number(hour.inputTokens) || 0\n row.outputTokens += Number(hour.outputTokens) || 0\n row.failures += Number(hour.failures) || 0\n }\n\n return [...rows.values()].sort((a, b) => b.bucket.localeCompare(a.bucket) || b.calls - a.calls)\n}\n\n/** Names the bucket a UTC moment falls in, in the domain's time zone (the server's when the domain has none). */\nfunction bucketLabeller(bucket: AiUsageBucketSize, timezone?: string): (moment: Date) => string {\n const format = new Intl.DateTimeFormat('en-CA', {\n timeZone: timezone || undefined,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n hourCycle: 'h23'\n })\n return moment => {\n const part = Object.fromEntries(format.formatToParts(moment).map(one => [one.type, one.value]))\n if (bucket === 'month') return `${part.year}-${part.month}`\n if (bucket === 'day') return `${part.year}-${part.month}-${part.day}`\n return `${part.year}-${part.month}-${part.day} ${part.hour}:00`\n }\n}\n"]}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { User } from '@things-factory/auth-base';
|
|
2
|
+
import { Domain } from '@things-factory/shell';
|
|
3
|
+
/**
|
|
4
|
+
* One provider request - the metering boundary writes a row each time a model is called (ADR-0061 decision 6,
|
|
5
|
+
* design/ai/agent-settings.md §6).
|
|
6
|
+
*
|
|
7
|
+
* A turn that loops through tools makes several rows. The row is joined to a conversation by id only and is never
|
|
8
|
+
* copied onto a message. It is written once and never changed, so it has no updater.
|
|
9
|
+
*/
|
|
10
|
+
export declare class AiUsage {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
domain?: Domain;
|
|
13
|
+
domainId?: string;
|
|
14
|
+
agentId?: string;
|
|
15
|
+
agentDomainId?: string;
|
|
16
|
+
keyDomainId?: string;
|
|
17
|
+
provider: string;
|
|
18
|
+
model: string;
|
|
19
|
+
modeKind?: string;
|
|
20
|
+
operation: string;
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
inputTokens?: number;
|
|
23
|
+
outputTokens?: number;
|
|
24
|
+
durationMs: number;
|
|
25
|
+
/** `ok`, or why it failed: key-rejected · rate-limited · provider-error. */
|
|
26
|
+
outcome: string;
|
|
27
|
+
/**
|
|
28
|
+
* The UTC hour the call started in. Usage is grouped by this column, which every database can do; days and months
|
|
29
|
+
* are rolled up from it in the domain's time zone.
|
|
30
|
+
*/
|
|
31
|
+
hourStart: Date;
|
|
32
|
+
creator?: User;
|
|
33
|
+
creatorId?: string;
|
|
34
|
+
createdAt: Date;
|
|
35
|
+
}
|