Skip to content

fix(engine): warn when the provider pricing hook returns nothing for every model - #388

Merged
Jason Robert (jrob5756) merged 7 commits into
microsoft:mainfrom
franklixuefei:fix/386-surface-provider-pricing-silence
Aug 11, 2026
Merged

fix(engine): warn when the provider pricing hook returns nothing for every model#388
Jason Robert (jrob5756) merged 7 commits into
microsoft:mainfrom
franklixuefei:fix/386-surface-provider-pricing-silence

Conversation

@franklixuefei

Copy link
Copy Markdown
Member

Refs #386 — addresses the diagnosability half. Deliberately not Fixes, see below.

What #386 turned out to be

I filed #386 thinking two models were missing from DEFAULT_PRICING. Probing the SDK directly showed something more useful: the provider hook that #265 added is reading a field that no longer exists.

On github-copilot-sdk == 1.0.1:

model: claude-opus-5
billing type: ModelBilling
billing attrs: ['from_dict', 'multiplier', 'to_dict']
multiplier = None

There is no token_prices attribute at all, so getattr(billing, "token_prices", None) returns None unconditionally. Across the whole listing:

models=21 with token_prices=0 with multiplier=0

Not a per-model gap, not auth, not a matching failure — match_model_id resolves all 21. The documented chain

override → hook → DEFAULT_PRICING → unpriced

is in practice override → (structurally always None) → DEFAULT_PRICING, so the static table #265 demoted to a fallback is quietly carrying 100% of the load.

Why this was invisible

#265 warns once if the hook raises. A hook that never raises and returns None for everything logs nothing at all. And because models that are in the static table still report a plausible cost, the run looks fine — the only symptom is that newer models show up as unpriced, which reads as "two missing table entries" rather than "the mechanism is dead".

Those two conditions are genuinely different and were indistinguishable. Telling them apart cost me an afternoon of probing, which is the actual bug from a user's point of view.

The change

Warn once per run when the hook has returned None for more than one distinct model and has never priced anything, naming the models.

Both thresholds are load-bearing:

  • A single None stays quiet — one model the provider cannot price is ordinary, and the table covers it.
  • Any successful pricing stays quiet — a hook that works but declines some models is not a systemic failure.

Only "None for everything, nothing ever priced" is reported. It sits next to the existing _pricing_hook_failed_warned latch and follows the same once-per-run pattern.

What I deliberately did not do

I did not add claude-opus-5 / gpt-5.6-sol to DEFAULT_PRICING. I do not have authoritative per-token rates for them, and inventing numbers would produce confidently wrong costs — which is worse than unpriced, and is precisely what #265 set out to stop. That change wants real pricing data rather than a guess from me, which is why this is Refs and not Fixes.

I did not chase the SDK. If token_prices moved rather than being dropped, repointing the hook would restore pricing for all 21 models at once and would be the better fix. I could not find a replacement on the model object; someone with visibility into the SDK changelog would know quickly.

So #386 stays open for the pricing data and the SDK question. This PR only ensures the next person does not have to reverse-engineer why everything is unpriced.

Tests

Five tests, and the two negative cases matter as much as the positive one — a warning that fires on a single unpriced model would be noise, and noise gets ignored:

caseexpected
None for 3 models, none pricedwarns once, names them
None for 1 modelsilent
one priced, then two Nonesilent
same model probed twicesilent (set is de-duplicated)

Verified by neutering the warning: 2 of 5 fail.

Validation

tests/test_engine matches the pre-existing baseline exactly — 442 failures before and after, measured by stashing the change rather than assumed. (My sandbox has no PyPI access so pytest-asyncio is unavailable; those async tests error identically either way.) ruff check and ruff format --check clean.

One note on that measurement: my first attempt at the baseline was invalid — git stash silently failed on CRLF (fatal: CRLF would be replaced by LF) so I was comparing the tree against itself. Normalising line endings first gave the real numbers.

Environment

conductor v0.1.26
github-copilot-sdk 1.0.1
provider copilot (SDK available, 21 models listed)

Related

#265 built the hook and the ~$X (N unpriced) display — both working as designed; this is the layer beneath. #137 (fuzzy-match in get_pricing), #301 (doctor surfacing per-model metadata — a natural home for pricing coverage).

…hing
microsoft#265 added the get_model_pricing hook and warns once if it RAISES. It does not
notice the companion case: a hook that never raises and returns None for every
model.
That is the case in practice. github-copilot-sdk 1.0.1 removed
ModelBilling.token_prices, which is the field CopilotProvider's hook reads, so
it resolves None for all 21 models it lists. Verified directly against the SDK:
billing attrs: ['from_dict', 'multiplier', 'to_dict']
models=21 with token_prices=0 with multiplier=0
Nothing raises, nothing logs, and models that happen to be in the static table
still report a plausible cost -- so the fact that live pricing is dead is
invisible. The only symptom is newer models showing as unpriced, which looks
like two missing table entries rather than a broken mechanism. It cost me an
afternoon of probing to tell those apart.
Warn once per run when the hook has returned None for more than one distinct
model and has never priced anything, naming the models. The thresholds matter:
a single None is ordinary (that model just is not priced) and any successful
pricing means the hook works, so both stay quiet. Only 'None for everything,
nothing ever priced' is reported.
Deliberately NOT adding claude-opus-5 / gpt-5.6-sol to DEFAULT_PRICING in this
change. I do not have authoritative per-token rates for them, and inventing
numbers would produce confidently wrong costs -- which is worse than unpriced
and is precisely what microsoft#265 set out to stop. That wants real pricing data, not a
guess from me.
Tests assert both the warning and, as importantly, the two silences. Verified
by neutering the warning: 2 of 5 fail. tests/test_engine matches the
pre-existing baseline exactly (442 failures before and after; sandbox lacks
pytest-asyncio). ruff clean.
Refs microsoft#386
@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@87f38e1). Learn more about missing BASE report.

Additional details and impacted files
@@ Coverage Diff @@## main #388 +/- ##
=======================================
Coverage ? 91.72% =======================================
Files ? 109 Lines ? 17836 Branches ? 0 =======================================
Hits ? 16360 Misses ? 1476 Partials ? 0 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jrob5756Jason Robert (jrob5756) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed both the concept and the implementation. Thanks again for your contributions! The problem is real and worth fixing: a hook that goes quiet is genuinely indistinguishable from a model that simply isn't priced, and #265 only covers the raising case. The latch design is sound, and the negative test cases are well chosen.

Three things I'd want resolved before this lands, all clustered around who sees the warning and when.

It fires for behaviour that is correct on four of the five providers.AgentProvider.get_model_pricing returns None by default and only Copilot overrides it, so claude, hermes, aca and claude-agent-sdk users get told their SDK dropped a field. I reproduced this against a provider inheriting the base hook, with two models the static table prices correctly. Every clause of the message is false for that run. The cost is worse than noise: people learn within two runs to scroll past it, and then miss it when the real Copilot regression arrives.

It also fires early._pricing_hook_priced_any is read on the second None, so a working hook that declines two models before pricing a third has already warned. Because _ensure_pricing_resolved locks per model, arrival order under parallel and for_each groups varies, so this is nondeterministic. Deferring the verdict to end of run fixes that, removes the need for the two-model floor, and puts the conclusion next to the cost total.

Which is the third point.cli/run.py:1190 isn't touched here, and a model priced from the static table has a cost_usd, so it never enters unpriced_models. The summary keeps printing a confident number sourced from a possibly stale table, while the explanation goes to lastResort stderr. Threading a live_pricing_degraded flag into the usage data the summary already reads would put the caveat where people actually look.

Smaller items: there's no CHANGELOG entry (the #265 commit added two), and ty check reports 11 errors on the new test file, which CI won't catch because it only checks src.

One thing that isn't a blocker for this PR but changes the picture on #386: tokenPrices is still on the wire and still modelled in copilot/generated/rpc.py. It's copilot/client.py's hand-written ModelBilling that discards it, and client.list_models() returns that one. The field wasn't removed, so #386 may be a much smaller fix than it looks. Probably worth a comment on the issue.

Comment threadsrc/conductor/engine/workflow.py Outdated
Comment on lines +420 to +427
# The companion case: a hook that never raises but returns ``None`` for
# everything. That is what happens when the provider SDK stops
# publishing the field the hook reads — ``github-copilot-sdk`` 1.0.1
# dropped ``ModelBilling.token_prices``, so the hook resolves ``None``
# for all models and every run silently falls back to the static table.
# A single ``None`` is ordinary (one unknown model); ``None`` for
# everything with nothing ever priced means the mechanism is dead, and
# the two are indistinguishable without this. See #386.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The SDK didn't remove this field. copilot/generated/rpc.py still defines ModelBilling with token_prices, parsed from the wire key tokenPrices. What actually happens is that copilot/client.py declares a second, hand-written ModelBilling whose from_dict reads only multiplier and throws tokenPrices away, and client.list_models() returns that one.

The distinction matters because the two diagnoses send you to different places. "The SDK dropped it" points at pinning an older version or hunting for a rename, and both are dead ends. The real fix is upstream in client.ModelBilling.from_dict, or a local workaround that bypasses the lossy dataclass.

The same claim appears at line 1935, in the log message at 1964, and in the test module docstring, so all four need to move together.

Suggested change
# The companion case: a hook that never raises but returns ``None`` for
# everything. That is what happens when the provider SDK stops
# publishing the field the hook reads — ``github-copilot-sdk`` 1.0.1
# dropped ``ModelBilling.token_prices``, so the hook resolves ``None``
# for all models and every run silently falls back to the static table.
# A single ``None`` is ordinary (one unknown model); ``None`` for
# everything with nothing ever priced means the mechanism is dead, and
# the two are indistinguishable without this. See #386.
# The companion case: a hook that never raises but returns ``None`` for
# everything, which is indistinguishable from "these models are simply
# unpriced" unless it is tracked. Live today on Copilot: the SDK's
# hand-written ``client.ModelBilling`` parses only ``multiplier`` and
# discards the ``tokenPrices`` wire field that ``generated.rpc`` still
# models, so the hook resolves ``None`` for every model. See #386.

Comment threadsrc/conductor/engine/workflow.py Outdated
return None
return self._single_provider

def _note_pricing_hook_result(self, model: str, pricing: object | None) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

object | None collapses to plain object, so anything type-checks here and the annotation carries no information. ModelPricing is already imported at line 26, and the caller holds exactly that type (set_provider_pricing declares it in engine/usage.py).

Suggested change
def_note_pricing_hook_result(self, model: str, pricing: object|None) ->None:
def_note_pricing_hook_result(self, model: str, pricing: ModelPricing|None) ->None:

Comment threadsrc/conductor/engine/workflow.py Outdated
Comment on lines +1951 to +1958
# Require more than one distinct model before concluding the hook is
# systemically silent, so a single unpriced model stays quiet.
if (
self._pricing_hook_silent_warned
or self._pricing_hook_priced_any
or len(self._pricing_hook_none_models) < 2
):
return

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

_pricing_hook_priced_any is read at the moment the second None arrives, so this decides the question before the answer exists. A hook that declines models A and B and then prices C has already warned, and nothing takes it back. Since _ensure_pricing_resolved locks per model rather than globally, arrival order under a parallel or for_each group varies, so the same workflow can warn on one run and stay quiet on the next.

Accumulating is fine; it's the verdict that's early. Consider keeping _pricing_hook_none_models and _pricing_hook_priced_any as pure bookkeeping and evaluating once where the usage summary is built, near line 6712.

That would also let you drop the two-model floor. Right now a single-model workflow can never warn no matter how dead the hook is, and 36 of the 40 shipped examples resolve to exactly one model. #386's own reproduction is a single-model run.

Comment threadsrc/conductor/engine/workflow.py Outdated
Comment on lines +1962 to +1965
"Provider pricing hook returned no pricing for any of %d models (%s); "
"live pricing is unavailable and costs fall back to the static table "
"or show as unpriced. This usually means the provider SDK no longer "
"publishes the pricing field the hook reads.",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two problems with the text, and one with where it goes.

The root-cause sentence is wrong (see the comment on line 420), and the engine can't know the cause in any case. On top of that, this fires on four of the five providers for behaviour providers/base.py:406 documents as correct, so "usually" points the wrong way: the common case is a provider that never implemented the hook.

"falls back to the static table or show as unpriced" also leaves readers unable to tell which happened to their run, and gives them nothing to do about it.

Separately, Conductor installs no logging handlers, so this reaches logging.lastResort as an unattributed stderr line, absent from the JSONL log and the dashboard. Under --web-bg it lands in a temp file nobody was told to read. executor/agent.py:597 hit the same wall and settled on log plus _emit, following checkpoint_save_failed. Worth emitting an event here too.

Suggested change
"Provider pricing hook returned no pricing for any of %d models (%s); "
"live pricing is unavailable and costs fall back to the static table "
"or show as unpriced. This usually means the provider SDK no longer "
"publishes the pricing field the hook reads.",
"Provider pricing hook returned no pricing for any of the %d models "
"resolved so far (%s). Costs for models in the static pricing table "
"are estimates from that table; models missing from it are reported "
"as unpriced. Set `cost.pricing` in the workflow to supply rates.",

Comment threadsrc/conductor/engine/workflow.py Outdated
)
else:
self.usage_tracker.set_provider_pricing(model, pricing)
self._note_pricing_hook_result(model, pricing)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The base get_model_pricing returns None for every model by design, and providers/base.py:406 says so: providers whose SDK exposes no pricing "should return None and let the static table handle it, which is exactly what this default does." Only CopilotProvider overrides it. So this call treats the documented, correct behaviour of claude, claude-agent-sdk, hermes and aca as evidence that their SDK broke.

I confirmed it with a provider inheriting the base hook and two models the static table prices correctly. The warning fires and the run is fully priced.

A pricing field on ProviderCapabilities would be the tidier long-term shape, matching how skills is declared, but the identity check below is enough to stop the false positive.

One more thing about this line: it sits in the else: rather than inside the try:, and _ensure_pricing_resolved promises never to raise. Nothing in the helper can raise today, so this is latent rather than live. It stops being latent the moment _emit goes in and puts subscriber code on this path.

Suggested change
self._note_pricing_hook_result(model, pricing)
iftype(provider).get_model_pricingisnotAgentProvider.get_model_pricing:
self._note_pricing_hook_result(model, pricing)

Comment on lines +7 to +9
That is not hypothetical. ``github-copilot-sdk`` 1.0.1 removed
``ModelBilling.token_prices``, which is the field ``CopilotProvider``'s hook
reads, so it resolves ``None`` for all 21 models it can list. Nothing raises,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same correction as workflow.py:420: the field is still on the wire and still modelled in copilot/generated/rpc.py. It's the client's hand-written dataclass that drops it.

The model count will go stale too, and nothing in the tests depends on it.

Suggested change
Thatisnothypothetical. ``github-copilot-sdk``1.0.1removed
``ModelBilling.token_prices``, whichisthefield``CopilotProvider``'shook
reads, soitresolves``None``forall21modelsitcanlist. Nothingraises,
Thatisnothypothetical. TheCopilotSDKclient'shand-written``ModelBilling``
discardsthe``tokenPrices``wirefield, so``CopilotProvider``'shookresolves
``None``foreverymodelitlists. Nothingraises,

Comment on lines +29 to +40
class _Recorder:
"""Minimal stand-in exposing just the latch state the helper touches."""

def __init__(self) -> None:
self._pricing_hook_none_models: set[str] = set()
self._pricing_hook_priced_any = False
self._pricing_hook_silent_warned = False

# Bind the real implementation so these tests exercise production code.
from conductor.engine.workflow import WorkflowEngine

_note_pricing_hook_result = WorkflowEngine._note_pricing_hook_result

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Binding the real method onto a stand-in that re-declares the real state means these tests pass whether or not the feature is wired up. I checked both mutations against this branch:

  • Delete the call site at workflow.py:2040, and the whole engine suite is still green (942 passed).
  • Delete the three __init__ fields at workflow.py:428-430, and all five tests here still pass, because _Recorder.__init__ supplies its own.

So the guard arithmetic is well covered and the wiring isn't covered at all. "Verified by neutering the warning: 2 of 5 fail" measures the first and is silent on the second, which is the one a reviewer cares about.

The sibling file already solved this. tests/test_engine/test_pricing_hook.py::test_systemic_hook_failure_warns_once tests the raise-path latch, the direct counterpart of this one, by building a real WorkflowEngine with CopilotProvider(mock_handler=...) and awaiting _ensure_pricing_resolved twice. Its _make_config and _priced_provider helpers are reusable, and the parallel-group configs there cover the concurrency case. I'd move these five into that file rather than keep a second file for the same method.

The three negative tests survive the move essentially unchanged. They're well chosen, and the docstrings explaining why each silence matters are the best thing in this PR.

For the false-positive case, reproduce the non-overriding providers faithfully rather than hand-rolling a None hook:

provider.get_model_pricing = AgentProvider.get_model_pricing.__get__(provider)

Addresses the review on microsoft#388.
Root cause corrected in all four places. `tokenPrices` is still on the wire;
the SDK did not remove it. In the pinned github-copilot-sdk 1.0.1 the
hand-written `client.ModelBilling` declares only `multiplier` and its
`from_dict` drops `tokenPrices`, so the hook resolves None for every model.
I pulled both wheels to check, which turned up something beyond the review:
1.0.9 declares `token_prices` and parses `tokenPrices`, so the field is
handled again upstream. The project floor is >=1.0.0 and uv.lock pins 1.0.1,
so microsoft#386 may reduce to a version bump. Noted on the issue rather than acted on
here.
The warning fired for behaviour four of the five providers implement
correctly. `AgentProvider.get_model_pricing` returns None by design and
providers/base.py documents that as right for a provider whose SDK exposes no
pricing; only Copilot overrides it. Tracking is now gated on an identity check
against the base implementation, so a provider inheriting the default is never
counted. Moved inside the `try:` as well -- it was in the `else:` on the
strength of "the helper cannot raise", which stops being true now that `_emit`
puts subscriber code on this path.
The verdict was also drawn too early. `_pricing_hook_priced_any` was read on
the second None, so a hook that declines two models and prices a third had
already warned, and nothing took it back. Because `_ensure_pricing_resolved`
locks per model, arrival order varies under parallel and for_each groups, so
the same workflow warned intermittently. `_note_pricing_hook_result` is now
pure bookkeeping and `_warn_if_pricing_hook_silent` draws the conclusion once,
where the usage summary is built.
That also removes the need for the two-model floor, which exempted exactly the
runs most likely to hit this: most shipped examples resolve a single model, and
microsoft#386's own reproduction is a single-model run.
The message no longer guesses at a cause the engine cannot know, and says which
of the two outcomes applies -- static-table estimate versus unpriced -- plus
what to do about it. It is emitted as a `pricing_hook_silent` event as well:
conductor installs no logging handlers, so a log line alone reaches
`logging.lastResort` as unattributed stderr, absent from the JSONL log and the
dashboard, and under --web-bg written to a temp file nobody was told to read.
The run summary gains `usage.live_pricing_degraded`, since a table-priced model
has a cost_usd and never enters `unpriced_models`, so the summary otherwise
prints a confident number with the caveat somewhere else entirely.
`object | None` collapses to `object` and typed nothing; it is `ModelPricing |
None` now. The 11 `ty` errors on the test file are gone -- it type-checks clean.
Tests rewritten for the deferred verdict, including the ordering case that an
early verdict gets wrong, the single-model case the floor used to exempt, the
event emission, and the base-vs-override identity check. 9 passed. CHANGELOG
entry added.
ruff check unchanged at 2 pre-existing findings, `ty check src` unchanged at
64, my files ruff-format and `ty` clean.
@franklixuefei

Copy link
Copy Markdown
MemberAuthor

Thanks — every one of these held up, and chasing the first one turned up something that changes the picture on #386. Addressed in 245c9d8.

The root cause, corrected — and it goes further than the review

You were right that the SDK did not remove the field, and I pulled both wheels to be sure rather than take either version on trust:

github-copilot-sdkclient.ModelBillingfrom_dict
1.0.1 (pinned by uv.lock, what CI runs)multiplier onlyreads multiplier, drops tokenPrices
1.0.9 (latest, satisfies the >=1.0.0 floor)multiplier+ token_pricesparses tokenPrices and keeps it

So the diagnosis is confirmed andit appears fixed upstream. pyproject.toml declares github-copilot-sdk>=1.0.0 and the lock pins 1.0.1, so #386 may reduce to a version bump — smaller even than the client-side workaround you suggested. I have not acted on it here (out of scope for this PR, and the bump wants its own verification against the live API), but it belongs on the issue.

Corrected in all four places you listed.

Firing at four of the five providers

Confirmed: AgentProvider.get_model_pricing returns None by design and providers/base.py documents that as correct for a provider whose SDK exposes no pricing. Only CopilotProvider overrides it. Tracking is now gated on the identity check you suggested.

Also moved it inside the try:. You flagged that as latent — it stops being latent in this very commit, since _emit now puts subscriber code on that path.

The verdict was early, and nondeterministic

_note_pricing_hook_result is pure bookkeeping now; _warn_if_pricing_hook_silent draws the conclusion once, where the usage summary is built. There is a test for exactly the case an early verdict gets wrong — decline, decline, price — which would have warned and never retracted.

Two-model floor dropped, for your reason: most shipped examples resolve one model and #386's own reproduction is single-model, so the floor exempted precisely the runs most likely to hit this. Running to completion having priced nothing is the evidence; a count threshold adds nothing.

Message, reach, and the summary

Rewrote it as suggested — no guess at a cause the engine cannot know, and it now distinguishes the two outcomes (static-table estimate vs unpriced) and says what to do.

On reach: took the _emit suggestion, following checkpoint_save_failed. A log line alone lands in logging.lastResort as unattributed stderr, missing from the JSONL log and the dashboard, and under --web-bg in a temp file nobody was told to read.

And the third point — usage.live_pricing_degraded is now threaded into the summary, since a table-priced model has a cost_usd and never enters unpriced_models, so the confident number and its caveat were in different places.

Smaller items

object | NoneModelPricing | None (it collapsed to object and typed nothing). The 11 ty errors on the test file are gone; it checks clean.

Verification

9 tests pass. One thing worth stating plainly: my first attempt at the identity check broke 228 testsAgentProvider is imported under TYPE_CHECKING only, so the comparison raised NameError at runtime on every priced model. Caught by running the full engine + CLI suites rather than just the new file; now a local import at the use site.

Final: 1,446 passed across tests/test_engine + tests/test_cli. Five failures remain, and I checked each against a reverted tree — four fail identically without my changes, and the fifth (test_timeout_context_raises_error) passes in isolation and under its own file, so it is load-sensitive. None are from this change.

ruff check unchanged at 2 pre-existing findings; ty check src unchanged at 64; my files ruff format and ty clean. CHANGELOG entry added.

@jrob5756Jason Robert (jrob5756) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Went through this against 245c9d8. The rework is real, and the design feedback landed properly: splitting bookkeeping from the verdict is the right shape, and dropping the two-model floor means single-model runs are covered now.

The 1.0.9 catch is the best thing in this update, and I checked it separately rather than take it on trust. In the 1.0.9 wheel client.ModelBilling declares token_prices and its from_dict reads tokenPrices, and the field names line up with what copilot.py pulls out via getattr. Lock pins 1.0.1. So #386 may well come down to a lockfile bump, which is worth saying on the issue itself.

Two things still need fixing before this goes in.

The first is a regression the refactor introduced. _warn_if_pricing_hook_silent() only has one caller, get_execution_summary(), and the CLI reaches that after the except BaseException: raise block, guarded by cost.show_summary. Three cases on this branch:

scenariowarning
run completes, summary built1
run completes, show_summary: false0
run fails part way through0

The old eager version caught all three. A long run that dies half way is when I'd most want to know the numbers came out of the static table, and it now says nothing at all there.

The second is that live_pricing_degraded gets written and nothing reads it. cli/run.py isn't in the diff, so display_usage_summary prints the same confident total it did before, which was the whole reason for adding the flag. The event has the same problem in a milder form. It does land in the JSONL log, because that subscriber writes whatever it's handed, but the console dispatch is an explicit elif t == ... chain with no branch for it. checkpoint_save_failed, which the comment names as the model, has one at cli/run.py:1050. The CHANGELOG's "reaches the event log and dashboard" is half right.

Then one repeat from last time, which I'd call a recommendation rather than a blocker: _Recorder is unchanged, and the refactor turned one untested wiring point into three. Details inline on the test file.

Everything else came back clean. 1475 passed, 4 skipped across test_engine and test_cli, ruff clean, ty check src clean.

# point at which the run has finished asking the hook, so "it priced
# nothing" is finally answerable. It also puts the caveat next to the
# cost total it qualifies.
self._warn_if_pricing_hook_silent()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is the only caller of the verdict, and it sits past the point where a failing run has already re-raised. cli/run.py calls get_execution_summary() after the except BaseException: raise block and only when cost.show_summary is on.

I ran the three cases on this branch with a hook that returns None for everything. A completed run with the summary built warns once. A completed run where the summary is never built stays quiet. A run that raises part way through stays quiet too. The version I reviewed last week caught all three, so this is a regression rather than a pre-existing gap.

The failing run is the one I care about most. That's when someone is squinting at cost numbers trying to work out what they spent before it died.

_pricing_hook_silent_warned already makes this idempotent, so calling it from the failure path as well is safe and needs no extra guard.

# never appears in ``unpriced_models``. Without this flag the
# summary prints a confident number sourced from a possibly stale
# table while the explanation goes only to stderr.
"live_pricing_degraded": self._pricing_hook_silent_warned,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nothing consumes this. cli/run.py isn't in the diff, so display_usage_summary still prints the total with no caveat attached, which is what the flag was for.

The event a few lines up has a softer version of the same problem. It reaches the JSONL log fine, since EventLogSubscriber writes every event it gets. But the console side is an explicit elif t == ... chain and there's no arm for pricing_hook_silent. checkpoint_save_failed, which the comment cites as the pattern being followed, has one at cli/run.py:1050.

Worth trimming the CHANGELOG line to match whatever you decide here. As it stands it claims the dashboard, and that isn't wired.

AgentProvider as _AgentProviderBase,
)

if type(provider).get_model_pricing is not _AgentProviderBase.get_model_pricing:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The gate is correct. I checked it against the real classes rather than a stand-in: ClaudeProvider, HermesProvider, AcaRuntimeProvider and ClaudeAgentSdkProvider all inherit the base and get skipped, and Copilot is the only one tracked. That closes the false positive properly.

It just isn't covered. I swapped this whole condition for an unconditional call and both pricing test files stayed green at 16 passed.

TestBaseHookProvidersAreNotAccused reads like the test for it, but what it actually asserts is _Inherits.get_model_pricing is AgentProvider.get_model_pricing on two throwaway subclasses. That's Python attribute lookup, which will hold whether or not this line exists.

Comment on lines +40 to +56
class _Recorder:
"""Minimal stand-in exposing just the latch state the helpers touch."""

def __init__(self) -> None:
self._pricing_hook_none_models: set[str] = set()
self._pricing_hook_priced_any = False
self._pricing_hook_silent_warned = False
self.events: list[tuple[str, dict]] = []

def _emit(self, event_type: str, data: dict) -> None:
self.events.append((event_type, data))

# Bind the real implementations so these tests exercise production code.
from conductor.engine.workflow import WorkflowEngine

_note_pricing_hook_result = WorkflowEngine._note_pricing_hook_result
_warn_if_pricing_hook_silent = WorkflowEngine._warn_if_pricing_hook_silent

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Still the stand-in, and there are three wiring points now where there was one. Mutations on this branch, running both pricing test files together:

mutationresult
drop the provider gate16 passed
drop the _warn_if_pricing_hook_silent() call16 passed
drop the _note_pricing_hook_result() call16 passed
drop live_pricing_degraded16 passed
drop the _emit1 failed

Only the _emit mutation dies, and it dies because _Recorder supplies its own _emit. The rest are invisible for the reason they were before: _Recorder.__init__ declares the state itself, and nothing here calls the resolver or the summary.

Both of the blocking items I raised live in that blind spot, which is the part that bothers me. The failure-path gap and the unread flag are exactly the kind of thing an engine-level test catches for free.

test_pricing_hook.py already has the scaffolding. It builds a real engine, awaits _ensure_pricing_resolved, and calls get_execution_summary() in six of its tests, so both new seams are reachable there without writing anything new.

…ummary is asked for
Deferring the verdict to \get_execution_summary()\ was a regression. The CLI
calls it after its \�xcept BaseException: raise\ block and only when
\cost.show_summary\ is set, so of the three cases the eager version covered,
the refactor left one:
| scenario | before | after |
|---------------------------------|--------|-------|
| run completes, summary built | warns | warns |
| run completes, show_summary off | warns | quiet |
| run dies part way | warns | quiet |
The failing run is the one that matters most -- that is when someone is
looking at a partial cost total trying to work out what it cost before it
broke, and it is exactly where a summary-time call can never reach.
Draw it in a \inally\ around the execution loop in \
un()\ and \
esume()\,
so it fires however the run ends. The verdict is idempotent, so the
summary-time call stays as a safety net for callers that drive the engine
without those entry points.
Wire up \live_pricing_degraded\, which was written and read by nothing:
\display_usage_summary\ now prints a caveat under the total, and the console
event chain gains the \pricing_hook_silent\ arm it was missing (the JSONL
log already had it, being type-agnostic). CHANGELOG corrected -- it claimed
the dashboard, which is not wired.
Tests now go through \WorkflowEngine.run\ rather than a stand-in that
declares the latch state itself. The stand-in left four of the five wiring
points untestable; the mutation matrix is now 6 of 6 caught, was 1 of 5.
\	est_event_ordering\ gains \pricing_hook_silent\: a mock handler bypasses
the SDK, so the hook is asked and prices nothing, which is the condition the
verdict reports. It cannot be suppressed for mock runs without making test
and production behaviour diverge -- a real SDK that prices nothing (microsoft#386)
looks identical at this layer.
@franklixuefei

Copy link
Copy Markdown
MemberAuthor

You are right on both, and the first one is a regression I introduced while addressing your last round. Fixed in 99dcb4a.

The verdict was attached to the wrong thing

I moved it to get_execution_summary() because that is where "it priced nothing" becomes answerable. But that is a reporting call site, and the CLI reaches it after except BaseException: raise and only under cost.show_summary. So the verdict inherited the reachability of a summary rather than of a run. Your table is exactly right.

The fix is to hang it off the run ending rather than off anyone asking:

try:
result=awaitself._execute_loop(current_agent_name)
finally:
self._warn_if_pricing_hook_silent()

in both run() and resume(). The verdict is already idempotent, so the summary-time call stays as a safety net for callers that drive the engine without those entry points.

One thing this surfaced that I did not expect: test_event_ordering started failing, because a mock handler bypasses the SDK, so the hook is asked and returns None for everything -- which is the condition the verdict reports. I added the event to that test's expected sequence rather than suppressing it for mock runs, because suppressing it would need test and production to diverge, and a real SDK that prices nothing (#386) is indistinguishable at that layer. Worth flagging in case you would rather it went the other way.

live_pricing_degraded

Also correct -- written and read by nothing. display_usage_summary now prints a caveat under the total, and the console dispatch gains the pricing_hook_silent arm it was missing; the JSONL side was already fine since that subscriber is type-agnostic. CHANGELOG corrected: it claimed the dashboard, which is not wired, and I have narrowed it to what I actually verified.

The tests

Your point about _Recorder was the right one to keep pressing. Tests now go through WorkflowEngine.run with a provider that overrides the hook and prices nothing. Same mutation matrix you ran:

mutationbeforenow
drop the provider gate16 passedcaught
drop the _warn_if_pricing_hook_silent() call16 passedcaught
drop the _note_pricing_hook_result() call16 passedcaught
drop live_pricing_degraded16 passedcaught
drop the display caveatn/acaught
drop the _emitcaughtcaught

The base-hook gate is now covered by a provider that reaches the engine and returns None from the base implementation, rather than by asserting is on two throwaway subclasses -- you were right that the old assertion only tested Python's attribute lookup.

One detail worth recording: my first attempt at the failing-run test had the first agent raise, and it correctly stayed quiet -- a run that dies before anything asked the hook has nothing to conclude. It needs a run that dies half way, which is your scenario anyway.

On #386

Agreed, and I will say it on the issue. One extra data point for whoever picks it up: I had 1.0.9 installed locally against a lock pinning 1.0.1, and ty check src reported approve_all receiving dict[str, str] where PermissionInvocation is expected. So the bump may not be a pure lockfile change -- copilot.py looks like it needs a look at the same time.

1479 passed across tests/test_engine and tests/test_cli, ruff and format clean.

xuefl-msftand others added 3 commits August 11, 2026 10:43
Two things left over from review, plus one the merge with main surfaced.
resume() carries its own _warn_if_pricing_hook_silent() call, separate from
run()'s, and nothing exercised it -- reverting that line kept the whole suite
green. A resumed run is still a run that priced nothing, and AGENTS.md treats
run/resume as a parity pair, so the gap was free to widen. The new test drives
engine.resume() with a restored context and limits, following the pattern in
test_resume.py, and it kills that mutation and only that mutation.
The markup failure is drift rather than an authoring mistake: main gained
test_markup_guards.py (microsoft#406) after this branch last merged, and the new
live_pricing_degraded caveat interpolates nothing, so it wants
Text.from_markup() like its neighbours in display_usage_summary rather than a
bare markup string. Verified the rendered output is unchanged and no markup
leaks.
Deliberately left alone: pricing_hook_silent is absent from
_REPLAY_ROOT_SKIP_TYPES while checkpoint_save_failed, the event it is modelled
on, is in it -- so a resumed dashboard replays a stale pricing warning from the
previous attempt. It is cosmetic (no frontend handler, no latch) and fixing it
would pull web/server.py into a PR that does not otherwise touch it.
5952 passed, ruff and ty clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jrob5756

Copy link
Copy Markdown
Collaborator

Pushed three things to your branch (7c6c6d0) rather than send you round again for them. All small, and all verified before pushing.

CHANGELOG conflict. Main had moved and both sides appended to ### Fixed. Kept both, yours first, no wording touched.

The resume() verdict test. This was the one gap left from my last pass: run() and resume() carry separate _warn_if_pricing_hook_silent() calls, and reverting the resume() one kept the suite green. The new test restores a context and limits the way test_resume.py does, then drives engine.resume("agent2"). I checked it kills that specific mutation and nothing else moves.

A markup guard you couldn't have known about.test_markup_guards.py landed on main (#406) after your last merge, and it fails on the new live_pricing_degraded caveat: nothing is interpolated into it, so it wants Text.from_markup() like its neighbours in display_usage_summary rather than a bare markup string. Pure drift, not an authoring slip. I rendered the summary before and after to confirm the output is identical and nothing leaks.

One thing I deliberately left for you to decide. pricing_hook_silent isn't in _REPLAY_ROOT_SKIP_TYPES, while checkpoint_save_failed (the event your comment names as the model) is. So a resumed dashboard replays a stale pricing warning from the previous attempt, even if the resumed run prices fine. It's cosmetic today, since there's no frontend handler and it sets no latch, and fixing it drags web/server.py into a PR that doesn't otherwise touch it. Your call whether it belongs here or in a follow-up.

On your mock-handler question: I think you called it right. mock_handler appears nowhere in factory.py or the CLI, so it's test-only and there's no user-facing noise. Suppressing it would mean production and test taking different routes through the exact code under test, which is a worse trade than one extra line in an expected sequence.

Everything else held up under re-checking. The failure-path verdict fires (I ran a workflow that dies after pricing resolves), live_pricing_degraded reaches the printed summary, and the mutation matrix went from 1 of 5 caught to 7 of 7 with the resume test added. Full suite 5952 passed, ruff and ty clean, and CI is green across the board.

Nice work on the 1.0.9 dig, and the approve_all / PermissionInvocation note is a useful heads-up for whoever takes #386.

@jrob5756Jason Robert (jrob5756) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. Approved!

@jrob5756
Jason Robert (jrob5756) merged commit bf0a8d8 into microsoft:mainAug 11, 2026
9 checks passed
Jason Robert (jrob5756) pushed a commit that referenced this pull request Aug 11, 2026
Resolves a CHANGELOG.md ordering conflict with #414/#413/#388, which
merged into main while this PR's review was in progress. No other
files conflicted; workflow.py's auto-merge is clean (this branch's
_context_window_anomaly_warned latch and main's new
_pricing_hook_silent_warned latch are independent additions to the
same __init__).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for freeto 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.

4 participants

@franklixuefei@codecov-commenter@jrob5756@xuefl-msft