@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.
Files changed (46) hide show
  1. package/config/config.development.js +72 -0
  2. package/config/config.production.js +24 -0
  3. package/dist-server/index.d.ts +19 -0
  4. package/dist-server/index.js +23 -0
  5. package/dist-server/index.js.map +1 -0
  6. package/dist-server/service/factory.d.ts +16 -0
  7. package/dist-server/service/factory.js +65 -0
  8. package/dist-server/service/factory.js.map +1 -0
  9. package/dist-server/service/index.d.ts +4 -0
  10. package/dist-server/service/index.js +8 -0
  11. package/dist-server/service/index.js.map +1 -0
  12. package/dist-server/service/providers/anthropic.d.ts +13 -0
  13. package/dist-server/service/providers/anthropic.js +201 -0
  14. package/dist-server/service/providers/anthropic.js.map +1 -0
  15. package/dist-server/service/providers/gemini.d.ts +14 -0
  16. package/dist-server/service/providers/gemini.js +292 -0
  17. package/dist-server/service/providers/gemini.js.map +1 -0
  18. package/dist-server/service/providers/index.d.ts +6 -0
  19. package/dist-server/service/providers/index.js +14 -0
  20. package/dist-server/service/providers/index.js.map +1 -0
  21. package/dist-server/service/providers/openai.d.ts +13 -0
  22. package/dist-server/service/providers/openai.js +232 -0
  23. package/dist-server/service/providers/openai.js.map +1 -0
  24. package/dist-server/service/types.d.ts +171 -0
  25. package/dist-server/service/types.js +8 -0
  26. package/dist-server/service/types.js.map +1 -0
  27. package/dist-server/service/utils/index.d.ts +1 -0
  28. package/dist-server/service/utils/index.js +5 -0
  29. package/dist-server/service/utils/index.js.map +1 -0
  30. package/dist-server/service/utils/parse-json.d.ts +20 -0
  31. package/dist-server/service/utils/parse-json.js +99 -0
  32. package/dist-server/service/utils/parse-json.js.map +1 -0
  33. package/dist-server/tsconfig.tsbuildinfo +1 -0
  34. package/package.json +31 -0
  35. package/server/index.ts +19 -0
  36. package/server/service/factory.ts +68 -0
  37. package/server/service/index.ts +4 -0
  38. package/server/service/providers/anthropic.ts +252 -0
  39. package/server/service/providers/gemini.ts +353 -0
  40. package/server/service/providers/index.ts +8 -0
  41. package/server/service/providers/openai.ts +284 -0
  42. package/server/service/types.ts +197 -0
  43. package/server/service/utils/index.ts +1 -0
  44. package/server/service/utils/parse-json.ts +100 -0
  45. package/things-factory.config.js +1 -0
  46. package/tsconfig.json +9 -0
@@ -0,0 +1,8 @@
1
+ // Auto-register providers on module load
2
+ import './anthropic.js'
3
+ import './gemini.js'
4
+ import './openai.js'
5
+
6
+ export { AnthropicAIClient } from './anthropic.js'
7
+ export { GeminiAIClient } from './gemini.js'
8
+ export { OpenAIAIClient } from './openai.js'
@@ -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 {}
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../tsconfig-base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist-server",
5
+ "baseUrl": "./"
6
+ },
7
+ "include": ["./server/**/*"],
8
+ "exclude": ["**/*.spec.ts", "**/*.test.ts"]
9
+ }