@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
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI AI Client adapter.
|
|
3
|
+
*
|
|
4
|
+
* 기본 모델: gpt-4o (구성에서 override 가능).
|
|
5
|
+
* SDK: openai
|
|
6
|
+
*
|
|
7
|
+
* 비고: legacy `openai` config 키 (codelingua 의 사용처) 도 factory.ts 가 이 어댑터로 자동 매핑.
|
|
8
|
+
* organization 필드 지원.
|
|
9
|
+
*/
|
|
10
|
+
import OpenAI from 'openai'
|
|
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
|
+
const DEFAULT_MODEL = 'gpt-4o'
|
|
28
|
+
const DEFAULT_MAX_TOKENS = 4096
|
|
29
|
+
|
|
30
|
+
export class OpenAIAIClient implements AIClient {
|
|
31
|
+
readonly id: string
|
|
32
|
+
private sdk: OpenAI
|
|
33
|
+
private defaultModel: string
|
|
34
|
+
|
|
35
|
+
constructor(config: AIClientConfig) {
|
|
36
|
+
// OpenAI SDK 는 apiKey 없을 시 OPENAI_API_KEY 환경변수 자동 인식.
|
|
37
|
+
const apiKey = config.apiKey ?? process.env.OPENAI_API_KEY
|
|
38
|
+
this.sdk = new OpenAI({
|
|
39
|
+
apiKey,
|
|
40
|
+
...(config.organization && { organization: config.organization }),
|
|
41
|
+
...(config.baseURL && { baseURL: config.baseURL })
|
|
42
|
+
})
|
|
43
|
+
this.defaultModel = config.model ?? DEFAULT_MODEL
|
|
44
|
+
this.id = `openai:${this.defaultModel}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async chat(messages: AIMessage[], options: AIChatOptions = {}): Promise<string> {
|
|
48
|
+
const sdkMessages = buildSDKMessages(messages, options.systemPrompt)
|
|
49
|
+
const response = await this.sdk.chat.completions.create({
|
|
50
|
+
model: options.model ?? this.defaultModel,
|
|
51
|
+
max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
52
|
+
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
53
|
+
...(options.stopSequences && { stop: options.stopSequences }),
|
|
54
|
+
messages: sdkMessages as any
|
|
55
|
+
})
|
|
56
|
+
return response.choices[0]?.message?.content ?? ''
|
|
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
|
+
|
|
66
|
+
const schemaHint = options.schema
|
|
67
|
+
? `\n\nResponse MUST conform to this JSON schema:\n${JSON.stringify(options.schema)}`
|
|
68
|
+
: ''
|
|
69
|
+
const enforcedSystem =
|
|
70
|
+
(options.systemPrompt ? options.systemPrompt + '\n\n' : '') +
|
|
71
|
+
'Return only valid JSON. No markdown code fences, no commentary.' +
|
|
72
|
+
schemaHint
|
|
73
|
+
|
|
74
|
+
const sdkMessages = buildSDKMessages(messages, enforcedSystem)
|
|
75
|
+
const response = await this.sdk.chat.completions.create({
|
|
76
|
+
model: options.model ?? this.defaultModel,
|
|
77
|
+
max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
78
|
+
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
79
|
+
response_format: { type: 'json_object' },
|
|
80
|
+
messages: sdkMessages as any
|
|
81
|
+
})
|
|
82
|
+
const text = response.choices[0]?.message?.content ?? '{}'
|
|
83
|
+
return parseJSONResponse<T>(text)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async vision(
|
|
87
|
+
image: Buffer | Uint8Array | { url: string },
|
|
88
|
+
prompt: string,
|
|
89
|
+
options: AIVisionOptions = {}
|
|
90
|
+
): Promise<string> {
|
|
91
|
+
const imagePart: AIContentPart =
|
|
92
|
+
typeof image === 'object' && 'url' in image
|
|
93
|
+
? { type: 'image', source: { kind: 'url', url: image.url } }
|
|
94
|
+
: {
|
|
95
|
+
type: 'image',
|
|
96
|
+
source: { kind: 'base64', data: image as Buffer | Uint8Array, mediaType: 'image/png' }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return this.chat(
|
|
100
|
+
[{ role: 'user', content: [imagePart, { type: 'text', text: prompt }] }],
|
|
101
|
+
options
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async chatWithTools(
|
|
106
|
+
messages: AIMessage[],
|
|
107
|
+
tools: AITool[],
|
|
108
|
+
options: AIToolChatOptions = {}
|
|
109
|
+
): Promise<AIToolChatResult> {
|
|
110
|
+
const sdkMessages = buildSDKMessages(messages, options.systemPrompt)
|
|
111
|
+
|
|
112
|
+
const sdkTools = tools.map(t => ({
|
|
113
|
+
type: 'function' as const,
|
|
114
|
+
function: {
|
|
115
|
+
name: t.name,
|
|
116
|
+
description: t.description,
|
|
117
|
+
parameters: t.parameters
|
|
118
|
+
}
|
|
119
|
+
}))
|
|
120
|
+
|
|
121
|
+
const toolChoice = buildOpenAIToolChoice(options)
|
|
122
|
+
|
|
123
|
+
const response = await this.sdk.chat.completions.create({
|
|
124
|
+
model: options.model ?? this.defaultModel,
|
|
125
|
+
max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
126
|
+
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
127
|
+
...(options.stopSequences && { stop: options.stopSequences }),
|
|
128
|
+
messages: sdkMessages as any,
|
|
129
|
+
tools: sdkTools,
|
|
130
|
+
...(toolChoice !== undefined && { tool_choice: toolChoice as any }),
|
|
131
|
+
...(options.allowParallelToolCalls === false && { parallel_tool_calls: false })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const choice = response.choices?.[0]
|
|
135
|
+
const message = choice?.message
|
|
136
|
+
const text = message?.content || undefined
|
|
137
|
+
|
|
138
|
+
const toolCalls: AIToolCall[] = (message?.tool_calls || []).map((tc: any) => {
|
|
139
|
+
const fn = tc.function
|
|
140
|
+
let args: Record<string, any> = {}
|
|
141
|
+
try {
|
|
142
|
+
args = fn?.arguments ? JSON.parse(fn.arguments) : {}
|
|
143
|
+
} catch {
|
|
144
|
+
args = {}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
id: typeof tc.id === 'string' && tc.id ? tc.id : `call_${Math.random().toString(36).slice(2, 12)}`,
|
|
148
|
+
name: fn?.name ?? '',
|
|
149
|
+
arguments: args
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
text,
|
|
155
|
+
toolCalls,
|
|
156
|
+
stopReason: normalizeOpenAIStopReason(choice?.finish_reason)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── 변환 유틸 ─────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
function buildSDKMessages(messages: AIMessage[], externalSystem?: string): any[] {
|
|
164
|
+
const out: any[] = []
|
|
165
|
+
|
|
166
|
+
const systemFromMessages = messages
|
|
167
|
+
.filter(m => m.role === 'system')
|
|
168
|
+
.map(extractTextContent)
|
|
169
|
+
.filter(Boolean)
|
|
170
|
+
.join('\n\n')
|
|
171
|
+
const combinedSystem = [externalSystem, systemFromMessages].filter(Boolean).join('\n\n')
|
|
172
|
+
if (combinedSystem) {
|
|
173
|
+
out.push({ role: 'system', content: combinedSystem })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const msg of messages) {
|
|
177
|
+
if (msg.role === 'system') continue
|
|
178
|
+
const sdkMsgs = toSDKMessages(msg)
|
|
179
|
+
out.push(...sdkMsgs)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return out
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 한 AIMessage 가 다중 tool_result 를 가지면 OpenAI 측에서 여러 role:'tool' 메시지로
|
|
187
|
+
* 분리해야 함 (OpenAI 는 메시지당 tool_call_id 하나만 매핑). 따라서 array 를 반환.
|
|
188
|
+
*/
|
|
189
|
+
function toSDKMessages(msg: AIMessage): any[] {
|
|
190
|
+
if (typeof msg.content === 'string') {
|
|
191
|
+
return [{ role: msg.role, content: msg.content }]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const parts = msg.content
|
|
195
|
+
const toolUseParts = parts.filter(p => p.type === 'tool_use') as any[]
|
|
196
|
+
const toolResultParts = parts.filter(p => p.type === 'tool_result') as any[]
|
|
197
|
+
const otherParts = parts.filter(p => p.type !== 'tool_use' && p.type !== 'tool_result')
|
|
198
|
+
|
|
199
|
+
// tool_result 가 있으면 각각을 role='tool' 메시지로 분리 (OpenAI 요구사항).
|
|
200
|
+
if (toolResultParts.length > 0) {
|
|
201
|
+
return toolResultParts.map((r: any) => ({
|
|
202
|
+
role: 'tool',
|
|
203
|
+
tool_call_id: r.toolUseId,
|
|
204
|
+
content: r.content
|
|
205
|
+
}))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// assistant 측 tool_use 가 있으면 message.tool_calls 로
|
|
209
|
+
const out: any = { role: msg.role }
|
|
210
|
+
if (otherParts.length > 0) {
|
|
211
|
+
out.content = otherParts.map(toSDKContentPart)
|
|
212
|
+
} else {
|
|
213
|
+
out.content = null
|
|
214
|
+
}
|
|
215
|
+
if (toolUseParts.length > 0) {
|
|
216
|
+
out.tool_calls = toolUseParts.map(p => ({
|
|
217
|
+
id: p.id,
|
|
218
|
+
type: 'function',
|
|
219
|
+
function: { name: p.name, arguments: JSON.stringify(p.arguments) }
|
|
220
|
+
}))
|
|
221
|
+
}
|
|
222
|
+
return [out]
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function toSDKContentPart(part: AIContentPart): any {
|
|
226
|
+
if (part.type === 'text') {
|
|
227
|
+
return { type: 'text', text: part.text }
|
|
228
|
+
}
|
|
229
|
+
if (part.type === 'tool_use' || part.type === 'tool_result') {
|
|
230
|
+
// 처리는 toSDKMessage 가 함 — 도달하면 버그.
|
|
231
|
+
return { type: 'text', text: '' }
|
|
232
|
+
}
|
|
233
|
+
// image
|
|
234
|
+
if (part.source.kind === 'url') {
|
|
235
|
+
return { type: 'image_url', image_url: { url: part.source.url } }
|
|
236
|
+
}
|
|
237
|
+
const b64 = Buffer.isBuffer(part.source.data)
|
|
238
|
+
? part.source.data.toString('base64')
|
|
239
|
+
: Buffer.from(part.source.data).toString('base64')
|
|
240
|
+
return {
|
|
241
|
+
type: 'image_url',
|
|
242
|
+
image_url: { url: `data:${part.source.mediaType};base64,${b64}` }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function extractTextContent(msg: AIMessage): string {
|
|
247
|
+
if (typeof msg.content === 'string') return msg.content
|
|
248
|
+
return msg.content
|
|
249
|
+
.filter(p => p.type === 'text')
|
|
250
|
+
.map(p => (p as { type: 'text'; text: string }).text)
|
|
251
|
+
.join('\n')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildOpenAIToolChoice(options: AIToolChatOptions): any {
|
|
255
|
+
const choice = options.toolChoice ?? 'auto'
|
|
256
|
+
if (choice === 'none') return 'none'
|
|
257
|
+
if (choice === 'required') {
|
|
258
|
+
if (options.forceTool) {
|
|
259
|
+
return { type: 'function', function: { name: options.forceTool } }
|
|
260
|
+
}
|
|
261
|
+
return 'required'
|
|
262
|
+
}
|
|
263
|
+
return 'auto'
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normalizeOpenAIStopReason(raw: any): AIToolChatResult['stopReason'] {
|
|
267
|
+
switch (raw) {
|
|
268
|
+
case 'tool_calls':
|
|
269
|
+
return 'tool_use'
|
|
270
|
+
case 'function_call':
|
|
271
|
+
return 'tool_use'
|
|
272
|
+
case 'stop':
|
|
273
|
+
return 'end_turn'
|
|
274
|
+
case 'length':
|
|
275
|
+
return 'max_tokens'
|
|
276
|
+
case 'content_filter':
|
|
277
|
+
return 'stop_sequence'
|
|
278
|
+
default:
|
|
279
|
+
return raw ?? 'end_turn'
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Auto-register at module load
|
|
284
|
+
registerAIProvider('openai', config => new OpenAIAIClient(config))
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic AI client types.
|
|
3
|
+
*
|
|
4
|
+
* 공통 인터페이스만 정의 — 실제 호출은 provider 별 어댑터에서.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type AIRole = 'system' | 'user' | 'assistant'
|
|
8
|
+
|
|
9
|
+
export interface AITextPart {
|
|
10
|
+
type: 'text'
|
|
11
|
+
text: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type AIImageMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'
|
|
15
|
+
|
|
16
|
+
export interface AIImagePart {
|
|
17
|
+
type: 'image'
|
|
18
|
+
source:
|
|
19
|
+
| { kind: 'base64'; data: Buffer | Uint8Array; mediaType: AIImageMediaType }
|
|
20
|
+
| { kind: 'url'; url: string }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Assistant 가 tool 을 호출하라고 한 시점의 content part.
|
|
25
|
+
* 다음 턴에서 같은 id 로 tool_result 를 매핑한다.
|
|
26
|
+
*/
|
|
27
|
+
export interface AIToolUsePart {
|
|
28
|
+
type: 'tool_use'
|
|
29
|
+
/** provider-stable id — Anthropic tool_use.id / OpenAI tool_call.id / Gemini 의 자체 매핑 */
|
|
30
|
+
id: string
|
|
31
|
+
/** tool 이름 */
|
|
32
|
+
name: string
|
|
33
|
+
/** 호출 인자 (JSON object) */
|
|
34
|
+
arguments: Record<string, any>
|
|
35
|
+
/**
|
|
36
|
+
* provider-specific opaque metadata that must be echoed back when this tool_use
|
|
37
|
+
* is referenced in subsequent turns.
|
|
38
|
+
*
|
|
39
|
+
* Gemini: `thoughtSignature` — 안 보내면 400 에러.
|
|
40
|
+
* Anthropic: 없음.
|
|
41
|
+
* OpenAI: 없음.
|
|
42
|
+
*
|
|
43
|
+
* 호출자(assistant loop)는 AIToolCall.providerMeta 를 그대로 복사해 넣는다.
|
|
44
|
+
*/
|
|
45
|
+
providerMeta?: Record<string, any>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 도구 실행 결과를 LLM 에 회신하는 user 측 content part.
|
|
50
|
+
*
|
|
51
|
+
* Anthropic: { type: 'tool_result', tool_use_id, content } — user role 에 들어감.
|
|
52
|
+
* OpenAI: tool role 메시지로 전송.
|
|
53
|
+
* Gemini: functionResponse part — user role 에 들어감.
|
|
54
|
+
*/
|
|
55
|
+
export interface AIToolResultPart {
|
|
56
|
+
type: 'tool_result'
|
|
57
|
+
/** 매핑되는 AIToolUsePart.id */
|
|
58
|
+
toolUseId: string
|
|
59
|
+
/** 도구 실행 결과 (text 또는 JSON-serializable) */
|
|
60
|
+
content: string
|
|
61
|
+
/** 실행 중 에러 발생 표시 (LLM 이 retry/대안 판단) */
|
|
62
|
+
isError?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type AIContentPart = AITextPart | AIImagePart | AIToolUsePart | AIToolResultPart
|
|
66
|
+
|
|
67
|
+
export interface AIMessage {
|
|
68
|
+
role: AIRole
|
|
69
|
+
content: string | AIContentPart[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AIChatOptions {
|
|
73
|
+
/** 모델 override (없으면 client 기본값) */
|
|
74
|
+
model?: string
|
|
75
|
+
maxTokens?: number
|
|
76
|
+
temperature?: number
|
|
77
|
+
/**
|
|
78
|
+
* system prompt — messages 의 system 메시지와 합쳐서 전달.
|
|
79
|
+
* Anthropic 의 system 파라미터 / OpenAI 의 system 메시지로 매핑.
|
|
80
|
+
*/
|
|
81
|
+
systemPrompt?: string
|
|
82
|
+
stopSequences?: string[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface AIJSONOptions extends AIChatOptions {
|
|
86
|
+
/** JSON Schema (옵션). 어댑터가 지원하면 structured output 강제 */
|
|
87
|
+
schema?: object
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface AIVisionOptions extends AIChatOptions {}
|
|
91
|
+
|
|
92
|
+
// ── Tool calling ──────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Provider-agnostic 도구 정의.
|
|
96
|
+
* Anthropic input_schema / OpenAI parameters / Gemini parameters 로 어댑터에서 정규화.
|
|
97
|
+
*/
|
|
98
|
+
export interface AITool {
|
|
99
|
+
name: string
|
|
100
|
+
description: string
|
|
101
|
+
/** JSON Schema (object 권장). enum / required / properties 등 표준 키 사용. */
|
|
102
|
+
parameters: Record<string, any>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* LLM 이 호출하라고 지시한 도구 1 건.
|
|
107
|
+
* id 는 multi-turn 흐름에서 tool_result 매핑에 사용 — 어댑터가 항상 채워야 한다.
|
|
108
|
+
*/
|
|
109
|
+
export interface AIToolCall {
|
|
110
|
+
id: string
|
|
111
|
+
name: string
|
|
112
|
+
arguments: Record<string, any>
|
|
113
|
+
/**
|
|
114
|
+
* provider-specific opaque metadata to round-trip on subsequent turns.
|
|
115
|
+
* Gemini 는 `thoughtSignature` 를 여기에 담는다 — 다음 턴에 functionCall.thoughtSignature
|
|
116
|
+
* 로 복원되어야 400 에러 회피.
|
|
117
|
+
*/
|
|
118
|
+
providerMeta?: Record<string, any>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface AIToolChatOptions extends AIChatOptions {
|
|
122
|
+
/**
|
|
123
|
+
* tool 선택 모드:
|
|
124
|
+
* - 'auto': LLM 자율 (tool 호출 또는 텍스트 응답)
|
|
125
|
+
* - 'required': 반드시 하나 이상 tool 호출
|
|
126
|
+
* - 'none': tool 무시, 텍스트만
|
|
127
|
+
*/
|
|
128
|
+
toolChoice?: 'auto' | 'required' | 'none'
|
|
129
|
+
/** 'required' 와 함께 특정 tool 강제. provider 별 지원 다름. */
|
|
130
|
+
forceTool?: string
|
|
131
|
+
/**
|
|
132
|
+
* 병렬 tool 호출 허용 여부 (default true).
|
|
133
|
+
* false 시 LLM 이 한 번에 하나의 tool 만 호출.
|
|
134
|
+
*/
|
|
135
|
+
allowParallelToolCalls?: boolean
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface AIToolChatResult {
|
|
139
|
+
/** 사용자에게 보여줄 텍스트 (tool 호출과 함께 동봉될 수도 있음) */
|
|
140
|
+
text?: string
|
|
141
|
+
/** LLM 이 호출하라고 지시한 도구들 (병렬 가능) */
|
|
142
|
+
toolCalls: AIToolCall[]
|
|
143
|
+
/**
|
|
144
|
+
* 정규화된 stop reason:
|
|
145
|
+
* 'tool_use' — tool 호출 발생
|
|
146
|
+
* 'end_turn' — 텍스트 응답 종료
|
|
147
|
+
* 'max_tokens' — 토큰 한도
|
|
148
|
+
* 'stop_sequence' — stop sequence 도달
|
|
149
|
+
* 기타 — provider 원본 그대로
|
|
150
|
+
*/
|
|
151
|
+
stopReason: 'tool_use' | 'end_turn' | 'max_tokens' | 'stop_sequence' | string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Client ────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
export interface AIClient {
|
|
157
|
+
/** 'anthropic:claude-sonnet-4-6' 등 사람이 읽을 식별자 */
|
|
158
|
+
readonly id: string
|
|
159
|
+
|
|
160
|
+
/** 텍스트 chat completion */
|
|
161
|
+
chat(messages: AIMessage[], options?: AIChatOptions): Promise<string>
|
|
162
|
+
|
|
163
|
+
/** JSON 출력 강제. 파싱된 객체 반환. */
|
|
164
|
+
generateJSON<T = unknown>(prompt: string | AIMessage[], options?: AIJSONOptions): Promise<T>
|
|
165
|
+
|
|
166
|
+
/** 이미지 입력 — 텍스트 응답. 어댑터가 vision 모델을 지원해야 함. */
|
|
167
|
+
vision?(
|
|
168
|
+
image: Buffer | Uint8Array | { url: string },
|
|
169
|
+
prompt: string,
|
|
170
|
+
options?: AIVisionOptions
|
|
171
|
+
): Promise<string>
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Tool calling — LLM 이 자유 텍스트 대신 정의된 도구를 schema-검증된 인자로 호출.
|
|
175
|
+
* 각 도구의 parameters 가 enum/required 강제 → invalid type / 누락 필드 봉쇄.
|
|
176
|
+
* 어댑터별로 Anthropic tool_use / OpenAI tools / Gemini functionCalls 로 매핑.
|
|
177
|
+
*/
|
|
178
|
+
chatWithTools?(
|
|
179
|
+
messages: AIMessage[],
|
|
180
|
+
tools: AITool[],
|
|
181
|
+
options?: AIToolChatOptions
|
|
182
|
+
): Promise<AIToolChatResult>
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface AIClientConfig {
|
|
186
|
+
/** 등록된 provider 이름 (예: 'anthropic', 'openai', 'gemini') */
|
|
187
|
+
provider: string
|
|
188
|
+
/** 기본 모델 ID */
|
|
189
|
+
model?: string
|
|
190
|
+
apiKey?: string
|
|
191
|
+
/** OpenAI organization (provider:'openai' 전용) */
|
|
192
|
+
organization?: string
|
|
193
|
+
baseURL?: string
|
|
194
|
+
[k: string]: any
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type AIProviderFactory = (config: AIClientConfig) => AIClient
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './parse-json.js'
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robust JSON parsing for LLM responses.
|
|
3
|
+
*
|
|
4
|
+
* LLM 응답은 다음 같은 변형이 흔하다:
|
|
5
|
+
* - 마크다운 코드펜스로 감싸짐 (```json ... ```)
|
|
6
|
+
* - 앞뒤 자연어 코멘트 ("Sure, here is: { ... }")
|
|
7
|
+
* - 끝에 trailing 문자 (Gemini 의 알려진 케이스: 닫는 } 가 한 번 더)
|
|
8
|
+
* - smart quote 등의 사소한 변형
|
|
9
|
+
*
|
|
10
|
+
* 이 helper 는:
|
|
11
|
+
* 1) 코드펜스를 제거
|
|
12
|
+
* 2) 첫 balanced JSON object/array 만 잘라냄 (brace counting + 문자열 인지)
|
|
13
|
+
* 3) 그래도 실패하면 raw 출력 포함한 명확한 에러
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export function parseJSONResponse<T = unknown>(text: string): T {
|
|
17
|
+
if (typeof text !== 'string') {
|
|
18
|
+
throw new Error(`AI response is not a string (got ${typeof text})`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const stripped = stripCodeFences(text).trim()
|
|
22
|
+
|
|
23
|
+
// 1) 직접 시도
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(stripped) as T
|
|
26
|
+
} catch {
|
|
27
|
+
/* fallthrough */
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 2) 첫 balanced JSON 추출 시도
|
|
31
|
+
const extracted = extractFirstBalanced(stripped)
|
|
32
|
+
if (extracted) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(extracted) as T
|
|
35
|
+
} catch (e) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`AI response is not valid JSON (after extraction): ${(e as Error).message}\n----- raw -----\n${text}`
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new Error(`AI response is not valid JSON: no balanced JSON found.\n----- raw -----\n${text}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stripCodeFences(text: string): string {
|
|
46
|
+
return text
|
|
47
|
+
.replace(/^\s*```(?:json|JSON)?\s*\n?/i, '')
|
|
48
|
+
.replace(/\n?\s*```\s*$/i, '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 첫 balanced { ... } 또는 [ ... ] 추출.
|
|
53
|
+
* 문자열 내의 brace 는 무시. escape 처리.
|
|
54
|
+
*/
|
|
55
|
+
export function extractFirstBalanced(text: string): string | null {
|
|
56
|
+
const startIdx = findFirstBraceOrBracket(text)
|
|
57
|
+
if (startIdx < 0) return null
|
|
58
|
+
|
|
59
|
+
const opener = text[startIdx]
|
|
60
|
+
const closer = opener === '{' ? '}' : ']'
|
|
61
|
+
let depth = 0
|
|
62
|
+
let inString = false
|
|
63
|
+
let escape = false
|
|
64
|
+
|
|
65
|
+
for (let i = startIdx; i < text.length; i++) {
|
|
66
|
+
const ch = text[i]
|
|
67
|
+
if (escape) {
|
|
68
|
+
escape = false
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (inString) {
|
|
72
|
+
if (ch === '\\') {
|
|
73
|
+
escape = true
|
|
74
|
+
} else if (ch === '"') {
|
|
75
|
+
inString = false
|
|
76
|
+
}
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (ch === '"') {
|
|
80
|
+
inString = true
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (ch === opener) depth++
|
|
84
|
+
else if (ch === closer) {
|
|
85
|
+
depth--
|
|
86
|
+
if (depth === 0) {
|
|
87
|
+
return text.substring(startIdx, i + 1)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findFirstBraceOrBracket(text: string): number {
|
|
95
|
+
for (let i = 0; i < text.length; i++) {
|
|
96
|
+
const ch = text[i]
|
|
97
|
+
if (ch === '{' || ch === '[') return i
|
|
98
|
+
}
|
|
99
|
+
return -1
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default {}
|