@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,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,4 @@
1
+ export * from './types.js'
2
+ export * from './factory.js'
3
+ export * from './providers/index.js'
4
+ export * from './utils/index.js'
@@ -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))