diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba37092..deb6af7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,62 +1,46 @@ name: ci + on: push: workflow_dispatch: + jobs: compile: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 + - name: Set up python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: 3.8 + python-version: '3.8' + - name: Bootstrap poetry - run: | - curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + run: curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Install dependencies run: poetry install + - name: Compile run: poetry run mypy . + test: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 + - name: Set up python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: 3.8 + python-version: '3.8' + - name: Bootstrap poetry - run: | - curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + run: curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Install dependencies run: poetry install + - name: Test run: poetry run pytest -rP . - - publish: - needs: [compile, test] - if: (github.event_name == 'push' && contains(github.ref, 'refs/tags/')) || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - permissions: - id-token: write - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - name: Set up python - uses: actions/setup-python@v4 - with: - python-version: 3.8 - - name: Bootstrap poetry - run: | - curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 - - name: Install dependencies - run: poetry install - - name: Build package - run: poetry build - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9cabaaa --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,65 @@ +name: release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write # create GitHub Release + id-token: write # PyPI trusted publishing (OIDC) + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Set up python + uses: actions/setup-python@v6 + with: + python-version: '3.8' + + - name: Bootstrap poetry + run: curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + + - name: Install dependencies + run: poetry install + + - name: Compile + run: poetry run mypy . + + - name: Test + run: poetry run pytest -rP . + + - name: Build + run: poetry build + + - name: Extract changelog notes + id: changelog + run: | + VERSION="${GITHUB_REF_NAME}" + NOTES=$(awk -v ver="## [${VERSION}]" ' + index($0, ver) == 1 { found=1; next } + found && /^## / { exit } + found { print } + ' changelog.md) + echo "notes<> "$GITHUB_OUTPUT" + echo "$NOTES" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + NOTES: ${{ steps.changelog.outputs.notes }} + run: | + echo "$NOTES" > release_notes.md + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --notes-file release_notes.md \ + dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/changelog.md b/changelog.md index 0a94719..f5a6bcd 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [v1.2.0] — 2026-03-27 + +### Fixed + +- **`AresSTT`** — Removed redundant `language` key from the `params` dict. Language is now emitted only at the top level. `params` is only included when `additional_params` is provided. +- **`OpenAIRealtime` / `VertexAI` (MLLM)** — Agent-level `greeting`, `failure_message`, and `max_history` overrides are now correctly applied when the agent is in MLLM mode. Previously these values were silently dropped. +- **`VertexAI` (MLLM)** — `messages` is now correctly placed inside `params` (required by the Gemini Live API). Previously it was emitted at the top level and silently ignored. + +### Changed + +- **`OpenAITTS`** — Renamed constructor parameter `key` → `api_key` to match the Agora server API expectation. ⚠️ **Breaking change.** +- **`CartesiaTTS`** — Renamed constructor parameter `key` → `api_key`. Voice is now serialized as `{"mode": "id", "id": ""}` instead of a flat `voice_id` string. ⚠️ **Breaking change.** +- **`HeyGenAvatar`** — Removed legacy fields `avatar_name`, `voice_id`, `language`, `version`. Added `agora_token`, `avatar_id`, `enable`, `disable_idle_timeout`, `activity_idle_timeout`. The config now includes a top-level `enable` field (defaults `true`). ⚠️ **Breaking change.** + +### Added + +- **`OpenAITTS`** — New optional parameters: `response_format` (str, e.g. `"pcm"`) and `speed` (float). +- **`CartesiaTTS`** — `voice_id` user-facing field is preserved; voice is serialized to the required nested object format automatically. +- **`RimeTTS`** — New optional parameters: `lang` (str), `sampling_rate` (int, serialized as `samplingRate`), `speed_alpha` (float, serialized as `speedAlpha`). +- **`OpenAIRealtime`** — New optional parameters: `predefined_tools` (List[str]), `failure_message` (str), `max_history` (int). +- **`VertexAI` (MLLM)** — New optional parameters: `predefined_tools` (List[str]), `failure_message` (str), `max_history` (int). +- **`HeyGenAvatar`** — New fields: `agora_token` (str, optional), `avatar_id` (str, optional), `enable` (bool, optional, default `True`), `disable_idle_timeout` (bool, optional), `activity_idle_timeout` (int, optional). + ## [v1.1.0] — 2026-03-17 ### Added diff --git a/docs/reference/vendors.md b/docs/reference/vendors.md index c28ccb6..747e3a8 100644 --- a/docs/reference/vendors.md +++ b/docs/reference/vendors.md @@ -139,9 +139,11 @@ llm = Gemini(api_key='your-google-key', model='gemini-2.0-flash-exp') | Parameter | Type | Required | Default | Description | |---|---|---|---|---| -| `key` | `str` | Yes | — | OpenAI API key | +| `api_key` | `str` | Yes | — | OpenAI API key | | `voice` | `str` | Yes | — | Voice: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer` | | `model` | `str` | No | `None` | Model: `tts-1` or `tts-1-hd` | +| `response_format` | `str` | No | `None` | Audio format (e.g., `pcm`) | +| `speed` | `float` | No | `None` | Speech speed multiplier | | `skip_patterns` | `List[int]` | No | `None` | Skip patterns | Fixed sample rate: 24000 Hz. @@ -150,8 +152,8 @@ Fixed sample rate: 24000 Hz. | Parameter | Type | Required | Default | Description | |---|---|---|---|---| -| `key` | `str` | Yes | — | Cartesia API key | -| `voice_id` | `str` | Yes | — | Voice ID | +| `api_key` | `str` | Yes | — | Cartesia API key | +| `voice_id` | `str` | Yes | — | Voice ID (serialized as `{"mode": "id", "id": "..."}`) | | `model_id` | `str` | No | `None` | Model ID | | `sample_rate` | `int` | No | `None` | Sample rate: 8000–48000 Hz | | `skip_patterns` | `List[int]` | No | `None` | Skip patterns | @@ -190,6 +192,9 @@ Fixed sample rate: 24000 Hz. | `key` | `str` | Yes | — | Rime API key | | `speaker` | `str` | Yes | — | Speaker ID | | `model_id` | `str` | No | `None` | Model ID | +| `lang` | `str` | No | `None` | Language code | +| `sampling_rate` | `int` | No | `None` | Sampling rate in Hz (serialized as `samplingRate`) | +| `speed_alpha` | `float` | No | `None` | Speed multiplier (serialized as `speedAlpha`) | | `skip_patterns` | `List[int]` | No | `None` | Skip patterns | ### `FishAudioTTS` @@ -331,6 +336,9 @@ Fixed sample rate: 24000 Hz. | `model` | `str` | No | `None` | Model (e.g., `gpt-4o-realtime-preview`) | | `url` | `str` | No | `None` | Custom WebSocket URL | | `greeting_message` | `str` | No | `None` | Greeting message | +| `failure_message` | `str` | No | `None` | Message played when the model call fails | +| `max_history` | `int` | No | `None` | Maximum conversation history length | +| `predefined_tools` | `List[str]` | No | `None` | Predefined tools (e.g., `["_publish_message"]`) | | `input_modalities` | `List[str]` | No | `None` | Input modalities | | `output_modalities` | `List[str]` | No | `None` | Output modalities | | `messages` | `List[Dict]` | No | `None` | Conversation messages | @@ -347,6 +355,9 @@ Fixed sample rate: 24000 Hz. | `instructions` | `str` | No | `None` | System instructions | | `voice` | `str` | No | `None` | Voice name (e.g., `Aoede`, `Charon`) | | `greeting_message` | `str` | No | `None` | Greeting message | +| `failure_message` | `str` | No | `None` | Message played when the model call fails | +| `max_history` | `int` | No | `None` | Maximum conversation history length | +| `predefined_tools` | `List[str]` | No | `None` | Predefined tools (e.g., `["_publish_message"]`) | | `input_modalities` | `List[str]` | No | `None` | Input modalities | | `output_modalities` | `List[str]` | No | `None` | Output modalities | | `messages` | `List[Dict]` | No | `None` | Conversation messages | @@ -365,10 +376,11 @@ Required TTS sample rate: **24000 Hz** | `api_key` | `str` | Yes | — | HeyGen API key | | `quality` | `str` | Yes | — | Avatar quality: `low`, `medium`, or `high` | | `agora_uid` | `str` | Yes | — | Agora UID for avatar video stream | -| `avatar_name` | `str` | No | `None` | Avatar name | -| `voice_id` | `str` | No | `None` | Voice ID | -| `language` | `str` | No | `None` | Language code | -| `version` | `str` | No | `None` | API version (`v1` or `v2`) | +| `agora_token` | `str` | No | `None` | RTC token for avatar authentication | +| `avatar_id` | `str` | No | `None` | HeyGen avatar ID | +| `enable` | `bool` | No | `True` | Enable or disable the avatar | +| `disable_idle_timeout` | `bool` | No | `None` | Disable the idle timeout | +| `activity_idle_timeout` | `int` | No | `None` | Idle timeout in seconds (default: 120) | ### `AkoolAvatar` diff --git a/pyproject.toml b/pyproject.toml index 8f51a0e..ef55276 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "agora-agent-server-sdk" [tool.poetry] name = "agora-agent-server-sdk" -version = "1.1.0" +version = "1.2.0" description = "" readme = "README.md" authors = [] diff --git a/src/agora_agent/agentkit/agent.py b/src/agora_agent/agentkit/agent.py index c005a9b..a02160d 100644 --- a/src/agora_agent/agentkit/agent.py +++ b/src/agora_agent/agentkit/agent.py @@ -510,6 +510,15 @@ def to_properties( base_kwargs["filler_words"] = self._filler_words if is_mllm_mode: + if self._mllm is not None: + mllm_config = dict(self._mllm) + if self._greeting: + mllm_config.setdefault("greeting_message", self._greeting) + if self._failure_message: + mllm_config.setdefault("failure_message", self._failure_message) + if self._max_history is not None: + mllm_config.setdefault("max_history", self._max_history) + base_kwargs["mllm"] = mllm_config return StartAgentsRequestProperties(**base_kwargs) if self._tts is None: diff --git a/src/agora_agent/agentkit/vendors/avatar.py b/src/agora_agent/agentkit/vendors/avatar.py index 3ebde3d..ad99f78 100644 --- a/src/agora_agent/agentkit/vendors/avatar.py +++ b/src/agora_agent/agentkit/vendors/avatar.py @@ -12,10 +12,11 @@ class HeyGenAvatarOptions(BaseModel): api_key: str = Field(..., description="HeyGen API key") quality: str = Field(..., description="Avatar quality: low, medium, or high") agora_uid: str = Field(..., description="Agora UID for the avatar stream") - avatar_name: Optional[str] = Field(default=None, description="Avatar name") - voice_id: Optional[str] = Field(default=None, description="Voice ID") - language: Optional[str] = Field(default=None, description="Language code") - version: Optional[str] = Field(default=None, description="API version (v1 or v2)") + agora_token: Optional[str] = Field(default=None, description="RTC token for avatar authentication") + avatar_id: Optional[str] = Field(default=None, description="HeyGen avatar ID") + enable: Optional[bool] = Field(default=None, description="Enable avatar (default: true)") + disable_idle_timeout: Optional[bool] = Field(default=None, description="Whether to disable idle timeout") + activity_idle_timeout: Optional[int] = Field(default=None, description="Idle timeout in seconds") @field_validator("quality") @classmethod @@ -44,16 +45,17 @@ def to_config(self) -> Dict[str, Any]: "agora_uid": self.options.agora_uid, } - if self.options.avatar_name is not None: - params["avatar_name"] = self.options.avatar_name - if self.options.voice_id is not None: - params["voice_id"] = self.options.voice_id - if self.options.language is not None: - params["language"] = self.options.language - if self.options.version is not None: - params["version"] = self.options.version + if self.options.agora_token is not None: + params["agora_token"] = self.options.agora_token + if self.options.avatar_id is not None: + params["avatar_id"] = self.options.avatar_id + if self.options.disable_idle_timeout is not None: + params["disable_idle_timeout"] = self.options.disable_idle_timeout + if self.options.activity_idle_timeout is not None: + params["activity_idle_timeout"] = self.options.activity_idle_timeout - return {"vendor": "heygen", "params": params} + enable = self.options.enable if self.options.enable is not None else True + return {"enable": enable, "vendor": "heygen", "params": params} class AkoolAvatarOptions(BaseModel): diff --git a/src/agora_agent/agentkit/vendors/mllm.py b/src/agora_agent/agentkit/vendors/mllm.py index 6a6f115..44c6baa 100644 --- a/src/agora_agent/agentkit/vendors/mllm.py +++ b/src/agora_agent/agentkit/vendors/mllm.py @@ -14,6 +14,9 @@ class OpenAIRealtimeOptions(BaseModel): output_modalities: Optional[List[str]] = Field(default=None, description="Output modalities") messages: Optional[List[Dict[str, Any]]] = Field(default=None, description="Conversation messages") params: Optional[Dict[str, Any]] = Field(default=None, description="Additional parameters") + predefined_tools: Optional[List[str]] = Field(default=None, description="List of predefined tools") + failure_message: Optional[str] = Field(default=None, description="Message played on failure") + max_history: Optional[int] = Field(default=None, description="Maximum conversation history length") class Config: extra = "forbid" @@ -47,6 +50,12 @@ def to_config(self) -> Dict[str, Any]: config["output_modalities"] = self.options.output_modalities if self.options.messages is not None: config["messages"] = self.options.messages + if self.options.predefined_tools is not None: + config["predefined_tools"] = self.options.predefined_tools + if self.options.failure_message is not None: + config["failure_message"] = self.options.failure_message + if self.options.max_history is not None: + config["max_history"] = self.options.max_history return config @@ -63,6 +72,9 @@ class VertexAIOptions(BaseModel): output_modalities: Optional[List[str]] = Field(default=None, description="Output modalities") messages: Optional[List[Dict[str, Any]]] = Field(default=None, description="Conversation messages") additional_params: Optional[Dict[str, Any]] = Field(default=None, description="Additional parameters") + predefined_tools: Optional[List[str]] = Field(default=None, description="List of predefined tools") + failure_message: Optional[str] = Field(default=None, description="Message played on failure") + max_history: Optional[int] = Field(default=None, description="Maximum conversation history length") class Config: extra = "forbid" @@ -84,6 +96,8 @@ def to_config(self) -> Dict[str, Any]: params["instructions"] = self.options.instructions if self.options.voice is not None: params["voice"] = self.options.voice + if self.options.messages is not None: + params["messages"] = self.options.messages if self.options.additional_params is not None: params.update(self.options.additional_params) @@ -99,7 +113,11 @@ def to_config(self) -> Dict[str, Any]: config["input_modalities"] = self.options.input_modalities if self.options.output_modalities is not None: config["output_modalities"] = self.options.output_modalities - if self.options.messages is not None: - config["messages"] = self.options.messages + if self.options.predefined_tools is not None: + config["predefined_tools"] = self.options.predefined_tools + if self.options.failure_message is not None: + config["failure_message"] = self.options.failure_message + if self.options.max_history is not None: + config["max_history"] = self.options.max_history return config diff --git a/src/agora_agent/agentkit/vendors/stt.py b/src/agora_agent/agentkit/vendors/stt.py index d2508df..6159e3a 100644 --- a/src/agora_agent/agentkit/vendors/stt.py +++ b/src/agora_agent/agentkit/vendors/stt.py @@ -234,17 +234,12 @@ def __init__(self, **kwargs: Any): self.options = AresSTTOptions(**kwargs) def to_config(self) -> Dict[str, Any]: - params: Dict[str, Any] = {} + config: Dict[str, Any] = {"vendor": "ares"} if self.options.language is not None: - params["language"] = self.options.language - if self.options.additional_params is not None: - params.update(self.options.additional_params) - - return { - "vendor": "ares", - "language": self.options.language, - "params": params, - } + config["language"] = self.options.language + if self.options.additional_params: + config["params"] = self.options.additional_params + return config class SarvamSTTOptions(BaseModel): diff --git a/src/agora_agent/agentkit/vendors/tts.py b/src/agora_agent/agentkit/vendors/tts.py index 255094b..56da05a 100644 --- a/src/agora_agent/agentkit/vendors/tts.py +++ b/src/agora_agent/agentkit/vendors/tts.py @@ -94,9 +94,11 @@ def to_config(self) -> Dict[str, Any]: class OpenAITTSOptions(BaseModel): - key: str = Field(..., description="OpenAI API key") + api_key: str = Field(..., description="OpenAI API key") voice: str = Field(..., description="Voice name (alloy, echo, fable, onyx, nova, shimmer)") model: Optional[str] = Field(default=None, description="Model name (tts-1, tts-1-hd)") + response_format: Optional[str] = Field(default=None, description="Audio format (e.g., pcm)") + speed: Optional[float] = Field(default=None, description="Speech speed multiplier") skip_patterns: Optional[List[int]] = Field(default=None) class Config: @@ -113,12 +115,16 @@ def sample_rate(self) -> Optional[int]: def to_config(self) -> Dict[str, Any]: params: Dict[str, Any] = { - "key": self.options.key, + "api_key": self.options.api_key, "voice": self.options.voice, } if self.options.model is not None: params["model"] = self.options.model + if self.options.response_format is not None: + params["response_format"] = self.options.response_format + if self.options.speed is not None: + params["speed"] = self.options.speed result: Dict[str, Any] = {"vendor": "openai", "params": params} if self.options.skip_patterns is not None: @@ -127,7 +133,7 @@ def to_config(self) -> Dict[str, Any]: class CartesiaTTSOptions(BaseModel): - key: str = Field(..., description="Cartesia API key") + api_key: str = Field(..., description="Cartesia API key") voice_id: str = Field(..., description="Voice ID") model_id: Optional[str] = Field(default=None, description="Model ID") sample_rate: Optional[CartesiaSampleRate] = Field(default=None, description="Sample rate in Hz") @@ -147,8 +153,8 @@ def sample_rate(self) -> Optional[int]: def to_config(self) -> Dict[str, Any]: params: Dict[str, Any] = { - "key": self.options.key, - "voice_id": self.options.voice_id, + "api_key": self.options.api_key, + "voice": {"mode": "id", "id": self.options.voice_id}, } if self.options.model_id is not None: @@ -264,6 +270,9 @@ class RimeTTSOptions(BaseModel): key: str = Field(..., description="Rime API key") speaker: str = Field(..., description="Speaker ID") model_id: Optional[str] = Field(default=None, description="Model ID") + lang: Optional[str] = Field(default=None, description="Language code") + sampling_rate: Optional[int] = Field(default=None, description="Sampling rate in Hz") + speed_alpha: Optional[float] = Field(default=None, description="Speed multiplier") skip_patterns: Optional[List[int]] = Field(default=None) class Config: @@ -286,6 +295,12 @@ def to_config(self) -> Dict[str, Any]: if self.options.model_id is not None: params["model_id"] = self.options.model_id + if self.options.lang is not None: + params["lang"] = self.options.lang + if self.options.sampling_rate is not None: + params["samplingRate"] = self.options.sampling_rate + if self.options.speed_alpha is not None: + params["speedAlpha"] = self.options.speed_alpha result: Dict[str, Any] = {"vendor": "rime", "params": params} if self.options.skip_patterns is not None: