@things-factory/ai-client-base 10.1.149 → 10.1.157
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 +5 -2
- 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,205 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto'
|
|
2
|
+
import { EntityManager, In } from 'typeorm'
|
|
3
|
+
|
|
4
|
+
import { Domain, EnvVar, getRepository, Refusal } from '@things-factory/shell'
|
|
5
|
+
import { Setting } from '@things-factory/setting-base'
|
|
6
|
+
|
|
7
|
+
import { readAiAgentDirectory, readDescendantDomains } from './agent-directory.js'
|
|
8
|
+
import {
|
|
9
|
+
AI_AGENTS_SETTING,
|
|
10
|
+
AI_AGENTS_SETTING_CATEGORY,
|
|
11
|
+
aiAgentKeyName,
|
|
12
|
+
type AiAgentSpec,
|
|
13
|
+
isAiAgentProvider,
|
|
14
|
+
parseAiAgentsSetting,
|
|
15
|
+
stringifyAiAgentsSetting
|
|
16
|
+
} from './agent-spec.js'
|
|
17
|
+
import { listProviderModels } from './provider-models.js'
|
|
18
|
+
|
|
19
|
+
/** One of this domain's own agents as the settings card sends it. No id means a new agent. */
|
|
20
|
+
export interface AiAgentWrite {
|
|
21
|
+
id?: string
|
|
22
|
+
name: string
|
|
23
|
+
provider: string
|
|
24
|
+
model: string
|
|
25
|
+
baseURL?: string
|
|
26
|
+
organization?: string
|
|
27
|
+
active: boolean
|
|
28
|
+
/** A new key. Left out, the stored key stays (ADR-0112 decision 3). */
|
|
29
|
+
apiKey?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** This domain's key for an agent it inherits - set one, or clear it to go back to the key above. */
|
|
33
|
+
export interface InheritedKeyWrite {
|
|
34
|
+
agentId: string
|
|
35
|
+
apiKey?: string
|
|
36
|
+
clear?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AiAgentsWrite {
|
|
40
|
+
/** The whole list of this domain's own agents. One that is left out is deleted. */
|
|
41
|
+
agents: AiAgentWrite[]
|
|
42
|
+
defaultAgentId?: string
|
|
43
|
+
turnedOff?: string[]
|
|
44
|
+
inheritedKeys?: InheritedKeyWrite[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Saves a domain's agents and their keys in one transaction, touching only the request domain's rows - except that
|
|
49
|
+
* deleting an agent also removes the keys domains below had set for it (design/ai/agent-settings.md §3-2, §3-3).
|
|
50
|
+
*/
|
|
51
|
+
export async function saveAiAgents(
|
|
52
|
+
write: AiAgentsWrite,
|
|
53
|
+
{ tx, domain, user }: { tx: EntityManager; domain: Domain; user?: { id: string } }
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const directory = await readAiAgentDirectory(domain)
|
|
56
|
+
const storedById = new Map(directory.own.agents.map(one => [one.id, one]))
|
|
57
|
+
const inheritedIds = new Set(directory.entries.filter(one => one.inherited).map(one => one.spec.id))
|
|
58
|
+
|
|
59
|
+
const agents = write.agents.map(one => normalize(one, storedById))
|
|
60
|
+
refuseDuplicateNames(agents)
|
|
61
|
+
await refuseNamesTakenAround(domain, agents, directory.entries.filter(one => one.inherited).map(one => one.spec))
|
|
62
|
+
|
|
63
|
+
const storedKeyOf = new Map(
|
|
64
|
+
directory.entries.filter(one => !one.inherited && one.keyState === 'own').map(one => [one.spec.id, one.apiKey!])
|
|
65
|
+
)
|
|
66
|
+
for (const [index, agent] of agents.entries()) {
|
|
67
|
+
const newKey = write.agents[index].apiKey?.trim()
|
|
68
|
+
const key = newKey || storedKeyOf.get(agent.id)
|
|
69
|
+
if (!key) throw new Refusal('ai-agent-key-required', { agent: agent.name }, `Enter the API key of "${agent.name}".`)
|
|
70
|
+
if (needsModelCheck(agent, storedById.get(agent.id), !!newKey)) {
|
|
71
|
+
const offered = await listProviderModels({ provider: agent.provider, apiKey: key, baseURL: agent.baseURL, organization: agent.organization })
|
|
72
|
+
if (!offered.includes(agent.model)) {
|
|
73
|
+
throw new Refusal(
|
|
74
|
+
'ai-agent-model-not-offered',
|
|
75
|
+
{ agent: agent.name, model: agent.model },
|
|
76
|
+
`The provider does not offer "${agent.model}" to this key.`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ownIds = new Set(agents.map(one => one.id))
|
|
83
|
+
const defaultAgentId = write.defaultAgentId && (ownIds.has(write.defaultAgentId) || inheritedIds.has(write.defaultAgentId))
|
|
84
|
+
? write.defaultAgentId
|
|
85
|
+
: undefined
|
|
86
|
+
if (write.defaultAgentId && !defaultAgentId) {
|
|
87
|
+
throw new Refusal('ai-agent-not-available', { agentId: write.defaultAgentId }, 'The default agent is not one this domain can use.')
|
|
88
|
+
}
|
|
89
|
+
const turnedOff = (write.turnedOff ?? []).filter(id => inheritedIds.has(id))
|
|
90
|
+
|
|
91
|
+
await upsertSetting(tx, domain, user, stringifyAiAgentsSetting({ agents, defaultAgentId, turnedOff }))
|
|
92
|
+
|
|
93
|
+
for (const [index, agent] of agents.entries()) {
|
|
94
|
+
const newKey = write.agents[index].apiKey?.trim()
|
|
95
|
+
if (newKey) await upsertKey(tx, domain, agent, newKey)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const one of write.inheritedKeys ?? []) {
|
|
99
|
+
if (!inheritedIds.has(one.agentId)) {
|
|
100
|
+
throw new Refusal('ai-agent-not-available', { agentId: one.agentId }, 'That agent is not inherited by this domain.')
|
|
101
|
+
}
|
|
102
|
+
if (one.clear) {
|
|
103
|
+
await tx.getRepository(EnvVar).delete({ domain: { id: domain.id }, name: aiAgentKeyName(one.agentId) })
|
|
104
|
+
} else if (one.apiKey?.trim()) {
|
|
105
|
+
const spec = directory.entries.find(entry => entry.spec.id === one.agentId)!.spec
|
|
106
|
+
await upsertKey(tx, domain, spec, one.apiKey.trim())
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const deleted = directory.own.agents.filter(one => !ownIds.has(one.id)).map(one => aiAgentKeyName(one.id))
|
|
111
|
+
if (deleted.length) {
|
|
112
|
+
const below = await readDescendantDomains(domain)
|
|
113
|
+
await tx.getRepository(EnvVar).delete({
|
|
114
|
+
domain: { id: In([domain.id, ...below.map(one => one.id)]) },
|
|
115
|
+
name: In(deleted)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalize(write: AiAgentWrite, storedById: Map<string, AiAgentSpec>): AiAgentSpec {
|
|
121
|
+
const name = write.name?.trim()
|
|
122
|
+
if (!name) throw new Refusal('ai-agent-name-required', {}, 'Every agent needs a name.')
|
|
123
|
+
if (!isAiAgentProvider(write.provider)) {
|
|
124
|
+
throw new Refusal('ai-agent-provider-unknown', { provider: String(write.provider) }, `"${write.provider}" is not a provider an agent can use.`)
|
|
125
|
+
}
|
|
126
|
+
const model = write.model?.trim()
|
|
127
|
+
if (!model) throw new Refusal('ai-agent-model-required', { agent: name }, `Pick the model of "${name}".`)
|
|
128
|
+
if (write.id && !storedById.has(write.id)) {
|
|
129
|
+
throw new Refusal('ai-agent-not-in-domain', { agentId: write.id }, 'That agent does not belong to this domain.')
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
id: write.id || randomUUID(),
|
|
133
|
+
name,
|
|
134
|
+
provider: write.provider,
|
|
135
|
+
model,
|
|
136
|
+
...(write.baseURL?.trim() && { baseURL: write.baseURL.trim() }),
|
|
137
|
+
...(write.provider === 'openai' && write.organization?.trim() && { organization: write.organization.trim() }),
|
|
138
|
+
active: write.active !== false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The model is checked against the provider's list when anything that decides the list, or the model, changed. */
|
|
143
|
+
function needsModelCheck(agent: AiAgentSpec, stored: AiAgentSpec | undefined, newKey: boolean): boolean {
|
|
144
|
+
if (!stored || newKey) return true
|
|
145
|
+
return stored.provider !== agent.provider || stored.model !== agent.model || stored.baseURL !== agent.baseURL || stored.organization !== agent.organization
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function refuseDuplicateNames(agents: AiAgentSpec[]) {
|
|
149
|
+
const seen = new Set<string>()
|
|
150
|
+
for (const agent of agents) {
|
|
151
|
+
const key = agent.name.toLowerCase()
|
|
152
|
+
if (seen.has(key)) throw new Refusal('ai-agent-name-taken', { name: agent.name, domain: '' }, `There is already an agent named "${agent.name}".`)
|
|
153
|
+
seen.add(key)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A name is one across the chain, so a picker never shows two agents of the same name: not the name of an agent
|
|
159
|
+
* inherited from above, nor of an agent a domain below declared.
|
|
160
|
+
*/
|
|
161
|
+
async function refuseNamesTakenAround(domain: Domain, agents: AiAgentSpec[], inherited: AiAgentSpec[]) {
|
|
162
|
+
const taken = new Map<string, string>()
|
|
163
|
+
for (const spec of inherited) taken.set(spec.name.toLowerCase(), '')
|
|
164
|
+
const below = await readDescendantDomains(domain)
|
|
165
|
+
if (below.length) {
|
|
166
|
+
const settings = await getRepository(Setting).find({
|
|
167
|
+
where: { domain: { id: In(below.map(one => one.id)) }, name: AI_AGENTS_SETTING }
|
|
168
|
+
})
|
|
169
|
+
const nameOf = new Map(below.map(one => [one.id, one.name]))
|
|
170
|
+
for (const setting of settings) {
|
|
171
|
+
for (const spec of parseAiAgentsSetting(setting.value).agents) taken.set(spec.name.toLowerCase(), nameOf.get(setting.domainId) ?? '')
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const agent of agents) {
|
|
175
|
+
if (taken.has(agent.name.toLowerCase())) {
|
|
176
|
+
throw new Refusal(
|
|
177
|
+
'ai-agent-name-taken',
|
|
178
|
+
{ name: agent.name, domain: taken.get(agent.name.toLowerCase()) ?? '' },
|
|
179
|
+
`There is already an agent named "${agent.name}".`
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function upsertSetting(tx: EntityManager, domain: Domain, user: { id: string } | undefined, value: string) {
|
|
186
|
+
const repository = tx.getRepository(Setting)
|
|
187
|
+
const stored = await repository.findOne({ where: { domain: { id: domain.id }, name: AI_AGENTS_SETTING } })
|
|
188
|
+
await repository.save({
|
|
189
|
+
...(stored ?? { domain: { id: domain.id }, name: AI_AGENTS_SETTING, category: AI_AGENTS_SETTING_CATEGORY, creator: user }),
|
|
190
|
+
value,
|
|
191
|
+
updater: user
|
|
192
|
+
} as any)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function upsertKey(tx: EntityManager, domain: Domain, agent: AiAgentSpec, apiKey: string) {
|
|
196
|
+
const repository = tx.getRepository(EnvVar)
|
|
197
|
+
const name = aiAgentKeyName(agent.id)
|
|
198
|
+
const stored = await repository.findOne({ where: { domain: { id: domain.id }, name } })
|
|
199
|
+
await repository.save({
|
|
200
|
+
...(stored ?? { domain: { id: domain.id }, name }),
|
|
201
|
+
value: apiKey,
|
|
202
|
+
description: `API key of the AI agent "${agent.name}"`,
|
|
203
|
+
active: true
|
|
204
|
+
} as any)
|
|
205
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An AI agent - a provider, a model and a key that together can answer (design/ai/agent-settings.md §3).
|
|
3
|
+
*
|
|
4
|
+
* A domain keeps its agents in one Setting row (`ai-agents`) and each key in an EnvVar of its own, so the
|
|
5
|
+
* definition and the secret live where the framework already keeps domain settings and secrets (ADR-0112).
|
|
6
|
+
* This file is the shape and its parsing - no database.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The providers an agent may name. Three, by decision (2026-10-12). */
|
|
10
|
+
export const AI_AGENT_PROVIDERS = ['openai', 'anthropic', 'gemini'] as const
|
|
11
|
+
export type AiAgentProvider = (typeof AI_AGENT_PROVIDERS)[number]
|
|
12
|
+
|
|
13
|
+
/** The Setting row that holds a domain's agents. */
|
|
14
|
+
export const AI_AGENTS_SETTING = 'ai-agents'
|
|
15
|
+
export const AI_AGENTS_SETTING_CATEGORY = 'ai'
|
|
16
|
+
|
|
17
|
+
export interface AiAgentSpec {
|
|
18
|
+
id: string
|
|
19
|
+
name: string
|
|
20
|
+
provider: AiAgentProvider
|
|
21
|
+
model: string
|
|
22
|
+
baseURL?: string
|
|
23
|
+
/** OpenAI only. */
|
|
24
|
+
organization?: string
|
|
25
|
+
/** Off takes the agent out of this domain and every domain below it. */
|
|
26
|
+
active: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What the `ai-agents` Setting row holds for one domain. */
|
|
30
|
+
export interface AiAgentsSetting {
|
|
31
|
+
agents: AiAgentSpec[]
|
|
32
|
+
/** This domain's default - one of its own agents or an inherited one. */
|
|
33
|
+
defaultAgentId?: string
|
|
34
|
+
/** Inherited agents this domain does not use. */
|
|
35
|
+
turnedOff?: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The EnvVar that holds an agent's key. Named by the agent id, so renaming an agent keeps its key. */
|
|
39
|
+
export function aiAgentKeyName(agentId: string): string {
|
|
40
|
+
return `AiAgent::${agentId}::apiKey`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const AI_AGENT_KEY_PREFIX = 'AiAgent::'
|
|
44
|
+
|
|
45
|
+
export function isAiAgentProvider(value: unknown): value is AiAgentProvider {
|
|
46
|
+
return typeof value === 'string' && (AI_AGENT_PROVIDERS as readonly string[]).includes(value)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const EMPTY: AiAgentsSetting = { agents: [] }
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reads a stored `ai-agents` value. The row can also be written through the generic setting doors, so a value that
|
|
53
|
+
* does not parse reads as no agents, and an entry without an id, a name, a known provider or a model is left out -
|
|
54
|
+
* a broken row never becomes an agent that fails at the provider.
|
|
55
|
+
*/
|
|
56
|
+
export function parseAiAgentsSetting(value: string | null | undefined): AiAgentsSetting {
|
|
57
|
+
if (!value) return EMPTY
|
|
58
|
+
let raw: any
|
|
59
|
+
try {
|
|
60
|
+
raw = JSON.parse(value)
|
|
61
|
+
} catch {
|
|
62
|
+
return EMPTY
|
|
63
|
+
}
|
|
64
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.agents)) return EMPTY
|
|
65
|
+
|
|
66
|
+
const agents: AiAgentSpec[] = raw.agents.flatMap((one: any) => {
|
|
67
|
+
if (!one || typeof one !== 'object') return []
|
|
68
|
+
const id = text(one.id)
|
|
69
|
+
const name = text(one.name)
|
|
70
|
+
const model = text(one.model)
|
|
71
|
+
if (!id || !name || !model || !isAiAgentProvider(one.provider)) return []
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
id,
|
|
75
|
+
name,
|
|
76
|
+
provider: one.provider,
|
|
77
|
+
model,
|
|
78
|
+
...(text(one.baseURL) && { baseURL: text(one.baseURL) }),
|
|
79
|
+
...(one.provider === 'openai' && text(one.organization) && { organization: text(one.organization) }),
|
|
80
|
+
active: one.active !== false
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const turnedOff = Array.isArray(raw.turnedOff) ? raw.turnedOff.filter((id: unknown) => typeof id === 'string') : []
|
|
86
|
+
return {
|
|
87
|
+
agents,
|
|
88
|
+
...(text(raw.defaultAgentId) && { defaultAgentId: text(raw.defaultAgentId) }),
|
|
89
|
+
...(turnedOff.length && { turnedOff })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function stringifyAiAgentsSetting(setting: AiAgentsSetting): string {
|
|
94
|
+
return JSON.stringify({
|
|
95
|
+
agents: setting.agents,
|
|
96
|
+
...(setting.defaultAgentId && { defaultAgentId: setting.defaultAgentId }),
|
|
97
|
+
...(setting.turnedOff?.length && { turnedOff: setting.turnedOff })
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function text(value: unknown): string {
|
|
102
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
103
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: **the one door to a model picks the agent the person asked for or the domain's default, refuses
|
|
3
|
+
* anything else, and writes a usage row for every provider request** (ADR-0061 decision 6,
|
|
4
|
+
* design/ai/agent-settings.md §4).
|
|
5
|
+
*/
|
|
6
|
+
const saved: any[] = []
|
|
7
|
+
|
|
8
|
+
jest.mock('@things-factory/shell', () => ({
|
|
9
|
+
...jest.requireActual('@things-factory/shell'),
|
|
10
|
+
getRepository: jest.fn(() => ({ save: async (rows: any[]) => saved.push(...rows) }))
|
|
11
|
+
}))
|
|
12
|
+
jest.mock('./agent-directory', () => ({ readAiAgentDirectory: jest.fn() }))
|
|
13
|
+
|
|
14
|
+
import { readAiAgentDirectory } from './agent-directory'
|
|
15
|
+
import { aiClientFor, hourOf, outcomeOf, pickAgent } from './ai-client-for'
|
|
16
|
+
import { registerAIProvider } from '../factory'
|
|
17
|
+
|
|
18
|
+
const entry = (id: string, extra: any = {}) => ({
|
|
19
|
+
spec: { id, name: id.toUpperCase(), provider: 'fake', model: 'fake-1', active: true },
|
|
20
|
+
ownerDomainId: 'hq',
|
|
21
|
+
inherited: true,
|
|
22
|
+
turnedOff: false,
|
|
23
|
+
keyState: 'inherited',
|
|
24
|
+
keyDomainId: 'hq',
|
|
25
|
+
apiKey: 'k',
|
|
26
|
+
keyVersion: 'hq:0',
|
|
27
|
+
usable: true,
|
|
28
|
+
...extra
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
let replies: Array<(options: any) => any> = []
|
|
32
|
+
registerAIProvider('fake', config => ({
|
|
33
|
+
id: `fake:${config.model}`,
|
|
34
|
+
chat: async (_messages, options: any) => {
|
|
35
|
+
const reply = replies.shift()!
|
|
36
|
+
return reply(options)
|
|
37
|
+
},
|
|
38
|
+
generateJSON: async () => ({}),
|
|
39
|
+
chatWithTools: async (_messages, _tools, options: any) => {
|
|
40
|
+
options.onUsage?.({ model: 'fake-1-0607', inputTokens: 100, outputTokens: 20 })
|
|
41
|
+
options.onUsage?.({ model: 'fake-1-0607', inputTokens: 300, outputTokens: 5 })
|
|
42
|
+
return { toolCalls: [], stopReason: 'end_turn' }
|
|
43
|
+
},
|
|
44
|
+
listModels: async () => ['fake-1']
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
const flush = () => new Promise(resolve => setImmediate(resolve))
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
saved.length = 0
|
|
51
|
+
replies = []
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('which agent answers', () => {
|
|
55
|
+
it('★ the agent the person picked, when this domain can use it', () => {
|
|
56
|
+
expect(pickAgent([entry('a'), entry('b')] as any, 'b', 'a').spec.id).toBe('b')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('★ the domain default when nobody picked', () => {
|
|
60
|
+
expect(pickAgent([entry('a'), entry('b')] as any, undefined, 'a').spec.id).toBe('a')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('★ a picked agent this domain cannot use is refused - never swapped for another', () => {
|
|
64
|
+
const entries = [entry('a'), entry('b', { usable: false })] as any
|
|
65
|
+
expect(() => pickAgent(entries, 'b', 'a')).toThrow(expect.objectContaining({ code: 'ai-agent-not-available' }))
|
|
66
|
+
expect(() => pickAgent(entries, 'nonesuch', 'a')).toThrow(expect.objectContaining({ code: 'ai-agent-not-available' }))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('★ no usable agent is "not configured"', () => {
|
|
70
|
+
expect(() => pickAgent([entry('a', { usable: false })] as any, undefined, 'a')).toThrow(
|
|
71
|
+
expect.objectContaining({ code: 'ai-not-configured' })
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('usage', () => {
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
;(readAiAgentDirectory as jest.Mock).mockResolvedValue({ entries: [entry('a')], defaultAgentId: 'a' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('★ every provider request of a call is one row, with the domain, the person, the agent and who paid', async () => {
|
|
82
|
+
const client = await aiClientFor({ domain: { id: 'plant' } as any, user: { id: 'u1' }, modeKind: 'twin.space', sessionId: 's1' })
|
|
83
|
+
await client.chatWithTools!([], [], {})
|
|
84
|
+
await flush()
|
|
85
|
+
|
|
86
|
+
expect(saved).toHaveLength(2)
|
|
87
|
+
expect(saved[0]).toMatchObject({
|
|
88
|
+
domain: { id: 'plant' },
|
|
89
|
+
creator: { id: 'u1' },
|
|
90
|
+
agentId: 'a',
|
|
91
|
+
agentDomainId: 'hq',
|
|
92
|
+
keyDomainId: 'hq',
|
|
93
|
+
provider: 'fake',
|
|
94
|
+
model: 'fake-1-0607',
|
|
95
|
+
modeKind: 'twin.space',
|
|
96
|
+
operation: 'chatWithTools',
|
|
97
|
+
sessionId: 's1',
|
|
98
|
+
inputTokens: 100,
|
|
99
|
+
outputTokens: 20,
|
|
100
|
+
outcome: 'ok'
|
|
101
|
+
})
|
|
102
|
+
expect(saved[1]).toMatchObject({ inputTokens: 300, outputTokens: 5 })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('★ a failed request is still a row, with why it failed', async () => {
|
|
106
|
+
replies.push(() => {
|
|
107
|
+
throw Object.assign(new Error('nope'), { status: 429 })
|
|
108
|
+
})
|
|
109
|
+
const client = await aiClientFor({ domain: { id: 'plant' } as any })
|
|
110
|
+
|
|
111
|
+
await expect(client.chat([])).rejects.toThrow('nope')
|
|
112
|
+
await flush()
|
|
113
|
+
|
|
114
|
+
expect(saved).toEqual([expect.objectContaining({ operation: 'chat', outcome: 'rate-limited', model: 'fake-1' })])
|
|
115
|
+
expect(saved[0].creator).toBeUndefined()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('the caller\'s own usage listener still hears every request', async () => {
|
|
119
|
+
const heard: any[] = []
|
|
120
|
+
const client = await aiClientFor({ domain: { id: 'plant' } as any })
|
|
121
|
+
await client.chatWithTools!([], [], { onUsage: usage => heard.push(usage) })
|
|
122
|
+
expect(heard).toHaveLength(2)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('the client names the agent that answered', async () => {
|
|
126
|
+
const client = await aiClientFor({ domain: { id: 'plant' } as any })
|
|
127
|
+
expect(client.agent).toEqual({ id: 'a', name: 'A', provider: 'fake', model: 'fake-1' })
|
|
128
|
+
expect(client.id).toBe('fake:fake-1')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('a failure is named by what the provider said', () => {
|
|
133
|
+
expect(outcomeOf({ status: 401 })).toBe('key-rejected')
|
|
134
|
+
expect(outcomeOf({ status: 403 })).toBe('key-rejected')
|
|
135
|
+
expect(outcomeOf({ status: 429 })).toBe('rate-limited')
|
|
136
|
+
expect(outcomeOf(new Error('socket hang up'))).toBe('provider-error')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('a call is grouped by the UTC hour it started in', () => {
|
|
140
|
+
expect(hourOf(new Date('2026-10-12T05:47:13.120Z')).toISOString()).toBe('2026-10-12T05:00:00.000Z')
|
|
141
|
+
})
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Domain, getRepository, Refusal } from '@things-factory/shell'
|
|
2
|
+
|
|
3
|
+
import { createAIClient } from '../factory.js'
|
|
4
|
+
import type { AIChatOptions, AIClient, AIProviderUsage } from '../types.js'
|
|
5
|
+
import { type AiAgentEntry, readAiAgentDirectory } from './agent-directory.js'
|
|
6
|
+
import type { AiUsage } from './ai-usage.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **The one door to a model** (design/ai/agent-settings.md §4).
|
|
10
|
+
*
|
|
11
|
+
* Every call that reaches a provider gets its client here, for a domain: the agent is the one asked for, or the
|
|
12
|
+
* domain's default, and its key is the closest one on the domain chain. There is no deployment-wide client - a domain
|
|
13
|
+
* with no usable agent has no AI (user decision 2026-10-12).
|
|
14
|
+
*
|
|
15
|
+
* The client handed back is metered: each provider request writes one AiUsage row with the domain, the person, the
|
|
16
|
+
* agent and what was used (ADR-0061 decision 6).
|
|
17
|
+
*/
|
|
18
|
+
export interface AiClientRequest {
|
|
19
|
+
domain: Domain
|
|
20
|
+
/** The person calling. Empty for a call with no person, such as a webhook. */
|
|
21
|
+
user?: { id: string } | null
|
|
22
|
+
/** The agent the person picked. Empty takes the domain's default. */
|
|
23
|
+
agentId?: string | null
|
|
24
|
+
/** The conversation mode, when the call is part of a conversation. */
|
|
25
|
+
modeKind?: string | null
|
|
26
|
+
/** The conversation, by id. */
|
|
27
|
+
sessionId?: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The client of one agent, as a call got it. */
|
|
31
|
+
export interface AgentClient extends AIClient {
|
|
32
|
+
readonly agent: { id: string; name: string; provider: string; model: string }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function aiClientFor(request: AiClientRequest): Promise<AgentClient> {
|
|
36
|
+
if (_testing) return _testing(request)
|
|
37
|
+
if (!request.domain) throw new Error('aiClientFor needs the domain the call is made in')
|
|
38
|
+
|
|
39
|
+
const directory = await readAiAgentDirectory(request.domain)
|
|
40
|
+
const entry = pickAgent(directory.entries, request.agentId, directory.defaultAgentId)
|
|
41
|
+
return meter(providerClientOf(entry), entry, request)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The agent a request is answered by. A named agent that this domain cannot use is refused, never swapped for
|
|
46
|
+
* another; no agent at all is "not configured".
|
|
47
|
+
*/
|
|
48
|
+
export function pickAgent(entries: AiAgentEntry[], agentId: string | null | undefined, defaultAgentId?: string): AiAgentEntry {
|
|
49
|
+
if (agentId) {
|
|
50
|
+
const named = entries.find(one => one.spec.id === agentId)
|
|
51
|
+
if (!named?.usable) {
|
|
52
|
+
throw new Refusal('ai-agent-not-available', { agentId }, 'The AI agent you picked is not available here.')
|
|
53
|
+
}
|
|
54
|
+
return named
|
|
55
|
+
}
|
|
56
|
+
const fallback = entries.find(one => one.spec.id === defaultAgentId && one.usable)
|
|
57
|
+
if (!fallback) {
|
|
58
|
+
throw new Refusal('ai-not-configured', {}, 'AI is not set up for this domain.')
|
|
59
|
+
}
|
|
60
|
+
return fallback
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Provider clients are kept per agent and rebuilt when its definition or its key changes. */
|
|
64
|
+
const _clients = new Map<string, { version: string; client: AIClient }>()
|
|
65
|
+
|
|
66
|
+
function providerClientOf(entry: AiAgentEntry): AIClient {
|
|
67
|
+
const { spec } = entry
|
|
68
|
+
const version = JSON.stringify([spec.provider, spec.model, spec.baseURL, spec.organization, entry.keyVersion])
|
|
69
|
+
const cached = _clients.get(spec.id)
|
|
70
|
+
if (cached?.version === version) return cached.client
|
|
71
|
+
|
|
72
|
+
const client = createAIClient({
|
|
73
|
+
provider: spec.provider,
|
|
74
|
+
model: spec.model,
|
|
75
|
+
apiKey: entry.apiKey!,
|
|
76
|
+
...(spec.baseURL && { baseURL: spec.baseURL }),
|
|
77
|
+
...(spec.organization && { organization: spec.organization })
|
|
78
|
+
})
|
|
79
|
+
_clients.set(spec.id, { version, client })
|
|
80
|
+
return client
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function meter(client: AIClient, entry: AiAgentEntry, request: AiClientRequest): AgentClient {
|
|
84
|
+
const { spec } = entry
|
|
85
|
+
|
|
86
|
+
async function measured<T>(operation: string, options: AIChatOptions | undefined, call: (options: AIChatOptions) => Promise<T>): Promise<T> {
|
|
87
|
+
const started = new Date()
|
|
88
|
+
const reported: AIProviderUsage[] = []
|
|
89
|
+
const withUsage: AIChatOptions = {
|
|
90
|
+
...options,
|
|
91
|
+
onUsage: usage => {
|
|
92
|
+
reported.push(usage)
|
|
93
|
+
options?.onUsage?.(usage)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const result = await call(withUsage)
|
|
98
|
+
record(operation, started, reported, 'ok')
|
|
99
|
+
return result
|
|
100
|
+
} catch (error: any) {
|
|
101
|
+
record(operation, started, reported, outcomeOf(error))
|
|
102
|
+
/* A failure must say why somewhere: the loop above turns it into a sentence for the person, and the usage row
|
|
103
|
+
keeps only its code. The provider's own message names no key. */
|
|
104
|
+
console.error(
|
|
105
|
+
`[ai-client-base] ${spec.provider}:${spec.model} (${spec.name}) ${operation} failed:`,
|
|
106
|
+
error?.status ?? '',
|
|
107
|
+
error?.message ?? error
|
|
108
|
+
)
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function record(operation: string, started: Date, reported: AIProviderUsage[], outcome: string) {
|
|
114
|
+
const durationMs = Date.now() - started.valueOf()
|
|
115
|
+
/* A failed request reports nothing, and is still one row - a failure costs time and often money. */
|
|
116
|
+
const rows = (reported.length ? reported : [{ model: spec.model } as AIProviderUsage]).map((usage, index, all) => ({
|
|
117
|
+
domain: { id: request.domain.id },
|
|
118
|
+
...(request.user?.id && { creator: { id: request.user.id } }),
|
|
119
|
+
agentId: spec.id,
|
|
120
|
+
agentDomainId: entry.ownerDomainId,
|
|
121
|
+
keyDomainId: entry.keyDomainId,
|
|
122
|
+
provider: spec.provider,
|
|
123
|
+
model: usage.model || spec.model,
|
|
124
|
+
modeKind: request.modeKind ?? undefined,
|
|
125
|
+
operation,
|
|
126
|
+
sessionId: request.sessionId ?? undefined,
|
|
127
|
+
inputTokens: usage.inputTokens,
|
|
128
|
+
outputTokens: usage.outputTokens,
|
|
129
|
+
/* The whole call's time goes on its last request; the rows of one call still add up to it. */
|
|
130
|
+
durationMs: index === all.length - 1 ? durationMs : 0,
|
|
131
|
+
outcome: index === all.length - 1 ? outcome : 'ok',
|
|
132
|
+
hourStart: hourOf(started)
|
|
133
|
+
}))
|
|
134
|
+
/* Outside the caller's transaction: a rolled-back write still spent the tokens. By name, so the decorated entity
|
|
135
|
+
stays out of this module's imports. */
|
|
136
|
+
getRepository<AiUsage>('AiUsage')
|
|
137
|
+
.save(rows as any)
|
|
138
|
+
.catch(error => console.error('[ai-client-base] could not record AI usage:', error?.message ?? error))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const metered: AgentClient = {
|
|
142
|
+
id: `${spec.provider}:${spec.model}`,
|
|
143
|
+
agent: { id: spec.id, name: spec.name, provider: spec.provider, model: spec.model },
|
|
144
|
+
chat: (messages, options) => measured('chat', options, o => client.chat(messages, o)),
|
|
145
|
+
generateJSON: (prompt, options) => measured('generateJSON', options, o => client.generateJSON(prompt, o)),
|
|
146
|
+
...(client.vision && {
|
|
147
|
+
vision: (image, prompt, options) => measured('vision', options, o => client.vision!(image, prompt, o))
|
|
148
|
+
}),
|
|
149
|
+
...(client.chatWithTools && {
|
|
150
|
+
chatWithTools: (messages, tools, options) =>
|
|
151
|
+
measured('chatWithTools', options, o => client.chatWithTools!(messages, tools, o))
|
|
152
|
+
}),
|
|
153
|
+
listModels: () => client.listModels()
|
|
154
|
+
}
|
|
155
|
+
return metered
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The UTC hour a moment falls in. */
|
|
159
|
+
export function hourOf(moment: Date): Date {
|
|
160
|
+
const hour = new Date(moment)
|
|
161
|
+
hour.setUTCMinutes(0, 0, 0)
|
|
162
|
+
return hour
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Why a provider request failed, as the usage row keeps it. */
|
|
166
|
+
export function outcomeOf(error: any): string {
|
|
167
|
+
const status = Number(error?.status ?? error?.statusCode ?? error?.response?.status)
|
|
168
|
+
if (status === 401 || status === 403) return 'key-rejected'
|
|
169
|
+
if (status === 429) return 'rate-limited'
|
|
170
|
+
return 'provider-error'
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type TestClientResolver = (request: AiClientRequest) => AgentClient
|
|
174
|
+
|
|
175
|
+
let _testing: TestClientResolver | undefined
|
|
176
|
+
|
|
177
|
+
export function isAIClientForTesting(): boolean {
|
|
178
|
+
return !!_testing
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Tests answer aiClientFor with a client of their own - no database, no provider, no usage rows. A client is wrapped
|
|
183
|
+
* as an agent named after it; a function decides per request. `undefined` puts the real door back.
|
|
184
|
+
*/
|
|
185
|
+
export function setAIClientForTesting(client: AIClient | TestClientResolver | undefined): void {
|
|
186
|
+
if (!client) {
|
|
187
|
+
_testing = undefined
|
|
188
|
+
} else if (typeof client === 'function') {
|
|
189
|
+
_testing = client
|
|
190
|
+
} else {
|
|
191
|
+
/* The test's own object answers - its counters and `this` stay its own; it only gains the agent it is named as. */
|
|
192
|
+
if (!(client as AgentClient).agent) {
|
|
193
|
+
const [provider, model] = String(client.id ?? 'test:test').split(':')
|
|
194
|
+
Object.defineProperty(client, 'agent', {
|
|
195
|
+
value: { id: 'test-agent', name: client.id ?? 'test', provider, model },
|
|
196
|
+
configurable: true
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
_testing = () => client as AgentClient
|
|
200
|
+
}
|
|
201
|
+
}
|