feat: add metadata overrides and custom source for model aliases#163
Conversation
mcowger
left a comment
There was a problem hiding this comment.
Two issues introduced by this PR:
1. N+1 query worsened in getAllAliases() (config-repository.ts:483)
The PR adds a getMetadataOverrideRow(row.id) call inside the existing per-alias loop, which already fires one targets query per alias. This turns 1+N into 1+2N sequential round-trips. Please batch-fetch all override rows before the loop using inArray across all alias IDs (can be run in parallel with the targets fetch), then look them up by alias ID in a Map.
2. TypeScript type errors on principal?.keyName (Debug.tsx:269, Errors.tsx:149, Logs.tsx:391)
keyName only exists on the 'limited' variant of the Principal union — not on { role: 'admin' }. TypeScript cannot narrow the union through the opaque isLimited boolean, so these three lines are type errors. Use the discriminant directly instead: principal?.role === 'limited' && principal.keyName.
|
@claude review this please, like a pirate. |
|
I'll analyze this and get back to you. |
Lets users override individual fields (name, description, context length, pricing, architecture, supported parameters, top provider limits) on top of models.dev / OpenRouter / Catwalk catalog data, and adds a new 'custom' metadata source where users supply everything themselves — seeded with sensible defaults. - New table alias_metadata_overrides (SQLite + PostgreSQL) keyed by alias_id with typed columns per field. Cascades on alias delete. - Extend ModelMetadataSchema to a discriminated union; adds 'custom' source with required overrides; other sources gain optional overrides. - mergeOverrides() helper: scalars replace, nested objects spread-merge, arrays replace. GET /v1/models applies overrides on top of catalog hits. - /v1/metadata/search continues to reject 'custom' (no catalog to search). - Frontend adds an Override toggle in the Metadata accordion, a grouped edit form (Basic / Pricing / Architecture / Capabilities), and a Custom source option that pre-fills safe defaults.
- config.ts: custom source now requires a non-empty name (Zod min(1)) so
{ source: 'custom', overrides: { description: '...' } } no longer validates
into an empty-name record.
- models.ts route: for non-custom sources, a catalog miss now returns the
base record and skips mergeOverrides entirely instead of silently
synthesizing a partial entry from overrides alone — hides nothing.
- config-repository.ts: saveAlias now wraps the full alias + targets +
overrides replacement in a single transaction so partial failures roll
back cleanly.
- lib/api.ts: AliasMetadata is now a discriminated union — non-custom
requires source_path, custom allows it to be omitted. Mirrors backend.
- Models.tsx: switching to 'custom' merges seeded defaults with any
existing overrides (overrides take precedence) and preserves the prior
source_path. The 'name' field in custom mode is no longer auto-deleted
when the user clears it, and handleSave refuses to submit custom
metadata with a blank name with a clear inline error.
- Postgres schema: jsonb columns are typed as string[] via .$type<string[]>()
so InferSelectModel / InferInsertModel produce the right shape. No SQL
change — purely type-level.
- SQLite schema: metadata_source comment now lists 'custom' alongside the
other three values to match the PostgreSQL enum and runtime behavior.
…rors Both methods built their `where` clause from `this.schema.*` before invoking `ensureDb()`, which is what lazily initializes `this.schema`. On a cold backend the first call threw `TypeError: null is not an object (evaluating 'this.schema.debugLogs')`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…arams Improves the metadata override UX so users can see and tweak the current catalog values instead of starting from a blank form, and modernizes the list inputs to use tag-style selectors. Backend: - Add GET /v1/metadata/lookup endpoint that returns the full normalized metadata record for a single (source, source_path) pair, with 400/404/503 error paths. Used by the frontend to prefill the override form. - Update Catwalk catalog URL from /providers to /v2/providers in both the ModelMetadataManager default and docs/CONFIGURATION.md. - Bump drizzle journal timestamps to avoid ordering conflicts. - Add coverage for the new lookup route (missing params, uninitialized source, unknown source_path, happy path). Frontend: - Add NormalizedModelMetadata type and api.getModelMetadata client that gracefully returns null on 404/503 so callers can fall back to blank. - On opening the override panel (or selecting a new catalog model while override is on), fetch the catalog record and merge its values into the overrides blob — user-entered fields always win on conflict. - Track a catalogReference alongside the editing alias and rework countOverrides to only count fields that actually differ from the reference (catalog values for catalog sources, buildCustomDefaults for custom), so the "N fields overridden" strip reflects real edits. - Replace comma-separated Input fields for input/output modalities and supported_parameters with TagSelect dropdowns backed by suggestion lists. - Extend TagSelect with an allowCustom prop: Enter/comma commits the current text as a new tag, Backspace on an empty input peels the last tag, pasted "a, b, c" splits into tags, and the dropdown shows a "Create '<search>'" affordance when the typed value isn't in options. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stop the override form from overwriting the new catalog's values when a user switches between sources or models, and clear the stale source_path when the catalog changes (paths are not portable between openrouter/models.dev/custom). TagSelect now batches comma-pasted tags into a single onChange and commits suggestion clicks via onMouseDown so they win the race against blur. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ts merge TagSelect: Introduce a single normalize() helper and use it for duplicate detection in filteredOptions, showCreateOption, and addCustomTags so the dropdown's "Create" affordance and the commit paths (Enter/blur/comma) agree on case. Gate the input's onBlur commit on event.relatedTarget leaving the container, and revert dropdown items to onClick now that blur no longer commits partial search text when clicking a suggestion. Models: Deep-merge buildCustomDefaults with existingOverrides when switching to the custom source so a partial nested override (e.g. only architecture.input_modalities) no longer wipes default sibling fields like architecture.output_modalities. Mirrors the existing merge pattern in populateOverridesFromCatalog. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pending metadata-catalog search timeouts could overwrite results after the user switched source or cleared the query, and clearing metadata left isOverrideOpen stuck on so a later source pick would reopen the override form against the new catalog. Cancel the debounce and reset related UI state in handleMetadataSearch, clearMetadata, the source-selector onChange, and the modal-open effect. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…inant Replace per-alias metadata override lookup in getAllAliases() with a single inArray query run in parallel with the targets fetch, then index both into maps keyed by aliasId. The old loop issued 1+2N sequential round-trips. On the frontend, the Principal union's keyName only exists on the 'limited' variant, and TypeScript can't narrow through the opaque isLimited boolean. Switch Debug/Errors/Logs to the discriminant check (principal?.role === 'limited' && principal.keyName) and drop isLimited from the destructure where it's no longer used. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rebase PR branch onto current main (which has advanced past the branch's base), replay only the new metadata-override feature commits, then regenerate migrations at the correct next-available indices: SQLite → 0029_simple_skullbuster (alias_metadata_overrides + provider_reported_cost re-add) PG → 0033_colossal_exiles (alias_metadata_overrides + metadata_source enum)
227cf2b to
29f56f0
Compare
…y.send()
In Fastify v5, async hooks must throw to abort the hook chain rather than
calling reply.send() and returning. Sending in an async preHandler does not
reliably set reply.sent before subsequent hooks are invoked, causing a
double-send ERR_HTTP_HEADERS_SENT crash on all 401/403 rejection paths.
Introduce ManagementAuthError (statusCode + authBody) thrown by
authenticate() and requireAdmin(). A setErrorHandler() in
registerManagementRoutes() catches it and formats the expected
{ error: { message, type, code } } response body.
…ce for model aliases

Lets users override individual fields (name, description, context length,
pricing, architecture, supported parameters, top provider limits) on top of
models.dev / OpenRouter / Catwalk catalog data, and adds a new 'custom'
metadata source where users supply everything themselves — seeded with
sensible defaults.