Skip to content

feat(OMN-2318): integrate SPI 0.9.0 LLM cost tracking contracts - #345

Merged
jonahgabriel merged 9 commits into
mainfrom
feat/integrate-spi-llm-contracts
Feb 16, 2026
Merged

jonahgabriel merged 9 commits into
mainfrom
feat/integrate-spi-llm-contracts

Conversation

@jonahgabriel

@jonahgabriel jonahgabriel commented Feb 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Integrates the new SPI 0.9.0 LLM cost tracking contracts (ContractLlmCallMetrics, ContractLlmUsageRaw, ContractLlmUsageNormalized, ContractEnumUsageSource) into omnibase_infra's LLM effect handlers.

Ticket: OMN-2318
Pipeline Run: omn2318a

Changes

  • Converter module (converter_llm_usage_to_contract.py): Three pure functions bridging ModelLlmUsage to SPI contracts
    • to_usage_raw() → ContractLlmUsageRaw
    • to_usage_normalized() → ContractLlmUsageNormalized
    • to_call_metrics() → ContractLlmCallMetrics
  • Usage provenance: Added usage_source: ContractEnumUsageSource field to ModelLlmUsage (default: MISSING)
  • Raw data preservation: Added raw_provider_usage: dict | None field to ModelLlmUsage
  • Handler updates: All 4 LLM handlers (OpenAI + Ollama for inference + embedding) now populate provenance and raw usage data
  • Dependencies: Updated omnibase-core to >=0.18.0 and omnibase-spi to >=0.9.0

Test Plan

  • 51 new unit tests (22 converter + 29 provenance)
  • All 262 effects model tests pass
  • Ruff lint + format clean
  • mypy strict clean
  • Pre-commit hooks pass
  • CI passes
  • CodeRabbit review addressed

Summary by CodeRabbit

  • New Features

    • LLM usage metrics now track data source (API-derived vs missing/estimated)
    • Raw provider usage data preservation for enhanced audit capabilities
    • New event stream for LLM call completion events
  • Bug Fixes

    • Improved validation and error reporting for embedding operations
  • Chores

    • Database schema refinements for improved latency tracking precision

@coderabbitai

coderabbitai Bot commented Feb 16, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR extends ModelLlmUsage with provenance tracking fields (usage_source, raw_provider_usage) and introduces three adapter functions to convert infra models to SPI measurement contracts. All handlers are updated to parse and populate these fields; database schema is refined with nullable fields and precision improvements; a new intelligence topic suffix is added; and comprehensive tests validate the new functionality.

Changes

Cohort / File(s) Summary
Core Model & Public API
src/omnibase_infra/nodes/effects/models/model_llm_usage.py, src/omnibase_infra/nodes/effects/models/__init__.py
Added usage_source and raw_provider_usage fields to ModelLlmUsage for provenance tracking; exported three new adapter functions (to_call_metrics, to_usage_normalized, to_usage_raw) in module __init__.py.
Adapter Module
src/omnibase_infra/nodes/effects/models/adapter_llm_usage_to_contract.py
New converter module that translates ModelLlmUsage objects into SPI measurement contracts via three pure functions with validation, field mapping, and frozen Pydantic models.
LLM Embedding Handlers
src/omnibase_infra/nodes/node_llm_embedding_effect/handlers/handler_embedding_ollama.py, handler_embedding_openai_compatible.py
Enhanced usage parsing to compute usage_source (API/MISSING) and populate raw_provider_usage with timing data; added validation to raise error on empty embeddings.
LLM Inference Handlers
src/omnibase_infra/nodes/node_llm_inference_effect/handlers/handler_llm_ollama.py, handler_llm_openai_compatible.py
Updated handlers to parse provenance fields and populate usage_source based on usage availability; added properties to OpenAI handler for classification; reset stale metrics before processing.
Topics Configuration
src/omnibase_infra/topics/platform_topic_suffixes.py, src/omnibase_infra/topics/__init__.py
Added new SUFFIX_INTELLIGENCE_LLM_CALL_COMPLETED constant for intelligence domain with 3 partitions; exposed in public exports.
Database Schema
docker/migrations/forward/031_create_llm_call_metrics_and_cost_aggregates.sql, docker/migrations/schema_fingerprint.sha256
Made session_id nullable; changed latency_ms to NUMERIC(10, 2) for sub-millisecond precision and nullable handling; updated input_hash to VARCHAR(71) for sha256-prefixed format; partial index on session_id.
Test Coverage
tests/unit/models/effects/test_*.py, tests/unit/nodes/node_llm_inference_effect/handlers/test_*.py, tests/unit/topics/test_*.py
Added comprehensive tests for provenance fields, adapter functions, handler classification, usage parsing with edge cases, and topic registry updates; verified immutability, serialization, and validation behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~40 minutes

Possibly related PRs

Poem

🐰 We've traced the lineage of every token flow,
From handler to contract, the provenance glows!
Raw metrics preserved, source marked with care,
Adapters transform with precision to spare. 🔍✨

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (16 files):

⚔️ docker/migrations/forward/031_create_llm_call_metrics_and_cost_aggregates.sql (content)
⚔️ docker/migrations/schema_fingerprint.sha256 (content)
⚔️ pyproject.toml (content)
⚔️ src/omnibase_infra/nodes/effects/models/__init__.py (content)
⚔️ src/omnibase_infra/nodes/effects/models/model_llm_usage.py (content)
⚔️ src/omnibase_infra/nodes/node_llm_embedding_effect/handlers/handler_embedding_ollama.py (content)
⚔️ src/omnibase_infra/nodes/node_llm_embedding_effect/handlers/handler_embedding_openai_compatible.py (content)
⚔️ src/omnibase_infra/nodes/node_llm_inference_effect/handlers/handler_llm_ollama.py (content)
⚔️ src/omnibase_infra/nodes/node_llm_inference_effect/handlers/handler_llm_openai_compatible.py (content)
⚔️ src/omnibase_infra/observability/__init__.py (content)
⚔️ src/omnibase_infra/topics/__init__.py (content)
⚔️ src/omnibase_infra/topics/platform_topic_suffixes.py (content)
⚔️ tests/unit/models/effects/test_effects_models_exports.py (content)
⚔️ tests/unit/nodes/node_llm_inference_effect/handlers/test_handler_llm_openai_compatible_class.py (content)
⚔️ tests/unit/nodes/node_llm_inference_effect/handlers/test_handler_llm_openai_compatible_metrics.py (content)
⚔️ tests/unit/topics/test_platform_topic_suffixes.py (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main objective: integrating SPI 0.9.0 LLM cost tracking contracts, which is the core purpose reflected across all file changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/integrate-spi-llm-contracts
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch feat/integrate-spi-llm-contracts
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/omnibase_infra/nodes/effects/models/converter_llm_usage_to_contract.py`:
- Around line 1-30: Rename the module file from
converter_llm_usage_to_contract.py to an approved pattern (e.g.,
adapter_llm_usage_to_contract.py or model_llm_usage_to_contract.py) and update
every import that references the old module name; specifically update imports
that reference symbols exported from the module such as ModelLlmUsage,
ContractLlmCallMetrics, ContractLlmUsageRaw, ContractLlmUsageNormalized and
ContractEnumUsageSource where the current import path points to
converter_llm_usage_to_contract, as well as any package __init__.py exports,
tests, and CI/deployment references; ensure the module-level docstring and
internal references remain unchanged and run tests to confirm no import errors.
- Around line 90-133: The function to_call_metrics should validate that model_id
is a non-empty string before building the ContractLlmCallMetrics; add an early
guard in to_call_metrics that checks model_id (e.g., if not isinstance(model_id,
str) or not model_id.strip():) and raise a ValueError with a clear message like
"model_id must be a non-empty string" so invalid/empty model identifiers are
rejected before calling ContractLlmCallMetrics.

In
`@src/omnibase_infra/nodes/node_llm_embedding_effect/handlers/handler_embedding_openai_compatible.py`:
- Line 43: In handler_embedding_openai_compatible.py update the usage-extraction
logic (the function that builds the usage object / sets ContractEnumUsageSource
to API) so that when prompt_tokens is missing or not an int but total_tokens is
present and valid you fall back to using total_tokens for prompt_tokens (and for
any other missing token fields as appropriate) rather than reporting zero;
ensure you cast/validate total_tokens as int before assigning, keep
usage_source=API, and apply the same fallback change in the second
usage-extraction block around lines 220-258 so both code paths preserve token
counts when only total_tokens is provided.

Comment on lines +1 to +30
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 OmniNode Team
"""Converter bridging ModelLlmUsage to SPI LLM cost tracking contracts.

This module provides pure functions that translate between the infra-layer
``ModelLlmUsage`` value object and the SPI measurement contracts:

- ``ContractLlmCallMetrics``
- ``ContractLlmUsageRaw``
- ``ContractLlmUsageNormalized``
- ``ContractEnumUsageSource``

All functions are stateless and produce frozen Pydantic models suitable for
downstream measurement pipeline ingestion.

Related:
- OMN-2318: Integrate SPI 0.9.0 LLM cost tracking contracts
- ModelLlmUsage: Source infra-layer usage model
- ContractLlmCallMetrics: Target SPI per-call metrics contract
"""

from __future__ import annotations

from omnibase_infra.nodes.effects.models.model_llm_usage import ModelLlmUsage
from omnibase_spi.contracts.measurement import (
ContractEnumUsageSource,
ContractLlmCallMetrics,
ContractLlmUsageNormalized,
ContractLlmUsageRaw,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Rename module to match required Python file naming patterns.

The current filename converter_llm_usage_to_contract.py does not match the allowed prefixes. Please rename it (and update imports) to an approved pattern (e.g., adapter_llm_usage_to_contract.py or model_llm_usage_to_contract.py, depending on intent).
As per coding guidelines, File naming must follow pattern: model_.py, adapter_.py, dispatcher_.py, enum_.py, mixin_.py, protocol_.py, service_.py, store_.py, validator_.py, registry_infra_.py.

Also applies to: 136-140

🤖 Prompt for AI Agents
In `@src/omnibase_infra/nodes/effects/models/converter_llm_usage_to_contract.py`
around lines 1 - 30, Rename the module file from
converter_llm_usage_to_contract.py to an approved pattern (e.g.,
adapter_llm_usage_to_contract.py or model_llm_usage_to_contract.py) and update
every import that references the old module name; specifically update imports
that reference symbols exported from the module such as ModelLlmUsage,
ContractLlmCallMetrics, ContractLlmUsageRaw, ContractLlmUsageNormalized and
ContractEnumUsageSource where the current import path points to
converter_llm_usage_to_contract, as well as any package __init__.py exports,
tests, and CI/deployment references; ensure the module-level docstring and
internal references remain unchanged and run tests to confirm no import errors.

from omnibase_infra.nodes.node_llm_embedding_effect.models.model_llm_embedding_response import (
ModelLlmEmbeddingResponse,
)
from omnibase_spi.contracts.measurement import ContractEnumUsageSource

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Preserve token counts when only total_tokens is provided.

If prompt_tokens is missing or non-int but total_tokens is valid, the current logic reports zero tokens while still setting usage_source=API. Consider falling back to total_tokens for embeddings to avoid under-reporting.

Proposed fix
-    prompt_tokens = usage_raw.get("prompt_tokens", 0)
-    if not isinstance(prompt_tokens, int):
-        prompt_tokens = 0
+    prompt_tokens = usage_raw.get("prompt_tokens", 0)
+    if not isinstance(prompt_tokens, int):
+        prompt_tokens = 0

-    total_tokens = usage_raw.get("total_tokens", 0)
-    if not isinstance(total_tokens, int):
-        total_tokens = 0
+    total_tokens = usage_raw.get("total_tokens", 0)
+    if not isinstance(total_tokens, int):
+        total_tokens = 0
+
+    if prompt_tokens == 0 and total_tokens > 0:
+        prompt_tokens = total_tokens

Also applies to: 220-258

🤖 Prompt for AI Agents
In
`@src/omnibase_infra/nodes/node_llm_embedding_effect/handlers/handler_embedding_openai_compatible.py`
at line 43, In handler_embedding_openai_compatible.py update the
usage-extraction logic (the function that builds the usage object / sets
ContractEnumUsageSource to API) so that when prompt_tokens is missing or not an
int but total_tokens is present and valid you fall back to using total_tokens
for prompt_tokens (and for any other missing token fields as appropriate) rather
than reporting zero; ensure you cast/validate total_tokens as int before
assigning, keep usage_source=API, and apply the same fallback change in the
second usage-extraction block around lines 220-258 so both code paths preserve
token counts when only total_tokens is provided.

…infra

Bridge ModelLlmUsage to the new SPI measurement contracts
(ContractLlmCallMetrics, ContractLlmUsageRaw, ContractLlmUsageNormalized)
introduced in omnibase_spi 0.9.0.

- Add usage_source (ContractEnumUsageSource) and raw_provider_usage fields
  to ModelLlmUsage for provenance tracking (API/ESTIMATED/MISSING)
- Create converter module (to_call_metrics, to_usage_normalized, to_usage_raw)
  that maps infra-layer ModelLlmUsage to SPI contract models
- Update all 4 LLM handlers to populate provenance and raw usage data:
  handler_llm_openai_compatible, handler_llm_ollama,
  handler_embedding_openai_compatible, handler_embedding_ollama
- Update dependencies: omnibase-core ^0.18.0, omnibase-spi ^0.9.0
- Add 51 new unit tests for provenance fields and converters
…cases

Major:
- Fix has_usage always True in Ollama inference handler: add > 0 checks
  so usage_source correctly falls back to MISSING when Ollama omits usage

Minor:
- Guard against IndexError on empty embeddings in both OpenAI and Ollama
  embedding handlers
- Fix tokens_total fallback in converter to compute from input + output
  instead of defaulting to 0
- Change latency_ms column from INTEGER to NUMERIC(10,2) to preserve
  sub-millisecond precision matching SPI contract type
…onstraints

- OpenAI inference/embedding handlers now check has_usage before setting
  usage_source=API, matching the Ollama handler pattern (all-zero tokens → MISSING)
- Migration 031: pg_column_size → octet_length for predictable 64KB limit
- Migration 031: session_id nullable until write path integrated
- Migration 031: latency_ms nullable for failed LLM calls
Remove dead total-token fallback in to_usage_normalized and to_call_metrics;
ModelLlmUsage.model_validator guarantees tokens_total is always populated.
- Use `or 0` fallback for `tokens_total` in converter to satisfy
  mypy when field type is `int | None` (model_validator guarantees
  non-None at runtime, but static analysis cannot see that)
- Remove duplicated model_validator guarantee comments
- Simplify Ollama handler `has_usage` isinstance to `int` only
  (drop redundant `float`) matching embedding handler pattern
@jonahgabriel
jonahgabriel force-pushed the feat/integrate-spi-llm-contracts branch from 874ddfe to 444f42e Compare February 16, 2026 18:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
`@src/omnibase_infra/nodes/node_llm_inference_effect/handlers/handler_llm_ollama.py`:
- Around line 416-443: The has_usage check in handler_llm_ollama.py currently
tests isinstance(tokens_input/output, int) which can miss numeric usage when
values are floats; update the logic to determine has_usage from the resolved
rounded values (resolved_input, resolved_output) — e.g., consider has_usage true
when resolved_input > 0 or resolved_output > 0 (and include any
tokens_total-equivalent if present) before constructing ModelLlmUsage; adjust
the has_usage reference used in the ModelLlmUsage constructor so provenance
matches the OpenAI handler's behavior.
🧹 Nitpick comments (1)
src/omnibase_infra/nodes/node_llm_inference_effect/handlers/handler_llm_openai_compatible.py (1)

137-192: Missing handler_type and handler_category properties.

The HandlerLlmOpenaiCompatible class does not implement the @property handler_type returning EnumHandlerType or @property handler_category returning EnumHandlerTypeCategory, which are required for handler classes. However, the class docstring explicitly notes this is a "documented deviation from the standard ONEX handler pattern" since it operates at the infrastructure layer with typed request/response models rather than envelope-based dispatch.

If this deviation is intentional and approved, consider adding a comment or annotation to suppress this guideline check. Otherwise, add the required properties similar to HandlerLlmOllama.

As per coding guidelines, handlers must include @property handler_type returning EnumHandlerType and @property handler_category returning EnumHandlerTypeCategory in all handler classes.

- [major] rename converter_llm_usage_to_contract → adapter_ to match
  approved file naming prefixes (git mv preserves history)
- [minor] add model_id validation guard in to_call_metrics() + test
- [minor] fallback to total_tokens when prompt_tokens missing in
  embedding OpenAI handler
- [minor] use resolved integer values for has_usage in Ollama handler,
  aligning with OpenAI handler provenance logic
- [nitpick] add handler_type/handler_category properties to
  HandlerLlmOpenaiCompatible + update test assertion
…set stale metrics

- Widen input_hash VARCHAR(64) to VARCHAR(71) in migration 031 to fit
  sha256- prefix (7 chars) + 64 hex chars
- Add SUFFIX_INTELLIGENCE_LLM_CALL_COMPLETED to provisioned topic specs
  so TopicProvisioner auto-creates the LLM call completed topic
- Reset self.last_call_metrics = None at start of handle() to prevent
  stale metrics surviving if _build_usage_metrics throws
Some LLM providers include cached/reasoning tokens in total_tokens,
making it exceed prompt_tokens + completion_tokens. The ModelLlmUsage
model_validator would raise ValueError on mismatch, crashing handle().

Now _parse_usage falls back to auto-compute when the provider total
doesn't match the sum. Raw provider data preserved in raw_provider_usage
for auditing.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fix all issues with AI agents
Before applying any fix, first verify the finding against the current code and
decide whether a code change is actually needed. If the finding is not valid or
no change is required, do not modify code for that item and briefly explain why
it was skipped.

In
`@src/omnibase_infra/nodes/node_llm_embedding_effect/handlers/handler_embedding_openai_compatible.py`:
- Around line 150-160: The empty-embeddings guard in
handler_embedding_openai_compatible.py is redundant because
_parse_openai_embeddings already raises for empty/invalid data; remove the if
not embeddings block (the ModelInfraErrorContext.with_correlation +
InfraProtocolError raise) from the method that calls _parse_openai_embeddings,
or alternatively move the distinct error message into the
_parse_openai_embeddings implementation so a single failure path (inside
_parse_openai_embeddings) reports the desired context/message; update callers to
rely on _parse_openai_embeddings to raise instead of checking embeddings
themselves.
🧹 Nitpick comments (1)
🤖 Fix all nitpicks with AI agents
Before applying any fix, first verify the finding against the current code and
decide whether a code change is actually needed. If the finding is not valid or
no change is required, do not modify code for that item and briefly explain why
it was skipped.

In
`@src/omnibase_infra/nodes/node_llm_embedding_effect/handlers/handler_embedding_openai_compatible.py`:
- Around line 150-160: The empty-embeddings guard in
handler_embedding_openai_compatible.py is redundant because
_parse_openai_embeddings already raises for empty/invalid data; remove the if
not embeddings block (the ModelInfraErrorContext.with_correlation +
InfraProtocolError raise) from the method that calls _parse_openai_embeddings,
or alternatively move the distinct error message into the
_parse_openai_embeddings implementation so a single failure path (inside
_parse_openai_embeddings) reports the desired context/message; update callers to
rely on _parse_openai_embeddings to raise instead of checking embeddings
themselves.
src/omnibase_infra/nodes/node_llm_embedding_effect/handlers/handler_embedding_openai_compatible.py (1)

150-160: Consider removing the redundant empty-embeddings guard.

_parse_openai_embeddings already raises on empty/invalid data, so this branch looks unreachable. If you want a distinct message, consider moving that logic into the parser instead.

♻️ Optional simplification
-        if not embeddings:
-            ctx = ModelInfraErrorContext.with_correlation(
-                correlation_id=request.correlation_id,
-                transport_type=EnumInfraTransportType.HTTP,
-                operation="parse_openai_embeddings",
-                target_name=self._llm_target_name,
-            )
-            raise InfraProtocolError(
-                "OpenAI embedding response returned no embeddings",
-                context=ctx,
-            )
🤖 Prompt for AI Agents
Before applying any fix, first verify the finding against the current code and
decide whether a code change is actually needed. If the finding is not valid or
no change is required, do not modify code for that item and briefly explain why
it was skipped.
In
`@src/omnibase_infra/nodes/node_llm_embedding_effect/handlers/handler_embedding_openai_compatible.py`
around lines 150 - 160, The empty-embeddings guard in
handler_embedding_openai_compatible.py is redundant because
_parse_openai_embeddings already raises for empty/invalid data; remove the if
not embeddings block (the ModelInfraErrorContext.with_correlation +
InfraProtocolError raise) from the method that calls _parse_openai_embeddings,
or alternatively move the distinct error message into the
_parse_openai_embeddings implementation so a single failure path (inside
_parse_openai_embeddings) reports the desired context/message; update callers to
rely on _parse_openai_embeddings to raise instead of checking embeddings
themselves.

@jonahgabriel
jonahgabriel merged commit c9d7a91 into main Feb 16, 2026
22 checks passed
@jonahgabriel
jonahgabriel deleted the feat/integrate-spi-llm-contracts branch February 16, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant