@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.
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 +5 -2
  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,65 @@
1
+ /*
2
+ * What this guards: **usage is counted per bucket × agent × person in the domain's own time zone, and a domain above
3
+ * never sees the people of a domain below** (design/ai/agent-settings.md §6-2).
4
+ */
5
+ import { rollUp } from './ai-usage-summary'
6
+
7
+ const hour = (iso: string, extra: any = {}) => ({
8
+ hourStart: iso,
9
+ agentId: 'a1',
10
+ provider: 'openai',
11
+ model: 'gpt-4o',
12
+ domainId: 'hq',
13
+ domainName: '본사',
14
+ userId: 'u1',
15
+ userName: '김철수',
16
+ calls: '2',
17
+ inputTokens: '100',
18
+ outputTokens: '10',
19
+ failures: '0',
20
+ ...extra
21
+ })
22
+
23
+ const names = new Map([['a1', 'GPT-4o']])
24
+
25
+ it('★ hours roll up into the domain\'s days - 15:00Z on the 11th is the 12th in Seoul', () => {
26
+ const rows = rollUp(
27
+ [hour('2026-10-11T14:00:00.000Z'), hour('2026-10-11T15:00:00.000Z'), hour('2026-10-12T03:00:00.000Z', { failures: '1' })],
28
+ 'day',
29
+ 'Asia/Seoul',
30
+ 'hq',
31
+ names
32
+ )
33
+ expect(rows.map(one => [one.bucket, one.calls, one.inputTokens, one.failures])).toEqual([
34
+ ['2026-10-12', 4, 200, 1],
35
+ ['2026-10-11', 2, 100, 0]
36
+ ])
37
+ expect(rows[0]).toMatchObject({ agentName: 'GPT-4o', userName: '김철수', domainName: '본사' })
38
+ })
39
+
40
+ it('hour and month buckets are named in the domain\'s time zone too', () => {
41
+ expect(rollUp([hour('2026-10-31T15:00:00.000Z')], 'hour', 'Asia/Seoul', 'hq', names)[0].bucket).toBe('2026-11-01 00:00')
42
+ expect(rollUp([hour('2026-10-31T15:00:00.000Z')], 'month', 'Asia/Seoul', 'hq', names)[0].bucket).toBe('2026-11')
43
+ })
44
+
45
+ it('★ a domain below is one line per domain - its people are not named above', () => {
46
+ const rows = rollUp(
47
+ [
48
+ hour('2026-10-12T01:00:00.000Z', { domainId: 'plant', domainName: '서울공장', userId: 'p1', userName: '이영희' }),
49
+ hour('2026-10-12T02:00:00.000Z', { domainId: 'plant', domainName: '서울공장', userId: 'p2', userName: '박민수' })
50
+ ],
51
+ 'day',
52
+ 'Asia/Seoul',
53
+ 'hq',
54
+ names
55
+ )
56
+ expect(rows).toEqual([expect.objectContaining({ domainName: '서울공장', calls: 4 })])
57
+ expect(rows[0].userId).toBeUndefined()
58
+ expect(rows[0].userName).toBeUndefined()
59
+ })
60
+
61
+ it('a deleted agent keeps its usage, without a name', () => {
62
+ const [row] = rollUp([hour('2026-10-12T01:00:00.000Z', { agentId: 'gone' })], 'day', 'Asia/Seoul', 'hq', names)
63
+ expect(row).toMatchObject({ agentId: 'gone', calls: 2 })
64
+ expect(row.agentName).toBeUndefined()
65
+ })
@@ -0,0 +1,146 @@
1
+ import { Domain, getRepository } from '@things-factory/shell'
2
+
3
+ import { fullDomain, readAiAgentDirectory, readDescendantDomains } from './agent-directory.js'
4
+ import type { AiUsage } from './ai-usage.js'
5
+
6
+ export type AiUsageBucketSize = 'hour' | 'day' | 'month'
7
+
8
+ export interface AiUsageFilter {
9
+ from: Date
10
+ to: Date
11
+ bucket: AiUsageBucketSize
12
+ agentIds?: string[]
13
+ userIds?: string[]
14
+ /** Also count what domains below did with this domain's agents - per domain, without their people. */
15
+ includeDescendants?: boolean
16
+ }
17
+
18
+ export interface AiUsageBucketRow {
19
+ /** The bucket in the domain's time zone: `2026-10-12 14:00`, `2026-10-12` or `2026-10`. */
20
+ bucket: string
21
+ agentId?: string
22
+ agentName?: string
23
+ provider: string
24
+ model: string
25
+ /** The domain the calls were made in. */
26
+ domainId: string
27
+ domainName: string
28
+ /** Empty on a row of a domain below, and for calls with no person. */
29
+ userId?: string
30
+ userName?: string
31
+ calls: number
32
+ inputTokens: number
33
+ outputTokens: number
34
+ failures: number
35
+ }
36
+
37
+ /**
38
+ * Usage per bucket × agent × person (design/ai/agent-settings.md §6-2).
39
+ *
40
+ * The database groups by the stored UTC hour, which every database can do; the hours are then rolled up into the
41
+ * domain's days or months here. People of a domain below are never named to the domain above.
42
+ */
43
+ export async function summarizeAiUsage(given: Domain, filter: AiUsageFilter): Promise<AiUsageBucketRow[]> {
44
+ /* The time zone and the chain are the domain row's. */
45
+ const domain = await fullDomain(given)
46
+ const below = filter.includeDescendants ? await readDescendantDomains(domain) : []
47
+
48
+ const query = getRepository<AiUsage>('AiUsage')
49
+ .createQueryBuilder('usage')
50
+ .leftJoin('usage.domain', 'domain')
51
+ .leftJoin('usage.creator', 'creator')
52
+ .select('usage.hourStart', 'hourStart')
53
+ .addSelect('usage.agentId', 'agentId')
54
+ .addSelect('usage.provider', 'provider')
55
+ .addSelect('usage.model', 'model')
56
+ .addSelect('domain.id', 'domainId')
57
+ .addSelect('domain.name', 'domainName')
58
+ .addSelect('creator.id', 'userId')
59
+ .addSelect('creator.name', 'userName')
60
+ .addSelect('COUNT(*)', 'calls')
61
+ .addSelect('SUM(COALESCE(usage.inputTokens, 0))', 'inputTokens')
62
+ .addSelect('SUM(COALESCE(usage.outputTokens, 0))', 'outputTokens')
63
+ .addSelect(`SUM(CASE WHEN usage.outcome = 'ok' THEN 0 ELSE 1 END)`, 'failures')
64
+ .where('usage.hourStart >= :from AND usage.hourStart < :to', { from: filter.from, to: filter.to })
65
+ .groupBy('usage.hourStart')
66
+ .addGroupBy('usage.agentId')
67
+ .addGroupBy('usage.provider')
68
+ .addGroupBy('usage.model')
69
+ .addGroupBy('domain.id')
70
+ .addGroupBy('domain.name')
71
+ .addGroupBy('creator.id')
72
+ .addGroupBy('creator.name')
73
+
74
+ if (below.length) {
75
+ query.andWhere('(domain.id = :here OR (domain.id IN (:...below) AND usage.agentDomainId = :here))', {
76
+ here: domain.id,
77
+ below: below.map(one => one.id)
78
+ })
79
+ } else {
80
+ query.andWhere('domain.id = :here', { here: domain.id })
81
+ }
82
+ if (filter.agentIds?.length) query.andWhere('usage.agentId IN (:...agentIds)', { agentIds: filter.agentIds })
83
+ if (filter.userIds?.length) query.andWhere('creator.id IN (:...userIds)', { userIds: filter.userIds })
84
+
85
+ const [hours, directory] = await Promise.all([query.getRawMany(), readAiAgentDirectory(domain)])
86
+ const agentNameOf = new Map(directory.entries.map(one => [one.spec.id, one.spec.name]))
87
+ return rollUp(hours, filter.bucket, domain.timezone, domain.id, agentNameOf)
88
+ }
89
+
90
+ export function rollUp(
91
+ hours: any[],
92
+ bucket: AiUsageBucketSize,
93
+ timezone: string | undefined,
94
+ hereId: string,
95
+ agentNameOf: Map<string, string>
96
+ ): AiUsageBucketRow[] {
97
+ const label = bucketLabeller(bucket, timezone)
98
+ const rows = new Map<string, AiUsageBucketRow>()
99
+
100
+ for (const hour of hours) {
101
+ const below = hour.domainId !== hereId
102
+ const userId = below ? undefined : hour.userId ?? undefined
103
+ const key = [label(new Date(hour.hourStart)), hour.agentId, hour.provider, hour.model, hour.domainId, userId].join('\u0000')
104
+ const row =
105
+ rows.get(key) ??
106
+ rows
107
+ .set(key, {
108
+ bucket: label(new Date(hour.hourStart)),
109
+ ...(hour.agentId && { agentId: hour.agentId, agentName: agentNameOf.get(hour.agentId) }),
110
+ provider: hour.provider,
111
+ model: hour.model,
112
+ domainId: hour.domainId,
113
+ domainName: hour.domainName,
114
+ ...(userId && { userId, userName: hour.userName }),
115
+ calls: 0,
116
+ inputTokens: 0,
117
+ outputTokens: 0,
118
+ failures: 0
119
+ })
120
+ .get(key)!
121
+ row.calls += Number(hour.calls) || 0
122
+ row.inputTokens += Number(hour.inputTokens) || 0
123
+ row.outputTokens += Number(hour.outputTokens) || 0
124
+ row.failures += Number(hour.failures) || 0
125
+ }
126
+
127
+ return [...rows.values()].sort((a, b) => b.bucket.localeCompare(a.bucket) || b.calls - a.calls)
128
+ }
129
+
130
+ /** Names the bucket a UTC moment falls in, in the domain's time zone (the server's when the domain has none). */
131
+ function bucketLabeller(bucket: AiUsageBucketSize, timezone?: string): (moment: Date) => string {
132
+ const format = new Intl.DateTimeFormat('en-CA', {
133
+ timeZone: timezone || undefined,
134
+ year: 'numeric',
135
+ month: '2-digit',
136
+ day: '2-digit',
137
+ hour: '2-digit',
138
+ hourCycle: 'h23'
139
+ })
140
+ return moment => {
141
+ const part = Object.fromEntries(format.formatToParts(moment).map(one => [one.type, one.value]))
142
+ if (bucket === 'month') return `${part.year}-${part.month}`
143
+ if (bucket === 'day') return `${part.year}-${part.month}-${part.day}`
144
+ return `${part.year}-${part.month}-${part.day} ${part.hour}:00`
145
+ }
146
+ }
@@ -0,0 +1,97 @@
1
+ import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm'
2
+ import { Field, ID, Int, ObjectType } from 'type-graphql'
3
+
4
+ import { User } from '@things-factory/auth-base'
5
+ import { Domain } from '@things-factory/shell'
6
+
7
+ /**
8
+ * One provider request - the metering boundary writes a row each time a model is called (ADR-0061 decision 6,
9
+ * design/ai/agent-settings.md §6).
10
+ *
11
+ * A turn that loops through tools makes several rows. The row is joined to a conversation by id only and is never
12
+ * copied onto a message. It is written once and never changed, so it has no updater.
13
+ */
14
+ @Entity()
15
+ @Index('ix_ai_usage_0', (usage: AiUsage) => [usage.domain, usage.hourStart])
16
+ @Index('ix_ai_usage_1', (usage: AiUsage) => [usage.agentDomainId, usage.hourStart])
17
+ @ObjectType({ description: 'One request an AI agent sent to its provider, with what it used.' })
18
+ export class AiUsage {
19
+ @PrimaryGeneratedColumn('uuid')
20
+ @Field(type => ID, { description: 'Row id.' })
21
+ readonly id: string
22
+
23
+ @ManyToOne(type => Domain)
24
+ @Field(type => Domain, { nullable: true, description: 'The domain the call was made in.' })
25
+ domain?: Domain
26
+
27
+ @RelationId((usage: AiUsage) => usage.domain)
28
+ domainId?: string
29
+
30
+ @Column({ nullable: true })
31
+ @Field(type => String, { nullable: true, description: 'The agent that answered.' })
32
+ agentId?: string
33
+
34
+ @Column({ nullable: true })
35
+ @Field(type => String, { nullable: true, description: 'The domain that declared the agent - a domain above when the agent was inherited.' })
36
+ agentDomainId?: string
37
+
38
+ @Column({ nullable: true })
39
+ @Field(type => String, { nullable: true, description: 'The domain whose API key answered - whose cost the call is.' })
40
+ keyDomainId?: string
41
+
42
+ @Column()
43
+ @Field(type => String, { description: 'openai, anthropic or gemini.' })
44
+ provider: string
45
+
46
+ @Column()
47
+ @Field(type => String, { description: 'The model the provider reported answering.' })
48
+ model: string
49
+
50
+ @Column({ nullable: true })
51
+ @Field(type => String, { nullable: true, description: 'The conversation mode kind, when the call was part of a conversation.' })
52
+ modeKind?: string
53
+
54
+ @Column()
55
+ @Field(type => String, { description: 'chat, chatWithTools, generateJSON or vision.' })
56
+ operation: string
57
+
58
+ @Column({ nullable: true })
59
+ @Field(type => String, { nullable: true, description: 'The conversation the call belongs to, by id only.' })
60
+ sessionId?: string
61
+
62
+ @Column({ type: 'int', nullable: true })
63
+ @Field(type => Int, { nullable: true, description: 'Input tokens the provider reported.' })
64
+ inputTokens?: number
65
+
66
+ @Column({ type: 'int', nullable: true })
67
+ @Field(type => Int, { nullable: true, description: 'Output tokens the provider reported.' })
68
+ outputTokens?: number
69
+
70
+ @Column({ type: 'int' })
71
+ @Field(type => Int, { description: 'How long the request took, in milliseconds.' })
72
+ durationMs: number
73
+
74
+ /** `ok`, or why it failed: key-rejected · rate-limited · provider-error. */
75
+ @Column()
76
+ @Field(type => String, { description: 'ok, or why the request failed: key-rejected, rate-limited or provider-error.' })
77
+ outcome: string
78
+
79
+ /**
80
+ * The UTC hour the call started in. Usage is grouped by this column, which every database can do; days and months
81
+ * are rolled up from it in the domain's time zone.
82
+ */
83
+ @Column()
84
+ @Field(type => Date, { description: 'The UTC hour the call started in.' })
85
+ hourStart: Date
86
+
87
+ @ManyToOne(type => User, { nullable: true })
88
+ @Field(type => User, { nullable: true, description: 'The person who made the call. Empty for a call with no person, such as a webhook.' })
89
+ creator?: User
90
+
91
+ @RelationId((usage: AiUsage) => usage.creator)
92
+ creatorId?: string
93
+
94
+ @CreateDateColumn()
95
+ @Field(type => Date, { description: 'When the call was made.' })
96
+ createdAt: Date
97
+ }
@@ -0,0 +1,7 @@
1
+ export * from './agent-spec.js'
2
+ export * from './agent-directory.js'
3
+ export * from './ai-client-for.js'
4
+ export * from './provider-models.js'
5
+ export * from './agent-settings-save.js'
6
+ export * from './ai-usage-summary.js'
7
+ export { AiUsage } from './ai-usage.js'
@@ -0,0 +1,57 @@
1
+ /*
2
+ * What this guards: **every call that reaches a model goes through aiClientFor** - the door that picks the domain's
3
+ * agent and writes the usage row (ADR-0061 decision 6, design/ai/agent-settings.md §4).
4
+ *
5
+ * A module that imports a provider SDK, or builds a client of its own, reaches a provider with no domain, no agent and
6
+ * no usage row - and with a key from somewhere other than the domain's settings. So this counts, across the
7
+ * framework's sources, what does either: only this package's adapters and its own door may.
8
+ */
9
+ import { readdirSync, readFileSync, statSync } from 'fs'
10
+ import path from 'path'
11
+
12
+ const PACKAGES = path.resolve(__dirname, '../../../..')
13
+ const HOME = path.join(PACKAGES, 'ai-client-base')
14
+
15
+ function sources(dir: string, out: string[] = []): string[] {
16
+ for (const entry of readdirSync(dir)) {
17
+ if (entry === 'node_modules' || entry.startsWith('dist') || entry.startsWith('.')) continue
18
+ const full = path.join(dir, entry)
19
+ if (statSync(full).isDirectory()) sources(full, out)
20
+ else if (/\.ts$/.test(entry) && !/\.(test|spec)\.ts$/.test(entry) && !entry.endsWith('.d.ts')) out.push(full)
21
+ }
22
+ return out
23
+ }
24
+
25
+ const files = readdirSync(PACKAGES)
26
+ .flatMap(name => ['server', 'client'].map(part => path.join(PACKAGES, name, part)))
27
+ .filter(dir => {
28
+ try {
29
+ return statSync(dir).isDirectory()
30
+ } catch {
31
+ return false
32
+ }
33
+ })
34
+ .flatMap(dir => sources(dir))
35
+
36
+ const SDK = /from ['"](openai|@anthropic-ai\/sdk|@google\/generative-ai)['"]|require\(['"](openai|@anthropic-ai\/sdk|@google\/generative-ai)['"]\)/
37
+ const OWN_CLIENT = /\bcreateAIClient\s*\(/
38
+
39
+ it('reads the framework\'s sources', () => {
40
+ expect(files.length).toBeGreaterThan(1000)
41
+ })
42
+
43
+ it('★ only ai-client-base\'s adapters import a provider SDK', () => {
44
+ const outside = files.filter(file => SDK.test(readFileSync(file, 'utf8')) && !file.startsWith(path.join(HOME, 'server/service/providers')))
45
+ expect(outside.map(file => path.relative(PACKAGES, file))).toEqual([])
46
+ })
47
+
48
+ it('★ no module builds a provider client of its own - it asks aiClientFor for the domain\'s agent', () => {
49
+ const outside = files.filter(file => OWN_CLIENT.test(readFileSync(file, 'utf8')) && !file.startsWith(HOME))
50
+ expect(outside.map(file => path.relative(PACKAGES, file))).toEqual([])
51
+ })
52
+
53
+ it('★ nothing reads a deployment-wide AI configuration any more', () => {
54
+ const global = /config\.get\(\s*['"](aiClient|openai)['"]|getDefaultAIClient|setDefaultAIClient/
55
+ const readers = files.filter(file => global.test(readFileSync(file, 'utf8')))
56
+ expect(readers.map(file => path.relative(PACKAGES, file))).toEqual([])
57
+ })
@@ -0,0 +1,43 @@
1
+ import { Refusal } from '@things-factory/shell'
2
+
3
+ import { createAIClient } from '../factory.js'
4
+ import { type AiAgentProvider, isAiAgentProvider } from './agent-spec.js'
5
+ import { outcomeOf } from './ai-client-for.js'
6
+
7
+ export interface ProviderModelsRequest {
8
+ provider: string
9
+ apiKey: string
10
+ baseURL?: string
11
+ organization?: string
12
+ }
13
+
14
+ /**
15
+ * The conversation models a key may call, read from the provider (design/ai/agent-settings.md §3-4). A model is only
16
+ * ever picked from this list. Reading it also proves the key: a refused key and an unreachable provider come back as
17
+ * reasons, never as an empty list.
18
+ */
19
+ export async function listProviderModels(request: ProviderModelsRequest): Promise<string[]> {
20
+ if (!isAiAgentProvider(request.provider)) {
21
+ throw new Refusal('ai-agent-provider-unknown', { provider: request.provider }, `"${request.provider}" is not a provider an agent can use.`)
22
+ }
23
+ if (!request.apiKey?.trim()) {
24
+ throw new Refusal('ai-agent-key-required', {}, 'Enter the API key first.')
25
+ }
26
+
27
+ const client = createAIClient({
28
+ provider: request.provider as AiAgentProvider,
29
+ apiKey: request.apiKey.trim(),
30
+ ...(request.baseURL && { baseURL: request.baseURL }),
31
+ ...(request.organization && { organization: request.organization })
32
+ })
33
+
34
+ try {
35
+ return await client.listModels()
36
+ } catch (error) {
37
+ /* Gemini answers a wrong key with 400 ("API key not valid"); a list request has no other input to be wrong. */
38
+ if (outcomeOf(error) === 'key-rejected' || Number((error as any)?.status) === 400) {
39
+ throw new Refusal('ai-provider-key-rejected', { provider: request.provider }, 'The provider did not accept this API key.')
40
+ }
41
+ throw new Refusal('ai-provider-unreachable', { provider: request.provider }, 'The provider could not be reached.')
42
+ }
43
+ }
@@ -1,69 +1,40 @@
1
1
  /*
2
- * What this guards: a screen can ask whether this deployment has an AI, instead of finding out by
3
- * pressing a button that fails.
2
+ * What this guards: a screen can ask whether this domain has an AI, instead of finding out by pressing a button that
3
+ * fails.
4
4
  *
5
- * The defect this replaces: twin decided the same question from whether a mutation had thrown once.
6
- * The mutation stopped being called, the flag stayed true, and deployments with no provider showed
7
- * the button anyway (standard A7-a).
5
+ * The defect this replaces: twin decided the same question from whether a mutation had thrown once. The mutation
6
+ * stopped being called, the flag stayed true, and deployments with no provider showed the button anyway (standard A7-a).
7
+ * Agents are set per domain now, so the answer is read from the domain's agents.
8
8
  */
9
- const mockConfigGet = jest.fn()
10
- jest.mock('@things-factory/env', () => ({ config: { get: (...args: any[]) => mockConfigGet(...args) } }))
9
+ import { capabilityOf } from './capability'
11
10
 
12
- import { aiCapability } from './capability'
13
- import { registerAIProvider, setDefaultAIClient } from './factory'
14
-
15
- beforeEach(() => {
16
- mockConfigGet.mockReset()
17
- setDefaultAIClient(undefined as any)
18
- })
19
-
20
- it('★ a configured deployment says so, and names what answers', () => {
21
- mockConfigGet.mockReturnValue(null)
22
- setDefaultAIClient({ id: 'anthropic:claude-sonnet-5' } as any)
23
-
24
- expect(aiCapability()).toEqual({ state: 'configured', provider: 'anthropic', model: 'claude-sonnet-5' })
11
+ const entry = (id: string, extra: any = {}) => ({
12
+ spec: { id, name: id, provider: 'anthropic', model: 'claude-sonnet-5', active: true },
13
+ turnedOff: false,
14
+ usable: true,
15
+ ...extra
25
16
  })
26
17
 
27
- it('★ no configuration at all is "not configured", with the reason — never silence', () => {
28
- mockConfigGet.mockReturnValue(null)
29
-
30
- expect(aiCapability()).toEqual({ state: 'not-configured', reason: 'no-config' })
31
- })
32
-
33
- it('a configuration naming a provider nobody registered says which one', () => {
34
- mockConfigGet.mockReturnValue({ provider: 'nonesuch', model: 'x' })
35
-
36
- expect(aiCapability()).toEqual({ state: 'not-configured', provider: 'nonesuch', reason: 'unknown-provider' })
18
+ it('★ a domain with a usable default says so, and names what answers', () => {
19
+ expect(capabilityOf({ entries: [entry('a')] as any, defaultAgentId: 'a' })).toEqual({
20
+ state: 'configured',
21
+ provider: 'anthropic',
22
+ model: 'claude-sonnet-5'
23
+ })
37
24
  })
38
25
 
39
- it('a configuration with no provider field is its own reason', () => {
40
- mockConfigGet.mockReturnValue({ model: 'x' })
41
-
42
- expect(aiCapability()).toEqual({ state: 'not-configured', reason: 'no-provider' })
26
+ it('★ no agent at all is "not configured", with the reason - never silence', () => {
27
+ expect(capabilityOf({ entries: [] })).toEqual({ state: 'not-configured', reason: 'no-agent' })
43
28
  })
44
29
 
45
- it('★ a provider whose adapter throws is not configured — it does not throw at the caller', () => {
46
- registerAIProvider('explodes', () => {
47
- throw new Error('no api key')
30
+ it('★ agents with no key are their own reason - every request would fail at the provider', () => {
31
+ expect(capabilityOf({ entries: [entry('a', { usable: false, keyState: 'missing' })] as any })).toEqual({
32
+ state: 'not-configured',
33
+ reason: 'no-agent-key'
48
34
  })
49
- mockConfigGet.mockReturnValue({ provider: 'explodes', model: 'x' })
50
-
51
- expect(aiCapability()).toEqual({ state: 'not-configured', provider: 'explodes', model: 'x', reason: 'no-adapter' })
52
- })
53
-
54
- it('★ a key slot with nothing in it is not configured - every request would fail at the provider', () => {
55
- mockConfigGet.mockReturnValue({ provider: 'gemini', model: 'gemini-3.5-flash', apiKey: undefined })
56
- setDefaultAIClient({ id: 'gemini:gemini-3.5-flash' } as any)
57
-
58
- expect(aiCapability()).toEqual({ state: 'not-configured', provider: 'gemini', model: 'gemini-3.5-flash', reason: 'no-api-key' })
59
35
  })
60
36
 
61
- it('a configuration with a key, or one that names no key slot at all, stays configured', () => {
62
- setDefaultAIClient({ id: 'gemini:gemini-3.5-flash' } as any)
63
-
64
- mockConfigGet.mockReturnValue({ provider: 'gemini', model: 'gemini-3.5-flash', apiKey: 'k' })
65
- expect(aiCapability().state).toBe('configured')
66
-
67
- mockConfigGet.mockReturnValue({ provider: 'local', model: 'm' })
68
- expect(aiCapability().state).toBe('configured')
37
+ it('agents that are all off are their own reason', () => {
38
+ const entries = [entry('a', { usable: false, spec: { id: 'a', active: false } }), entry('b', { usable: false, turnedOff: true })]
39
+ expect(capabilityOf({ entries: entries as any })).toEqual({ state: 'not-configured', reason: 'no-usable-agent' })
69
40
  })
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Is there an AI on this deployment, and what is missing — asked before anything is tried.
2
+ * Is there an AI for this domain, and what is missing — asked before anything is tried.
3
3
  *
4
4
  * The twin had a button that asked the assistant, and whether to show it was decided by whether a
5
5
  * now-dead mutation had failed once. When the dock moved to the neutral door nothing called that
@@ -7,60 +7,43 @@
7
7
  * with no provider at all and failed when pressed (standard A7-a).
8
8
  *
9
9
  * A capability is read, not discovered by failing (ADR-0042 decision ②). It lives here because the
10
- * provider configuration does, and the answer has three states — the third is the one a screen
11
- * must not fold away: **not asked yet** is not "configured".
10
+ * agents do, and the answer has three states — the third is the one a screen must not fold away:
11
+ * **not asked yet** is not "configured". Since agents are set per domain (design/ai/agent-settings.md),
12
+ * so is the answer.
12
13
  */
13
- import { config as envConfig } from '@things-factory/env'
14
+ import type { Domain } from '@things-factory/shell'
14
15
 
15
- import { getDefaultAIClient, listRegisteredProviders } from './factory.js'
16
+ import { type AiAgentDirectory, readAiAgentDirectory } from './agent/agent-directory.js'
17
+ import { isAIClientForTesting } from './agent/ai-client-for.js'
16
18
 
17
19
  export type AiCapabilityState = 'configured' | 'not-configured'
18
20
 
19
21
  export interface AiCapability {
20
22
  state: AiCapabilityState
21
- /** Which provider answers, when one does — for the person reading a boot log, not for a screen. */
23
+ /** The provider of the agent a request without a choice gets. */
22
24
  provider?: string
23
- /** The default model this deployment would use. */
25
+ /** That agent's model. */
24
26
  model?: string
25
- /** Why it is not configured, as a code: no-config · no-provider · unknown-provider · no-adapter · no-api-key. */
27
+ /** Why it is not configured, as a code: no-agent · no-agent-key · no-usable-agent. */
26
28
  reason?: string
27
29
  }
28
30
 
29
- /**
30
- * What this server can do, as of now.
31
- *
32
- * Creating the client is what proves a provider adapter is loaded and the configuration parses, so
33
- * this asks for it. It never throws: a deployment with no AI is a normal deployment.
34
- */
35
- export function aiCapability(): AiCapability {
36
- const cfg = envConfig.get('aiClient', null) as { provider?: string; model?: string; apiKey?: unknown } | null
37
- const client = safeDefaultClient()
38
- if (client) {
39
- const [provider, model] = String(client.id ?? '').split(':')
40
- /*
41
- A configuration that has a key slot and nothing in it - `apiKey: process.env.X` with X unset - still builds a
42
- client, and every request then fails at the provider. The dock offered a chat that could only answer "something
43
- went wrong" (operato-figure with no .env, 2026-09-25). No key is not configured.
44
- */
45
- if (cfg && 'apiKey' in cfg && !String(cfg.apiKey ?? '').trim()) {
46
- return { state: 'not-configured', provider: provider || cfg.provider, model: model || cfg.model, reason: 'no-api-key' }
47
- }
48
- return { state: 'configured', provider: provider || undefined, model: model || undefined }
49
- }
50
-
51
- if (!cfg) return { state: 'not-configured', reason: 'no-config' }
52
- if (!cfg.provider) return { state: 'not-configured', reason: 'no-provider' }
53
- if (!listRegisteredProviders().includes(cfg.provider)) {
54
- return { state: 'not-configured', provider: cfg.provider, reason: 'unknown-provider' }
31
+ /** What this domain can do, as of now. It never throws: a domain with no AI is a normal domain. */
32
+ export async function aiCapability(domain: Domain): Promise<AiCapability> {
33
+ if (isAIClientForTesting()) return { state: 'configured' }
34
+ try {
35
+ return capabilityOf(await readAiAgentDirectory(domain))
36
+ } catch (error) {
37
+ console.error('[ai-client-base] could not read the AI agents of a domain:', (error as Error)?.message)
38
+ return { state: 'not-configured', reason: 'no-agent' }
55
39
  }
56
- return { state: 'not-configured', provider: cfg.provider, model: cfg.model, reason: 'no-adapter' }
57
40
  }
58
41
 
59
- function safeDefaultClient() {
60
- try {
61
- return getDefaultAIClient()
62
- } catch {
63
- /* A broken configuration is "not configured", and it is the reason below that says which. */
64
- return undefined
65
- }
42
+ export function capabilityOf(directory: Pick<AiAgentDirectory, 'entries' | 'defaultAgentId'>): AiCapability {
43
+ const chosen = directory.entries.find(one => one.spec.id === directory.defaultAgentId && one.usable)
44
+ if (chosen) return { state: 'configured', provider: chosen.spec.provider, model: chosen.spec.model }
45
+
46
+ const live = directory.entries.filter(one => one.spec.active && !one.turnedOff)
47
+ if (!live.length) return { state: 'not-configured', reason: directory.entries.length ? 'no-usable-agent' : 'no-agent' }
48
+ return { state: 'not-configured', reason: 'no-agent-key' }
66
49
  }