Audit: {provider, model} vs {providerId, model} Schema Fitness
Date: 2026-05-11
Branch: cvl-node-tree-direct
Scope: All TypeScript files in the monorepo (excluding node_modules, .d.ts)
Background
Two distinct object shapes coexist in this codebase:
| Shape | Type name | Where it lives | Fields |
|---|---|---|---|
| ChainSlot | ChainSlot from @cv/schemas/cloud-keys.schema |
Stored in CloudKeys.chains, job.chain_overrides, BYOK editor |
{ provider: string, model: string } |
| ProviderSlot | ProviderSlot from @cv/domain/ai |
Returned by resolveChain() / chainResolver() — the ready-to-call shape with injected API key |
{ providerId: string, apiKey: string, model: string } |
The resolveChain() function converts ChainSlot → ProviderSlot by reading config.providerKeys[slot.provider] and renaming the field:
result.push({ providerId: slot.provider, apiKey: key, model: slot.model });
So providerId is correct on resolved/runtime objects; provider is correct on stored/seed objects. Confusion arises where test fixtures construct objects that are meant to be stored ChainSlots but mistakenly use providerId.
Findings Table
| File:line | What we found | Classification | Fix needed? |
|---|---|---|---|
lib/schemas/src/cloud-keys.schema.ts:18-21 |
ChainSlotSchema = z.object({ provider, model }) — canonical definition |
REFERENCE | No |
lib/domain/src/ai.ts:45-49 |
ProviderSlot = { providerId, apiKey, model } — canonical resolved shape |
REFERENCE | No |
lib/domain/src/chain.ts:238 |
resolveChain maps slot.provider → providerId — correct conversion |
LEGITIMATE | No |
lib/domain/src/chain.ts:193,350,379 |
pruneOrphanedChainSlots and resolveKeyFromChain use slot.provider — correct |
LEGITIMATE | No |
lib/domain/src/pipeline/stages/enrich.ts:235,288 |
slot.providerId === 'jina' — slot here is from chainResolver(), returns ProviderSlot[] |
LEGITIMATE | No |
lib/domain/src/pipeline/stages/enrich.ts:285 |
hasResearchChainSlot type annotation: { providerId: string }[] — matches ProviderSlot, correct |
LEGITIMATE | No |
web/src/routes/api/workspaces/[id]/process-requests/fallback/+server.ts:757 |
researchChain.some((slot) => slot.providerId === 'jina') — researchChain is ProviderSlot[] from resolveChain() |
LEGITIMATE | No |
web/src/routes/api/llm/evaluate/+server.ts:240 |
slot.providerId — slot is from ProviderSlot[] (mergeProviderChains input) |
LEGITIMATE | No |
web/src/routes/api/chain/server.test.ts:180,198,216 |
expect(resolveChain(...)).toEqual([{ providerId, apiKey, model }]) — resolveChain returns ProviderSlot[] so providerId is correct |
LEGITIMATE | No |
lib/domain/tests/seed.test.ts:265 |
{ provider: 'custom', model: 'special' } — correct ChainSlot shape in l2 override |
LEGITIMATE | No |
lib/domain/tests/seed.test.ts:27,36,47,57 |
ChainSlot[][] = [[{ provider: ..., model: ... }]] — all correct |
LEGITIMATE | No |
lib/domain/tests/pipeline-stages.test.ts:95-99 |
makeChainResolver maps s.provider → providerId — correct ChainSlot→ProviderSlot conversion |
LEGITIMATE | No |
lib/domain/tests/bundle-equivalence.test.ts:469,491,563,647 |
chainResolver: (() => ([{ providerId, apiKey, model }])) — these stub a chainResolver which by contract returns ProviderSlot[] |
LEGITIMATE | No |
lib/domain/tests/bundle-equivalence.test.ts:467,558,644 |
chains: { extract: { chat: [{ provider, model }] } } in chainConfig — correct ChainSlot shape |
LEGITIMATE | No |
web/src/routes/api/workspaces/[id]/process-requests/fallback/server.test.ts:989-992 |
chain_overrides: JSON.stringify({ evaluate: [{ provider: 'mistral', model: 'mistral-small-latest' }] }) — CORRECT, already fixed (comment at line 989 documents it) |
LEGITIMATE | No — already correct |
web/src/routes/api/workspaces/[id]/process-requests/fallback/server.test.ts:1054-1055 |
as Array<{ providerId: string; model: string }> cast on a ProviderSlot[] — this is reading from callWithChain's first arg, which is ProviderSlot[]. The cast correctly reflects ProviderSlot (sans apiKey which is not needed for the assertion) |
LEGITIMATE | No |
web/src/routes/api/workspaces/[id]/process-requests/fallback/server.test.ts:912-913 |
expect.objectContaining({ providerId: 'mistral' }) on ai-usage request body — that endpoint receives providerId (telemetry field), not a ChainSlot |
LEGITIMATE | No |
mcp/test/tools.test.ts:278 |
chain_overrides: JSON.stringify({ evaluate: [{ providerId: 'mistral', model: 'mistral-small-latest' }] }) — seeds a chain_overrides value that would be parsed as a ChainSlot[] by flattenCascade. providerId should be provider. |
WRONG SHAPE | Yes — test fixture bug |
mcp/test/tools.test.ts:289 |
evaluate: [{ providerId: 'mistral', model: 'mistral-small-latest' }] in the matching expect — correct expectation shape for the wrongly-seeded fixture (both seed and assertion are wrong together, but the test passes because list_jobs round-trips the raw JSON without schema-validating slot contents) |
WRONG SHAPE | Yes — fix with line 278 |
Production code: all fallback/+server.ts usages of providerId |
All reference telemetry fields on stage results (not ChainSlot objects) — cvUsageInfo.providerId, clUsageInfo.providerId, stage.telemetry.providerId |
LEGITIMATE | No |
All pipeline.svelte.ts usages of providerId |
All reference attempt/telemetry/attribution objects (attempt.providerId, telemetry.providerId) — ProviderSlot or telemetry, never a stored ChainSlot |
LEGITIMATE | No |
lib/domain/src/ai.ts (all ~30 instances) |
All on ProviderSlot objects in callWithChain internals |
LEGITIMATE | No |
lib/domain/src/scrape.ts (all instances) |
All on ProviderSlot objects passed to scrape() |
LEGITIMATE | No |
lib/domain/src/tailor/index.ts (all instances) |
Telemetry/usage objects from callWithChain returns |
LEGITIMATE | No |
web/src/routes/api/ai-usage/+server.ts |
API endpoint that receives providerId in telemetry body — that's the field name in the POST body, not a ChainSlot |
LEGITIMATE | No |
web/src/routes/api/evaluate/+server.ts, extract-fields/+server.ts |
Same as above — telemetry providerId from stage result |
LEGITIMATE | No |
Inverse Mistake Check
Checking for places that use {provider, model} where the consumer expects {providerId, model}:
lib/domain/tests/bundle-equivalence.test.ts:chainConfig.chainsentries correctly use{provider, model}(ChainSlot);chainResolverstubs correctly return{providerId, apiKey, model}(ProviderSlot). No confusion.lib/domain/tests/pipeline-stages.test.ts:makeChainResolvercorrectly convertss.provider → { providerId: s.provider, apiKey: ..., model: s.model }. No confusion.- No inverse mistake instances found.
Wrong-Shape Instances — Proposed Fixes
Instance 1: mcp/test/tools.test.ts:278-289
What: Test fixture seeds chain_overrides for a job with { providerId: 'mistral', model: 'mistral-small-latest' } inside evaluate: [...]. Since chain_overrides is Record<capability, ChainSlot[]> per lib/domain/src/types.ts:60, the slot shape must be { provider: string, model: string }.
Why the test passes today: list_jobs in the MCP tool round-trips chain_overrides by parsing it from JSON and returning it to the caller as a plain object without schema-validating the slot contents. The expect(jobs[0].chain_overrides).toEqual({ evaluate: [{ providerId: ... }] }) assertion then matches what was seeded. The test never exercises the slot through flattenCascade or resolveChain, so the wrong field name never causes a failure — but the fixture is still wrong and would produce incorrect runtime behaviour (the slot would be filtered out by pruneOrphanedChainSlots because slot.provider would be undefined).
Proposed fix:
- chain_overrides: JSON.stringify({ evaluate: [{ providerId: 'mistral', model: 'mistral-small-latest' }] }),
+ chain_overrides: JSON.stringify({ evaluate: [{ provider: 'mistral', model: 'mistral-small-latest' }] }),
- expect(jobs[0].chain_overrides).toEqual({
- evaluate: [{ providerId: 'mistral', model: 'mistral-small-latest' }]
- });
+ expect(jobs[0].chain_overrides).toEqual({
+ evaluate: [{ provider: 'mistral', model: 'mistral-small-latest' }]
+ });
Summary Verdict
| Metric | Count |
|---|---|
Total providerId references in TS files |
386 |
LEGITIMATE — on ProviderSlot objects or telemetry |
384 |
WRONG SHAPE — ChainSlot with providerId instead of provider |
2 (lines 278, 289 in mcp/test/tools.test.ts) |
| AMBIGUOUS | 0 |
| Tests silently broken by wrong shape | 1 test (list_jobs parses JSON text blobs before validating job shape) — passes but exercises wrong fixture data |
| Production code bugs | 0 — no production code constructs a ChainSlot with providerId |
How many tests are silently broken?
One test in mcp/test/tools.test.ts (the list_jobs parses JSON text blobs test at line 272) seeds a wrong-shape fixture and asserts the round-tripped wrong shape. It passes because list_jobs never schema-validates ChainSlot contents. If the tool were updated to validate or forward the chain_overrides through flattenCascade, the slot would be silently dropped (pruned as orphaned — no slot.provider key).
The previously-reported bug in fallback/server.test.ts is already fixed: the chain_overrides fixture at line 992 now correctly uses { provider: 'mistral', model: 'mistral-small-latest' } (with an explanatory comment at line 989).
Recommended next action
- Fix
mcp/test/tools.test.tslines 278 and 289 (one-line patch each, shown above). - No production code changes needed.
- Consider adding a
ChainSlotSchema.parse()orz.array(ChainSlotSchema)validation at thelist_jobsdeserialization boundary in the MCP tool to catch future shape drift at the ingestion point.