Skip to content

osi

osi

OSI Semantic Layer integration for crew-dcs.

Exports Domo Data Models and Dataset Views (DSVs) into the Open Semantic Index (OSI) format — a vendor-neutral semantic layer schema that can be consumed by AI agents and BI tooling.

Phase 1.0 scope: - DataModelTemplate → list[OsiSemanticModel] via DataModelConverter - ViewDefinition → list[OsiSemanticModel] via DsvConverter - YAML / JSON file export via export_to_file() - Live DomoSemanticModel → list[OsiSemanticModel] via from_domo_semantic_model()

Phase 1.5 scope (shipped): - Beast Mode aggregated columns → OsiMetric via Beast Mode route (DataModelConverter.convert(..., beastmodes=..., domo_instance=...), wired end-to-end through from_domo_semantic_model()) - AI_Readiness_Column subType == "METRIC" → OsiMetric (primary path, DataModelConverter and DsvConverter both) - DOMO_BEAST_MODE() reference resolution — exported metric expressions are self-contained; an unresolved reference raises UnresolvedBeastModeReferenceError rather than shipping silently

Usage

from crew_dcs.integrations.osi import from_domo_semantic_model

osi_models = await from_domo_semantic_model(domo_semantic_model) for m in osi_models: print(m.to_yaml)

DataModelConverter

Bases: OsiConverter

Converts a Domo DataModelTemplate to a list of OsiSemanticModels.

One OsiSemanticModel is produced per DataModelObject (source table). Join key columns become OsiEntity instances; all other columns are classified as dimensions or metrics based on AI_Readiness subType, falling back to data-type inference.

Cross-model relationships (JOINs) are attached to the left-side model.

Metrics (Phase 1.5) come from two routes, per the PRD's classification rules — both can contribute to the same model, they are not mutually exclusive: 1. Primary: columns with AI_Readiness_Column.subType == 'METRIC'. 2. Fallback: Beast Modes with BeastModeTemplate.aggregated is True, passed in via the beastmodes param (keyed by datasource_id). A Beast Mode whose name collides with an AI-Readiness-derived metric name is skipped — the AI Readiness path wins.

Usage

converter = DataModelConverter() models = converter.convert(template, datasets) converter.export_to_file(models, "output.yaml")

With Beast Mode metrics:

models = converter.convert( template, datasets, beastmodes={"ds-orders": [aggregated_template, ...]}, domo_instance="mycompany", )

convert

convert(
    source,
    datasets: dict,
    beastmodes: (
        dict[str, list[BeastModeTemplate]] | None
    ) = None,
    domo_instance: str | None = None,
    **options
) -> list[OsiSemanticModel]

Convert a DataModelTemplate to OSI semantic models.

Parameters:

Name Type Description Default
source

DataModelTemplate with objects and relationships

required
datasets dict

dict mapping datasource_id -> DomoDataset (or None)

required
beastmodes dict[str, list[BeastModeTemplate]] | None

dict mapping datasource_id -> list[BeastModeTemplate] (Phase 1.5). Only templates with aggregated is True are converted to metrics; refs must already be resolved via BeastModeTemplate.resolve_domo_beast_mode_refs() or conversion raises UnresolvedBeastModeReferenceError.

None
domo_instance str | None

Domo instance hostname, recorded on Beast-Mode- derived metrics' custom_extensions block. Optional — omitted from output when not provided.

None
**options

Unused beyond the params above

{}

Returns:

Type Description
list[OsiSemanticModel]

list[OsiSemanticModel], one per DataModelObject

Source code in src/crew_dcs/integrations/osi/data_model_converter.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def convert(  # noqa: C901
    self,
    source,
    datasets: dict,
    beastmodes: dict[str, list[BeastModeTemplate]] | None = None,
    domo_instance: str | None = None,
    **options,
) -> list[OsiSemanticModel]:
    """Convert a DataModelTemplate to OSI semantic models.

    Args:
        source: DataModelTemplate with objects and relationships
        datasets: dict mapping datasource_id -> DomoDataset (or None)
        beastmodes: dict mapping datasource_id -> list[BeastModeTemplate]
            (Phase 1.5). Only templates with ``aggregated is True`` are
            converted to metrics; refs must already be resolved via
            ``BeastModeTemplate.resolve_domo_beast_mode_refs()`` or
            conversion raises ``UnresolvedBeastModeReferenceError``.
        domo_instance: Domo instance hostname, recorded on Beast-Mode-
            derived metrics' ``custom_extensions`` block. Optional —
            omitted from output when not provided.
        **options: Unused beyond the params above

    Returns:
        list[OsiSemanticModel], one per DataModelObject
    """
    beastmodes = beastmodes or {}
    models: dict[str, OsiSemanticModel] = {}

    # Collect all join keys per object alias
    all_join_keys: dict[str, set[str]] = {}
    for rel in source.relationships:
        all_join_keys.setdefault(rel.left, set()).update(rel.left_keys or [])
        all_join_keys.setdefault(rel.right, set()).update(rel.right_keys or [])

    # Build one OsiSemanticModel per DataModelObject
    for alias, obj in source.objects.items():
        dataset = datasets.get(obj.datasource_id)
        ar_map = self._get_ai_readiness_map(dataset)
        join_keys = all_join_keys.get(alias, set())

        entities = []
        dimensions = []
        metrics = []

        for col in obj.columns or []:
            col_name = col.name
            col_type = col.type if hasattr(col, "type") else ""
            classification = self._classify_column(
                col_name, col_type, join_keys, ar_map
            )

            if classification == "entity":
                # Determine primary vs foreign: primary if this alias appears
                # on the left side of any relationship using this key
                is_primary = any(
                    rel.left == alias and col_name in (rel.left_keys or [])
                    for rel in source.relationships
                )
                entities.append(self._build_entity(col_name, is_primary=is_primary))

            elif classification in ("dimension", "time_dimension"):
                dimensions.append(
                    self._build_dimension(col_name, col_type, ar_map.get(col_name))
                )
            elif classification == "metric":
                metrics.append(
                    self._build_metric_from_ai_readiness(
                        col_name, col_type, ar_map.get(col_name)
                    )
                )

        # Beast Mode fallback (Phase 1.5): aggregated Beast Modes linked
        # to this object's dataset. A name collision with an
        # AI-Readiness-derived metric defers to the primary path.
        existing_metric_names = {m.name for m in metrics}
        for template in beastmodes.get(obj.datasource_id, []):
            if not template.aggregated or template.name in existing_metric_names:
                continue
            metrics.append(
                self._build_metric_from_beastmode(
                    template, obj.datasource_id, domo_instance
                )
            )
            existing_metric_names.add(template.name)

        name = (dataset.name if dataset else None) or alias
        description = ""
        if dataset and dataset.AI_Readiness:
            description = dataset.AI_Readiness.unit_of_analysis or ""

        domo_ext: dict = {"dataset_id": obj.datasource_id}
        if dataset and dataset.AI_Readiness and dataset.AI_Readiness.columns:
            domo_ext["agent_enabled"] = any(
                c.agentEnabled for c in dataset.AI_Readiness.columns
            )

        models[alias] = OsiSemanticModel(
            name=name,
            source_dataset_id=obj.datasource_id,
            description=description,
            entities=entities,
            dimensions=dimensions,
            metrics=metrics,
            domo_extensions=domo_ext,
        )

    # Attach cross-model relationships to the left-side model
    for rel in source.relationships:
        for left_key, right_key in zip(rel.left_keys or [], rel.right_keys or []):  # noqa: B905
            left_model = models.get(rel.left)
            right_model = models.get(rel.right)
            relationship = OsiRelationship(
                from_model=left_model.name if left_model else rel.left,
                to_model=right_model.name if right_model else rel.right,
                from_field=left_key,
                to_field=right_key,
                join_type=rel.join_type or "INNER",
                cardinality=rel.cardinality,
            )
            if left_model is not None:
                left_model.relationships.append(relationship)

    return list(models.values())

DsvConverter

Bases: OsiConverter

Converts a Domo ViewDefinition (DSV) to a list of OsiSemanticModels.

One OsiSemanticModel is produced per participating dataset: the base (FROM) dataset plus each JOIN target. Cardinality is always None for DSV joins because the ViewJoin schema does not expose it.

Usage

converter = DsvConverter() models = converter.convert(view_def, datasets) converter.export_to_file(models, "output.yaml")

convert

convert(
    source, datasets: dict, **options
) -> list[OsiSemanticModel]

Convert a ViewDefinition to OSI semantic models.

Parameters:

Name Type Description Default
source

ViewDefinition with from_item and joins

required
datasets dict

dict mapping datasource_id -> DomoDataset (or None)

required
**options

Unused in Phase 1.0

{}

Returns:

Type Description
list[OsiSemanticModel]

list[OsiSemanticModel], one per participating dataset

Source code in src/crew_dcs/integrations/osi/dsv_converter.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
def convert(self, source, datasets: dict, **options) -> list[OsiSemanticModel]:  # noqa: C901
    """Convert a ViewDefinition to OSI semantic models.

    Args:
        source: ViewDefinition with from_item and joins
        datasets: dict mapping datasource_id -> DomoDataset (or None)
        **options: Unused in Phase 1.0

    Returns:
        list[OsiSemanticModel], one per participating dataset
    """
    models: dict[str, OsiSemanticModel] = {}
    base_ds_id = source.from_item.dataset_id

    # Collect join key columns per dataset_id, tracking which side they're on.
    # Base-table left-side keys are "primary"; joined-table right-side keys are "foreign".
    primary_keys: dict[str, set[str]] = {}  # dataset_id → primary (left) join cols
    foreign_keys: dict[str, set[str]] = {}  # dataset_id → foreign (right) join cols

    for join in source.joins or []:
        if join.left_column:
            primary_keys.setdefault(base_ds_id, set()).add(join.left_column)
        if join.right_column and join.dataset_id:
            foreign_keys.setdefault(join.dataset_id, set()).add(join.right_column)

    def _build_model(dataset_id: str, alias: str | None = None) -> OsiSemanticModel:
        dataset = datasets.get(dataset_id)
        ar_map = self._get_ai_readiness_map(dataset)
        join_keys = primary_keys.get(dataset_id, set()) | foreign_keys.get(
            dataset_id, set()
        )
        entities = []
        dimensions = []
        metrics = []

        schema_columns = None
        if dataset and dataset.Schema and dataset.Schema.columns:
            schema_columns = dataset.Schema.columns

        if schema_columns:
            for col in schema_columns:
                col_name = col.name if hasattr(col, "name") else str(col)
                col_type = col.type if hasattr(col, "type") else ""
                classification = self._classify_column(
                    col_name, col_type, join_keys, ar_map
                )

                if classification == "entity":
                    is_primary = col_name in primary_keys.get(dataset_id, set())
                    entities.append(
                        self._build_entity(col_name, is_primary=is_primary)
                    )
                elif classification in ("dimension", "time_dimension"):
                    dimensions.append(
                        self._build_dimension(
                            col_name, col_type, ar_map.get(col_name)
                        )
                    )
                elif classification == "metric":
                    metrics.append(
                        self._build_metric_from_ai_readiness(
                            col_name, col_type, ar_map.get(col_name)
                        )
                    )
        elif join_keys:
            # No schema available — at minimum expose the join keys as entities
            for jk in sorted(primary_keys.get(dataset_id, set())):
                entities.append(self._build_entity(jk, is_primary=True))
            for jk in sorted(foreign_keys.get(dataset_id, set())):
                entities.append(self._build_entity(jk, is_primary=False))

        name = (dataset.name if dataset else None) or alias or dataset_id
        description = ""
        if dataset and dataset.AI_Readiness:
            description = dataset.AI_Readiness.unit_of_analysis or ""

        return OsiSemanticModel(
            name=name,
            source_dataset_id=dataset_id,
            description=description,
            entities=entities,
            dimensions=dimensions,
            metrics=metrics,
            domo_extensions={"dataset_id": dataset_id},
        )

    # Build base model
    models[base_ds_id] = _build_model(base_ds_id, source.from_item.alias)

    # Build a model for each join target and attach relationship to base
    for join in source.joins or []:
        joined_ds_id = join.dataset_id
        if joined_ds_id not in models:
            models[joined_ds_id] = _build_model(joined_ds_id)

        relationship = OsiRelationship(
            from_model=models[base_ds_id].name,
            to_model=models[joined_ds_id].name,
            from_field=join.left_column or "",
            to_field=join.right_column or "",
            join_type=join.join_type or "LEFT",
            cardinality=None,  # DSV schema does not expose cardinality
        )
        models[base_ds_id].relationships.append(relationship)

    return list(models.values())

OsiDimension dataclass

OsiDimension(
    name: str,
    type: str,
    description: str = "",
    synonyms: list[str] = list(),
    domo_extensions: dict = dict(),
)

A dimension (non-aggregatable field) in an OSI semantic model.

Attributes:

Name Type Description
name str

Column name

type str

"categorical" or "time"

description str

Human-readable description (from AI Readiness if available)

synonyms list[str]

Alternative names (from AI Readiness if available)

domo_extensions dict

DOMO-specific metadata

OsiEntity dataclass

OsiEntity(
    name: str, type: str, domo_extensions: dict = dict()
)

An entity (join key column) in an OSI semantic model.

Attributes:

Name Type Description
name str

Column name

type str

"primary" (left/source side) or "foreign" (right/target side)

domo_extensions dict

DOMO-specific metadata to include in custom_extensions

OsiMermaidConverter dataclass

OsiMermaidConverter()

Convert a list of OsiSemanticModel objects to a MermaidERDiagram.

Each OsiSemanticModel becomes one entity. Entities, dimensions, and metrics become typed columns. Relationships become ERD relationship lines with cardinality and join-field labels.

Example

from crew_dcs.integrations.osi import OsiMermaidConverter diagram = OsiMermaidConverter().convert(osi_models, title="DomoStats") print(diagram.export_to_markdown())

convert

convert(
    models: list[OsiSemanticModel],
    title: str | None = None,
    simple: bool = False,
) -> MermaidERDiagram

Convert OSI models to a Mermaid ERD.

Parameters:

Name Type Description Default
models list[OsiSemanticModel]

OSI semantic models to render.

required
title str | None

Optional diagram title.

None
simple bool

When True, render only PK/FK entity columns using the standard string type — no dimension/metric columns, no comments. Produces a minimal diagram that renders reliably in all Mermaid environments (VSCode, GitHub, etc.). When False (default), render all columns with rich type labels and inline description comments.

False
Source code in src/crew_dcs/integrations/osi/mermaid_converter.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def convert(
    self,
    models: list[OsiSemanticModel],
    title: str | None = None,
    simple: bool = False,
) -> MermaidERDiagram:
    """Convert OSI models to a Mermaid ERD.

    Args:
        models: OSI semantic models to render.
        title: Optional diagram title.
        simple: When True, render only PK/FK entity columns using the
            standard ``string`` type — no dimension/metric columns, no
            comments.  Produces a minimal diagram that renders reliably
            in all Mermaid environments (VSCode, GitHub, etc.).
            When False (default), render all columns with rich type
            labels and inline description comments.
    """
    diagram = MermaidERDiagram(title=title)
    name_to_entity: dict[str, MermaidEREntity] = {}

    # Pass 1 — build one entity per OSI model
    for model in models:
        entity = MermaidEREntity(id=model.source_dataset_id, name=model.name)

        for ent in model.entities:
            entity.add_column(
                MermaidERColumn(
                    name=_col_name(ent.name),
                    col_type="string",
                    key="PK" if ent.type == "primary" else "FK",
                )
            )

        if not simple:
            for dim in model.dimensions:
                entity.add_column(
                    MermaidERColumn(
                        name=_col_name(dim.name),
                        col_type="temporal"
                        if dim.type == "time"
                        else "categorical",
                        comment=_dim_comment(dim),
                    )
                )

            for metric in model.metrics:
                entity.add_column(
                    MermaidERColumn(
                        name=_col_name(metric.name),
                        col_type="metric",
                        comment=_metric_comment(metric),
                    )
                )

        diagram.add_entity(entity)
        name_to_entity[model.name] = entity

    # Pass 2 — relationships (deduplicated by entity pair via add_relationship)
    for model in models:
        for rel in model.relationships:
            from_ent = name_to_entity.get(rel.from_model)
            to_ent = name_to_entity.get(rel.to_model)
            if from_ent is None or to_ent is None:
                continue

            cardinality = _CARDINALITY_MAP.get(
                (rel.cardinality or "").lower(),
                CARDINALITY_ONE_TO_ZERO_OR_MANY,
            )
            identifying = rel.join_type.upper() in _IDENTIFYING_JOINS
            label = f"{rel.from_field} -> {rel.to_field}"

            diagram.add_relationship(
                MermaidERRelationship(
                    from_entity=from_ent,
                    to_entity=to_ent,
                    cardinality=cardinality,
                    label=label,
                    identifying=identifying,
                )
            )

    return diagram

OsiMetric dataclass

OsiMetric(
    name: str,
    expression: str,
    datatype: str = "UNKNOWN",
    description: str = "",
    domo_extensions: dict = dict(),
)

A metric (aggregatable field) in an OSI semantic model.

Field names and shape follow the OSI spec's metrics[] object (core-spec/spec.yaml: name, expression.dialects[], description, datatype, custom_extensions). Domo-specific derived data — the mapped aggregation semantic and the raw Beast Mode functions — is NOT a spec field (there is no aggregation key in OSI); it lives in domo_extensions instead, alongside the rest of the vendor metadata.

Attributes:

Name Type Description
name str

Metric name

expression str

SQL/Beast Mode expression (resolved — self-contained, never a dangling DOMO_BEAST_MODE(nnn) reference). Stored here as a plain string; to_dict() wraps it in the spec's dialects[] structure.

datatype str

Logical data type (default "UNKNOWN")

description str

Human-readable description

domo_extensions dict

DOMO-specific metadata (dataset id, beast mode template id, legacy id, raw functions, and the derived aggregation semantic when unambiguous — see OsiConverter._map_functions_to_aggregation)

OsiRelationship dataclass

OsiRelationship(
    from_model: str,
    to_model: str,
    from_field: str,
    to_field: str,
    join_type: str,
    cardinality: str | None = None,
)

A relationship (JOIN) between two OSI semantic models.

Attributes:

Name Type Description
from_model str

Name of the source/left model

to_model str

Name of the target/right model

from_field str

Join column on the from_model side

to_field str

Join column on the to_model side

join_type str

SQL join type (e.g., "INNER", "LEFT")

cardinality str | None

Relationship cardinality (omitted from output if None)

OsiSemanticModel dataclass

OsiSemanticModel(
    name: str,
    source_dataset_id: str,
    description: str = "",
    entities: list[OsiEntity] = list(),
    dimensions: list[OsiDimension] = list(),
    metrics: list[OsiMetric] = list(),
    relationships: list[OsiRelationship] = list(),
    domo_extensions: dict = dict(),
)

Top-level OSI semantic model for a single source table.

One OsiSemanticModel is generated per source table (DataModelObject or dataset participating in a ViewDefinition join).

Attributes:

Name Type Description
name str

Human-readable model name (from DomoDataset.name if available)

source_dataset_id str

The underlying Domo dataset ID

description str

Model description (from AI_Readiness.unit_of_analysis if set)

entities list[OsiEntity]

Join key columns

dimensions list[OsiDimension]

Non-aggregatable fields

metrics list[OsiMetric]

Aggregatable Beast Mode fields (Phase 1.5)

relationships list[OsiRelationship]

Cross-model JOIN relationships

domo_extensions dict

DOMO-specific metadata for custom_extensions block

SemanticConverter

Bases: ABC

Abstract base converter for OSI semantic layer.

Converters transform Domo source definitions (DataModelTemplate, ViewDefinition) into OSI-compliant semantic model output.

All conversion is synchronous — operates on pre-loaded data objects.

convert abstractmethod

convert(source: Any, datasets: dict, **options) -> list

Convert source definition to a list of OsiSemanticModels.

Parameters:

Name Type Description Default
source Any

A DataModelTemplate or ViewDefinition

required
datasets dict

dict mapping datasource_id -> DomoDataset (or None)

required
**options

Converter-specific options

{}

Returns:

Type Description
list

list[OsiSemanticModel]

Source code in src/crew_dcs/integrations/osi/converter.py
20
21
22
23
24
25
26
27
28
29
30
31
@abstractmethod
def convert(self, source: Any, datasets: dict, **options) -> list:
    """Convert source definition to a list of OsiSemanticModels.

    Args:
        source: A DataModelTemplate or ViewDefinition
        datasets: dict mapping datasource_id -> DomoDataset (or None)
        **options: Converter-specific options

    Returns:
        list[OsiSemanticModel]
    """

export_to_file abstractmethod

export_to_file(
    output: list, file_path: str, **options
) -> None

Export converted semantic models to file.

Parameters:

Name Type Description Default
output list

Result from convert()

required
file_path str

Destination file path

required
**options

format="yaml" (default) | "json"

{}
Source code in src/crew_dcs/integrations/osi/converter.py
33
34
35
36
37
38
39
40
41
@abstractmethod
def export_to_file(self, output: list, file_path: str, **options) -> None:
    """Export converted semantic models to file.

    Args:
        output: Result from convert()
        file_path: Destination file path
        **options: format="yaml" (default) | "json"
    """

UnresolvedBeastModeReferenceError

Bases: ValueError

Raised when a Beast Mode's DOMO_BEAST_MODE() reference can't be resolved.

Emitting an OSI metric with a dangling DOMO_BEAST_MODE(nnn) token would ship an expression that means nothing outside Domo. Callers must resolve refs via BeastModeTemplate.resolve_domo_beast_mode_refs() before converting — this error means that either wasn't done, or the resolution attempt itself failed to reach every referenced template.

from_domo_semantic_model async

from_domo_semantic_model(sm) -> list[OsiSemanticModel]

Fetch a data model's definition and convert it to OSI output.

Calls sm.Model.get() to hydrate the definition, then runs DataModelConverter with an empty datasets dict (Phase 1.0 — no AI Readiness hydration). Pass enriched datasets directly to DataModelConverter().convert() if you need AI Readiness metadata.

Phase 1.5: also fetches each source dataset's aggregated Beast Modes and resolves any DOMO_BEAST_MODE() refs they contain, so the returned models include Beast-Mode-derived OsiMetric entries with self-contained expressions.

Parameters:

Name Type Description Default
sm

DomoSemanticModel with auth embedded.

required

Returns:

Type Description
list[OsiSemanticModel]

list[OsiSemanticModel], one per source table in the data model.

Raises:

Type Description
ValueError

if the API returns no model definition.

Source code in src/crew_dcs/integrations/osi/from_domo.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
async def from_domo_semantic_model(sm) -> list[OsiSemanticModel]:
    """Fetch a data model's definition and convert it to OSI output.

    Calls ``sm.Model.get()`` to hydrate the definition, then runs
    ``DataModelConverter`` with an empty datasets dict (Phase 1.0 — no AI
    Readiness hydration).  Pass enriched ``datasets`` directly to
    ``DataModelConverter().convert()`` if you need AI Readiness metadata.

    Phase 1.5: also fetches each source dataset's aggregated Beast Modes and
    resolves any ``DOMO_BEAST_MODE()`` refs they contain, so the returned
    models include Beast-Mode-derived ``OsiMetric`` entries with
    self-contained expressions.

    Args:
        sm: DomoSemanticModel with auth embedded.

    Returns:
        list[OsiSemanticModel], one per source table in the data model.

    Raises:
        ValueError: if the API returns no model definition.
    """
    await sm.Model.get()
    if not sm.Model.template:
        raise ValueError(f"No model definition returned for data model {sm.id!r}")

    beastmodes = await _fetch_aggregated_beastmodes(sm.auth, sm.Model.template)

    return DataModelConverter().convert(
        sm.Model.template,
        {},
        beastmodes=beastmodes,
        domo_instance=sm.auth.domo_instance,
    )

Modules