fix(migrations): repair deepseek default seeded onto self-hosted - #528
Open
juanmichelini wants to merge 10 commits into
Open
juanmichelini wants to merge 10 commits into
juanmichelini wants to merge 10 commits into
Conversation
Migrations 158/160 seeded the managed deepseek-v4-flash verified_models row (default+enabled+free+verified) on every host, including self-hosted, where no managed LiteLLM proxy serves it. #476 gated the seed so fresh self-hosted installs no longer get it; this repairs hosts that already ran 158/160 before that gate landed. On self-hosted (WEB_HOST-gated; SaaS skips so the managed default stays): - DELETE the deepseek verified_models row, converging upgraded DBs to the same state as a fresh install (which post-#476 never seeds it). - Restore org Default LLM profiles baked onto org.llm_profiles via activate_profile while the bogus default was live. Most orgs self-heal once the row is gone; only orgs whose concrete BYOK Default was overwritten (and survives only in org.agent_settings.llm) get an active restore. Phantoms (no legacy model) are stripped; ambiguous managed- legacy cases are left for manual review. org.agent_settings is plain JSON; org.llm_profiles is EncryptedJSON, so the restore decrypts/encrypts inline like migration 137. Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
…ith main main merged 166_track_budget_alert_delivery / 167 / 168 / 169 after this branch cut from 165, so revision '166' collided and produced 'Revision 166 is present more than once' (duplicate head) on the PR merge ref. Renumber to 170 (next free) with down_revision '169', and rename the migration and its test accordingly. Co-authored-by: openhands <openhands@all-hands.dev>
Two CI jobs failed on fork PRs for reasons unrelated to the PR diff:
1. enterprise-check-migrations / check-sync: the final 'Comment warning on
PR' step hits a 403 ('Resource not accessible by integration') because
fork PRs run with a read-only GITHUB_TOKEN that cannot post comments.
That failed check skipped the apply-migrations job that needs it. The
comment is informational, so mark the step continue-on-error: true; the
real integrity/ancestor checks still gate the job.
2. ghcr-build / Enterprise: pushing to ghcr.io fails with 'denied:
installation not allowed to Write organization package' on fork PRs.
Skipping the build would leave the required per-arch checks stuck at
'Expected' forever, so instead add a 'push' input to _build-image.yml
and pass push: false on fork PRs — the image still compiles (build-only,
no registry push/cache) so the required 'Build ... (amd64/arm64)' checks
report success. The merge-manifest job is skipped on build-only runs
since it needs pushed images. Non-fork PRs and pushes are unchanged.
Co-authored-by: openhands <openhands@all-hands.dev>
|
|
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||
This reverts commit 1c64ddf.
This comment was marked as outdated.
This comment was marked as outdated.
Migration 170 declared org.llm_profiles as sa.JSON() in its sa.table() construct. org.llm_profiles is an EncryptedJSON column whose impl is String: the at-rest value is a JWE ciphertext string, not JSON. On the write path, SQLAlchemy's JSON bind processor JSON-encodes the ciphertext string (wrapping it in quotes), storing `"<ciphertext>"` and making the column permanently undecryptable for any org 170 actually repaired. This was invisible to clean-install VM tests (fresh DB has no pre-seeded corrupted org, so 170's write-back loop hits zero rows and to the existing unit test (fakes the bind/result path, never applying the JSON bind processor). It is caught by a real-Postgres integration test that seeds orgs with real encrypted llm_profiles before running 170 (Option B: preserved-DB / in-place-upgrade scenario). Root cause: sa.JSON() is benign on the read side (postgres _PGJSON result_processor is a no-op / pass-through, so _decrypt_profiles got the raw ciphertext string) but destructive on the write side (bind_processor double-encodes). Switching to sa.String() matches the column's real impl and the migration-137 precedent. Adds scripts/verify_migratAdds scripts/verify_migratAdds scripts/verify_migratAdds scripts/verify_mns the chain to 169, seeds restore/strip/noop orgs with real JWE ciphertext, runwith real JWE ciphertext, runwith real JWE ciphertext, runwith real JWE iewith real JWE ciphertext, runwith real JWE ciph existing unit tests still green (18 passed). Co-authored-by: openhands <openhands@all-hands.dev> EOF )
Co-authored-by: openhands <openhands@all-hands.dev>
tofarr
approved these changes
Sep 28, 2026
juanmichelini
enabled auto-merge (squash)
September 28, 2026 12:29
juanmichelini
disabled auto-merge
September 28, 2026 12:30
…'s 170) main merged migration 170 (add_user_allow_match_by_email) after this PR branched, creating a duplicate revision 170 / filename prefix 170. Renumber the deepseek repair migration to 171 (down_revision='170') so the migration graph keeps a single linear head. - migrations/versions/170_repair... -> 171_repair... (revision 171, revises 170) - tests/unit/test_migration_170_repair... -> test_migration_171_repair... - scripts/verify_migration_170_writeback.py -> verify_migration_171_writeback.py Verified: check_enterprise_migration_integrity passes, alembic heads = 171 (single head), test_migration_graph + test_enterprise_migration_integrity (15 passed), migration 171 unit tests (18 passed), ruff clean. Co-authored-by: openhands <openhands@all-hands.dev>
saurya
approved these changes
Sep 28, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Human
Tested on replicated VM in two phases.
Phase 1 before migration
Phase 2 after migration
Also teted on SaaS which remained unaffected
Agent
Summary
Follow-up to #476. Migrations
158/160seeded the manageddeepseek-v4-flashverified_modelsrow (is_default = is_enabled = is_free = is_verified = true) on every host, including self-hosted, where no managed LiteLLM proxy serves the model. #476 gated the seed so fresh self-hosted installs no longer get it; this migration repairs hosts that already ran158/160before that gate landed.What it does
On a self-hosted host (
WEB_HOSTnot a managed SaaS host; SaaS skips the migration so the managed default stays correct):Step 1 — DELETE the
openhands/deepseek-v4-flashverified_modelsrow. Removing the whole row (not just clearingis_default) drops the default and the model-picker surface (is_enabled→picker list,is_enabled AND is_free→"Free" badge). This converges an upgraded self-hosted DB to the same state as a fresh install, which post-#476 never seeds the row.Step 2 — restore org
DefaultLLM profiles that got baked ontoorg.llm_profiles. The runtime materializer overlays theDefaultprofile at read time from theis_defaultrow, andactivate_profilewrites that overlay back into storedorg.llm_profiles. Most orgs self-heal once the row is gone (the materializer strips anyopenhands/-modelDefaultat read time when no DB default exists); only orgs whose concrete BYOKDefaultwas overwritten need an active restore.Per-org classification (decrypt
org.llm_profiles, readorg.agent_settings.llm— the legacy LLM, which is plain JSON and was not persisted by the materializer, so it survives as a forensic source):Defaultagent_settings.llm.modelopenhands/deepseek-v4-flashDefaultfromagent_settings.llmopenhands/deepseek-v4-flashDefault; clearactiveif it pointed thereopenhands/deepseek-v4-flashorg.agent_settingsis plain JSON;org.llm_profilesisEncryptedJSON, so the restore decrypts/encrypts inline like migration137(from storage.encrypt_utils import decrypt_value/encrypt_value). Thedowngradeis a no-op — re-applying the bug is not a safe restore.Key finding that bounds the blast radius
The deepseek override is a read-time overlay, not a stored mutation.
materialize_default_llm_profileoverlays theDefaultin memory on every load, andload()does not write it back (its only persist path,_persist_seeded_default_profile, fires only when there is no DB default — the opposite of the bug condition). So storedorg.llm_profiles/org.agent_settings.llmare pristine unless a mutating endpoint ran. The only bake path isactivate_profile(it materializes before the_org_profiles_transactioncommit). That's why only therestore/stripbuckets need stored-byte surgery.What this does / does not do
158/160pre-fix(migrations): gate deepseek default seed on SaaS WEB_HOST only #476.WEB_HOSTgate skips the migration).reviewbucket (stored Default is deepseek and the legacy model is itself managed) cannot distinguish "genuinely wanted deepseek" from "also corrupted but no forensic trace" — left for manual review / backup restore. The row delete still strips it at read time.noop).Write-back bug found & fixed (sa.JSON → sa.String)
Reviewer concern (resolved): the write-back path was unverified — clean-install VM tests and the unit tests could not exercise it, because both start from an empty/no-corrupted-org state where migration 171's restore/strip loop hits zero rows.
A real-Postgres integration test (
scripts/verify_migration_171_writeback.py) was added that reproduces the in-place-upgrade / preserved-DB scenario: it runs the migration chain to170, seeds orgs with real JWE-encryptedllm_profiles(restore / strip / noop buckets) before running 171, then verifies the ORM can decrypt + load the expected Default afterward.Phase 1 (bug present,
sa.JSON()): migration 171 declaredorg.llm_profilesassa.JSON()in itssa.table()construct.org.llm_profilesis anEncryptedJSONcolumn whose impl isString— the at-rest value is a JWE ciphertext string, not JSON. On the write path, SQLAlchemy's JSON bind processor JSON-encodes the ciphertext string, wrapping it in quotes, so the DB stored"<ciphertext>"instead of<ciphertext>— permanently undecryptable for every org 171 actually repaired (restore + strip). Measured directly: 171'sencrypt_valueproduced valid 429/236-char ciphertext that round-tripped at write time, but the DB held 431/238-char values literally starting and ending with". Thenooporg (untouched by 171) decrypted fine.This was invisible to:
sa.JSON()is benign on the read side (postgres_PGJSONresult_processoris a no-op / pass-through, so_decrypt_profilesreceived the raw ciphertext string) but destructive on the write side (bind_processor double-encodes).Phase 2 (fix applied,
sa.String()): switched the column tosa.String(), matching the realEncryptedJSONimpl=String and the migration-137 precedent. All three buckets now PASS on both psycopg2 and pg8000:restore→ Default restored to the BYOKanthropic/claude-3-5-sonnet(model + base_url + api_key) ✅strip→ phantom Default removed,active = null✅noop→ untouched ✅Testing
--noconftest, no Postgres needed): 18 passed — per-bucket outcomes (restore/strip/review/noop), verified_models DELETE, WEB_HOST gate (SaaS skip + self-hosted run), other-profile preservation, and idempotency.test_enterprise_migration_integrity.py+test_migration_graph.py: 15 passed (single linear head preserved at171).scripts/verify_migration_171_writeback.py): ALL PASS on psycopg2 and pg8000 — seeds real JWE-encryptedllm_profilesfor restore/strip/noop orgs before running 171, then verifies the ORM decrypts + loads the expected Default. Reproduces Phase 1 (bug) and confirms Phase 2 (fix).ruffclean. Realencrypt_value/decrypt_valueround-trip exercised in tests.This pull request was created by an AI agent (OpenHands) on behalf of @juanmichelini.
Co-authored-by: openhands openhands@all-hands.dev
Renumber note
This migration was originally numbered
166(branched when165was head).mainhas since merged166-170, so166/170collided on the PR merge ref. Renumbered to171(down_revision =170) to keep a single linear head; migration and test files renamed accordingly.test_migration_graph.py+test_enterprise_migration_integrity.pyconfirm one linear head at171`.Enterprise server image for this PR: