Skip to content

fix(usai-catalog): refuse a near-miss instead of enriching from the wrong model - #484

Merged
mogul merged 8 commits into
mainfrom
fix/387-matcher-precision-guard
Oct 21, 2026
Merged

mogul merged 8 commits into
mainfrom
fix/387-matcher-precision-guard

Conversation

@wz-gsa

@wz-gsa wz-gsa commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #387.

The defect

integrations/providers/usai/scripts/build-catalog.mjs's models.dev matcher scored candidates but never rejected one. A gateway model absent from the enrichment source was enriched with the nearest same-family model's data.

Reproduced offline against the committed fixtures (--models-url tests/fixtures/models-list.json --models-dev tests/fixtures/models-dev.json):

findModelsDevMatch("claude_4_5_haiku") -> anthropic.claude-opus-5

                   ctx       out      cost in/out
  reviewed/shipped 200000    64000    1 / 5
  enriched         1000000   128000   5 / 25        <- Opus's numbers

A 5x price overstatement, presented as fact. The fixture contains no haiku entry at all.

Scoring alone cannot fix this — it ranks whatever field it is handed, and the best of a bad field still wins. There was no floor, so ids that exist nowhere in the source still matched:

claude_9_9_nonexistent -> anthropic.claude-opus-5
claude_totally_made_up -> anthropic.claude-opus-5
llama_99_fake          -> meta.llama4-maverick-17b-instruct-v1:0

Mechanism: a variant match scored +50, but a variant mismatch scored nothing. Only lite carried a mismatch penalty. Base score 10 survived every disagreement.

This is worse than missing data: the fields come out populated and plausible, so a completeness check ("every model has a price") passes while the values are wrong.

The fix

An eligibility guard ahead of the score (isPreciseMatch). A candidate must agree on all of:

Axis Because
family claude is not gpt
tier / variant haiku is not opus is not sonnet; flash is not pro
major.minor 4.5 is not 5, and is not 4.8
lite flash-lite is not flash

No match now means the model is emitted visibly unenriched rather than silently mis-enriched.

The scoring terms the guard subsumes are removed — a hard tier check plus a -20 penalty for pro would be two mechanisms for one rule. What remains discriminates only between duplicate listings of the same model: dated SKUs, us./eu. regional prefixes, modality siblings (-tts, -vision). lite detection moves from a substring test to a token test.

The verified normalizations are unchanged: gateway deployment suffixes still strip (gpt_5_5_default_v2 matches gpt-5.5) and the llama4 -> llama-4 glue-split still matches the Bedrock form.

Unenrichment is reported, not silent

The issue asked for absent fields. Limits cannot be absent here — shapeFromFeeds applies FALLBACK_LIMITS (128000/8192), and making them absent would make the opencode emitter write literal undefined into the JSON (a separate defect, not fixed in this PR). Cost has no fallback, so it genuinely goes absent.

So the fallback is made loud instead:

UNENRICHED (4): no models.dev entry agreed on family, tier and major.minor;
limits fall back to 128000/8192 and cost is ABSENT:
  claude_4_5_haiku
  claude_4_5_sonnet
  claude_4_7_opus
  claude_4_8_opus

Suppressed under --no-enrichment, so "did not look" cannot masquerade as "looked and failed".

Note claude_4_7_opus / claude_4_8_opus: these previously matched claude-opus-5 — a different model — and their numbers merely coincided with the reviewed values. The fixture has no 4.7/4.8 entry. They were right by luck, and now report honestly.

Tests

23 new hermetic tests in tests/matcher-precision.test.mjs, covering the four negative classes the issue names plus the positive controls it requires.

Fixtures are inline, not the committed models-dev.json subset — a future refresh of that subset must not be able to quietly turn a negative case positive.

Proved failable: neutering the guard with an early return true fails 11 of 23, while all 12 positive controls stay green. The tests exercise the guard rather than merely passing alongside it.

Verification

providers/usai      63/63  (was 40)
usai-provider kit   59/59
paseo               13/13
make validate       clean
make validate-kits  clean (10 kits)

catalog.json is unchanged — it is bootstrapped from the shipped opencode.jsonc, and --bootstrap output is byte-identical to the committed file. The byte-exact round-trip guards for both shipped opencode.jsonc copies still pass.

This is therefore a latent fix: it changes nothing shipped today, and makes the next live regeneration trustworthy. That is the condition the issue describes as the reason the guard exists.


AI-assisted (OpenCode). Advisory — requires human review and sign-off.

…rong model

The models.dev enrichment matcher scored candidates but never rejected one, so
a gateway model absent from the enrichment source was enriched with the nearest
same-family model's data. With only `claude-opus-5` / `claude-sonnet-5` present
under the Anthropic provider, `claude_4_5_haiku` came out with Opus numbers:
context 1,000,000 and $5/$25 input/output, where the reviewed values are
200,000 and $1/$5. A 5x price overstatement, presented as fact.

Mis-enrichment is worse than missing data. The fields come out populated and
plausible, so a completeness check ("every model has a price") passes while the
values are wrong.

Scoring alone cannot fix this: it ranks whatever field it is handed, and the
best of a bad field still wins. Invented ids that exist nowhere in the source
(`claude_9_9_nonexistent`, `llama_99_fake`) all matched something.

Add an eligibility guard ahead of the score. A candidate must agree on family,
tier (haiku is not opus, flash is not pro), major.minor version (4.5 is not 5)
and the `lite` qualifier, or it is not considered at all. No match now means the
model is emitted visibly unenriched rather than silently mis-enriched.

The scoring terms the guard subsumes are removed; what remains discriminates
only between duplicate listings of the SAME model (dated SKUs, regional
prefixes, modality siblings). `lite` detection moves from a substring test to a
token test. The verified normalizations are unchanged: gateway deployment
suffixes still strip (`gpt_5_5_default_v2` matches `gpt-5.5`) and the
`llama4`->`llama-4` glue-split still matches the Bedrock form.

A model that could not be enriched is now reported by id on stderr, with the
fallback limits named, so "could not look it up" is distinguishable from
"looked it up" instead of both rendering as a complete-looking entry.

Adds 23 hermetic tests with inline fixtures, so a refresh of the committed
models-dev subset cannot quietly turn a negative case positive. Proved failable:
neutering the guard fails 11 of them. The committed catalog.json is unchanged
(bootstrap output is byte-identical), and the byte-exact round-trip guards for
both shipped opencode.jsonc copies still pass.

Verified: 63/63 providers/usai, 59/59 usai-provider kit, 13/13 paseo,
make validate and make validate-kits clean.

Co-authored-by: OpenCode Agent <[email protected]>
@wz-gsa
wz-gsa requested a review from a team as a code owner October 7, 2026 21:14
wz-gsa added 3 commits October 8, 2026 18:16
Reject base/codex/max near-matches before scoring and report unmatched pricing separately from actual feed-limit fallbacks.

Co-authored-by: OpenCode Agent <[email protected]>
…sion-guard

# Conflicts:
#	integrations/providers/usai/tests/feed-fixture.test.mjs

@basiliskus basiliskus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: request changes — e1346ad

The precision guard still treats unknown same-version qualifiers as the base model. isPreciseMatch compares only the hard-coded codex, mini, nano, and max tokens, so I reproduced gpt-5.2-luna -> gpt-5.2, claude-opus-5-thinking -> claude-opus-5, and gemini-2.5-flash-preview -> gemini-2.5-flash. A future qualified gateway ID can therefore receive a sibling's limits and pricing, recreating the defect this PR is meant to close. Please make identity comparison fail closed for unrecognized tokens (while explicitly normalizing known provider/region/date/deployment noise) and add a regression test for an unknown same-version qualifier.

Assisted-by: OpenCode [gpt-5.6-sol-latest-guardrails-defaultv2] skill:change-review

The precision guard compared a hard-coded qualifier list (`codex`,
`mini`, `nano`, `max`), so any qualifier outside that list was invisible
to it and a qualified gateway id collapsed onto its base model,
inheriting that sibling's limits and pricing.

Reproduced against the guard as it stood:

    gpt-5.2-luna              vs gpt-5.2             -> MATCH
    claude-opus-5-thinking    vs claude-opus-5       -> MATCH
    gemini-2.5-flash-preview  vs gemini-2.5-flash    -> MATCH

That is the same mis-enrichment defect this guard exists to prevent, for
every qualifier the list did not happen to name.

Enumerating qualifiers cannot close this: the set is open and owned by
the vendors, so the list is always one release behind. Invert it instead.
`identityResidue` collects every token that is NOT a family, variant,
version, or known packaging token, and a candidate must agree on that
whole remainder. Unknown input therefore fails closed by construction
rather than by enumeration.

Enumerating NOISE is safe in a way enumerating qualifiers is not: the
noise set is small, observable in the catalogs actually read (provider
prefixes, region scopes, marketplace revisions, parameter counts, tuning
descriptors), and being wrong about it costs only a missed enrichment,
which is already reported as unenriched rather than silently wrong.

FAMILY_TOKEN and VARIANT_TOKEN are hoisted so the tokenizer and the
residue cannot drift apart — a token consumed as a family or variant must
not also count as identity-bearing remainder.

Adds five regression tests using deliberately INVENTED qualifiers
(`luna`, `thinking`, `preview`, `solstice`). A test using a real
qualifier would start passing the moment someone adds that token to a
list, which is the failure mode being guarded against; an invented token
can only pass if identity comparison is genuinely closed over unknown
input. They cover both directions (qualified->base and base->qualified),
two different unknown qualifiers against each other, and that failing
closed does not mean failing always: a qualified id still matches its own
exact entry.

Verified the new tests fail against the previous guard (5 failed, 24
passed) and pass with this change (29 passed), so they are regression
tests and not restatements. Full providers/usai suite 86 passed across 8
files, `build-catalog.mjs --check` reports catalog.json up to date (20
models), and enrichment of the committed fixtures is unchanged — the same
4 models were already unenriched before this change, so no real model
loses data.

Co-authored-by: OpenCode Agent <[email protected]>
@wz-gsa

wz-gsa commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

AI-assisted follow-up (OpenCode), advisory — needs a human to confirm before it drives a change. Pushed 3cf43f0.

@basiliskus's finding was correct, and I reproduced it before changing anything. Extracted the guard from this PR's build-catalog.mjs at head e1346addd and drove it directly:

gpt-5.2-luna                 vs gpt-5.2              -> MATCH
claude-opus-5-thinking       vs claude-opus-5        -> MATCH
gemini-2.5-flash-preview     vs gemini-2.5-flash     -> MATCH
claude-opus-5-20260101       vs claude-opus-5        -> reject   (control)
gpt-5.2-mini                 vs gpt-5.2              -> reject   (control)
gemini-2.5-flash-lite        vs gemini-2.5-flash     -> reject   (control)

All three reported false matches reproduce. The controls confirm the guard was not broken generally — it failed open only for tokens absent from its hardcoded list, which is precisely the diagnosis in the review.


The fix inverts the comparison instead of extending the list

I did not add luna/thinking/preview to PROFILE_TOKENS. That would fix these three strings and leave the method intact, and the method is the defect: the qualifier set is open and owned by the vendors, so any allowlist is permanently one release behind. A fourth qualifier would have arrived and the same comment would have been written again.

identityResidue collects every token that is not a family, variant, version, or known packaging token, and a candidate must agree on that whole remainder:

function identityResidue(normalizedId) {
  const residue = new Set()
  for (const part of normalizedId.split(/[.\-/:]/)) {
    if (!part) continue
    if (FAMILY_TOKEN.test(part) || VARIANT_TOKEN.test(part)) continue
    if (/^\d+$/.test(part)) continue
    if (IDENTITY_NOISE.some((re) => re.test(part))) continue
    residue.add(part)
  }
  return residue
}

Unknown input now fails closed by construction rather than by enumeration.

Why enumerating noise is safe where enumerating qualifiers was not. The noise set is small, observable in the catalogs actually read (provider prefixes, region scopes, marketplace revisions like v1:0, parameter counts like 17b, tuning descriptors like instruct), and asymmetric in consequence: being wrong about a noise token costs a missed enrichment, which the pipeline already reports as unenriched. Being wrong about a qualifier cost a silent mis-enrichment. The two error directions are not equally bad, and the guard should fail in the cheap one.

I also hoisted FAMILY_TOKEN / VARIANT_TOKEN to module scope so the tokenizer and the residue cannot drift apart — a token consumed as a family or variant must not also count as identity-bearing remainder. They were previously inline literals in extractModelTokens.

Tests use invented qualifiers on purpose

Five new tests, with deliberately fictional tokens (luna, thinking, preview, solstice):

an unrecognized qualifier does not match the bare base model
a bare base model does not match an unrecognized-qualifier entry
two different unrecognized qualifiers do not match each other
an unrecognized qualifier still matches its own exact entry
an unrecognized qualifier is not rescued by a provider prefix or region

A test using a real qualifier would start passing the moment someone adds that token to a list — which is the failure mode being guarded against. An invented token can only pass if identity comparison is genuinely closed over unknown input.

The review asked for coverage of "an unknown same-version qualifier"; I added the reverse direction too, since a request for the base model is the more common case and a base→qualified match is equally damaging. And one positive control, because failing closed must not mean failing always: a qualified id still matches its own exact entry.

Verification

Confirmed these are regression tests, not restatements — the new tests against the previous guard:

not ok 13 - an unrecognized qualifier does not match the bare base model
not ok 14 - a bare base model does not match an unrecognized-qualifier entry
not ok 15 - two different unrecognized qualifiers do not match each other
not ok 16 - an unrecognized qualifier still matches its own exact entry
not ok 17 - an unrecognized qualifier is not rescued by a provider prefix or region
# tests 29  # pass 24  # fail 5

With the fix, in a network-off container:

catalog-check.test.mjs       # pass 9  # fail 0
catalog-validation.test.mjs  # pass 10 # fail 0
emitters.test.mjs            # pass 6  # fail 0
feed-fixture.test.mjs        # pass 9  # fail 0
matcher-precision.test.mjs   # pass 29 # fail 0
models-json-regen.test.mjs   # pass 3  # fail 0
non-chat-filter.test.mjs     # pass 10 # fail 0
prime-agent.test.mjs         # pass 10 # fail 0

node scripts/build-catalog.mjs --check  ->  catalog.json is up to date (20 models)
python scripts/validate_repo.py         ->  All validators passed

No real model loses data. I ran shapeFromFeeds over the committed fixtures before and after: the same 4 models (claude_4_5_haiku, claude_4_5_sonnet, claude_4_7_opus, claude_4_8_opus) were already unenriched under the old guard and still are. The stricter comparison rejected nothing that was previously enriched.

One thing I want to flag myself

IDENTITY_NOISE is still a list, so it has the same structural weakness as the one I removed — just pointed in the safe direction. If a vendor ships a genuinely new packaging token, that model goes unenriched until the list learns it. That is a visible, reported outcome rather than a wrong number, which is why I accepted it, but it is a maintenance surface and worth naming rather than leaving for the next reviewer to find.

Also: gemini-2.5-flash vs gemini-2.5-flash-tts is now rejected by the guard rather than losing on score (-60). The existing ranking test still passes because the chat model is present in that fixture, but the modality-suffix scoring branch is now partly unreachable. I left it in place as defense-in-depth rather than widening this diff further; say the word if you would rather it be removed.

@wz-gsa

wz-gsa commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Branch updated to current main (2a0816a) so the review path is clear — the previous head was 1 commit behind, which showed as BEHIND on the merge state.

The merge was clean. Worth noting why that wasn't a given: 800ba7e ("serialize feed data before config emission") touches the same integrations/providers/usai/scripts/build-catalog.mjs this PR modifies, but it was already an ancestor of this branch, so the only incoming change was CHANGELOG.md from the 1.13.0 release commit.

Re-ran the full suite after merging rather than assuming it still held:

providers/usai tests                    ->  86 passed, 0 failed
node scripts/build-catalog.mjs --check  ->  catalog.json is up to date (20 models)
CI                                      ->  7/7 SUCCESS

No code changed in this push — it is a merge commit only. The fix itself is still 3cf43f0, described in my previous comment.

@basiliskus this is ready for another look when you have time. mergeStateStatus is now BLOCKED purely on the missing approval, not on a failing check or a stale branch.

One process note: the repository ruleset has dismiss_stale_reviews_on_push: true, so an approval gets dismissed by any subsequent push. I'm not planning further pushes here, but if you approve and then ask for a change, the approval will need re-granting after I push the fix.

AI-assisted (OpenCode), advisory — a human should confirm before this merges.

@basiliskus basiliskus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: request changes — 2a0816a

The prior unknown-qualifier issue is resolved, including class-level regression coverage. One over-refusal remains at integrations/providers/usai/scripts/build-catalog.mjs:253-299,318-339: known date and regional packaging noise can now prevent enrichment. I reproduced gpt-4o -> gpt-4o-2024-08-06 and claude-opus-5 -> eu.anthropic.claude-opus-5 returning no match, while the comparable provider, US-region, Anthropic-date, and gateway-deployment cases still match. Please preserve these legitimate normalizations without weakening the fail-closed behavior for unknown identity tokens, and add positive controls for both forms.

Assisted-by: OpenCode [gpt-5.6-sol-latest-guardrails-defaultv2] skill:change-review

Model valid terminal release dates separately from semantic major/minor
versions. Dated gateway IDs now require the same snapshot, while undated
aliases prefer an undated entry or deterministically choose the latest valid
dated snapshot.

Treat provider/region names as packaging only in leading prefix positions and
marketplace revisions only in the terminal position. Keep malformed dates,
extra numeric qualifiers, duplicate structural tokens, modality markers, and
unknown qualifiers in ordered identity residue so near-misses fail closed.
Compare numeric identity as strings to avoid Number precision collapse.

Add positive, negative, calendar, insertion-order, provider, Bedrock, Cohere,
and precision regressions.

Co-authored-by: OpenCode Agent <[email protected]>
@wz-gsa

wz-gsa commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

AI-assisted follow-up (OpenCode). Pushed 933169d9cf73e4ea05b7f68881a312964c87b06f and addressed the current-head date/region over-refusal finding.

The matcher now:

  • models valid terminal basic/extended ISO dates separately from semantic major/minor identity;
  • requires exact dates for dated gateway IDs;
  • prefers an undated identity regardless of catalog order, or deterministically selects the latest valid dated alias;
  • treats provider/region text as noise only in leading prefixes and marketplace revisions only terminally;
  • keeps malformed dates, extra/large numeric qualifiers, duplicate structural tokens, unknown qualifiers, and modality markers as ordered identity residue;
  • compares numeric identity as strings, avoiding Number precision collisions.

Verification at the committed head:

matcher-precision.test.mjs: 43 passed, 0 failed
providers/usai full suite: 100 passed, 0 failed
pre-commit hooks: PASS
git diff --check: PASS
independent fixed-head QA/security: no findings

Please re-review exact head 933169d9cf73e4ea05b7f68881a312964c87b06f.

@basiliskus basiliskus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: approve — 933169d

The prior date/region over-refusal is resolved. I reproduced both formerly failing matches (gpt-4o to a dated alias and claude-opus-5 to a sole EU-prefixed entry), and the added controls preserve those normalizations while retaining exact-date and unknown-qualifier rejection. I found no new issues in the delta since 2a0816a6.

Assisted-by: OpenCode [gpt-5.6-sol-latest-guardrails-defaultv2] skill:change-review

@basiliskus
basiliskus self-requested a review October 20, 2026 14:44
@mogul
mogul enabled auto-merge (squash) October 21, 2026 06:28
@mogul
mogul merged commit c3a6dac into main Oct 21, 2026
6 of 7 checks passed
@mogul
mogul deleted the fix/387-matcher-precision-guard branch October 21, 2026 06:29
mogul pushed a commit that referenced this pull request Oct 21, 2026
🤖 I have created a release *beep* *boop*
---


##
[1.15.0](v1.14.0...v1.15.0)
(2026-10-21)


### Features

* **acq-kits:** add OpenDesign browser kit
([#438](#438))
([83e202f](83e202f))
* **isolation:** define provider-neutral input contract
([#518](#518))
([28a8ebd](28a8ebd))
* **usai-catalog:** refresh models and withhold undeployed gateway
entries
([#519](#519))
([03421e0](03421e0))


### Bug Fixes

* **usai-catalog:** refuse a near-miss instead of enriching from the
wrong model
([#484](#484))
([c3a6dac](c3a6dac))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
mogul pushed a commit to GSA-TTS/agentic-coding-quickstart that referenced this pull request Oct 21, 2026
## Summary

Moves `PATTERNS_KIT_REF` from v1.12.0 (`56d1f5f`) to
agentic-coding-patterns v1.15.0 (`f3c6acb`), so the built-in kit bundle
picks up:

- **opencode kit → OpenCode 2.0.30**
(GSA-TTS/agentic-coding-patterns#499). OpenCode 2.0.0–2.0.29 ignore
config `instructions` (anomalyco/opencode#51341, fixed in 2.0.30), so a
sandbox on an image without a baked OpenCode (`devenv-shell`, or the
`devenv-shell` default proposed in #584) currently runs the bundled
2.0.22 and silently drops `instructions`-based conventions.
- **paseo kit → Paseo 0.12.0** (GSA-TTS/agentic-coding-patterns#504):
fixes for OpenCode v2 under Paseo (daemon OOM on long v2 streams,
multi-select answers, HTTP 500s instead of tool errors) and the
host-sleep daemon-status fix.
- **Kit verify scripts gain a "could not check" verdict**
(GSA-TTS/agentic-coding-patterns#460); it only affects kit verify
output.

Also v1.13.0: usai-provider's host-side catalog normalizer (#436), the
paseo identity-gate script (#453), and the team-kit and personal-kit
templates under `acq-kits/examples/`. None changes what acq applies by
default beyond the two kits above.

Also v1.15.0: refreshed usai-provider model catalog
(GSA-TTS/agentic-coding-patterns#519,
GSA-TTS/agentic-coding-patterns#484), the OpenDesign opt-in kit
(GSA-TTS/agentic-coding-patterns#438), and the provider-neutral input
contract (GSA-TTS/agentic-coding-patterns#518). acq's provider-facts
loader reads `provider-facts/usai.env`, which v1.15.0 does not change.

The doc examples and Bats assertions that spell the pin move with it.
KNOWN_FAILURE_MODES §9 now names 2.0.30 as the version the kit installs
and notes its 15-minute default `chunkTimeout` (the timeout message and
retry limits are unchanged from 2.0.22).

## Verification

On the v1.15.0 pin:

- Focused Bats: `135-configure`, `95-kit-ref-persistence`,
`36-agent-catalog`, `50-kit-list-completeness` (63/63)
- markdownlint on the changed docs

On the earlier v1.14.0 pin (not re-run for v1.15.0):

- `npm run lint:md`
- `RUN_ACQ=1 scripts/verify-backends --only msb` on macOS, msb 0.7.8: 16
passed, 1 skipped (playbook fetch, no `gh` login), run with `TMPDIR` set
to its resolved `/private/var/...` path to get past an unrelated create
failure that also hits `main` (#594)
- Downstream, with this pin on msb 0.7.8 and
`ACQ_IMAGE=devenv-shell:1.0.0`: a team kit's end-to-end verify passed
71/71 (OpenCode 2.0.30 runs and loads `instructions`); with the paseo
kit added, Paseo 0.12.0 drove an OpenCode 2.0.30 agent that received the
team `instructions`, and its sessions survived a backup, `acq rm`,
recreate and restore

## Rollback

Revert this PR to restore the v1.12.0 bundle.
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.

build-catalog: fuzzy matcher can enrich a model with a DIFFERENT model's pricing

3 participants