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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ ONTOLOGY_CONTEXT_REQUIRED=false
# ONTOLOGY_CONTEXT_MAX_TRIPLES above for prompt size.
#ONTOLOGY_MAX_TRIPLES=
PARALLEL_WORKERS=16
# Per-document caps on billed provider tokens, unset (unlimited) by default.
# The next call past a cap is refused and the document stops with
# BudgetExceededError; cache replays do not count. Reasoning counts as output.
#MAX_INPUT_TOKENS_PER_DOCUMENT=
#MAX_OUTPUT_TOKENS_PER_DOCUMENT=
ENABLE_ONTOLOGY_CONSOLIDATION=false

# LLM Configuration
Expand Down
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.6.8]

### Added

- **`RunMeter`**, bound with `use_run_meter`: counts provider and cache-replayed tokens per model
across a run while it is in progress, with optional `token_caps`, a caller's `limit` policy
checked before each provider call, and a throttled `on_usage` callback.
- **`BudgetExceededError`**: raised before a provider call once a limit is reached; stops the
whole run after its parallel units finish, and carries the meter's `snapshot`.
- **`MAX_INPUT_TOKENS_PER_DOCUMENT` / `MAX_OUTPUT_TOKENS_PER_DOCUMENT`**: per-document token caps
for `ontocast process`, `/process`, `/process_unit` and `make_ontocast_node`. A capped route
answers `422` with `error_code` `budget_exceeded`; a capped batch file is recorded as failed.
- **Budget fields** `replayed_reasoning_tokens`, `replayed_cache_read_input_tokens` and
`replayed_cache_creation_input_tokens`.

### Fixed

- **`reasoning_tokens`, `cache_read_input_tokens` and `cache_creation_input_tokens`** count
provider calls only; cache replays go to the new `replayed_*` fields. The two ratios are
unchanged.

## [0.6.7]

### Added
Expand Down Expand Up @@ -2647,7 +2668,7 @@ BASE_RECURSION_LIMIT=1000

See [docs/user_guide/](docs/user_guide/) for full guides.

[Unreleased]: https://github.com/growgraph/ontocast/compare/v0.6.4...HEAD
[Unreleased]: https://github.com/growgraph/ontocast/compare/v0.6.7...HEAD
[0.6.4]: https://github.com/growgraph/ontocast/compare/v0.6.2...v0.6.4
[0.6.2]: https://github.com/growgraph/ontocast/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/growgraph/ontocast/compare/v0.6.0...v0.6.1
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ document you run twice with the same prompts costs nothing the second time; see
For reasoning models,
[`LLM_REASONING_EFFORT`](../reference/configuration/llm.md#llm_reasoning_effort)
sets how much the model thinks before answering, from `none` to `max` (OpenAI, Gemini 3 and later);
reasoning tokens are billed as output. For local models through Ollama, raise
reasoning tokens count as output. For local models through Ollama, raise
[`LLM_NUM_CTX`](../reference/configuration/llm.md#llm_num_ctx): Ollama's default
context window is too small for most OntoCast prompts.

Expand Down
60 changes: 60 additions & 0 deletions docs/guides/embedding.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,66 @@ builder = build_agent_graph(tools) # uncompiled, to add your own nodes
`name` when you embed the graph as a subgraph; otherwise it appears as
`LangGraph` in traces.

## Limit a run's tokens

A `RunMeter` counts tokens while a run is in progress, across all of its content
units, and stops the run at a limit. Bind it around the run:

```python
from ontocast import BudgetExceededError, RunMeter, use_run_meter

meter = RunMeter(
token_caps={"input": 400_000, "output": 60_000},
on_usage=report_progress, # receives meter.snapshot()
min_interval_s=5.0,
)
try:
with use_run_meter(meter):
async for chunk in workflow.astream(state, stream_mode="values"):
final = chunk
except BudgetExceededError as exc:
report_usage(exc.snapshot)
else:
report_usage(meter.snapshot())
```

- Usage is kept per model as `TokenTotals`. `input`, `cache_read`,
`cache_write`, `output` and `reasoning` count provider calls, as the provider
reports them: `cache_read` and `cache_write` are included in `input`, and
`reasoning` in `output`. `replayed_input` and `replayed_output` count answers
replayed from the [LLM response cache](llm_caching.md), for which no provider
call is made.
- `token_caps` limits counts summed over models, keyed by field name.
- `limit` is your own policy: a function given a copy of the counts per model
before each provider call, returning a reason to stop or `None`. The meter
holds no prices; a caller that budgets in money prices the counts here:

```python
def within_budget(usage):
spent = sum(price(model, totals) for model, totals in usage.items())
return f"spent {spent:.2f}" if spent >= budget else None


meter = RunMeter(limit=within_budget)
```

- The limit is checked before each provider call. The first call that would
go past it is not sent, and the run raises `BudgetExceededError` with the
meter's `snapshot` once its parallel units have finished. Calls already in
flight complete and are recorded, so the final usage can go over the limit
by what those calls used.
- `on_usage` is called with a snapshot at most once every `min_interval_s`
seconds, and once more when the `use_run_meter` block exits.

When no meter is bound, `ontocast process`, the `/process` and `/process_unit`
routes, and `make_ontocast_node` apply
[`MAX_INPUT_TOKENS_PER_DOCUMENT`](../reference/configuration/pipeline.md#max_input_tokens_per_document)
and
[`MAX_OUTPUT_TOKENS_PER_DOCUMENT`](../reference/configuration/pipeline.md#max_output_tokens_per_document)
to each document. The routes answer `422` with `error_code`
`budget_exceeded`, and `ontocast process` records the document as failed and
goes on to the next one. A meter you bind yourself replaces these settings.

## Several tenants in one process

A `ToolBox` serves one partition of the stores. To serve another:
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ be set per request.
[`LLM_OUTPUT_LAYOUT`](../reference/configuration/pipeline.md#llm_output_layout)
sets the whitespace of the response. **Keep the default, `compact`.** It asks
for minified JSON and, under Turtle, one line per subject. Without it, models
indent their JSON, and every indentation token is billed output that the parser
indent their JSON, and every indentation token is output that the parser
discards. `free` exists to compare against; it changes no content, so compare
`output_tokens` with the layout on and off.

Expand Down
19 changes: 12 additions & 7 deletions docs/guides/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ every key.
|---|---|
| `calls_count` | Calls sent to the provider |
| `cache_hits` | Calls answered from the [LLM response cache](llm_caching.md) |
| `input_tokens`, `output_tokens` | Tokens billed for the calls sent |
| `cached_input_tokens`, `cached_output_tokens` | Tokens of cached answers: what those calls cost when they were first made. Not added to the billed totals |
| `reasoning_tokens` | Reasoning tokens, included in the output totals |
| `cache_read_input_tokens` | Input tokens the provider served from its own prompt cache, included in the input totals |
| `prefix_cache_hit_rate` | `cache_read_input_tokens` over all input tokens |
| `reasoning_share_of_output` | `reasoning_tokens` over all output tokens |
| `input_tokens`, `output_tokens` | Tokens of the calls sent to the provider |
| `cached_input_tokens`, `cached_output_tokens` | Tokens of cached answers: what those calls cost when they were first made. Not added to the provider totals |
| `reasoning_tokens` | Reasoning tokens of the calls sent, included in `output_tokens` |
| `cache_read_input_tokens` | Input tokens of the calls sent that the provider served from its own prompt cache, included in `input_tokens` |
| `replayed_reasoning_tokens`, `replayed_cache_read_input_tokens` | The same for cached answers |
| `prefix_cache_hit_rate` | Provider-cache reads over all input tokens, sent and cached |
| `reasoning_share_of_output` | Reasoning tokens over all output tokens, sent and cached |

Providers report tokens differently, and one that reports none leaves the
token fields at `0` and both ratios at `null`. A `null` ratio means
Expand All @@ -42,6 +43,10 @@ unmeasured, not zero. The two ratios say where to aim a cost change: a high
tuning](performance.md#what-makes-a-prompt-expensive). OntoCast reports tokens,
not money.

`budget` is complete once the run ends. To follow usage while the run is in
progress, or to stop the run at a limit, bind a `RunMeter`; see [Limit what a run
spends](embedding.md#limit-a-runs-tokens).

Before reading cost or quality, check three counters in `budget.counters`:

- `llm/rate_limited` and `llm/timeouts`: calls lost to throttling or to the
Expand Down Expand Up @@ -189,4 +194,4 @@ through to every step. See [Embedding OntoCast in your agent](embedding.md).
!!! note "Cached calls leave no LLM span"
A call answered from the LLM response cache never reaches the provider, so
it produces no LLM span, while `budget` still counts it. For a complete
trace, set `LLM_CACHE_ENABLED=false`; every call is then billed.
trace, set `LLM_CACHE_ENABLED=false`; every call is then sent to the provider.
20 changes: 10 additions & 10 deletions docs/internals/telemetry_counters.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ unit finishes: fields and counters are summed, and duration keys ending in
| `calls_count` | Calls sent to the provider, including calls that timed out |
| `cache_hits` | Calls answered from the LLM response cache |
| `chars_sent`, `chars_received` | Prompt and answer characters, for sent and cached calls alike. A timed-out call adds its prompt |
| `input_tokens`, `output_tokens` | Tokens billed for sent calls |
| `input_tokens`, `output_tokens` | Tokens of calls sent to the provider |
| `cached_input_tokens`, `cached_output_tokens` | Tokens stored with cached answers. Cache entries written without usage add nothing |
| `reasoning_tokens` | Reasoning tokens, inside the output totals; sent and cached calls |
| `cache_read_input_tokens` | Input tokens served from the provider's prompt cache, inside the input totals; sent and cached calls |
| `cache_creation_input_tokens` | Input tokens written to the provider's prompt cache; sent and cached calls |
| `reasoning_tokens` | Reasoning tokens of sent calls, inside `output_tokens` |
| `cache_read_input_tokens` | Input tokens of sent calls served from the provider's prompt cache, inside `input_tokens` |
| `cache_creation_input_tokens` | Input tokens of sent calls written to the provider's prompt cache |
| `replayed_reasoning_tokens`, `replayed_cache_read_input_tokens`, `replayed_cache_creation_input_tokens` | The same three, stored with cached answers |
| `ontology_triples_generated`, `ontology_operations_count` | Triples and update operations the ontology renders produced |
| `facts_triples_generated`, `facts_operations_count` | The same for facts renders |
| `node_durations` | Seconds per key; see [Duration keys](#duration-keys) |
Expand All @@ -32,12 +33,11 @@ Two fields are derived when the budget is serialized:

| Field | Formula | `null` when |
|---|---|---|
| `prefix_cache_hit_rate` | `cache_read_input_tokens / (input_tokens + cached_input_tokens)` | No input tokens were reported |
| `reasoning_share_of_output` | `reasoning_tokens / (output_tokens + cached_output_tokens)` | No output tokens were reported |
| `prefix_cache_hit_rate` | `(cache_read_input_tokens + replayed_cache_read_input_tokens) / (input_tokens + cached_input_tokens)` | No input tokens were reported |
| `reasoning_share_of_output` | `(reasoning_tokens + replayed_reasoning_tokens) / (output_tokens + cached_output_tokens)` | No output tokens were reported |

The denominators include cached tokens because the numerators do. Dividing by
`input_tokens` alone can exceed 1 on a run that was partly answered from the
cache.
Both ratios cover sent and cached calls together: they describe the workload,
not what this run paid for.

## Duration keys

Expand Down Expand Up @@ -113,7 +113,7 @@ wall × effective workers ≈ llm/provider + llm/inflight_wait
| `ontology/update_rejected_over_budget` | `ontocast/agent/render_ontology.py` | Ontology updates discarded for exceeding `ONTOLOGY_MAX_TRIPLES` |

`calls_count` equals `llm/calls_timed + llm/timeouts`: a call that raised for
any other reason is not billed and not counted in `calls_count`. The
any other reason is not counted in `calls_count`. The
`chapter/*` counters are recorded once per chapter built, not per call that
reads it, and only when at least one text cap is set; [How prompts are
built](prompt_construction.md#text-caps) describes the caps.
Expand Down
1 change: 1 addition & 0 deletions docs/reference/http_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ an upload route called without `file`, gets FastAPI's standard `422` with a
| `422` | The uploaded file could not be converted (`/process_unit`) | `conversion_failed:<stage>` |
| `422` | The single unit failed (`/process_unit`) | none |
| `422` | A section selection (`target_sections`, `summarize_sections`, or `exclude_sections` including the schema's default exclusions) removed every part of the document, with [`CHUNK_SECTION_FILTER_ON_EMPTY`](configuration/chunking.md#chunk_section_filter_on_empty)`=error`; with the default `warn`, the run continues with nothing to extract (`/process`) | `empty_section_selection:<name>` |
| `422` | The document reached [`MAX_INPUT_TOKENS_PER_DOCUMENT`](configuration/pipeline.md#max_input_tokens_per_document) or [`MAX_OUTPUT_TOKENS_PER_DOCUMENT`](configuration/pipeline.md#max_output_tokens_per_document); `error_details.run_meter` holds the usage | `budget_exceeded` |
| `500` | Any other failure during processing or in a store | none |
| `503` | `/health` only: the LLM client is missing | none |

Expand Down
8 changes: 8 additions & 0 deletions ontocast/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@

__all__ = [
"AgentState",
"BudgetExceededError",
"Config",
"RunMeter",
"TokenTotals",
"ToolBox",
"__version__",
"build_agent_graph",
Expand All @@ -34,13 +37,18 @@
"ontology_loop",
"run_unit_pipeline",
"text_in_turtle_out",
"use_run_meter",
]

# Everything is resolved lazily so `import ontocast` stays cheap: ToolBox pulls
# in the whole tool tree and Config the settings tree, and neither is wanted
# just to read __version__.
_LAZY_EXPORTS: dict[str, tuple[str, str]] = {
"AgentState": ("ontocast.onto.state", "AgentState"),
"BudgetExceededError": ("ontocast.onto.run_meter", "BudgetExceededError"),
"RunMeter": ("ontocast.onto.run_meter", "RunMeter"),
"TokenTotals": ("ontocast.onto.run_meter", "TokenTotals"),
"use_run_meter": ("ontocast.onto.run_meter", "use_run_meter"),
"Config": ("ontocast.config", "Config"),
"ToolBox": ("ontocast.toolbox", "ToolBox"),
"build_agent_graph": ("ontocast.stategraph.create", "build_agent_graph"),
Expand Down
4 changes: 2 additions & 2 deletions ontocast/agent/complete_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
collect_catalog_terms,
expand_vocabulary_terms,
)
from ontocast.tool.llm import LLMConfigurationError
from ontocast.tool.llm import RUN_STOPPING_ERRORS
from ontocast.util.numeric_inventory import NumericInventory

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -149,7 +149,7 @@ async def complete_facts(
prompt_kwargs=prompt_data,
llm_graph_format=state.llm_graph_format,
)
except LLMConfigurationError:
except RUN_STOPPING_ERRORS:
# Best-effort is the right call for a failed completion pass, but
# not for a request the provider will never accept: swallowing it
# here hides the fault behind a pass that is allowed to add
Expand Down
6 changes: 3 additions & 3 deletions ontocast/agent/criticise_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
accept_reason,
material_defects,
)
from ontocast.tool.llm import LLMConfigurationError
from ontocast.tool.llm import RUN_STOPPING_ERRORS

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -248,15 +248,15 @@ async def criticise_facts(

return state

except LLMConfigurationError:
except RUN_STOPPING_ERRORS:
# A rejected request is not a critic that failed to answer: the
# next unit's critic will be rejected the same way.
raise
except Exception as e:
# A critic that did not answer -- timeout, transport error, a response
# that never parsed -- is not a critic that accepted. The unit leaves
# the loop FAILED at the critique stage with its render intact, and
# the attempt is on the record as a billed call that produced no
# the attempt is on the record as a call that produced no
# critique; the loop reads ``critic_outcome`` and applies no patch.
logger.error(f"Failed to criticize facts: {str(e)}")
state.critic_outcome = "unavailable"
Expand Down
4 changes: 2 additions & 2 deletions ontocast/agent/criticise_ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from ontocast.tool import LLMTool
from ontocast.tool.atomic import AtomicToolBox
from ontocast.tool.facts_validation import accept_reason, material_defects
from ontocast.tool.llm import LLMConfigurationError
from ontocast.tool.llm import RUN_STOPPING_ERRORS
from ontocast.tool.ontology_validation import count_fixes_targeting_snapshot

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -255,7 +255,7 @@ async def criticise_ontology(
)
return state

except LLMConfigurationError:
except RUN_STOPPING_ERRORS:
# A rejected request is the deployment, not this critique: every
# other unit is about to be rejected identically.
raise
Expand Down
4 changes: 2 additions & 2 deletions ontocast/agent/external_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
)
from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState
from ontocast.tool.atomic import AtomicToolBox, SearchHit
from ontocast.tool.llm import LLMConfigurationError
from ontocast.tool.llm import RUN_STOPPING_ERRORS

logger = logging.getLogger(__name__)
UnitStateT = TypeVar("UnitStateT", UnitFactsState, UnitOntologyState)
Expand Down Expand Up @@ -275,7 +275,7 @@ async def plan_external_evidence_for_node(
"format_instructions": parser.get_format_instructions(),
},
)
except LLMConfigurationError:
except RUN_STOPPING_ERRORS:
# Skipping external evidence is a sound fallback for a planner
# that failed; it is not one for a provider that rejects every
# request, which the rest of the unit is about to hit too.
Expand Down
4 changes: 2 additions & 2 deletions ontocast/agent/render_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
repair_property_aliases,
resolve_code_literals,
)
from ontocast.tool.llm import LLMConfigurationError
from ontocast.tool.llm import RUN_STOPPING_ERRORS
from ontocast.tool.validate import partition_object_property_literal_triples

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -432,7 +432,7 @@ async def render_facts_fresh(
state.set_node_status(WorkflowNode.TEXT_TO_FACTS, Status.SUCCESS)
return state

except LLMConfigurationError:
except RUN_STOPPING_ERRORS:
# The provider rejects the request itself, not this attempt at
# it: every other unit is about to be rejected identically.
# Failing one unit here turns a configuration fault into an
Expand Down
Loading
Loading