Skip to content

Commit 975235b

Browse files
feat: flag deprecated models and redesign admin models table (eneo-ai#315)
* feat: flag deprecated models in admin UI using LiteLLM deprecation data Enrich API responses with deprecation_date from litellm.model_cost at serialization time. Models past their deprecation date are automatically flagged as deprecated. The frontend shows red/yellow labels in the Details column and exposes a Migrate action directly in the dropdown menu for deprecated completion models. Also adds SDK functions for migration history endpoints (already existed in backend but were never wired to frontend) and a Migration History tab in the admin models page. Backend: - New deprecation_lookup utility with get_litellm_deprecation_date() - deprecation_date field on CompletionModelPublic, EmbeddingModelPublic, TranscriptionModelPublic (computed at serialization, no DB migration) - 21 new unit tests covering lookup and enrichment logic Frontend: - Red "Deprecated" / yellow "Retiring YYYY-MM-DD" labels in ModelLabels - Migrate button in ModelActions dropdown for deprecated models - getMigrationHistory/getAllMigrationHistory SDK functions - MigrationHistoryPanel component + tab in admin/models page - Translation keys for EN and SV * feat: redesign admin models table for clarity and accessibility Replace the dense tag-based table with a cleaner design: - Remove Details column tags, replace with compact status icons (ModelStatusIcons: deprecation, reasoning, vision, tools) - Remove Security column (moved to ModelDetailDialog) - Add status indicator dot on each model name (green/red/yellow/gray) - New ModelDetailDialog: click model name to see full details (capabilities, hosting, security, metadata, deprecation alert) - Deprecation warning banner above table with model count - Row tinting for deprecated/retiring models (CSS :has selector) - Reorder dropdown actions: neutral first, destructive last - Migrate action available for all completion models, not just deprecated - Enable Vite polling for HMR in Docker dev environment * feat: improve model migration with validation, security checks, and history Migration validation: - New GET /completion-models/{id}/migration-validate endpoint for preflight - Server-side compatibility check on target selection (no client-side approx) - Security classification blocker: prevents migration to lower-classified model when spaces require higher classification (cannot be overridden) - Race condition fix: stale validation responses discarded - Dual warning format: human-readable warnings + machine-readable warning_codes Migration execution: - Fix kwargs reset being a blocker (now informational only) - Remove auto-delete that ran even on failure; delete only after success - Spaces included in default migration via MIGRATABLE_ENTITY_TYPES - Audit logging with COMPLETION_MODEL_MIGRATED action type Migration history: - Alembic migration: FK ondelete CASCADE → SET NULL, add model name columns - Store from_model_name/to_model_name at creation (survives model deletion) - ModelMigrationHistory: model IDs now Optional[UUID] - Expandable detail rows: breakdown per entity type, warnings, errors, duration - Auto-refresh via migrationHistoryRefreshVersion store Migration dialog UX: - Impact preview: shows affected resources grouped by type with details - Collapsible sections per entity type (assistants, apps, questions) - Spaces info: "Target model will be enabled on N spaces" - Preflight warnings shown on target selection with translated messages - Security blocker shown as red panel, disables migrate button - SDK: validateMigration, getUsageStats, getUsageDetails functions * test: add migration validation, history, and endpoint tests 48 new unit tests across 3 files: - test_migration_validation.py (22): compatibility checks, security blockers, warning codes, kwargs reset, multiple warnings combined - test_migration_history.py (15): stored model names, SET NULL handling, Pydantic nullable IDs, fallback logic, serialization roundtrip - test_migration_endpoint.py (11): audit action type, category mapping, metadata, ValidationResult/MigrationRequest schema tests Updated existing tests for warning_codes field and audit action registration. * fix: security blocker bypass and impact total underreporting - Fix security classification blocker check: was searching for codes in warnings (human-readable text) instead of warning_codes. With confirm_migration=true, a security-blocked migration could pass through. - Fix impact preview total: usage/details endpoint now counts real total across all entity types via separate COUNT queries, instead of returning len(items) which was just the current page size. * fix: add type annotations to deprecation_lookup for pyright * fix: set model name columns after init to satisfy pyright * docs: inlang * feat: consolidate model settings into detail and edit dialogs Move scattered model settings into a unified flow: - ModelDetailDialog is now read-only info view - EditModelDialog handles all editing: parameters, security classification, default model status - ModelActions dropdown simplified to Edit, Migrate, Delete - Table status dots: red=disabled, icons for deprecated/retiring - SelectSecurityClassification trigger now full-width * feat: add security classification to model creation wizard Security classification can now be set when adding a model, so admins don't need to create and then immediately edit to set classification. Uses a separate API call after create since the backend create endpoint doesn't accept security_classification directly. * refactor: clean up model name display in table and detail view Table name column now shows only the display name — model identifier and context window moved to the detail dialog. Detail dialog always shows both display name and model identifier with consistent styling. * feat: preserve historical question attribution during model migration Questions are historical records that must not be migrated — doing so falsely attributes answers to the target model and corrupts token usage analytics. Changes: - Exclude questions from MIGRATABLE_ENTITY_TYPES - Add migrated_to_model_id (FK RESTRICT) and deleted_at to completion_models - Change questions FK from SET NULL to RESTRICT as safety net - Mark source model with migrated_to_model_id after successful migration - Block re-migration of already-migrated models (both preflight and execute) - Soft-delete replaces hard-delete in both tenant and sysadmin endpoints - Update can_access in all three paths (domain, AIModelsService, pydantic) - Filter migrated/deleted models from space assembler, model selection UIs - Add MODEL_IN_USE check before soft-delete via shared has_active_references - Frontend: remove auto-delete after migration, add Migrated label, lock enable switch, exclude migrated from target lists and space settings * feat: add weekly lifecycle cleanup for orphaned completion models Automatically hard-deletes completion models once all references are gone (questions gallrad, active entities migrated, no migration pointers). - Weekly ARQ cron job (Sunday 4 AM) targeting soft-deleted and migrated models - Reuses shared has_active_references from CompletionModelsRepository - Reconciles soft-deleted template FK references before hard-delete - IntegrityError classified as db_restrict skip, not unexpected error - Preserves migration history via immutable from/to_model_original_id columns - History service falls back to original_id when FK is SET NULL after cleanup - Indexes on deleted_at, migrated_to_model_id, and original_id columns * refactor: replace original_id columns with provider_type snapshots in migration history Simplify the migration history model: remove from/to_model_original_id (dead UUIDs pointing to deleted rows) and replace with provider_type snapshots that give readable audit context. - Remove from_model_original_id and to_model_original_id columns - Add from_provider_type and to_provider_type as audit snapshots - Backfill provider_type from model_providers for existing records - Simplify repo queries to only match live FK IDs (no OR fallback) - History service returns None for model IDs after cleanup (not stale UUIDs) - Per-model history works for live models; use tenant/migration_id after cleanup * fix: unblock lifecycle cleanup typecheck and unit tests * test: align sysadmin completion model delete with soft-delete semantics * fix: add explicit type annotations to Dropdown ctx for d.ts generation Fixes svelte-package failing to generate type declarations due to inferred types referencing internal @melt-ui/svelte modules. * fix: restore migration_id column and post-rebase corrections - migration_id column was lost during conflict resolution - audit user_action count updated from 29 to 30 to reflect the COMPLETION_MODEL_MIGRATED action added by the deprecation-flag work - replace deprecated datetime.utcnow() with timezone-aware datetime.now(timezone.utc) - drop unnecessary is-not-None guard on non-optional service.completion_model_id - pass already-typed local IDs to ModelMigrationHistory * fix: regenerate schema and align frontend models endpoints - regenerate schema.d.ts from current backend openapi.json - align getUsageDetails return type with PaginatedResponse (the backend's local ModelUsagePaginatedResponse alias is reduced to PaginatedResponse at schema-generation time) - use ts-expect-error for query params on endpoints whose query is read via a custom Depends() that doesn't surface in the OpenAPI schema - restore JSDoc on migrateCompletion so its parameters are typed instead of any - drop now-unused ts-expect-error in model-providers.listModels - align WizardData.models.securityClassification with the intric-js SecurityClassification type to fix a structural mismatch with StepModels' models prop - type the wizard's intermediate created model and replace the open any cast on the security_classification update - add missing migrated_to_model_id and deleted_at fields on the SimpleNamespace test fixture to match the new domain CompletionModel * feat(backend): add per-token cost tracking with LiteLLM enrichment Adds NUMERIC(20,12) input/output_cost_per_token columns to completion and embedding model tables, and NUMERIC(20,6) cost_per_minute to transcription models. Domain/presentation models, assemblers, and the model-providers capability flow are extended to surface cost from LiteLLM's model_cost map both for the live catalog enrichment and the static \`/capabilities/\` endpoint. A backfill alembic step fills NULL costs for existing rows by looking up the model in LiteLLM with the same prefix-fallback resolution as the runtime defaults endpoint. Also fixes \`deleted_at\` on completion models to map as \`DateTime(timezone=True)\` so tz-aware UTC datetimes no longer crash the asyncpg soft-delete path. * feat(backend): MODEL_IN_USE (9039) error + scope deletion blockers to actual usage Introduces a dedicated \`ModelInUseException\` mapped to error code 9039 so the frontend can localize the message and offer the migrate flow inline instead of swallowing a generic 400. The four delete paths (tenant completion / embedding / transcription routers and the sysadmin counterpart) now raise this typed exception consistently. Also narrows what blocks completion-model deletion to actual resource references (assistants, apps, services, assistant/app templates). Space membership alone no longer blocks — spaces are containers, and a model "enabled" on a space without any resource using it is just configuration. The cross-reference rows are cleaned up automatically inside \`delete_model\` so soft-deleted models don't dangle in \`Spaces.completion_models\` reads. * feat(frontend/admin-models): redesign add wizard, cost-aware UX, shadcn dialogs Consolidates the legacy per-type dialogs (AddEmbeddingModelDialog, AddTranscriptionModelDialog) into a single AddWizard that handles completion, embedding, and transcription models with a stepper, draft list, suggestions catalog, and provider/credentials/security steps. Adds end-to-end pricing UX: ModelCostBadge in tables, Cost row in the detail dialog, and a per-1M-tokens display in the wizard/edit forms with auto-fill from the LiteLLM defaults endpoint. Storage stays as USD/token. Migrates the model dialogs to shadcn: EditModelDialog, MigrateModelDialog, ModelDetailDialog, the delete confirm in ModelActions, and the wizard. Hooks the new MODEL_IN_USE (9039) error through getErrorMessage so the delete confirm renders a localized message and offers the migrate flow inline. Migrate dialog skips the resource warnings (different_family, kwargs_reset, …) when no resources actually use the model — the warnings are vacuous in that case. Splits the form-reset effect so an upstream availableTargets refresh can no longer wipe an in-progress edit. Clarifies the deprecation banner / tooltip to read "should be migrated or removed" since deletion is now a first-class option for unused models. Regenerates the OpenAPI schema for the new ErrorCodes value. * feat(frontend/admin-usage): show estimated USD cost alongside token totals Pulls the new per-token costs from the model list and surfaces an estimated USD cost in the tenant token-usage views. Adds an EstimatedCostCell renderer to the token overview table and pipes a shared cost-rate map through the user breakdown, the per-user detail view, and the summary cards. Models without a published rate fall back to "—" rather than zero so unknown costs aren't silently rolled into totals. * fix: stabilize admin model migration flows * refactor(frontend/admin-models): drop provider status badge, polish empty state and form placeholders Removes the auto-test ProviderStatusBadge component and its i18n keys — the badge surfaced too rarely useful state to justify the on-click round trip to upstream providers, and was visually noisy in tables with many configured providers. Other polish in the same surface: - PageEmptyState now accepts custom title/description/ctaLabel/helper props so it can be reused for other empty contexts beyond the zero-providers case. - ModelDraftForm cost inputs use a localised cost_input_placeholder instead of hardcoded sample numbers (matches Swedish translations more naturally and avoids implying specific pricing). - ProviderDialog footer becomes a plain div to match the AddWizard footer styling rather than the shadcn Dialog.Footer default spacing. * feat(frontend/admin-models): hide providers without models in current section A provider only shows up on a tab (Completion / Embedding / Transcription) once it has at least one model in that section. Previously every configured provider rendered an empty row in every tab regardless of whether it actually served that model type, which created visual clutter and implied capabilities the provider hadn't been set up for. The "Add provider" CTA still routes through the AddWizard, where the "Use existing provider" tab lets the user reuse a configured provider and just add a model to it — at which point the provider appears in the section. Adds a focused empty state for the case where the tenant has providers configured but none with models in this section, pointing at the same "Add provider" CTA. * feat(tenant-models): set security classification at create time Lets admins assign a security classification while creating a tenant model in the AddWizard rather than via a separate post-create dialog. The bulk-classification flow on /admin/security-classifications is removed; classification is now a column on the model table and editable through the per-model dialog. Backend: - Add `security_classification: ModelId | None` to the three TenantXxxModelCreate schemas (completion / embedding / transcription) - Extract `resolve_tenant_security_classification` into `intric.security_classifications.tenant_validation` so all three routers share one tenant-isolation check - Switch the update routers to `model_fields_set` for nullable fields (description, dimensions, max_input, cost columns) so clients can clear them by sending an explicit `null` - Drop the placeholder "Tenant model: X" description fallback in the completion router; store what the admin sent, matching embedding and transcription - Hoist inline imports to module level for consistency Frontend: - AddWizard sends `security_classification` with the create payload instead of a follow-up update call - ProviderGroupedModelTable shows a classification column when the tenant has any classifications configured - New ModelClassificationCell renders the value (or "no classification") - Remove ModelClassificationDialog + MultipleModelsClassificationDialog and related strings - Regenerate schema.d.ts * test(tenant-models): cover security classification + nullable update flows Unit tests exercise `resolve_tenant_security_classification` directly (none/match/missing/cross-tenant) so the helper has fast regression coverage that runs in the devcontainer. Integration tests run end-to-end against the three create/update endpoints. Notable scenarios: - Cross-tenant classification id rejected as 404 — the most important isolation guarantee, exercised from each modality - description is stored verbatim (no placeholder), guarding the placeholder removal in the completion router - nullable fields (description, cost columns, dimensions, max_input, cost_per_minute) are clearable by sending an explicit `null` - omitted fields are preserved on update — the regression guard for any future revert from `model_fields_set` to `is not None` - a model in another tenant cannot be reached through this tenant's update endpoint * refactor(admin/models): validate cost input client-side and document DB caps Admins enter token cost as USD-per-million in the AddWizard / Edit dialog. The values are stored as `Numeric(20, 12)` per token (so < 10^8 USD/token, < 10^14 USD/million-tokens) and per-minute audio cost as `Numeric(20, 6)` (< 10^14 USD/minute). The form needs to catch overflow before the DB rejects it, otherwise admins get a 500 with no useful message. - Add `MAX_COST_INPUT = 99_999_999_999_999` and helpers `findDraftCostOverflow` / `isCostValueOverflow` in `draft.ts`, shared between the wizard and EditModelDialog - Export `rawCostToNumber` and reuse it from EditModelDialog instead of duplicating the same parser - Wire `<input type="number" max={MAX_COST_INPUT}>` and a translated toast/error so the limit is visible in the UI - Cross-link the constant with the SQL column definitions in `ai_models_table.py` so a future precision change has to update both sides * refactor(ai-models): unify ModelNameAndVendor description modes Replace the dual `showDescription` + `descriptionTabbable` props with a single `descriptionMode: "interactive" | "non-tabbable" | "hidden"` prop. The previous booleans had three valid combinations; the new enum makes that explicit and removes the redundant fourth state. The component now also renders the model description as a focusable info button instead of a tooltip on the model name itself, so the description is reachable both visually and via screen reader without breaking nested-interactive-content rules in cells/listbox options. * feat(migrate-dialog): block migration when impact load fails Previously, when `getUsageStats` failed for the source model, the dialog silently set `spacesCount = 0` and let the admin migrate against an incomplete impact summary. The backend's `get_model_usage_statistics` returns 200 with zero counts for models without recorded usage (verified in `completion_model_usage_service`), so a failure here always means a real backend or network problem. Now the dialog surfaces an explanatory error with a retry button and disables the migrate action until impact data has loaded. The admin can no longer click "migrate" while staring at a partial summary. * fix(frontend): assorted UX and DX polish Bundles small unrelated improvements that landed alongside the security classification work: - vite.config.ts: allow the dev server to serve files from the monorepo root so workspace icons load without "outside of allow list" errors - intric-js client.js: throw a descriptive error when a path parameter is `undefined` / `null`, instead of producing a URL containing the literal string "undefined" - Markdown.svelte / ReferenceContext.ts: turn `renderer` into a getter so the custom in-text-reference renderer reacts to changes in the host context - ExtendExpirationDialog.svelte: initialise `pickerValue` to null and seed via `$effect` to avoid `state_referenced_locally` - admin/templates "deleted" tab: use dedicated empty-state strings instead of the generic "no templates yet" message - Hide cost badges in the user-facing model selectors (apps, assistants, services edit pages); cost remains visible in admin - estimated_cost_tooltip clarified — reasoning tokens are billed as regular output, matching how providers actually charge * feat(audit): add CREATED and DELETED action types for AI models The audit vocabulary previously only had `*_MODEL_UPDATED` for the three model types, so adding/removing tenant models was invisible in the audit trail. Adds: - COMPLETION_MODEL_CREATED / _DELETED - EMBEDDING_MODEL_CREATED / _DELETED - TRANSCRIPTION_MODEL_CREATED / _DELETED All six map to the `user_actions` category (matching the existing `*_MODEL_UPDATED` mapping) and ship with Swedish admin-config labels so the audit-config UI keeps working without manual seeding. Updates the hardcoded category-count assertions in the audit mapping/config tests from 30 → 36. * refactor(tenant-models): extract service layer for create/update/delete The three tenant routers (completion / embedding / transcription) were mostly inline SQLAlchemy: provider lookup, default-unsetting, classification resolution, model construction and audit-friendly loading all sat in the request handler. That made cross-cutting concerns hard to share, hard to test in isolation, and inconsistent with how the global model routers compose business logic via container-resolved services. This change introduces `intric.tenant_models.application.tenant_model_service` with three classes (one per model type) that share helpers via composition: - `_validate_active_provider` — uses ModelProviderRepository.get_by_id for tenant-scoped lookup, raises BadRequest on disabled providers - `_unset_other_defaults` — generic over the table class, called only when the new/updated model is being promoted to default - `_resolve_tenant_security_classification` — already extracted - `_audit` — log_async wrapper with the standard metadata shape - `_DeletedSnapshot` — small id+name carrier so delete-time audit metadata isn't lost when SQLAlchemy detaches the row Audit logging is now wired for create / update / delete on all three modalities (six new action types added in the previous commit). The update logs the set of fields that were actually changed, so audit viewers can distinguish "renamed model" from "rewrote ratecard". The routers themselves drop to ~20 lines each: validate permission, build the service, call one method, commit the session, return. * feat(security-classifications): block deletion when classification is in use The classification-id FK on completion / embedding / transcription models, spaces and MCP servers is `ON DELETE SET NULL`, so deleting a referenced classification used to silently downgrade every dependent row to "no classification". For a system whose whole point is gating model availability by classification, that's an accidental privilege escalation: the admin clicks "delete", sees the row gone, and only notices the loosened access in production traffic. Changes: - `SecurityClassificationRepoImpl.count_usages(id)` returns per-table reference counts (completion/embedding/transcription/spaces/mcp) - `SecurityClassificationService.delete_security_classification` now refuses by default if any usage > 0, with an error message naming the dependent resource counts so the admin can act on it - `force=True` is the explicit opt-in for the old behaviour; passed via `?force=true` query param on the router - The audit metadata records `forced=true|false` so an audit reviewer can see when an admin overrode the guard Tests cover all three branches (blocked-when-in-use, passes-through- when-unused, force-skips-check) at the service level, plus an integration test that creates a tenant model + classification and verifies the 400 vs the 204+force pathways. * feat(conversations): preflight token estimate endpoint with live context bar (#404) Adds POST /api/v1/conversations/preflight which returns the exact token delta the next chat request would add (input + file_tokens), echoing the target model name + context window so clients can compute percentage fill locally. Backend: - Extract shared _ConversationTarget mixin for the "exactly one of session/assistant/group_chat" validator (drops duplicate between PreflightRequest and ConversationRequest) - Route through new _resolve_completion_model so preflight uses the same model the actual chat will, with explicit return type and tested coverage for the session-with-group_chat-id path - Rate-limit at 600 req/min/user (400ms debounce yields ~150/min in normal use; limit catches scripted abuse, fail-open on Redis outage) - Validate input: max 50 file_ids per request, reject empty (no question and no files) with 422 - Remove unused get_files_for_token_estimate / get_list_by_id_and_tenant - Document Message.num_tokens_* semantics: default 0 covers pre-measurement rows, clients summing these should treat 0 as "zero OR unmeasured" Frontend: - ChatService tracks lockedInput/Output (from token_usage SSE), pending preflight tokens (debounced 400ms, race-safe via generation counter) and exposes willExceedContext as the single source of truth - ContextUsageBar shows segmented fill (locked vs pending, four distinct WCAG-compliant hues), opens popover with full breakdown + cumulative spend, persists visibility preference - ConversationInput blocks Send when willExceedContext so the bar and the button can't disagree - Augment ConversationMessage with num_tokens_* and PreflightResponse type hand-authored against schema (will resolve on next openapi-typescript regen) Tests: - tests/unittests/conversations/: 20 unit tests covering rate-limit 429, fail-open, scope 403, exact token math against build_files_string output, group-chat routing, empty-input rejection, max_length cap i18n: - context_usage_* keys for the bar UI in en/sv .gitignore: - Ignore local review notes and ad-hoc UI screenshots * fix(tests): align cost-tracking unit tests + merge dual alembic heads Cost-tracking commit (3260dd0a) added input_cost_per_token / output_cost_per_token on the domain model and in the LiteLLM enrichment helper, but three tests still asserted the pre-cost shape. The recent merge from develop also brought in 202605061100, leaving 20260501_backfill_model_costs and 202605061100 as two heads diverging from 202604291030. Add an empty merge migration to collapse them. * fix(migrations): disambiguate LiteLLM cost backfill + tighten sessions downgrade 20260501_backfill_model_costs previously picked the alphabetically-first provider prefix when a model name appeared under multiple providers in LiteLLM, silently writing wrong prices (e.g. Azure-served gpt-4o getting OpenAI prices because azure < openai). It now JOINs model_providers to resolve provider_type per row and prefers the matching prefix; global models with ambiguous prefixes are skipped with a reported count instead of being guessed. 202605061100 downgrade previously dropped api_key_id before the SET NOT NULL on user_id, which left operators with no way to identify service-key sessions if the constraint re-application failed. The order is now: purge NULL-user_id rows → SET NOT NULL → drop column. * fix(sysadmin): convert force-delete IntegrityError to MODEL_IN_USE (400) 20260402_lifecycle switched questions.completion_model_id to RESTRICT so historical attribution cannot be silently erased. The sysadmin delete_completion_model(force=true) path issues a raw sa.delete, which now raises IntegrityError for any model with question history. Surfacing that as a 500 leaves operators with no actionable signal; catch and re-raise as ModelInUseException so the response carries the same 400 + MODEL_IN_USE (9039) code the soft-delete path already uses. Adds integration tests covering both force=true paths (with/without question history). * refactor(users): stop auto-provisioning personal API keys at user creation Every user created via /admin/users or /sysadmin/users/ silently received an ACTIVE admin-permission sk_ key in api_keys_v2. The plaintext was returned in the HTTP response but no frontend client read it (UserEditor.svelte ignored the api_key field), so the keys were minted, persisted, and forgotten — an unused admin-scope credential per created user with no way for the user themselves to ever see the value. Removes: - create_user_api_key_v2 from auth_service (no remaining callers) - the api_key field from UserCreatedAdminView - the api_key tuple slot from user_service.register()'s return shape - the api_key= kwarg in sysadmin/admin user-creation responses Users mint their own API keys on demand via POST /api/v1/api-keys, which is the only flow that actually surfaces a usable plaintext value. * fix(audit, migration-service): demote chatty WARNINGs + swallow audit enqueue failures Migration service: the MIGRATION_DEBUG [N/7] step logs were emitted at WARNING level on every successful migration, polluting alerting channels and leaking tenant_id/user_id on benign progress events. Demoted to INFO and dropped the "MIGRATION_DEBUG" prefix that betrayed leftover debug instrumentation. The genuine warning paths (user overriding compatibility issues, unknown entity types) keep their WARNING level. Audit service: log_async now treats Redis/ARQ enqueue failures as best-effort. A transient Redis outage previously propagated and 500'd every audited mutation. The handler now logs a warning, returns None, and lets the caller proceed — turning a partial degradation back into a partial degradation. * fix(db): build new completion-model indexes CONCURRENTLY + enforce sessions XOR 20260403 added two indexes on completion_models without CREATE INDEX CONCURRENTLY. On a large completion_models table that would lock the table for the duration of the build. Wrap the index creation (and the corresponding drops in downgrade) in autocommit_block + raw CONCURRENTLY SQL, mirroring the pattern from 202604221200. Adds a new migration 20260512_sessions_xor_check that enforces "exactly one of sessions.user_id / sessions.api_key_id is set" at the DB layer. The invariant was previously documented in sessions_table.py but only upheld by calling code, so a future regression (or manual tweak) could silently land a NULL/NULL row that no list query would surface. The ORM mapping picks up the same CHECK so model-driven setups stay in sync. * Revert "refactor(users): stop auto-provisioning personal API keys at user creation" This reverts commit 14a41524c3f31b0a844a1029e0ad6e470b798cce. * fix(migrations): shorten merge-heads revision id to fit varchar(32) alembic_version.version_num is varchar(32). The merge-migration's revision id 20260512_merge_cost_and_api_key_heads is 37 characters, so any UPGRADE that lands at that node fails with StringDataRightTruncation when alembic tries to write the new version_num. The dependent xor-check migration carried the same oversize identifier as its down_revision. Rename both occurrences to 20260512_merge_heads (20 chars). Filename unchanged. * refactor(migrations): linearize sessions branch + inline XOR check The branch had two follow-up migrations patching another migration that landed on the same branch — i.e. migrations-of-migrations that never need to exist in history because nothing outside this branch has run them yet. - 20260512_merge_heads only existed to converge two heads that diverged from 202604291030 (sessions-nullable vs cost-backfill). Re-parent 202605061100 onto 20260501_backfill_model_costs so the chain is linear and the merge node is unnecessary. - 20260512_sessions_xor_check just added a CHECK constraint to the sessions table that 202605061100 itself had introduced. Fold the constraint into 202605061100 since the invariant is part of the same design ("a session is either user-owned or service-key-owned"). Result: one head (202605061100), no merge migration, no follow-up patch migration. ORM-side CheckConstraint in sessions_table.py is unchanged. * fix(model-defaults): provider-aware lookup + drop preflight ts-ignore Extract a shared resolve_model_defaults() helper used by both the /model-defaults/ endpoint and the cost-backfill migration so the wizard's "Lookup defaults" button and the bulk backfill agree on which LiteLLM row maps to a given (name, provider_type) pair. Without the provider hint, Azure-served gpt-4o silently resolved to openai/gpt-4o prices. The endpoint now accepts provider_type as an optional query param. The wizard threads providerType through ModelDraftForm; the edit dialog uses the model's persisted provider_type when available. Ambiguous names without provider context return found=false and are skipped by the backfill so admins can disambiguate via the UI instead of receiving a silently-wrong price. Regenerating schema.d.ts also makes /conversations/preflight typed, so the handwritten PreflightResponse alias in resources.d.ts and the @ts-ignore in conversations.js are gone. * fix(tenant-models): fold is_default + classification into tenant update The edit dialog used to make two backend calls per save: first the tenant update endpoint, then a legacy /models/{id} update for is_default and security_classification because the tenant routes didn't accept them. If the second call failed the user saw the display name persist while the classification "saved" toast lied. Tenant*ModelUpdate now accepts both fields. The service applies them under the same transaction as the rest of the update — `is_default=True` unsets sibling defaults via the same helper used at create time, and `security_classification` runs through the cross-tenant guard in intric.security_classifications.tenant_validation. Sending an explicit `null` clears the classification (matching the existing model_fields_set semantics for nullable fields). Frontend: EditModelDialog merges the two fields into the build*Update() payloads and drops syncSecurityAndDefault(). The legacy intric.models.update fallback is no longer reachable from this dialog. The hidden is_org_default check stays so transcription models (no UI toggle today) don't accidentally send a stale default flag. Tests: - Drop the now-redundant test_backfill_model_costs_lookup.py (the shared resolver tests in test_model_defaults_lookup.py cover the same matrix). - Add integration tests for: promoting default unsets sibling, set+clear classification, cross-tenant classification rejected on update, and a combined save that mutates display name + default + classification in one request. Mirror tests for embedding + transcription. * refactor(completion-models): slim router migration + usage handlers The migration + usage endpoints had ~150 lines of scaffolding that duplicated work already done elsewhere: - Local `import logging` and `getLogger(__name__)` in three handlers. Hoisted to module level. - Local `from fastapi import HTTPException` repeated four times to re-validate `model_id is None`, `to_model_id is None`, `user.tenant_id is None`. FastAPI's path-param coercion and the current_user dependency already guarantee those — the manual checks would only fire on a bug we'd want to see as a real 500, not a silently-rewritten 400. - Same-model rejection lived in both the router and the service. Dropped from the router; CompletionModelMigrationService.migrate_model_usage already raises ValidationException with a richer error message that includes the conflicting model name. - Catch-all `except Exception: logger.error(...); raise` wrappers that added nothing FastAPI's own exception handlers don't already log, with the side effect of double-emitting errors at WARN+ERROR. Kept the targeted try/except around the audit log call — a failed audit must not break the user-facing response after the migration committed. Validation: pyright clean, 79 completion_models unit tests pass. * refactor(worker): explicit per-job session ownership for cleanup loop The lifecycle cleanup job had its own session-management quirk: the cron wrapper already opens a session+transaction, but cleanup opened a SECOND session and overrode the container's session provider with `cast(Any, container.session).override(providers.Object(session))`. The outer transaction did nothing while the inner one held the actual work, and the cast leaked the dependency_injector internals across an infrastructure boundary. Easy to copy wrong and hard to reason about. Worker.cron_job now accepts `manages_own_session=True`. With that flag the wrapper still hands the job a configured container but skips the implicit `session.begin()` — the job manages per-batch transactions itself. Default behaviour (one outer transaction) is unchanged so the nine other cron jobs are unaffected. Cleanup job changes: - Opts in via `manages_own_session=True`. - Drops the second sessionmanager.session() and the cast/override dance. - Adds a 500-row batch cap with deterministic ordering so a backlog drains over multiple weekly runs rather than locking the table once. - Drops the now-unused sessionmanager + dependency_injector.providers imports. Tests: - Update test container shape to the new contract (a callable that returns the session) and drop the sessionmanager monkeypatch. - Reach past the cron wrapper via __wrapped__ so unit tests exercise the cleanup logic without dragging in arq context or the real sessionmaker. - Add a regression guard that asserts the candidate query runs on the container-provided session. If a future refactor re-introduces the override pattern, this test fails. Validation: pyright clean, 1489 unit tests pass. * polish(admin-models): localise migration history + stabilise deprecation test Drop the last few hand-rolled strings + a calendar time-bomb the PR review flagged: - MigrationHistoryPanel: the load-failure fallback was a hardcoded English string. Status badges rendered the raw backend value ("completed", "in_progress") instead of a localised label. Both now go through the paraglide message layer with unknown statuses falling back to the raw key so a future backend addition doesn't blank the badge. - StepModels unsupported-provider warning: the alert interpolated raw `providerType` ("hosted_vllm") and raw `modelType` ("transcription") into the message. Threaded them through formatProviderLabel() and a small modelType → m.model_type_* map so the user sees "vLLM" and "transcription" rendered the same way as elsewhere in the wizard. - test_deprecation_lookup: the future-date fixture was a hardcoded "2026-12-01" + assertions like "this is in the future". That test starts failing the day the calendar passes 2026-12-01 with no code change. Replaced with date.today() ± 365 days so the past/future distinction stays stable indefinitely. No new English-only strings; sv.json and en.json kept in sync. * chore(frontend): format space settings page Drops a stray multi-line filter that prettier wanted on one line. Pre-existing formatting drift caught when running bun run lint end-to-end during the Fas 6 cleanup pass. * fix(model-migration): persist failure history * fix(tests): drop manual commit inside db_container session.begin block The db_container fixture already opens a session.begin() block that commits on exit. Calling session.commit() inside closes the transaction early, after which the trailing ORM attribute read (or the block's own __aexit__) crashes with InvalidRequestError. Removing the manual commits lets the surrounding transaction commit normally and the rows are visible to the subsequent HTTP requests through the shared sessionmanager. * style: apply ruff format to three modified files * fix: enforce effective model deprecation * fix(chat): guard tool SSE handlers against pre-first-chunk events onToolCall and onToolApprovalRequired read ref.mcp_tool_calls without verifying ref is defined. ref is only assigned inside onFirstChunk, so a tool_call or tool_approval_required event arriving before the first text chunk crashes the entire stream with "Cannot read 'mcp_tool_calls' of undefined". Both handlers also invoked ensureCurrentSession but ignored its return value, leaving them able to mutate a message belonging to a previous conversation if the user switched sessions mid-stream. Apply the same ref+isStale+ensureCurrentSession guard that onText, onImage, onIntricEvent and onToolApprovalTimeout already use. * fix(auth): validate resolved client IP so audit INET column never sees garbage resolve_client_ip returned request.client.host unchanged. Starlette's TestClient sets that to the literal "testclient", and proxies can inject non-IP values in X-Forwarded-For or X-Real-IP. Both end up flowing into the audit_logs.ip_address INET column (worker insert crashes with DataError) and the API-key allow-list parser (which calls ipaddress.ip_network and silently treats the match as a miss). Validate every extracted candidate against ipaddress.ip_address(); on ValueError return None so callers treat the IP as unknown rather than storing or comparing garbage. The api_key_policy _validate_ip path already raises 403 "Client IP unavailable" on None, which is the right fail-closed behaviour when an IP-restricted key cannot identify the caller. Tests cover non-IP hostnames, proxy-injected garbage in X-Forwarded-For, IPv6 happy path, and the middleware end-to-end propagation. * fix(auth): close disable_grace_period microsecond race api_key_lifecycle sets rotation_grace_until = datetime.now(timezone.utc) when the rotation request opts out of the grace window. The downstream compare in compute_effective_state used strict less-than, so a request arriving on the same microsecond would compute as still inside grace and authenticate with the now-rotated secret one more time. Make the grace comparison inclusive (<=) and add a dedicated unit test for the exact-at-now case plus a paired ACTIVE-during-grace control. The existing integration test for disable_grace_period only checked the DB field; extend it to also verify that a real HTTP request with the old secret receives 401 after rotation. * fix(admin-models): block EditModelDialog submit on missing token budgets handleSubmit only checked displayName and cost overflow before posting, so clearing max_input_tokens or max_output_tokens on an existing completion model sent null to the backend — which rejects it, and any downstream context-window math would have divided by zero anyway. Extract the existing AddWizard rule (isDraftComplete) into a shared hasValidCompletionTokenBudgets helper in draft.ts, then reuse it from both the wizard and the edit dialog so the two places cannot drift. * fix(auth): close legacy pk_ origin bypass when allowed_origins is NULL _validate_origin treated NULL allowed_origins as "permit every origin", intended as a back-compat shim for legacy pk_ rows minted before the field became required. The same passthrough also fires for any future regression that lets a row land with allowed_origins=NULL — silently disabling CORS-style origin enforcement for that key. Fail closed: NULL and empty list both raise origin_not_allowed (the same error path empty-list already used). Legacy keys that ended up NULL need to be rotated with an explicit origin list; the existing test that asserted the permit-legacy branch is inverted to lock in the closed behaviour. Production impact: any pk_ key still carrying allowed_origins=NULL stops authenticating. The deliberate trade-off is that this small population of misconfigured keys was already a CORS bypass — failing them loudly is preferable to silently accepting any origin. * docs(model-migration): document partial-state + failure-history edge cases Two related migration edge cases that surfaced during review but turned out to be deeper than a single-commit fix: 1. Selective entity_types — when "assistants" is passed without "spaces" the source model is enabled-then-orphaned on SpacesCompletionModels. The frontend always sends undefined (= all types), so this is only reachable via direct API calls and the dangling row is cosmetic (filtered by migrated_to_model_id checks and cleaned up by the model-cleanup worker on hard delete). Documented inline so future API-direct callers see why selective migrations skipping spaces are accepted but discouraged. 2. SQLAlchemyError except-block — writes migration_history through the same session whose outer transaction has already aborted, so true DB failures (deadlock, connection loss) silently lose the failed-row that the surrounding contract promises to persist. A proper fix needs a separate session via DI; flagged as a follow-up so we don't risk happy-path regressions in this round. * fix(sysadmin): map embedding force-delete IntegrityError to MODEL_IN_USE The completion-model force-delete branch wraps the destructive DELETE in a try/except that translates IntegrityError into MODEL_IN_USE (400), so operators get a meaningful response when an FK still references the model. The embedding-model branch had the same kind of risk (Websites and IntegrationKnowledge reference embedding_model_id without ondelete) but let the IntegrityError bubble up as a 500. Apply the same wrapper. Also flag the four sysadmin model-lifecycle endpoints with an inline AUDIT GAP note. audit_logs.tenant_id is NOT NULL, but a global model row has no owning tenant at create/delete time, so emitting an audit row from these paths would require either a system-tenant convention or a separate sysadmin-only audit store. Tenant-scoped lifecycle (tenant_model_service) already audits CREATED/DELETED correctly — the gap is only the cross-tenant sysadmin path, which is reachable solely via ENEO_SUPER_API_KEY and traced through app logs today. * fix(security-classifications): exclude soft-deleted models from in-use check count_usages drove the "cannot delete, X resources still reference this classification" gate, but completion_models retain their FK after soft-delete. The admin would then see e.g. "3 completion_models in use" with no live model to actually reassign. Filter on deleted_at IS NULL so the count reflects only what the admin can act on. Also document the count → delete TOCTOU window inline. A new row linked to this classification between the two statements gets cleared to NULL via ON DELETE SET NULL — but NULL is the default/restrictive state, so the damage is operational (admin reassigns) rather than a privilege escalation. Postgres does not propagate row-level locks across FK checks, so SELECT FOR UPDATE on the classification would not block the racing INSERT — closing the gap properly needs SERIALIZABLE isolation, deferred until we see it in practice. * fix(audit): reraise programming errors in audit enqueue instead of swallowing The bare `except Exception` swallowed every failure from job_manager.enqueue so that an unreachable Redis would not turn a successful mutation into a 500. That contract is right, but it also hid TypeError on non-serialisable params, ValueError on invalid input, and AttributeError / AssertionError from refactors — bugs that should surface in dev/CI rather than vanish into a warning log. Catch the four programming-error classes ahead of the broad handler and let them propagate. Genuine infrastructure failures (Redis/ARQ unreachable, network timeouts) still hit the swallowing branch. * fix(model-migration): restore WARNING level for rejected validation Validation-rejected migrations were demoted from WARNING to INFO when the MIGRATION_DEBUG step logs were demoted, which buried a real "system blocked this admin action" event in normal success-step output. Operator alarm rules that watched WARN on this message stopped firing. Restore WARNING for the rejection branch only — the per-step success logs stay INFO since they fire on every migration run. * fix(admin-models): handle partial failure in AddWizard createModels loop The wizard's createModels iterated sequentially with a bare for-loop, so the first throw aborted the run with no toast, no closure, and the already-created rows still sitting in wizardData.models. The next submit attempt hit duplicate-name 400s from the backend with no way out of the dialog except cancel. Track succeeded vs failed per model, drop the succeeded ones from wizardData.models so a retry only resubmits what actually failed, and branch on the outcome: - All succeeded: existing success toast + close. - All failed: re-raise the first error so the existing catch sets the error banner and keeps the dialog open. - Partial: warning toast naming the failing models; failed rows stay in the list for the user to fix in place. New i18n key `models_partially_created` in en + sv. * polish: trim historical narrative from comments left by review fixes Code comments should carry forward value (invariants, edge cases, TODOs, cross-references) — not narrate what a diff changed. Reviewers and future readers get the history from git log; the inline comments should explain the contract that exists now. * docs(api-keys): document per-key origin trust model pk_ allowed_origins is the only authority — no tenant-wide allowlist overrides individual keys. Make the trust model explicit so operators understand that key creators choose origins, and that a NULL/empty list is rejected at auth rather than silently permitting any origin. * fix: small review follow-ups across model migration, preflight, and downgrade - Audit description for COMPLETION_MODEL_MIGRATED now resolves the target model name instead of echoing the UUID, so the operator-facing log string reads "from A to B" rather than "from A to <uuid>". - Legacy v1 -> v2 API-key migration: clarify the inline comment. Mapping non-admin owners to tenant+write does not actually let the migrated key authenticate (the resolver's owner-admin guard still rejects it); we preserve the row for data continuity, the key only becomes usable if the owner is later promoted. - Preflight: surface `excluded_file_count` so callers know image/binary attachments contributed unknown tokens. Tokens already exclude them; this just makes the omission explicit in the response contract. - Sessions downgrade migration: count and log the service-key sessions about to be purged so operators see the data-loss footprint before the DELETE runs. The CASCADE down to questions is irreversible. * fix(alembic): inline model-defaults resolver in cost backfill migration The backfill migration imported intric.model_providers.domain.model_defaults_lookup at module top, which makes a fresh-DB alembic upgrade fragile: any future rename or move of that module would break replaying old migrations. Inline the two functions (resolve_model_defaults, is_ambiguous) as module- local helpers. The runtime module stays the single source of truth for the wizard's "Lookup defaults" button; the migration carries a frozen copy for replay safety. A comment in both files flags that they must stay in sync if either changes. * polish(auth): drop redundant grace-comparison comment The inclusive <= behaviour is local enough to read from the code itself. * fix(auth): close pk_ allowed_origins null gap on update path validate_create_request rejects allowed_origins=None for pk_, but validate_update_request only blocked the empty-list case. Frontend edit form sent `null` when an admin cleared the origin list, so the PATCH landed and the next request hit the fail-closed branch in _validate_origin — locking the key out the moment it was edited. Mirror the create-path rule on update (None or [] → 400). Frontend now blocks the submit and surfaces api_keys_origin_required, so the round-trip is short-circuited before the backend ever rejects it. Adds test_update_request_rejects_null_allowed_origins_for_pk to lock the new behaviour in. * fix(admin-models): clear pendingDraft on partial create success finishWizard pushes pendingDraft into modelsToCreate by reference, but the partial-failure cleanup only filtered wizardData.models. If the unsubmitted draft succeeded while another row failed, the draft stayed in the form — the next retry resubmitted it and the backend rejected on duplicate name, defeating the whole point of the partial-failure fix. Filter pendingDraft against the succeeded list by identity in the same block. * fix(chat): materialize message when tool_approval_required arrives first onToolApprovalRequired guarded against ref being undefined by early-returning, mirroring onToolCall. For onToolCall that is acceptable — the event is cosmetic and gets replayed. For an approval event it is not: pendingToolApproval never gets set, the approve/deny buttons never render, and the backend keeps waiting for a decision that will never come. When ref is missing, create the placeholder message inline (like onFirstChunk does) and advance currentConversation.id from the event. ref.session_id is not assigned directly — a later onFirstChunk will Object.assign it, and the message itself does not need session_id for the approval UI. * fix(tests): add deprecation attrs to SimpleNamespace fixture Assembler now reads is_effectively_deprecated and litellm_deprecation_date, so the SimpleNamespace stand-in must expose both.
1 parent 6c43e95 commit 975235b

191 files changed

Lines changed: 15616 additions & 6327 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ backend/exports/
2828
# test results
2929
backend/test-results/
3030

31+
# Local review notes and ad-hoc UI screenshots (not part of the codebase)
32+
REVIEW_*.md
33+
REVIEW_PLAN.md
34+
REVIEW_REPORT.md
35+
chat-*.png
36+
notes/
37+
3138
# claude
3239
.claude/*.local.json
3340
backend/openapi.json
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""fix migration history cascade and add model names
2+
3+
Store model names directly in migration history so records survive
4+
model deletion. Change FK ondelete from CASCADE to SET NULL.
5+
6+
Revision ID: fix_migration_history
7+
Revises: 20260319_add_nickname
8+
Create Date: 2026-04-01
9+
10+
"""
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
# revision identifiers, used by Alembic
15+
revision = "fix_migration_history"
16+
down_revision = "20260319_add_nickname"
17+
branch_labels = None
18+
depends_on = None
19+
20+
21+
def upgrade() -> None:
22+
# Add name columns to store model names directly
23+
op.add_column(
24+
"completion_model_migration_history",
25+
sa.Column("from_model_name", sa.String(255), nullable=True),
26+
)
27+
op.add_column(
28+
"completion_model_migration_history",
29+
sa.Column("to_model_name", sa.String(255), nullable=True),
30+
)
31+
32+
# Backfill names from existing model data
33+
op.execute("""
34+
UPDATE completion_model_migration_history h
35+
SET from_model_name = m.name
36+
FROM completion_models m
37+
WHERE h.from_model_id = m.id AND h.from_model_name IS NULL
38+
""")
39+
op.execute("""
40+
UPDATE completion_model_migration_history h
41+
SET to_model_name = m.name
42+
FROM completion_models m
43+
WHERE h.to_model_id = m.id AND h.to_model_name IS NULL
44+
""")
45+
46+
# Drop old FK constraints with CASCADE
47+
op.drop_constraint(
48+
"completion_model_migration_history_from_model_id_fkey",
49+
"completion_model_migration_history",
50+
type_="foreignkey",
51+
)
52+
op.drop_constraint(
53+
"completion_model_migration_history_to_model_id_fkey",
54+
"completion_model_migration_history",
55+
type_="foreignkey",
56+
)
57+
58+
# Make model ID columns nullable (model may be deleted)
59+
op.alter_column(
60+
"completion_model_migration_history",
61+
"from_model_id",
62+
nullable=True,
63+
)
64+
op.alter_column(
65+
"completion_model_migration_history",
66+
"to_model_id",
67+
nullable=True,
68+
)
69+
70+
# Re-create FK constraints with SET NULL
71+
op.create_foreign_key(
72+
"completion_model_migration_history_from_model_id_fkey",
73+
"completion_model_migration_history",
74+
"completion_models",
75+
["from_model_id"],
76+
["id"],
77+
ondelete="SET NULL",
78+
)
79+
op.create_foreign_key(
80+
"completion_model_migration_history_to_model_id_fkey",
81+
"completion_model_migration_history",
82+
"completion_models",
83+
["to_model_id"],
84+
["id"],
85+
ondelete="SET NULL",
86+
)
87+
88+
89+
def downgrade() -> None:
90+
# Drop new FK constraints
91+
op.drop_constraint(
92+
"completion_model_migration_history_from_model_id_fkey",
93+
"completion_model_migration_history",
94+
type_="foreignkey",
95+
)
96+
op.drop_constraint(
97+
"completion_model_migration_history_to_model_id_fkey",
98+
"completion_model_migration_history",
99+
type_="foreignkey",
100+
)
101+
102+
# Make columns non-nullable again
103+
op.alter_column(
104+
"completion_model_migration_history",
105+
"from_model_id",
106+
nullable=False,
107+
)
108+
op.alter_column(
109+
"completion_model_migration_history",
110+
"to_model_id",
111+
nullable=False,
112+
)
113+
114+
# Re-create old FK constraints with CASCADE
115+
op.create_foreign_key(
116+
"completion_model_migration_history_from_model_id_fkey",
117+
"completion_model_migration_history",
118+
"completion_models",
119+
["from_model_id"],
120+
["id"],
121+
ondelete="CASCADE",
122+
)
123+
op.create_foreign_key(
124+
"completion_model_migration_history_to_model_id_fkey",
125+
"completion_model_migration_history",
126+
"completion_models",
127+
["to_model_id"],
128+
["id"],
129+
ondelete="CASCADE",
130+
)
131+
132+
# Drop name columns
133+
op.drop_column("completion_model_migration_history", "to_model_name")
134+
op.drop_column("completion_model_migration_history", "from_model_name")
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""add model lifecycle fields for migration and soft-delete
2+
3+
Add migrated_to_model_id and deleted_at to completion_models.
4+
Change questions.completion_model_id FK from SET NULL to RESTRICT
5+
to prevent silent loss of historical question attribution.
6+
7+
Revision ID: 20260402_lifecycle
8+
Revises: fix_migration_history
9+
Create Date: 2026-04-02
10+
11+
"""
12+
from alembic import op
13+
import sqlalchemy as sa
14+
from sqlalchemy.dialects.postgresql import UUID
15+
16+
# revision identifiers, used by Alembic
17+
revision = "20260402_lifecycle"
18+
down_revision = "fix_migration_history"
19+
branch_labels = None
20+
depends_on = None
21+
22+
23+
def upgrade() -> None:
24+
# 1. Add migrated_to_model_id (self-referencing FK with RESTRICT)
25+
op.add_column(
26+
"completion_models",
27+
sa.Column("migrated_to_model_id", UUID(as_uuid=True), nullable=True),
28+
)
29+
op.create_foreign_key(
30+
"fk_completion_models_migrated_to",
31+
"completion_models",
32+
"completion_models",
33+
["migrated_to_model_id"],
34+
["id"],
35+
ondelete="RESTRICT",
36+
)
37+
38+
# 2. Add deleted_at for soft-delete
39+
op.add_column(
40+
"completion_models",
41+
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
42+
)
43+
44+
# 3. Change questions.completion_model_id FK from SET NULL to RESTRICT
45+
op.drop_constraint(
46+
"questions_completion_model_id_fkey", "questions", type_="foreignkey"
47+
)
48+
op.create_foreign_key(
49+
"questions_completion_model_id_fkey",
50+
"questions",
51+
"completion_models",
52+
["completion_model_id"],
53+
["id"],
54+
ondelete="RESTRICT",
55+
)
56+
57+
58+
def downgrade() -> None:
59+
# Revert questions FK to SET NULL
60+
op.drop_constraint(
61+
"questions_completion_model_id_fkey", "questions", type_="foreignkey"
62+
)
63+
op.create_foreign_key(
64+
"questions_completion_model_id_fkey",
65+
"questions",
66+
"completion_models",
67+
["completion_model_id"],
68+
["id"],
69+
ondelete="SET NULL",
70+
)
71+
72+
# Remove deleted_at
73+
op.drop_column("completion_models", "deleted_at")
74+
75+
# Remove migrated_to_model_id
76+
op.drop_constraint(
77+
"fk_completion_models_migrated_to", "completion_models", type_="foreignkey"
78+
)
79+
op.drop_column("completion_models", "migrated_to_model_id")
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""add provider snapshots for migration history and lifecycle cleanup indexes
2+
3+
Revision ID: 20260403_cleanup_history
4+
Revises: 20260402_lifecycle
5+
Create Date: 2026-04-03
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "20260403_cleanup_history"
15+
down_revision = "20260402_lifecycle"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade() -> None:
21+
op.add_column(
22+
"completion_model_migration_history",
23+
sa.Column("from_provider_type", sa.String(length=255), nullable=True),
24+
)
25+
op.add_column(
26+
"completion_model_migration_history",
27+
sa.Column("to_provider_type", sa.String(length=255), nullable=True),
28+
)
29+
30+
op.execute(
31+
"""
32+
UPDATE completion_model_migration_history AS history
33+
SET from_provider_type = providers.provider_type
34+
FROM completion_models AS models
35+
LEFT JOIN model_providers AS providers ON models.provider_id = providers.id
36+
WHERE history.from_model_id = models.id
37+
AND history.from_provider_type IS NULL
38+
"""
39+
)
40+
op.execute(
41+
"""
42+
UPDATE completion_model_migration_history AS history
43+
SET to_provider_type = providers.provider_type
44+
FROM completion_models AS models
45+
LEFT JOIN model_providers AS providers ON models.provider_id = providers.id
46+
WHERE history.to_model_id = models.id
47+
AND history.to_provider_type IS NULL
48+
"""
49+
)
50+
# Build the new indexes without holding a table-level lock — on a large
51+
# `completion_models` table the default CREATE INDEX would block writes
52+
# for the duration of the build. See 202604221200 for the same pattern,
53+
# including notes on INVALID-index recovery if CONCURRENTLY fails
54+
# mid-build (Alembic still marks the migration applied).
55+
with op.get_context().autocommit_block():
56+
op.execute(
57+
"""
58+
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_completion_models_deleted_at
59+
ON completion_models (deleted_at);
60+
"""
61+
)
62+
op.execute(
63+
"""
64+
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_completion_models_migrated_to_model_id
65+
ON completion_models (migrated_to_model_id);
66+
"""
67+
)
68+
69+
70+
def downgrade() -> None:
71+
with op.get_context().autocommit_block():
72+
op.execute(
73+
"DROP INDEX CONCURRENTLY IF EXISTS ix_completion_models_migrated_to_model_id;"
74+
)
75+
op.execute(
76+
"DROP INDEX CONCURRENTLY IF EXISTS ix_completion_models_deleted_at;"
77+
)
78+
op.drop_column("completion_model_migration_history", "to_provider_type")
79+
op.drop_column("completion_model_migration_history", "from_provider_type")
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""add input/output cost columns to model tables
2+
3+
Adds optional USD cost columns so admins can record (and we can display) the
4+
indicative price of each model. Token-based costs are stored as `NUMERIC(20, 12)`
5+
to preserve precision down to fractions of a cent per token (modern frontier
6+
models are priced in 1e-7 USD/token territory).
7+
8+
Token-based modes (chat/completion + embedding):
9+
- input_cost_per_token
10+
- output_cost_per_token
11+
12+
Per-minute mode (transcription) gets its own column rather than reusing the
13+
token columns, since the underlying ratecard is structured differently:
14+
- cost_per_minute
15+
16+
All columns are nullable. Self-hosted/unknown providers are expected to leave
17+
them unset; the UI displays an em-dash and excludes the model from cost
18+
aggregation when any contributing rate is missing.
19+
20+
This revision also merges the two parallel heads on the develop branch.
21+
22+
Revision ID: 20260430_add_model_costs
23+
Revises: 202604291030, 20260403_cleanup_history
24+
Create Date: 2026-04-30 12:00:00.000000
25+
"""
26+
27+
import sqlalchemy as sa
28+
29+
from alembic import op
30+
31+
# revision identifiers, used by Alembic
32+
revision = "20260430_add_model_costs"
33+
down_revision = ("202604291030", "20260403_cleanup_history")
34+
branch_labels = None
35+
depends_on = None
36+
37+
38+
def upgrade() -> None:
39+
# Token-based cost on completion + embedding tables.
40+
for table in ("completion_models", "embedding_models"):
41+
op.add_column(
42+
table,
43+
sa.Column("input_cost_per_token", sa.Numeric(20, 12), nullable=True),
44+
)
45+
op.add_column(
46+
table,
47+
sa.Column("output_cost_per_token", sa.Numeric(20, 12), nullable=True),
48+
)
49+
50+
# Transcription is priced per minute of audio. Six decimals is enough for
51+
# any provider we've seen (Whisper is 0.006 USD/min).
52+
op.add_column(
53+
"transcription_models",
54+
sa.Column("cost_per_minute", sa.Numeric(20, 6), nullable=True),
55+
)
56+
57+
58+
def downgrade() -> None:
59+
op.drop_column("transcription_models", "cost_per_minute")
60+
for table in ("completion_models", "embedding_models"):
61+
op.drop_column(table, "output_cost_per_token")
62+
op.drop_column(table, "input_cost_per_token")

0 commit comments

Comments
 (0)