@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,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
|
|
3
|
-
*
|
|
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
|
-
*
|
|
7
|
-
* the
|
|
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
|
-
|
|
10
|
-
jest.mock('@things-factory/env', () => ({ config: { get: (...args: any[]) => mockConfigGet(...args) } }))
|
|
9
|
+
import { capabilityOf } from './capability'
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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('★
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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('
|
|
40
|
-
|
|
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('★
|
|
46
|
-
|
|
47
|
-
|
|
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('
|
|
62
|
-
|
|
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
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
14
|
+
import type { Domain } from '@things-factory/shell'
|
|
14
15
|
|
|
15
|
-
import {
|
|
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
|
-
/**
|
|
23
|
+
/** The provider of the agent a request without a choice gets. */
|
|
22
24
|
provider?: string
|
|
23
|
-
/**
|
|
25
|
+
/** That agent's model. */
|
|
24
26
|
model?: string
|
|
25
|
-
/** Why it is not configured, as a code: no-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
}
|