@things-factory/ai-client-base 10.1.260 → 10.1.261

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-client-base",
3
- "version": "10.1.260",
3
+ "version": "10.1.261",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -24,11 +24,11 @@
24
24
  "dependencies": {
25
25
  "@anthropic-ai/sdk": "^0.40.0",
26
26
  "@google/generative-ai": "^0.24.0",
27
- "@things-factory/auth-base": "^10.1.260",
27
+ "@things-factory/auth-base": "^10.1.261",
28
28
  "@things-factory/env": "^10.1.149",
29
- "@things-factory/setting-base": "^10.1.260",
30
- "@things-factory/shell": "^10.1.260",
29
+ "@things-factory/setting-base": "^10.1.261",
30
+ "@things-factory/shell": "^10.1.261",
31
31
  "openai": "^4.0.0"
32
32
  },
33
- "gitHead": "72de583ba0394dcf8195ef723b0ee4217507e0e5"
33
+ "gitHead": "06537757f8708e6a511c6232e8792f18fdd6a986"
34
34
  }
@@ -48,6 +48,7 @@ registerAIProvider('fake', config => ({
48
48
  options.onUsage?.({ model: 'fake-1-0607', inputTokens: 300, outputTokens: 5 })
49
49
  return { toolCalls: [], stopReason: 'end_turn' }
50
50
  },
51
+ transcribe: async (_audio, options: any) => { options.onUsage?.({ model: 'fake-speech' }); return { text: 'spoken', segments: [{ start: 0, end: 1, text: 'spoken' }] } },
51
52
  listModels: async () => ['fake-1']
52
53
  }))
53
54
 
@@ -157,3 +158,13 @@ it('a failure is named by what the provider said', () => {
157
158
  it('a call is grouped by the UTC hour it started in', () => {
158
159
  expect(hourOf(new Date('2026-10-12T05:47:13.120Z')).toISOString()).toBe('2026-10-12T05:00:00.000Z')
159
160
  })
161
+
162
+ it('speech transcription uses the same tenant usage boundary without fabricated token counts', async () => {
163
+ ;(readAiAgentDirectory as jest.Mock).mockResolvedValue({ entries: [entry('speech')], defaultAgentId: 'speech' })
164
+ const client = await aiClientFor({ domain: { id: 'office' } as any, user: { id: 'manager' }, modeKind: 'learning.material-transcription' })
165
+ expect(await client.transcribe!(Buffer.from('audio'))).toEqual({ text: 'spoken', segments: [{ start: 0, end: 1, text: 'spoken' }] })
166
+ await aiUsageRecorded()
167
+ expect(saved).toHaveLength(1)
168
+ expect(saved[0]).toMatchObject({ operation: 'transcribe', model: 'fake-speech', domain: { id: 'office' }, creator: { id: 'manager' } })
169
+ expect(saved[0].inputTokens).toBeUndefined()
170
+ })
@@ -156,6 +156,7 @@ function meter(client: AIClient, entry: AiAgentEntry, request: AiClientRequest):
156
156
  agent: { id: spec.id, name: spec.name, provider: spec.provider, model: spec.model },
157
157
  chat: (messages, options) => measured('chat', options, o => client.chat(messages, o)),
158
158
  generateJSON: (prompt, options) => measured('generateJSON', options, o => client.generateJSON(prompt, o)),
159
+ ...(client.transcribe && { transcribe: (audio, options) => measured('transcribe', options, o => client.transcribe!(audio, o)) }),
159
160
  ...(client.vision && {
160
161
  vision: (image, prompt, options) => measured('vision', options, o => client.vision!(image, prompt, o))
161
162
  }),
@@ -52,7 +52,7 @@ export class AiUsage {
52
52
  modeKind?: string
53
53
 
54
54
  @Column()
55
- @Field(type => String, { description: 'chat, chatWithTools, generateJSON or vision.' })
55
+ @Field(type => String, { description: 'chat, chatWithTools, generateJSON, vision or transcribe.' })
56
56
  operation: string
57
57
 
58
58
  @Column({ nullable: true })
@@ -7,6 +7,7 @@
7
7
  * organization 필드 지원.
8
8
  */
9
9
  import OpenAI from 'openai'
10
+ import { toFile } from 'openai/uploads'
10
11
  import type {
11
12
  AIChatOptions,
12
13
  AIClient,
@@ -42,6 +43,17 @@ export class OpenAIAIClient implements AIClient {
42
43
  this.id = `openai:${this.defaultModel}`
43
44
  }
44
45
 
46
+ /** Whisper accepts the small MP3 prepared by the material reader and returns speech timestamps. */
47
+ async transcribe(audio: Buffer, options: AIChatOptions = {}) {
48
+ const model = 'whisper-1'
49
+ const answer = await this.sdk.audio.transcriptions.create({
50
+ file: await toFile(audio, 'speech.mp3', { type: 'audio/mpeg' }),
51
+ model, response_format: 'verbose_json', timestamp_granularities: ['segment']
52
+ }, requestOptionsOf(options))
53
+ options.onUsage?.({ model }) // Speech billing does not report text token counts.
54
+ return { text: answer.text, segments: (answer.segments ?? []).map(s => ({ start: s.start, end: s.end, text: s.text })) }
55
+ }
56
+
45
57
  async chat(messages: AIMessage[], options: AIChatOptions = {}): Promise<string> {
46
58
  const sdkMessages = buildSDKMessages(messages, options.systemPrompt)
47
59
  const response = await this.sdk.chat.completions.create({
@@ -0,0 +1,18 @@
1
+ import { OpenAIAIClient } from './openai'
2
+
3
+ it('transcribes speech with timestamps, cancellation and usage reporting through the configured SDK', async () => {
4
+ const client: any = new OpenAIAIClient({ provider: 'openai', apiKey: 'test-only' })
5
+ const calls: any[] = []
6
+ client.sdk = { audio: { transcriptions: { create: async (body: any, request: any) => {
7
+ calls.push({ body, request })
8
+ return { text: 'Hello', segments: [{ start: 0, end: 1.5, text: 'Hello' }] }
9
+ } } } }
10
+ const usage: any[] = []
11
+ const { signal } = new AbortController()
12
+ const answer = await client.transcribe(Buffer.from('test audio'), { signal, onUsage: (u: any) => usage.push(u) })
13
+ expect(answer).toEqual({ text: 'Hello', segments: [{ start: 0, end: 1.5, text: 'Hello' }] })
14
+ expect(calls[0].body.model).toBe('whisper-1')
15
+ expect(calls[0].body.response_format).toBe('verbose_json')
16
+ expect(calls[0].request.signal).toBe(signal)
17
+ expect(usage).toEqual([{ model: 'whisper-1' }])
18
+ })
@@ -198,6 +198,9 @@ export interface AIClient {
198
198
  options?: AIToolChatOptions
199
199
  ): Promise<AIToolChatResult>
200
200
 
201
+ /** Optional speech recognition. The provider returns timestamped speech, never inferred video visuals. */
202
+ transcribe?(audio: Buffer, options?: AIChatOptions): Promise<{ text: string; segments: { start: number; end: number; text: string }[] }>
203
+
201
204
  /** The ids of the conversation models this key may call, sorted. Reading it also proves the key is accepted. */
202
205
  listModels(): Promise<string[]>
203
206
  }