@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.
Files changed (76) hide show
  1. package/config/config.development.js +2 -68
  2. package/config/config.production.js +2 -20
  3. package/dist-server/service/agent/agent-directory.d.ts +71 -0
  4. package/dist-server/service/agent/agent-directory.js +129 -0
  5. package/dist-server/service/agent/agent-directory.js.map +1 -0
  6. package/dist-server/service/agent/agent-settings-save.d.ts +38 -0
  7. package/dist-server/service/agent/agent-settings-save.js +153 -0
  8. package/dist-server/service/agent/agent-settings-save.js.map +1 -0
  9. package/dist-server/service/agent/agent-spec.d.ts +43 -0
  10. package/dist-server/service/agent/agent-spec.js +83 -0
  11. package/dist-server/service/agent/agent-spec.js.map +1 -0
  12. package/dist-server/service/agent/ai-client-for.d.ts +53 -0
  13. package/dist-server/service/agent/ai-client-for.js +166 -0
  14. package/dist-server/service/agent/ai-client-for.js.map +1 -0
  15. package/dist-server/service/agent/ai-usage-summary.d.ts +37 -0
  16. package/dist-server/service/agent/ai-usage-summary.js +108 -0
  17. package/dist-server/service/agent/ai-usage-summary.js.map +1 -0
  18. package/dist-server/service/agent/ai-usage.d.ts +35 -0
  19. package/dist-server/service/agent/ai-usage.js +118 -0
  20. package/dist-server/service/agent/ai-usage.js.map +1 -0
  21. package/dist-server/service/agent/index.d.ts +7 -0
  22. package/dist-server/service/agent/index.js +13 -0
  23. package/dist-server/service/agent/index.js.map +1 -0
  24. package/dist-server/service/agent/provider-models.d.ts +12 -0
  25. package/dist-server/service/agent/provider-models.js +37 -0
  26. package/dist-server/service/agent/provider-models.js.map +1 -0
  27. package/dist-server/service/capability.d.ts +21 -10
  28. package/dist-server/service/capability.js +20 -49
  29. package/dist-server/service/capability.js.map +1 -1
  30. package/dist-server/service/factory.d.ts +7 -12
  31. package/dist-server/service/factory.js +0 -45
  32. package/dist-server/service/factory.js.map +1 -1
  33. package/dist-server/service/index.d.ts +3 -0
  34. package/dist-server/service/index.js +6 -1
  35. package/dist-server/service/index.js.map +1 -1
  36. package/dist-server/service/providers/anthropic.d.ts +2 -0
  37. package/dist-server/service/providers/anthropic.js +16 -0
  38. package/dist-server/service/providers/anthropic.js.map +1 -1
  39. package/dist-server/service/providers/gemini.d.ts +13 -0
  40. package/dist-server/service/providers/gemini.js +52 -12
  41. package/dist-server/service/providers/gemini.js.map +1 -1
  42. package/dist-server/service/providers/openai.d.ts +6 -0
  43. package/dist-server/service/providers/openai.js +34 -8
  44. package/dist-server/service/providers/openai.js.map +1 -1
  45. package/dist-server/service/types.d.ts +16 -2
  46. package/dist-server/service/types.js.map +1 -1
  47. package/dist-server/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +6 -3
  49. package/server/service/agent/agent-directory.test.ts +206 -0
  50. package/server/service/agent/agent-directory.ts +198 -0
  51. package/server/service/agent/agent-settings-save.test.ts +151 -0
  52. package/server/service/agent/agent-settings-save.ts +205 -0
  53. package/server/service/agent/agent-spec.ts +103 -0
  54. package/server/service/agent/ai-client-for.test.ts +141 -0
  55. package/server/service/agent/ai-client-for.ts +201 -0
  56. package/server/service/agent/ai-usage-summary.test.ts +65 -0
  57. package/server/service/agent/ai-usage-summary.ts +146 -0
  58. package/server/service/agent/ai-usage.ts +97 -0
  59. package/server/service/agent/index.ts +7 -0
  60. package/server/service/agent/one-door.test.ts +57 -0
  61. package/server/service/agent/provider-models.ts +43 -0
  62. package/server/service/capability.test.ts +26 -55
  63. package/server/service/capability.ts +25 -42
  64. package/server/service/factory.ts +4 -44
  65. package/server/service/index.ts +6 -1
  66. package/server/service/providers/anthropic.ts +17 -0
  67. package/server/service/providers/gemini.ts +54 -12
  68. package/server/service/providers/openai-models.test.ts +30 -0
  69. package/server/service/providers/openai.ts +34 -8
  70. package/server/service/types.ts +18 -2
  71. package/translations/en.json +13 -0
  72. package/translations/es.json +13 -0
  73. package/translations/ja.json +13 -0
  74. package/translations/ko.json +13 -0
  75. package/translations/ms.json +13 -0
  76. 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
+ }