Repository navigation
fix(ai-cohere): split embed() input into batches of at most 96 #1984
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@tanstack/ai-cohere': patch | ||
| --- | ||
|
|
||
| `embed()` with the Cohere adapter no longer fails with a 400 when given more than 96 inputs. The adapter now splits the input into requests of at most 96 (the `v2/embed` per-request limit) and returns the vectors in input order, with token usage summed across requests. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,9 @@ export interface CohereEmbeddingConfig extends CohereClientConfig {} | |
|
|
||
| const DEFAULT_TIMEOUT_MS = 30_000 | ||
|
|
||
| /** Cohere v2/embed accepts at most 96 inputs per request. */ | ||
| const COHERE_MAX_BATCH_SIZE = 96 | ||
|
|
||
| /** | ||
| * Returns true when `url` is malformed, non-http(s), or targets a private / | ||
| * loopback / link-local host. Used to block SSRF via `allowUrlFetch`. | ||
|
|
@@ -116,7 +119,8 @@ interface CohereEmbedResponse { | |
| * SDK dependency. | ||
| * | ||
| * Features: | ||
| * - Batch embedding (one request for the whole input array) | ||
| * - Batch embedding (the input array is sent in requests of at most 96 | ||
| * inputs, the v2/embed per-request limit, and the results are merged) | ||
| * - Multimodal inputs: text, images, and fused text+image items (one vector | ||
| * per input item) | ||
| * - Matryoshka dimension reduction via the top-level `dimensions` option | ||
|
|
@@ -173,75 +177,94 @@ export class CohereEmbeddingAdapter< | |
| }), | ||
| ) | ||
|
|
||
| // embedding_types is pinned to ['float'] (overriding any disagreeing | ||
| // modelOptions.embeddingTypes) so vectors are always number[]. | ||
| const body: CohereEmbedRequestBody = { | ||
| model, | ||
| inputs, | ||
| input_type: inputType, | ||
| embedding_types: ['float'], | ||
| } | ||
| const truncate = modelOptions?.truncate | ||
| if (truncate !== undefined) { | ||
| body.truncate = truncate | ||
| } | ||
| if (options.dimensions !== undefined) { | ||
| body.output_dimension = options.dimensions | ||
| } | ||
|
|
||
| logger.request( | ||
| `activity=embed provider=${this.name} model=${model} inputs=${inputs.length}`, | ||
| { provider: this.name, model }, | ||
| ) | ||
|
|
||
| const timeoutMs = this.clientConfig.timeout ?? DEFAULT_TIMEOUT_MS | ||
| const transport = resolveCohereTransport(this.clientConfig) | ||
| const response = await fetchWithTimeout( | ||
| `${transport.baseUrl}/v2/embed`, | ||
| { | ||
| method: 'POST', | ||
| headers: { | ||
| Authorization: `Bearer ${this.clientConfig.apiKey}`, | ||
| 'Content-Type': 'application/json', | ||
| ...transport.headers, | ||
|
|
||
| const vectors: Array<Array<number>> = [] | ||
| let inputTokens: number | undefined | ||
| for ( | ||
| let start = 0; | ||
| start < inputs.length; | ||
| start += COHERE_MAX_BATCH_SIZE | ||
| ) { | ||
| const batch = inputs.slice(start, start + COHERE_MAX_BATCH_SIZE) | ||
|
|
||
| // embedding_types is pinned to ['float'] (overriding any disagreeing | ||
| // modelOptions.embeddingTypes) so vectors are always number[]. | ||
| const body: CohereEmbedRequestBody = { | ||
| model, | ||
| inputs: batch, | ||
| input_type: inputType, | ||
| embedding_types: ['float'], | ||
| } | ||
| const truncate = modelOptions?.truncate | ||
| if (truncate !== undefined) { | ||
| body.truncate = truncate | ||
| } | ||
| if (options.dimensions !== undefined) { | ||
| body.output_dimension = options.dimensions | ||
| } | ||
|
|
||
| logger.request( | ||
| `activity=embed provider=${this.name} model=${model} inputs=${batch.length}`, | ||
| { provider: this.name, model }, | ||
| ) | ||
|
|
||
| const response = await fetchWithTimeout( | ||
| `${transport.baseUrl}/v2/embed`, | ||
| { | ||
| method: 'POST', | ||
| headers: { | ||
| Authorization: `Bearer ${this.clientConfig.apiKey}`, | ||
| 'Content-Type': 'application/json', | ||
| ...transport.headers, | ||
| }, | ||
| body: JSON.stringify(body), | ||
| ...(options.abortSignal && { signal: options.abortSignal }), | ||
| }, | ||
| body: JSON.stringify(body), | ||
| ...(options.abortSignal && { signal: options.abortSignal }), | ||
| }, | ||
| timeoutMs, | ||
| ) | ||
| timeoutMs, | ||
| ) | ||
|
|
||
| if (!response.ok) { | ||
| const bodyText = await response.text() | ||
| let message = bodyText | ||
| try { | ||
| const parsed: unknown = JSON.parse(bodyText) | ||
| if ( | ||
| typeof parsed === 'object' && | ||
| parsed !== null && | ||
| 'message' in parsed && | ||
| typeof parsed.message === 'string' | ||
| ) { | ||
| message = parsed.message | ||
| if (!response.ok) { | ||
| const bodyText = await response.text() | ||
| let message = bodyText | ||
| try { | ||
| const parsed: unknown = JSON.parse(bodyText) | ||
| if ( | ||
| typeof parsed === 'object' && | ||
| parsed !== null && | ||
| 'message' in parsed && | ||
| typeof parsed.message === 'string' | ||
| ) { | ||
| message = parsed.message | ||
| } | ||
| } catch { | ||
| // Not JSON — fall back to the raw body text. | ||
| } | ||
| } catch { | ||
| // Not JSON — fall back to the raw body text. | ||
| throw new Error( | ||
| `Cohere embed failed (${response.status}): ${message}`, | ||
| ) | ||
| } | ||
| throw new Error(`Cohere embed failed (${response.status}): ${message}`) | ||
| } | ||
|
|
||
| const data = (await response.json()) as CohereEmbedResponse | ||
| const data = (await response.json()) as CohereEmbedResponse | ||
|
|
||
| const vectors = data.embeddings?.float | ||
| if (!vectors) { | ||
| throw new Error( | ||
| 'Cohere embed response did not include float embeddings', | ||
| ) | ||
| } | ||
| if (vectors.length !== inputs.length) { | ||
| throw new Error( | ||
| `Cohere embed returned ${vectors.length} embeddings for ${inputs.length} inputs`, | ||
| ) | ||
| const batchVectors = data.embeddings?.float | ||
| if (!batchVectors) { | ||
| throw new Error( | ||
| 'Cohere embed response did not include float embeddings', | ||
| ) | ||
| } | ||
| if (batchVectors.length !== batch.length) { | ||
| throw new Error( | ||
| `Cohere embed returned ${batchVectors.length} embeddings for ${batch.length} inputs`, | ||
| ) | ||
| } | ||
| vectors.push(...batchVectors) | ||
|
|
||
| const batchTokens = data.meta?.billed_units?.input_tokens | ||
| if (batchTokens !== undefined) { | ||
| inputTokens = (inputTokens ?? 0) + batchTokens | ||
|
Comment on lines
+264
to
+266
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| } | ||
|
|
||
| const result: EmbeddingResult = { | ||
|
|
@@ -250,7 +273,6 @@ export class CohereEmbeddingAdapter< | |
| embeddings: vectors.map((vector, index) => ({ vector, index })), | ||
| } | ||
|
|
||
| const inputTokens = data.meta?.billed_units?.input_tokens | ||
| if (inputTokens !== undefined) { | ||
| const usage: TokenUsage = { | ||
| promptTokens: inputTokens, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: TanStack/ai
Length of output: 665
Script executed:
Repository: TanStack/ai
Length of output: 14674
Script executed:
Repository: TanStack/ai
Length of output: 5360
Web query:
Cohere Embed API v2 rate limits per minute calls production trial key 429Result:
Script executed:
Repository: TanStack/ai
Length of output: 1971
Retry rate-limited embed batches, or pace the loop.
A call with more than 2,000 inputs sends back-to-back batches with no pacing or retry. Cohere's Embed endpoint allows 2,000 inputs per minute and returns 429 when that limit is exceeded. The first batches are billed, then the call throws and discards their vectors. On 429, wait for the
Retry-Afterinterval and retry the failed batch, or pace batches within the limit.