Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

### Added

- **Inworld integration completed: full TTS pricing, a config helper, and an
Inworld Realtime Router LLM (OpenAI-compatible) preset.** The Inworld TTS
adapter shipped already; this fills in the rest:
- **Pricing corrected & completed** (both SDKs) to the On-Demand list rates
from https://inworld.ai/pricing (per 1k chars): `inworld-tts-2` $0.025,
`inworld-tts-1.5-max` $0.035, and the previously-missing
`inworld-tts-1.5-mini` $0.015 (replacing the placeholder rates).
- **`providers.inworld()` TTS config helper** in both SDKs; Python's
`TTSConfig(provider="inworld")` path now builds the adapter
(`_create_tts_from_config`), closing the gap vs the other TTS providers.
- **`InworldLLM`** — a thin preset over the OpenAI-compatible LLM provider
targeting the Inworld Realtime Router (`https://api.inworld.ai/v1`,
`INWORLD_API_KEY`), unlocking 220+ models (OpenAI, Anthropic, Google,
Mistral, DeepSeek, Meta, …) behind one key. Uses the distinct
`inworld_router` provider key so token-based LLM cost is never confused with
the char-based TTS pricing. `getpatter/llm/inworld.py`, `src/llm/inworld.ts`.
Note: a standalone Inworld STT provider and the Realtime speech-to-speech
engine (WebSocket/WebRTC + JWT) are tracked as separate follow-ups.

- **Inbound audio front-end: opt-in high-pass / DC-block and speech-selective
AGC, plus inbound sample-rate validation.** The pipeline inbound chain
(caller → STT) gained the two canonical audio-processing stages it was
Expand Down
2 changes: 2 additions & 0 deletions libraries/python/getpatter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
from getpatter.llm.anthropic import LLM as AnthropicLLM
from getpatter.llm.groq import LLM as GroqLLM
from getpatter.llm.cerebras import LLM as CerebrasLLM
from getpatter.llm.inworld import LLM as InworldLLM
from getpatter.llm.google import LLM as GoogleLLM
from getpatter.llm.litellm import LLM as LiteLLMLLM
from getpatter.llm.openai_compatible import (
Expand Down Expand Up @@ -504,6 +505,7 @@ def mix_pcm(agent: bytes, bg: bytes, ratio: float) -> bytes:
"AnthropicLLM",
"GroqLLM",
"CerebrasLLM",
"InworldLLM",
"GoogleLLM",
"LiteLLMLLM",
"OpenAICompatibleLLM",
Expand Down
60 changes: 60 additions & 0 deletions libraries/python/getpatter/llm/inworld.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Inworld Realtime Router LLM for Patter's pipeline mode.

Inworld Router exposes an OpenAI-compatible ``/chat/completions`` endpoint at
``https://api.inworld.ai/v1`` that routes to 220+ models from OpenAI,
Anthropic, Google, Mistral, DeepSeek, Meta and more behind a single key — see
https://inworld.ai/models and https://docs.inworld.ai/router/openai-compatibility.

This is a thin preset over :class:`getpatter.llm.openai_compatible.LLM`: it
defaults the base URL and reads ``INWORLD_API_KEY``. The model string is the
Inworld target — either a configured router (``"inworld/<router-id>"``) or a
request-level ``"<provider>/<model>"`` slug from the models catalogue. Auth is
the Base64 Inworld key sent as a Bearer token (the form the OpenAI SDK uses).
"""

from __future__ import annotations

import os
from typing import ClassVar

from getpatter.llm.openai_compatible import OpenAICompatibleLLMProvider as _OpenAICompatLLM

__all__ = ["LLM"]

#: OpenAI-compatible Router endpoint (base URL only — change vs api.openai.com).
INWORLD_ROUTER_BASE_URL = "https://api.inworld.ai/v1"


class LLM(_OpenAICompatLLM):
"""Inworld Realtime Router LLM (OpenAI-compatible) for pipeline mode.

Example::

from getpatter.llm import inworld

# reads INWORLD_API_KEY; pick any model from the Router catalogue:
llm = inworld.LLM(model="openai/gpt-4o-mini")
llm = inworld.LLM(model="anthropic/claude-haiku-4-5-20251001")
# or a router you configured in the Inworld portal:
llm = inworld.LLM(api_key="...", model="inworld/my-router")
"""

#: Distinct from the TTS ``inworld`` key so token-based LLM cost is never
#: confused with the char-based TTS pricing entry.
provider_key: ClassVar[str] = "inworld_router"

def __init__(
self,
api_key: str | None = None,
*,
model: str,
base_url: str = INWORLD_ROUTER_BASE_URL,
**kwargs,
) -> None:
key = api_key or os.environ.get("INWORLD_API_KEY")
if not key:
raise ValueError(
"Inworld Router LLM requires an api_key. Pass api_key='...' or "
"set INWORLD_API_KEY in the environment."
)
super().__init__(api_key=key, base_url=base_url, model=model, **kwargs)
14 changes: 9 additions & 5 deletions libraries/python/getpatter/pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,16 @@ class PricingUnit(StrEnum):
},
"inworld": {
"unit": PricingUnit.THOUSAND_CHARS,
# Default = inworld-tts-2 (placeholder rate — verify against tier).
"price": 0.020,
# On-Demand (pay-as-you-go) list rates per https://inworld.ai/pricing
# ($/1M chars → $/1k chars): TTS-2 $25, TTS 1.5 Max $35, TTS 1.5 Mini
# $15. Paid tiers discount these (as low as ~$5/1M on Enterprise); the
# On-Demand rate is the safe default for unconfigured usage. Default =
# inworld-tts-2.
"price": 0.025,
"models": {
"inworld-tts-2": {"price": 0.020},
"inworld-tts-1.5-max": {"price": 0.025},
"inworld-tts-1.5": {"price": 0.025},
"inworld-tts-2": {"price": 0.025},
"inworld-tts-1.5-max": {"price": 0.035},
"inworld-tts-1.5-mini": {"price": 0.015},
},
},
# OpenAI Realtime — per token (actual tokens from response.done usage).
Expand Down
15 changes: 15 additions & 0 deletions libraries/python/getpatter/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ def lmnt(api_key: str, voice: str = "leah") -> TTSConfig:
return TTSConfig(provider="lmnt", api_key=api_key, voice=voice)


def inworld(
api_key: str, voice: str = "Ashley", *, model: str = "inworld-tts-2"
) -> TTSConfig:
"""Config helper for Inworld TTS (requires the ``inworld`` optional extra).

``api_key`` is the Base64 ``Authorization: Basic`` token from the Inworld
dashboard. ``model`` defaults to ``inworld-tts-2`` (100+ languages,
steering); pass ``inworld-tts-1.5-mini`` for the lowest-latency model or
``inworld-tts-1.5-max`` for the prior flagship.
"""
return TTSConfig(
provider="inworld", api_key=api_key, voice=voice, options={"model": model}
)


def _load_anthropic_llm():
from getpatter.providers.anthropic_llm import AnthropicLLMProvider

Expand Down
20 changes: 19 additions & 1 deletion libraries/python/getpatter/telephony/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,25 @@ def _create_tts_from_config(config):
kwargs = {k: v for k, v in opts.items() if k in allowed}
return LMNTTTS(api_key=config.api_key, voice=config.voice, **kwargs)

if provider == "inworld":
from getpatter.providers.inworld_tts import InworldTTS # type: ignore[import]

allowed = {
"model",
"language",
"audio_encoding",
"sample_rate",
"bitrate",
"temperature",
"speaking_rate",
"delivery_mode",
"base_url",
}
kwargs = {k: v for k, v in opts.items() if k in allowed}
# ``api_key`` carries the Base64 ``Authorization: Basic`` token.
return InworldTTS(auth_token=config.api_key, voice=config.voice, **kwargs)

raise ValueError(
f"Unknown TTS provider '{provider}'. "
"Supported: elevenlabs, openai, cartesia, rime, lmnt."
"Supported: elevenlabs, openai, cartesia, rime, lmnt, inworld."
)
77 changes: 77 additions & 0 deletions libraries/python/tests/test_inworld_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Inworld integration: TTS pricing/models, config path, and Router LLM preset.

Pricing rates verified against the On-Demand list price on
https://inworld.ai/pricing (TTS-2 $25/1M, TTS 1.5 Max $35/1M, TTS 1.5 Mini
$15/1M → $/1k chars) and the OpenAI-compatible Router endpoint
(https://docs.inworld.ai/router/openai-compatibility).
"""

from __future__ import annotations

import pytest

from getpatter import InworldLLM, providers
from getpatter.llm.inworld import INWORLD_ROUTER_BASE_URL
from getpatter.pricing import DEFAULT_PRICING, PricingUnit


class TestInworldTTSPricing:
def test_unit_is_per_thousand_chars(self) -> None:
assert DEFAULT_PRICING["inworld"]["unit"] == PricingUnit.THOUSAND_CHARS

def test_all_three_models_priced_on_demand_rates(self) -> None:
models = DEFAULT_PRICING["inworld"]["models"]
assert models["inworld-tts-2"]["price"] == 0.025 # $25 / 1M
assert models["inworld-tts-1.5-max"]["price"] == 0.035 # $35 / 1M
assert models["inworld-tts-1.5-mini"]["price"] == 0.015 # $15 / 1M

def test_default_is_tts_2_rate(self) -> None:
assert DEFAULT_PRICING["inworld"]["price"] == 0.025


class TestInworldTTSConfigHelper:
def test_providers_inworld_builds_tts_config(self) -> None:
cfg = providers.inworld("tok", voice="Olivia", model="inworld-tts-1.5-mini")
assert cfg.provider == "inworld"
assert cfg.api_key == "tok"
assert cfg.voice == "Olivia"
assert cfg.options == {"model": "inworld-tts-1.5-mini"}

def test_default_model_and_voice(self) -> None:
cfg = providers.inworld("tok")
assert cfg.voice == "Ashley"
assert cfg.options == {"model": "inworld-tts-2"}

def test_create_tts_from_config_builds_adapter(self) -> None:
pytest.importorskip("aiohttp")
from getpatter.telephony.common import _create_tts_from_config

cfg = providers.inworld("tok", voice="Olivia", model="inworld-tts-1.5-mini")
adapter = _create_tts_from_config(cfg)
assert type(adapter).__name__ == "InworldTTS"
assert adapter.voice == "Olivia"
assert adapter.model == "inworld-tts-1.5-mini"
assert adapter.auth_token == "tok"


class TestInworldRouterLLM:
def test_constructs_with_explicit_key(self) -> None:
llm = InworldLLM(api_key="basic-token", model="openai/gpt-4o-mini")
assert llm.provider_key == "inworld_router"
assert str(llm._client.base_url).rstrip("/") == INWORLD_ROUTER_BASE_URL

def test_reads_env_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INWORLD_API_KEY", "from-env")
llm = InworldLLM(model="anthropic/claude-haiku-4-5-20251001")
assert str(llm._client.base_url).rstrip("/") == INWORLD_ROUTER_BASE_URL

def test_requires_a_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("INWORLD_API_KEY", raising=False)
with pytest.raises(ValueError, match="Inworld Router LLM requires an api_key"):
InworldLLM(model="openai/gpt-4o-mini")

def test_provider_key_distinct_from_tts(self) -> None:
# The TTS provider key 'inworld' is char-priced; the Router LLM must NOT
# collide with it (token-based, at-cost), so it uses 'inworld_router'.
assert InworldLLM.provider_key == "inworld_router"
assert "inworld_router" not in DEFAULT_PRICING # at-cost, per-model
13 changes: 12 additions & 1 deletion libraries/python/tests/test_pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,19 @@ def test_openai_tts_default_is_tts1(self):

def test_inworld_tts_2_default(self):
pricing = merge_pricing(None)
# On-Demand list rate: TTS-2 $25/1M = $0.025/1k chars.
cost = calculate_tts_cost("inworld", 1000, pricing, model="inworld-tts-2")
assert cost == pytest.approx(0.020)
assert cost == pytest.approx(0.025)

def test_inworld_tts_1_5_mini_and_max_rates(self):
pricing = merge_pricing(None)
# TTS 1.5 Mini $15/1M = $0.015/1k; TTS 1.5 Max $35/1M = $0.035/1k.
mini = calculate_tts_cost(
"inworld", 1000, pricing, model="inworld-tts-1.5-mini"
)
mx = calculate_tts_cost("inworld", 1000, pricing, model="inworld-tts-1.5-max")
assert mini == pytest.approx(0.015)
assert mx == pytest.approx(0.035)


class TestPerModelOverrideMerge:
Expand Down
2 changes: 2 additions & 0 deletions libraries/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ export { LLM as GroqLLM } from "./llm/groq";
export type { GroqLLMOptions } from "./llm/groq";
export { LLM as CerebrasLLM } from "./llm/cerebras";
export type { CerebrasLLMOptions } from "./llm/cerebras";
export { LLM as InworldLLM } from "./llm/inworld";
export type { InworldLLMOptions } from "./llm/inworld";
export { LLM as GoogleLLM } from "./llm/google";
export type { GoogleLLMOptions } from "./llm/google";
export { LLM as LiteLLMLLM } from "./llm/litellm";
Expand Down
49 changes: 49 additions & 0 deletions libraries/typescript/src/llm/inworld.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Inworld Realtime Router LLM for Patter pipeline mode.
*
* Inworld Router exposes an OpenAI-compatible `/chat/completions` endpoint at
* `https://api.inworld.ai/v1` that routes to 220+ models from OpenAI,
* Anthropic, Google, Mistral, DeepSeek, Meta and more behind one key — see
* https://inworld.ai/models and
* https://docs.inworld.ai/router/openai-compatibility. Thin preset over
* {@link OpenAICompatibleLLMProvider}: defaults the base URL and reads
* `INWORLD_API_KEY`. The model string is the Inworld target — a configured
* router (`"inworld/<router-id>"`) or a request-level `"<provider>/<model>"`
* slug from the catalogue.
*/
import {
OpenAICompatibleLLMProvider,
type OpenAICompatibleLLMOptions,
} from "./openai-compatible";

/** OpenAI-compatible Router endpoint (base URL only — vs api.openai.com). */
export const INWORLD_ROUTER_BASE_URL = "https://api.inworld.ai/v1";

/** Constructor options for the Inworld Router `LLM` (base URL pre-filled). */
export interface InworldLLMOptions extends Omit<OpenAICompatibleLLMOptions, "baseUrl"> {
/** Override the Router base URL (rarely needed). */
baseUrl?: string;
}

/**
* Inworld Realtime Router LLM (OpenAI-compatible, streaming).
*
* @example
* ```ts
* import * as inworld from "getpatter/llm/inworld";
* const llm = new inworld.LLM({ model: "openai/gpt-4o-mini" }); // reads INWORLD_API_KEY
* const llm = new inworld.LLM({ apiKey: "...", model: "anthropic/claude-haiku-4-5-20251001" });
* ```
*/
export class LLM extends OpenAICompatibleLLMProvider {
static readonly providerKey = "inworld_router";
constructor(opts: InworldLLMOptions) {
const key = opts.apiKey ?? process.env.INWORLD_API_KEY;
if (!key) {
throw new Error(
"Inworld Router LLM requires an apiKey. Pass { apiKey: '...' } or set INWORLD_API_KEY.",
);
}
super({ ...opts, apiKey: key, baseUrl: opts.baseUrl ?? INWORLD_ROUTER_BASE_URL });
}
}
13 changes: 8 additions & 5 deletions libraries/typescript/src/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,12 +245,15 @@ export const DEFAULT_PRICING: Record<string, ProviderPricing> = {
},
inworld: {
unit: PricingUnit.THOUSAND_CHARS,
// Default = inworld-tts-2 (placeholder rate — verify against tier).
price: 0.020,
// On-Demand (pay-as-you-go) list rates per https://inworld.ai/pricing
// ($/1M chars → $/1k chars): TTS-2 $25, TTS 1.5 Max $35, TTS 1.5 Mini $15.
// Paid tiers discount these (as low as ~$5/1M on Enterprise); the On-Demand
// rate is the safe default for unconfigured usage. Default = inworld-tts-2.
price: 0.025,
models: {
'inworld-tts-2': { price: 0.020 },
'inworld-tts-1.5-max': { price: 0.025 },
'inworld-tts-1.5': { price: 0.025 },
'inworld-tts-2': { price: 0.025 },
'inworld-tts-1.5-max': { price: 0.035 },
'inworld-tts-1.5-mini': { price: 0.015 },
},
},
// OpenAI Realtime — per token. Provider defaults match
Expand Down
9 changes: 9 additions & 0 deletions libraries/typescript/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ export function lmnt(opts: { apiKey: string; voice?: string }): TTSConfig {
return new TTSConfigImpl("lmnt", opts.apiKey, opts.voice ?? "leah");
}

/**
* Inworld TTS config helper (parity with Python ``getpatter.providers.inworld``).
* For pipeline use the documented path is the direct adapter
* ``new InworldTTS({...})`` from ``getpatter``.
*/
export function inworld(opts: { apiKey: string; voice?: string }): TTSConfig {
return new TTSConfigImpl("inworld", opts.apiKey, opts.voice ?? "Ashley");
}

// ---------------------------------------------------------------------------
// Realtime / ConvAI helpers
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading