Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat(integrations/camb-ai): add CAMB AI integration#15010

Open
neilruaro-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
Open

feat(integrations/camb-ai): add CAMB AI integration#15010
neilruaro-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

@neilruaro-camb
Copy link
Copy Markdown

Summary

  • Add new CAMB AI integration with 5 actions: generateSpeech, translateText, translatedTts, cloneVoice, listVoices
  • Ultra-low-latency TTS via MARS models (mars-flash, mars-pro, mars-instruct)
  • Text translation and translated TTS across 140+ languages
  • Voice cloning from audio samples

Add CAMB AI integration for text-to-speech, translation, translated TTS,
voice cloning, and voice listing across 140+ languages using MARS models.
@neilruaro-camb neilruaro-camb requested review from a team as code owners March 13, 2026 05:59
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 13, 2026

Greptile Summary

This PR introduces a new CAMB AI integration that exposes five actions — generateSpeech, translateText, translatedTts, cloneVoice, and listVoices — backed by the @camb-ai/sdk. The integration follows the established Botpress integration pattern (definition + handler), uses a shared pollForResult helper for async task polling, and uploads generated audio to the Botpress Files API.

Key issues found:

  • speed parameter silently dropped (src/index.ts): The generateSpeech input schema exposes a speed field (0.5–2.0), but it is never forwarded to cambClient.textToSpeech.tts(). Users who configure a custom speed will have their value ignored without any error.
  • No null guard on cloneVoice result (src/index.ts): result.voice_id is returned directly without a null check. Every other critical response field (task_id, run_id) is guarded, but this one is not — a missing voice_id from the API would produce an invalid output object.
  • createTranslation cast to any (src/index.ts): The translation creation response is cast to any to access task_id, bypassing TypeScript type safety. The rest of the codebase avoids this pattern and a narrower type assertion would be safer.
  • Implicit coupling between TTS and translation run_id (src/index.ts): The translatedTts action uses the run_id returned by the TTS status poller to call translation.getTranslationResult. This works if both services share the same ID namespace, but that assumption is not documented and could silently break if the CAMB AI API changes.

Confidence Score: 2/5

  • Not safe to merge — one input parameter is silently discarded and a missing API field can produce an invalid output object.
  • The speed parameter being silently ignored is a functional regression from the user's perspective (they configure it, nothing happens). The missing null guard on cloneVoice's voice_id can produce a schema-violating response. These are straightforward fixes, but they need to be resolved before the integration ships to users.
  • integrations/camb-ai/src/index.ts requires the most attention — both bugs are concentrated there.

Important Files Changed

Filename Overview
integrations/camb-ai/src/index.ts Main integration handler implementing 5 actions; speed is silently dropped from the generateSpeech API call, and cloneVoice lacks a null guard on result.voice_id. Also casts translation response to any.
integrations/camb-ai/src/camb-client.ts Clean polling helper with well-defined timeout, ERROR, and SUCCESS handling; no issues found.
integrations/camb-ai/integration.definition.ts Well-structured integration definition with 5 actions; speed field is correctly declared but not forwarded in the implementation.
integrations/camb-ai/package.json Standard package config with @camb-ai/sdk dependency; no issues found.
integrations/camb-ai/hub.md Concise, accurate documentation for the hub listing; no issues found.

Sequence Diagram

sequenceDiagram
    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 }
Loading

Last reviewed commit: b4a2f7d

Comment thread integrations/camb-ai/src/index.ts Outdated
Comment on lines +42 to +48
const cost = 0
metadata.setCost(cost)
return {
audioUrl: file.url,
botpress: {
cost,
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread integrations/camb-ai/src/index.ts Outdated
Comment on lines +111 to +126
// 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')
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Comment thread integrations/camb-ai/src/index.ts Outdated
Comment on lines +133 to +135
const audioResponse = await fetch(`https://client.camb.ai/apis/tts-result/${runId}`, {
headers: { 'x-api-key': bp.secrets.CAMB_AI_API_KEY },
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}`, {

Comment on lines +195 to +203
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
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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():

Suggested change
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()).

Comment thread integrations/camb-ai/src/schemas.ts Outdated
Comment on lines +1 to +7
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>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 const

This duplication means the two enum definitions can diverge silently. Either:

  • Remove schemas.ts and use the local constant in integration.definition.ts as-is, or
  • Import ttsModelId from schemas.ts in integration.definition.ts and use it for the model field to keep the canonical list in one place.

Comment on lines +16 to +22
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,
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
})

Comment on lines +160 to +170
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,
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
}

Comment on lines +66 to +70
const createResult = (await cambClient.translation.createTranslation({
texts: [input.text],
source_language: input.sourceLanguage,
target_language: input.targetLanguage,
})) as any
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }

Comment on lines +115 to +117
// Get translation result
const translationResult = await cambClient.translation.getTranslationResult({ run_id: runId })
const translatedText = translationResult.texts?.[0] ?? ''
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

@SebastienPoitras SebastienPoitras removed the request for review from a team May 1, 2026 21:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Morty Proxy This is a proxified and sanitized view of the page, visit original site.