chore(drift-sync): needs-human model-family decision (2026-07-24)#337
Conversation
commit: |
… upstream, still mocked) Both families are gone from Gemini's live /models listing but are still legitimately referenced in aimock source, so the deprecation canary kept routing them to needs-human every run (see the two notes under drift-proposals/). The human decision is: keep mocking them. Move them out of includeFamilies.gemini into excludeFamilies.gemini's "Retired / legacy specialty" bucket, the established pattern for a retired-but-still-mocked family (gemini-pro, gpt-3.5, gemini-2.0-flash-exp; prior art in #315). excludeFamilies means "not counted as text-generation drift", not "not mocked" — every fixture, capability test and the isReasoningModel() === false verdict for gemini-1.5-* is untouched. Re-pin the two affected DATA_FROZEN membership hashes accordingly. Side effect: gemini's truncated-listing trust floor drops from 11 to 9 (floor = includeFamilies[provider].size).
jpr5
left a comment
There was a problem hiding this comment.
Human decision recorded: KEEP mocking gemini-1.5-pro / gemini-1.5-flash.
Both are gone from Gemini's live /models listing but remain legitimately referenced in aimock (notably NONREASONING_FAMILIES in src/model-utils.ts, plus the capability suites that use them as the non-reasoning exemplar). Users pinned to older SDKs still hit these ids, so removing the mocks would be destructive. Classifying them under excludeFamilies is the established pattern here (gemini-pro, gpt-3.5, gemini-2.0-flash-exp; prior art #315/#322).
Verified before approving:
- Zero behavior change, three independent ways: no shipped-source importer of model-registry.ts (only 5 tests + scripts/drift-sync.ts); pre/post dist semantically identical across all 285 shipped files; built-artifact probe over both ids (generateContent, SSE, strict on/off, /v1/chat/completions, /v1/models, isReasoningModel) compares equal.
- Genuine red-green: registry edit alone fails the two membership pins, re-pin turns it green on the same command. Pins independently derived, three-way agreement with vitest's Received.
- Full suite 4724 passed / 0 failed; 236 passed across the gemini-1.5 / isReasoningModel suites.
- Drift actually resolves: drove the real detectDeprecatedFamiliesForSync (no --dry-run exists) — 2 needs-human candidates pre-fix, 0 post-fix. CI's drift-live-pr agrees against the live listing.
- No changeset or version bump: releases are manual package.json bumps via publish-release.yml, and nothing derives a version from commit messages.
Commit is exactly the 2 intended test files, +11/-5.
jpr5
left a comment
There was a problem hiding this comment.
Human decision: KEEP mocking gemini-1.5-pro / gemini-1.5-flash. Verified zero behavior change (no shipped importer of model-registry.ts; pre/post dist semantically identical across 285 shipped files; built-artifact probe over both ids compares equal), genuine red-green on the membership pins, full suite 4724 passed / 0 failed, drift resolves 2 candidates to 0 via the real detector, and no changeset needed (manual version bumps, nothing derives versions from commit messages).
… chat) (#339) ## What was unclassified `claude-opus-5` — **Claude Opus 5** — is an unclassified Anthropic model family on `main`. It appears in Anthropic's live `GET /v1/models` listing but is in neither `includeFamilies.anthropic` nor `excludeFamilies.anthropic`, so the live `/models` family canary flags it as a new-family decision and re-routes it to a human on every run. It was simply missed: the last classification wave (`34056c2`) covered the 2026-07-16 listing, and Opus 5 landed after it (`created_at: 2026-07-24`). **Only this one family is unclassified.** The live listing returns 11 ids and every other one is already classified — including the other two Claude-5 members, `claude-sonnet-5` and `claude-fable-5`. So this is a one-family fix, not a wave. ## Evidence it was unclassified Live listing (`GET https://api.anthropic.com/v1/models`, fetched 2026-07-24, key sourced from the session secret cache — never persisted anywhere): ``` count 11 has_more False claude-opus-5 | Claude Opus 5 | 2026-07-24T00:00:00Z <-- unclassified claude-sonnet-5 | Claude Sonnet 5 | 2026-06-29T00:00:00Z claude-fable-5 | Claude Fable 5 | 2026-06-07T00:00:00Z claude-opus-4-8 | Claude Opus 4.8 | 2026-05-28T00:00:00Z claude-opus-4-7 | Claude Opus 4.7 | 2026-04-14T00:00:00Z claude-sonnet-4-6 | Claude Sonnet 4.6| 2026-02-17T00:00:00Z claude-opus-4-6 | Claude Opus 4.6 | 2026-02-04T00:00:00Z claude-opus-4-5-20251101 | Claude Opus 4.5 | 2025-11-24T00:00:00Z claude-haiku-4-5-20251001 | Claude Haiku 4.5 | 2025-10-15T00:00:00Z claude-sonnet-4-5-20250929 | Claude Sonnet 4.5| 2025-09-29T00:00:00Z claude-opus-4-1-20250805 | Claude Opus 4.1 | 2025-08-05T00:00:00Z ``` Independently corroborated by PR #337's `drift-live-pr` job, whose uploaded head **and** base drift reports both contain exactly one entry — this same finding — with `conclusion: critical`. It passed #337's delta gate only because that gate is new-in-head-only, so the finding has been sitting on `main` unaddressed. ## Classification chosen, and why **`includeFamilies.anthropic`** (`src/__tests__/drift/model-registry.ts:132`), under the existing `// Claude 5 families (text chat)` comment. Justification from the code, not from vibes: - `includeFamilies` is documented as "families aimock actually MOCKS … chat / text completion families" (`model-registry.ts:16-20`). Opus 5 is a GA text-chat/reasoning model on the live listing — squarely that category. - **Every** GA text-chat Claude family on the live listing is in `includeFamilies.anthropic`. `excludeFamilies.anthropic` holds only retired/legacy ids (`claude-v3`, `claude-2`, `claude-instant-1`, `model-registry.ts:217-222`). Opus 5 is brand new, not retired, so exclude would be wrong. - Direct sibling precedent: `claude-sonnet-5` and `claude-opus-4-8` are both `includeFamilies` entries added by `34056c2` ("INCLUDE (text chat / reasoning aimock mocks): … Anthropic all 4.x/5 point releases"). Opus 5 is the same class of thing. - It is not a preview and not Gemma, so neither exclude-by-rule predicate (`PREVIEW_FAMILY`, `GEMMA_FAMILY`) applies. ## Reasoning vs non-reasoning: reasoning-capable, and no code change needed `isReasoningModel("claude-opus-5")` **already returns `true`**, before and after this PR. Verified empirically (not reasoned) by driving the real `src/model-utils.ts` export on both tree states: ``` PRE-fix POST-fix claude-opus-5 -> true true claude-opus-5-20260724 -> true true claude-sonnet-5 -> true true claude-fable-5 -> true true claude-opus-4-8 -> true true claude-opus-4 -> true true claude-3-5-sonnet -> false false (control) claude-3-opus -> false false (control) ``` `true` is the **correct** verdict for an extended-thinking Opus-class model. It arrives via the documented fail-open default (`model-utils.ts:18-26`, `135-136`): no `NONREASONING_FAMILIES` prefix matches `claude-opus-5`, and `REASONING_FAMILIES`' `claude-opus-4` entry does not prefix-match `claude-opus-5` either, so it falls through to `true`. So **`src/model-utils.ts` is deliberately untouched.** Adding a `REASONING_FAMILIES` entry would be a no-op — that list is explicitly "mostly documentation; because unknown ids default to `true` it is not load-bearing for correctness" (`model-utils.ts:21-22`) — and it would break the precedent that `claude-sonnet-5` and `claude-fable-5` also rely on the default rather than an explicit entry. Touching shipped source for a zero-delta documentation edit would turn a registry-only change into a src change for no benefit. ## Mock / fixture support: none needed Registry classification alone is sufficient, and this PR does **not** claim support aimock lacks. aimock's Anthropic surface is fixture-driven and model-agnostic — it matches on request content, not on model id. Probed the built `dist/` with real HTTP requests against a running server: ``` POST /v1/messages {"model":"claude-opus-5", ...} -> {"error":{"message":"No fixture matched", ...}} POST /v1/messages {"model":"claude-sonnet-5", ...} -> {"error":{"message":"No fixture matched", ...}} ``` Byte-identical behavior to the already-included `claude-sonnet-5`: with a matching fixture loaded both serve it, without one both report `No fixture matched`. No per-family code path exists, so there is nothing family-specific to add. `DEFAULT_MODELS` (`src/server.ts:227`) is a separate static 5-id list that this registry does not feed and that no Claude-5 family was ever in. This is exactly the situation the existing `claude-fable-5` comment already documents — a family included ahead of a recorded fixture, intentionally. ## RED → GREEN The failure surface is the real live drift leg: `src/__tests__/drift/models.drift.ts:606-618`, `describe.skipIf(!process.env.ANTHROPIC_API_KEY)`, which calls `listAnthropicModels()` against the real API and asserts through `assertNoUnclassifiedFamilies`. **It gates on `ANTHROPIC_API_KEY` and is SKIPPED without one**, so reproducing the RED below requires credentials; a bare checkout silently skips it. A credential-free leg is included after. ### RED — unmodified `main` (d1b5304), live API ``` $ pnpm exec vitest run --config vitest.config.drift.ts \ src/__tests__/drift/models.drift.ts -t "live /models contains no unclassified family" × Anthropic Claude model-family availability > live /models contains no unclassified family 499ms → API DRIFT DETECTED: Anthropic Claude (live /models family canary) 1. [critical] Unclassified model family "claude-opus-5" in anthropic /models — add it to includeFamilies (aimock mocks it) or excludeFamilies (non-text / retired / preview) in model-registry.ts Path: models/claude-opus-5 SDK: (family in includeFamilies ∪ excludeFamilies) Real: claude-opus-5 Mock: <no mock leg — live /models family canary> : expected [ 'claude-opus-5' ] to deeply equal [] ❯ assertNoUnclassifiedFamilies src/__tests__/drift/models.drift.ts:95:32 ❯ src/__tests__/drift/models.drift.ts:611:7 Test Files 1 failed (1) Tests 1 failed | 24 skipped (25) ``` ### GREEN — same command, after the classification ``` $ pnpm exec vitest run --config vitest.config.drift.ts \ src/__tests__/drift/models.drift.ts -t "live /models contains no unclassified family" ✓ src/__tests__/drift/models.drift.ts (25 tests | 24 skipped) 506ms ✓ Anthropic Claude model-family availability > live /models contains no unclassified family 505ms Test Files 1 passed (1) Tests 1 passed | 24 skipped (25) ``` ### Credential-free corroboration Driving the pure mirror `unclassifiedFamiliesForSync` (`scripts/drift-sync.ts`) over the captured live payload, on both tree states: ``` captured LIVE anthropic /models ids (11): ["claude-opus-5","claude-sonnet-5","claude-fable-5", "claude-opus-4-8","claude-opus-4-7","claude-sonnet-4-6","claude-opus-4-6", "claude-opus-4-5-20251101","claude-haiku-4-5-20251001","claude-sonnet-4-5-20250929", "claude-opus-4-1-20250805"] PRE-fix : unclassifiedFamiliesForSync(anthropic) = ["claude-opus-5"] claude-opus-5 flagged = true includeFamilies.anthropic.has('claude-opus-5') = false POST-fix: unclassifiedFamiliesForSync(anthropic) = [] claude-opus-5 flagged = false includeFamilies.anthropic.has('claude-opus-5') = true excludeFamilies.anthropic.has('claude-opus-5') = false ``` ### Membership pin RED → GREEN The `DATA_FROZEN` canary correctly tripped on the deliberate addition: ``` × classification-data membership freeze > freezes includeFamilies.anthropic membership 5ms → Frozen data set "includeFamilies.anthropic" membership changed (now: ["claude-3-5-haiku", "claude-3-5-sonnet","claude-3-7-sonnet","claude-3-haiku","claude-3-opus","claude-3-sonnet", "claude-fable-5","claude-haiku-4","claude-haiku-4-5","claude-opus-4","claude-opus-4-1", "claude-opus-4-5","claude-opus-4-6","claude-opus-4-7","claude-opus-4-8","claude-opus-5", "claude-sonnet-4","claude-sonnet-4-5","claude-sonnet-4-6","claude-sonnet-5"]). If this is a deliberate, reviewed addition/removal of a classified family, update its pin here; if not, it is a silent canary-silencing edit and must be reverted. Expected: "dbd8b4ef9afd50057143480d89db373886e7c19abe36ef9b4421456305ca2509" Received: "ab79ff332fadeff93c2678ebe3e0af7a6280ce6f0deb4694228e316944dfeb74" Test Files 1 failed (1) Tests 1 failed | 19 passed (20) ``` The new pin was **derived independently**, not copied from vitest's `Received`. Reimplemented the test's own hashing (`sha256(JSON.stringify(members.sort()))`, `logic-pin.test.ts:59-61` + `:227`) in a standalone script, and first validated the method by reproducing the **pre-existing** pin exactly: ``` PRE derived : dbd8b4ef9afd50057143480d89db373886e7c19abe36ef9b4421456305ca2509 PRE in-repo : dbd8b4ef9afd50057143480d89db373886e7c19abe36ef9b4421456305ca2509 PRE METHOD VALIDATED: True POST derived : ab79ff332fadeff93c2678ebe3e0af7a6280ce6f0deb4694228e316944dfeb74 ``` Three-way agreement (derived / vitest `Received` / committed value). After re-pinning: ``` ✓ src/__tests__/drift/logic-pin.test.ts (20 tests) 3ms Test Files 1 passed (1) Tests 20 passed (20) ``` ## Full suite — zero regressions ``` $ pnpm test Test Files 166 passed (166) Tests 4768 passed | 3 skipped (4771) ``` ## Versioning verdict: no bump, no changeset **No behavior change**, so nothing to release. Three independent confirmations: 1. **No shipped importer.** Every importer of `model-registry.ts` is a test or a repo script (`scripts/drift-sync.ts`, `scripts/drift-sync-check.ts`, and six `src/__tests__/` files). Non-test, non-script importers: **none**. `src/model-utils.ts`, `src/server.ts` and the `tsdown` entrypoints never touch it — `includeFamilies`/`excludeFamilies` are drift metadata, not a runtime allowlist. 2. **Absent from the published tarball, and not inlined.** `package.json` `files` is an allowlist (`["dist","fixtures",".claude-plugin","skills","CHANGELOG.md"]`); `npm pack --dry-run` yields 576 files with **0** matching `__tests__` / `logic-pin` / `model-registry` / `src/`. `dist/` contains zero occurrences of `includeFamilies`, `excludeFamilies`, or `claude-opus-5`. 3. **Built artifact semantically identical.** Compared pre-fix and post-fix `dist/`, normalizing the two known non-reproducible-build artifacts this repo has (tsdown's import-alias numbering, e.g. `node_http0` vs `node_http1`, and declaration sourcemaps), with two identical-source builds as the noise floor: ``` noise floor (identical source): CONTROL: POST vs CTRL files=285 differing=0 setdiff=0 subject: SUBJECT: PRE-fix vs POST-fix files=285 differing=0 setdiff=0 VERDICT: PUBLISHED ARTIFACT SEMANTICALLY IDENTICAL ``` Release mechanism is a manual `package.json` version bump consumed by `publish-release.yml`; there is no Changesets / release-please / semantic-release, and no workflow derives a version from commit messages. `chore(drift):` is therefore the honest prefix — this is a classification-only change with zero behavior delta, so neither `feat` nor `fix` is warranted. ## Pre-push gates ``` $ pnpm format:check → All matched files use Prettier code style! $ pnpm lint → clean $ pnpm exec tsc --noEmit → clean $ pnpm test → 166 files passed, 4768 passed, 0 failed $ pnpm build → ✔ Build complete $ pnpm test:exports → node10 🟢 node16 CJS 🟢 node16 ESM 🟢 bundler 🟢 $ pnpm exec commitlint --from HEAD~1 --to HEAD → clean ``` ## Pre-existing caveat (not introduced here) `.github/workflows/unreleased-check.yml` counts commits touching `src/` since the last tag without excluding `src/__tests__/`, so on merge it will emit `::warning:: … version has not been bumped`. It is a warning with no `exit 1` and does not fail CI. #336 and #337 have the same shape. ## Live-CI RED → GREEN (added after CI ran) `drift-live-pr` runs the drift suite against the **real** provider `/models` listings and uploads a base and a head report. On this PR ([run 30132383648](https://github.com/CopilotKit/aimock/actions/runs/30132383648)) those two artifacts are themselves the red-green, from CI rather than from my machine: ``` BASE (main @ d1b5304) HEAD (this PR) "conclusion": "critical" "conclusion": "clean" entries: 1 "entries": [] - critical | claude-opus-5 ``` `main` is measurably red on this finding right now; this branch is clean. Full CI: **26 pass, 2 skipping, 0 fail** (28 rows).
Needs a human decision (drift-sync)
The deterministic, zero-LLM drift-sync found a model-family change it must
NOT auto-apply (a genuinely new/unclassified family, a still-referenced
deprecation, or a registry structural mismatch). It wrote the note file(s)
below and opened this PR so the decision is REACHABLE in the repo.
Note file(s) in this PR
drift-proposals/gemini-gemini-1.5-flash-deprecated-referenced.mddrift-proposals/gemini-gemini-1.5-pro-deprecated-referenced.mddrift-sync outcome
Human decision: keep mocking these families
Decision: KEEP.
gemini-1.5-proandgemini-1.5-flashare gone from Gemini'slive
/modelslisting, but aimock still mocks them on purpose — clients pinned toolder SDKs and older CopilotKit configs still send those ids, and
isReasoningModel()must keep answeringfalsefor them.So nothing is being removed. Instead, commit
a3dc250records the decision in theregistry by moving both families from
includeFamilies.geminiintoexcludeFamilies.gemini's existing "Retired / legacy specialty" bucket, andre-pins the two affected
DATA_FROZENmembership hashes.excludeFamiliesmeans "not counted as text-generation drift", not"not mocked" —
gemini-pro,gpt-3.5andgemini-2.0-flash-expalready livethere while being mocked and heavily referenced in
src/. Prior art for exactlythis move: #315 (
fix(drift): classify bare chat-latest alias in openai excludeFamilies).Because
detectDeprecatedFamiliesForSyncdraws its candidate set fromincludeFamilies[provider], the two families can no longer become deprecationcandidates — drift-sync stops re-flagging them needs-human, which ends the daily
red job and the daily Slack page. The two note files stay in this PR as the audit
trail for the
excludeFamiliesentries; they become inert on merge and will neverbe re-read or re-written.
Nothing that references
gemini-1.5-*was touched.src/model-utils.ts:54(
NONREASONING_FAMILIES), the three gemini reasoning-capability suites, thegemini.test.ts/vertex-ai.test.tsconformance assertions and the recordedsdk-shapes.tsfixture are all unchanged.Diff
Known side effect
Gemini's truncated-listing trust floor is
includeFamilies[provider].size, so itdrops from 11 to 9. Reversible: move the families back and re-pin.
Local red-green proof
1. Baseline — before any edit, drift suites all green
2. RED — registry membership moved, pins NOT yet updated
logic-pin.test.tsfails loudly on the stale membership pins, exactly as itsmodule doc promises:
The two new pin values were derived independently —
sha256(JSON.stringify([...set].sort()))recomputed from
model-registry.tsin a standalone script, matching the values thetest itself reports:
3. GREEN — pins re-pinned, same command
4. Full suite — zero regressions
And the suites that specifically pin
gemini-1.5-*behavior:5. Drift resolution proved offline
scripts/drift-sync.tshas no--dry-run/--checkflag, so the canary wasexercised directly instead:
detectDeprecatedFamiliesForSyncis exported andpure, so it was called against a simulated live
/modelslisting containing everygemini family aimock classifies except
gemini-1.5-pro/gemini-1.5-flash(31 ids — comfortably over the trust floor under both the old size 11 and the new
size 9), once on the pre-fix tree and once on the fixed tree.
Pre-fix (this branch at
85fc683) — reproduces the needs-human routing:Post-fix (
a3dc250) — resolved:detectDeprecatedFamiliesForSyncis the exact functiondrift-sync.tsuses to buildthe deprecation candidate set, so this is the real routing surface, not a stand-in.
Plus a genuine LIVE confirmation from this PR's own CI. The
drift-live-prcheckran the drift suite against the real provider
/modelslistings on this head commit(
preflight: Gemini key OK) and the uploaded head drift report contains exactly oneentry — a pre-existing anthropic
claude-opus-5unclassified-family finding, alsopresent in the base report — with zero
gemini-1.5mentions and no gemini entryat all:
That confirms against the live listing that moving both families to
excludeFamiliesintroduced no new drift and no unclassified-family regression(
isClassifiedFamilyis satisfied by include or exclude). To be precise aboutwhat it does not prove: this leg does not surface the still-referenced-deprecation
class as a report entry — the base report shows zero
gemini-1.5mentions too — sothe live job is regression evidence, and the needs-human routing fix itself is
proved by the
detectDeprecatedFamiliesForSyncred-green above.6. Pre-push gates
7. Zero behavior change → no version bump, no changeset
The test that matters is not "which directory did the files sit in" but does this
change aimock's behavior for a consumer? It does not, and that is verified three
independent ways.
(a) The registry is not reachable from shipped code. Every importer of
src/__tests__/drift/model-registry.tsis a test or the maintenance script:No shipped-source importer exists —
src/model-utils.ts,src/server.ts, thehandlers and every published entrypoint in
tsdown.config.tsare all absent fromthat list.
includeFamilies/excludeFamiliesare drift-detection metadata, nota runtime allowlist. Confirmed at the artifact level too:
dist/contains zerooccurrences of
includeFamilies,excludeFamiliesorisClassifiedFamily, so thedata isn't inlined either, and
npm pack --dry-runships 576 files with nonematching
__tests__//drift//model-registry/logic-pin/src/(
filesallowlist is["dist","fixtures",".claude-plugin","skills","CHANGELOG.md"];scripts/ships 0 entries; no.npmignore).(b) The published artifact is byte-identical. Built
dist/from the pre-fix andpost-fix trees and compared all 285 shipped code/declaration files. Note the build
is not reproducible — two builds of identical source differ in declaration
sourcemaps and in tsdown's non-deterministic import-alias numbering
(
node_http0↔node_http1), so a naive whole-dist hash gives a false positive.Normalizing that noise, with two identical-source builds as the control noise floor:
(c) Consumer-visible behavior is bit-identical. Probed the built
dist/beforeand after with real requests for both ids —
generateContent, SSEstreamGenerateContent, strict-on and strict-off reasoning, the OpenAI-compatible/v1/chat/completionssurface, and/v1/models— plus theisReasoningModel()verdicts and the captured capability warnings. The two JSON captures compare equal:
So aimock still mocks both ids identically,
isReasoningModel()still returnsfalsefor both, and the advertised model list is unchanged.Release mechanism.
publish-release.ymltriggers on push tomain, comparespackage.json's version against npm, and publishes + tags only when that version isnot yet published — i.e. releases come from a manual
package.jsonversion bump.There is no Changesets setup (no
.changeset/, no@changesets/cli), norelease-please and no semantic-release, and no workflow derives a version from commit
messages, so the conventional-commit type does not trigger a release; the
fix(drift):prefix here matches the repo's own prior art for registryclassification changes (#315, #322) and is inert with respect to versioning.
publish-commit.yml→pkg-pr-newis just the per-PR preview publish behind the"Continuous Releases" / "preview" checks and gates nothing on versioning. No
changeset file and no version bump are required.
Minor pre-existing caveat:
unreleased-check.ymlcounts commits touchingsrc/since the last tag without excluding
src/__tests__/, so on merge it will emit a::warning::that the version wasn't bumped. It is a warning only — noexit 1—so it does not fail CI. #336 had the same shape.
Separate latent defect, filed for follow-up (NOT introduced by this PR)
The mechanical registry-edit path documented for new-family notes is currently
dead-locked by its own gate, so
ok-appliedcan never happen: gate-2 ofdrift-sync-check.tsre-runslogic-pin.test.tsas a subprocess, that file'sDATA_FROZENblock freezes set membership, and a mechanical add/remove is amembership change — so it trips the pin, gets reverted by
revertFiles, andreports
gate-failed. #335 shipped the mechanical path; #336 addedDATA_FROZEN40 minutes later. It has never been observed because no
ok-appliedrun has everoccurred. The
ok-appliedtest injects a fake gate (makeFakeDeps), so it assertsrunSyncCheckwas called, never that the real pin would pass. Worth fixingseparately — either exempt the
DATA_FROZENmembership hashes from gate-2, or havedrift-sync re-pin the hash as part of the allowlisted data edit and lean on human
PR review as the guard.