@brickflow/cli 0.0.5 → 0.0.7

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.
@@ -0,0 +1,168 @@
1
+ import { generateContentWithLimits } from './gemini.js'
2
+ import { getTranslateRuntimeConfig } from './runtime-config.js'
3
+
4
+ const MODEL = process.env.TRANSLATE_AI_MODEL || 'gemini-3.1-flash-lite-preview'
5
+
6
+ export async function translateBatch(strings, options) {
7
+ const { componentContext = '', sourceLocale = 'en', targetLocales = [] } = options ?? {}
8
+
9
+ if (!Array.isArray(strings) || strings.length === 0) {
10
+ return Object.fromEntries(targetLocales.map((locale) => [locale, {}]))
11
+ }
12
+
13
+ const contents = buildContents(strings, sourceLocale, targetLocales, componentContext)
14
+ const systemInstruction = buildSystemInstruction()
15
+ const maxRetries = 3
16
+
17
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
18
+ try {
19
+ const raw = await requestTranslation(contents, systemInstruction)
20
+ const parsed = safeParse(raw)
21
+ return normalizeTranslations(parsed, strings, targetLocales)
22
+ } catch (error) {
23
+ console.warn(`AI translate attempt ${attempt}/${maxRetries} failed`)
24
+
25
+ if (attempt === maxRetries) {
26
+ throw new Error('Translation failed after retries', { cause: error })
27
+ }
28
+ }
29
+ }
30
+
31
+ return Object.fromEntries(targetLocales.map((locale) => [locale, {}]))
32
+ }
33
+
34
+ function buildContents(strings, sourceLocale, targetLocales, componentContext) {
35
+ const payload = strings.map((item) => ({
36
+ component: item.component || extractComponent(item.filePath),
37
+ key: item.key,
38
+ text: item.text,
39
+ type: detectType(item.text),
40
+ }))
41
+
42
+ return [
43
+ `Source locale: ${sourceLocale}`,
44
+ `Target locales: ${targetLocales.join(', ')}`,
45
+ '',
46
+ 'Component context for translators:',
47
+ componentContext || 'No extra component context available.',
48
+ '',
49
+ 'Return this exact schema:',
50
+ '{',
51
+ ' "pl": { "some.key": "..." },',
52
+ ' "ru": { "some.key": "..." }',
53
+ '}',
54
+ '',
55
+ 'Input strings:',
56
+ JSON.stringify(payload, null, 2),
57
+ ].join('\n')
58
+ }
59
+
60
+ function buildSystemInstruction() {
61
+ const { productContext, terminology, tone } = getTranslateRuntimeConfig()
62
+
63
+ return [
64
+ 'You are a professional localization engine for a paid adult content platform.',
65
+ '',
66
+ 'PRODUCT CONTEXT:',
67
+ productContext,
68
+ '',
69
+ 'TERMINOLOGY:',
70
+ terminology,
71
+ '',
72
+ 'TONE:',
73
+ tone,
74
+ '',
75
+ 'RULES:',
76
+ '- Return only raw JSON.',
77
+ '- Preserve JSON shape exactly.',
78
+ '- Do not omit keys or locales.',
79
+ '- Never remove words, qualifiers, examples, slang, or awkward fragments from the source.',
80
+ '- Preserve the full meaning of the source even if the text is clumsy, ungrammatical, explicit, or repetitive.',
81
+ '- If the source contains an unusual word or phrase, translate it or preserve it, but do not silently drop it.',
82
+ '- Preserve placeholders like {count}, {price}, %s, :name, \\n and HTML tags.',
83
+ '- Do not translate product or brand names unless source text clearly localizes them.',
84
+ '- Keep short labels concise only when the source itself is short. Do not compress longer messages.',
85
+ '- Example rule: "Username can contain from %s to %s characters only with dildo" must keep the final phrase in translation and must not be shortened.',
86
+ ].join('\n')
87
+ }
88
+
89
+ function cleanJson(text) {
90
+ return text
91
+ .replace(/```json/gi, '')
92
+ .replace(/```/g, '')
93
+ .trim()
94
+ }
95
+
96
+ function detectType(text) {
97
+ if (text.length <= 12) {
98
+ return 'short_ui'
99
+ }
100
+ if (text.includes('?')) {
101
+ return 'question'
102
+ }
103
+ if (text.includes('{') || text.includes('%')) {
104
+ return 'template'
105
+ }
106
+ return 'text'
107
+ }
108
+
109
+ function extractComponent(filePath) {
110
+ if (!filePath) {
111
+ return 'Unknown'
112
+ }
113
+
114
+ const parts = filePath.split('/').filter(Boolean)
115
+ const file = parts[parts.length - 2] || parts[parts.length - 1]
116
+
117
+ return file || 'Unknown'
118
+ }
119
+
120
+ function extractJson(text) {
121
+ const match = text.match(/\{[\s\S]*\}/)
122
+ return match ? match[0] : text
123
+ }
124
+
125
+ function normalizeTranslations(parsed, strings, targetLocales) {
126
+ const keys = strings.map((item) => item.key)
127
+ const result = {}
128
+
129
+ for (const locale of targetLocales) {
130
+ const localeValues = parsed?.[locale]
131
+
132
+ if (!localeValues || typeof localeValues !== 'object' || Array.isArray(localeValues)) {
133
+ throw new Error(`Invalid locale block: ${locale}`)
134
+ }
135
+
136
+ result[locale] = {}
137
+
138
+ for (const key of keys) {
139
+ const value = localeValues[key]
140
+
141
+ if (typeof value !== 'string') {
142
+ throw new Error(`Missing translation for ${locale}.${key}`)
143
+ }
144
+
145
+ result[locale][key] = value.trim()
146
+ }
147
+ }
148
+
149
+ return result
150
+ }
151
+
152
+ async function requestTranslation(contents, systemInstruction) {
153
+ const { apiKey } = getTranslateRuntimeConfig()
154
+
155
+ return await generateContentWithLimits({
156
+ apiKey,
157
+ config: {
158
+ systemInstruction,
159
+ },
160
+ contents,
161
+ model: MODEL,
162
+ })
163
+ }
164
+
165
+ function safeParse(text) {
166
+ const cleaned = extractJson(cleanJson(text))
167
+ return JSON.parse(cleaned)
168
+ }
@@ -0,0 +1,142 @@
1
+ import { GoogleGenAI } from '@google/genai'
2
+
3
+ import { getModelRpm } from './models.js'
4
+
5
+ const clientsByApiKey = new Map()
6
+ const modelState = new Map()
7
+
8
+ export async function generateContentWithLimits({ apiKey, config, contents, maxRetries = 5, model }) {
9
+ const client = getClient(apiKey)
10
+
11
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
12
+ await waitForModelSlot(model)
13
+
14
+ try {
15
+ const response = await client.models.generateContent({
16
+ config,
17
+ contents,
18
+ model,
19
+ })
20
+
21
+ markModelRequest(model)
22
+ return response.text ?? ''
23
+ } catch (error) {
24
+ markModelRequest(model)
25
+
26
+ if (!isRateLimitError(error)) {
27
+ throw error
28
+ }
29
+
30
+ const delayMs = getRetryDelayMs(error, model)
31
+ console.warn(
32
+ `Rate limited for ${model}, retrying in ${Math.ceil(delayMs / 1000)}s (${attempt}/${maxRetries})`,
33
+ )
34
+ await sleep(delayMs)
35
+
36
+ if (attempt === maxRetries) {
37
+ throw error
38
+ }
39
+ }
40
+ }
41
+
42
+ return ''
43
+ }
44
+
45
+ function getClient(apiKey) {
46
+ if (!apiKey) {
47
+ throw new Error('Gemini API key is required')
48
+ }
49
+
50
+ const cached = clientsByApiKey.get(apiKey)
51
+
52
+ if (cached) {
53
+ return cached
54
+ }
55
+
56
+ const client = new GoogleGenAI({ apiKey })
57
+ clientsByApiKey.set(apiKey, client)
58
+ return client
59
+ }
60
+
61
+ function getModelState(model) {
62
+ const current = modelState.get(model) ?? {
63
+ nextAllowedAt: 0,
64
+ }
65
+
66
+ modelState.set(model, current)
67
+ return current
68
+ }
69
+
70
+ function getRetryDelayMs(error, model) {
71
+ const retryFromDetails = parseRetryDelayFromDetails(error)
72
+
73
+ if (retryFromDetails !== null) {
74
+ return retryFromDetails
75
+ }
76
+
77
+ const rpm = Math.max(getModelRpm(model), 1)
78
+ return Math.ceil(60000 / rpm)
79
+ }
80
+
81
+ function isRateLimitError(error) {
82
+ return error?.status === 429
83
+ }
84
+
85
+ function markModelRequest(model) {
86
+ const state = getModelState(model)
87
+ const rpm = Math.max(getModelRpm(model), 1)
88
+ const intervalMs = Math.ceil(60000 / rpm)
89
+
90
+ state.nextAllowedAt = Math.max(state.nextAllowedAt, Date.now()) + intervalMs
91
+ }
92
+
93
+ function parseDurationMs(value) {
94
+ const match = String(value).match(/^([\d.]+)s$/i)
95
+
96
+ if (!match) {
97
+ return null
98
+ }
99
+
100
+ return Math.ceil(Number.parseFloat(match[1]) * 1000)
101
+ }
102
+
103
+ function parseRetryDelayFromDetails(error) {
104
+ const details = error?.errorInfo?.details ?? error?.details ?? error?.message
105
+
106
+ if (Array.isArray(details)) {
107
+ for (const detail of details) {
108
+ const retryDelay = detail?.retryDelay
109
+
110
+ if (typeof retryDelay === 'string') {
111
+ const parsed = parseDurationMs(retryDelay)
112
+
113
+ if (parsed !== null) {
114
+ return parsed
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ if (typeof details === 'string') {
121
+ const match = details.match(/retry in ([\d.]+)s/i) || details.match(/"retryDelay":"(\d+)s"/i)
122
+
123
+ if (match) {
124
+ return Math.ceil(Number.parseFloat(match[1]) * 1000)
125
+ }
126
+ }
127
+
128
+ return null
129
+ }
130
+
131
+ function sleep(ms) {
132
+ return new Promise((resolve) => setTimeout(resolve, ms))
133
+ }
134
+
135
+ async function waitForModelSlot(model) {
136
+ const state = getModelState(model)
137
+ const delayMs = state.nextAllowedAt - Date.now()
138
+
139
+ if (delayMs > 0) {
140
+ await sleep(delayMs)
141
+ }
142
+ }
@@ -0,0 +1,362 @@
1
+ import fs from 'fs'
2
+ import { globSync } from 'glob'
3
+ import { dirname, join, relative, resolve } from 'path'
4
+ import { fileURLToPath } from 'url'
5
+
6
+ import { ensureAiContext, getAiContextState } from './ai-context.js'
7
+ import { translateBatch } from './ai.js'
8
+ import { buildTranslateHelp, parseTranslateRuntimeArgs, setTranslateRuntimeConfig } from './runtime-config.js'
9
+ import { getTranslationPaths, listWorkspaceFiles, sortObjectKeys, stringifySortedJson } from './utils.js'
10
+
11
+ const currentDir = dirname(fileURLToPath(import.meta.url))
12
+ const workspaceRoot = resolve(currentDir, '../../../..')
13
+ const rawArgs = process.argv.slice(3)
14
+
15
+ if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
16
+ console.log(buildTranslateHelp())
17
+ process.exit(0)
18
+ }
19
+
20
+ const { options } = parseTranslateRuntimeArgs(rawArgs)
21
+
22
+ try {
23
+ setTranslateRuntimeConfig(options)
24
+ } catch (error) {
25
+ console.error(error instanceof Error ? error.message : String(error))
26
+ console.error('')
27
+ console.error(buildTranslateHelp())
28
+ process.exit(1)
29
+ }
30
+
31
+ const DEFAULT_LANGUAGE_CODES = [
32
+ 'bn',
33
+ 'cz',
34
+ 'dk',
35
+ 'de',
36
+ 'en',
37
+ 'es',
38
+ 'fi',
39
+ 'fr',
40
+ 'hi',
41
+ 'hu',
42
+ 'it',
43
+ 'ja',
44
+ 'nl',
45
+ 'no',
46
+ 'pl',
47
+ 'pt',
48
+ 'ru',
49
+ 'si',
50
+ 'se',
51
+ 'sk',
52
+ ]
53
+ const BATCH_MAX_ITEMS = readPositiveInt('TRANSLATE_BATCH_MAX_ITEMS', 30)
54
+ const BATCH_MAX_CHARS = readPositiveInt('TRANSLATE_BATCH_MAX_CHARS', 3500)
55
+ const languagePaths = globSync(
56
+ '{packages/brick,apps/*}/{components/**/translate,pages-translate/*,layouts/**,global/*}/generated/*.json',
57
+ {
58
+ absolute: true,
59
+ cwd: workspaceRoot,
60
+ ignore: ['**/node_modules/**', '**/.nuxt/**', '**/dist/**', '**/.output/**', '**/coverage/**', '**/public/**'],
61
+ },
62
+ ).sort()
63
+
64
+ const languageCodes = [
65
+ ...new Set([
66
+ ...languagePaths.map((filePath) => filePath.replace(/.*\/([^/]+)\.json$/, '$1')),
67
+ ...DEFAULT_LANGUAGE_CODES,
68
+ ]),
69
+ ].sort()
70
+
71
+ const samplePaths = globSync(
72
+ '{packages/brick,apps/*}/{components/**/translate,pages-translate/*,layouts/**,global/*}/sample.json',
73
+ {
74
+ absolute: true,
75
+ cwd: workspaceRoot,
76
+ ignore: ['**/node_modules/**', '**/.nuxt/**', '**/dist/**', '**/.output/**', '**/coverage/**', '**/public/**'],
77
+ },
78
+ ).sort()
79
+
80
+ const sourceFiles = listWorkspaceFiles(workspaceRoot).filter(
81
+ (filePath) => /\.(?:js|ts|vue)$/.test(filePath) && !filePath.endsWith('.d.ts'),
82
+ )
83
+
84
+ const sampleToSource = new Map()
85
+
86
+ for (const sourceFilePath of sourceFiles) {
87
+ const samplePath = getTranslationPaths(sourceFilePath)?.samplePath
88
+
89
+ if (samplePath && fs.existsSync(samplePath) && !sampleToSource.has(samplePath)) {
90
+ sampleToSource.set(samplePath, sourceFilePath)
91
+ }
92
+ }
93
+
94
+ const tasks = samplePaths.map((samplePath) => ({
95
+ sample: readJson(samplePath),
96
+ samplePath,
97
+ sourceFilePath: sampleToSource.get(samplePath),
98
+ }))
99
+
100
+ const total = tasks.reduce((count, task) => count + Object.keys(task.sample).length * languageCodes.length, 0)
101
+ const progress = createProgress(total)
102
+
103
+ if (tasks.length === 0) {
104
+ console.log('✅ No translation sample folders found')
105
+ process.exit(0)
106
+ }
107
+
108
+ for (const task of tasks) {
109
+ await processSample(task)
110
+ }
111
+
112
+ process.stdout.write('\n')
113
+ console.log(
114
+ `✅ Done: ${samplePaths.length} sample folders, ${languageCodes.length} languages, batch=${BATCH_MAX_ITEMS}/${BATCH_MAX_CHARS}`,
115
+ )
116
+
117
+ function createProgress(totalCount) {
118
+ let done = 0
119
+ let lastRenderedAt = 0
120
+ const start = Date.now()
121
+
122
+ return function update(languageCode, samplePath) {
123
+ done += 1
124
+
125
+ const now = Date.now()
126
+ if (done !== totalCount && now - lastRenderedAt < 80) {
127
+ return
128
+ }
129
+
130
+ lastRenderedAt = now
131
+
132
+ const percent = totalCount === 0 ? 100 : Math.round((done * 100) / totalCount)
133
+ const filled = Math.round(percent / 5)
134
+ const empty = 20 - filled
135
+ const elapsed = ((now - start) / 1000).toFixed(1)
136
+ const shortName = shortenPath(relative(workspaceRoot, samplePath))
137
+
138
+ process.stdout.write(
139
+ `\r🌍 Translation: [${'█'.repeat(filled)}${' '.repeat(empty)}] ` +
140
+ `${percent}% (${done}/${totalCount}) ` +
141
+ `⏱ ${elapsed}s ` +
142
+ `\x1b[90m${languageCode} ${shortName}\x1b[0m\x1b[K`,
143
+ )
144
+ }
145
+ }
146
+
147
+ function detectEol(filePath) {
148
+ if (!fs.existsSync(filePath)) {
149
+ return '\n'
150
+ }
151
+
152
+ const text = fs.readFileSync(filePath, 'utf-8')
153
+ return text.includes('\r\n') ? '\r\n' : '\n'
154
+ }
155
+
156
+ function estimateEntryChars(entry) {
157
+ return String(entry.key).length + String(entry.text).length + String(entry.filePath ?? '').length + 32
158
+ }
159
+
160
+ function existsJson(filePath) {
161
+ return fs.existsSync(filePath)
162
+ }
163
+
164
+ function normalizeEol(content) {
165
+ return String(content).replace(/\r\n/g, '\n')
166
+ }
167
+
168
+ async function processSample({ sample, samplePath, sourceFilePath }) {
169
+ writeSortedJsonIfNeeded(samplePath, sample)
170
+
171
+ const generatedDir = join(dirname(samplePath), 'generated')
172
+ const enPath = join(generatedDir, 'en.json')
173
+ const currentEn = existsJson(enPath) ? readJson(enPath) : {}
174
+ const currentByLanguage = new Map(
175
+ languageCodes.map((languageCode) => [
176
+ languageCode,
177
+ existsJson(join(generatedDir, `${languageCode}.json`))
178
+ ? readJson(join(generatedDir, `${languageCode}.json`))
179
+ : {},
180
+ ]),
181
+ )
182
+ const resultByLanguage = new Map(languageCodes.map((languageCode) => [languageCode, {}]))
183
+ const pendingEntriesByLocales = new Map()
184
+
185
+ fs.mkdirSync(generatedDir, { recursive: true })
186
+
187
+ for (const [key, sampleValue] of Object.entries(sample)) {
188
+ const missingLocales = []
189
+
190
+ for (const languageCode of languageCodes) {
191
+ const languageResult = resultByLanguage.get(languageCode)
192
+
193
+ if (!languageResult) {
194
+ continue
195
+ }
196
+
197
+ if (languageCode === 'en' || typeof sampleValue !== 'string') {
198
+ languageResult[key] = sampleValue
199
+ progress(languageCode, samplePath)
200
+ continue
201
+ }
202
+
203
+ const currentLang = currentByLanguage.get(languageCode) ?? {}
204
+
205
+ if (typeof currentLang[key] === 'string' && currentEn[key] === sampleValue) {
206
+ languageResult[key] = currentLang[key]
207
+ progress(languageCode, samplePath)
208
+ continue
209
+ }
210
+
211
+ missingLocales.push(languageCode)
212
+ }
213
+
214
+ if (typeof sampleValue === 'string' && missingLocales.length > 0) {
215
+ const localeKey = missingLocales.join(',')
216
+ const entries = pendingEntriesByLocales.get(localeKey) ?? []
217
+
218
+ entries.push({
219
+ filePath: relative(workspaceRoot, samplePath),
220
+ key,
221
+ text: sampleValue,
222
+ })
223
+
224
+ pendingEntriesByLocales.set(localeKey, entries)
225
+ }
226
+ }
227
+
228
+ let componentContext = null
229
+
230
+ if (pendingEntriesByLocales.size > 0) {
231
+ const contextState = getAiContextState({
232
+ samplePath,
233
+ sourceFilePath,
234
+ })
235
+
236
+ componentContext = contextState.shouldRegenerate
237
+ ? await ensureAiContext({
238
+ sample,
239
+ samplePath,
240
+ sourceFilePath,
241
+ })
242
+ : contextState.description
243
+ }
244
+
245
+ for (const [localeKey, entries] of pendingEntriesByLocales) {
246
+ const targetLocales = localeKey.split(',').filter(Boolean)
247
+ const chunks = splitIntoBatches(entries, BATCH_MAX_ITEMS, BATCH_MAX_CHARS)
248
+
249
+ for (const chunk of chunks) {
250
+ const translations = await translateChunk(chunk, samplePath, targetLocales, componentContext)
251
+
252
+ for (const languageCode of targetLocales) {
253
+ const languageResult = resultByLanguage.get(languageCode)
254
+ const localizedValues = translations[languageCode]
255
+
256
+ if (!languageResult || !localizedValues) {
257
+ throw new Error(
258
+ `Missing locale "${languageCode}" in AI response for ${relative(workspaceRoot, samplePath)}`,
259
+ )
260
+ }
261
+
262
+ for (const entry of chunk) {
263
+ const translatedValue = localizedValues[entry.key]
264
+
265
+ if (typeof translatedValue !== 'string' || translatedValue.length === 0) {
266
+ throw new Error(
267
+ `Missing translation for ${languageCode} ${relative(workspaceRoot, samplePath)} :: ${entry.key}`,
268
+ )
269
+ }
270
+
271
+ languageResult[entry.key] = translatedValue
272
+ progress(languageCode, samplePath)
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ for (const languageCode of languageCodes) {
279
+ const generatedPath = join(generatedDir, `${languageCode}.json`)
280
+ const languageResult = sortObjectKeys(resultByLanguage.get(languageCode) ?? {})
281
+ writeTextPreservingEol(generatedPath, stringifySortedJson(languageResult))
282
+ }
283
+ }
284
+
285
+ function readJson(filePath) {
286
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
287
+ }
288
+
289
+ function readPositiveInt(name, fallback) {
290
+ const raw = process.env[name]
291
+
292
+ if (!raw) {
293
+ return fallback
294
+ }
295
+
296
+ const value = Number.parseInt(raw, 10)
297
+ return Number.isFinite(value) && value > 0 ? value : fallback
298
+ }
299
+
300
+ function shortenPath(filePath, maxLength = 72) {
301
+ if (filePath.length <= maxLength) {
302
+ return filePath
303
+ }
304
+
305
+ return `...${filePath.slice(-(maxLength - 3))}`
306
+ }
307
+
308
+ function splitIntoBatches(entries, maxItems, maxChars) {
309
+ const batches = []
310
+ let current = []
311
+ let currentChars = 0
312
+
313
+ for (const entry of entries) {
314
+ const entryChars = estimateEntryChars(entry)
315
+ const shouldFlush = current.length > 0 && (current.length >= maxItems || currentChars + entryChars > maxChars)
316
+
317
+ if (shouldFlush) {
318
+ batches.push(current)
319
+ current = []
320
+ currentChars = 0
321
+ }
322
+
323
+ current.push(entry)
324
+ currentChars += entryChars
325
+ }
326
+
327
+ if (current.length > 0) {
328
+ batches.push(current)
329
+ }
330
+
331
+ return batches
332
+ }
333
+
334
+ async function translateChunk(entries, samplePath, targetLocales, componentContext) {
335
+ try {
336
+ return await translateBatch(entries, {
337
+ componentContext,
338
+ sourceLocale: 'en',
339
+ targetLocales,
340
+ })
341
+ } catch (error) {
342
+ throw new Error(
343
+ `Translation failed for ${relative(workspaceRoot, samplePath)} (${entries.length} strings, ${targetLocales.join(', ')})`,
344
+ { cause: error },
345
+ )
346
+ }
347
+ }
348
+
349
+ function writeSortedJsonIfNeeded(filePath, value) {
350
+ const nextText = stringifySortedJson(value)
351
+ const prevText = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null
352
+
353
+ if (prevText === null || normalizeEol(prevText) !== normalizeEol(nextText)) {
354
+ writeTextPreservingEol(filePath, nextText)
355
+ }
356
+ }
357
+
358
+ function writeTextPreservingEol(filePath, content) {
359
+ const eol = detectEol(filePath)
360
+ const normalized = String(content).replace(/\r?\n/g, eol)
361
+ fs.writeFileSync(filePath, normalized, 'utf-8')
362
+ }
@@ -0,0 +1,12 @@
1
+ export const GEMINI_MODEL_LIMITS = {
2
+ 'gemini-3-flash-preview': {
3
+ rpm: 5,
4
+ },
5
+ 'gemini-3.1-flash-lite-preview': {
6
+ rpm: 15,
7
+ },
8
+ }
9
+
10
+ export function getModelRpm(model) {
11
+ return GEMINI_MODEL_LIMITS[model]?.rpm ?? 5
12
+ }