feat(integrations/camb-ai): add CAMB AI integration#15010
feat(integrations/camb-ai): add CAMB AI integration#15010neilruaro-camb wants to merge 2 commits intobotpress:masterbotpress/botpress:masterfrom neilruaro-camb:feat/camb-ai-integrationneilruaro-camb/botpress:feat/camb-ai-integrationCopy head branch name to clipboard
Conversation
Add CAMB AI integration for text-to-speech, translation, translated TTS, voice cloning, and voice listing across 140+ languages using MARS models.
Greptile SummaryThis PR introduces a new CAMB AI integration that exposes five actions — Key issues found:
Confidence Score: 2/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Bot
participant Integration as camb-ai Integration
participant CambSDK as @camb-ai/sdk
participant CambAPI as CAMB AI API
participant BpFiles as Botpress Files API
Note over Bot,BpFiles: generateSpeech
Bot->>Integration: generateSpeech(text, voiceId, model, speed, ...)
Integration->>CambSDK: textToSpeech.tts(text, voice_id, model)
CambSDK->>CambAPI: POST /tts
CambAPI-->>CambSDK: audio bytes
CambSDK-->>Integration: arrayBuffer
Integration->>BpFiles: uploadFile(key, content, accessPolicies)
BpFiles-->>Integration: file.url
Integration-->>Bot: { audioUrl }
Note over Bot,BpFiles: translateText
Bot->>Integration: translateText(text, srcLang, tgtLang)
Integration->>CambSDK: translation.createTranslation(texts, src, tgt)
CambAPI-->>Integration: { task_id }
loop poll until SUCCESS
Integration->>CambSDK: translation.getTranslationTaskStatus(task_id)
CambAPI-->>Integration: { status, run_id }
end
Integration->>CambSDK: translation.getTranslationResult(run_id)
CambAPI-->>Integration: { texts[] }
Integration-->>Bot: { translatedText }
Note over Bot,BpFiles: translatedTts
Bot->>Integration: translatedTts(text, srcLang, tgtLang, voiceId, ...)
Integration->>CambSDK: translatedTts.createTranslatedTts(...)
CambAPI-->>Integration: { task_id }
loop poll until SUCCESS
Integration->>CambSDK: translatedTts.getTranslatedTtsTaskStatus(task_id)
CambAPI-->>Integration: { status, run_id }
end
Integration->>CambSDK: translation.getTranslationResult(run_id)
CambAPI-->>Integration: { texts[] }
Integration->>CambAPI: GET /apis/tts-result/{run_id} (raw fetch)
CambAPI-->>Integration: audio bytes
Integration->>BpFiles: uploadFile(key, content, accessPolicies)
BpFiles-->>Integration: file.url
Integration-->>Bot: { audioUrl, translatedText }
Note over Bot,BpFiles: cloneVoice
Bot->>Integration: cloneVoice(audioFileUrl, voiceName, gender, ...)
Integration->>CambAPI: fetch(audioFileUrl) — download sample
CambAPI-->>Integration: audio blob
Integration->>CambSDK: voiceCloning.createCustomVoice(file, ...)
CambAPI-->>Integration: { voice_id }
Integration-->>Bot: { voiceId }
Last reviewed commit: b4a2f7d |
| const cost = 0 | ||
| metadata.setCost(cost) | ||
| return { | ||
| audioUrl: file.url, | ||
| botpress: { | ||
| cost, | ||
| }, |
There was a problem hiding this comment.
Cost always reported as 0 for a billable action
Both generateSpeech (line 42) and translatedTts (line 157) hardcode const cost = 0 and call metadata.setCost(0), even though both actions are marked billable: true in the integration definition. This means the platform will never charge or track any cost for these operations, which contradicts the intent of the billable flag.
Compare with the OpenAI integration, which computes (input.length / 1_000_000) * PricePer1MCharacters before calling metadata.setCost. CAMB AI's pricing model should be reflected here (e.g., based on character count or duration). If actual cost tracking isn't feasible at this time, billable: false would be more accurate until it is implemented.
| // Poll for completion | ||
| let runId: number | undefined | ||
| await pollForResult( | ||
| async () => { | ||
| const status = await cambClient.translatedTts.getTranslatedTtsTaskStatus({ task_id: taskId }) | ||
| return { status: status.status, run_id: status.run_id } | ||
| }, | ||
| async (rid: number) => { | ||
| runId = rid | ||
| return rid | ||
| } | ||
| ) | ||
|
|
||
| if (!runId) { | ||
| throw new RuntimeError('CAMB AI did not return a run_id for translated TTS') | ||
| } |
There was a problem hiding this comment.
pollForResult used as a side-effect mechanism
pollForResult<T> is designed so that the getResult callback is the source of truth for the returned value T. Here, the callback is abused — it sets runId = rid as a side-effect and then returns rid, making the outer let runId: number | undefined variable the actual carrier of the result. This leaks the run_id via closure mutation rather than using the return value.
A cleaner approach is to use the return value of pollForResult directly:
const runId = await pollForResult(
async () => {
const status = await cambClient.translatedTts.getTranslatedTtsTaskStatus({ task_id: taskId })
return { status: status.status, run_id: status.run_id }
},
async (rid: number) => rid
)Then remove the let runId: number | undefined declaration and the guard if (!runId) block (since pollForResult already throws if no run_id is returned on SUCCESS).
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| const audioResponse = await fetch(`https://client.camb.ai/apis/tts-result/${runId}`, { | ||
| headers: { 'x-api-key': bp.secrets.CAMB_AI_API_KEY }, | ||
| }) |
There was a problem hiding this comment.
Hardcoded API base URL bypasses the SDK
The URL https://client.camb.ai/apis/tts-result/${runId} is constructed manually and the request is fired with a raw fetch call, while all other API calls go through the @camb-ai/sdk. If the API host ever changes (e.g. versioning, domain migration), this line will silently break.
Consider either using the SDK method if one exists, or at minimum extracting the base URL into a named constant at the top of the file so it can be updated in one place:
const CAMB_AI_BASE_URL = 'https://client.camb.ai'
// ...
const audioResponse = await fetch(`${CAMB_AI_BASE_URL}/apis/tts-result/${runId}`, {| function generateFileKey(prefix: string, input: object, suffix?: string) { | ||
| const json = JSON.stringify(input) | ||
| const hash = crypto.createHash('sha1') | ||
|
|
||
| hash.update(json) | ||
| const hexHash = hash.digest('hex') | ||
|
|
||
| return prefix + Date.now() + '_' + hexHash + suffix | ||
| } |
There was a problem hiding this comment.
Date.now() and a content hash are used together, defeating both purposes
generateFileKey combines a SHA-1 hash of the input (good for content-addressable deduplication) with Date.now() (good for uniqueness). These two goals contradict each other: because the timestamp changes on every invocation, the full key is never reproducible, so the hash provides no deduplication value whatsoever.
If the intent is to deduplicate file uploads for identical inputs (which pairs well with cacheable: true), drop Date.now():
| function generateFileKey(prefix: string, input: object, suffix?: string) { | |
| const json = JSON.stringify(input) | |
| const hash = crypto.createHash('sha1') | |
| hash.update(json) | |
| const hexHash = hash.digest('hex') | |
| return prefix + Date.now() + '_' + hexHash + suffix | |
| } | |
| return prefix + hexHash + suffix |
If the intent is simply to produce a unique key per invocation (e.g. to avoid collisions), drop the hash and keep only the timestamp (or use crypto.randomUUID()).
| import { z } from '@botpress/sdk' | ||
|
|
||
| export const ttsModelId = z | ||
| .enum(['mars-flash', 'mars-pro', 'mars-instruct']) | ||
| .describe('MARS model to use for text-to-speech') | ||
| .placeholder('mars-flash') | ||
| export type TtsModelId = z.infer<typeof ttsModelId> |
There was a problem hiding this comment.
schemas.ts is dead code — ttsModelId is never imported or used
ttsModelId and TtsModelId are defined here but are not imported by either integration.definition.ts or src/index.ts. The definition file declares its own local TtsModels constant:
// integration.definition.ts
const TtsModels = ['mars-flash', 'mars-pro', 'mars-instruct'] as constThis duplication means the two enum definitions can diverge silently. Either:
- Remove
schemas.tsand use the local constant inintegration.definition.tsas-is, or - Import
ttsModelIdfromschemas.tsinintegration.definition.tsand use it for themodelfield to keep the canonical list in one place.
| const response = await cambClient.textToSpeech.tts({ | ||
| text: input.text, | ||
| voice_id: input.voiceId ?? 147320, | ||
| language: (input.language ?? 'en-us') as any, | ||
| speech_model: (input.model ?? 'mars-flash') as any, | ||
| user_instructions: input.instructions, | ||
| }) |
There was a problem hiding this comment.
speed input parameter silently ignored
The generateSpeech input schema defines a speed field (validated between 0.5–2.0), but it is never forwarded to the cambClient.textToSpeech.tts() call. Any value a user provides for speech speed will be silently discarded.
| const response = await cambClient.textToSpeech.tts({ | |
| text: input.text, | |
| voice_id: input.voiceId ?? 147320, | |
| language: (input.language ?? 'en-us') as any, | |
| speech_model: (input.model ?? 'mars-flash') as any, | |
| user_instructions: input.instructions, | |
| }) | |
| const response = await cambClient.textToSpeech.tts({ | |
| text: input.text, | |
| voice_id: input.voiceId ?? 147320, | |
| language: (input.language ?? 'en-us') as any, | |
| speech_model: (input.model ?? 'mars-flash') as any, | |
| user_instructions: input.instructions, | |
| speed: input.speed, | |
| }) |
| const result = await cambClient.voiceCloning.createCustomVoice({ | ||
| file: audioBlob, | ||
| voice_name: input.voiceName, | ||
| gender: input.gender, | ||
| language: input.language, | ||
| enhance_audio: input.enhanceAudio, | ||
| }) | ||
|
|
||
| return { | ||
| voiceId: result.voice_id, | ||
| } |
There was a problem hiding this comment.
No null guard on cloneVoice result
result.voice_id is used directly without any null/undefined check. All other actions guard their critical response fields (e.g. the task_id checks on lines 72–75 and 102–105), but cloneVoice does not. If the CAMB AI API returns a missing or null voice_id, the function will return { voiceId: undefined }, violating the output schema's z.number() constraint and silently surfacing bad data to downstream bots.
| const result = await cambClient.voiceCloning.createCustomVoice({ | |
| file: audioBlob, | |
| voice_name: input.voiceName, | |
| gender: input.gender, | |
| language: input.language, | |
| enhance_audio: input.enhanceAudio, | |
| }) | |
| return { | |
| voiceId: result.voice_id, | |
| } | |
| if (result.voice_id == null) { | |
| throw new RuntimeError('CAMB AI did not return a voice_id for the cloned voice') | |
| } | |
| return { | |
| voiceId: result.voice_id, | |
| } |
| const createResult = (await cambClient.translation.createTranslation({ | ||
| texts: [input.text], | ||
| source_language: input.sourceLanguage, | ||
| target_language: input.targetLanguage, | ||
| })) as any |
There was a problem hiding this comment.
as any cast bypasses SDK types for translation result
createTranslation is cast to any to access task_id. This suppresses TypeScript's ability to catch any future shape mismatch (e.g. if the SDK renames the field). The translatedTts action avoids this — createResult.task_id is accessed directly from the typed createResult without a cast. It would be worth checking whether the SDK type definition for createTranslation is missing task_id, and if so, opening an issue with @camb-ai/sdk. In the interim, a narrower cast is safer than as any:
const createResult = await cambClient.translation.createTranslation({...}) as { task_id?: string }| // Get translation result | ||
| const translationResult = await cambClient.translation.getTranslationResult({ run_id: runId }) | ||
| const translatedText = translationResult.texts?.[0] ?? '' |
There was a problem hiding this comment.
Reusing a TTS run_id with the translation API may be fragile
After polling translatedTts.getTranslatedTtsTaskStatus, the resolved runId is passed to cambClient.translation.getTranslationResult. This works today because the CAMB AI API appears to share a single run_id namespace across both the TTS and translation sub-services for a translatedTts job, but this coupling is undocumented and non-obvious. If the API ever separates the two IDs (or if the TTS run_id becomes a different type than the translation run_id), this will fail silently or throw at runtime.
Consider adding a comment referencing the API docs that confirms the run_id from a translated-TTS task is also a valid translation result run_id, so the intent is clear to future maintainers.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Summary