@things-factory/ai-client-base 10.0.0-beta.64
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 +72 -0
- package/config/config.production.js +24 -0
- package/dist-server/index.d.ts +19 -0
- package/dist-server/index.js +23 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/service/factory.d.ts +16 -0
- package/dist-server/service/factory.js +65 -0
- package/dist-server/service/factory.js.map +1 -0
- package/dist-server/service/index.d.ts +4 -0
- package/dist-server/service/index.js +8 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/providers/anthropic.d.ts +13 -0
- package/dist-server/service/providers/anthropic.js +201 -0
- package/dist-server/service/providers/anthropic.js.map +1 -0
- package/dist-server/service/providers/gemini.d.ts +14 -0
- package/dist-server/service/providers/gemini.js +292 -0
- package/dist-server/service/providers/gemini.js.map +1 -0
- package/dist-server/service/providers/index.d.ts +6 -0
- package/dist-server/service/providers/index.js +14 -0
- package/dist-server/service/providers/index.js.map +1 -0
- package/dist-server/service/providers/openai.d.ts +13 -0
- package/dist-server/service/providers/openai.js +232 -0
- package/dist-server/service/providers/openai.js.map +1 -0
- package/dist-server/service/types.d.ts +171 -0
- package/dist-server/service/types.js +8 -0
- package/dist-server/service/types.js.map +1 -0
- package/dist-server/service/utils/index.d.ts +1 -0
- package/dist-server/service/utils/index.js +5 -0
- package/dist-server/service/utils/index.js.map +1 -0
- package/dist-server/service/utils/parse-json.d.ts +20 -0
- package/dist-server/service/utils/parse-json.js +99 -0
- package/dist-server/service/utils/parse-json.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +31 -0
- package/server/index.ts +19 -0
- package/server/service/factory.ts +68 -0
- package/server/service/index.ts +4 -0
- package/server/service/providers/anthropic.ts +252 -0
- package/server/service/providers/gemini.ts +353 -0
- package/server/service/providers/index.ts +8 -0
- package/server/service/providers/openai.ts +284 -0
- package/server/service/types.ts +197 -0
- package/server/service/utils/index.ts +1 -0
- package/server/service/utils/parse-json.ts +100 -0
- package/things-factory.config.js +1 -0
- package/tsconfig.json +9 -0
package/server/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @things-factory/ai-client-base — Provider-agnostic AI client abstraction.
|
|
3
|
+
*
|
|
4
|
+
* 사용 예:
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { createAIClient } from '@things-factory/ai-client-base'
|
|
7
|
+
*
|
|
8
|
+
* const client = createAIClient({
|
|
9
|
+
* provider: 'anthropic',
|
|
10
|
+
* model: 'claude-sonnet-4-6',
|
|
11
|
+
* apiKey: process.env.ANTHROPIC_API_KEY
|
|
12
|
+
* })
|
|
13
|
+
*
|
|
14
|
+
* const text = await client.chat([{ role: 'user', content: 'Hello' }])
|
|
15
|
+
* const json = await client.generateJSON<MyShape>('Return {"x": 1, "y": 2} as JSON')
|
|
16
|
+
* const desc = await client.vision(imageBuffer, 'What is in this image?')
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export * from './service/index.js'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Provider 등록 + Client 생성.
|
|
3
|
+
*
|
|
4
|
+
* - 어댑터 패키지(또는 동일 패키지의 providers/) 가 모듈 로드 시 `registerAIProvider` 호출.
|
|
5
|
+
* - 호출자는 `createAIClient({ provider: 'anthropic', ... })` 로 인스턴스 획득.
|
|
6
|
+
* - things-factory env config 의 `aiClient` 키가 있으면 `getDefaultAIClient()` 가 자동 생성.
|
|
7
|
+
*/
|
|
8
|
+
import { config as envConfig } from '@things-factory/env'
|
|
9
|
+
import type { AIClient, AIClientConfig, AIProviderFactory } from './types.js'
|
|
10
|
+
|
|
11
|
+
const REGISTERED_PROVIDERS = new Map<string, AIProviderFactory>()
|
|
12
|
+
|
|
13
|
+
export function registerAIProvider(name: string, factory: AIProviderFactory): void {
|
|
14
|
+
REGISTERED_PROVIDERS.set(name, factory)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function listRegisteredProviders(): string[] {
|
|
18
|
+
return Array.from(REGISTERED_PROVIDERS.keys())
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createAIClient(config: AIClientConfig): AIClient {
|
|
22
|
+
const factory = REGISTERED_PROVIDERS.get(config.provider)
|
|
23
|
+
if (!factory) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Unknown AI provider "${config.provider}". Registered: ${listRegisteredProviders().join(', ') || '(none)'}`
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
return factory(config)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let _defaultClient: AIClient | undefined
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 기본 client.
|
|
35
|
+
* 1) `setDefaultAIClient()` 로 명시 설정된 게 있으면 그것
|
|
36
|
+
* 2) env config 의 `aiClient` 키가 있으면 거기서 생성
|
|
37
|
+
* 3) 없으면 undefined (호출자는 nullable 처리)
|
|
38
|
+
*
|
|
39
|
+
* 다른 모듈 (예: codelingua) 의 `openai` 키와는 의도적으로 분리. 두 키는 서로 모르고 동작.
|
|
40
|
+
*
|
|
41
|
+
* 잘못된 config (provider 누락, 등록되지 않은 provider) 는 console.warn 후 undefined.
|
|
42
|
+
*/
|
|
43
|
+
export function getDefaultAIClient(): AIClient | undefined {
|
|
44
|
+
if (_defaultClient) return _defaultClient
|
|
45
|
+
const cfg = envConfig.get('aiClient', null) as AIClientConfig | null
|
|
46
|
+
if (!cfg) return undefined
|
|
47
|
+
if (!cfg.provider) {
|
|
48
|
+
console.warn(
|
|
49
|
+
'[ai-client-base] config.aiClient is set but missing required "provider" field. ' +
|
|
50
|
+
'See @things-factory/ai-client-base/config/config.development.js for examples.'
|
|
51
|
+
)
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
_defaultClient = createAIClient(cfg)
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.warn(
|
|
58
|
+
`[ai-client-base] Failed to create default AI client: ${(e as Error).message}. ` +
|
|
59
|
+
`Registered providers: ${listRegisteredProviders().join(', ') || '(none)'}`
|
|
60
|
+
)
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
return _defaultClient
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function setDefaultAIClient(client: AIClient | undefined): void {
|
|
67
|
+
_defaultClient = client
|
|
68
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic (Claude) AI Client adapter.
|
|
3
|
+
*
|
|
4
|
+
* 기본 모델: claude-sonnet-4-6 (구성에서 override 가능).
|
|
5
|
+
* SDK: @anthropic-ai/sdk
|
|
6
|
+
*/
|
|
7
|
+
import Anthropic from '@anthropic-ai/sdk'
|
|
8
|
+
import type {
|
|
9
|
+
AIChatOptions,
|
|
10
|
+
AIClient,
|
|
11
|
+
AIClientConfig,
|
|
12
|
+
AIContentPart,
|
|
13
|
+
AIJSONOptions,
|
|
14
|
+
AIMessage,
|
|
15
|
+
AITool,
|
|
16
|
+
AIToolCall,
|
|
17
|
+
AIToolChatOptions,
|
|
18
|
+
AIToolChatResult,
|
|
19
|
+
AIVisionOptions
|
|
20
|
+
} from '../types.js'
|
|
21
|
+
import { registerAIProvider } from '../factory.js'
|
|
22
|
+
import { parseJSONResponse } from '../utils/parse-json.js'
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MODEL = 'claude-sonnet-4-6'
|
|
25
|
+
const DEFAULT_MAX_TOKENS = 4096
|
|
26
|
+
|
|
27
|
+
export class AnthropicAIClient implements AIClient {
|
|
28
|
+
readonly id: string
|
|
29
|
+
private sdk: Anthropic
|
|
30
|
+
private defaultModel: string
|
|
31
|
+
|
|
32
|
+
constructor(config: AIClientConfig) {
|
|
33
|
+
this.sdk = new Anthropic({
|
|
34
|
+
apiKey: config.apiKey,
|
|
35
|
+
...(config.baseURL && { baseURL: config.baseURL })
|
|
36
|
+
})
|
|
37
|
+
this.defaultModel = config.model ?? DEFAULT_MODEL
|
|
38
|
+
this.id = `anthropic:${this.defaultModel}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async chat(messages: AIMessage[], options: AIChatOptions = {}): Promise<string> {
|
|
42
|
+
const systemFromMessages = messages
|
|
43
|
+
.filter(m => m.role === 'system')
|
|
44
|
+
.map(extractTextContent)
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join('\n\n')
|
|
47
|
+
const system = [options.systemPrompt, systemFromMessages].filter(Boolean).join('\n\n')
|
|
48
|
+
|
|
49
|
+
const conversation = messages
|
|
50
|
+
.filter(m => m.role !== 'system')
|
|
51
|
+
.map(toAnthropicMessage)
|
|
52
|
+
|
|
53
|
+
const response = await this.sdk.messages.create({
|
|
54
|
+
model: options.model ?? this.defaultModel,
|
|
55
|
+
max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
56
|
+
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
57
|
+
...(system && { system }),
|
|
58
|
+
...(options.stopSequences && { stop_sequences: options.stopSequences }),
|
|
59
|
+
messages: conversation
|
|
60
|
+
} as any)
|
|
61
|
+
|
|
62
|
+
return extractResponseText(response)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async generateJSON<T = unknown>(
|
|
66
|
+
prompt: string | AIMessage[],
|
|
67
|
+
options: AIJSONOptions = {}
|
|
68
|
+
): Promise<T> {
|
|
69
|
+
const messages: AIMessage[] =
|
|
70
|
+
typeof prompt === 'string' ? [{ role: 'user', content: prompt }] : prompt
|
|
71
|
+
|
|
72
|
+
const schemaHint = options.schema
|
|
73
|
+
? `\n\nResponse MUST conform to this JSON schema:\n${JSON.stringify(options.schema)}`
|
|
74
|
+
: ''
|
|
75
|
+
const enforcedSystem =
|
|
76
|
+
(options.systemPrompt ? options.systemPrompt + '\n\n' : '') +
|
|
77
|
+
'Return only valid JSON. No markdown code fences, no commentary.' +
|
|
78
|
+
schemaHint
|
|
79
|
+
|
|
80
|
+
const text = await this.chat(messages, { ...options, systemPrompt: enforcedSystem })
|
|
81
|
+
return parseJSONResponse<T>(text)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async vision(
|
|
85
|
+
image: Buffer | Uint8Array | { url: string },
|
|
86
|
+
prompt: string,
|
|
87
|
+
options: AIVisionOptions = {}
|
|
88
|
+
): Promise<string> {
|
|
89
|
+
const imagePart: AIContentPart =
|
|
90
|
+
typeof image === 'object' && 'url' in image
|
|
91
|
+
? { type: 'image', source: { kind: 'url', url: image.url } }
|
|
92
|
+
: {
|
|
93
|
+
type: 'image',
|
|
94
|
+
source: { kind: 'base64', data: image as Buffer | Uint8Array, mediaType: 'image/png' }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return this.chat(
|
|
98
|
+
[{ role: 'user', content: [imagePart, { type: 'text', text: prompt }] }],
|
|
99
|
+
options
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async chatWithTools(
|
|
104
|
+
messages: AIMessage[],
|
|
105
|
+
tools: AITool[],
|
|
106
|
+
options: AIToolChatOptions = {}
|
|
107
|
+
): Promise<AIToolChatResult> {
|
|
108
|
+
const systemFromMessages = messages
|
|
109
|
+
.filter(m => m.role === 'system')
|
|
110
|
+
.map(extractTextContent)
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.join('\n\n')
|
|
113
|
+
const system = [options.systemPrompt, systemFromMessages].filter(Boolean).join('\n\n')
|
|
114
|
+
const conversation = messages.filter(m => m.role !== 'system').map(toAnthropicMessage)
|
|
115
|
+
|
|
116
|
+
const sdkTools = tools.map(t => ({
|
|
117
|
+
name: t.name,
|
|
118
|
+
description: t.description,
|
|
119
|
+
input_schema: t.parameters as any
|
|
120
|
+
}))
|
|
121
|
+
|
|
122
|
+
const toolChoice = buildAnthropicToolChoice(options)
|
|
123
|
+
|
|
124
|
+
const response = await this.sdk.messages.create({
|
|
125
|
+
model: options.model ?? this.defaultModel,
|
|
126
|
+
max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
127
|
+
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
128
|
+
...(system && { system }),
|
|
129
|
+
...(options.stopSequences && { stop_sequences: options.stopSequences }),
|
|
130
|
+
messages: conversation,
|
|
131
|
+
tools: sdkTools,
|
|
132
|
+
...(toolChoice && { tool_choice: toolChoice })
|
|
133
|
+
} as any)
|
|
134
|
+
|
|
135
|
+
const text = extractResponseText(response) || undefined
|
|
136
|
+
const toolCalls: AIToolCall[] = Array.isArray(response.content)
|
|
137
|
+
? response.content
|
|
138
|
+
.filter((b: any) => b && b.type === 'tool_use')
|
|
139
|
+
.map((b: any) => ({
|
|
140
|
+
id: typeof b.id === 'string' && b.id ? b.id : `call_${Math.random().toString(36).slice(2, 12)}`,
|
|
141
|
+
name: b.name,
|
|
142
|
+
arguments: (b.input && typeof b.input === 'object' ? b.input : {}) as Record<string, any>
|
|
143
|
+
}))
|
|
144
|
+
: []
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
text,
|
|
148
|
+
toolCalls,
|
|
149
|
+
stopReason: normalizeAnthropicStopReason(response.stop_reason)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── 변환 유틸 ─────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function extractTextContent(msg: AIMessage): string {
|
|
157
|
+
if (typeof msg.content === 'string') return msg.content
|
|
158
|
+
return msg.content
|
|
159
|
+
.filter(p => p.type === 'text')
|
|
160
|
+
.map(p => (p as { type: 'text'; text: string }).text)
|
|
161
|
+
.join('\n')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function toAnthropicMessage(msg: AIMessage): any {
|
|
165
|
+
if (typeof msg.content === 'string') {
|
|
166
|
+
return { role: msg.role, content: msg.content }
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
role: msg.role,
|
|
170
|
+
content: msg.content.map(toAnthropicContentBlock)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toAnthropicContentBlock(part: AIContentPart): any {
|
|
175
|
+
if (part.type === 'text') {
|
|
176
|
+
return { type: 'text', text: part.text }
|
|
177
|
+
}
|
|
178
|
+
if (part.type === 'tool_use') {
|
|
179
|
+
return { type: 'tool_use', id: part.id, name: part.name, input: part.arguments }
|
|
180
|
+
}
|
|
181
|
+
if (part.type === 'tool_result') {
|
|
182
|
+
return {
|
|
183
|
+
type: 'tool_result',
|
|
184
|
+
tool_use_id: part.toolUseId,
|
|
185
|
+
content: part.content,
|
|
186
|
+
...(part.isError && { is_error: true })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// image
|
|
190
|
+
if (part.source.kind === 'url') {
|
|
191
|
+
return { type: 'image', source: { type: 'url', url: part.source.url } }
|
|
192
|
+
}
|
|
193
|
+
const data = Buffer.isBuffer(part.source.data)
|
|
194
|
+
? part.source.data.toString('base64')
|
|
195
|
+
: Buffer.from(part.source.data).toString('base64')
|
|
196
|
+
return {
|
|
197
|
+
type: 'image',
|
|
198
|
+
source: { type: 'base64', media_type: part.source.mediaType, data }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function extractResponseText(response: any): string {
|
|
203
|
+
if (!response || !Array.isArray(response.content)) return ''
|
|
204
|
+
return response.content
|
|
205
|
+
.filter((b: any) => b && b.type === 'text')
|
|
206
|
+
.map((b: any) => b.text || '')
|
|
207
|
+
.join('')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildAnthropicToolChoice(options: AIToolChatOptions): any | undefined {
|
|
211
|
+
const choice = options.toolChoice ?? 'auto'
|
|
212
|
+
const disableParallel = options.allowParallelToolCalls === false
|
|
213
|
+
if (choice === 'none') {
|
|
214
|
+
return { type: 'none' }
|
|
215
|
+
}
|
|
216
|
+
if (choice === 'required') {
|
|
217
|
+
if (options.forceTool) {
|
|
218
|
+
return {
|
|
219
|
+
type: 'tool',
|
|
220
|
+
name: options.forceTool,
|
|
221
|
+
...(disableParallel && { disable_parallel_tool_use: true })
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
type: 'any',
|
|
226
|
+
...(disableParallel && { disable_parallel_tool_use: true })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// 'auto'
|
|
230
|
+
return {
|
|
231
|
+
type: 'auto',
|
|
232
|
+
...(disableParallel && { disable_parallel_tool_use: true })
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function normalizeAnthropicStopReason(raw: any): AIToolChatResult['stopReason'] {
|
|
237
|
+
switch (raw) {
|
|
238
|
+
case 'tool_use':
|
|
239
|
+
return 'tool_use'
|
|
240
|
+
case 'end_turn':
|
|
241
|
+
return 'end_turn'
|
|
242
|
+
case 'max_tokens':
|
|
243
|
+
return 'max_tokens'
|
|
244
|
+
case 'stop_sequence':
|
|
245
|
+
return 'stop_sequence'
|
|
246
|
+
default:
|
|
247
|
+
return raw ?? 'end_turn'
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Auto-register at module load
|
|
252
|
+
registerAIProvider('anthropic', config => new AnthropicAIClient(config))
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Gemini AI Client adapter.
|
|
3
|
+
*
|
|
4
|
+
* 기본 모델: gemini-3-pro (구성에서 override 가능).
|
|
5
|
+
* SDK: @google/generative-ai
|
|
6
|
+
*
|
|
7
|
+
* 비고: Antigravity (Google 의 agentic IDE) 가 기본 백엔드로 사용하는 모델군.
|
|
8
|
+
* 이 어댑터는 Gemini API 직접 호출 — Antigravity IDE 와는 별개.
|
|
9
|
+
*/
|
|
10
|
+
import { GoogleGenerativeAI } from '@google/generative-ai'
|
|
11
|
+
import type {
|
|
12
|
+
AIChatOptions,
|
|
13
|
+
AIClient,
|
|
14
|
+
AIClientConfig,
|
|
15
|
+
AIContentPart,
|
|
16
|
+
AIJSONOptions,
|
|
17
|
+
AIMessage,
|
|
18
|
+
AITool,
|
|
19
|
+
AIToolCall,
|
|
20
|
+
AIToolChatOptions,
|
|
21
|
+
AIToolChatResult,
|
|
22
|
+
AIVisionOptions
|
|
23
|
+
} from '../types.js'
|
|
24
|
+
import { registerAIProvider } from '../factory.js'
|
|
25
|
+
import { parseJSONResponse } from '../utils/parse-json.js'
|
|
26
|
+
|
|
27
|
+
// `gemini-3-pro` 는 2026-03-09 종료됨. 후속 권장 모델로 변경.
|
|
28
|
+
// 사용자가 자기 환경에서 활용 가능한 정확한 ID 는 ListModels 로 확인:
|
|
29
|
+
// curl "https://generativelanguage.googleapis.com/v1beta/models?key=$KEY"
|
|
30
|
+
const DEFAULT_MODEL = 'gemini-3-flash-preview'
|
|
31
|
+
const DEFAULT_MAX_TOKENS = 8192
|
|
32
|
+
|
|
33
|
+
export class GeminiAIClient implements AIClient {
|
|
34
|
+
readonly id: string
|
|
35
|
+
private sdk: GoogleGenerativeAI
|
|
36
|
+
private defaultModel: string
|
|
37
|
+
|
|
38
|
+
constructor(config: AIClientConfig) {
|
|
39
|
+
// Gemini SDK 는 환경변수 자동 인식이 없으므로 어댑터에서 명시적으로 fallback.
|
|
40
|
+
// 우선순위: config.apiKey → GOOGLE_API_KEY → GEMINI_API_KEY → GOOGLE_GENERATIVE_AI_API_KEY
|
|
41
|
+
const apiKey =
|
|
42
|
+
config.apiKey ??
|
|
43
|
+
process.env.GOOGLE_API_KEY ??
|
|
44
|
+
process.env.GEMINI_API_KEY ??
|
|
45
|
+
process.env.GOOGLE_GENERATIVE_AI_API_KEY ??
|
|
46
|
+
''
|
|
47
|
+
this.sdk = new GoogleGenerativeAI(apiKey)
|
|
48
|
+
this.defaultModel = config.model ?? DEFAULT_MODEL
|
|
49
|
+
this.id = `gemini:${this.defaultModel}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async chat(messages: AIMessage[], options: AIChatOptions = {}): Promise<string> {
|
|
53
|
+
const { systemPrompt, contents } = splitMessages(messages, options.systemPrompt)
|
|
54
|
+
const model = this.buildModel(options, systemPrompt)
|
|
55
|
+
const result = await model.generateContent({ contents } as any)
|
|
56
|
+
return result.response.text()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async generateJSON<T = unknown>(
|
|
60
|
+
prompt: string | AIMessage[],
|
|
61
|
+
options: AIJSONOptions = {}
|
|
62
|
+
): Promise<T> {
|
|
63
|
+
const messages: AIMessage[] =
|
|
64
|
+
typeof prompt === 'string' ? [{ role: 'user', content: prompt }] : prompt
|
|
65
|
+
const { systemPrompt, contents } = splitMessages(messages, options.systemPrompt)
|
|
66
|
+
|
|
67
|
+
const generationConfig: any = baseGenerationConfig(options)
|
|
68
|
+
generationConfig.responseMimeType = 'application/json'
|
|
69
|
+
if (options.schema) generationConfig.responseSchema = options.schema
|
|
70
|
+
|
|
71
|
+
const model = this.sdk.getGenerativeModel({
|
|
72
|
+
model: options.model ?? this.defaultModel,
|
|
73
|
+
...(systemPrompt && { systemInstruction: systemPrompt }),
|
|
74
|
+
generationConfig
|
|
75
|
+
})
|
|
76
|
+
const result = await model.generateContent({ contents } as any)
|
|
77
|
+
const text = result.response.text()
|
|
78
|
+
return parseJSONResponse<T>(text)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async vision(
|
|
82
|
+
image: Buffer | Uint8Array | { url: string },
|
|
83
|
+
prompt: string,
|
|
84
|
+
options: AIVisionOptions = {}
|
|
85
|
+
): Promise<string> {
|
|
86
|
+
const imagePart: AIContentPart =
|
|
87
|
+
typeof image === 'object' && 'url' in image
|
|
88
|
+
? { type: 'image', source: { kind: 'url', url: image.url } }
|
|
89
|
+
: {
|
|
90
|
+
type: 'image',
|
|
91
|
+
source: { kind: 'base64', data: image as Buffer | Uint8Array, mediaType: 'image/png' }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return this.chat(
|
|
95
|
+
[{ role: 'user', content: [imagePart, { type: 'text', text: prompt }] }],
|
|
96
|
+
options
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private buildModel(options: AIChatOptions, systemInstruction?: string) {
|
|
101
|
+
return this.sdk.getGenerativeModel({
|
|
102
|
+
model: options.model ?? this.defaultModel,
|
|
103
|
+
...(systemInstruction && { systemInstruction }),
|
|
104
|
+
generationConfig: baseGenerationConfig(options)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async chatWithTools(
|
|
109
|
+
messages: AIMessage[],
|
|
110
|
+
tools: AITool[],
|
|
111
|
+
options: AIToolChatOptions = {}
|
|
112
|
+
): Promise<AIToolChatResult> {
|
|
113
|
+
const { systemPrompt, contents } = splitMessages(messages, options.systemPrompt)
|
|
114
|
+
|
|
115
|
+
// Gemini 는 OpenAPI 3.0 subset — JSON Schema 의 일부 키를 거부함.
|
|
116
|
+
// 'additionalProperties' / '$schema' / '$defs' 등을 제거.
|
|
117
|
+
const functionDeclarations = tools.map(t => ({
|
|
118
|
+
name: t.name,
|
|
119
|
+
description: t.description,
|
|
120
|
+
parameters: sanitizeForGeminiSchema(t.parameters)
|
|
121
|
+
}))
|
|
122
|
+
|
|
123
|
+
const toolConfig = buildGeminiToolConfig(options)
|
|
124
|
+
|
|
125
|
+
const model = this.sdk.getGenerativeModel({
|
|
126
|
+
model: options.model ?? this.defaultModel,
|
|
127
|
+
...(systemPrompt && { systemInstruction: systemPrompt }),
|
|
128
|
+
generationConfig: baseGenerationConfig(options),
|
|
129
|
+
tools: [{ functionDeclarations }] as any,
|
|
130
|
+
...(toolConfig && { toolConfig })
|
|
131
|
+
} as any)
|
|
132
|
+
|
|
133
|
+
const result = await model.generateContent({ contents } as any)
|
|
134
|
+
const response: any = result.response
|
|
135
|
+
|
|
136
|
+
const candidate = response?.candidates?.[0]
|
|
137
|
+
const parts = candidate?.content?.parts ?? []
|
|
138
|
+
|
|
139
|
+
// Gemini 는 한 응답에서 (a) thinking part (p.thought === true) 와
|
|
140
|
+
// (b) 본문 text part 를 함께 emit 할 수 있다. thought 는 사용자에게 노출하면
|
|
141
|
+
// "구조화 답변 + 산문 답변" 형태의 중복으로 보이므로 제거.
|
|
142
|
+
const textParts = parts
|
|
143
|
+
.filter((p: any) => typeof p?.text === 'string' && p?.thought !== true)
|
|
144
|
+
.map((p: any) => p.text)
|
|
145
|
+
const text = textParts.join('') || undefined
|
|
146
|
+
|
|
147
|
+
// Gemini 는 functionCall 에 자체 id 가 없음 — 어댑터가 turn 내부에서 부여.
|
|
148
|
+
// id 포맷 'g:<name>:<seq>' — assistant 가 tool_result 를 만들 때 이 id 를 그대로
|
|
149
|
+
// 넘기면 toGeminiPart 가 name 을 추출해 functionResponse.name 에 채운다.
|
|
150
|
+
//
|
|
151
|
+
// thoughtSignature: Gemini 2.5/3 부터 thinking 모델이 functionCall 에 서명을 붙임.
|
|
152
|
+
// 다음 turn 에 그대로 echo 해야 400 에러 안 남 (cite: ai.google.dev/.../thought-signatures).
|
|
153
|
+
let geminiCallSeq = 0
|
|
154
|
+
const ts = Date.now()
|
|
155
|
+
const toolCalls: AIToolCall[] = parts
|
|
156
|
+
.filter((p: any) => p?.functionCall && typeof p.functionCall.name === 'string')
|
|
157
|
+
.map((p: any) => {
|
|
158
|
+
// SDK 가 camelCase 또는 snake_case 둘 중 하나로 emit — 둘 다 받음.
|
|
159
|
+
const sig: string | undefined = p.thoughtSignature ?? p.thought_signature
|
|
160
|
+
return {
|
|
161
|
+
id: `g:${p.functionCall.name}:${ts}_${geminiCallSeq++}`,
|
|
162
|
+
name: p.functionCall.name,
|
|
163
|
+
arguments:
|
|
164
|
+
p.functionCall.args && typeof p.functionCall.args === 'object'
|
|
165
|
+
? (p.functionCall.args as Record<string, any>)
|
|
166
|
+
: {},
|
|
167
|
+
...(typeof sig === 'string' && sig.length > 0 && {
|
|
168
|
+
providerMeta: { thoughtSignature: sig }
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
text,
|
|
175
|
+
toolCalls,
|
|
176
|
+
stopReason: normalizeGeminiStopReason(candidate?.finishReason, toolCalls.length > 0)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── 변환 유틸 ─────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
function baseGenerationConfig(options: AIChatOptions): any {
|
|
184
|
+
return {
|
|
185
|
+
maxOutputTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
186
|
+
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
187
|
+
...(options.stopSequences && { stopSequences: options.stopSequences })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function splitMessages(
|
|
192
|
+
messages: AIMessage[],
|
|
193
|
+
externalSystem?: string
|
|
194
|
+
): { systemPrompt: string; contents: any[] } {
|
|
195
|
+
const systemMessages = messages
|
|
196
|
+
.filter(m => m.role === 'system')
|
|
197
|
+
.map(extractTextContent)
|
|
198
|
+
.filter(Boolean)
|
|
199
|
+
const conversation = messages.filter(m => m.role !== 'system')
|
|
200
|
+
const systemPrompt = [externalSystem, ...systemMessages].filter(Boolean).join('\n\n')
|
|
201
|
+
return { systemPrompt, contents: conversation.map(toGeminiContent) }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function extractTextContent(msg: AIMessage): string {
|
|
205
|
+
if (typeof msg.content === 'string') return msg.content
|
|
206
|
+
return msg.content
|
|
207
|
+
.filter(p => p.type === 'text')
|
|
208
|
+
.map(p => (p as { type: 'text'; text: string }).text)
|
|
209
|
+
.join('\n')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function toGeminiContent(msg: AIMessage): any {
|
|
213
|
+
// Our 'assistant' role → Gemini's 'model' role
|
|
214
|
+
const role = msg.role === 'assistant' ? 'model' : msg.role
|
|
215
|
+
const parts =
|
|
216
|
+
typeof msg.content === 'string'
|
|
217
|
+
? [{ text: msg.content }]
|
|
218
|
+
: msg.content.map(toGeminiPart)
|
|
219
|
+
return { role, parts }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function toGeminiPart(part: AIContentPart): any {
|
|
223
|
+
if (part.type === 'text') {
|
|
224
|
+
return { text: part.text }
|
|
225
|
+
}
|
|
226
|
+
if (part.type === 'tool_use') {
|
|
227
|
+
// Gemini: assistant 가 호출 의도 표현. functionCall.name + args 만 가짐 (id 없음).
|
|
228
|
+
// thoughtSignature 는 thinking 모델 사용 시 필수 — providerMeta 에서 복원해 echo.
|
|
229
|
+
const sig = part.providerMeta?.thoughtSignature
|
|
230
|
+
return {
|
|
231
|
+
functionCall: { name: part.name, args: part.arguments },
|
|
232
|
+
...(typeof sig === 'string' && sig.length > 0 && { thoughtSignature: sig })
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (part.type === 'tool_result') {
|
|
236
|
+
// Gemini: user role 에 functionResponse part 로 회신. 이름은 호출했던 tool name 과
|
|
237
|
+
// 같아야 함 — assistant.ts 의 loop 에서 part 생성 시 함께 전달.
|
|
238
|
+
let parsed: any = part.content
|
|
239
|
+
try {
|
|
240
|
+
parsed = JSON.parse(part.content)
|
|
241
|
+
} catch {
|
|
242
|
+
parsed = { result: part.content }
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
functionResponse: {
|
|
246
|
+
// name 은 원래 호출했던 tool name. toolUseId 에 임베드되어 있지 않으므로
|
|
247
|
+
// assistant 가 메시지 생성 시 별도 채워줘야 함 — 여기서는 placeholder.
|
|
248
|
+
// 실제 사용 시 toolUseId 를 'tool_<name>_<seq>' 형태로 만들어 name 추출.
|
|
249
|
+
name: extractGeminiNameFromId(part.toolUseId),
|
|
250
|
+
response: parsed
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// image
|
|
255
|
+
if (part.source.kind === 'url') {
|
|
256
|
+
// Gemini SDK 는 inlineData(base64) 만 지원. URL 은 호출 전에 fetch 필요.
|
|
257
|
+
throw new Error(
|
|
258
|
+
'Gemini adapter: image URL is not supported directly. Fetch the URL and pass as Buffer.'
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
const data = Buffer.isBuffer(part.source.data)
|
|
262
|
+
? part.source.data.toString('base64')
|
|
263
|
+
: Buffer.from(part.source.data).toString('base64')
|
|
264
|
+
return {
|
|
265
|
+
inlineData: { data, mimeType: part.source.mediaType }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Gemini 의 functionCall 에는 자체 id 가 없어서 chatWithTools 가 'gemini_<ts>_<seq>'
|
|
271
|
+
* 형태의 가짜 id 를 만든다. 그런데 functionResponse 에는 name 이 필수 — assistant 의
|
|
272
|
+
* tool 실행기는 이 id 와 name 을 별도 매핑해야 한다. assistant 가 tool_result part 를
|
|
273
|
+
* 만들 때 toolUseId 에 'name:<actualName>;id:<originalId>' 같은 인코딩을 할 수 있지만
|
|
274
|
+
* 그건 invasive — 대신 toolUseId 가 'g:<name>:<id>' 형태면 name 을 직접 추출.
|
|
275
|
+
*
|
|
276
|
+
* 현재는 fallback: id 가 'g:' 로 시작하면 두 번째 segment 를 name 으로,
|
|
277
|
+
* 아니면 그대로 사용 (Gemini 가 받지 않으면 에러 — assistant 가 책임지고 인코딩).
|
|
278
|
+
*/
|
|
279
|
+
function extractGeminiNameFromId(id: string): string {
|
|
280
|
+
if (id.startsWith('g:')) {
|
|
281
|
+
const parts = id.split(':')
|
|
282
|
+
return parts[1] || id
|
|
283
|
+
}
|
|
284
|
+
return id
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* JSON Schema → Gemini OpenAPI 3.0 subset 변환.
|
|
289
|
+
* Gemini API 가 거부하는 키들 (deep) 제거.
|
|
290
|
+
* 보존: type / properties / items / required / enum / description / format / nullable
|
|
291
|
+
*/
|
|
292
|
+
const GEMINI_REJECTED_KEYS = new Set([
|
|
293
|
+
'additionalProperties',
|
|
294
|
+
'$schema',
|
|
295
|
+
'$defs',
|
|
296
|
+
'$ref',
|
|
297
|
+
'$id',
|
|
298
|
+
'definitions',
|
|
299
|
+
'patternProperties',
|
|
300
|
+
'dependencies'
|
|
301
|
+
])
|
|
302
|
+
|
|
303
|
+
function sanitizeForGeminiSchema(schema: any): any {
|
|
304
|
+
if (schema === null || schema === undefined) return schema
|
|
305
|
+
if (Array.isArray(schema)) return schema.map(sanitizeForGeminiSchema)
|
|
306
|
+
if (typeof schema !== 'object') return schema
|
|
307
|
+
const out: any = {}
|
|
308
|
+
for (const [k, v] of Object.entries(schema)) {
|
|
309
|
+
if (GEMINI_REJECTED_KEYS.has(k)) continue
|
|
310
|
+
out[k] = sanitizeForGeminiSchema(v)
|
|
311
|
+
}
|
|
312
|
+
return out
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function buildGeminiToolConfig(options: AIToolChatOptions): any | undefined {
|
|
316
|
+
const choice = options.toolChoice ?? 'auto'
|
|
317
|
+
if (choice === 'none') {
|
|
318
|
+
return { functionCallingConfig: { mode: 'NONE' } }
|
|
319
|
+
}
|
|
320
|
+
if (choice === 'required') {
|
|
321
|
+
return {
|
|
322
|
+
functionCallingConfig: {
|
|
323
|
+
mode: 'ANY',
|
|
324
|
+
...(options.forceTool && { allowedFunctionNames: [options.forceTool] })
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { functionCallingConfig: { mode: 'AUTO' } }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function normalizeGeminiStopReason(
|
|
332
|
+
raw: any,
|
|
333
|
+
hasToolCalls: boolean
|
|
334
|
+
): AIToolChatResult['stopReason'] {
|
|
335
|
+
// Gemini 는 function call 발생 시 finishReason 을 'STOP' 으로 마킹할 수 있어
|
|
336
|
+
// toolCalls 존재 여부로 우선 판정.
|
|
337
|
+
if (hasToolCalls) return 'tool_use'
|
|
338
|
+
switch (raw) {
|
|
339
|
+
case 'STOP':
|
|
340
|
+
return 'end_turn'
|
|
341
|
+
case 'MAX_TOKENS':
|
|
342
|
+
return 'max_tokens'
|
|
343
|
+
case 'SAFETY':
|
|
344
|
+
case 'RECITATION':
|
|
345
|
+
case 'OTHER':
|
|
346
|
+
return 'stop_sequence'
|
|
347
|
+
default:
|
|
348
|
+
return raw ?? 'end_turn'
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Auto-register at module load
|
|
353
|
+
registerAIProvider('gemini', config => new GeminiAIClient(config))
|