diff --git a/docs-site/src/content/docs/fr/reference/adapters.md b/docs-site/src/content/docs/fr/reference/adapters.md index 3a89e76b55f..71ccc61cb1e 100644 --- a/docs-site/src/content/docs/fr/reference/adapters.md +++ b/docs-site/src/content/docs/fr/reference/adapters.md @@ -95,6 +95,7 @@ Avec l’authentification `key`, [`retryOn429`](/fr/reference/configuration/) s - Convertit les messages en blocs de contenu Anthropic (texte, image base64, `tool_use`, `thinking`). - **Calcul du raisonnement étendu :** Anthropic exige `max_tokens > thinking.budget_tokens`. L’adaptateur associe l’effort de raisonnement à un budget (minimal 1024 … max 32000), calcule ensuite une valeur sûre de `max_tokens` avec une marge pour la sortie et **supprime `temperature`/`top_p`** lorsque le raisonnement est activé, car Anthropic les interdit dans ce cas. +- **Affichage du raisonnement adaptatif :** les modèles à raisonnement adaptatif (Opus 4.7+, Sonnet 5, Fable) reçoivent `thinking.display: "summarized"`, de sorte qu’une longue réflexion arrive aux clients Chat et Responses sous forme de deltas de raisonnement au lieu de minutes de heartbeats. Une requête qui masque le résumé (`reasoning.summary: "none"`) conserve la valeur par défaut du fournisseur. - **Sortie structurée :** les requêtes Responses `text.format` et Chat Completions `response_format` dont le type est `type: "json_schema"` deviennent `output_config.format` dans Anthropic. Le format est fusionné avec une configuration de sortie de raisonnement adaptatif existante, tout en préservant un `output_config.effort` compatible. Les requêtes Anthropic Messages routées conservent ce même format lors de la traduction OAuth stockée. L’adaptateur reproduit le sous-ensemble de JSON Schema pris en charge par le SDK TypeScript d’Anthropic : les contraintes non prises en charge sont déplacées dans `description` à titre d’instructions pour le modèle, `oneOf` devient `anyOf` et les schémas d’objet reçoivent `additionalProperties: false`. Une racine `$ref` conserve le `$defs` adjacent afin que la référence locale reste résoluble. Les champs d’enveloppe OpenAI tels que le `name` du schéma, la `description` de l’enveloppe et `strict` ne font pas partie du protocole Anthropic. Le mode objet JSON sans schéma n’a pas d’équivalent Anthropic et n’est pas traduit. - Envoie toujours `anthropic-version: 2023-06-01`. Diffuse `content_block_delta` (`text_delta`, `thinking_delta`, le compatible `reasoning_delta`, `input_json_delta`). Le décodeur SSE conserve l’état des événements d’un fragment reçu à l’autre et accepte un événement terminal `message_stop` sans saut de ligne final. - Pour les tours Responses routés vers Anthropic avec des outils clients, une garde terminale bornée détecte le cas hautement probable où l’utilisateur a demandé une action, mais où Claude termine en affirmant l’avoir exécutée sans appeler d’outil. Elle effectue au plus une continuation interne ; les réponses normales, les demandes de précision, les tours qui utilisent un outil et les réponses incomplètes au niveau du transport ne sont pas relancés automatiquement. diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index cb1e67181ee..b817283250d 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -111,6 +111,7 @@ HTTP リトライ ループの対象外です。 - メッセージを Anthropic content block(text、base64 image、`tool_use`、`thinking`)に変換します。 - **Extended thinking の計算:** Anthropic は `max_tokens > thinking.budget_tokens` を要求します。 アダプターは reasoning effort を budget にマッピングし(minimal 1024 … max 32000)、出力余裕を取った安全な `max_tokens` を計算します。thinking がオンのときは Anthropic が禁止する **`temperature`/`top_p` を削除**します。 +- **adaptive thinking の表示:** adaptive thinking モデル(Opus 4.7+、Sonnet 5、Fable)には `thinking.display: "summarized"` を送るため、長い思考は数分間の heartbeat ではなく reasoning delta として Chat と Responses のクライアントに届きます。reasoning の要約を隠すリクエスト(`reasoning.summary: "none"`)はプロバイダーの既定値を維持します。 - 常に `anthropic-version: 2023-06-01` を送ります。`content_block_delta`(`text_delta`、 `thinking_delta`、`input_json_delta`)をストリーミングします。 diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 533280d8d5f..546a0dea6eb 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -120,6 +120,7 @@ Ollama 공급자에 `adapter: "ollama-native"`로 설정할 수도 있습니다. 어댑터는 reasoning effort를 budget으로 매핑하고(minimal 1024 … max 32000), 출력 여유를 둔 안전한 `max_tokens`를 계산합니다. thinking이 켜지면 Anthropic에서 금지한 **`temperature`/`top_p`를 제거**합니다. +- **적응형 thinking 표시:** 적응형 thinking 모델(Opus 4.7+, Sonnet 5, Fable)에는 `thinking.display: "summarized"`를 보내므로, 긴 사고가 몇 분 동안의 heartbeat 대신 reasoning delta로 Chat과 Responses 클라이언트에 전달됩니다. reasoning 요약을 숨기는 요청(`reasoning.summary: "none"`)은 공급자 기본값을 그대로 씁니다. - 항상 `anthropic-version: 2023-06-01`을 보냅니다. `content_block_delta`(`text_delta`, `thinking_delta`, `input_json_delta`)를 스트리밍합니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 3ce14054e60..f00d96edc08 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -242,6 +242,10 @@ MiMo model Command Code serves. maps reasoning effort to a budget (minimal 1024 … max 32000), then computes a safe `max_tokens` with output headroom, and **drops `temperature`/`top_p`** when thinking is enabled (Anthropic forbids them there). +- **Adaptive thinking display:** adaptive-thinking models (Opus 4.7+, Sonnet 5, Fable) are asked + for `thinking.display: "summarized"`, so a long think reaches Chat and Responses clients as + reasoning deltas instead of minutes of heartbeats. A request that hides the reasoning summary + (`reasoning.summary: "none"`) keeps the provider default. - **Structured output:** Responses `text.format` and Chat Completions `response_format` requests with `type: "json_schema"` become Anthropic `output_config.format`. The format merges into an existing adaptive-thinking output configuration, preserving a compatible `output_config.effort`. diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index c8b95aecbcb..3bad254262f 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -135,6 +135,7 @@ interface ProviderAdapter { Адаптер отображает уровень рассуждений в бюджет (minimal 1024 … max 32000), затем вычисляет безопасный `max_tokens` с запасом на вывод и **удаляет `temperature`/`top_p`**, когда thinking включён (Anthropic запрещает их в этом режиме). +- **Показ адаптивного thinking:** модели с адаптивным thinking (Opus 4.7+, Sonnet 5, Fable) получают `thinking.display: "summarized"`, поэтому долгое размышление приходит клиентам Chat и Responses как reasoning-дельты, а не как минуты heartbeat. Запрос, скрывающий сводку рассуждений (`reasoning.summary: "none"`), сохраняет значение провайдера по умолчанию. - Всегда отправляет `anthropic-version: 2023-06-01`. Стримит `content_block_delta` (`text_delta`, `thinking_delta`, `input_json_delta`). diff --git a/docs-site/src/content/docs/tr/reference/adapters.md b/docs-site/src/content/docs/tr/reference/adapters.md index e61985a720c..b17bb178f39 100644 --- a/docs-site/src/content/docs/tr/reference/adapters.md +++ b/docs-site/src/content/docs/tr/reference/adapters.md @@ -141,6 +141,7 @@ Pro/Max için Bearer + `anthropic-beta`). (en az 1024 … en çok 32000) eşler, ardından çıktı payı ile güvenli bir `max_tokens` hesaplar ve düşünme etkinleştirildiğinde **`temperature`/`top_p`'yi bırakır** (Anthropic orada bunları yasaklar). +- **Uyarlanabilir thinking gösterimi:** uyarlanabilir thinking modellerine (Opus 4.7+, Sonnet 5, Fable) `thinking.display: "summarized"` gönderilir; böylece uzun bir düşünme, Chat ve Responses istemcilerine dakikalarca heartbeat yerine reasoning deltaları olarak ulaşır. Reasoning özetini gizleyen bir istek (`reasoning.summary: "none"`) sağlayıcının varsayılanını korur. - **Yapılandırılmış çıktı:** `type: "json_schema"` içeren Responses `text.format` ve Chat Completions `response_format` istekleri Anthropic `output_config.format` haline gelir. Format, uyumlu bir diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index c5f57a743c4..a9963dcdc3b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -109,6 +109,7 @@ interface ProviderAdapter { - **Extended thinking 计算:** Anthropic 要求 `max_tokens > thinking.budget_tokens`。adapter 把 reasoning effort 映射成 budget(minimal 1024 … max 32000),再计算留有输出余量的安全 `max_tokens`;启用 thinking 后会**移除 `temperature`/`top_p`**,因为 Anthropic 禁止此组合。 +- **自适应 thinking 显示:** 自适应 thinking 模型(Opus 4.7+、Sonnet 5、Fable)会收到 `thinking.display: "summarized"`,因此长时间思考会以 reasoning 增量送达 Chat 和 Responses 客户端,而不是几分钟的 heartbeat。隐藏推理摘要的请求(`reasoning.summary: "none"`)保持提供方默认值。 - 始终发送 `anthropic-version: 2023-06-01`。流式输出 `content_block_delta`(`text_delta`、`thinking_delta`、`input_json_delta`)。 diff --git a/docs-site/src/content/docs/zh-tw/reference/adapters.md b/docs-site/src/content/docs/zh-tw/reference/adapters.md index 896d4c3b192..37ac0080cd5 100644 --- a/docs-site/src/content/docs/zh-tw/reference/adapters.md +++ b/docs-site/src/content/docs/zh-tw/reference/adapters.md @@ -99,6 +99,7 @@ interface ProviderAdapter { - **Extended thinking 計算:** Anthropic 要求 `max_tokens > thinking.budget_tokens`。adapter 把 reasoning effort 對映成 budget(minimal 1024 … max 32000),再計算留有輸出餘量的安全 `max_tokens`;啟用 thinking 後會**移除 `temperature`/`top_p`**,因為 Anthropic 禁止此組合。 +- **自適應 thinking 顯示:** 自適應 thinking 模型(Opus 4.7+、Sonnet 5、Fable)會收到 `thinking.display: "summarized"`,因此長時間思考會以 reasoning 增量送達 Chat 與 Responses 用戶端,而不是數分鐘的 heartbeat。隱藏推理摘要的請求(`reasoning.summary: "none"`)維持供應商預設值。 - 始終傳送 `anthropic-version: 2023-06-01`。流式輸出 `content_block_delta`(`text_delta`、`thinking_delta`、`input_json_delta`)。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2f3304b4c7b..c524eecc1a2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1782,6 +1782,7 @@ "update-desktop-badge.test.ts": "update", "update-desktop-owner.test.ts": "update", "update-job.test.ts": "update", + "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 10284c6fc64..cd83e5a0b21 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1099,7 +1099,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // `thinking.type: "enabled"` outright. `max_tokens` still caps thinking plus visible // output, so high effort needs the same total-token headroom as budget thinking or a // default 8192-token request can spend everything on thought and return empty text. - body.thinking = { type: "adaptive" }; + // Opus 4.7+ defaults `display` to "omitted": the stream then carries signature-only + // thinking blocks, so a Chat client sees minutes of heartbeats and no reasoning delta + // during a long think (#5824). Ask for summarized thinking unless the caller hides it. + body.thinking = parsed.options.hideThinkingSummary + ? { type: "adaptive" } + : { type: "adaptive", display: "summarized" }; const effort = adaptiveEffort(effectiveReasoning); body.output_config = { effort }; const explicitMaxOut = parsed.options.maxOutputTokens; diff --git a/src/update/job.ts b/src/update/job.ts index 565a593400e..bba2483f093 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -60,6 +60,7 @@ import { runNpmCachePreflight, type NpmCachePreflightReason, } from "./npm-cache-preflight.mjs"; +import { guiUpdateWorkerCommand } from "./worker-launch"; const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; const UPDATE_JOB_FILENAME = "update-job.json"; @@ -574,7 +575,8 @@ export function spawnGuiUpdateWorker( restart ? "restart" : "no-restart", ]); if (process.platform !== "win32") { - return spawn(process.execPath, args, { + const launch = guiUpdateWorkerCommand(process.execPath, args); + return spawn(launch.command, launch.argv, { detached: true, stdio: "ignore", windowsHide: true, diff --git a/src/update/worker-launch.ts b/src/update/worker-launch.ts new file mode 100644 index 00000000000..89811193e1f --- /dev/null +++ b/src/update/worker-launch.ts @@ -0,0 +1,46 @@ +import { spawnSync } from "node:child_process"; + +/** + * How to launch the dashboard update worker on POSIX. + * + * A worker spawned from the systemd user service stays in that service's cgroup even when + * detached, and the generated unit keeps the default `KillMode=control-group`. The updater then + * stops `opencodex-proxy.service` and systemd kills the worker with it, leaving the proxy offline + * on the old package (#5750). `systemd-run --user --scope` moves the worker into its own + * transient scope first; `--scope` execs the command in place, so the returned PID is still the + * worker's. Outside a systemd-started process (`INVOCATION_ID` unset), or when `systemd-run` is + * missing, the plain detached spawn is unchanged. + */ +export const SYSTEMD_SCOPE_ARGS = ["--user", "--scope", "--quiet", "--collect", "--"] as const; + +export interface WorkerLaunchContext { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + hasSystemdRun?: () => boolean; +} + +let systemdRunProbe: boolean | undefined; + +function probeSystemdRun(): boolean { + if (systemdRunProbe === undefined) { + // Run a real no-op scope rather than `--version`: a present binary without a reachable user + // bus would otherwise pass the probe and then fail to start the worker at all. + const probe = spawnSync("systemd-run", [...SYSTEMD_SCOPE_ARGS, "true"], { stdio: "ignore", timeout: 5_000 }); + systemdRunProbe = !probe.error && probe.status === 0; + } + return systemdRunProbe; +} + +export function guiUpdateWorkerCommand( + execPath: string, + args: readonly string[], + context: WorkerLaunchContext = {}, +): { command: string; argv: string[] } { + const platform = context.platform ?? process.platform; + const env = context.env ?? process.env; + const underSystemd = platform === "linux" && Boolean(env.INVOCATION_ID); + if (underSystemd && (context.hasSystemdRun ?? probeSystemdRun)()) { + return { command: "systemd-run", argv: [...SYSTEMD_SCOPE_ARGS, execPath, ...args] }; + } + return { command: execPath, argv: [...args] }; +} diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 8b5986622fe..389e1d690cb 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -275,3 +275,5 @@ src/update/refresh-scheduler.ts owns the package cache timer and per-channel sin src/update/async-check.ts uses the existing owner-bound registry target with a bounded asynchronous child; pnpm owner discovery runs in src/update/pnpm-owner-worker.ts off the request loop. `src/update/notify.ts` writes successful results atomically and preserves a dismissal only for the same channel and version. The interactive pre-bind prompt reads the cache and does not launch a second detached refresh. `src/update/badge.ts` only reads the cache and reports unknown at 40 hours. The desktop badge snapshot in src/update/desktop-badge.ts is process-local display state keyed by a Tauri session id. A 60-second shell heartbeat renews receipt time; entries expire after 180 seconds and the store retains at most 32 sessions. It is separate from the package version cache and from the updater job/ownership transaction. A proxy restart reports unknown until a bound desktop shell republishes; no update installation can be authorized by this snapshot. + +On Linux, a dashboard update worker started from the systemd user service is launched through `systemd-run --user --scope --quiet --collect` (`src/update/worker-launch.ts`), so it leaves the service cgroup before the updater stops `opencodex-proxy.service`; the default `KillMode=control-group` otherwise kills it with the proxy (#5750). The path applies only when `INVOCATION_ID` is set and a no-op scope probe succeeds; every other case keeps the plain detached spawn. `--scope` moves `systemd-run` itself into the scope and then execs the worker, so the recorded PID is the worker's (`tests/update/update-worker-launch.test.ts`). diff --git a/tests/adapters/anthropic/anthropic-reasoning.test.ts b/tests/adapters/anthropic/anthropic-reasoning.test.ts index a38d56cf0a9..8e69dbe5aad 100644 --- a/tests/adapters/anthropic/anthropic-reasoning.test.ts +++ b/tests/adapters/anthropic/anthropic-reasoning.test.ts @@ -98,13 +98,17 @@ describe("anthropic extended-thinking gate", () => { "claude-opus-4-8.1", ])("adaptive-thinking model %s sends thinking.adaptive + output_config.effort", async (modelId) => { const b = await bodyOf(parsed("xhigh", { temperature: 0.3, topP: 0.9 }, modelId)); - expect(b.thinking).toEqual({ type: "adaptive" }); + expect(b.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(b.output_config).toEqual({ effort: "xhigh" }); expect(b.temperature).toBeUndefined(); expect(b.top_p).toBeUndefined(); }); test("adaptive-thinking model maps unsupported 'minimal' effort to 'low'", async () => { + // #5824: summarized thinking keeps a long think visible as reasoning deltas; a caller that + // hides the summary keeps the provider default instead. + const hidden = await bodyOf(parsed("high", { hideThinkingSummary: true }, "claude-opus-4-8")); + expect(hidden.thinking).toEqual({ type: "adaptive" }); const b = await bodyOf(parsed("minimal", {}, "claude-fable-5")); expect(b.output_config).toEqual({ effort: "low" }); expect(b.max_tokens).toBe(12_288); @@ -259,7 +263,7 @@ describe("anthropic extended-thinking gate", () => { // Exact regression: effort=max budget is 32000; adaptive ceiling adds OUTPUT_HEADROOM (8192) // so max_tokens = 40192, genuinely above the reasoning budget at full effort. expect(b.max_tokens as number).toBe(40_192); - expect(b.thinking).toEqual({ type: "adaptive" }); + expect(b.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(b.output_config).toEqual({ effort: "max" }); }); @@ -354,7 +358,7 @@ describe("anthropic extended-thinking gate", () => { "claude-opus-4-8/vendor-suffix", ])("adaptive-thinking model %s keeps the adaptive wire shape", async (modelId) => { const b = await bodyOf(parsed("high", {}, modelId)); - expect(b.thinking).toEqual({ type: "adaptive" }); + expect(b.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(b.output_config).toEqual({ effort: "high" }); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 18fe4f35168..3af4e6eda8a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1608,6 +1608,7 @@ "update-desktop-badge.test.ts": "update", "update-desktop-owner.test.ts": "update", "update-job.test.ts": "update", + "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", diff --git a/tests/update/update-worker-launch.test.ts b/tests/update/update-worker-launch.test.ts new file mode 100644 index 00000000000..4132df54912 --- /dev/null +++ b/tests/update/update-worker-launch.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { guiUpdateWorkerCommand, SYSTEMD_SCOPE_ARGS } from "../../src/update/worker-launch"; + +// #5750: a worker spawned by the systemd user service must leave the service cgroup before the +// updater stops that service, or systemd kills it along with the proxy. +describe("dashboard update worker launch", () => { + const args = ["/opt/ocx/src/cli/index.ts", "__gui-update-worker", "job-1", "stable", "restart"]; + + test("a systemd-started Linux proxy launches the worker in its own scope", () => { + const launch = guiUpdateWorkerCommand("/usr/bin/bun", args, { + platform: "linux", env: { INVOCATION_ID: "abc" }, hasSystemdRun: () => true, + }); + expect(launch).toEqual({ command: "systemd-run", argv: [...SYSTEMD_SCOPE_ARGS, "/usr/bin/bun", ...args] }); + }); + + test("without systemd-run, outside systemd, or off Linux the spawn is unchanged", () => { + const plain = { command: "/usr/bin/bun", argv: args }; + expect(guiUpdateWorkerCommand("/usr/bin/bun", args, { + platform: "linux", env: { INVOCATION_ID: "abc" }, hasSystemdRun: () => false, + })).toEqual(plain); + let probed = false; + expect(guiUpdateWorkerCommand("/usr/bin/bun", args, { + platform: "linux", env: {}, hasSystemdRun: () => { probed = true; return true; }, + })).toEqual(plain); + expect(probed).toBe(false); + expect(guiUpdateWorkerCommand("/usr/bin/bun", args, { + platform: "darwin", env: { INVOCATION_ID: "abc" }, hasSystemdRun: () => true, + })).toEqual(plain); + }); +});