fix: freeze the public voice API contract (#4578)

This commit is contained in:
Kazuhiro Sera
2026-08-22 18:08:27 +09:00
committed by GitHub
parent 5f6a733284
commit 5b8f6c7174
4 changed files with 1262 additions and 27 deletions
+352 -5
View File
@@ -12,8 +12,8 @@ from collections.abc import Callable, Iterable, Mapping
from copy import deepcopy
from importlib.util import find_spec
from pathlib import Path
from types import FunctionType, TracebackType
from typing import Any, ForwardRef, cast, get_origin, get_type_hints
from types import FunctionType, TracebackType, UnionType
from typing import Any, ForwardRef, Literal, Union, cast, get_args, get_origin, get_type_hints
import typing_extensions
from pydantic import BaseModel
@@ -36,7 +36,9 @@ class SubmoduleExportPolicy:
modules: dict[str, dict[str, dict[str, str]]]
dependency_installations: tuple[OptionalDependencyInstallation, ...]
canonical_imports: tuple[dict[str, str], ...] = ()
public_class_contracts: tuple[dict[str, Any], ...] = ()
public_properties: tuple[dict[str, Any], ...] = ()
public_type_aliases: tuple[dict[str, str], ...] = ()
public_typed_dicts: tuple[dict[str, Any], ...] = ()
@@ -56,7 +58,9 @@ def load_submodule_export_policy(path: Path) -> SubmoduleExportPolicy:
"canonical_imports",
"modules",
"optional_dependencies",
"public_class_contracts",
"public_properties",
"public_type_aliases",
"public_typed_dicts",
}
)
@@ -164,7 +168,11 @@ def load_submodule_export_policy(path: Path) -> SubmoduleExportPolicy:
)
),
canonical_imports=_canonical_import_policy(value.get("canonical_imports", [])),
public_class_contracts=_public_class_contract_policy(
value.get("public_class_contracts", [])
),
public_properties=_public_property_policy(value.get("public_properties", [])),
public_type_aliases=_public_type_alias_policy(value.get("public_type_aliases", [])),
public_typed_dicts=_public_typed_dict_policy(value.get("public_typed_dicts", [])),
)
@@ -205,7 +213,8 @@ def _public_property_policy(value: object) -> tuple[dict[str, Any], ...]:
if not isinstance(entry, dict):
raise ValueError("submodule export policy public_properties entries must be objects")
owner_fields = {"class_name", "factory_name"} & set(entry)
if len(owner_fields) != 1 or set(entry) != {"module", "names", *owner_fields}:
required_fields = {"module", "names", *owner_fields}
if len(owner_fields) != 1 or set(entry) != required_fields:
raise ValueError(
"submodule export policy public_properties entries must contain exactly "
"module, names, and one of class_name or factory_name"
@@ -240,7 +249,75 @@ def _public_property_policy(value: object) -> tuple[dict[str, Any], ...]:
f"{module_name}.{owner_name}"
)
identities.add(identity)
entries.append({owner_field: owner_name, "module": module_name, "names": list(names)})
normalized_entry = {
owner_field: owner_name,
"module": module_name,
"names": list(names),
}
entries.append(normalized_entry)
return tuple(entries)
def _public_class_contract_policy(value: object) -> tuple[dict[str, Any], ...]:
if not isinstance(value, list):
raise ValueError("submodule export policy public_class_contracts must be a list")
required_fields = {"class_name", "module"}
contract_fields = {"abstract", "abstract_members"}
entries: list[dict[str, Any]] = []
identities: set[tuple[str, str]] = set()
for entry in value:
if (
not isinstance(entry, dict)
or not required_fields.issubset(entry)
or not set(entry).issubset(required_fields | contract_fields)
or not (set(entry) & contract_fields)
):
raise ValueError(
"submodule export policy public_class_contracts entries must contain exactly "
"module, class_name, and at least one of abstract or abstract_members"
)
module_name = entry["module"]
class_name = entry["class_name"]
if type(module_name) is not str or not module_name:
raise ValueError(
"submodule export policy public_class_contracts module must be a non-empty string"
)
if type(class_name) is not str or not class_name:
raise ValueError(
"submodule export policy public_class_contracts class_name must be a non-empty "
"string"
)
if "abstract" in entry and type(entry["abstract"]) is not bool:
raise ValueError(
"submodule export policy public_class_contracts abstract must be a boolean"
)
abstract_members = entry.get("abstract_members")
if "abstract_members" in entry and (
not isinstance(abstract_members, list)
or not abstract_members
or not all(type(name) is str and name for name in abstract_members)
or len(abstract_members) != len(set(abstract_members))
):
raise ValueError(
"submodule export policy public_class_contracts abstract_members must be a "
"non-empty list of unique non-empty strings"
)
identity = (module_name, class_name)
if identity in identities:
raise ValueError(
"submodule export policy public_class_contracts must not repeat "
f"{module_name}.{class_name}"
)
identities.add(identity)
normalized_entry: dict[str, Any] = {
"class_name": class_name,
"module": module_name,
}
if "abstract" in entry:
normalized_entry["abstract"] = entry["abstract"]
if "abstract_members" in entry:
normalized_entry["abstract_members"] = sorted(abstract_members)
entries.append(normalized_entry)
return tuple(entries)
@@ -288,6 +365,33 @@ def _public_typed_dict_policy(value: object) -> tuple[dict[str, Any], ...]:
return tuple(entries)
def _public_type_alias_policy(value: object) -> tuple[dict[str, str], ...]:
if not isinstance(value, list):
raise ValueError("submodule export policy public_type_aliases must be a list")
required_fields = {"module", "name"}
entries: list[dict[str, str]] = []
identities: set[tuple[str, str]] = set()
for entry in value:
if not isinstance(entry, dict) or set(entry) != required_fields:
raise ValueError(
"submodule export policy public_type_aliases entries must contain exactly "
"module and name"
)
if not all(type(entry[field]) is str and entry[field] for field in required_fields):
raise ValueError(
"submodule export policy public_type_aliases values must be non-empty strings"
)
identity = (entry["module"], entry["name"])
if identity in identities:
raise ValueError(
"submodule export policy public_type_aliases must not repeat "
f"{entry['module']}.{entry['name']}"
)
identities.add(identity)
entries.append({"module": entry["module"], "name": entry["name"]})
return tuple(entries)
def _add_legacy_literal_types(value: object) -> None:
if isinstance(value, dict):
if value.get("kind") == "literal" and "value" in value and "type" not in value:
@@ -414,6 +518,11 @@ def _default_contract(value: object) -> dict[str, object]:
"name": value.name,
"value": _default_contract(value.value),
}
if isinstance(value, type):
return {
"kind": "type",
"identity": f"{value.__module__}.{value.__qualname__}",
}
if isinstance(value, tuple | list):
return {
"kind": "sequence",
@@ -723,6 +832,31 @@ def _merge_public_properties(
return result
def _merge_public_class_contracts(
existing: Iterable[Mapping[str, Any]], promoted: Iterable[Mapping[str, Any]]
) -> list[dict[str, Any]]:
result = [deepcopy(dict(entry)) for entry in existing]
by_identity = {(entry["module"], entry["class_name"]): entry for entry in result}
for entry_value in promoted:
entry = deepcopy(dict(entry_value))
identity = (entry["module"], entry["class_name"])
previous = by_identity.get(identity)
if previous is None:
result.append(entry)
by_identity[identity] = entry
continue
for field_name in ("abstract", "abstract_members"):
if field_name not in entry:
continue
previous_value = previous.setdefault(field_name, entry[field_name])
if previous_value != entry[field_name]:
raise ValueError(
"release policy public class contract conflicts with the released contract "
f"for {entry['module']}.{entry['class_name']} field {field_name}"
)
return result
def _public_property_identity(entry: Mapping[str, Any]) -> tuple[str, str, str]:
if "class_name" in entry:
return ("class_name", cast(str, entry["module"]), cast(str, entry["class_name"]))
@@ -744,6 +878,86 @@ def _annotation_contract(annotation: object) -> str:
return annotation_text
def _sorted_type_alias_members(members: Iterable[dict[str, object]]) -> list[dict[str, object]]:
return sorted(
members,
key=lambda member: (
cast(str, member["kind"]),
json.dumps(member, sort_keys=True, separators=(",", ":")),
),
)
def _type_alias_definition(value: object) -> dict[str, object]:
origin = get_origin(value)
if origin is Literal:
literal_values: list[dict[str, object]] = []
for literal_value in get_args(value):
literal_contract = _default_contract(literal_value)
if literal_contract["kind"] not in {"literal", "enum"}:
raise TypeError(
"public type alias Literal members must use supported literal or enum values"
)
literal_values.append(literal_contract)
return {
"kind": "literal",
"values": _sorted_type_alias_members(literal_values),
}
if origin in {Union, UnionType}:
members = [_type_alias_definition(member) for member in get_args(value)]
return {
"kind": "union",
"members": _sorted_type_alias_members(members),
}
if isinstance(value, type) and (
value.__module__ == "agents" or value.__module__.startswith("agents.")
):
return {
"kind": "type",
"identity": f"{value.__module__}.{value.__qualname__}",
}
raise TypeError(f"unsupported public type alias member: {value!r}")
def _public_type_alias_contract(
policy_entries: Iterable[Mapping[str, str]],
agents_module: Any | None,
) -> list[dict[str, object]]:
entries: list[dict[str, object]] = []
missing = object()
for policy_entry in policy_entries:
module_name = policy_entry["module"]
alias_name = policy_entry["name"]
module = _import_contract_module(module_name, agents_module)
alias = getattr(module, alias_name, missing)
if alias is missing:
raise ValueError(
f"Cannot promote public type alias {module_name}.{alias_name} because it is missing"
)
try:
definition = _type_alias_definition(alias)
except TypeError as error:
raise ValueError(
f"Cannot promote public type alias {module_name}.{alias_name}: {error}"
) from None
entries.append({"definition": definition, "module": module_name, "name": alias_name})
return entries
def _merge_public_type_aliases(
existing: Iterable[Mapping[str, Any]], promoted: Iterable[Mapping[str, Any]]
) -> list[dict[str, Any]]:
result = [deepcopy(dict(entry)) for entry in existing]
identities = {(entry["module"], entry["name"]) for entry in result}
for entry_value in promoted:
entry = deepcopy(dict(entry_value))
identity = (entry["module"], entry["name"])
if identity not in identities:
result.append(entry)
identities.add(identity)
return result
def _typed_dict_field_is_required(typed_dict: type, name: str, annotation: object) -> bool:
if isinstance(annotation, ForwardRef):
annotation_text = annotation.__forward_arg__
@@ -879,9 +1093,21 @@ def _optional_dependency_is_unsupported_for_contract(
def _optional_dependency_for_binding(
contract: Mapping[str, Any], module_name: str, binding_name: str
) -> str | None:
return _optional_dependency_for_binding_in_modules(
dependency = _optional_dependency_for_binding_in_modules(
contract.get("required_submodule_exports", {}), module_name, binding_name
)
if dependency is not None:
return dependency
canonical_dependencies = {
_optional_dependency_for_binding_in_modules(
contract.get("required_submodule_exports", {}), entry["module"], entry["name"]
)
for entry in contract.get("canonical_imports", [])
if entry["canonical_module"] == module_name and entry["canonical_name"] == binding_name
}
if canonical_dependencies and len(canonical_dependencies) == 1:
return next(iter(canonical_dependencies))
return None
def _optional_dependency_for_binding_in_modules(
@@ -913,6 +1139,17 @@ def _optional_dependency_for_module_import(
dependencies = {optional_bindings.get(name) or optional_exports.get(name) for name in names}
if names and len(dependencies) == 1 and None not in dependencies:
return cast(str, next(iter(dependencies)))
if names:
return None
canonical_dependencies = {
_optional_dependency_for_binding(contract, entry["module"], entry["name"])
for entry in contract.get("canonical_imports", [])
if entry["canonical_module"] == module_name
}
if canonical_dependencies and len(canonical_dependencies) == 1:
dependency = next(iter(canonical_dependencies))
if dependency is not None:
return dependency
return None
@@ -1086,10 +1323,20 @@ def build_released_api_contract(
updated["required_top_level_exports"] = ordered_exports
updated["callables"] = callables
updated["canonical_imports"] = canonical_imports
updated["public_class_contracts"] = _merge_public_class_contracts(
contract.get("public_class_contracts", []),
release_policy.public_class_contracts if release_policy is not None else (),
)
updated["public_properties"] = _merge_public_properties(
contract.get("public_properties", []),
release_policy.public_properties if release_policy is not None else (),
)
updated["public_type_aliases"] = _merge_public_type_aliases(
contract.get("public_type_aliases", []),
_public_type_alias_contract(release_policy.public_type_aliases, agents_module)
if release_policy is not None
else (),
)
updated["public_typed_dicts"] = _merge_public_typed_dicts(
contract.get("public_typed_dicts", []),
_public_typed_dict_contract(release_policy.public_typed_dicts, agents_module)
@@ -1220,7 +1467,9 @@ def build_released_api_contract(
"callables",
"optional_dependency_unsupported_platforms",
"platform_import_errors",
"public_class_contracts",
"public_properties",
"public_type_aliases",
"public_typed_dicts",
"public_modules",
"required_submodule_exports",
@@ -1361,6 +1610,48 @@ def _validate_public_property_contract(
return errors
def _validate_public_class_contract(
contract: dict[str, Any],
agents_module: Any | None,
*,
unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None,
) -> list[str]:
errors: list[str] = []
unsupported_platforms = unsupported_platforms or {}
for entry in contract.get("public_class_contracts", []):
module_name = entry["module"]
class_name = entry["class_name"]
optional_dependency = _optional_dependency_for_binding(contract, module_name, class_name)
if optional_dependency is not None and not _optional_dependency_is_available_for_contract(
optional_dependency, unsupported_platforms
):
continue
try:
module = _import_contract_module(module_name, agents_module)
except Exception as error:
errors.append(f"Failed to import released module {module_name}: {error!r}")
continue
class_value = getattr(module, class_name, None)
if not isinstance(class_value, type):
errors.append(f"Missing released public class {module_name}.{class_name}")
continue
if "abstract" in entry and inspect.isabstract(class_value) != entry["abstract"]:
expected_state = "abstract" if entry["abstract"] else "concrete"
current_state = "abstract" if inspect.isabstract(class_value) else "concrete"
errors.append(
f"{module_name}.{class_name} changed its released public class state: "
f"expected {expected_state}, got {current_state}"
)
if "abstract_members" in entry:
current_members = sorted(getattr(class_value, "__abstractmethods__", ()))
if current_members != entry["abstract_members"]:
errors.append(
f"{module_name}.{class_name} changed its released public abstract members: "
f"expected {entry['abstract_members']!r}, got {current_members!r}"
)
return errors
def _validate_public_typed_dict_contract(
contract: dict[str, Any],
agents_module: Any | None,
@@ -1397,6 +1688,48 @@ def _validate_public_typed_dict_contract(
return errors
def _validate_public_type_alias_contract(
contract: dict[str, Any],
agents_module: Any | None,
*,
unsupported_platforms: Mapping[str, tuple[str, ...]] | None = None,
) -> list[str]:
errors: list[str] = []
unsupported_platforms = unsupported_platforms or {}
missing = object()
for entry in contract.get("public_type_aliases", []):
module_name = entry["module"]
alias_name = entry["name"]
optional_dependency = _optional_dependency_for_binding(contract, module_name, alias_name)
if optional_dependency is not None and not _optional_dependency_is_available_for_contract(
optional_dependency, unsupported_platforms
):
continue
try:
module = _import_contract_module(module_name, agents_module)
except Exception as error:
errors.append(f"Failed to import released module {module_name}: {error!r}")
continue
alias = getattr(module, alias_name, missing)
if alias is missing:
errors.append(f"Missing released public type alias {module_name}.{alias_name}")
continue
try:
current_definition = _type_alias_definition(alias)
except TypeError as error:
errors.append(
f"{module_name}.{alias_name} no longer has a supported released public type "
f"alias definition: {error}"
)
continue
if current_definition != entry["definition"]:
errors.append(
f"{module_name}.{alias_name} changed its released public type alias: "
f"expected {entry['definition']!r}, got {current_definition!r}"
)
return errors
def _submodule_export_contract(
module: object,
*,
@@ -1495,6 +1828,13 @@ def validate_released_api_contract(
errors.append(f"Invalid released optional dependency platform declarations: {error}")
unsupported_platforms = {}
errors.extend(
_validate_public_class_contract(
contract,
agents_module,
unsupported_platforms=unsupported_platforms,
)
)
errors.extend(
_validate_public_property_contract(
contract,
@@ -1502,6 +1842,13 @@ def validate_released_api_contract(
unsupported_platforms=unsupported_platforms,
)
)
errors.extend(
_validate_public_type_alias_contract(
contract,
agents_module,
unsupported_platforms=unsupported_platforms,
)
)
errors.extend(
_validate_public_typed_dict_contract(
contract,
+7 -1
View File
@@ -1,4 +1,9 @@
from .events import VoiceStreamEvent, VoiceStreamEventAudio, VoiceStreamEventLifecycle
from .events import (
VoiceStreamEvent,
VoiceStreamEventAudio,
VoiceStreamEventError,
VoiceStreamEventLifecycle,
)
from .exceptions import STTWebsocketConnectionError
from .input import AudioInput, StreamedAudioInput
from .model import (
@@ -41,6 +46,7 @@ __all__ = [
"OpenAISTTModel",
"OpenAITTSModel",
"VoiceStreamEventAudio",
"VoiceStreamEventError",
"VoiceStreamEventLifecycle",
"VoiceStreamEvent",
"VoicePipeline",
+359
View File
@@ -197,6 +197,168 @@
"canonical_name": "VercelSandboxClientOptions",
"module": "agents.extensions.sandbox",
"name": "VercelSandboxClientOptions"
},
{
"canonical_module": "agents.voice.input",
"canonical_name": "AudioInput",
"module": "agents.voice",
"name": "AudioInput"
},
{
"canonical_module": "agents.voice.input",
"canonical_name": "StreamedAudioInput",
"module": "agents.voice",
"name": "StreamedAudioInput"
},
{
"canonical_module": "agents.voice.model",
"canonical_name": "STTModel",
"module": "agents.voice",
"name": "STTModel"
},
{
"canonical_module": "agents.voice.model",
"canonical_name": "STTModelSettings",
"module": "agents.voice",
"name": "STTModelSettings"
},
{
"canonical_module": "agents.voice.model",
"canonical_name": "TTSCustomVoice",
"module": "agents.voice",
"name": "TTSCustomVoice"
},
{
"canonical_module": "agents.voice.model",
"canonical_name": "TTSModel",
"module": "agents.voice",
"name": "TTSModel"
},
{
"canonical_module": "agents.voice.model",
"canonical_name": "TTSModelSettings",
"module": "agents.voice",
"name": "TTSModelSettings"
},
{
"canonical_module": "agents.voice.model",
"canonical_name": "TTSVoice",
"module": "agents.voice",
"name": "TTSVoice"
},
{
"canonical_module": "agents.voice.model",
"canonical_name": "VoiceModelProvider",
"module": "agents.voice",
"name": "VoiceModelProvider"
},
{
"canonical_module": "agents.voice.result",
"canonical_name": "StreamedAudioResult",
"module": "agents.voice",
"name": "StreamedAudioResult"
},
{
"canonical_module": "agents.voice.workflow",
"canonical_name": "SingleAgentVoiceWorkflow",
"module": "agents.voice",
"name": "SingleAgentVoiceWorkflow"
},
{
"canonical_module": "agents.voice.models.openai_model_provider",
"canonical_name": "OpenAIVoiceModelProvider",
"module": "agents.voice",
"name": "OpenAIVoiceModelProvider"
},
{
"canonical_module": "agents.voice.models.openai_stt",
"canonical_name": "OpenAISTTModel",
"module": "agents.voice",
"name": "OpenAISTTModel"
},
{
"canonical_module": "agents.voice.models.openai_tts",
"canonical_name": "OpenAITTSModel",
"module": "agents.voice",
"name": "OpenAITTSModel"
},
{
"canonical_module": "agents.voice.events",
"canonical_name": "VoiceStreamEventAudio",
"module": "agents.voice",
"name": "VoiceStreamEventAudio"
},
{
"canonical_module": "agents.voice.events",
"canonical_name": "VoiceStreamEventLifecycle",
"module": "agents.voice",
"name": "VoiceStreamEventLifecycle"
},
{
"canonical_module": "agents.voice.events",
"canonical_name": "VoiceStreamEvent",
"module": "agents.voice",
"name": "VoiceStreamEvent"
},
{
"canonical_module": "agents.voice.events",
"canonical_name": "VoiceStreamEventError",
"module": "agents.voice",
"name": "VoiceStreamEventError"
},
{
"canonical_module": "agents.voice.pipeline",
"canonical_name": "VoicePipeline",
"module": "agents.voice",
"name": "VoicePipeline"
},
{
"canonical_module": "agents.voice.pipeline_config",
"canonical_name": "VoicePipelineConfig",
"module": "agents.voice",
"name": "VoicePipelineConfig"
},
{
"canonical_module": "agents.voice.utils",
"canonical_name": "get_sentence_based_splitter",
"module": "agents.voice",
"name": "get_sentence_based_splitter"
},
{
"canonical_module": "agents.voice.workflow",
"canonical_name": "VoiceWorkflowHelper",
"module": "agents.voice",
"name": "VoiceWorkflowHelper"
},
{
"canonical_module": "agents.voice.workflow",
"canonical_name": "VoiceWorkflowBase",
"module": "agents.voice",
"name": "VoiceWorkflowBase"
},
{
"canonical_module": "agents.voice.workflow",
"canonical_name": "SingleAgentWorkflowCallbacks",
"module": "agents.voice",
"name": "SingleAgentWorkflowCallbacks"
},
{
"canonical_module": "agents.voice.model",
"canonical_name": "StreamedTranscriptionSession",
"module": "agents.voice",
"name": "StreamedTranscriptionSession"
},
{
"canonical_module": "agents.voice.models.openai_stt",
"canonical_name": "OpenAISTTTranscriptionSession",
"module": "agents.voice",
"name": "OpenAISTTTranscriptionSession"
},
{
"canonical_module": "agents.voice.exceptions",
"canonical_name": "STTWebsocketConnectionError",
"module": "agents.voice",
"name": "STTWebsocketConnectionError"
}
],
"optional_dependencies": {
@@ -304,6 +466,86 @@
"optional_bindings": {},
"optional_exports": {}
},
"agents.voice": {
"optional_bindings": {
"AudioInput": "numpy",
"OpenAISTTModel": "numpy",
"OpenAISTTTranscriptionSession": "numpy",
"OpenAITTSModel": "numpy",
"OpenAIVoiceModelProvider": "numpy",
"STTModel": "numpy",
"STTModelSettings": "numpy",
"STTWebsocketConnectionError": "numpy",
"SingleAgentVoiceWorkflow": "numpy",
"SingleAgentWorkflowCallbacks": "numpy",
"StreamedAudioInput": "numpy",
"StreamedAudioResult": "numpy",
"StreamedTranscriptionSession": "numpy",
"TTSCustomVoice": "numpy",
"TTSModel": "numpy",
"TTSModelSettings": "numpy",
"TTSVoice": "numpy",
"VoiceModelProvider": "numpy",
"VoicePipeline": "numpy",
"VoicePipelineConfig": "numpy",
"VoiceStreamEvent": "numpy",
"VoiceStreamEventAudio": "numpy",
"VoiceStreamEventError": "numpy",
"VoiceStreamEventLifecycle": "numpy",
"VoiceWorkflowBase": "numpy",
"VoiceWorkflowHelper": "numpy",
"get_sentence_based_splitter": "numpy"
},
"optional_exports": {}
},
"agents.voice.events": {
"optional_bindings": {},
"optional_exports": {}
},
"agents.voice.exceptions": {
"optional_bindings": {},
"optional_exports": {}
},
"agents.voice.input": {
"optional_bindings": {},
"optional_exports": {}
},
"agents.voice.imports": {
"optional_bindings": {
"np": "numpy",
"npt": "numpy",
"websockets": "numpy"
},
"optional_exports": {}
},
"agents.voice.model": {
"optional_bindings": {},
"optional_exports": {}
},
"agents.voice.models.openai_model_provider": {
"optional_bindings": {},
"optional_exports": {}
},
"agents.voice.models.openai_stt": {
"optional_bindings": {},
"optional_exports": {}
},
"agents.voice.models.openai_tts": {
"optional_bindings": {},
"optional_exports": {}
},
"agents.voice.pipeline": {
"optional_bindings": {},
"optional_exports": {}
},
"agents.voice.pipeline_config": {
"optional_bindings": {},
"optional_exports": {}
},
"agents.voice.result": {
"optional_bindings": {},
"optional_exports": {}
},
"agents.voice.testing": {
"optional_bindings": {
"STTCall": "numpy",
@@ -320,8 +562,73 @@
"pcm16_samples": "numpy"
},
"optional_exports": {}
},
"agents.voice.utils": {
"optional_bindings": {},
"optional_exports": {}
},
"agents.voice.workflow": {
"optional_bindings": {},
"optional_exports": {}
}
},
"public_class_contracts": [
{
"abstract_members": [
"create_session",
"model_name",
"transcribe"
],
"class_name": "STTModel",
"module": "agents.voice.model"
},
{
"abstract_members": [
"close",
"transcribe_turns"
],
"class_name": "StreamedTranscriptionSession",
"module": "agents.voice.model"
},
{
"abstract_members": [
"model_name",
"run"
],
"class_name": "TTSModel",
"module": "agents.voice.model"
},
{
"abstract_members": [
"get_stt_model",
"get_tts_model"
],
"class_name": "VoiceModelProvider",
"module": "agents.voice.model"
},
{
"abstract": false,
"class_name": "OpenAISTTModel",
"module": "agents.voice.models.openai_stt"
},
{
"abstract": false,
"class_name": "OpenAISTTTranscriptionSession",
"module": "agents.voice.models.openai_stt"
},
{
"abstract": false,
"class_name": "OpenAITTSModel",
"module": "agents.voice.models.openai_tts"
},
{
"abstract_members": [
"run"
],
"class_name": "VoiceWorkflowBase",
"module": "agents.voice.workflow"
}
],
"public_properties": [
{
"class_name": "RunState",
@@ -390,6 +697,41 @@
"transcriptions"
]
},
{
"class_name": "STTModel",
"module": "agents.voice.model",
"names": [
"model_name"
]
},
{
"class_name": "TTSModel",
"module": "agents.voice.model",
"names": [
"model_name"
]
},
{
"class_name": "OpenAISTTModel",
"module": "agents.voice.models.openai_stt",
"names": [
"model_name"
]
},
{
"class_name": "OpenAITTSModel",
"module": "agents.voice.models.openai_tts",
"names": [
"model_name"
]
},
{
"class_name": "OpenAIVoiceModelProvider",
"module": "agents.voice.models.openai_model_provider",
"names": [
"agent_registration"
]
},
{
"class_name": "RunloopPlatformClient",
"module": "agents.extensions.sandbox",
@@ -425,6 +767,16 @@
]
}
],
"public_type_aliases": [
{
"module": "agents.voice.model",
"name": "TTSVoice"
},
{
"module": "agents.voice.events",
"name": "VoiceStreamEvent"
}
],
"public_typed_dicts": [
{
"class_name": "ModelStepSpec",
@@ -463,6 +815,13 @@
"playback_tracker",
"call_id"
]
},
{
"class_name": "TTSCustomVoice",
"module": "agents.voice",
"names": [
"id"
]
}
]
}
+544 -21
View File
@@ -1,5 +1,7 @@
import abc
import builtins
import importlib
import inspect
import json
import subprocess
import sys
@@ -10,7 +12,7 @@ from importlib.metadata import version
from inspect import Parameter, Signature
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from typing import Any, Literal, cast
import pytest
from pydantic import BaseModel, Field
@@ -25,7 +27,9 @@ from integration_tests._contract_support import (
_parameter_contract,
_public_class_member_contract,
_validate_parameter_contract,
_validate_public_class_contract,
_validate_public_property_contract,
_validate_public_type_alias_contract,
_validate_public_typed_dict_contract,
build_released_api_contract,
load_api_contract,
@@ -41,14 +45,18 @@ def _release_policy(
*,
dependency_installations: tuple[OptionalDependencyInstallation, ...] = (),
canonical_imports: tuple[dict[str, str], ...] = (),
public_class_contracts: tuple[dict[str, Any], ...] = (),
public_properties: tuple[dict[str, Any], ...] = (),
public_type_aliases: tuple[dict[str, str], ...] = (),
public_typed_dicts: tuple[dict[str, Any], ...] = (),
) -> SubmoduleExportPolicy:
return SubmoduleExportPolicy(
modules=modules,
dependency_installations=dependency_installations,
canonical_imports=canonical_imports,
public_class_contracts=public_class_contracts,
public_properties=public_properties,
public_type_aliases=public_type_aliases,
public_typed_dicts=public_typed_dicts,
)
@@ -79,6 +87,59 @@ def test_literal_default_contract_preserves_exact_builtin_type(
assert "changed its released positional parameter prefix" in errors[0]
def test_type_default_contract_preserves_identity() -> None:
assert _default_contract(int) == {
"kind": "type",
"identity": "builtins.int",
}
def released_callable(value: type = int) -> None:
_ = value
def changed_callable(value: type = float) -> None:
_ = value
errors = _validate_parameter_contract(
"Example",
_parameter_contract(released_callable),
_parameter_contract(changed_callable),
)
assert len(errors) == 1
assert "changed its released positional parameter prefix" in errors[0]
def test_optional_dependency_for_module_import_uses_canonical_bindings() -> None:
contract = {
"required_submodule_exports": {
"agents.voice": {
"names": ["VoicePipeline"],
"optional_bindings": {"VoicePipeline": "numpy"},
"optional_exports": {},
}
},
"canonical_imports": [
{
"canonical_module": "agents.voice.pipeline",
"canonical_name": "VoicePipeline",
"module": "agents.voice",
"name": "VoicePipeline",
}
],
}
assert (
contract_support._optional_dependency_for_module_import(contract, "agents.voice.pipeline")
== "numpy"
)
assert (
contract_support._optional_dependency_for_binding(
contract, "agents.voice.pipeline", "VoicePipeline"
)
== "numpy"
)
def test_released_api_contract_fixture_matches_installed_version() -> None:
contract = load_api_contract(CONTRACT)
assert contract["baseline"] == f"v{version('openai-agents')}"
@@ -255,12 +316,19 @@ def test_public_class_member_contract_tracks_direct_callable_bindings() -> None:
def test_curated_public_property_contract_detects_removed_or_changed_properties() -> None:
class ReleasedBase:
class ReleasedBase(metaclass=abc.ABCMeta):
@abc.abstractmethod
def base_requirement(self) -> None:
pass
@property
def retained(self) -> str:
return "value"
class Released(ReleasedBase):
def base_requirement(self) -> None:
pass
@property
def retained(self) -> str:
return "value"
@@ -293,21 +361,89 @@ def test_curated_public_property_contract_detects_removed_or_changed_properties(
"agents.ReleasedBase.removed removed or changed a released public property"
]
Changed = type(
"Changed",
(ReleasedBase,),
{"retained": lambda self: "value"},
)
class Changed(ReleasedBase):
def base_requirement(self) -> None:
pass
@abc.abstractmethod
def new_requirement(self) -> None:
pass
with pytest.raises(TypeError):
cast(type[Any], Changed)()
agents_module.Released = Changed
assert _validate_public_property_contract(contract, agents_module) == [
"agents.ReleasedBase.removed removed or changed a released public property",
"agents.Released.retained removed or changed a released public property",
"agents.Released.concrete_only removed or changed a released public property",
]
def test_curated_public_class_contract_detects_abstract_member_and_state_changes() -> None:
class ReleasedBase(metaclass=abc.ABCMeta):
@abc.abstractmethod
def base_requirement(self) -> None:
pass
class Released(ReleasedBase):
def base_requirement(self) -> None:
pass
contract: dict[str, Any] = {
"public_class_contracts": [
{
"abstract_members": ["base_requirement"],
"class_name": "ReleasedBase",
"module": "agents",
},
{
"abstract": False,
"class_name": "Released",
"module": "agents",
},
]
}
agents_module = SimpleNamespace(__all__=[], ReleasedBase=ReleasedBase, Released=Released)
assert _validate_public_class_contract(contract, agents_module) == []
class ChangedBase(ReleasedBase):
@abc.abstractmethod
def new_requirement(self) -> None:
pass
class Changed(ChangedBase):
def base_requirement(self) -> None:
pass
def new_requirement(self) -> None:
pass
class ExistingExternalSubclass(ChangedBase):
def base_requirement(self) -> None:
pass
assert not inspect.isabstract(Changed)
with pytest.raises(TypeError):
cast(type[Any], ExistingExternalSubclass)()
agents_module.ReleasedBase = ChangedBase
agents_module.Released = Changed
assert _validate_public_class_contract(contract, agents_module) == [
"agents.ReleasedBase changed its released public abstract members: expected "
"['base_requirement'], got ['base_requirement', 'new_requirement']"
]
agents_module.Released = ExistingExternalSubclass
assert _validate_public_class_contract(contract, agents_module) == [
"agents.ReleasedBase changed its released public abstract members: expected "
"['base_requirement'], got ['base_requirement', 'new_requirement']",
"agents.Released changed its released public class state: expected concrete, got abstract",
]
def test_curated_public_property_contract_supports_factory_return_surfaces(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -341,6 +477,125 @@ def test_curated_public_property_contract_supports_factory_return_surfaces(
]
def test_curated_public_type_alias_contract_records_and_validates_members(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class EventA:
pass
class EventB:
pass
EventA.__module__ = "agents.aliases"
EventA.__qualname__ = "EventA"
EventB.__module__ = "agents.aliases"
EventB.__qualname__ = "EventB"
agents_module = SimpleNamespace(__all__=[])
aliases_module = SimpleNamespace(
__all__=[],
PublicAlias=Literal["b", "a"] | EventB | EventA,
)
modules = {
"agents": agents_module,
"agents.aliases": aliases_module,
}
monkeypatch.setattr(
contract_support,
"_import_contract_module",
lambda module_name, _agents_module: modules[module_name],
)
contract: dict[str, Any] = {
"baseline": "v0.19.4",
"baseline_commit": "a" * 40,
"required_top_level_exports": [],
"public_modules": ["agents"],
"canonical_imports": [],
"public_class_contracts": [],
"public_properties": [],
"public_type_aliases": [],
"public_typed_dicts": [],
"callables": {},
}
updated = build_released_api_contract(
contract,
baseline="v0.20.0",
baseline_commit="b" * 40,
agents_module=agents_module,
release_policy=_release_policy(
{"agents.aliases": {"optional_bindings": {}, "optional_exports": {}}},
public_type_aliases=(
{
"module": "agents.aliases",
"name": "PublicAlias",
},
),
),
)
assert updated["public_type_aliases"] == [
{
"definition": {
"kind": "union",
"members": [
{
"kind": "literal",
"values": [
{
"kind": "literal",
"type": "builtins.str",
"value": "a",
},
{
"kind": "literal",
"type": "builtins.str",
"value": "b",
},
],
},
{"identity": "agents.aliases.EventA", "kind": "type"},
{"identity": "agents.aliases.EventB", "kind": "type"},
],
},
"module": "agents.aliases",
"name": "PublicAlias",
}
]
aliases_module.PublicAlias = Literal["a", "b"] | EventA | EventB
assert _validate_public_type_alias_contract(updated, agents_module) == []
aliases_module.PublicAlias = Literal["a"] | EventA
errors = _validate_public_type_alias_contract(updated, agents_module)
assert len(errors) == 1
assert errors[0].startswith("agents.aliases.PublicAlias changed its released public type alias")
aliases_module.PublicAlias = list[str]
with pytest.raises(
ValueError,
match=(
r"agents\.aliases\.PublicAlias no longer has a supported released public type alias "
r"definition: unsupported"
),
):
build_released_api_contract(
updated,
baseline="v0.20.1",
baseline_commit="c" * 40,
agents_module=agents_module,
release_policy=_release_policy(
{"agents.aliases": {"optional_bindings": {}, "optional_exports": {}}},
public_type_aliases=(
{
"module": "agents.aliases",
"name": "PublicAlias",
},
),
),
)
def test_curated_public_typed_dict_contract_detects_field_shape_drift(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -1685,6 +1940,13 @@ def test_release_contract_policy_promotes_curated_public_state_surfaces(
"name": "NewPublic",
},
),
public_class_contracts=(
{
"abstract": False,
"class_name": "NewPublic",
"module": "agents.submodule",
},
),
public_properties=(
{
"class_name": "NewPublic",
@@ -1727,6 +1989,13 @@ def test_release_contract_policy_promotes_curated_public_state_surfaces(
"names": ["calls"],
},
]
assert updated["public_class_contracts"] == [
{
"abstract": False,
"class_name": "NewPublic",
"module": "agents.submodule",
}
]
assert updated["public_typed_dicts"] == [
{
"class_name": "PublicState",
@@ -2428,9 +2697,14 @@ def test_load_submodule_export_policy_collects_artifact_installations(tmp_path:
'{"ConditionalExport": "export_dependency"}}}, "optional_dependencies": '
'{"binding_dependency": {"requirement": "binding-package>=1"}, '
'"export_dependency": {"extra": "export-extra"}}, "public_properties": '
'[{"class_name": "ConditionalExport", "module": "agents.submodule", '
'"names": ["status"]}, {"factory_name": "create_client", '
'"module": "agents.submodule", "names": ["calls"]}], "public_typed_dicts": '
'[{"class_name": "ConditionalExport", '
'"module": "agents.submodule", "names": ["status"]}, '
'{"factory_name": "create_client", '
'"module": "agents.submodule", "names": ["calls"]}], "public_class_contracts": '
'[{"abstract": false, "class_name": "ConditionalExport", '
'"module": "agents.submodule"}], "public_type_aliases": '
'[{"module": "agents.submodule", "name": "PublicAlias"}], '
'"public_typed_dicts": '
'[{"class_name": "ClientState", "module": "agents.submodule", '
'"names": ["status"]}]}',
encoding="utf-8",
@@ -2466,6 +2740,13 @@ def test_load_submodule_export_policy_collects_artifact_installations(tmp_path:
"name": "ConditionalExport",
},
)
assert policy.public_class_contracts == (
{
"abstract": False,
"class_name": "ConditionalExport",
"module": "agents.submodule",
},
)
assert tuple(
entry
for entry in policy.public_properties
@@ -2488,6 +2769,12 @@ def test_load_submodule_export_policy_collects_artifact_installations(tmp_path:
"names": ["calls"],
},
)
assert policy.public_type_aliases == (
{
"module": "agents.submodule",
"name": "PublicAlias",
},
)
assert policy.public_typed_dicts == (
{
"class_name": "ClientState",
@@ -2497,6 +2784,23 @@ def test_load_submodule_export_policy_collects_artifact_installations(tmp_path:
)
def test_load_submodule_export_policy_rejects_invalid_class_abstract_state(
tmp_path: Path,
) -> None:
policy_path = tmp_path / "policy.json"
policy_path.write_text(
'{"modules": {}, "optional_dependencies": {}, "public_class_contracts": '
'[{"abstract": "false", "class_name": "Released", "module": "agents"}]}',
encoding="utf-8",
)
with pytest.raises(
ValueError,
match="public_class_contracts abstract must be a boolean",
):
load_submodule_export_policy(policy_path)
def test_load_submodule_export_policy_collects_unsupported_platforms(tmp_path: Path) -> None:
policy_path = tmp_path / "policy.json"
policy_path.write_text(
@@ -2544,6 +2848,10 @@ def test_repository_release_policy_declares_v020_contract_surfaces() -> None:
"agents.testing.model",
"agents.testing.sandbox",
"agents.realtime.testing",
"agents.voice.model",
"agents.voice.models.openai_model_provider",
"agents.voice.models.openai_stt",
"agents.voice.models.openai_tts",
"agents.voice.testing",
}
) == (
@@ -2580,23 +2888,56 @@ def test_repository_release_policy_declares_v020_contract_surfaces() -> None:
)
def test_repository_release_policy_declares_public_testing_modules() -> None:
def test_repository_release_policy_declares_public_optional_modules() -> None:
policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json"))
expected_modules = {
documented_voice_modules = {
"agents.voice.events",
"agents.voice.exceptions",
"agents.voice.input",
"agents.voice.imports",
"agents.voice.model",
"agents.voice.models.openai_model_provider",
"agents.voice.models.openai_stt",
"agents.voice.models.openai_tts",
"agents.voice.pipeline",
"agents.voice.pipeline_config",
"agents.voice.result",
"agents.voice.testing",
"agents.voice.utils",
"agents.voice.workflow",
}
expected_modules = documented_voice_modules | {
"agents.realtime.testing",
"agents.testing",
"agents.testing.model",
"agents.testing.sandbox",
"agents.voice.testing",
"agents.voice",
}
assert expected_modules <= policy.modules.keys()
assert policy.modules["agents.voice.testing"] == {
"optional_bindings": {
export: "numpy" for export in importlib.import_module("agents.voice.testing").__all__
},
"optional_exports": {},
documented_directive_modules = {
line.removeprefix("::: ")
for path in (CONTRACT.parents[2] / "docs" / "ref" / "voice").rglob("*.md")
for line in path.read_text(encoding="utf-8").splitlines()
if line.startswith("::: agents.voice")
}
assert documented_directive_modules == documented_voice_modules
assert expected_modules <= policy.modules.keys()
for module_name in documented_voice_modules - {
"agents.voice.imports",
"agents.voice.testing",
}:
assert policy.modules[module_name] == {
"optional_bindings": {},
"optional_exports": {},
}
for module_name in ("agents.voice", "agents.voice.imports", "agents.voice.testing"):
assert policy.modules[module_name] == {
"optional_bindings": {
export: "numpy" for export in importlib.import_module(module_name).__all__
},
"optional_exports": {},
}
assert (
next(
installation
@@ -2607,7 +2948,7 @@ def test_repository_release_policy_declares_public_testing_modules() -> None:
)
def test_repository_release_policy_declares_public_testing_state_surfaces() -> None:
def test_repository_release_policy_declares_public_state_surfaces() -> None:
policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json"))
expected_modules = {
"agents.realtime.testing",
@@ -2659,6 +3000,133 @@ def test_repository_release_policy_declares_public_testing_state_surfaces() -> N
"names": ["transcriptions"],
},
)
assert tuple(
entry
for entry in policy.public_properties
if entry["module"]
in {
"agents.voice.model",
"agents.voice.models.openai_model_provider",
"agents.voice.models.openai_stt",
"agents.voice.models.openai_tts",
}
) == (
{
"class_name": "STTModel",
"module": "agents.voice.model",
"names": ["model_name"],
},
{
"class_name": "TTSModel",
"module": "agents.voice.model",
"names": ["model_name"],
},
{
"class_name": "OpenAISTTModel",
"module": "agents.voice.models.openai_stt",
"names": ["model_name"],
},
{
"class_name": "OpenAITTSModel",
"module": "agents.voice.models.openai_tts",
"names": ["model_name"],
},
{
"class_name": "OpenAIVoiceModelProvider",
"module": "agents.voice.models.openai_model_provider",
"names": ["agent_registration"],
},
)
assert policy.public_class_contracts == (
{
"class_name": "STTModel",
"module": "agents.voice.model",
"abstract_members": ["create_session", "model_name", "transcribe"],
},
{
"class_name": "StreamedTranscriptionSession",
"module": "agents.voice.model",
"abstract_members": ["close", "transcribe_turns"],
},
{
"class_name": "TTSModel",
"module": "agents.voice.model",
"abstract_members": ["model_name", "run"],
},
{
"class_name": "VoiceModelProvider",
"module": "agents.voice.model",
"abstract_members": ["get_stt_model", "get_tts_model"],
},
{
"abstract": False,
"class_name": "OpenAISTTModel",
"module": "agents.voice.models.openai_stt",
},
{
"abstract": False,
"class_name": "OpenAISTTTranscriptionSession",
"module": "agents.voice.models.openai_stt",
},
{
"abstract": False,
"class_name": "OpenAITTSModel",
"module": "agents.voice.models.openai_tts",
},
{
"class_name": "VoiceWorkflowBase",
"module": "agents.voice.workflow",
"abstract_members": ["run"],
},
)
assert policy.public_type_aliases == (
{
"module": "agents.voice.model",
"name": "TTSVoice",
},
{
"module": "agents.voice.events",
"name": "VoiceStreamEvent",
},
)
type_aliases: dict[tuple[str, str], dict[str, Any]] = {
(cast(str, entry["module"]), cast(str, entry["name"])): cast(
dict[str, Any], entry["definition"]
)
for entry in contract_support._public_type_alias_contract(policy.public_type_aliases, None)
}
tts_voice = type_aliases[("agents.voice.model", "TTSVoice")]
assert tts_voice["kind"] == "union"
assert [
value["value"]
for member in tts_voice["members"]
if member["kind"] == "literal"
for value in member["values"]
] == [
"alloy",
"ash",
"ballad",
"cedar",
"coral",
"echo",
"fable",
"marin",
"nova",
"onyx",
"sage",
"shimmer",
"verse",
]
assert {member["identity"] for member in tts_voice["members"] if member["kind"] == "type"} == {
"agents.voice.model.TTSCustomVoice"
}
voice_stream_event = type_aliases[("agents.voice.events", "VoiceStreamEvent")]
assert voice_stream_event["kind"] == "union"
assert {member["identity"] for member in voice_stream_event["members"]} == {
"agents.voice.events.VoiceStreamEventAudio",
"agents.voice.events.VoiceStreamEventError",
"agents.voice.events.VoiceStreamEventLifecycle",
}
assert policy.public_typed_dicts == (
{
"class_name": "ModelStepSpec",
@@ -2692,6 +3160,11 @@ def test_repository_release_policy_declares_public_testing_state_surfaces() -> N
"call_id",
],
},
{
"class_name": "TTSCustomVoice",
"module": "agents.voice",
"names": ["id"],
},
)
for module_name in expected_modules:
module = importlib.import_module(module_name)
@@ -2722,6 +3195,56 @@ def test_repository_release_policy_declares_public_testing_state_surfaces() -> N
canonical_module = importlib.import_module(canonical_module_name)
assert getattr(module, name) is getattr(canonical_module, canonical_name)
voice_canonical_modules = {
"AudioInput": "agents.voice.input",
"StreamedAudioInput": "agents.voice.input",
"STTModel": "agents.voice.model",
"STTModelSettings": "agents.voice.model",
"TTSCustomVoice": "agents.voice.model",
"TTSModel": "agents.voice.model",
"TTSModelSettings": "agents.voice.model",
"TTSVoice": "agents.voice.model",
"VoiceModelProvider": "agents.voice.model",
"StreamedAudioResult": "agents.voice.result",
"SingleAgentVoiceWorkflow": "agents.voice.workflow",
"OpenAIVoiceModelProvider": "agents.voice.models.openai_model_provider",
"OpenAISTTModel": "agents.voice.models.openai_stt",
"OpenAITTSModel": "agents.voice.models.openai_tts",
"VoiceStreamEventAudio": "agents.voice.events",
"VoiceStreamEventError": "agents.voice.events",
"VoiceStreamEventLifecycle": "agents.voice.events",
"VoiceStreamEvent": "agents.voice.events",
"VoicePipeline": "agents.voice.pipeline",
"VoicePipelineConfig": "agents.voice.pipeline_config",
"get_sentence_based_splitter": "agents.voice.utils",
"VoiceWorkflowHelper": "agents.voice.workflow",
"VoiceWorkflowBase": "agents.voice.workflow",
"SingleAgentWorkflowCallbacks": "agents.voice.workflow",
"StreamedTranscriptionSession": "agents.voice.model",
"OpenAISTTTranscriptionSession": "agents.voice.models.openai_stt",
"STTWebsocketConnectionError": "agents.voice.exceptions",
}
expected_voice_canonical_imports = {
("agents.voice", name, canonical_module_name, name)
for name, canonical_module_name in voice_canonical_modules.items()
}
actual_voice_canonical_imports = {
(
entry["module"],
entry["name"],
entry["canonical_module"],
entry["canonical_name"],
)
for entry in policy.canonical_imports
if entry["module"] == "agents.voice"
}
assert actual_voice_canonical_imports == expected_voice_canonical_imports
for module_name, name, canonical_module_name, canonical_name in actual_voice_canonical_imports:
module = importlib.import_module(module_name)
canonical_module = importlib.import_module(canonical_module_name)
assert getattr(module, name) is getattr(canonical_module, canonical_name)
def test_voice_testing_start_sentinel_has_stable_contract_identity() -> None:
from agents.voice.testing import _START_NOT_CONFIGURED