diff --git a/CHANGELOG.md b/CHANGELOG.md index fda5801e..737886e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/libraries/python/getpatter/__init__.py b/libraries/python/getpatter/__init__.py index 346be875..5d669288 100644 --- a/libraries/python/getpatter/__init__.py +++ b/libraries/python/getpatter/__init__.py @@ -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 ( @@ -504,6 +505,7 @@ def mix_pcm(agent: bytes, bg: bytes, ratio: float) -> bytes: "AnthropicLLM", "GroqLLM", "CerebrasLLM", + "InworldLLM", "GoogleLLM", "LiteLLMLLM", "OpenAICompatibleLLM", diff --git a/libraries/python/getpatter/llm/inworld.py b/libraries/python/getpatter/llm/inworld.py new file mode 100644 index 00000000..7af7de1b --- /dev/null +++ b/libraries/python/getpatter/llm/inworld.py @@ -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/"``) or a +request-level ``"/"`` 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) diff --git a/libraries/python/getpatter/pricing.py b/libraries/python/getpatter/pricing.py index a90081af..b136fdd3 100644 --- a/libraries/python/getpatter/pricing.py +++ b/libraries/python/getpatter/pricing.py @@ -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). diff --git a/libraries/python/getpatter/providers/__init__.py b/libraries/python/getpatter/providers/__init__.py index 3f3ba683..fe8b7998 100644 --- a/libraries/python/getpatter/providers/__init__.py +++ b/libraries/python/getpatter/providers/__init__.py @@ -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 diff --git a/libraries/python/getpatter/telephony/common.py b/libraries/python/getpatter/telephony/common.py index 9716e4bf..f54190d6 100644 --- a/libraries/python/getpatter/telephony/common.py +++ b/libraries/python/getpatter/telephony/common.py @@ -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." ) diff --git a/libraries/python/tests/test_inworld_integration.py b/libraries/python/tests/test_inworld_integration.py new file mode 100644 index 00000000..2504f8e2 --- /dev/null +++ b/libraries/python/tests/test_inworld_integration.py @@ -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 diff --git a/libraries/python/tests/test_pricing.py b/libraries/python/tests/test_pricing.py index c03b1868..b2de8d18 100644 --- a/libraries/python/tests/test_pricing.py +++ b/libraries/python/tests/test_pricing.py @@ -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: diff --git a/libraries/typescript/src/index.ts b/libraries/typescript/src/index.ts index 8178bf7b..121c6a56 100644 --- a/libraries/typescript/src/index.ts +++ b/libraries/typescript/src/index.ts @@ -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"; diff --git a/libraries/typescript/src/llm/inworld.ts b/libraries/typescript/src/llm/inworld.ts new file mode 100644 index 00000000..ba6d8c33 --- /dev/null +++ b/libraries/typescript/src/llm/inworld.ts @@ -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/"`) or a request-level `"/"` + * 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 { + /** 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 }); + } +} diff --git a/libraries/typescript/src/pricing.ts b/libraries/typescript/src/pricing.ts index e1daf58d..5282fb3f 100644 --- a/libraries/typescript/src/pricing.ts +++ b/libraries/typescript/src/pricing.ts @@ -245,12 +245,15 @@ export const DEFAULT_PRICING: Record = { }, 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 diff --git a/libraries/typescript/src/providers.ts b/libraries/typescript/src/providers.ts index 3445d7a0..cbcf83ee 100644 --- a/libraries/typescript/src/providers.ts +++ b/libraries/typescript/src/providers.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/libraries/typescript/tests/inworld.test.ts b/libraries/typescript/tests/inworld.test.ts new file mode 100644 index 00000000..d637678f --- /dev/null +++ b/libraries/typescript/tests/inworld.test.ts @@ -0,0 +1,74 @@ +/** + * Inworld integration: TTS pricing/models + Router LLM preset. + * + * 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) and the + * OpenAI-compatible Router endpoint + * (https://docs.inworld.ai/router/openai-compatibility). + */ +import { describe, it, expect } from 'vitest'; +import { DEFAULT_PRICING, PricingUnit } from '../src/pricing'; +import { inworld } from '../src/providers'; +import { InworldLLM } from '../src'; +import { INWORLD_ROUTER_BASE_URL } from '../src/llm/inworld'; + +describe('Inworld TTS pricing', () => { + it('prices all three models at On-Demand rates (per 1k chars)', () => { + const p = DEFAULT_PRICING.inworld; + expect(p.unit).toBe(PricingUnit.THOUSAND_CHARS); + expect(p.price).toBe(0.025); // default = TTS-2 + expect(p.models?.['inworld-tts-2']).toEqual({ price: 0.025 }); // $25/1M + expect(p.models?.['inworld-tts-1.5-max']).toEqual({ price: 0.035 }); // $35/1M + expect(p.models?.['inworld-tts-1.5-mini']).toEqual({ price: 0.015 }); // $15/1M + }); +}); + +describe('providers.inworld TTS config helper', () => { + it('builds an inworld TTS config (parity with Python)', () => { + const cfg = inworld({ apiKey: 'tok', voice: 'Olivia' }); + expect(cfg.provider).toBe('inworld'); + expect(cfg.voice).toBe('Olivia'); + }); + + it('defaults the voice to Ashley', () => { + expect(inworld({ apiKey: 'tok' }).voice).toBe('Ashley'); + }); +}); + +describe('Inworld Router LLM (OpenAI-compatible)', () => { + it('constructs with an explicit key and the distinct provider key', () => { + const llm = new InworldLLM({ apiKey: 'basic-token', model: 'openai/gpt-4o-mini' }); + expect(llm).toBeInstanceOf(InworldLLM); + // Distinct from the char-priced TTS 'inworld' key (token-based, at-cost). + expect(InworldLLM.providerKey).toBe('inworld_router'); + }); + + it('defaults the base URL to the Router endpoint', () => { + expect(INWORLD_ROUTER_BASE_URL).toBe('https://api.inworld.ai/v1'); + }); + + it('reads INWORLD_API_KEY from the environment', () => { + const prev = process.env.INWORLD_API_KEY; + process.env.INWORLD_API_KEY = 'from-env'; + try { + expect( + () => new InworldLLM({ model: 'anthropic/claude-haiku-4-5-20251001' }), + ).not.toThrow(); + } finally { + if (prev === undefined) delete process.env.INWORLD_API_KEY; + else process.env.INWORLD_API_KEY = prev; + } + }); + + it('requires an api key', () => { + const prev = process.env.INWORLD_API_KEY; + delete process.env.INWORLD_API_KEY; + try { + expect(() => new InworldLLM({ model: 'openai/gpt-4o-mini' })).toThrow( + /apiKey|INWORLD_API_KEY/, + ); + } finally { + if (prev !== undefined) process.env.INWORLD_API_KEY = prev; + } + }); +});