diff --git a/.env.production.example b/.env.production.example index dc6d226c1..39868372c 100644 --- a/.env.production.example +++ b/.env.production.example @@ -375,11 +375,11 @@ API_TOKEN_HMAC_KEY="" # per-user BYO keys keep the pinned public-host posture either way. # ADMIN_AI_BASE_URL_ALLOWLIST="" -# Codex (ChatGPT sign-in) model slugs. CODEX_MODEL pins one slug ahead of the -# built-in chain; CODEX_MODEL_FALLBACK_CHAIN replaces the chain outright -# (comma-separated, first match wins). Reach for these when the provider -# rotates a slug and requests start failing with 503 -- without them a -# rotation needs a new release to fix. +# Codex (ChatGPT sign-in) model slugs. Left unset, each sign-in picks from +# the models its account is offered (read from the provider the way the Codex +# CLI reads it), with a built-in order as the fallback. CODEX_MODEL pins one +# slug ahead of that; CODEX_MODEL_FALLBACK_CHAIN replaces the order outright +# (comma-separated, first match wins) and the account's list is then not read. # CODEX_MODEL="" # CODEX_MODEL_FALLBACK_CHAIN="" diff --git a/CHANGELOG.md b/CHANGELOG.md index f463bd1ad..efa959829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # Changelog +## [1.46.3] — 2026-10-29 + +### Fixed + +- **Codex picks from what the account is offered.** OpenAI retires Codex + models for ChatGPT sign-in in steps, and every retirement left Codex + refusing every request until a release changed the built-in list. Each + sign-in now reads its account's model list, the way the Codex CLI does, + and chooses from it in HealthLog's order of preference. Whether a model + reads images comes from the same list. If the list can't be read, the + built-in order applies, and `CODEX_MODEL` and + `CODEX_MODEL_FALLBACK_CHAIN` keep their meaning. The working model is + remembered per sign-in instead of in one slot for the whole server, so + two accounts on different plans, or the shared central connection, no + longer overwrite each other, and a sign-in reconnected to another + account starts fresh. (#1287) +- **A gateway that refuses the reasoning field is asked without it for a + day.** The lesson held for an hour, and every expiry cost one refused + request, a few a day on a gateway that runs the briefings unattended. + Lessons read from a single reply keep the hour. (#1279) + +### Added + +- **Lab values on the timeline.** The values menu lists the lab markers + the record has, and a chosen marker draws its readings as their own + points, never averaged, with the reference range behind them. A reading + outside the range is drawn as a triangle and named in words. A tap shows + the day, the value, the range and the report it came from. Readings in a + unit that cannot be converted are left out and the timeline says how + many. Up to 24 markers at once; the choice is remembered like the other + value lines. (#1299) +- **Restoring the backup of an erased account.** An upload whose account + no longer exists used to be refused with nowhere to go. Admin, Backups + now offers to restore it into a new account created for it, or into an + existing account that holds no records beyond what sign-up and setup + leave, confirmed by typing its username. Admin accounts are not offered. + Nothing names the erased account afterwards. A new account has no + password until an admin sets one with Reset password. Restoring the same + file into a second account is refused with an explanation instead of a + generic failure. The runbook in `docs/ops/backup-restore.md` has the + details. + +### Thanks + +@haritowa for taking the Codex retirements apart in #1287 and proposing +the live list. @mills1975 for the timeline idea in #1299. @Tio61 for three +days of request counts that showed what the hour still cost (#1279). + ## [1.46.2] — 2026-10-29 ### Fixed diff --git a/docker-compose.yml b/docker-compose.yml index eb6dc7dcf..b7e7faef3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -347,11 +347,11 @@ x-healthlog-env: &healthlog_env # you run a gateway this instance should trust; it widens only the admin # setting, never the per-user BYO-key posture. ADMIN_AI_BASE_URL_ALLOWLIST: "${ADMIN_AI_BASE_URL_ALLOWLIST:-}" - # Codex (ChatGPT sign-in) model slugs. Set CODEX_MODEL to pin one slug - # ahead of the built-in chain, or CODEX_MODEL_FALLBACK_CHAIN (comma- - # separated) to replace the chain outright. Reach for these when the - # provider rotates a slug and every request starts coming back 503 — - # without them a rotation needs a new release to fix. + # Codex (ChatGPT sign-in) model slugs. Unset, each sign-in picks from the + # models its account is offered, with a built-in order as the fallback. + # Set CODEX_MODEL to pin one slug ahead of that, or + # CODEX_MODEL_FALLBACK_CHAIN (comma-separated) to replace the order + # outright; the account's list is then not read. CODEX_MODEL: "${CODEX_MODEL:-}" CODEX_MODEL_FALLBACK_CHAIN: "${CODEX_MODEL_FALLBACK_CHAIN:-}" # Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f31a99c8e..e6973a27c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.46.2 + version: 1.46.3 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -25090,10 +25090,52 @@ paths: same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account - that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens - cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST - /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it - the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give." + that does not exist here (`meta.errorCode` = `backup.owner.missing`). A file of an account that no longer exists + here (erased, say) can be sent again with a target in the query: `into=existing&userId=&confirm=` + for an account that holds no records yet, or `into=new&username=` for a new account created for the copy, + with no password and no email (an admin sets its first password with the existing password reset). The file's + owner is rewritten to that account as it is read, so the stored copy is that account's copy and restores like + any other; the erased account's id is not stored. A target is refused before the file is read when the account + does not exist, is a managed profile, is pending deletion, or holds records (a restore replaces records, it does + not merge them); a file whose own account is still here is refused with a target (`backup.owner.exists`). Three + uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A + browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last + five minutes (with a second factor, on an account that has one). Without it the answer is 401 + `auth.reproof.required`, with `meta.methods` naming the proofs the account can give." + parameters: + - in: query + name: into + schema: + description: "Where the copy goes when the account the file names does not exist here. Absent: the file's own account, + which has to exist." + type: string + enum: + - existing + - new + description: "Where the copy goes when the account the file names does not exist here. Absent: the file's own account, + which has to exist." + - in: query + name: userId + schema: + description: "With `into=existing`: the account's id." + type: string + maxLength: 64 + description: "With `into=existing`: the account's id." + - in: query + name: confirm + schema: + description: "With `into=existing`: the account's username, typed by the operator." + type: string + maxLength: 64 + description: "With `into=existing`: the account's username, typed by the operator." + - in: query + name: username + schema: + description: "With `into=new`: the new account's username (letters, digits, `_` and `-`)." + type: string + minLength: 3 + maxLength: 30 + description: "With `into=new`: the new account's username (letters, digits, `_` and `-`)." requestBody: required: true content: @@ -25139,6 +25181,22 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": *a25 + "404": + description: The account chosen with `into=existing` does not exist (`backup.target.not_found`). Nothing was stored. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "The chosen target cannot take the copy, and nothing was stored: a managed profile + (`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an admin + account (`backup.target.admin_account`), an account that already holds records + (`backup.target.holds_records`, `meta.models` naming them), a username already taken + (`backup.target.username_taken`), or a file whose own account is still here (`backup.owner.exists`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "413": description: "Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` = @@ -25150,8 +25208,9 @@ paths: "422": description: "The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not - hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist - here." + hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), an owner account that does not exist here + (`backup.owner.missing`), a target query that does not parse (`details.issues`), or an `into=existing` whose + `confirm` is not the account's username (`backup.target.unconfirmed`)." content: application/json: schema: @@ -26131,11 +26190,19 @@ paths: - in: query name: values schema: - description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts - once, so the list is bounded by the names that exist; an unknown name answers 422. + description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as + `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the + names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab + markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones + left out without an error. A lab marker that is not in the record (deleted, or another record's) is + likewise left out of `labSeries`, so a stored choice never breaks the page." type: string - description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts - once, so the list is bounded by the names that exist; an unknown name answers 422. + description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as + `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the + names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers + are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out + without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out + of `labSeries`, so a stored choice never breaks the page." responses: "200": description: The timeline for the requested zoom and window. @@ -38648,34 +38715,38 @@ components: auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — - backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active, - backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — - body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle, - coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, - coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, - coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — - coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, - consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, - cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, - cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; - cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — - disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich — - dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, - dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — - documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, - documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, - documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, - documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, - documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, - documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, - documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound, - documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfNeedsAnthropic, - documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, - documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, - documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, - documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, - documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, - documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, + backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing, + backup.payload.undecryptable, backup.restore.active, backup.restore.rows_exist_elsewhere, + backup.section.missing, backup.target.admin_account, backup.target.holds_records, + backup.target.managed_profile, backup.target.not_found, backup.target.pending_deletion, + backup.target.unconfirmed, backup.target.username_taken, backup.upload.too_large; biomarkers — + biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded, + coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, + coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, + coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, + coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; + consent — consent.ai.confirmRequired, consent.ai.required; cycle — cycle.bulk.invalid, + cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, + cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, + cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, + cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; + dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, + dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — + demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, + documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, + documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, + documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, + documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, + documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted, + documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit, + documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, + documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, + documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, + documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, + documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, + documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -90542,9 +90613,11 @@ components: type: string description: "Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`, `incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`, - `document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, `time_budget`, - `transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. - Every code but `failed_after_commit` means the account was not changed." + `document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, + `backup.restore.rows_exist_elsewhere` (a copy put into a chosen account whose records another account + already holds, because the same file was restored there before), `time_budget`, `transaction_failed`, + `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but + `failed_after_commit` means the account was not changed." message: type: string sections: @@ -92030,6 +92103,18 @@ components: description: "Every value series this record can show, whatever the window: the `MeasurementType` names with live readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has entries. Each may be named in `values`." + labSeries: + type: array + items: + $ref: "#/components/schemas/TimelineLabSeries" + description: The lab markers named in `values`, in the order named, each with its single readings in the window. A + marker named but not found in the record is left out. + availableLabs: + type: array + items: + $ref: "#/components/schemas/TimelineLabMarker" + description: Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs. + Each may be named in `values` by its `key`. notable: type: array items: @@ -92057,6 +92142,8 @@ components: - bucket - series - availableSeries + - labSeries + - availableLabs - notable additionalProperties: false TimelineItem: @@ -92209,6 +92296,155 @@ components: - unit - points additionalProperties: false + TimelineLabSeries: + type: object + properties: + key: + type: string + description: "`lab:`, as named in `values`." + biomarkerId: + type: string + name: + type: string + description: The marker's name, as the labs list shows it. + panel: + type: + - string + - "null" + unit: + type: string + description: The marker's own unit; every point is in it. + band: + anyOf: + - type: object + properties: + low: + type: + - number + - "null" + high: + type: + - number + - "null" + required: + - low + - high + additionalProperties: false + - type: "null" + description: "The reference range to draw behind the points: the marker's own, else the window printed with its newest + reading in the window. Null when neither is known." + points: + type: array + items: + $ref: "#/components/schemas/TimelineLabPoint" + description: "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A + day with two readings has two points." + excluded: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points` + rather than drawn on a scale they are not in. + required: + - key + - biomarkerId + - name + - panel + - unit + - band + - points + - excluded + additionalProperties: false + TimelineLabPoint: + type: object + properties: + id: + type: string + description: The lab result's id. + date: + type: string + description: The record's local day of the draw, the day `GET /api/day/{date}` lists it under. + value: + type: number + description: In the series' `unit`. + sourceValue: + description: The figure the report printed, when `value` was converted into the series' unit; it sets the precision + `value` is shown with. Null for a reading stored as stated. + type: + - number + - "null" + rangeStatus: + type: string + enum: + - in-range + - below + - above + - unknown + description: The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known. + referenceLow: + description: "The window this reading is judged against, in the series' unit: the one its report printed, else the + marker's own." + type: + - number + - "null" + referenceHigh: + type: + - number + - "null" + report: + anyOf: + - type: object + properties: + id: + type: string + title: + description: The document's title, else its file name. + type: + - string + - "null" + href: + type: string + required: + - id + - title + - href + additionalProperties: false + - type: "null" + description: The document the reading was read from, when one is linked and the caller may see documents. + required: + - id + - date + - value + - sourceValue + - rangeStatus + - referenceLow + - referenceHigh + - report + additionalProperties: false + TimelineLabMarker: + type: object + properties: + key: + type: string + description: "`lab:`, to name in `values`." + biomarkerId: + type: string + name: + type: string + panel: + description: The marker's group, as the labs list shows it. + type: + - string + - "null" + unit: + type: string + required: + - key + - biomarkerId + - name + - panel + - unit + additionalProperties: false TimelineReadinessEnvelope: type: object properties: diff --git a/docs/codex-protocol-spec.md b/docs/codex-protocol-spec.md index 67b45d4bb..ba81bf20f 100644 --- a/docs/codex-protocol-spec.md +++ b/docs/codex-protocol-spec.md @@ -642,9 +642,23 @@ The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _ The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-codex` → `gpt-5.3-codex` happened over 48 h in May 2026 and bricked HealthLog twice while the v1.4.7..v1.4.13 saga unrolled). The cause: a single hard-coded slug in `CODEX_MODEL` flips invalid the moment the upstream rotates it. Operator brief, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht sein." -To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series. +To eliminate that risk class, HealthLog's `CodexClient` tries its sign-in's working slug first and otherwise walks an ordered chain built from the models the account is offered. -**Chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default: +**The account's model list.** Before it walks, the client reads the list the Codex CLI reads, on the same sign-in as the responses call: + +```http +GET /backend-api/codex/models?client_version=0.165.0 HTTP/1.1 +Host: chatgpt.com +Authorization: Bearer +ChatGPT-Account-ID: +originator: healthlog +``` + +The answer is `ModelsResponse` from `codex-rs/protocol/src/openai_models.rs`: `{ "models": [ModelInfo, ...] }`, where the client reads `slug`, `visibility` (`list` / `hide` / `none`), `priority` (lower first), `input_modalities` (`text`, `image`, `audio`) and `model_specialty`. `codex-rs/codex-api/src/endpoint/models.rs` appends `client_version`; the backend leaves out models whose `minimal_client_version` is above it, so the version sent tracks a current Codex CLI release (`CODEX_MODELS_CLIENT_VERSION` in `src/lib/ai/codex-model-catalog.ts`). The read is bounded like the CLI's (5 s, `codex-rs/model-provider/src/models_endpoint.rs`) and to 4 MiB, cached per sign-in for an hour, and a failed read waits ten minutes before the next attempt. The list is a hint, never a gate: a read that fails, times out, does not parse or comes back empty leaves the static chain below, and one `ai.codex.models_unreadable` warning per sign-in reports the stretch. + +**Order.** `CODEX_MODEL` first when set. `CODEX_MODEL_FALLBACK_CHAIN`, when set, replaces everything after it and the list is not read. Otherwise: the default chain below kept to what the account is offered; then every other offered model in the list's `priority` order, leaving out what the list hides (`visibility` other than `list`), the specialist lines (`model_specialty`) and the `-astra` frontier tier; then the rest of the default chain, so the walk never holds fewer models than before the list existed. `supported_in_api` is not a filter: it describes API-key access, and the CLI ignores it for a ChatGPT sign-in (`ModelPreset::filter_by_auth`). A slug this sign-in was refused in the last six hours goes to the very end, even while its list still offers it. With the list's `input_modalities` for the slug in hand, the Codex vision gate reads them; without, it falls back to the model family. + +**Default chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default: ```ts const DEFAULT_SLUG_FALLBACK_CHAIN = [ @@ -662,9 +676,9 @@ Every default rung must be a model family the reasoning, sampling and vision gat The shipped constant in `src/lib/ai/codex-slug-chain.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows. -A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. Reading the account's live list (`GET /backend-api/codex/models`, as the Codex CLI does) would avoid this. +A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. That is why the account's live list now comes first and the default chain only orders and backs it up. -The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated. +The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated), which replaces it outright; if `CODEX_MODEL` is also set, it is folded into position 0 and de-duplicated. **Trigger condition** (when to walk to the next slug): @@ -678,15 +692,15 @@ The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-sep | `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) | | `200` with SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong, slug is fine) | -**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` in a process-local Map. On the next request, jump straight to the cached slug. TTL: 1 hour. Cache scope: per-process (no DB write). +**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` for that sign-in. On its next request, jump straight to the cached slug; the model list is read only when there is none or it stops working. TTL: 1 hour. Scope: per stored credential (`user:` for a person's connection, `central` for the operator-shared one), process-local, no DB write, at most 1000 sign-ins held. Each entry carries a digest of the ChatGPT account id it was learned under, and a client reads it only under that account, so a credential reconnected to another account starts empty: its first request tries no slug, skips no refusal and trusts no list the old account left, in any process. The vision gate holds no account id; the connect and disconnect routes rebind or drop the entry, so in the process that handled the change it answers from the new sign-in at once, and elsewhere from that process's first request on it. Two sign-ins on different plans no longer overwrite each other's working slug. -**Cache invalidation**: on first 400/404 walk, the cached entry is dropped. The next-best slug is promoted into the cache only after it succeeds. +**Cache invalidation**: on a 400/404 walk the refused slug leaves the working slot and goes to the back of that sign-in's line for six hours. The next-best slug is promoted into the cache only after it succeeds. **All-failed case**: if every slug in the chain rejects with the walk-trigger, the client throws a structured error with `httpStatus: 503` and message `"AI provider unreachable — all configured Codex slugs were rejected"`. The route layer surfaces this to the user with "AI provider unreachable — check Settings". -**Observability**: each attempted slug is recorded in the Wide-Event annotation `codex.slug.attempts: ["gpt-5.3-codex","gpt-5-codex"]` so Loki can answer "which slug carried this request" and "how often did the cache hit". Cache hits emit `codex.slug.cache: "hit" | "miss" | "expired"`. +**Observability**: a request that builds the chain annotates `codex.models.chain` (`operator` / `live` / `static`), `codex.models.read` (`live` / `cache` / `skipped`), `codex.models.failure` (`timeout`, `network`, `auth`, `http_error`, `too_large`, `unparsable`, `empty`, or null) and `codex.models.offered` (a count). `CodexClient.getLastDiagnostics()` holds the attempted slugs, the cache state and the working slug. -The implementation lives in `src/lib/ai/codex-client.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-client.test.ts`. +The walk lives in `src/lib/ai/codex-client.ts`, the order in `src/lib/ai/codex-slug-chain.ts`, the list read in `src/lib/ai/codex-model-catalog.ts`, the per-sign-in memory in `src/lib/ai/codex-slug-cache.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-live-models.test.ts`, `codex-slug-chain.test.ts`, `codex-model-catalog.test.ts` and `codex-slug-fallback.test.ts`. --- @@ -781,7 +795,10 @@ All paths relative to `openai/codex` repo: - `codex-rs/codex-api/src/rate_limits.rs` — rate-limit headers - `codex-rs/codex-api/src/auth.rs` — `AuthProvider` trait - `codex-rs/protocol/src/models.rs` — `ResponseItem`, `ContentItem`, `MessagePhase`, `ImageDetail`, etc. -- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum +- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum; `ModelsResponse`, `ModelInfo`, `ModelVisibility`, `InputModality`, `ModelPreset::filter_by_auth` (the `/models` wire) +- `codex-rs/codex-api/src/endpoint/models.rs` — `ModelsClient`: `GET models?client_version=…` +- `codex-rs/model-provider/src/models_endpoint.rs` — the 5 s model-list bound; `models_identity.rs` — the per-account cache identity +- `codex-rs/models-manager/src/manager.rs` — priority sort, picker visibility, cache TTL - `codex-rs/protocol/src/config_types.rs` — `ReasoningSummary`, `Verbosity` enums - `codex-rs/model-provider/src/bearer_auth_provider.rs` — `Authorization` + `ChatGPT-Account-ID` headers - `codex-rs/model-provider/src/auth.rs` — auth-provider selection diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 2b57bb9cb..1f8d5dd98 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -2,9 +2,10 @@ HealthLog keeps copies of each account so that one account can be rolled back on this server. These copies are per account. They cannot rebuild a lost -server: a restore writes into an account that already exists here under the -same id, and refuses a copy whose account is gone. To recover a server, you -need a database dump and the encryption keys. That procedure is in +server: a restore writes into an account on this server, the one the copy +belongs to, or, when that account has been erased, one you choose (see +"Restoring an erased account"). To recover a server, you need a database dump +and the encryption keys. That procedure is in [disaster-recovery.md](disaster-recovery.md). ## What exists @@ -81,13 +82,75 @@ pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-.json.enc /tmp/restor ``` Then upload `/tmp/restored.json` in Admin → Backups (up to 512 MB after -compression) and restore it from its row. The account `` must exist on this -server. +compression) and restore it from its row. When the account `` no longer +exists on this server, the upload offers to put the copy into another account +(next section). Every restore, preview and upload checks first that this server holds every application key the copy's content was written under, and refuses with the key id named if not. Nothing is changed by a refused restore. +### Restoring an erased account + +Once an account has been erased (its deletion window ended and the hourly purge +ran), its copies on this server and in the bucket are gone, but a file you kept +still holds the record: a disaster-recovery download, an off-host object you +fetched before it expired, or the person's own full backup. Uploading it in +Admin → Backups is refused at first, because the account it names no longer +exists, and the console then asks where the records should go: + +- **A new account.** Type a username. The account is created without a + password or email, as an ordinary account with the user role, and nobody + can sign into it yet. Before handing it over, set its first password with + **Reset password** in Admin → Users; the person can change it, add an email + and enrol a passkey from their settings. The account is made before the + file is read, with an `admin.user.create` audit row (`outcome: "created"`), + and removed again when the file is refused, with an `admin.user.delete` row + (`reason: "backup_upload_refused"`). When that row says `outcome: "failed"`, + the removal did not happen and the account is left over, holding nothing: + delete it in Admin → Users. +- **An existing account**, for someone who has signed up again. Pick the + account and type its username to confirm. The account must hold no records + yet: a restore replaces an account's records and does not merge them, so an + account with even one record of any kind a copy carries is refused, with the + kinds named. What signing up and the setup questions leave behind does not + count (the setup answers and the cycle settings): the restore replaces both + with the copy's. A reading or medication entered on setup's last screen is a + record, and the account is refused until it is deleted, because the restore + would delete it too. Managed profiles, accounts pending deletion and admin + accounts are not offered: an admin account is the operator's, its settings + are read under the operator's wider AI endpoint policy, and its role reaches + every account, so another person's record does not go into it. + +The file is then uploaded a second time, and its owner is changed to the chosen +account as it is read. Nothing else in the file changes. The stored copy is +then that account's copy in every respect but one: its preview, its download +and its restore are the ordinary ones, and **Restore** on its row brings +everything back under the chosen account. The one difference is that it is +stored as `RETARGETED_UPLOAD_