diff --git a/devlog/_plan/261004_translated_reset_retry/000_plan.md b/devlog/_plan/261004_translated_reset_retry/000_plan.md new file mode 100644 index 00000000000..c748a5461e8 --- /dev/null +++ b/devlog/_plan/261004_translated_reset_retry/000_plan.md @@ -0,0 +1,32 @@ +# Translated Responses pre-header reset retry + +A translated Responses request currently refuses a connection reset before headers even when its provider enables `retryOnReset`. Carry public PR #6525 through the existing request-wide grant and send accounting, with direct regression coverage for recovery legs and a real loopback disconnect. + +- Archetype: satisfy-spec; trigger: next-release replacement of public PR #6525. +- Goal: one focused ordinary draft PR against `dev`, preserving the source behavior and authorship. +- Class: C3 transport repair, with C4 care for the replay/send-budget contract. +- Non-goals: adapter-owned fetch recovery, translated post-header recovery, merge, source closure, release/version changes, installed runtime or account changes, paid/live requests, native GitHub stacks. +- Verifier: focused handler and retry tests, typecheck, structure/privacy/layout/ratchet gates, docs build, independent review and current-head hosted CI inspection. Full/changed local suites are excluded by the explicit concurrent-lane resource contract. +- Stop: attributed replacement published and attached; source coverage accounted; focused gates green; applicable review findings fixed; hosted CI inspected with missing/native/live evidence disclosed and draft retained as needed. +- Artifacts: this unit; ignored `.tmp/next-release-retry/` contains raw evidence. New security material stays only in scratch. +- Outcomes: DONE is publication under those criteria; unresolved behavior or verification remains explicit, never described as passing. +- Escalation: scope changes beyond replay wiring; any denied publication; no new authority inferred. +- Resources: local checkout, git/gh for this lane, gpt-6.1-sol leaves, fake upstreams and ephemeral loopback only. No user token/time budget was set; use bounded commands, no shared process killing or lock bypass. + +## Source and baseline + +Source: #6525, commit `49c5c7012f1f27d781079231dde92562ced68594`, author Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com>. Its single commit has 14 files and 10 handler cases. No source reviews/inline comments or native stack membership at inspection. Source common ancestor is `358b8ffd4c7f95237dadee1e9a5b4fedb671f4f4`; refreshed dev/base is `0818ea1812a028e1c14cd0b0511b44863407bc52`. + +One cohesive work phase: plan/audit → carry and refine wiring/tests/docs → check/review → publish. No separate independently useful implementation units require a roadmap cycle. + +## Existing owners and necessity + +Reuse `request-resend-gate.ts`, `reset-replay.ts`, `request-send-budget.ts` and `upstream-retry.ts`; do not add a parallel retry policy or budget. Configuration alone cannot fix the missing callback; deleting the refusal would lose the safety boundary. `adapter-dispatch.ts` owns both generic send sites; public provider prose and `structure/transports/responses-failover.md` describe them. See the diff plan in `010_retry.md`. + +## Consultation + +V1 leaf surface, requested model gpt-6.1-sol. Architect handle (recorded in local evidence); proposal and first reflection received. RETRY-D01 shared gate, D02 external accounting, D03 exact caps with prepaid handling, D04 docs are accepted. D05 unreachable reset-then-recovery scenario was replaced by terminal-replacement and spent-grant-on-rebuild scenarios; final same-architect reflection ALIGNED on D01–D05 with no design gaps. The six reservation sites and prepaid last-slot handling are explicitly recorded in 010. Main owns the executable plan and all decisions. Documentation coverage is independently inspected by leaf (recorded in local evidence). + +Independent A reviewer (recorded in local evidence) returned VERDICT: PASS with no blockers, confirming all source files/cases and reachable tests accounted. Main accepts the verdict. Baseline typecheck, structure/privacy, 561-page docs build and 77,944 link checks passed. + +Implementation and focused verification are complete; see `011_verification.md` for source coverage, the combo accounting decision, current publication base and evidence limits. Publication targets an ordinary draft PR; hosted current-head results are reported in its Verification section. diff --git a/devlog/_plan/261004_translated_reset_retry/010_retry.md b/devlog/_plan/261004_translated_reset_retry/010_retry.md new file mode 100644 index 00000000000..86f89b566f7 --- /dev/null +++ b/devlog/_plan/261004_translated_reset_retry/010_retry.md @@ -0,0 +1,49 @@ +# One request-wide replay grant through translated dispatch + +Depends on: existing reset-grant/send-budget owners; no new public type or config field. + +## File map and concrete delta + +- MODIFY `src/server/responses/adapter-dispatch.ts`: carry source #6525 imports, budget Pick fields `claimAmbiguousResend`/`sendsUsed`, memoized inbound self-contained judgment and one pre-header grant callback. Pass that same callback to both initial and rebuilt generic retry helpers. For reset opt-in, supply remaining send allowance and `noteTransientSends` to both helpers. Preserve compact prepaid sends and adapter-owned branches. +- Refine recovery accounting in that same file: all helper-counted reset-only credential/repair hop reservations must set `countedExternally` just like transient helpers; physical sends and workflow counts must agree. Exact configured transient totals must be reduced by prior sends and cannot borrow the final recovery reserve. Any adjustment stays in generic adapter dispatch and is activated by focused regressions. +- NEW `tests/responses/responses-translated-reset.test.ts`: carry all 10 original tests. Add previous-id refusal (construct valid stored predecessor if needed), post-header/output failure with exactly one physical send, shared grant across initial and rebuilt legs, explicit total across rebuilt legs, prepaid recovery accounting, and a loopback upstream which reads a full request then closes before headers, succeeds on the replacement, and verifies identical bodies and exactly two sends. Deterministic completion signals and `finally` cleanup, no sleeps for synchronization. +- MODIFY `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`: register the new test under responses with consistent existing indentation/order. +- MODIFY `src/types/provider.ts`: describe pre-header translated generic fetch support; no type/schema change. +- MODIFY `docs-site/src/content/docs/reference/configuration/providers.md` and corresponding `fr`, `ja`, `ko`, `ru`, `tr`, `zh-cn`, `zh-tw` pages: carry source policy extension, clarify existing native-only introductory wording, retain default-off/self-contained/grant/billing caveats and explicitly exclude adapter-owned and post-header translated failures. +- MODIFY `structure/transports/responses-failover.md`: state initial/rebuilt shared-grant behavior and accounting, link added regression; keep `fetchWithResetRetry` tool-side `replaySafe` distinct from operator grant. +- Existing unit docs record decisions, verification and publication; no screenshots or raw security notes enter public commits. + +## Execution and delegation + +After independent A audit, a bounded gpt-6.1-sol executor owns adapter-dispatch and the new test/layout entries, with no branches/commits/goals/FSM. Main handles integration, git and evidence. A separate doc executor owns provider type comment, the eight provider pages and failover SoT. Write sets are disjoint. Fresh independent C reviewer checks all changed files and source coverage. + +## Acceptance activation + +| Scenario | Observable proof | +|---|---| +| Bare opt-in plus pre-header reset | HTTP 200, two byte-identical translated sends, request/workflow count two | +| No/disabled opt-in, stored or prior state | No replay; existing refusal (or prior-state admission), no spent replacement | +| Grant already spent/repeated resets | At most the configured shared replacements; refusal preserves non-replayability | +| Effort refusal then reset on rebuild | Three sends, same downgraded bytes for final two, count three | +| Initial replacement returns a recoverable-looking HTTP failure | Non-replayable refusal prevents rebuild/target hop | +| Effort refusal reaches rebuilt send with already-spent shared grant | Rebuilt reset refuses replacement; grant is not reset | +| Exact one-send/total cap/exhaustion | No physical send beyond cap, zero dispatch when initially exhausted | +| Credential/repair prepaid hop | Count every wire send exactly once; workflow matches request count | +| Abort during failing send | 499 and no replacement | +| Post-header stream failure/output | No reset replay and no second upstream request | +| Real loopback reset | Full request consumed then socket closed before headers; replacement succeeds exactly once | +| Core optional subsystem | `tests/lab/core-lab-boundary.test.ts` stays green | + +## Verification + +Baseline commands executed before implementation: `bun test tests/lib/ambiguous-resend-gate.test.ts tests/responses/responses-reset-replay.test.ts` (23 pass); `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts tests/ci-workflows/file-size-ratchet.test.ts` (27 pass); `bun run structure:check`, `bun run privacy:scan`, `bun run typecheck` (completion recorded in evidence). Test paths are direct arguments, structure/privacy scripts inspect repository files, and tsconfig includes `src`. + +After implementation run the new test plus reset/reasoning-downgrade/send-budget/fast-downgrade/lib-grant and core-lab suites; repeat static gates once on final code. Docs validation uses `cd docs-site && bun install --frozen-lockfile && bun run build`. Confirm the new happy-path test fails on original dispatch before claiming regression proof. Wider native/broad coverage belongs to real hosted PR checks; `.github/workflows/ci.yml` runs on ordinary `pull_request` events, not branch pushes. Missing/skipped/pending checks remain missing proof. + +## Boundary strength + +Existing runtime gate executes synchronously at ambiguous replay admission and uses the shared budget; no new enforcement layer is introduced. Known bypass: adapters owning `fetchResponse` retain their own policies outside this lane. Residual: upstream may have executed an inference before reset, which is why opt-in and billing caveats remain. No claim of exactly-once upstream inference. + +Documentation coverage amendment: also MODIFY `structure/transports/responses.md` reset-replay ownership row and `ResetReplayPolicy` comment at `src/types/provider.ts:71`. Qualify failover helper prose at lines 52/62 to distinguish `replaySafe` from the existing operator-grant override. Scout reviewed all 20 mapped server/type SoT documents; no other consequence changed. + +Architect refinements accepted (RETRY-D02/D03): all six generic helper-funded hop sites use transient OR reset policy for `countedExternally`. Exclude an already-booked pending permit from prior sends when calculating the exact configured remaining cap; preserve its last funded slot without opening a final reserve. Assert wire count = request delta = attempt-log count, with workflow count where observable. No new runtime file owner is needed. diff --git a/devlog/_plan/261004_translated_reset_retry/011_verification.md b/devlog/_plan/261004_translated_reset_retry/011_verification.md new file mode 100644 index 00000000000..2963ec08b8f --- /dev/null +++ b/devlog/_plan/261004_translated_reset_retry/011_verification.md @@ -0,0 +1,25 @@ +# Verification and source coverage + +All useful behavior from public PR #6525 at `49c5c7012f1f27d781079231dde92562ced68594` is carried: initial/rebuilt generic pre-header replay, shared grant, default-off and self-contained restrictions, physical accounting, all ten original test cases, provider comments, eight translated/reference pages, and failover SoT. No source behavior is deliberately dropped or unresolved. Yuxin Qiao's public commit identity is retained in co-author trailers. Source closure remains a coordinator decision after replacement publication and verification. + +The implementation adds 25 regression cases. Coverage includes complete-body loopback disconnects, previous-response expansion, post-header prelude/output failures, terminal replacement statuses, exhausted/spent grants, exact direct/rebuild ceilings, and prepaid compaction/Fast/OAuth accounting. It preserves compact-only one-send behavior. Combo admission retains its target-local initial allowance: `comboTargetSendBudget` reserves capacity for later declared targets and the child settles an already-booked send. The shared aggregate budget and replacement grant remain authoritative; this change does not redesign combo admission. + +## Observed checks + +- Original source test on unchanged dispatch: 6 passed, 4 failed. Failures: initial/rebuilt reset opt-in returned 429 instead of 200; repeated reset made one send instead of two; exhausted budget sent once instead of zero. +- `bun test tests/responses/responses-translated-reset.test.ts tests/responses/responses-send-budget-counts.test.ts`: 55 passed, zero failed (35 new-file cases, including all original ten; 20 existing count cases). +- `bun test tests/lib/upstream-retry.test.ts tests/codex-integration/reserve-dispatch.test.ts`: 90 passed, zero failed. +- Adjacent reset-replay, reasoning-downgrade, Fast-downgrade and ambiguous-resend-gate files: 37 passed during implementation. The combined intermediate run also exposed three combo regressions. Those were repaired, and all 20 existing count cases passed unchanged in the final 55-test run. +- `bun test tests/lab/core-lab-boundary.test.ts`: 25 passed, zero failed after implementation. +- `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts tests/ci-workflows/file-size-ratchet.test.ts`: 27 passed, zero failed. +- Typecheck, privacy scan and diff whitespace check passed. Structure check passed after staging the new test, as required by its Git-index path authority. +- Docs frozen install/build passed: 561 pages and 77,944 internal links. +- Curl against an isolated loopback handler and a real disconnecting upstream: opt-in succeeded with two POSTs; absent opt-in and stored turn each stopped at one and returned `x-should-retry: false`; empty/malformed bodies returned 400 with zero POSTs. Temporary servers, leases and state directory were cleaned up. + +Architect consultation and independent plan audit passed. A fresh implementation reviewer returned PASS with no remaining blockers. One publication hygiene finding removed private review handles from the unpushed plan history; code and tests were unchanged. + +Before publication, remote dev advanced from the inspected `0818ea1812a028e1c14cd0b0511b44863407bc52` to `87e315633978a6a0431696b952bedde83ede1a47` through release-version PR #6549. The lane was rebased onto that commit. Only inherited version files changed; the feature diff and behavior verification remain identical. + +## Verification limits + +Concurrent release stabilization and parallel lanes make local full/changed suites impractical under the explicit resource contract. Wider checks belong to actual current-head hosted PR CI. Missing, pending, skipped or cancelled jobs are not passing proof; retain draft status while broad/native evidence is incomplete. Native packaged runtime and live-provider acceptance were not exercised. No merge, source closure, release/deploy or installed-runtime/account changes were performed. diff --git a/devlog/_plan/261004_translated_reset_retry/020_compaction_allowance_repair.md b/devlog/_plan/261004_translated_reset_retry/020_compaction_allowance_repair.md new file mode 100644 index 00000000000..e20c452d2f6 --- /dev/null +++ b/devlog/_plan/261004_translated_reset_retry/020_compaction_allowance_repair.md @@ -0,0 +1,26 @@ +# Repair emergency compaction target allowance + +The first publication's local checks missed a compaction integration regression. Hosted test4/4 on #6555 head `9e8d7817fe458041a9410bf12b7758b1c40cc43c` proved that a configured three-send emergency provider made only two sends despite shared capacity for source plus three. This cycle restores that existing behavior without changing its assertion. + +Loop: satisfy-spec follow-up to the prior D publication, with hosted failure as new evidence. Goal: repair and push the same PR with focused/local and current-head CI proof. Non-goals: merge/source closure/release/runtime/account changes, new retry owners, full/changed local suites, paid/live requests. Artifacts: this document plus ignored repair evidence. Stop: regression green unchanged, protections retained, independent review PASS, repaired head published and actual CI inspected. No user token/time budget was set; use bounded managed commands. Escalate scope/authority changes only. + +## Evidence and root cause + +The unchanged test is `tests/responses/responses-compaction-recovery.test.ts:340`; local RED reproduced Expected3/Received2. `compaction-recovery.ts:203` reserves the emergency child's first send against the shared budget and `:262` passes that permit. At dispatch entry, shared used=2 includes source1 and prepaid emergency1. `adapter-dispatch.ts:364` subtracts used-minus-prepaid from the emergency provider's configured attempts3, yielding2. That wrongly deducts the source provider's send from the emergency target's own initial ladder; the shared-budget intersection already accounts for it. + +## Diff plan + +- MODIFY `src/server/responses/adapter-dispatch.ts` initialSendCap only: treat a valid `compactPrepaid` handoff like combo admission for the target-local configured initial ceiling. Before: `options.comboAttempt ? 0 : sendsUsed - prepaid`. After: `(options.comboAttempt || compactPrepaid) ? 0 : sendsUsed`. Keep `Math.min(initialSendCap, remainingTransientSendBudget(initialSendCap) + prepaid)` unchanged, plus one-use permit and helper send reports. No change to direct/rebuilt deductions, absent-policy one-send behavior, grants or adapter-owned/post-header paths. +- MODIFY `tests/responses/responses-compaction-recovery.test.ts`: retain the original failing case and assertions byte-for-byte; add a small parameter matrix proving configured emergency attempts1/2/3 and shared cap2/3/4 intersect correctly, with reset opt-in present/absent. Synthetic upstream500, one source failure, assert emergency count and shared used. Reuse the existing fixture and no new registry file. +- MODIFY `structure/transports/responses-failover.md`: clarify emergency initial provider allowance is intersected with remaining shared capacity plus already-prepaid send; source provider usage is not deducted twice. +- Update this unit's verification record and PR Verification after actual results. Existing source coverage/attribution unchanged. + +Main owns runtime/SoT/evidence; a bounded leaf may implement only the test matrix. No new abstraction, configuration, dependency or public type. Reuse existing cap and budget owners; configuring or deleting the refusal cannot repair arithmetic. + +## Verification and acceptance + +Executed baseline command: `bun test tests/responses/responses-compaction-recovery.test.ts -t 'emergency transient 5xx retry cannot exceed the shared cap'` failed0pass1fail for the hosted signature. After repair run the full compaction file plus translated-reset, send-count, reset-replay, Fast-downgrade, and core-lab tests. Run typecheck, structure/privacy/layout/ratchet gates. Do not repeat passing checks on unchanged code. The original case must show three emergency sends and totalused4; cap2 allows one emergency, cap3 allows at mosttwo, configured1 remains one. Existing35 translated cases retain exact direct/rebuild totals, prepaid last-slot, default-off, cancellation, no post-output replay and loopback behavior. + +Independent architect and audit will assess the proposed target handoff boundary. Fresh final review inspects arithmetic/negative tests and no weakened assertions. Hosted ordinary PR checks run after push; no release dispatch or merge. The prior source was closed by the coordinator, not this lane. + +Architect proposal/reflection CR-D01–D05 ALIGNED: target-local admitted handoff; unchanged shared clamp; direct and absent-policy behavior intact; permit/reporting intact; grants intact. Main accepts all. Matrix expected emergency sends by attempts1/2/3 and cap2/3/4: `[1,1,1]`, `[1,2,2]`, `[1,2,3]`, each with reset present/absent. HTTP500 matrix proves policy neutrality, not reset-grant exhaustion; retained reset tests own the latter. diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index b939aff7146..2bdf799069b 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -143,7 +143,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `responsesSnapshotRepair?` | `boolean` | Réparation côté client désactivée par défaut pour les instantanés du cycle de vie des réponses clairsemés dans SSE et JSON. Remplit les métadonnées d'état canonique, de sortie et d'outil manquantes tandis que l'inspection brute et la persistance restent inchangées. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Fournisseurs à clé API uniquement (`authMode: "key"`). Nouvelle tentative facultative sur la même cible après un 429 : lorsque `retryOn429` est absent, la fonctionnalité est désactivée ; la présence d'un objet l'active, sauf avec `enabled: false`. Après un 429, le proxy attend selon `Retry-After` reçu en amont ou selon l'intervalle fixe, puis relit la requête à l'identique avec la même clé avant tout basculement de clé. Ce comportement couvre la boucle principale de récupération d'un tour textuel, le protocole de transfert Responses, le pont d'images et de vidéos, le service auxiliaire de recherche Web et les continuations du terminal. Seules les réponses HTTP 429 reçues avant le début de la diffusion peuvent être relues ; les transports `runTurn` personnalisés ne font pas partie de la boucle de nouvelle tentative HTTP. `attempts` compte les relectures avec la même clé après le premier 429, soit `attempts` + 1 envois au total, et constitue un budget commun à toute la requête, partagé entre la boucle principale de récupération, la continuation de la garde du terminal et les nouvelles tentatives du pont. L'épuisement de `attempts` arrête uniquement les relectures supplémentaires avec la même clé : le basculement normal de clé ou la gestion de l'erreur finale s'applique ensuite selon les cibles disponibles. Sur le protocole de transfert authentifié par clé, aucun basculement n'est possible ; le 429 final est donc renvoyé sans modification. Codex ne retente jamais lui-même une requête après un 429 : cette option constitue ainsi la seule protection pour les fournisseurs à clé unique. Valeurs par défaut : `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (chaque attente est plafonnée à `maxIntervalMs`, lui-même plafonné à 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Fournisseurs `openai-chat` et `openai-responses` authentifiés par clé uniquement. Les fournisseurs `authMode: "forward"` (le pool de comptes ChatGPT) ne lisent jamais cette option et conservent l'échelle par défaut. Nouvelle tentative facultative pour les états transitoires reçus en amont avant le début de la diffusion (500, 502, 503, 504, 520, 521, 522) : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Ce comportement couvre la requête Responses initiale, la continuation de la garde du terminal, le point de terminaison natif `/v1/chat/completions` et les réémissions liées à la récupération après un 429 ou à la récupération de compte. `attempts` représente le nombre TOTAL d'envois en amont autorisés pour une requête, premier envoi compris (de 1 à 10, valeur par défaut : 3). Il constitue un budget commun à la requête, partagé avec la récupération après une réinitialisation de connexion ; ainsi, `3` signifie qu'au plus trois requêtes réelles atteignent le fournisseur. Les attentes utilisent une temporisation exponentielle à base fixe de 400 ms, plafonnée à 5 s, et respectent `Retry-After`. Cette option est distincte de `retryOn429`, qui traite la limitation de débit ; les échecs en cours de diffusion ne sont jamais relus. | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Fournisseurs `openai-responses` natifs uniquement, `authMode: "forward"` compris. Remplacement facultatif d'un envoi qui a échoué alors que l'appelant n'avait rien observé : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Couvre les deux étapes ambiguës — une connexion rompue avant tout en-tête de réponse, et un corps SSE rompu après l'en-tête alors qu'il ne portait que des événements de contrôle. Une WebSocket canonique ChatGPT en amont qui s'est fermée ou a échoué après l'envoi de sa trame de création, avant tout événement Responses, est couverte de la même façon ; son remplacement est envoyé en HTTP. Seule une requête autonome est remplacée : `store: false`, `input` complet, ni `previous_response_id`, ni `conversation`, ni `stream_id`, et uniquement des outils exécutés par le client. `replacements` est le nombre d'envois de remplacement qu'UNE requête logique peut effectuer, toutes étapes et tous enfants de combo confondus (de 1 à 2, valeur par défaut : 1). Ce n'est ni un nombre de tentatives par étape ni un budget d'envoi : un remplacement doit toujours tenir dans l'allocation d'envois dont l'étape disposait déjà. Une requête qui a déjà émis une sortie ou un appel d'outil n'est jamais remplacée, quelle que soit cette valeur. L'inférence de remplacement peut tout de même être facturée si l'origine avait déjà démarré la première, d'où la désactivation par défaut. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Fournisseurs `openai-responses` natifs, `authMode: "forward"` compris, ainsi que les requêtes Responses traduites envoyées par la voie générique avant les en-têtes reçus en amont. Remplacement facultatif d'un envoi qui a échoué alors que l'appelant n'avait rien observé : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Couvre les deux étapes ambiguës — une connexion rompue avant tout en-tête de réponse, et un corps SSE rompu après l'en-tête alors qu'il ne portait que des événements de contrôle. Une WebSocket canonique ChatGPT en amont qui s'est fermée ou a échoué après l'envoi de sa trame de création, avant tout événement Responses, est couverte de la même façon ; son remplacement est envoyé en HTTP. Seule une requête autonome est remplacée : `store: false`, `input` complet, ni `previous_response_id`, ni `conversation`, ni `stream_id`, et uniquement des outils exécutés par le client. `replacements` est le nombre d'envois de remplacement qu'UNE requête logique peut effectuer, toutes étapes et tous enfants de combo confondus (de 1 à 2, valeur par défaut : 1). Ce n'est ni un nombre de tentatives par étape ni un budget d'envoi : un remplacement doit toujours tenir dans l'allocation d'envois dont l'étape disposait déjà. Une requête qui a déjà émis une sortie ou un appel d'outil n'est jamais remplacée, quelle que soit cette valeur. L'inférence de remplacement peut tout de même être facturée si l'origine avait déjà démarré la première, d'où la désactivation par défaut. La voie générique de traduction prend en charge le remplacement après une rupture avant les en-têtes pour les envois initiaux et reconstruits, avec la même autorisation et le même budget d’envois. Les transports gérés par les adaptateurs et les erreurs des flux traduits après les en-têtes reçus en amont sont exclus. | | `autoToolChoiceOnlyModels?` | `string[]` | Modèles dont `tool_choice` accepte uniquement `auto` ou `none` ; les choix forcés sont dévalorisés. | | `preserveReasoningContentModels?` | `string[]` | Modèles nécessitant un assistant préalable `reasoning_content` dans l'historique des discussions. Les enregistrements depuis le tableau de bord conservent la liste enregistrée, y compris `[]`. `PATCH /api/providers?name=` accepte un tableau ou `null` pour l'effacer. Une sauvegarde qui déplace le fournisseur vers un autre adaptateur, une autre URL de base ou un autre mode d'authentification ne la conserve pas (voir la section ci-dessous). | | `reasoningDetailsModels?` | `string[]` | Modèles dont le point de terminaison renvoie la réflexion sous forme de tableau structuré `reasoning_details` (MiniMax série M avec `reasoning_split`) ; les deltas de flux sont des instantanés cumulatifs comparés par préfixe, et la réflexion conservée est rejouée sous forme de tableau `reasoning_details` plutôt que de chaîne `reasoning_content`. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index e7f99b8cacb..04f1868680c 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -135,7 +135,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key プロバイダーのみ(`authMode: "key"`)。オプトインの同一ターゲット 429 リトライ: `retryOn429` が無ければ無効で、オブジェクトがあれば `enabled: false` でない限り有効になります。429 時に待機(上流の `Retry-After` または固定間隔)してから、キー フェイルオーバーの前に同一キーで同一リクエストを再送します — メインのテキストターン回復ループ、Responses passthrough、画像/動画ブリッジ、web-search サイドカー、ターミナル継続要求をすべてカバーします。再送の対象はプリストリームの HTTP 429 応答のみで、カスタム `runTurn` トランスポートは HTTP リトライループの対象外です。`attempts` は最初の 429 以降の同一キー再送回数(合計送信数 = `attempts` + 1)で、メインの回復ループ・ターミナルガード継続・ブリッジ再試行で共有されるリクエスト単位の予算です。`attempts` を使い切っても同一キーでの再送が止まるだけで、通常のキー フェイルオーバーまたは最終エラー処理が利用可能なターゲットに応じて続きます — キー認証の passthrough ワイヤにはフェイルオーバーがないため、使い切った 429 はそのまま返ります。Codex 自体は 429 をリトライしないため、単一キーのプロバイダーでは唯一の防御です。デフォルト: `enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(1回の待機は `maxIntervalMs` で上限、その上限は 600000)、`respectRetryAfter: true`。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | キー認証の `openai-chat` および `openai-responses` プロバイダーのみ。`authMode: "forward"` のプロバイダー(ChatGPT アカウントプール)はこのオプションを読まず、既定の再試行段数を維持します。ストリーム開始前に上流から返される一時的なステータス(500、502、503、504、520、521、522)に対するオプトインの再試行です。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。最初の Responses リクエスト、ターミナルガード継続、ネイティブの `/v1/chat/completions`、および 429/アカウント回復時の再取得が対象です。`attempts` は最初の送信を含め、1 回のリクエストで許可される上流への送信総数です(1~10、デフォルトは 3)。接続リセット回復と共有するリクエスト単位の単一予算であるため、`3` を指定した場合、プロバイダーに到達する実リクエストは最大 3 回です。待機には 400 ms を基準とする固定式の指数バックオフを使用し、上限は 5 秒で、`Retry-After` に従います。レート制限を扱う `retryOn429` とは別の機能であり、ストリーム開始後の失敗は再送されません。 | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | ネイティブ `openai-responses` プロバイダー専用で、`authMode: "forward"` も含みます。呼び出し側が何も観測しないまま失敗した送信を、オプトインで置き換えます。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。レスポンスヘッダーが届く前に接続が切れた場合と、ヘッダー後に SSE 本文が制御イベントだけを運んだまま切れた場合の両方が対象です。canonical ChatGPT upstream WebSocket で create フレームの送信後、Responses イベントが届く前にソケットが閉じたかエラーになった場合も同じく対象で、その置き換えは HTTP で送信します。置き換えるのは自己完結したリクエストだけで、`store: false`、完全な `input`、`previous_response_id` / `conversation` / `stream_id` がないこと、クライアントが実行するツールのみ、が条件です。`replacements` は、すべてのレッグとすべてのコンボ子リクエストを合わせて 1 つの論理リクエストが行える置き換え送信の回数です(1..2、デフォルトは 1)。レッグ単位の再試行回数でも送信予算でもないため、置き換え送信もそのレッグがすでに持つ送信許容量に収まる必要があります。すでに出力やツール呼び出しを送ったリクエストは、この値に関わらず置き換えません。元の送信がすでに開始されていた場合は置き換えた推論も課金される可能性があるため、既定では無効です。 | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | ネイティブ `openai-responses` プロバイダー(`authMode: "forward"` を含む)と、上流ヘッダー受信前の汎用 Responses 変換経路に適用されます。呼び出し側が何も観測しないまま失敗した送信を、オプトインで置き換えます。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。レスポンスヘッダーが届く前に接続が切れた場合と、ヘッダー後に SSE 本文が制御イベントだけを運んだまま切れた場合の両方が対象です。canonical ChatGPT upstream WebSocket で create フレームの送信後、Responses イベントが届く前にソケットが閉じたかエラーになった場合も同じく対象で、その置き換えは HTTP で送信します。置き換えるのは自己完結したリクエストだけで、`store: false`、完全な `input`、`previous_response_id` / `conversation` / `stream_id` がないこと、クライアントが実行するツールのみ、が条件です。`replacements` は、すべてのレッグとすべてのコンボ子リクエストを合わせて 1 つの論理リクエストが行える置き換え送信の回数です(1..2、デフォルトは 1)。レッグ単位の再試行回数でも送信予算でもないため、置き換え送信もそのレッグがすでに持つ送信許容量に収まる必要があります。すでに出力やツール呼び出しを送ったリクエストは、この値に関わらず置き換えません。元の送信がすでに開始されていた場合は置き換えた推論も課金される可能性があるため、既定では無効です。 汎用変換経路では、初回送信と再構築後の送信のどちらも、同じ許可と送信予算でヘッダー受信前の接続リセットを置き換えます。アダプター独自のトランスポートと、上流ヘッダー受信後の変換ストリーム障害は対象外です。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` が `auto` または `none` のみを受け入れるモデル。強制的な選択は格下げされます。 | | `preserveReasoningContentModels?` | `string[]` |チャット履歴に以前のアシスタント `reasoning_content` が必要なモデル。ダッシュボードから保存しても、保存済みのリスト(`[]` を含む)は保持されます。`PATCH /api/providers?name=` は配列、または消去するための `null` を受け付けます。 アダプター、ベース URL、または認証モードを変えて別の宛先に移す保存では保持されません(下の節を参照)。 | | `reasoningDetailsModels?` | `string[]` | thinking を構造化された `reasoning_details` 配列で返すモデル(`reasoning_split` 使用の MiniMax M シリーズ)。ストリーム差分は累積スナップショットとして prefix-diff され、保持された reasoning は `reasoning_content` 文字列ではなく `reasoning_details` 配列としてリプレイされます。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 1f15a246edc..f78de15e39f 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -135,7 +135,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key 프로바이더 전용(`authMode: "key"`). 동일 대상 429 재시도: `retryOn429`가 없으면 기능이 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 429 시 대기(업스트림 `Retry-After` 또는 고정 간격) 후 키 장애 조치 전에 동일 키로 동일 요청을 재전송합니다 — 일반 텍스트 턴 복구 루프, Responses passthrough, 이미지/비디오 브리지, web-search 사이드카, 터미널 연속 요청을 모두 포함합니다. 재전송 대상은 프리스트림 HTTP 429 응답뿐이며, 커스텀 `runTurn` 전송은 HTTP 재시도 루프에서 제외됩니다. `attempts`는 첫 429 이후의 동일 키 재전송 횟수(총 전송 = `attempts` + 1)이며, 메인 복구 루프·터미널 가드 연속 요청·브리지 재시도가 공유하는 요청 단위 예산입니다. `attempts`를 모두 소진해도 동일 키 재전송만 중단되며, 이후에는 일반 키 장애 조치 또는 최종 오류 처리가 사용 가능한 대상에 따라 진행됩니다 — 키 인증 passthrough 와이어에는 장애 조치가 없으므로 소진된 429가 그대로 반환됩니다. Codex 자체는 429를 재시도하지 않으므로 단일 키 프로바이더의 유일한 방어선입니다. 기본값: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000`(단일 대기는 `maxIntervalMs`로 상한, 그 자체는 600000으로 상한), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 키 인증 `openai-chat` 및 `openai-responses` 프로바이더 전용입니다. `authMode: "forward"` 프로바이더(ChatGPT 계정 풀)는 이 옵션을 읽지 않고 기본 재시도 단계를 유지합니다. 스트림 시작 전의 일시적인 업스트림 상태(500, 502, 503, 504, 520, 521, 522)를 선택적으로 재시도합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 최초 Responses 요청, 터미널 가드 연속 요청, 네이티브 `/v1/chat/completions`, 429/계정 복구 재조회를 포함합니다. `attempts`는 최초 전송을 포함하여 요청 하나에 허용되는 업스트림 전송의 총횟수(1..10, 기본값 3)입니다. 연결 재설정 복구와 요청 단위 예산 하나를 공유하므로 `3`이면 실제로 프로바이더에 도달하는 요청은 최대 세 번입니다. 대기에는 400ms로 고정된 지수 백오프를 사용하고 상한은 5초이며 `Retry-After`를 따릅니다. 속도 제한을 처리하는 `retryOn429`와는 별개이며, 스트림 도중의 실패는 절대 재전송하지 않습니다. | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 네이티브 `openai-responses` 프로바이더 전용이며 `authMode: "forward"`도 포함합니다. 호출자가 아무것도 관측하지 못한 채 실패한 전송을 선택적으로 대체합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 응답 헤더가 오기 전에 연결이 끊어진 경우와, 헤더 이후 SSE 본문이 제어 이벤트만 실은 채 끊어진 경우를 모두 다룹니다. canonical ChatGPT 업스트림 WebSocket에서 create 프레임을 보낸 뒤 Responses 이벤트가 오기 전에 소켓이 닫히거나 오류가 난 경우도 같은 방식으로 다루며, 이때 대체 전송은 HTTP로 보냅니다. 자체 완결된 요청만 대체합니다. `store: false`, 완전한 `input`, `previous_response_id`·`conversation`·`stream_id` 없음, 클라이언트가 실행하는 도구만 해당합니다. `replacements`는 모든 구간과 모든 콤보 자식을 합쳐 논리 요청 하나가 만들 수 있는 대체 전송 횟수입니다(1..2, 기본값 1). 구간별 재시도 횟수도 전송 예산도 아니므로, 대체 전송도 해당 구간이 이미 가진 전송 허용량 안에 들어가야 합니다. 이미 출력이나 도구 호출을 내보낸 요청은 이 값과 무관하게 대체하지 않습니다. 원본 전송이 이미 시작됐다면 대체한 추론도 과금될 수 있어서 기본값은 꺼짐입니다. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 네이티브 `openai-responses` 프로바이더(`authMode: "forward"` 포함)와 업스트림 헤더 수신 전의 일반 Responses 변환 전송 경로에 적용됩니다. 호출자가 아무것도 관측하지 못한 채 실패한 전송을 선택적으로 대체합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 응답 헤더가 오기 전에 연결이 끊어진 경우와, 헤더 이후 SSE 본문이 제어 이벤트만 실은 채 끊어진 경우를 모두 다룹니다. canonical ChatGPT 업스트림 WebSocket에서 create 프레임을 보낸 뒤 Responses 이벤트가 오기 전에 소켓이 닫히거나 오류가 난 경우도 같은 방식으로 다루며, 이때 대체 전송은 HTTP로 보냅니다. 자체 완결된 요청만 대체합니다. `store: false`, 완전한 `input`, `previous_response_id`·`conversation`·`stream_id` 없음, 클라이언트가 실행하는 도구만 해당합니다. `replacements`는 모든 구간과 모든 콤보 자식을 합쳐 논리 요청 하나가 만들 수 있는 대체 전송 횟수입니다(1..2, 기본값 1). 구간별 재시도 횟수도 전송 예산도 아니므로, 대체 전송도 해당 구간이 이미 가진 전송 허용량 안에 들어가야 합니다. 이미 출력이나 도구 호출을 내보낸 요청은 이 값과 무관하게 대체하지 않습니다. 원본 전송이 이미 시작됐다면 대체한 추론도 과금될 수 있어서 기본값은 꺼짐입니다. 일반 변환 전송 경로에서는 최초 전송과 재구축한 전송 모두 같은 대체 권한과 전송 예산으로 헤더 수신 전 연결 리셋을 대체합니다. 어댑터가 자체 관리하는 전송 경로와 업스트림 헤더 수신 후 변환 스트림 장애는 제외됩니다. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`가 `auto` 또는 `none`만 받는 모델입니다. 강제 선택은 낮은 수준으로 바뀝니다. | | `preserveReasoningContentModels?` | `string[]` | chat 기록에서 이전 assistant `reasoning_content`가 필요한 모델입니다. 대시보드에서 저장해도 저장된 목록(`[]` 포함)은 유지됩니다. `PATCH /api/providers?name=`는 배열 또는 지우기 위한 `null`을 받습니다. 어댑터, 기본 URL, 인증 모드를 바꿔 다른 목적지로 옮기는 저장에서는 유지되지 않습니다(아래 절 참고). | | `reasoningDetailsModels?` | `string[]` | thinking을 구조화된 `reasoning_details` 배열로 반환하는 모델(`reasoning_split` 사용 MiniMax M 시리즈). 스트림 델타는 누적 스냅샷이라 prefix-diff로 처리하고, 보존된 reasoning은 `reasoning_content` 문자열 대신 `reasoning_details` 배열로 리플레이합니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 729d00601ff..69fee3caeba 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -288,7 +288,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `webSearchBridge?` | `{ enabled?: boolean; backend?: "ollama" \| "openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"; maxSearches?: number; timeoutMs?: number; endpoint?: string }` | Key-auth `openai-responses` passthrough providers only. Off by default. Codex always declares the hosted `web_search` tool, and the passthrough relays it on the assumption the destination executes it. A gateway that does not run hosted search answers with a `function_call` named `web_search` that nothing runs, and the undeclared-tool guard ends the turn. With `enabled: true` and an explicit `backend` OpenCodex intercepts that call, runs the search itself, feeds the result back to the same upstream, and shows Codex a hosted `web_search_call` cell. Never armed for `authMode: "forward"` (ChatGPT already searches) or for a provider that executes hosted search upstream. `backend` is required; there is no implicit default and a missing credential for the named backend leaves the bridge disarmed rather than falling through to another paid search. `ollama` reuses this provider's own API key on `POST /api/web_search`, so the origin must be `https://ollama.com` unless the operator names `endpoint` explicitly. `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and that executor's own credential (`webSearchSidecar.exaApiKey` for Exa). The search model comes from `webSearchSidecar.model` only when `webSearchSidecar.backend` resolves to the same backend this bridge names; otherwise the bridge runs that backend's own default, because a model chosen for one vendor is rejected by another. An unset `webSearchSidecar.backend` resolves to `openai`, so an unset-backend model reaches an `openai` bridge and no other. There is no per-provider bridge model override. Streaming turns only. A turn that mixes `web_search` with another client tool call still fails closed rather than dropping the client's call. Assistant text such as XML-like `` prose is not executed. Defaults: `maxSearches: 3` (1..10), `timeoutMs: 60000` (1000..600000). | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key providers only (`authMode: "key"`). Opt-in same-target 429 retry: when `retryOn429` is absent the feature is off, except that key-auth OpenCode Go and Command Code (canonical endpoints) fall back to a patient policy (6 replays, 10 s interval, 60 s cap); object presence enables it unless `enabled: false`, which also turns that fallback off. On 429 the proxy waits (upstream `Retry-After` or the fixed interval) and replays the identical request on the same key before any key failover — across the main text-turn recovery loop, the Responses passthrough wire, the image/video bridge, the web-search sidecar, and terminal continuations. Only pre-stream HTTP 429 responses are eligible for replay; custom `runTurn` transports are outside the HTTP retry loop. `attempts` counts same-key replays after the first 429 (total sends = `attempts` + 1) and is one request-wide budget shared by the main recovery loop, the terminal-guard continuation, and bridge retries. Exhausting `attempts` only stops further same-key replays: normal key failover or final-error handling then applies per the available targets — on the key-auth passthrough wire there is no failover, so the exhausted 429 surfaces as-is. Codex itself never retries 429, so this is the only defense for single-key providers. Defaults: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (any single wait is capped at `maxIntervalMs`, itself capped at 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Key-auth `openai-chat` and `openai-responses` providers only. `authMode: "forward"` providers (the ChatGPT account pool) never read this option and keep the default ladder. Opt-in retry for pre-stream transient upstream statuses (500, 502, 503, 504, 520, 521, 522): absent means off, object presence enables it unless `enabled: false`. Covers the initial Responses request, the Responses passthrough lane and each of its recovery legs (OAuth-401 replay, same-target 429 replay, validated rebuild), the terminal-guard continuation, and native `/v1/chat/completions`. `attempts` is the TOTAL number of upstream sends allowed for one request including the first (1..10, default 3) — it is one budget shared with connection-reset recovery, so `3` means at most three real requests reach the provider. On the Responses passthrough lane the configured value is additionally intersected with the request-wide send allowance, so a value below that allowance narrows the ladder exactly while a value above it does not raise the bound. Waits use a fixed 400 ms exponential backoff capped at 5 s and honor `Retry-After`. Separate from `retryOn429`, which handles rate limiting; mid-stream failures are never replayed. | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Native `openai-responses` providers, including `authMode: "forward"`. Opt-in replacement of a send that failed while the caller had observed nothing: absent means off, object presence enables it unless `enabled: false`. Covers both ambiguous stages — a connection that died before any response header, and an SSE body that died after the header while carrying only control events. A canonical ChatGPT upstream WebSocket that closed or errored after its create frame left, before any Responses event, is covered the same way, and its replacement is sent over HTTP. Only a self-contained request is ever replaced: `store: false`, complete `input`, no `previous_response_id`, `conversation` or `stream_id`, and only client-executed tools. `replacements` is the number of replacement sends ONE logical request may make across every leg and every combo child (1..2, default 1) — not a per-leg retry count and not a send budget, so a replacement still has to fit inside the send allowance the leg already had. A request that already emitted output or a tool call is never replaced, whatever this is set to. The replacement inference may still be billed if the origin had already started the first one, which is why this is off by default. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Native `openai-responses` providers, including `authMode: "forward"`, plus generic translated Responses dispatch before upstream headers. Opt-in replacement of a send that failed while the caller had observed nothing: absent means off, object presence enables it unless `enabled: false`. Covers both ambiguous stages — a connection that died before any response header, and an SSE body that died after the header while carrying only control events. A canonical ChatGPT upstream WebSocket that closed or errored after its create frame left, before any Responses event, is covered the same way, and its replacement is sent over HTTP. Only a self-contained request is ever replaced: `store: false`, complete `input`, no `previous_response_id`, `conversation` or `stream_id`, and only client-executed tools. `replacements` is the number of replacement sends ONE logical request may make across every leg and every combo child (1..2, default 1) — not a per-leg retry count and not a send budget, so a replacement still has to fit inside the send allowance the leg already had. A request that already emitted output or a tool call is never replaced, whatever this is set to. The replacement inference may still be billed if the origin had already started the first one, which is why this is off by default. Generic translated dispatch supports pre-header reset replacement for initial and rebuilt sends under the same grant and send budget. Adapter-owned transports and translated stream failures after upstream headers are excluded. | | `autoToolChoiceOnlyModels?` | `string[]` | Models whose `tool_choice` accepts only `auto` or `none`; forced choices are downgraded. | | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. A provider save that keeps the destination keeps the stored list, including `[]`; see [What a provider save keeps](#what-a-provider-save-keeps). `PATCH /api/providers?name=` accepts an array or `null` to clear it. | | `inlineThinkTagModels?` | `string[]` | Opt-in recovery for `openai-chat` gateways without a server-side reasoning parser. A leading `` / `` / `` block (optionally after whitespace) activates splitting in streamed and buffered replies. All answer whitespace is preserved. Subsequent tags are delimiters anywhere, including same-line interleaving and code fences; this mode does not interpret Markdown. Ordinary text or a code fence before the first tag keeps the whole reply untouched. Off by default; prefer structured upstream reasoning or `reasoningSplitModels` where supported. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 1d36ca37398..7070027d795 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -148,7 +148,7 @@ cross-route credential fallback не существует. Строки API GPT- | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Только для провайдеров с API-ключом (`authMode: "key"`). Опциональный повтор при 429 на том же таргете: если `retryOn429` отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. При 429: ожидание (`Retry-After` апстрима или фиксированный интервал) и повтор идентичного запроса на том же ключе до любого фейловера ключей — покрывает основной цикл восстановления текстовых ходов, passthrough-канал Responses, мост изображений/видео, sidecar web-search и терминальные продолжения. Повтор допустим только для HTTP 429, полученных до начала потока; пользовательские транспорты `runTurn` не входят в цикл HTTP-повторов. `attempts` — это число повторов на том же ключе после первого 429 (всего отправок = `attempts` + 1) и единый бюджет на запрос, общий для основного цикла восстановления, терминального продолжения и повторов моста. Исчерпание `attempts` лишь останавливает дальнейшие повторы на том же ключе; далее применяется обычный фейловер ключей или финальная обработка ошибки в зависимости от доступных таргетов — на passthrough-канале с ключевой аутентификацией фейловера нет, поэтому исчерпанный 429 возвращается как есть. Codex сам никогда не повторяет 429, поэтому это единственная защита для провайдеров с одним ключом. По умолчанию: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (любое ожидание ограничено `maxIntervalMs`, который сам ограничен 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Только для провайдеров `openai-chat` и `openai-responses` с аутентификацией по ключу. Провайдеры с `authMode: "forward"` (пул аккаунтов ChatGPT) никогда не читают эту настройку и сохраняют число повторов по умолчанию. Опциональный повтор при временных статусах апстрима до начала потока (500, 502, 503, 504, 520, 521, 522): если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает исходный запрос `Responses`, продолжение терминального предохранителя, нативный `/v1/chat/completions`, а также повторные запросы при восстановлении после 429 или ошибки учётной записи. `attempts` — ОБЩЕЕ число разрешённых отправок в апстрим для одного запроса, включая первую (1..10, по умолчанию 3). Это единый бюджет на запрос, общий с восстановлением после сброса соединения, поэтому `3` означает, что до провайдера дойдут не более трёх реальных запросов. Ожидание использует экспоненциальную задержку с фиксированной начальной величиной 400 мс, ограниченную 5 с, и учитывает `Retry-After`. Параметр не связан с `retryOn429`, который обрабатывает ограничение частоты запросов; сбои после начала потока никогда не воспроизводятся. | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Только для нативных провайдеров `openai-responses`, включая `authMode: "forward"`. Необязательная замена отправки, которая завершилась неудачей, когда вызывающая сторона ещё ничего не наблюдала: если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает обе неоднозначные стадии — обрыв соединения до любого заголовка ответа и обрыв тела SSE после заголовка, когда оно несло только управляющие события. Так же покрывается upstream WebSocket canonical ChatGPT, который закрылся или завершился ошибкой после отправки кадра create и до любого события Responses; замена в этом случае отправляется по HTTP. Заменяется только самодостаточный запрос: `store: false`, полный `input`, отсутствие `previous_response_id`, `conversation` и `stream_id`, и только инструменты, исполняемые клиентом. `replacements` — число замещающих отправок, которые ОДИН логический запрос может сделать по всем участкам и всем дочерним запросам комбо (1..2, по умолчанию 1). Это не число повторов на участок и не бюджет отправок, поэтому замена всё равно должна поместиться в уже имеющийся у участка лимит отправок. Запрос, который уже выдал вывод или вызов инструмента, не заменяется никогда, каким бы ни было это значение. Замещающий вывод модели всё равно может быть оплачен, если источник уже начал первый, поэтому параметр выключен по умолчанию. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Для нативных провайдеров `openai-responses`, включая `authMode: "forward"`, а также для общего пути отправки переведённых запросов Responses до получения заголовков от upstream. Необязательная замена отправки, которая завершилась неудачей, когда вызывающая сторона ещё ничего не наблюдала: если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает обе неоднозначные стадии — обрыв соединения до любого заголовка ответа и обрыв тела SSE после заголовка, когда оно несло только управляющие события. Так же покрывается upstream WebSocket canonical ChatGPT, который закрылся или завершился ошибкой после отправки кадра create и до любого события Responses; замена в этом случае отправляется по HTTP. Заменяется только самодостаточный запрос: `store: false`, полный `input`, отсутствие `previous_response_id`, `conversation` и `stream_id`, и только инструменты, исполняемые клиентом. `replacements` — число замещающих отправок, которые ОДИН логический запрос может сделать по всем участкам и всем дочерним запросам комбо (1..2, по умолчанию 1). Это не число повторов на участок и не бюджет отправок, поэтому замена всё равно должна поместиться в уже имеющийся у участка лимит отправок. Запрос, который уже выдал вывод или вызов инструмента, не заменяется никогда, каким бы ни было это значение. Замещающий вывод модели всё равно может быть оплачен, если источник уже начал первый, поэтому параметр выключен по умолчанию. Общий путь перевода поддерживает замену при сбросе соединения до заголовков для первоначальных и заново сформированных отправок, используя то же разрешение и бюджет отправок. Транспорты, управляемые самими адаптерами, и сбои переведённого потока после получения заголовков от upstream исключены. | | `autoToolChoiceOnlyModels?` | `string[]` | Модели, у которых `tool_choice` принимает только `auto` или `none`; forced choice понижается. | | `preserveReasoningContentModels?` | `string[]` | Модели, которым нужен предыдущий assistant `reasoning_content` в chat history. Сохранение в дашборде не меняет записанный список, в том числе `[]`. `PATCH /api/providers?name=` принимает массив или `null`, чтобы его очистить. Сохранение, которое переносит провайдера на другой адаптер, базовый URL или режим аутентификации, список не сохраняет (см. раздел ниже). | | `reasoningDetailsModels?` | `string[]` | Модели, чей endpoint возвращает thinking как структурированный массив `reasoning_details` (MiniMax M-series с `reasoning_split`); потоковые дельты — кумулятивные снимки, сравниваемые по префиксу, а сохранённый reasoning воспроизводится массивом `reasoning_details` вместо строки `reasoning_content`. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 0d7bb915565..c54cf2a12bb 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -149,7 +149,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Yalnızca API anahtarı sağlayıcıları (`authMode: "key"`). İsteğe bağlı aynı hedef 429 yeniden denemesi: `retryOn429` olmadığında özellik kapalıdır; nesnenin varlığı `enabled: false` olmadığı sürece özelliği etkinleştirir. 429'da proxy bekler (yukarı akış `Retry-After` veya sabit aralık) ve herhangi bir anahtar yük devretmesinden önce aynı istek üzerinde aynı anahtarla aynı isteği yeniden oynatır — ana metin turu kurtarma döngüsü, Responses doğrudan geçiş hattı, görsel/video köprüsü, web araması sidecar'ı ve terminal devamları genelinde. Yalnızca akış öncesi HTTP 429 yanıtları yeniden oynatma için uygundur; özel `runTurn` aktarımları HTTP yeniden deneme döngüsünün dışındadır. `attempts`, ilk 429'dan sonraki aynı anahtar yeniden oynatmalarını sayar (toplam gönderim = `attempts` + 1) ve ana kurtarma döngüsü, terminal koruma devamı ve köprü yeniden denemeleri tarafından paylaşılan tek bir istek genelinde bütçedir. `attempts`'ı tüketmek yalnızca daha fazla aynı anahtar yeniden oynatmasını durdurur: normal anahtar yük devretmesi veya nihai hata işleme daha sonra kullanılabilir hedeflere göre geçerli olur — anahtar kimlik doğrulamalı doğrudan geçiş hattında yük devretme yoktur, bu nedenle tükenen 429 olduğu gibi görünür. Codex'in kendisi 429'u asla yeniden denemez, bu nedenle tek anahtarlı sağlayıcılar için tek savunma budur. Varsayılanlar: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (tek bir bekleme `maxIntervalMs` ile sınırlandırılır, kendisi de 600000 ile sınırlandırılır), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Yalnızca anahtarla kimlik doğrulanan `openai-chat` ve `openai-responses` sağlayıcıları. `authMode: "forward"` sağlayıcıları (ChatGPT hesap havuzu) bu seçeneği hiç okumaz ve varsayılan merdiveni korur. Akış öncesi geçici yukarı akış durumları (500, 502, 503, 504, 520, 521, 522) için isteğe bağlı yeniden deneme: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İlk Responses isteğini, terminal koruma devamını, yerel `/v1/chat/completions` isteklerini ve 429/hesap kurtarma yeniden getirmelerini kapsar. `attempts`, bir istek için ilk gönderim dahil izin verilen yukarı akış gönderimlerinin TOPLAM sayısıdır (1..10, varsayılan 3) — bağlantı sıfırlama kurtarmasıyla paylaşılan, istek kapsamlı tek bütçedir; dolayısıyla `3`, sağlayıcıya en fazla üç gerçek isteğin ulaşması anlamına gelir. Beklemelerde 400 ms'lik sabit üstel geri çekilme uygulanır, süre 5 sn ile sınırlandırılır ve `Retry-After` dikkate alınır. Hız sınırlamasını işleyen `retryOn429` seçeneğinden ayrıdır; akış ortası hataları hiçbir zaman yeniden oynatılmaz. | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Yalnızca yerel `openai-responses` sağlayıcıları, `authMode: "forward"` dahil. Çağıranın hiçbir şey gözlemlemediği bir anda başarısız olan gönderimin isteğe bağlı olarak değiştirilmesi: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İki belirsiz aşamayı da kapsar: yanıt başlığı gelmeden kopan bağlantı ve başlıktan sonra yalnızca denetim olayları taşırken kopan SSE gövdesi. Kanonik ChatGPT upstream WebSocket'i create çerçevesi gönderildikten sonra, herhangi bir Responses olayından önce kapanır veya hata verirse aynı şekilde kapsanır ve değiştirme gönderimi HTTP üzerinden yapılır. Yalnızca kendi kendine yeten bir istek değiştirilir: `store: false`, eksiksiz `input`, `previous_response_id`, `conversation` veya `stream_id` bulunmaması ve yalnızca istemcinin yürüttüğü araçlar. `replacements`, BİR mantıksal isteğin tüm bacaklar ve tüm combo alt istekleri boyunca yapabileceği değiştirme gönderimi sayısıdır (1..2, varsayılan 1). Bacak başına yeniden deneme sayısı da gönderim bütçesi de değildir; bu yüzden bir değiştirme gönderimi, ilgili bacağın hâlihazırda sahip olduğu gönderim payına sığmak zorundadır. Halihazırda çıktı veya araç çağrısı üretmiş bir istek, bu değer ne olursa olsun asla değiştirilmez. Kaynak ilk çıkarımı zaten başlatmışsa değiştirilen çıkarım yine ücretlendirilebilir; bu nedenle varsayılan olarak kapalıdır. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Yerel `openai-responses` sağlayıcıları (`authMode: "forward"` dahil) ve upstream başlıkları gelmeden önceki genel Responses çeviri gönderim yolu için geçerlidir. Çağıranın hiçbir şey gözlemlemediği bir anda başarısız olan gönderimin isteğe bağlı olarak değiştirilmesi: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İki belirsiz aşamayı da kapsar: yanıt başlığı gelmeden kopan bağlantı ve başlıktan sonra yalnızca denetim olayları taşırken kopan SSE gövdesi. Kanonik ChatGPT upstream WebSocket'i create çerçevesi gönderildikten sonra, herhangi bir Responses olayından önce kapanır veya hata verirse aynı şekilde kapsanır ve değiştirme gönderimi HTTP üzerinden yapılır. Yalnızca kendi kendine yeten bir istek değiştirilir: `store: false`, eksiksiz `input`, `previous_response_id`, `conversation` veya `stream_id` bulunmaması ve yalnızca istemcinin yürüttüğü araçlar. `replacements`, BİR mantıksal isteğin tüm bacaklar ve tüm combo alt istekleri boyunca yapabileceği değiştirme gönderimi sayısıdır (1..2, varsayılan 1). Bacak başına yeniden deneme sayısı da gönderim bütçesi de değildir; bu yüzden bir değiştirme gönderimi, ilgili bacağın hâlihazırda sahip olduğu gönderim payına sığmak zorundadır. Halihazırda çıktı veya araç çağrısı üretmiş bir istek, bu değer ne olursa olsun asla değiştirilmez. Kaynak ilk çıkarımı zaten başlatmışsa değiştirilen çıkarım yine ücretlendirilebilir; bu nedenle varsayılan olarak kapalıdır. Genel çeviri gönderim yolu, ilk ve yeniden oluşturulan gönderimlerde başlık öncesi bağlantı sıfırlamasından sonra aynı izin ve gönderim bütçesiyle değiştirme destekler. Adaptörlerin kendi yönettiği taşıma yolları ve upstream başlıklarından sonraki çevrilmiş akış hataları kapsam dışıdır. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`'u yalnızca `auto` veya `none` kabul eden modeller; zorunlu seçimlerin derecesi düşürülür. | | `preserveReasoningContentModels?` | `string[]` | Sohbet geçmişinde önceki asistan `reasoning_content`'ini gerektiren modeller. Dashboard üzerinden yapılan kayıtlar, `[]` dahil saklanan listeyi korur. `PATCH /api/providers?name=`, bir dizi veya temizlemek için `null` kabul eder. Sağlayıcıyı başka bir bağdaştırıcıya, temel URL'ye veya kimlik doğrulama moduna taşıyan bir kayıt listeyi korumaz (aşağıdaki bölüme bakın). | | `reasoningDetailsModels?` | `string[]` | Thinking'i yapılandırılmış bir `reasoning_details` dizisi olarak döndüren modeller (`reasoning_split` ile MiniMax M-serisi); akış deltaları önek farkıyla işlenen kümülatif anlık görüntülerdir ve korunan reasoning, `reasoning_content` dizesi yerine `reasoning_details` dizisi olarak yeniden oynatılır. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 0edbc641cad..fa9e46f2fc2 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -136,7 +136,7 @@ selector,而不是分配一个新名称。 | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | 仅限 API-key 提供商(`authMode: "key"`)。可选的同目标 429 重试:未配置 `retryOn429` 时功能关闭;对象存在即启用,除非 `enabled: false`。收到 429 时等待(上游 `Retry-After` 或固定间隔)后在相同 key 上重放完全相同请求,再进入任何 key 故障转移——覆盖主文本恢复循环、Responses passthrough、图像/视频桥、web-search 侧车与终结续接。重放仅适用于流开始前的 HTTP 429 响应;自定义 `runTurn` 传输不在 HTTP 重试循环范围内。`attempts` 是首个 429 之后的同 key 重放次数(总发送次数 = `attempts` + 1),是主恢复循环、终结守卫续接与桥接重试共享的按请求统一预算;`attempts` 耗尽只会停止进一步的同 key 重放:随后按可用目标进行正常的 key 故障转移或最终错误处理——key 认证的 passthrough 线路上没有故障转移,因此耗尽的 429 会原样透出。Codex 自身从不重试 429,因此这是单 key 提供商唯一的防线。默认值:`enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(单次等待以 `maxIntervalMs` 为上限,其本身上限 600000)、`respectRetryAfter: true`。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 仅限使用 key 认证的 `openai-chat` 与 `openai-responses` 提供商。`authMode: "forward"` 的提供商(ChatGPT 账号池)从不读取此选项,保持默认重试次数。可选的流开始前上游瞬态状态码(500、502、503、504、520、521、522)重试:未配置时关闭;对象存在即启用,除非 `enabled: false`。覆盖初始 Responses 请求、终结守卫续接、原生 `/v1/chat/completions`,以及 429/账户恢复重新获取。`attempts` 是单个请求允许向上游发送的总次数,包含首次发送(1..10,默认 3);它是与连接重置恢复共享的按请求预算,因此 `3` 表示最多只有三个实际请求到达提供商。等待采用固定 400 毫秒的指数退避,上限为 5 秒,并遵循 `Retry-After`。此选项独立于处理速率限制的 `retryOn429`;流开始后的故障绝不会重放。 | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 仅限原生 `openai-responses` 提供商,包含 `authMode: "forward"`。可选地替换一次在调用方尚未观察到任何内容时就失败的发送:未配置时关闭;对象存在即启用,除非 `enabled: false`。涵盖两个不确定阶段——响应头到达前连接断开,以及响应头之后 SSE 正文只承载控制事件时断开。canonical ChatGPT 上游 WebSocket 在 create 帧发出之后、任何 Responses 事件到达之前关闭或出错时,也按同样方式处理,其替换发送走 HTTP。只有自包含的请求才会被替换:`store: false`、完整的 `input`、没有 `previous_response_id`/`conversation`/`stream_id`,且只使用由客户端执行的工具。`replacements` 是单个逻辑请求在所有环节和所有组合子请求中可以进行的替换发送次数(1..2,默认 1);它既不是按环节的重试次数,也不是发送预算,因此替换发送仍必须落在该环节已有的发送额度之内。已经产生输出或工具调用的请求,无论此值为何都不会被替换。如果上游已经开始了第一次推理,被替换的推理仍可能计费,因此该选项默认关闭。 | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 适用于原生 `openai-responses` 提供商(包含 `authMode: "forward"`),以及收到上游响应头之前的通用 Responses 转换发送路径。可选地替换一次在调用方尚未观察到任何内容时就失败的发送:未配置时关闭;对象存在即启用,除非 `enabled: false`。涵盖两个不确定阶段——响应头到达前连接断开,以及响应头之后 SSE 正文只承载控制事件时断开。canonical ChatGPT 上游 WebSocket 在 create 帧发出之后、任何 Responses 事件到达之前关闭或出错时,也按同样方式处理,其替换发送走 HTTP。只有自包含的请求才会被替换:`store: false`、完整的 `input`、没有 `previous_response_id`/`conversation`/`stream_id`,且只使用由客户端执行的工具。`replacements` 是单个逻辑请求在所有环节和所有组合子请求中可以进行的替换发送次数(1..2,默认 1);它既不是按环节的重试次数,也不是发送预算,因此替换发送仍必须落在该环节已有的发送额度之内。已经产生输出或工具调用的请求,无论此值为何都不会被替换。如果上游已经开始了第一次推理,被替换的推理仍可能计费,因此该选项默认关闭。 通用转换发送路径的首次发送和重建后的发送都支持响应头之前连接重置后的替换,使用同一授权和发送预算。适配器自行管理的传输,以及收到上游响应头之后的转换流故障,均不在此选项范围内。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` 只接受 `auto` 或 `none` 的模型;强制选择会被降级。 | | `preserveReasoningContentModels?` | `string[]` | 需要在聊天历史中保留先前 assistant `reasoning_content` 的模型。从仪表板保存时会保留已存储的列表(包括 `[]`)。`PATCH /api/providers?name=` 接受数组,或传入 `null` 清除该字段。将提供方改到其他适配器、base URL 或认证模式的保存不会保留该列表(见下文)。 | | `reasoningDetailsModels?` | `string[]` | 以结构化 `reasoning_details` 数组返回思考内容的模型(启用 `reasoning_split` 的 MiniMax M 系列);流式增量为累积快照,按前缀差分处理,保留的推理以 `reasoning_details` 数组而非 `reasoning_content` 字符串回放。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index e2834a0d8bd..f3d3a121179 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -107,7 +107,7 @@ ocx models provider openrouter on | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 與 `openai-responses` 供應商。`authMode: "forward"` 的供應商(ChatGPT 帳號池)從不讀取此選項,維持預設重試次數。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 僅限原生 `openai-responses` 供應商,包含 `authMode: "forward"`。可選擇性地替換一次在呼叫端尚未觀察到任何內容時就失敗的傳送:未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋兩個不確定階段——回應標頭抵達前連線中斷,以及標頭之後 SSE 內文只載有控制事件時中斷。canonical ChatGPT upstream WebSocket 在 create 訊框送出之後、任何 Responses 事件抵達之前關閉或出錯時,也以相同方式處理,其替換傳送改走 HTTP。只有自我完備的請求才會被替換:`store: false`、完整的 `input`、沒有 `previous_response_id`/`conversation`/`stream_id`,且僅使用由用戶端執行的工具。`replacements` 是單一邏輯請求在所有環節與所有組合子請求中可進行的替換傳送次數(1..2,預設 1);它既不是各環節的重試次數,也不是傳送預算,因此替換傳送仍必須落在該環節既有的傳送額度之內。已經產生輸出或工具呼叫的請求,無論此值為何都不會被替換。若上游已經開始第一次推論,被替換的推論仍可能計費,因此此選項預設停用。 | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 適用於原生 `openai-responses` 供應商(包含 `authMode: "forward"`),以及收到上游回應標頭之前的通用 Responses 轉換傳送路徑。可選擇性地替換一次在呼叫端尚未觀察到任何內容時就失敗的傳送:未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋兩個不確定階段——回應標頭抵達前連線中斷,以及標頭之後 SSE 內文只載有控制事件時中斷。canonical ChatGPT upstream WebSocket 在 create 訊框送出之後、任何 Responses 事件抵達之前關閉或出錯時,也以相同方式處理,其替換傳送改走 HTTP。只有自我完備的請求才會被替換:`store: false`、完整的 `input`、沒有 `previous_response_id`/`conversation`/`stream_id`,且僅使用由用戶端執行的工具。`replacements` 是單一邏輯請求在所有環節與所有組合子請求中可進行的替換傳送次數(1..2,預設 1);它既不是各環節的重試次數,也不是傳送預算,因此替換傳送仍必須落在該環節既有的傳送額度之內。已經產生輸出或工具呼叫的請求,無論此值為何都不會被替換。若上游已經開始第一次推論,被替換的推論仍可能計費,因此此選項預設停用。 通用轉換傳送路徑的首次傳送和重建後的傳送都支援標頭之前連線重設後的替換,使用同一授權和傳送預算。配接器自行管理的傳輸,以及收到上游回應標頭之後的轉換串流故障,均不在此選項範圍內。 | | `autoToolChoiceOnlyModels?` | `string[]` | 其 `tool_choice` 僅接受 `auto` 或 `none` 的模型;強制選擇被降級。 | | `preserveReasoningContentModels?` | `string[]` | 需要在 chat 歷史中保留先前 assistant `reasoning_content` 的模型。從儀表板儲存時會保留已儲存的清單(包括 `[]`)。`PATCH /api/providers?name=` 接受陣列,或傳入 `null` 清除該欄位。將供應商改到其他轉接器、base URL 或驗證模式的儲存不會保留該清單(見下文)。 | | `reasoningDetailsModels?` | `string[]` | 以結構化 `reasoning_details` 陣列回傳思考內容的模型(啟用 `reasoning_split` 的 MiniMax M 系列);串流增量為累積快照,以前綴差分處理,保留的推理以 `reasoning_details` 陣列而非 `reasoning_content` 字串重播。 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 39c908bd9fd..900a3a4334a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1544,6 +1544,7 @@ "responses-reasoning-effort-downgrade.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", "responses-reset-replay.test.ts": "responses", + "responses-translated-reset.test.ts": "responses", "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-send-budget-counts.test.ts": "responses", diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index ffc991fb755..e41c219f3f2 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -1,5 +1,8 @@ import { classifyAnthropic429 } from "../../oauth/anthropic-rate-limit-policy"; import { rotateAnthropicAccountOnResponse } from "../../oauth/anthropic-account-refusal"; +import { authorizeResendForRecovery } from "../../lib/request-resend-gate"; +import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay"; +import { transientSendCapFor } from "./request-send-budget"; import { isNonReplayableResponse } from "../../lib/upstream-retry"; import { isLocalUpstream } from "../../lib/local-upstream"; import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options"; @@ -25,6 +28,7 @@ import { withProviderRequestSlot } from "../../providers/request-pacing"; import { providerFetch, fetchWithHeaderTimeout, safeHostLabel } from "./fetch-helpers"; import { transientRetryPolicyFor, + resetReplayPolicyFor, rateLimitRetryPolicyFor, hasKeyPoolFailover, rotateProviderTransportOn401, @@ -156,6 +160,8 @@ export async function prepareAdapterExchange( | "reserveCredentialHop" | "pendingHopPermit" | "workflowRootId" + | "claimAmbiguousResend" + | "sendsUsed" >, ) { const { options, config, logCtx, req } = requestContext; @@ -189,9 +195,18 @@ export async function prepareAdapterExchange( recoveryClassFor, sendBudgetExhausted, reserveCredentialHop, + claimAmbiguousResend, } = sendBudgetState; + let selfContainedJudgment: boolean | undefined; + const requestIsSelfContained = (): boolean => + selfContainedJudgment ??= selfContainedResponsesBody(parsed._rawBody); + const claimPreHeaderResend = (): boolean => authorizeResendForRecovery( + "pre-header", "connection-reset", + ambiguousResendAllowanceFor(route.provider, requestIsSelfContained, claimAmbiguousResend), + ).allowed; + const upstream = new AbortController(); const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal); const connectMs = config.connectTimeoutMs ?? 200_000; @@ -343,6 +358,13 @@ export async function prepareAdapterExchange( const compactPrepaid = options.compactionRecoveryAttempted ? sendBudgetState.pendingHopPermit : undefined; if (compactPrepaid) sendBudgetState.pendingHopPermit = undefined; let compactPrepaidUsed = false; + // Combo and emergency compaction admission already book this target's first send. + // Its configured initial ceiling is target-local; the shared remainder below still + // accounts for earlier targets without deducting their sends from this target twice. + const initialSendCap = transientPolicy || resetReplayPolicyFor(route.provider) + ? transientSendCapFor(transientPolicy?.attempts, + (options.comboAttempt || compactPrepaid) ? 0 : sendBudgetState.sendsUsed) + : 1; const fetchWithRetryPolicy = (route.provider.adapter === "google" || transientPolicy) ? fetchWithTransientRetry : fetchWithResetRetry; @@ -367,15 +389,12 @@ export async function prepareAdapterExchange( { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url), - ...(transientPolicy || compactPrepaid - // Draws the remainder, not the raw policy. A combo child inherits the parent's - // holder but used to take a fresh full allowance on its own first send, so the - // shared counter was inherited without ever being read as a limit. + claimAmbiguousResend: claimPreHeaderResend, + ...(transientPolicy || resetReplayPolicyFor(route.provider) || compactPrepaid ? { - // The first emergency send is already paid for. Only retries consume the - // remaining allowance; treating the booking as unavailable blocks a cap of two. - attempts: Math.min(transientPolicy?.attempts ?? 1, - remainingTransientSendBudget(transientPolicy?.attempts ?? 1) + (compactPrepaid ? 1 : 0)), + // A pending compaction permit already booked this leg's first physical send. + attempts: Math.min(initialSendCap, + remainingTransientSendBudget(initialSendCap) + (compactPrepaid ? 1 : 0)), onSendsConsumed: noteTransientSends, } : {}), @@ -545,14 +564,21 @@ export async function prepareAdapterExchange( const refetchWithPolicy = (route.provider.adapter === "google" || refetchTransientPolicy) ? fetchWithTransientRetry : fetchWithResetRetry; - // Same rule as the passthrough rebuild: spend the base allowance first, then the one - // shared final-recovery reserve, so a recovery that follows a spent streak still gets - // its single send instead of dying at three. - const refetchAllowance = refetchTransientPolicy - ? recoverySendAllowance( - refetchTransientPolicy.attempts, - recoveryClassFor(recovery), + const helperCountsSends = refetchTransientPolicy !== null || resetReplayPolicyFor(route.provider) !== null; + const prepaid = sendBudgetState.pendingHopPermit; + const configuredTotal = refetchTransientPolicy?.attempts; + const refetchCap = transientSendCapFor(configuredTotal, + sendBudgetState.sendsUsed - (prepaid ? 1 : 0)); + // An exact total includes a booked hop, even when it consumed the last base slot. + // Keep that funded send while forbidding the final reserve from widening the total. + const prepaidLastSlot = helperCountsSends && configuredTotal !== undefined && prepaid + && refetchCap > 0 && remainingTransientSendBudget(refetchCap) === 0; + if (prepaidLastSlot) sendBudgetState.pendingHopPermit = undefined; + const refetchAllowance = helperCountsSends + ? prepaidLastSlot ? { attempts: 1, permit: prepaid } : recoverySendAllowance( + refetchCap, recoveryClassFor(recovery), `${route.providerName}|${route.modelId}|${recovery}`, + { allowFinalRecoveryReserve: configuredTotal === undefined }, ) : undefined; try { @@ -566,7 +592,7 @@ export async function prepareAdapterExchange( // can be refused above before it does. use() past the first attempt is a no-op. onDispatch?.(); if (preserveFailureResponse) replacementAdmitted = true; - if (!refetchTransientPolicy) chargeFastDowngradeWorkflowSend(); + if (!refetchAllowance) chargeFastDowngradeWorkflowSend(); return fetchWithHeaderTimeout(retryRequest.url, applyUpstreamRecoveryInit({ method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body, @@ -580,6 +606,7 @@ export async function prepareAdapterExchange( { abortSignal: upstream.signal, label: safeHostLabel(retryRequest.url), + claimAmbiguousResend: claimPreHeaderResend, ...(refetchAllowance ? { attempts: refetchAllowance.attempts, @@ -638,7 +665,7 @@ export async function prepareAdapterExchange( const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined; const hop = reserveCredentialHop("auth-recovery", `${route.providerName}|${route.modelId}|antigravity-auth`, - !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null); + !adapterOwnsDispatch && (transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null)); if (!hop.allowed) return null; try { const snapshot = await failoverAccountSnapshot(route.providerName, nextId); @@ -708,7 +735,7 @@ export async function prepareAdapterExchange( const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined; const hop = reserveCredentialHop("auth-recovery", `${route.providerName}|${route.modelId}|terminal-refresh-account`, - !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null); + !adapterOwnsDispatch && (transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null)); if (hop.allowed) { try { const admitted = await applyFailoverSnapshot(alternate); @@ -861,7 +888,7 @@ export async function prepareAdapterExchange( const hop = reserveCredentialHop( "repair", `${route.providerName}|${route.modelId}|anthropic-fast-downgrade`, - !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null, + !adapterOwnsDispatch && (transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null), ); if (hop.allowed) { anthropicFastDowngradeGuard.attempted = true; @@ -1059,10 +1086,9 @@ export async function prepareAdapterExchange( const hop = reserveCredentialHop( "auth-recovery", `${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`, - // Only a helper-routed replay reports this send back. A reset-only refetch reports - // nothing and an adapter ladder settles the booking itself, so promising an external - // report on either would leave a booking pending until it swallowed a later charge. - !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null, + // Transient and opted-in reset helpers report physical sends; an adapter-owned + // ladder settles its own booking. + !adapterOwnsDispatch && (transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null), ); if (!hop.allowed) break; const nextAccountId = rotateGenericOAuthAccountOnRefusal( @@ -1164,10 +1190,9 @@ export async function prepareAdapterExchange( const hop = reserveCredentialHop( "auth-recovery", `${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`, - // Only a helper-routed replay reports this send back. A reset-only refetch reports - // nothing and an adapter ladder settles the booking itself, so promising an external - // report on either would leave a booking pending until it swallowed a later charge. - !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null, + // Transient and opted-in reset helpers report physical sends; an adapter-owned + // ladder settles its own booking. + !adapterOwnsDispatch && (transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null), ); if (!hop.allowed) break; const nextAccountId = rotateGenericOAuthAccountOn429( @@ -1276,7 +1301,7 @@ export async function prepareAdapterExchange( const hop = reserveCredentialHop( "auth-recovery", `${route.providerName}|${route.modelId}|adapter-recovery-oauth-verify`, - !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null, + !adapterOwnsDispatch && (transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null), ); if (!hop.allowed) break; // Auth-refusal rotation, not the rate-limit one: a verification refusal diff --git a/src/types/provider.ts b/src/types/provider.ts index adfa680e808..5f13ce36d7f 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -68,15 +68,15 @@ export interface TransientRetryPolicy { } /** - * Opt-in replacement of a native Responses send whose upstream connection closed while the + * Opt-in replacement of a Responses send whose upstream connection closed while the * caller had observed nothing (`providers..retryOnReset`). * - * Covers both ambiguous stages the proxy can be in: no response head at all, and a head whose - * SSE body carried only control events. Disabled unless the object is present; a bare `{}` - * opts in with defaults. Only a request the proxy can judge self-contained is ever replaced; - * see `src/server/responses/reset-replay.ts`. The replacement inference may still be billed if - * the origin had already started the first one, which is what makes this opt-in rather than - * default. + * Native Responses covers pre-header resets and post-header SSE carrying only control events. + * Generic translated dispatch covers initial and rebuilt pre-header sends, sharing the same + * grant and send budget; adapter-owned transports and translated post-header failures are excluded. + * Disabled unless present; `{}` opts in. Only self-contained requests qualify (see + * `src/server/responses/reset-replay.ts`). Replacement inference may still be billed if the + * origin had already started the first one, so this is opt-in rather than default. */ export interface ResetReplayPolicy { /** Master switch. Presence of the object also enables the policy (default true). */ @@ -1010,9 +1010,9 @@ export interface OcxProviderConfig { */ transientRetryOn5xx?: TransientRetryPolicy; /** - * Opt-in replacement of a native Responses send that died while the caller had observed - * nothing (`providers..retryOnReset`). Disabled unless present; a bare `{}` opts in - * with defaults. Native Responses sends only, and only for self-contained requests. + * Opt-in replacement of self-contained Responses sends (`providers..retryOnReset`). + * Disabled unless present; `{}` opts in. Native sends and generic translated initial/rebuilt + * pre-header sends share the grant/budget; adapter-owned and translated post-header failures are excluded. */ retryOnReset?: ResetReplayPolicy; /** diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 4ed2e4972dc..22dab82520b 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -12,7 +12,7 @@ self-contained routed v1/v2 compaction in `core.ts` and `compact.ts`; normal and requests keep their original route. One configured emergency target shares the original send and translation budgets. Physical-send receipts and explicit retry-helper reports reconcile legacy fetch sends without double charging external reservations; one prepaid emergency permit is shared -with adapter dispatch, and only additional retries draw from the remainder. Adapter observers retain partial-output and structured denial evidence +with adapter dispatch, and only additional retries draw from the remainder. The emergency target's configured initial allowance is intersected with that shared remainder plus its prepaid send; source-provider sends are not deducted from the emergency target's allowance a second time. Adapter observers retain partial-output and structured denial evidence before response projection. Native encrypted compaction, uploaded files, stored continuations, and policy/combo routes are excluded. Emergency output must contain one readable portable compaction item; recent original user messages are retained verbatim, and recovery failure keeps @@ -49,18 +49,18 @@ with the same item id. The batch/non-streaming bridge follows the same rule. `src/lib/upstream-retry.ts` guards upstream fetches against stale pooled keep-alive sockets (Cloudflare closes idle connections; Bun's fetch reuses the dead socket and rejects with `ECONNRESET` before any response bytes). `fetchWithResetRetry` never retries on its own -account. A reset-shaped rejection is replayed only when the caller passes `replaySafe: true`, -and then up to 3 total attempts with jittered backoff, warn-logged. Without it the rejection -becomes the terminal refusal described in +account. A caller passing `replaySafe: true` permits up to 3 total attempts with jittered +backoff, warn-logged. A separate `retryOnReset` operator grant can authorize an ambiguous +replacement within the existing send budget. Without either permission, the rejection becomes the terminal refusal described in [ambiguous connection-reset replay boundary](#ambiguous-connection-reset-replay-boundary). Reusable request bytes were never the test: a string body makes a send mechanically repeatable, not idempotent, and a model POST is not idempotent. Timeouts, aborts, `ECONNREFUSED`, HTTP error statuses, and mid-stream SSE failures are never retried at all. -The opted-in callers are the sidecars, whose work is a tool call rather than a turn: the +The `replaySafe` callers are the sidecars, whose work is a tool call rather than a turn: the vision describers, the web-search executors and loop, and the image loop. The model-POST paths — native Responses passthrough, the generic adapter dispatch and its continuation loop, -compact, and native Chat — are deliberately not opted in. Adapters with their own +compact, and native Chat — do not set `replaySafe`; eligible operator-granted replacements are separate. Adapters with their own `fetchResponse` (kiro, cursor, google) keep their own retry policies; kiro imports the shared abort/sleep helpers from this module. @@ -83,7 +83,7 @@ counter rather than holding a second. A replacement never widens a send budget: fit inside the allowance the leg already had, and it is charged to the same counter every other send goes through. -The number of replacements is the request's as well. A leg reads it from `route.provider`, which +Generic translated dispatch in `src/server/responses/adapter-dispatch.ts` asks the same pre-header gate for initial and rebuilt sends, sharing the replacement grant and charging each physical send once to the existing request/workflow budgets. Adapter-owned transports and translated post-header failures are excluded. Coverage: `tests/responses/responses-translated-reset.test.ts`. The number of replacements is the request's as well. A leg reads it from `route.provider`, which credential rotation, OAuth refresh, transport resolution and each combo target reassign inside one request, so the grant is held to the smallest ceiling any leg has presented rather than to whatever the asking leg presents. Otherwise a request that had already spent the one replacement a diff --git a/structure/transports/responses.md b/structure/transports/responses.md index aaa09a62464..ce5ac093c47 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -420,7 +420,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `request-sidecar-auth.ts` | Routed-compaction image projection, sidecar credential resolution and vision preprocessing. | | `response-effects.ts` | Completion notification, replay publication and live request-tool aliases. | | `request-send-budget.ts` | Request-wide send accounting, remaining allowance, the pending recovery permit and the shared ambiguous-resend grant. | -| `reset-replay.ts` | The operator opt-in for replacing an ambiguous native Responses send, and the per-request grant both stages claim from. | +| `reset-replay.ts` | Self-contained-request eligibility and the shared operator replacement grant for native Responses and generic translated initial/rebuilt pre-header sends; adapter-owned transports and translated post-header failures are excluded. | | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | | `passthrough-execution.ts` | Native host-lease transfer and the enclosing dispatch/delivery `finally`. | | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c74522309a9..9cc76354f3e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1556,6 +1556,7 @@ "responses-reasoning-effort-downgrade.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", "responses-reset-replay.test.ts": "responses", + "responses-translated-reset.test.ts": "responses", "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-send-budget-counts.test.ts": "responses", diff --git a/tests/responses/responses-compaction-recovery.test.ts b/tests/responses/responses-compaction-recovery.test.ts index 076909a06d3..b320ff27a4f 100644 --- a/tests/responses/responses-compaction-recovery.test.ts +++ b/tests/responses/responses-compaction-recovery.test.ts @@ -430,3 +430,35 @@ describe("routed compaction emergency integration", () => { expect(calls).toHaveLength(0); }); }); + + +describe("emergency compaction provider allowance matrix", () => { + // Independent expected counts: attempts 1/2/3 across shared caps 2/3/4. + test.each([ + [1, 2, false, 1], [1, 3, false, 1], [1, 4, false, 1], + [2, 2, false, 1], [2, 3, false, 2], [2, 4, false, 2], + [3, 2, false, 1], [3, 3, false, 2], [3, 4, false, 3], + [1, 2, true, 1], [1, 3, true, 1], [1, 4, true, 1], + [2, 2, true, 1], [2, 3, true, 2], [2, 4, true, 2], + [3, 2, true, 1], [3, 3, true, 2], [3, 4, true, 3], + ] as const)("attempts=%s shared cap=%s reset policy=%s allows %s emergency sends", async (attempts, sharedCap, resetPolicy, expectedEmergencySends) => { + const config = settings(); + config.providers.emergency = { + adapter: "openai-chat", authMode: "key", apiKey: "fixture-only", baseUrl: "https://emergency.example/v1", + transientRetryOn5xx: { attempts }, ...(resetPolicy ? { retryOnReset: {} } : {}), + }; + let emergencyRequests = 0; + globalThis.fetch = (async () => { + emergencyRequests++; + return Response.json({ error: { code: "server_error", message: "Emergency unavailable" } }, { status: 500 }); + }) as typeof fetch; + const budget = createRequestExecutionBudget({ maxTotalModelSends: sharedCap, baseSendAllowance: sharedCap, finalRecoveryAllowance: 0, maxAlternateTargetSends: 1, maxTargetTransitions: 1 }); + const response = await handleResponses(request(), config, { model: "", provider: "" }, { sendBudget: budget }); + expect(response.status).toBe(400); + expect(await response.text()).toContain("Source rejected compact fixture"); + expect(calls.map(call => call.model)).toEqual(["swe-2"]); + expect(emergencyRequests).toBe(expectedEmergencySends); + expect(budget.used).toBe(1 + emergencyRequests); + expect(budget.used).toBeLessThanOrEqual(sharedCap); + }); +}); diff --git a/tests/responses/responses-translated-reset.test.ts b/tests/responses/responses-translated-reset.test.ts new file mode 100644 index 00000000000..d92b3deb25b --- /dev/null +++ b/tests/responses/responses-translated-reset.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:http"; +import { saveCredential, getAccountSet, setActiveAccount } from "../../src/oauth/store"; +import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; +import { rememberResponseState, clearResponseStateForTests } from "../../src/responses/state"; +import { admitWorkflowTurn, workflowBudgetSnapshot, resetWorkflowBudgetsForTest } from "../../src/lib/workflow-budget"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { HandleResponsesOptions } from "../../src/server/responses/core-options"; +import { handleResponses } from "../../src/server/responses"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +async function probe(options: { + provider?: Partial; + providerName?: string; + body?: Record; + resets?: number; + exhausted?: boolean; + abort?: boolean; + spentGrant?: boolean; + downgrade?: boolean; + used?: number; + spendOnDowngrade?: boolean; + previous?: boolean; + prepaid?: boolean; + config?: Partial; + answer?: (ordinal: number) => Response; + executor?: typeof fetch; + oauth?: boolean; +}) { + const home = mkdtempSync(join(tmpdir(), "ocx-translated-reset-")); + const oldHome = process.env.OPENCODEX_HOME; + const oldFetch = globalThis.fetch; + process.env.OPENCODEX_HOME = home; + const release = acquireOwnedSpendHome(); + try { + const budget = createRequestExecutionBudget(); + budget.used = options.used ?? (options.exhausted ? 3 : 0); + const usedBefore = budget.used; + if (options.spentGrant) budget.claimAmbiguousResend(1); + const abort = new AbortController(); + resetWorkflowBudgetsForTest(); + const root = "translated-reset-fixture"; + const admission = admitWorkflowTurn(root, "interactive"); + expect(admission?.admitted).toBe(true); + if (admission?.admitted) admission.lease.release(); + const logCtx: RequestLogContext = { model: "", provider: "" }; + const extraOptions: Partial = {}; + if (options.prepaid) { + const reserved = budget.reserveDispatch({ sendClass: "repair", targetKey: "mock|test", countedExternally: true }); + expect(reserved.allowed).toBe(true); + if (reserved.allowed) { + extraOptions.compactionRecoveryAttempted = true; + extraOptions.compactionRecoveryPermit = reserved.permit; + } + } + if (options.previous) rememberResponseState( + { model: "mock/test", input: "earlier", store: true }, + { id: "resp_translated_prior", output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "prior answer" }] }], status: "completed" }, + undefined, { clientThreadId: root }, + ); + if (options.oauth) { + clearGenericFailoverHealth(); + for (const id of ["first", "second"]) await saveCredential("xai", { + access: `synthetic-${id}`, refresh: `synthetic-refresh-${id}`, + expires: Date.now() + 3_600_000, accountId: id, + }, { addAccount: true }); + await setActiveAccount("xai", getAccountSet("xai")!.accounts[0]!.id); + } + const authorizations: Array = []; + const bodies: string[] = []; + globalThis.fetch = (async (_input, init) => { + bodies.push(String(init?.body)); + authorizations.push(new Headers(init?.headers).get("authorization")); + if (options.executor) return options.executor(_input, init); + if (options.abort) abort.abort(); + if (options.answer) return options.answer(bodies.length); + if (options.downgrade && bodies.length === 1) { + if (options.spendOnDowngrade) budget.claimAmbiguousResend(1); + return Response.json({ error: { type: "invalid_request_error", param: "reasoning_effort", + message: "reasoning_effort max is not supported for this model" } }, { status: 400 }); + } + if (bodies.length <= (options.resets ?? 1) + (options.downgrade ? 1 : 0)) { + throw Object.assign(new Error("synthetic pre-header reset"), { code: "ECONNRESET" }); + } + return Response.json({ id: "chat-test", choices: [{ index: 0, + message: { role: "assistant", content: "ok" }, finish_reason: "stop" }] }); + }) as typeof fetch; + const providerName = options.providerName ?? "mock"; + const config = { port: 0, defaultProvider: providerName, providers: { + [providerName]: { adapter: "openai-chat", baseUrl: "https://synthetic.invalid/v1", apiKey: "synthetic", + models: ["test"], retryOnReset: {}, ...options.provider }, + }, ...options.config } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", "x-codex-parent-thread-id": root }, + body: JSON.stringify({ ...(options.previous ? { previous_response_id: "resp_translated_prior" } : {}), model: "mock/test", input: "hello", store: false, stream: false, ...options.body }), + }), config, logCtx, { sendBudget: budget, abortSignal: abort.signal, ...extraOptions }); + const text = await response.text(); + const attemptSends = (logCtx.attempts ?? []).reduce((sum, attempt) => sum + attempt.sendCount, 0); + const workflowSends = workflowBudgetSnapshot(root)?.sends ?? 0; + if (!Object.hasOwn(options.provider ?? {}, "retryOnReset") || (options.provider?.retryOnReset !== undefined && options.provider.retryOnReset.enabled !== false)) { + expect(budget.used - usedBefore).toBe(bodies.length); + expect(workflowSends).toBe(bodies.length); + } + expect(attemptSends).toBe(bodies.length); + return { status: response.status, text, bodies, used: budget.used, workflowSends, attemptSends, + grantSpent: budget.ambiguousResendSpent, reserveSpent: budget.reserveSpent, authorizations }; + + } finally { + globalThis.fetch = oldFetch; + try { + release(); + } finally { + clearResponseStateForTests(); + resetWorkflowBudgetsForTest(); + clearGenericFailoverHealth(); + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(home, { recursive: true, force: true }); + } + } +} + +describe("translated Responses pre-header reset replay (#6510)", () => { + test("a bare opt-in replays identical chat bytes once and charges both sends", async () => { + const result = await probe({}); + expect(result.status, result.text).toBe(200); + expect(result.bodies).toHaveLength(2); + expect(result.bodies[0]).toBe(result.bodies[1]); + expect(result.used).toBe(2); + }); + + test.each([ + ["absent opt-in", { provider: { retryOnReset: undefined } }], + ["disabled opt-in", { provider: { retryOnReset: { enabled: false } } }], + ["stored turn", { body: { store: true } }], + ["spent request-wide replacement", { spentGrant: true }], + ["exact one-send policy", { provider: { transientRetryOn5xx: { attempts: 1 } } }], + ] as const)("%s does not fund a duplicate turn", async (_name, options) => { + const result = await probe(options); + expect(result.status).toBe(429); + expect(result.text).toContain("upstream_reset_replay_refused"); + expect(result.bodies).toHaveLength(1); + }); + + test("a reset during a rebuilt effort-downgrade send uses the same grant", async () => { + const result = await probe({ downgrade: true, provider: { reasoningEfforts: ["high", "max"] }, + body: { reasoning: { effort: "max" } } }); + expect(result.status, result.text).toBe(200); + expect(result.bodies).toHaveLength(3); + expect(result.bodies[1]).toBe(result.bodies[2]); + expect(result.used).toBe(3); + }); + + test("repeated resets exhaust the grant after two sends", async () => { + const result = await probe({ resets: 10 }); + expect(result.status).toBe(429); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + }); + + test("exhausted send budget blocks dispatch", async () => { + const result = await probe({ exhausted: true }); + expect(result.status).toBe(429); + expect(result.bodies).toHaveLength(0); + }); + + test("cancellation never spends the replacement", async () => { + const result = await probe({ abort: true }); + expect(result.status).toBe(499); + expect(result.bodies).toHaveLength(1); + }); +}); + +const reset = (): never => { throw Object.assign(new Error("synthetic reset"), { code: "ECONNRESET" }); }; +const effortRefusal = (): Response => Response.json({ error: { type: "invalid_request_error", + param: "reasoning_effort", message: "reasoning_effort max is not supported for this model" } }, { status: 400 }); +const chatSuccess = (): Response => Response.json({ id: "chat-test", choices: [{ index: 0, + message: { role: "assistant", content: "ok" }, finish_reason: "stop" }] }); + +describe("translated reset replay boundaries and accounting", () => { + test("a valid stored predecessor refuses replay even after local expansion", async () => { + const result = await probe({ previous: true }); + expect(result.status, result.text).toBe(429); + expect(result.text).toContain("upstream_reset_replay_refused"); + expect(result.bodies).toHaveLength(1); + expect(result.bodies[0]).toContain("prior answer"); + expect(result.grantSpent).toBe(false); + }); + + test.each([ + ["conversation", { conversation: "conv-fixture" }], + ["stream id", { stream_id: "stream-fixture" }], + ["hosted tool", { tools: [{ type: "web_search" }] }], + ])("%s cannot authorize an ambiguous replacement", async (_label, body) => { + const result = await probe({ body }); + expect(result.status).toBe(429); + expect(result.text).toContain("upstream_reset_replay_refused"); + expect(result.bodies).toHaveLength(1); + expect(result.grantSpent).toBe(false); + }); + + test.each([400, 401, 429, 503])("replacement HTTP %i is terminal before rebuild or target hop", async status => { + const result = await probe({ provider: { reasoningEfforts: ["high", "max"], retryOn429: { attempts: 2 } }, + body: { reasoning: { effort: "max" } }, + answer: ordinal => ordinal === 1 ? reset() : status === 400 ? effortRefusal() + : Response.json({ error: { message: "recoverable-looking failure" } }, { status }) }); + expect(result.status, result.text).toBe(status === 400 ? 400 : 429); + expect(result.text).toContain(status === 400 ? "reasoning_effort max is not supported" : "upstream_reset_replay_refused"); + expect(result.bodies).toHaveLength(2); + expect(result.bodies[0]).toBe(result.bodies[1]); + expect(result.grantSpent).toBe(true); + }); + + test("a grant spent before rebuild stays spent on the rebuilt reset", async () => { + const result = await probe({ downgrade: true, spendOnDowngrade: true, + provider: { reasoningEfforts: ["high", "max"] }, body: { reasoning: { effort: "max" } } }); + expect(result.status).toBe(429); + expect(result.text).toContain("upstream_reset_replay_refused"); + expect(result.bodies).toHaveLength(2); + expect(result.bodies[0]).not.toBe(result.bodies[1]); + expect(result.grantSpent).toBe(true); + }); + + test("two granted replacements are shared within the configured three sends", async () => { + const result = await probe({ resets: 2, provider: { retryOnReset: { replacements: 2 } } }); + expect(result.status, result.text).toBe(200); + expect(result.bodies).toHaveLength(3); + expect(new Set(result.bodies).size).toBe(1); + expect(result.used).toBe(3); + expect(result.workflowSends).toBe(3); + }); + + test.each([1, 2, 3])("configured total %i remains exact across an effort rebuild", async attempts => { + const result = await probe({ provider: { reasoningEfforts: ["high", "max"], + transientRetryOn5xx: { attempts } }, body: { reasoning: { effort: "max" } }, + answer: ordinal => ordinal === 1 ? effortRefusal() : ordinal === 2 ? reset() : chatSuccess() }); + expect(result.bodies).toHaveLength(attempts); + expect(result.used).toBe(attempts); + expect(result.reserveSpent).toBe(false); + expect(result.status).toBe(attempts === 3 ? 200 : 429); + if (attempts === 2) expect(result.text).toContain("upstream_reset_replay_refused"); + }); + + test("a configured total also deducts sends from an earlier request leg", async () => { + const result = await probe({ used: 1, provider: { transientRetryOn5xx: { attempts: 2 } } }); + expect(result.status).toBe(429); + expect(result.bodies).toHaveLength(1); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(false); + }); + + test.each([false, true])("a prepaid compaction slot is counted once (exact policy %s)", async exact => { + const result = await probe({ prepaid: true, resets: 0, used: 2, + provider: exact ? { transientRetryOn5xx: { attempts: 3 } } : {}, answer: () => chatSuccess() }); + expect(result.status, result.text).toBe(200); + expect(result.bodies).toHaveLength(1); + expect(result.used).toBe(3); + expect(result.workflowSends).toBe(1); + }); + + test.each([false, true])("a prepaid repair's last slot survives reset-only accounting (base exhausted %s)", async lastSlot => { + const fastRefusal = () => Response.json({ type: "error", error: { type: "rate_limit_error", + message: "Usage credits are required for fast mode." } }, { status: 429 }); + const success = () => Response.json({ id: "msg-ok", type: "message", role: "assistant", model: "claude-opus-5-5", + content: [{ type: "text", text: "ok" }], stop_reason: "end_turn", usage: { input_tokens: 1, output_tokens: 1 } }); + const result = await probe({ providerName: "anthropic-apikey", used: lastSlot ? 2 : 0, provider: { adapter: "anthropic", + baseUrl: "https://api.anthropic.com", models: ["claude-opus-5-5"], fastEnabled: true }, + config: { fastMode: true }, body: { model: "anthropic-apikey/claude-opus-5-5" }, + answer: ordinal => ordinal === 1 ? fastRefusal() : success() }); + expect(result.status, result.text).toBe(200); + expect(result.bodies).toHaveLength(2); + expect(JSON.parse(result.bodies[0]!).speed).toBe("fast"); + expect(JSON.parse(result.bodies[1]!).speed).toBeUndefined(); + expect(result.used).toBe(lastSlot ? 4 : 2); + expect(result.reserveSpent).toBe(lastSlot); + expect(result.workflowSends).toBe(2); + }); + + test("a compact-only prepaid slot without retry policies cannot replace a reset", async () => { + const result = await probe({ prepaid: true, provider: { retryOnReset: undefined } }); + expect(result.status).toBe(429); + expect(result.text).toContain("upstream_reset_replay_refused"); + expect(result.bodies).toHaveLength(1); + expect(result.used).toBe(1); + expect(result.workflowSends).toBe(1); + expect(result.grantSpent).toBe(false); + }); + + test("direct Google compact-only recovery keeps one send without either policy", async () => { + const result = await probe({ prepaid: true, provider: { adapter: "google", retryOnReset: undefined }, + answer: () => Response.json({ error: { message: "temporary unavailable" } }, { status: 503 }) }); + expect(result.status).toBe(503); + expect(result.bodies).toHaveLength(1); + expect(result.used).toBe(1); + expect(result.workflowSends).toBe(1); + }); + + test.each([false, true])("a prepaid OAuth hop is charged once even with an inapplicable transient policy (%s)", async exact => { + const result = await probe({ oauth: true, providerName: "xai", provider: { adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", authMode: "oauth", models: ["test"], + ...(exact ? { transientRetryOn5xx: { attempts: 2 } } : {}) }, + body: { model: "xai/test" }, answer: ordinal => ordinal === 1 + ? Response.json({ error: { message: "quota exhausted" } }, { status: 429 }) : chatSuccess() }); + expect(result.status, result.text).toBe(200); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.workflowSends).toBe(2); + expect(result.authorizations[0]).not.toBe(result.authorizations[1]); + expect(result.reserveSpent).toBe(false); + }); + + test.each([false, true])("translated post-header stream reset never replays (output %s)", async output => { + const result = await probe({ body: { stream: true }, answer: () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ id: "chat-stream", choices: [ + { index: 0, delta: output ? { role: "assistant", content: "visible" } : { role: "assistant" }, finish_reason: null }, + ] })}\n\n`)); + }, + pull(controller) { controller.error(Object.assign(new Error("post-header reset"), { code: "ECONNRESET" })); }, + }), { headers: { "content-type": "text/event-stream" } }) }); + expect(result.status).toBe(200); + expect(result.text).toContain("response.failed"); + if (output) expect(result.text).toContain("visible"); + expect(result.bodies).toHaveLength(1); + expect(result.grantSpent).toBe(false); + }); + + test("a real upstream consumes the complete body then disconnects before headers", async () => { + const received: string[] = []; + let signalSecondBody!: () => void; + const secondBody = new Promise(resolve => { signalSecondBody = resolve; }); + const sockets = new Set(); + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", chunk => chunks.push(Buffer.from(chunk))); + request.on("end", () => { + received.push(Buffer.concat(chunks).toString()); + if (received.length === 1) request.socket.destroy(); + else { + signalSecondBody(); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ id: "chat-loopback", choices: [{ index: 0, + message: { role: "assistant", content: "ok" }, finish_reason: "stop" }] })); + } + }); + }); + server.on("connection", socket => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); }); + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no loopback port"); + const result = await probe({ provider: { baseUrl: `http://127.0.0.1:${address.port}/v1`, allowPrivateNetwork: true }, executor: globalThis.fetch }); + expect(result.status, result.text).toBe(200); + await secondBody; + expect(received).toHaveLength(2); + expect(received[0]).toBe(received[1]); + expect(JSON.parse(received[0]!).messages).toEqual([{ role: "user", content: "hello" }]); + expect(result.used).toBe(2); + expect(result.workflowSends).toBe(2); + expect(result.attemptSends).toBe(2); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } + }); +});