Skip to content

Commit 06bf039

Browse files
committed
fix(embeddings): project before batching, and keep the sunset block's docs icon
Review round 2. Projection ran inside callEmbeddingAPI, after batchByTokenLimit had already measured and truncated the original text. The projector rewrites resolved secrets to placeholders, which changes length, so batching sized against a string that was never sent: a lengthening projection then pushed input past the model's ceiling and the provider rejected it, and a shortening one discarded document content that would have fit. Project once up front, then batch the projected text, so truncation measures what actually goes to the provider. This also keeps projection to exactly one call per embed(), so no retry can re-project. Separately, marking the legacy openai block hideFromToolbar dropped it from the generated docs icon map, which only retains hidden blocks when they are versioned. integrations/openai.mdx is deliberately kept — docsLink is baked into every placed instance — so BlockInfoCard lost its icon and fell back to a text tile. A sunset block keeps its docs page for the same reason a hidden versioned block does, so the generator now treats it the same way. The sim-side integrations map still omits it, which is intended: that feeds the discovery page a sunset block should not appear on, and placed blocks render from the registry's own icon reference.
1 parent 04e8621 commit 06bf039

4 files changed

Lines changed: 58 additions & 18 deletions

File tree

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ import {
159159
ObsidianIcon,
160160
OktaIcon,
161161
OnePasswordIcon,
162+
OpenAIIcon,
162163
OutlookIcon,
163164
PackageSearchIcon,
164165
PagerDutyIcon,
@@ -439,6 +440,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
439440
okta: OktaIcon,
440441
onedrive: MicrosoftOneDriveIcon,
441442
onepassword: OnePasswordIcon,
443+
openai: OpenAIIcon,
442444
outlook: OutlookIcon,
443445
pagerduty: PagerDutyIcon,
444446
parallel_ai: ParallelIcon,

apps/sim/lib/embeddings/client.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,29 @@ describe('embed', () => {
285285
expect(result.totalTokens).toBeLessThan(10)
286286
})
287287

288+
/**
289+
* Projection changes length, and batching truncates whatever it measures.
290+
* Batching the pre-projection text sized against a string that was never
291+
* sent: a lengthening projection then exceeded the model's ceiling, and a
292+
* shortening one discarded content that would have fit.
293+
*/
294+
it('batches the projected text, not the original', async () => {
295+
fetchMock.mockResolvedValue(jsonResponse({ embeddings: [{ values: [1] }] }))
296+
// Under Gemini's 2048 ceiling before projection, far over it after.
297+
const short = 'secret'
298+
299+
await embed([short], {
300+
model: 'gemini-embedding-001',
301+
apiKey: 'g-test',
302+
projectInputs: () => ['word '.repeat(8000)],
303+
})
304+
305+
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
306+
const sent = body.requests[0].content.parts[0].text
307+
// Truncated against the model ceiling, so the lengthened text cannot go out whole.
308+
expect(sent.length).toBeLessThan('word '.repeat(8000).length)
309+
})
310+
288311
it('projects once even when the request is retried', async () => {
289312
const projectInputs = vi.fn((values: readonly string[]) => values.map(() => 'projected'))
290313
fetchMock

apps/sim/lib/embeddings/client.ts

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -111,22 +111,16 @@ async function resolveProvider(model: string, options: EmbedOptions): Promise<Re
111111
}
112112
}
113113

114+
/** `inputs` are already projected and batched by {@link embed}. */
114115
async function callEmbeddingAPI(
115116
inputs: string[],
116117
provider: ResolvedProvider,
117-
taskType: EmbeddingTaskType,
118-
projectInputs: ((values: readonly string[]) => string[]) | null
118+
taskType: EmbeddingTaskType
119119
): Promise<{ embeddings: number[][]; totalTokens: number }> {
120-
/**
121-
* Projected once, outside the retry loop, so a retry cannot re-project
122-
* already-projected content. Token counts are taken from these same values so
123-
* usage reflects what was actually sent.
124-
*/
125-
const modelInputs = projectInputs ? projectInputs(inputs) : inputs
126120
return retryWithExponentialBackoff(
127121
async () => {
128122
const request = provider.adapter.buildRequest({
129-
inputs: modelInputs,
123+
inputs,
130124
taskType,
131125
dimensions: provider.requestedDimensions,
132126
})
@@ -154,7 +148,7 @@ async function callEmbeddingAPI(
154148
const totalTokens =
155149
request.parseTokens?.(json) ??
156150
// Providers that omit usage (e.g. Gemini) get an estimate from their tokenizer
157-
modelInputs.reduce(
151+
inputs.reduce(
158152
(sum, text) => sum + estimateTokenCount(text, provider.info.tokenizerProvider).count,
159153
0
160154
)
@@ -184,15 +178,27 @@ export async function embed(texts: string[], options: EmbedOptions): Promise<Emb
184178
const taskType = options.taskType ?? 'document'
185179
const provider = await resolveProvider(model, options)
186180

181+
/**
182+
* Projected before batching, not after. The projector rewrites resolved-secret
183+
* plaintext to placeholders, which changes length, and `batchByTokenLimit`
184+
* measures and truncates whatever it is handed. Batching the pre-projection
185+
* text would size against a different string than the one actually sent: a
186+
* lengthening projection then exceeds the model's ceiling and the provider
187+
* rejects it, and a shortening one discards content that would have fit.
188+
*
189+
* Doing it here also keeps projection to exactly once per call, so no retry
190+
* can re-project already-projected content.
191+
*/
192+
const modelInputs = options.projectInputs ? options.projectInputs(texts) : texts
193+
187194
/**
188195
* Batched against the selected model's own ceiling rather than one shared
189-
* constant. `batchByTokenLimit` truncates any single text above the limit, so
190-
* a value that is too high sends oversized input the provider rejects, and one
191-
* that is too low silently drops content the provider would have accepted.
192-
* Using the per-input ceiling as the per-batch budget also keeps every
193-
* individual text within it.
196+
* constant. A value that is too high sends oversized input the provider
197+
* rejects; one that is too low silently drops content the provider would have
198+
* accepted. Using the per-input ceiling as the per-batch budget also keeps
199+
* every individual text within it.
194200
*/
195-
const tokenBatches = batchByTokenLimit(texts, provider.info.maxInputTokens, model)
201+
const tokenBatches = batchByTokenLimit(modelInputs, provider.info.maxInputTokens, model)
196202
const itemLimit = provider.adapter.maxItemsPerRequest ?? provider.info.maxItemsPerRequest
197203
const batches = itemLimit
198204
? tokenBatches.flatMap((batch) => splitByItemLimit(batch, itemLimit))
@@ -203,7 +209,7 @@ export async function embed(texts: string[], options: EmbedOptions): Promise<Emb
203209
MAX_CONCURRENT_BATCHES,
204210
async (batch, i) => {
205211
try {
206-
return await callEmbeddingAPI(batch, provider, taskType, options.projectInputs)
212+
return await callEmbeddingAPI(batch, provider, taskType)
207213
} catch (error) {
208214
logger.error(`Failed to generate embeddings for batch ${i + 1}/${batches.length}:`, error)
209215
throw error

scripts/generate-docs.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,16 @@ async function generateIconMapping(options: {
440440
}
441441

442442
const isVersionedBlockType = isVersionedType(blockType)
443-
if (!hideFromToolbar || (options.includeHidden && isVersionedBlockType)) {
443+
/**
444+
* A sunset block keeps its docs page — `docsLink` is baked into every
445+
* placed instance — so it still needs an icon there, exactly like a
446+
* hidden versioned block. Without this it renders as a text tile.
447+
*/
448+
const isSunsetBlockType = /sunset\s*:\s*\{/.test(stripSourceComments(blockContent))
449+
if (
450+
!hideFromToolbar ||
451+
(options.includeHidden && (isVersionedBlockType || isSunsetBlockType))
452+
) {
444453
iconMapping[blockType] = {
445454
name: iconName,
446455
source: resolveIconSource(fileContent, iconName),

0 commit comments

Comments
 (0)