diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index d0e487a47f..ab533d2110 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -150,10 +150,19 @@ A model is marked text-only per provider: ## Dashboard controls and disabling - - -The config-file keys are available now. Set `enabled: false` on either sidecar in `config.json` to -disable it. Anthropic-OAuth search and image description reuse the existing Claude Code OAuth -fingerprint precedent, but should be soak-tested with the intended account and workload. +The Dashboard Vision sidecar card can enable or disable the sidecar, set +`maxDescriptionsPerTurn`, and set `timeoutMs`, along with the existing model, +backend, and reasoning controls. Disabling the sidecar does not delete those +settings; turning it back on keeps the previous model, backend, reasoning, +timeout, and limit. + +`PUT /api/sidecar-settings` accepts the same fields. Partial updates leave +omitted keys unchanged. `timeoutMs` uses the runtime integer bounds +(1–2147483647 ms). + +You can still set `enabled: false` in `config.json` if you prefer to edit the +file directly. Anthropic-OAuth search and image description reuse the existing +Claude Code OAuth fingerprint precedent, but should be soak-tested with the +intended account and workload. See the [Configuration reference](/reference/configuration/#sidecars) for every field. diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index 376bdabaa5..b30a8ea35e 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -128,9 +128,9 @@ OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.4-mini` を ## ダッシュボード設定とオフ - +ダッシュボードのビジョンサイドカーカードでは、既存のモデル・バックエンド・推論コントロールに加えて、サイドカーのオン/オフ、`maxDescriptionsPerTurn`、`timeoutMs` を設定できます。オフにしても他の設定は削除されず、再びオンにすると以前のモデル、バックエンド、推論、タイムアウト、上限が残ります。 -設定ファイルキーは今すぐ使えます。機能をオフにするには `config.json` で該当サイドカーの -`enabled` を `false` に設定してください。Anthropic OAuth 検索と画像説明は既存の Claude Code OAuth -fingerprint 先例に従いますが、実際のアカウントと作業量で十分 soak test するのが無難です。全 +`PUT /api/sidecar-settings` は同じフィールドを受け付けます。部分更新では省略したキーをそのまま残します。`timeoutMs` はランタイムの整数範囲(1–2147483647 ms)を使います。 + +ファイルを直接編集したい場合は、これまでどおり `config.json` で `enabled` を `false` にできます。Anthropic OAuth 検索と画像説明は既存の Claude Code OAuth fingerprint 先例に従いますが、実際のアカウントと作業量で十分 soak test するのが無難です。全 フィールドは[設定リファレンス](/ja/reference/configuration/#sidecars)を参照してください。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index f950a77859..9760a04017 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -152,7 +152,7 @@ OpenAI バックエンドには、ChatGPT ログインと有効な ChatGPT `forw | `model?` | `string` |バックエンド依存 | OpenAI の場合は `gpt-5.4-mini`、Anthropic の場合は `claude-sonnet-5`。 | | `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | OpenAI Responses の推論負荷。Anthropic は無視します。 | | `maxDescriptionsPerTurn?` | `number` | `8` |新しい説明のキャッシュミスはメインターンごとに許可されます。 `0` は通話を無効にします。無効な値にはデフォルトが使用されます。 | -| `timeoutMs?` | `number` | `45000` |サイドカーのフェッチタイムアウト。 | +| `timeoutMs?` | `number` | `45000` |サイドカーのフェッチタイムアウト。整数 1–2147483647。 | 対応するレベルは、上流プロバイダーの能力と選択したモデルが公表する推論ラダーによって制限されます。 Vision は、プロバイダーの `noVisionModels` のモデルに送信された画像に対してのみアクティブになります。 OpenAI には、検索と同じログイン/転送要件があります。明示的に選択された Anthropic は、使用可能な認証情報がないと失敗します。成功した `data:` 記述では、バックエンド、モデル、詳細、画像バイト、および正規化されたメッセージ コンテキストをキーとした境界付きキャッシュが使用されます。OpenAI のキーには推論負荷も含まれます(Anthropic のキーには含まれません)。ヒットと同じターンの重複は制限を消費しません。リモート `https:` イメージと失敗した説明、または空の説明はキャッシュされません。 diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index d22e8c66e1..df1fdb841f 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -129,9 +129,10 @@ OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.4-mini`를 폴백으로 ## 대시보드 설정과 끄기 - +대시보드 비전 사이드카 카드에서는 기존 모델·백엔드·추론 컨트롤과 함께 사이드카를 켜거나 끄고, `maxDescriptionsPerTurn`과 `timeoutMs`를 설정할 수 있습니다. 꺼도 다른 설정은 삭제되지 않으며, 다시 켜면 이전 모델, 백엔드, 추론, 제한 시간, 한도가 그대로 남습니다. -설정 파일 키는 지금 바로 사용할 수 있습니다. 기능을 끄려면 `config.json`에서 해당 사이드카의 -`enabled`를 `false`로 설정하세요. Anthropic OAuth 검색과 이미지 설명은 기존 Claude Code OAuth +`PUT /api/sidecar-settings`는 같은 필드를 받습니다. 부분 업데이트는 보내지 않은 키를 유지합니다. `timeoutMs`는 런타임 정수 범위(1–2147483647 ms)를 사용합니다. + +파일을 직접 고치고 싶다면 이전처럼 `config.json`에서 `enabled`를 `false`로 두면 됩니다. Anthropic OAuth 검색과 이미지 설명은 기존 Claude Code OAuth fingerprint 선례를 따르지만, 실제 계정과 작업량으로 충분히 soak test하는 편이 좋습니다. 전체 필드는 [설정 레퍼런스](/ko/reference/configuration/#sidecars)를 참고하세요. diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 984ff165bc..ad12aead09 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -152,7 +152,7 @@ OpenAI 백엔드는 ChatGPT 로그인과 활성화된 ChatGPT `forward` provider | `model?` | `string` | backend-dependent | OpenAI는 `gpt-5.4-mini`, Anthropic은 `claude-sonnet-5`입니다. | | `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | OpenAI Responses 추론 강도입니다. Anthropic은 무시합니다. | | `maxDescriptionsPerTurn?` | `number` | `8` | 메인 턴당 허용되는 새 설명 캐시 미스 수입니다. `0`이면 호출이 비활성화되며, 잘못된 값은 기본값을 사용합니다. | -| `timeoutMs?` | `number` | `45000` | 사이드카 fetch 제한 시간입니다. | +| `timeoutMs?` | `number` | `45000` | 사이드카 fetch 제한 시간입니다. 정수 1–2147483647. | 지원되는 수준은 업스트림 제공자의 역량과 선택한 모델이 공개한 추론 사다리에 따라 제한됩니다. Vision은 provider의 `noVisionModels`에 속한 모델로 보낸 이미지에만 활성화됩니다. OpenAI는 검색과 같은 로그인/forward 요건을 갖고 있으며, 명시적으로 선택한 Anthropic은 사용할 수 있는 자격 증명이 없으면 닫힌 상태로 실패합니다. 성공한 `data:` 설명은 backend, model, detail, image bytes, 그리고 정규화된 메시지 컨텍스트를 키로 하는 bounded cache를 사용합니다. OpenAI 키에는 reasoning effort도 포함됩니다(Anthropic 키에는 없습니다). 히트와 같은 턴의 중복은 한도를 소모하지 않습니다. 원격 `https:` 이미지와 실패했거나 비어 있는 설명은 캐시하지 않습니다. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 783c48b02f..88fd05fce4 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -227,7 +227,7 @@ an inactivity guard, not a total generation deadline. | `backend?` | `"openai" \| "anthropic"` | auto | Same explicit-first, Anthropic-credential-aware selection as web search. | | `model?` | `string` | backend-dependent | `gpt-5.4-mini` for OpenAI or `claude-sonnet-5` for Anthropic. | | `maxDescriptionsPerTurn?` | `number` | `8` | New description cache misses admitted per main turn. `0` disables calls; invalid values use default. | -| `timeoutMs?` | `number` | `45000` | Sidecar fetch timeout. | +| `timeoutMs?` | `number` | `45000` | Sidecar fetch timeout. Integer 1–2147483647. | Vision activates only for images sent to a model in its provider's `noVisionModels`. OpenAI has the same login/forward requirements as search; explicitly selected Anthropic fails closed without a usable diff --git a/docs-site/src/content/docs/ru/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index 717707f0c5..ae2e2479b2 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -141,10 +141,15 @@ SSE-событие `response.failed`. ## Управление из дашборда и отключение - +Карточка Vision sidecar на дашборде позволяет включать и выключать сайдкар, задавать +`maxDescriptionsPerTurn` и `timeoutMs`, не убирая уже существующие элементы модели, бэкенда и +рассуждения. Выключение не удаляет эти настройки; повторное включение сохраняет прежние +значения модели, бэкенда, reasoning, таймаута и лимита. -Ключи в файле конфигурации доступны уже сейчас. Чтобы отключить любой из сайдкаров, установите ему -`enabled: false` в `config.json`. Поиск и описание изображений через Anthropic OAuth переиспользуют +`PUT /api/sidecar-settings` принимает те же поля. Частичное обновление оставляет непереданные ключи +без изменений. `timeoutMs` использует целочисленные границы рантайма (1–2147483647 мс). + +Если удобнее править файл, по-прежнему можно поставить `enabled: false` в `config.json`. Поиск и описание изображений через Anthropic OAuth переиспользуют существующий прецедент OAuth-отпечатка Claude Code, но их стоит обкатать с целевым аккаунтом и нагрузкой. diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 4ac4eaf94b..b799c56549 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -187,7 +187,7 @@ routed-model и hosted-search timeout. Эффективный watchdog мост | `model?` | `string` | backend-dependent | `gpt-5.4-mini` для OpenAI или `claude-sonnet-5` для Anthropic. | | `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | Уровень рассуждений OpenAI Responses. Anthropic его игнорирует. | | `maxDescriptionsPerTurn?` | `number` | `8` | Максимум новых промахов description-cache за один main turn. `0` отключает вызовы; некорректные значения возвращают дефолт. | -| `timeoutMs?` | `number` | `45000` | Таймаут запроса sidecar'а. | +| `timeoutMs?` | `number` | `45000` | Таймаут запроса sidecar'а. Целое число 1–2147483647. | Поддерживаемые уровни зависят от возможностей вышестоящего провайдера и заявленной лестницы рассуждений выбранной модели. Vision включается только для изображений, отправленных в модель, входящую в `noVisionModels` её diff --git a/docs-site/src/content/docs/zh-cn/guides/sidecars.md b/docs-site/src/content/docs/zh-cn/guides/sidecars.md index 136d27d2d5..b0da7443b7 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -118,9 +118,10 @@ Dashboard 和管理 API 都使用 `gpt-5.4-mini` 作为回退。启动时仍会 ## 仪表盘设置与禁用 - +仪表盘的视觉附属服务卡片可以启用或停用 sidecar,并设置 `maxDescriptionsPerTurn` 和 +`timeoutMs`,同时保留已有的模型、后端和推理强度控件。停用不会删除这些设置;重新启用后仍会保留原来的模型、后端、推理强度、超时和次数上限。 -配置文件字段现在即可使用。如需禁用某个 sidecar,请在 `config.json` 中把对应的 `enabled` 设为 -`false`。Anthropic OAuth 搜索和图像描述沿用现有 Claude Code OAuth fingerprint 先例,但仍应使用 -目标账户和实际负载充分 soak test。所有字段见 +`PUT /api/sidecar-settings` 接受相同字段。部分更新会保留未提交的键。`timeoutMs` 使用运行时整数边界(1–2147483647 毫秒)。 + +如果更想直接改文件,仍可在 `config.json` 中把 `enabled` 设为 `false`。Anthropic OAuth 搜索和图像描述沿用现有 Claude Code OAuth fingerprint 先例,但仍应使用目标账户和实际负载充分 soak test。所有字段见 [配置参考](/zh-cn/reference/configuration/#sidecars)。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 6375d7ef35..ecc3ce1bf4 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -166,7 +166,7 @@ routed 重放会把主 ChatGPT 认证注入内部请求。Anthropic 后端使用 | `model?` | `string` | 依后端而定 | OpenAI 使用 `gpt-5.4-mini`,Anthropic 使用 `claude-sonnet-5`。 | | `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | OpenAI Responses 推理强度;Anthropic 会忽略该项。 | | `maxDescriptionsPerTurn?` | `number` | `8` | 每个主轮次允许的新增描述缓存未命中次数。`0` 会禁用调用;无效值会使用默认值。 | -| `timeoutMs?` | `number` | `45000` | 侧车获取超时。 | +| `timeoutMs?` | `number` | `45000` | 侧车获取超时。整数 1–2147483647。 | 支持的等级受上游提供方能力与所选模型公布的推理阶梯限制。Vision 只会对发送给其提供方 `noVisionModels` 中模型的图像生效。OpenAI 具有与 search 相同的登录/forward 要求;显式选择的 Anthropic 在没有可用凭据时会失败并关闭。成功的 `data:` 描述会使用一个受限缓存,其键由后端、模型、detail、图像字节以及规范化消息上下文组成;OpenAI 的键还会额外包含推理强度(Anthropic 键不含)。命中和同轮重复不会消耗限额。远程 `https:` 图像以及失败或空的描述不会被缓存。 diff --git a/docs-site/src/content/docs/zh-tw/guides/sidecars.md b/docs-site/src/content/docs/zh-tw/guides/sidecars.md index e6e6b46042..3db6485291 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-tw/guides/sidecars.md @@ -113,9 +113,10 @@ Anthropic OAuth provider。Sidecar 錯誤會轉換成長度受限的工具結果 ## 儀表板設定與停用 - +儀表板的視覺附屬服務卡片可以啟用或停用 sidecar,並設定 `maxDescriptionsPerTurn` 和 +`timeoutMs`,同時保留既有的模型、後端和推理強度控制。停用不會刪除這些設定;重新啟用後仍會保留原來的模型、後端、推理強度、逾時和次數上限。 -設定檔欄位現在即可使用。如需停用某個 sidecar,請在 `config.json` 中把對應的 `enabled` 設為 -`false`。Anthropic OAuth 搜尋和圖像描述沿用現有 Claude Code OAuth fingerprint 先例,但仍應使用 -目標帳號和實際負載充分 soak test。所有欄位見 +`PUT /api/sidecar-settings` 接受相同欄位。部分更新會保留未提交的鍵。`timeoutMs` 使用執行時整數邊界(1–2147483647 毫秒)。 + +如果更想直接改檔案,仍可在 `config.json` 中把 `enabled` 設為 `false`。Anthropic OAuth 搜尋和圖像描述沿用現有 Claude Code OAuth fingerprint 先例,但仍應使用目標帳號和實際負載充分 soak test。所有欄位見 [設定參考](/zh-tw/reference/configuration/#sidecars)。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index 40e1f4df5e..68b6ef516d 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -185,7 +185,7 @@ OpenAI backend 需要 ChatGPT 登入與啟用的 ChatGPT `forward` 供應商。C | `backend?` | `"openai" \| "anthropic"` | 自動 | 與網頁搜尋相同的明確優先、Anthropic 憑證感知選擇。 | | `model?` | `string` | 視 backend 而定 | OpenAI 為 `gpt-5.4-mini` 或 Anthropic 為 `claude-sonnet-5`。 | | `maxDescriptionsPerTurn?` | `number` | `8` | 每個主回合允許的新描述快取未命中。`0` 停用呼叫;無效值使用預設。 | -| `timeoutMs?` | `number` | `45000` | Sidecar 擷取逾時。 | +| `timeoutMs?` | `number` | `45000` | Sidecar 擷取逾時。整數 1–2147483647。 | 視覺僅對發送到其供應商 `noVisionModels` 中模型的圖片啟用。OpenAI 的登入/forward 需求與搜尋相同;明確選擇的 Anthropic 在無可用憑證時 fail closed。成功的 `data:` 描述使用以 backend、模型、細節、圖片位元組與正規化訊息 context 為 key 的有界快取。命中與同回合重複不消耗限制。遠端 `https:` 圖片與失敗或空的描述不被快取。 diff --git a/docs/pr-assets/1201-vision-1280.png b/docs/pr-assets/1201-vision-1280.png new file mode 100644 index 0000000000..144ca72f54 Binary files /dev/null and b/docs/pr-assets/1201-vision-1280.png differ diff --git a/docs/pr-assets/1201-vision-1440.png b/docs/pr-assets/1201-vision-1440.png new file mode 100644 index 0000000000..506cd1e024 Binary files /dev/null and b/docs/pr-assets/1201-vision-1440.png differ diff --git a/docs/pr-assets/1201-vision-420.png b/docs/pr-assets/1201-vision-420.png new file mode 100644 index 0000000000..90e56a2513 Binary files /dev/null and b/docs/pr-assets/1201-vision-420.png differ diff --git a/docs/pr-assets/1201-vision-768.png b/docs/pr-assets/1201-vision-768.png new file mode 100644 index 0000000000..1dfc73093c Binary files /dev/null and b/docs/pr-assets/1201-vision-768.png differ diff --git a/docs/pr-assets/1201-vision-sidecar-controls.png b/docs/pr-assets/1201-vision-sidecar-controls.png new file mode 100644 index 0000000000..506cd1e024 Binary files /dev/null and b/docs/pr-assets/1201-vision-sidecar-controls.png differ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 189d1d27bb..1894b3deb0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -273,6 +273,7 @@ export const de: Record = { "dash.webSearchStreamHint": "Führenden Text und Reasoning live streamen, bis das Modell über einen Tool-Aufruf entscheidet; der Rest bleibt für das Abfangen der Suche gepuffert. Text vor einer Suche kann sich teilweise wiederholen.", "dash.visionSidecar": "Vision-Sidecar", "dash.visionSidecarHint": "Backend und Modell zur Bildbeschreibung für reine Textmodelle auswählen.", + "dash.visionOff": "Aus", "dash.shadowCallIntercept": "Shadow-Call-Abfangen", "dash.shadowCallInterceptHint": "Fängt die Hintergrund-Hilfsaufrufe der Codex-App ({models}) ab und leitet sie an das gewählte Modell um. Effort wird auf low fixiert.", "dash.shadowCallWarning": "⚠ Bei Aktivierung werden ALLE Anfragen an {models} durch das gewählte Modell ersetzt.", @@ -2006,4 +2007,10 @@ export const de: Record = { "lab.layer.protocol_conformance": "Protokollkonformität", "lab.layer.live_route_compatibility": "Live-Route-Kompatibilität", "lab.layer.task_effectiveness": "Aufgabenwirksamkeit", + "dash.visionAdvanced": "Erweiterte Einstellungen", + "dash.visionMaxDescriptions": "Maximale Beschreibungen pro Turn", + "dash.visionMaxDescriptionsInvalid": "Geben Sie eine positive ganze Zahl ein.", + "dash.visionTimeout": "Timeout", + "dash.visionTimeoutInvalid": "Geben Sie eine ganze Zahl von {min} bis {max} Millisekunden ein.", + "dash.visionAdvancedPopover": "Erweiterte Vision-Einstellungen", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c25243a935..04b154d0be 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -285,6 +285,13 @@ export const en = { "dash.webSearchStreamHint": "Stream the model’s leading text and reasoning live until it decides on a tool call; the rest of the turn stays buffered for search interception. Text written before a search may partially repeat.", "dash.visionSidecar": "Vision sidecar", "dash.visionSidecarHint": "Choose the backend and model used to describe images for text-only routed models.", + "dash.visionOff": "Off", + "dash.visionAdvanced": "Advanced settings", + "dash.visionMaxDescriptions": "Maximum descriptions per turn", + "dash.visionMaxDescriptionsInvalid": "Enter a positive integer.", + "dash.visionTimeout": "Timeout", + "dash.visionTimeoutInvalid": "Enter an integer from {min} to {max} milliseconds.", + "dash.visionAdvancedPopover": "Advanced vision settings", "dash.shadowCallIntercept": "Shadow Call Intercept", "dash.shadowCallInterceptHint": "Intercepts Codex App's background helper calls ({models}) for title generation and commit messages and redirects them to your chosen model. Effort is fixed to low.", "dash.shadowCallWarning": "⚠ When enabled, ALL requests for {models} will be replaced with the selected model.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2e5b277dc6..690534344f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -282,6 +282,7 @@ export const ja: Record = { "dash.webSearchStreamHint": "モデルがツール呼び出しを決定するまで、先頭のテキストと推論をライブ配信します。以降は検索インターセプトのためバッファされます。検索前のテキストは一部繰り返される場合があります。", "dash.visionSidecar": "ビジョンサイドカー", "dash.visionSidecarHint": "テキスト専用ルーティングモデルで画像を説明するために使うバックエンドとモデルを選択します。", + "dash.visionOff": "オフ", "dash.shadowCallIntercept": "シャドウコール傍受", "dash.shadowCallInterceptHint": "Codex App のバックグラウンドヘルパー呼び出し({models}: タイトル生成、コミットメッセージ)を傍受し、選択したモデルにリダイレクトします。負荷は low に固定されます。", "dash.shadowCallWarning": "⚠ オンにすると、{models} へのリクエストがすべて選択したモデルに置き換えられます。", @@ -2027,4 +2028,10 @@ export const ja: Record = { "lab.layer.protocol_conformance": "Protocol conformance", "lab.layer.live_route_compatibility": "Live route compatibility", "lab.layer.task_effectiveness": "Task effectiveness", + "dash.visionAdvanced": "詳細設定", + "dash.visionMaxDescriptions": "1 ターンあたりの最大説明数", + "dash.visionMaxDescriptionsInvalid": "正の整数を入力してください。", + "dash.visionTimeout": "タイムアウト", + "dash.visionTimeoutInvalid": "{min} から {max} ミリ秒の整数を入力してください。", + "dash.visionAdvancedPopover": "詳細なビジョン設定", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 96d0f226fe..440319d2ed 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -277,6 +277,7 @@ export const ko: Record = { "dash.webSearchStreamHint": "모델이 도구 호출을 결정할 때까지 앞부분 텍스트와 추론을 실시간 스트리밍합니다. 이후는 검색 가로채기를 위해 버퍼링됩니다. 검색 전 텍스트가 일부 반복될 수 있습니다.", "dash.visionSidecar": "비전 사이드카", "dash.visionSidecarHint": "텍스트 전용 라우팅 모델이 이미지를 읽을 때 쓸 백엔드와 모델을 고릅니다.", + "dash.visionOff": "끔", "dash.shadowCallIntercept": "쉐도우 호출 가로채기", "dash.shadowCallInterceptHint": "Codex 앱이 제목·커밋 메시지 생성에 쓰는 백그라운드 호출({models})을 가로채 선택한 모델로 바꿉니다. effort는 low로 고정됩니다.", "dash.shadowCallWarning": "⚠ 활성화하면 {models} 요청이 모두 선택한 모델로 대체됩니다.", @@ -2028,4 +2029,10 @@ export const ko: Record = { "lab.layer.live_route_compatibility": "Live route compatibility", "lab.layer.task_effectiveness": "Task effectiveness", + "dash.visionAdvanced": "고급 설정", + "dash.visionMaxDescriptions": "턴당 최대 설명 수", + "dash.visionMaxDescriptionsInvalid": "양의 정수를 입력하세요.", + "dash.visionTimeout": "제한 시간", + "dash.visionTimeoutInvalid": "{min}에서 {max} 밀리초 사이의 정수를 입력하세요.", + "dash.visionAdvancedPopover": "고급 비전 설정", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d1d8429662..9b36937968 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -282,6 +282,7 @@ export const ru: Record = { "dash.webSearchStreamHint": "Транслировать начальный текст и рассуждения вживую, пока модель не решит вызвать инструмент; остальное буферизуется для перехвата поиска. Текст до поиска может частично повторяться.", "dash.visionSidecar": "Сайдкар для изображений", "dash.visionSidecarHint": "Выберите бэкенд и модель, которые описывают изображения для маршрутизируемых моделей, работающих только с текстом.", + "dash.visionOff": "Выкл", "dash.shadowCallIntercept": "Перехват теневых вызовов", "dash.shadowCallInterceptHint": "Перехватывает фоновые служебные вызовы Codex App ({models}: генерация заголовков, сообщений коммитов) и перенаправляет их на выбранную вами модель. Уровень рассуждений жёстко задан как low.", "dash.shadowCallWarning": "⚠ Когда функция включена, ВСЕ запросы к {models} будут заменены выбранной моделью.", @@ -2029,4 +2030,10 @@ export const ru: Record = { "lab.layer.protocol_conformance": "Protocol conformance", "lab.layer.live_route_compatibility": "Live route compatibility", "lab.layer.task_effectiveness": "Task effectiveness", + "dash.visionAdvanced": "Дополнительные настройки", + "dash.visionMaxDescriptions": "Максимум описаний за ход", + "dash.visionMaxDescriptionsInvalid": "Введите положительное целое число.", + "dash.visionTimeout": "Таймаут", + "dash.visionTimeoutInvalid": "Введите целое число от {min} до {max} миллисекунд.", + "dash.visionAdvancedPopover": "Дополнительные настройки изображений", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 77c81d666a..183b536af7 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -283,6 +283,7 @@ export const tr: Record = { "dash.webSearchStreamHint": "Model bir araç çağrısına karar verene kadar baştaki metni ve akıl yürütmeyi canlı akıtır; kalanı arama yakalama için arabelleğe alınır. Aramadan önce yazılan metin kısmen tekrarlanabilir.", "dash.visionSidecar": "Görsel yan aracı (sidecar)", "dash.visionSidecarHint": "Salt metin modeller için görselleri tanımlamakta kullanılan arka ucu ve modeli seçin.", + "dash.visionOff": "Kapalı", "dash.shadowCallIntercept": "Gölge Çağrı Yakalama", "dash.shadowCallInterceptHint": "Codex App'in arka plan yardımcı çağrılarını ({models}) başlık oluşturma ve commit mesajları için yakalar ve seçtiğiniz modele yönlendirir.", "dash.shadowCallWarning": "⚠ Etkinleştirildiğinde, {models} için olan TÜM istekler seçilen modelle değiştirilecektir.", @@ -2029,4 +2030,10 @@ export const tr: Record = { "lab.layer.protocol_conformance": "Protocol conformance", "lab.layer.live_route_compatibility": "Live route compatibility", "lab.layer.task_effectiveness": "Task effectiveness", + "dash.visionAdvanced": "Gelişmiş ayarlar", + "dash.visionMaxDescriptions": "Tur başına en fazla açıklama", + "dash.visionMaxDescriptionsInvalid": "Pozitif bir tam sayı girin.", + "dash.visionTimeout": "Zaman aşımı", + "dash.visionTimeoutInvalid": "{min} ile {max} milisaniye arasında bir tam sayı girin.", + "dash.visionAdvancedPopover": "Gelişmiş görsel ayarları", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 7a119a6667..340c9e18ec 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -176,6 +176,7 @@ export const zhTW: Record = { "dash.webSearchStreamHint": "即時串流輸出開頭的文字和推理,直到模型決定呼叫工具;其餘部分為攔截搜尋而保持緩衝。搜尋前的文字可能會部分重複。", "dash.visionSidecar": "視覺附屬服務", "dash.visionSidecarHint": "選擇純文字路由模型描述圖像時使用的後端和模型。", + "dash.visionOff": "關閉", "dash.shadowCallIntercept": "影子呼叫攔截", "dash.shadowCallInterceptHint": "攔截 Codex 應用的背景 helper 呼叫({models})以生成標題與提交訊息,並將它們重定向到您選擇的模型。effort 固定為 low。", "dash.shadowCallWarning": "⚠ 啟用後,{models} 的所有請求將被替換為所選模型。", @@ -1992,4 +1993,10 @@ export const zhTW: Record = { "lab.layer.protocol_conformance": "協定符合度", "lab.layer.live_route_compatibility": "即時路由相容性", "lab.layer.task_effectiveness": "任務效能", + "dash.visionAdvanced": "進階設定", + "dash.visionMaxDescriptions": "每回合最大描述次數", + "dash.visionMaxDescriptionsInvalid": "請輸入正整數。", + "dash.visionTimeout": "逾時", + "dash.visionTimeoutInvalid": "請輸入 {min} 到 {max} 毫秒之間的整數。", + "dash.visionAdvancedPopover": "進階視覺設定", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 9825780ec2..0385282d91 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -277,6 +277,7 @@ export const zh: Record = { "dash.webSearchStreamHint": "实时流式输出开头的文本和推理,直到模型决定调用工具;其余部分为拦截搜索而保持缓冲。搜索前的文本可能会部分重复。", "dash.visionSidecar": "视觉附属服务", "dash.visionSidecarHint": "选择纯文本路由模型描述图像时使用的后端和模型。", + "dash.visionOff": "关闭", "dash.shadowCallIntercept": "影子调用拦截", "dash.shadowCallInterceptHint": "拦截 Codex 应用的后台辅助调用({models}:标题生成、提交消息)并重定向到所选模型。effort 固定为 low。", "dash.shadowCallWarning": "⚠ 启用后,所有对 {models} 的请求都将被替换为所选模型。", @@ -2027,4 +2028,10 @@ export const zh: Record = { "lab.layer.protocol_conformance": "Protocol conformance", "lab.layer.live_route_compatibility": "Live route compatibility", "lab.layer.task_effectiveness": "Task effectiveness", + "dash.visionAdvanced": "高级设置", + "dash.visionMaxDescriptions": "每回合最大描述次数", + "dash.visionMaxDescriptionsInvalid": "请输入正整数。", + "dash.visionTimeout": "超时", + "dash.visionTimeoutInvalid": "请输入 {min} 到 {max} 毫秒之间的整数。", + "dash.visionAdvancedPopover": "高级视觉设置", }; diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index bf22ff796a..3c4b4b788e 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -1,10 +1,34 @@ -import { useEffect, useRef, useState } from "react"; -import { IconAlert, IconCheck, IconInfo, IconRefresh, IconX } from "../icons"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"; +import { createPortal } from "react-dom"; +import { IconAlert, IconCheck, IconChevron, IconInfo, IconRefresh, IconX } from "../icons"; import { Trans } from "../i18n/provider"; +import type { TFn } from "../i18n/shared"; import { Select } from "../ui"; +import { computeSelectMenuStyle } from "../select-position"; import { formatNamespacedModelId } from "../provider-icons"; import { navigateHash } from "../hash-routing"; -import { clampVisionReasoningToLadder, EFFORT_CAP_LEVELS, requireJson, shadowCallModelOptions, sidecarBackendForModel, updateJobLabel, visionReasoningLadder, visionReasoningOptionsFor, visionReasoningPatch, visionSidecarBackendForModel } from "./dashboard-shared"; +import { + clampVisionReasoningToLadder, + EFFORT_CAP_LEVELS, + parsePositiveInteger, + parseVisionTimeoutMs, + requireJson, + type SidecarPatch, + shadowCallModelOptions, + sidecarBackendForModel, + updateJobLabel, + visionEnabledPatch, + visionMaxDescriptionsPatch, + visionReasoningLadder, + visionReasoningOptionsFor, + visionReasoningPatch, + visionSidecarBackendForModel, + visionTimeoutPatch, + VISION_MAX_DESCRIPTIONS_DEFAULT, + VISION_TIMEOUT_MS_DEFAULT, + VISION_TIMEOUT_MS_MAX, + VISION_TIMEOUT_MS_MIN, +} from "./dashboard-shared"; import { shadowSourceModelBadge } from "./shadow-call-source"; import type { useDashboardData } from "./use-dashboard-data"; @@ -236,16 +260,223 @@ export function DashboardMaintenancePanel({ d }: { d: Dash }) { ); } +function VisionAdvancedPopover({ t, open, triggerRef, onClose, maxValue, maxInvalid, timeoutValue, timeoutInvalid, disabled, setMaxDraft, setMaxInvalid, setTimeoutDraft, setTimeoutInvalid, commitMaxDescriptions, commitTimeout }: { + t: TFn; + open: boolean; + triggerRef: React.RefObject; + onClose: () => void; + maxValue: string; + maxInvalid: boolean; + timeoutValue: string; + timeoutInvalid: boolean; + disabled: boolean; + setMaxDraft: (v: string | null) => void; + setMaxInvalid: (v: boolean) => void; + setTimeoutDraft: (v: string | null) => void; + setTimeoutInvalid: (v: boolean) => void; + commitMaxDescriptions: (raw?: string) => void; + commitTimeout: (raw?: string) => void; +}) { + const panelRef = useRef(null); + const firstInputRef = useRef(null); + const [style, setStyle] = useState(); + + const reposition = useCallback(() => { + if (!triggerRef.current) return; + + setStyle(computeSelectMenuStyle( + triggerRef.current.getBoundingClientRect(), + { + align: "right", + placement: "below", + menuHeight: panelRef.current?.offsetHeight ?? 180, + }, + )); + }, [triggerRef]); + + useLayoutEffect(() => { + if (!open) return; + reposition(); + const onViewportChange = () => reposition(); + window.addEventListener("resize", onViewportChange); + window.addEventListener("scroll", onViewportChange, true); + return () => { + window.removeEventListener("resize", onViewportChange); + window.removeEventListener("scroll", onViewportChange, true); + }; + }, [open, reposition, maxInvalid, timeoutInvalid]); + + useEffect(() => { + if (!open) return; + const onPointerDown = (e: MouseEvent) => { + const target = e.target as Node; + if (panelRef.current?.contains(target) || triggerRef.current?.contains(target)) return; + + const activeElement = document.activeElement as HTMLElement | null; + if (activeElement && panelRef.current?.contains(activeElement)) { + activeElement.blur(); + } + + onClose(); + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [open, onClose, triggerRef]); + + useEffect(() => { + if (!open) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + triggerRef.current?.focus(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, onClose, triggerRef]); + useEffect(() => { + if (!open) return; + firstInputRef.current?.focus(); + }, [open]); + + if (!open) return null; + + return ( + + ); +} + export function DashboardSidecarPanels({ d }: { d: Dash }) { const { t, settings, settingsSaving, toggleCodexAutoStart, sidecar, sidecarSaving, sidecarModels, visionModels, models, saveSidecar, shadowCall, shadowCallSaving, shadowCallHelpTriggerRef, shadowCallHelpOpen, setShadowCallHelpOpen, saveShadowCall, } = d; - const visionModel = sidecar?.vision.model ?? "gpt-5.4-mini"; + const visionEnabled = sidecar?.vision.enabled !== false; + const visionModel = visionEnabled ? (sidecar?.vision.model ?? "gpt-5.4-mini") : ""; const persistedVisionReasoning = sidecar?.vision.reasoning ?? "low"; const visionLadder = visionReasoningLadder(models, visionModel); const visionReasoning = clampVisionReasoningToLadder(visionLadder, persistedVisionReasoning); + const serverMaxDescriptions = String(sidecar?.vision.maxDescriptionsPerTurn ?? VISION_MAX_DESCRIPTIONS_DEFAULT); + const serverTimeoutMs = String(sidecar?.vision.timeoutMs ?? VISION_TIMEOUT_MS_DEFAULT); + const [maxDraft, setMaxDraft] = useState(null); + const [timeoutDraft, setTimeoutDraft] = useState(null); + const [maxInvalid, setMaxInvalid] = useState(false); + const [timeoutInvalid, setTimeoutInvalid] = useState(false); + const [visionAdvancedOpen, setVisionAdvancedOpen] = useState(false); + const visionAdvancedTriggerRef = useRef(null); + const maxValue = maxDraft ?? serverMaxDescriptions; + const timeoutValue = timeoutDraft ?? serverTimeoutMs; + + const commitMaxDescriptions = (raw = maxValue) => { + const parsed = parsePositiveInteger(raw); + if (parsed === undefined) { + setMaxDraft(raw); + setMaxInvalid(true); + return; + } + setMaxInvalid(false); + setMaxDraft(null); + if (parsed === (sidecar?.vision.maxDescriptionsPerTurn ?? VISION_MAX_DESCRIPTIONS_DEFAULT)) return; + void saveSidecar(visionMaxDescriptionsPatch(parsed)); + }; + + const commitTimeout = (raw = timeoutValue) => { + const parsed = parseVisionTimeoutMs(raw); + if (parsed === undefined) { + setTimeoutDraft(raw); + setTimeoutInvalid(true); + return; + } + setTimeoutInvalid(false); + setTimeoutDraft(null); + if (parsed === (sidecar?.vision.timeoutMs ?? VISION_TIMEOUT_MS_DEFAULT)) return; + void saveSidecar(visionTimeoutPatch(parsed)); + }; return ( <> @@ -302,38 +533,82 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { -
+
{t("dash.visionSidecar")}
{t("dash.visionSidecarHint")}
- ({ value, label: value }))} - onChange={reasoning => { - void saveSidecar(visionReasoningPatch(reasoning as typeof visionReasoning)); - }} - disabled={!sidecar || sidecarSaving} - align="right" - label={`${t("dash.visionSidecar")} — ${t("dash.injectionEffortLabel")}`} - /> +
+ ({ value, label: value }))} + onChange={reasoning => { + void saveSidecar(visionReasoningPatch(reasoning as typeof visionReasoning)); + }} + disabled={!visionEnabled || !sidecar || sidecarSaving} + align="right" + label={`${t("dash.visionSidecar")} — ${t("dash.injectionEffortLabel")}`} + /> +
+
+ +
+
+ {createPortal( + setVisionAdvancedOpen(false)} + maxValue={maxValue} + maxInvalid={maxInvalid} + timeoutValue={timeoutValue} + timeoutInvalid={timeoutInvalid} + disabled={!visionEnabled || !sidecar || sidecarSaving} + setMaxDraft={setMaxDraft} + setMaxInvalid={setMaxInvalid} + setTimeoutDraft={setTimeoutDraft} + setTimeoutInvalid={setTimeoutInvalid} + commitMaxDescriptions={commitMaxDescriptions} + commitTimeout={commitTimeout} + />, + document.body, + )}
-
diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index faa99d4d43..dcd25f98ef 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -1,5 +1,10 @@ import type { RefObject } from "react"; import { useEffect, useRef } from "react"; +import { + DEFAULT_VISION_TIMEOUT_MS, + MAX_VISION_TIMEOUT_MS, + MIN_VISION_TIMEOUT_MS, +} from "../../../src/vision/timeout-bounds"; import { readJsonOrThrow } from "../fetch-json"; import type { TKey } from "../i18n/shared"; import type { StartupHealthStatus } from "../startup-health-ui"; @@ -56,7 +61,15 @@ export interface SettingsData { } export type SidecarBackend = "openai" | "anthropic"; export type VisionReasoning = "low" | "medium" | "high" | "xhigh" | "max"; -export interface SidecarSetting { backend?: SidecarBackend; model: string; reasoning?: VisionReasoning; streamRoutedModelOutput?: boolean } +export interface SidecarSetting { + backend?: SidecarBackend; + model: string; + reasoning?: VisionReasoning; + streamRoutedModelOutput?: boolean; + enabled?: boolean; + maxDescriptionsPerTurn?: number; + timeoutMs?: number; +} export interface VisionModelOption { value: string; label: string; backend: SidecarBackend; baseline?: boolean } export interface SidecarData { webSearch: SidecarSetting; @@ -68,7 +81,14 @@ export interface SidecarData { } export interface SidecarPatch { webSearch?: { backend?: SidecarBackend | null; model?: string; streamRoutedModelOutput?: boolean }; - vision?: { backend?: SidecarBackend | null; model?: string; reasoning?: VisionReasoning }; + vision?: { + backend?: SidecarBackend | null; + model?: string; + reasoning?: VisionReasoning; + enabled?: boolean; + maxDescriptionsPerTurn?: number; + timeoutMs?: number; + }; } export interface ShadowCallData { enabled: boolean; model: string; sourceModels?: string[] } export interface UsageSummary30d { summary: { requests: number; totalTokens: number; coverageRatio: number } } @@ -151,7 +171,15 @@ export function updateJobLabel(status: UpdateJobStatus, t: (key: TKey) => string export function mergeSidecarSetting( current: SidecarSetting, - update?: { backend?: SidecarBackend | null; model?: string; reasoning?: VisionReasoning; streamRoutedModelOutput?: boolean }, + update?: { + backend?: SidecarBackend | null; + model?: string; + reasoning?: VisionReasoning; + streamRoutedModelOutput?: boolean; + enabled?: boolean; + maxDescriptionsPerTurn?: number; + timeoutMs?: number; + }, ): SidecarSetting { const merged = { ...current }; if (update?.model !== undefined) merged.model = update.model; @@ -159,6 +187,9 @@ export function mergeSidecarSetting( else if (update?.backend !== undefined) merged.backend = update.backend; if (update?.reasoning !== undefined) merged.reasoning = update.reasoning; if (update?.streamRoutedModelOutput !== undefined) merged.streamRoutedModelOutput = update.streamRoutedModelOutput; + if (update?.enabled !== undefined) merged.enabled = update.enabled; + if (update?.maxDescriptionsPerTurn !== undefined) merged.maxDescriptionsPerTurn = update.maxDescriptionsPerTurn; + if (update?.timeoutMs !== undefined) merged.timeoutMs = update.timeoutMs; return merged; } @@ -167,6 +198,42 @@ export function visionReasoningPatch(reasoning: VisionReasoning): SidecarPatch { return { vision: { reasoning } }; } +export function visionEnabledPatch(enabled: boolean): SidecarPatch { + return { vision: { enabled } }; +} + +export function visionMaxDescriptionsPatch(maxDescriptionsPerTurn: number): SidecarPatch { + return { vision: { maxDescriptionsPerTurn } }; +} + +export function visionTimeoutPatch(timeoutMs: number): SidecarPatch { + return { vision: { timeoutMs } }; +} + +/** + * Dashboard names for the runtime timeout contract in `src/vision/timeout-bounds.ts`. + * Pinned by `tests/vision-sidecar-timeout-bounds.test.ts`. + */ +export const VISION_TIMEOUT_MS_DEFAULT = DEFAULT_VISION_TIMEOUT_MS; +export const VISION_TIMEOUT_MS_MAX = MAX_VISION_TIMEOUT_MS; +export const VISION_TIMEOUT_MS_MIN = MIN_VISION_TIMEOUT_MS; +/** Mirrors `DEFAULT_MAX_DESCRIPTIONS_PER_TURN` and is pinned by the timeout-bounds contract test. */ +export const VISION_MAX_DESCRIPTIONS_DEFAULT = 8; + +export function parsePositiveInteger(raw: string): number | undefined { + const trimmed = raw.trim(); + if (!/^[0-9]+$/.test(trimmed)) return undefined; + const value = Number(trimmed); + if (!Number.isSafeInteger(value) || value <= 0) return undefined; + return value; +} + +export function parseVisionTimeoutMs(raw: string): number | undefined { + const value = parsePositiveInteger(raw); + if (value === undefined || value < VISION_TIMEOUT_MS_MIN || value > VISION_TIMEOUT_MS_MAX) return undefined; + return value; +} + export const VISION_REASONING_LEVELS: VisionReasoning[] = ["low", "medium", "high", "xhigh", "max"]; export function visionReasoningLadder(models: ModelInfo[], modelId: string): VisionReasoning[] { diff --git a/gui/src/styles-dashboard-workspace.css b/gui/src/styles-dashboard-workspace.css index 87e3e41c9c..b1499d49fc 100644 --- a/gui/src/styles-dashboard-workspace.css +++ b/gui/src/styles-dashboard-workspace.css @@ -143,6 +143,162 @@ min-width: 0; } +.dash-vision-sidecar-card { + flex-wrap: wrap; + align-items: flex-start; +} + +/* The shared sidecar copy rule is `flex: 1 1 0` so a one-row control group can + keep its intrinsic width. Vision's control column is two rows, and the number + fields alone are ~24rem; with a 0 basis the title collapses to a one-glyph + column and the card grows to over 1000px tall. Give copy a readable basis so + the card wraps to a stacked layout instead of shrinking the hint. */ +.dash-vision-sidecar-card .dash-sidecar-copy { + flex: 1 1 16rem; + min-width: min(100%, 14rem); +} + +.dash-vision-sidecar-card .dash-delegation-controls { + flex: 1 1 20rem; + min-width: 0; + max-width: 100%; + flex-direction: column; + align-items: stretch; + flex-wrap: nowrap; + gap: 12px; +} + +.dash-vision-select-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + flex-wrap: wrap; + min-width: 0; + width: 100%; +} + +.dash-vision-select-row .custom-select:first-child { + flex: 1 1 70%; + min-width: 9rem; + max-width: 16rem; +} + +.dash-vision-select-row .custom-select:nth-child(2) { + flex: 1 1 30%; + min-width: 6rem; + max-width: 9rem; +} + +.dash-vision-select-row .custom-select .select-trigger { + width: 100%; + max-width: 100%; +} + +.dash-vision-advanced-row { + display: flex; + justify-content: flex-end; + width: 100%; +} + +.dash-vision-advanced-trigger { + appearance: none; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + border: none; + background: none; + color: var(--muted); + font-size: var(--text-control); + cursor: pointer; +} +.dash-vision-advanced-trigger:hover:not(:disabled) { + color: var(--text); +} +.dash-vision-advanced-trigger:disabled { + opacity: 0.6; + cursor: not-allowed; +} +.dash-vision-advanced-trigger:focus-visible { + outline: 2px solid var(--accent-ring); + outline-offset: 2px; + border-radius: var(--radius-2xs); +} + +.dash-vision-number { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.dash-vision-number .codex-auto-switch-input-wrap { + width: min(11.5rem, 100%); +} + +.dash-vision-number .codex-auto-switch-input { + min-width: 0; +} + +/* Floating popover for the advanced vision settings. Portaled to body and + positioned via computeSelectMenuStyle (same pattern as the Select dropdown): + fixed position, never consumes page flow or shifts the dashboard layout. */ +.dash-vision-advanced-popover { + box-sizing: border-box; + width: max-content; + min-width: 16rem; + max-width: min(22rem, calc(100vw - 2rem)); + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 12px; + background: var(--raised); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 4px 24px rgb(0 0 0 / 0.14); + overflow-y: auto; +} +.dash-vision-advanced-popover-title { + font-weight: var(--weight-semibold); +} +.dash-vision-advanced-popover .dash-vision-number .codex-auto-switch-input-wrap { + width: 100%; +} +.dash-vision-advanced-popover .dash-vision-number .codex-auto-switch-input { + flex: 1 1 0; +} +.dash-vision-advanced-popover .dash-vision-number { + gap: 4px; +} + +@media (max-width: 36rem) { + .dash-vision-sidecar-card { + flex-direction: column; + align-items: stretch; + } + + .dash-vision-sidecar-card .dash-sidecar-copy, + .dash-vision-sidecar-card .dash-delegation-controls { + flex: 0 1 auto; + min-width: 0; + } + + .dash-vision-sidecar-card .dash-delegation-controls { + align-items: stretch; + } + + .dash-vision-select-row { + justify-content: flex-start; + } +} + +@media (max-width: 30rem) { + .dash-vision-number .codex-auto-switch-input-wrap { + width: 100%; + } +} + /* Below the width where a row can hold copy + two selects, stack instead of overflowing. Measured: the row is clean down to ~320px viewport; under that the controls would push past the panel edge. */ diff --git a/gui/tests/vision-sidecar-controls.test.ts b/gui/tests/vision-sidecar-controls.test.ts new file mode 100644 index 0000000000..e15a60f708 --- /dev/null +++ b/gui/tests/vision-sidecar-controls.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { + mergeSidecarSetting, + parsePositiveInteger, + parseVisionTimeoutMs, + visionEnabledPatch, + visionMaxDescriptionsPatch, + visionReasoningPatch, + visionTimeoutPatch, + VISION_MAX_DESCRIPTIONS_DEFAULT, + VISION_TIMEOUT_MS_DEFAULT, + VISION_TIMEOUT_MS_MAX, + VISION_TIMEOUT_MS_MIN, + type SidecarSetting, +} from "../src/pages/dashboard-shared"; + +const current: SidecarSetting = { + model: "gpt-5.6-luna", + backend: "openai", + reasoning: "medium", + enabled: true, + maxDescriptionsPerTurn: 8, + timeoutMs: 45_000, +}; + +test("vision control patches send only the edited field", () => { + expect(visionEnabledPatch(false)).toEqual({ vision: { enabled: false } }); + expect(visionMaxDescriptionsPatch(4)).toEqual({ vision: { maxDescriptionsPerTurn: 4 } }); + expect(visionTimeoutPatch(12_000)).toEqual({ vision: { timeoutMs: 12_000 } }); + expect(visionReasoningPatch("high")).toEqual({ vision: { reasoning: "high" } }); +}); + +test("partial merges keep model, backend, reasoning, timeout, and limit", () => { + expect(mergeSidecarSetting(current, { enabled: false })).toEqual({ ...current, enabled: false }); + expect(mergeSidecarSetting({ ...current, enabled: false }, { enabled: true })).toEqual(current); + expect(mergeSidecarSetting(current, { maxDescriptionsPerTurn: 3 })).toEqual({ + ...current, + maxDescriptionsPerTurn: 3, + }); + expect(mergeSidecarSetting(current, { timeoutMs: 12_000 })).toEqual({ ...current, timeoutMs: 12_000 }); + expect(mergeSidecarSetting(current, { reasoning: "high" })).toEqual({ ...current, reasoning: "high" }); + expect(mergeSidecarSetting(current, { streamRoutedModelOutput: true })).toEqual({ + ...current, + streamRoutedModelOutput: true, + }); +}); + +test("timeout and description parsers reject invalid and out-of-range input", () => { + expect(parsePositiveInteger("8")).toBe(8); + expect(parsePositiveInteger(String(VISION_MAX_DESCRIPTIONS_DEFAULT))).toBe(8); + expect(parsePositiveInteger("0")).toBeUndefined(); + expect(parsePositiveInteger("-1")).toBeUndefined(); + expect(parsePositiveInteger("1.5")).toBeUndefined(); + expect(parsePositiveInteger("")).toBeUndefined(); + expect(parsePositiveInteger("8abc")).toBeUndefined(); + + expect(parseVisionTimeoutMs(String(VISION_TIMEOUT_MS_DEFAULT))).toBe(45_000); + expect(parseVisionTimeoutMs(String(VISION_TIMEOUT_MS_MIN))).toBe(VISION_TIMEOUT_MS_MIN); + expect(parseVisionTimeoutMs(String(VISION_TIMEOUT_MS_MAX))).toBe(VISION_TIMEOUT_MS_MAX); + expect(parseVisionTimeoutMs("0")).toBeUndefined(); + expect(parseVisionTimeoutMs("45000.2")).toBeUndefined(); + expect(parseVisionTimeoutMs(String(VISION_TIMEOUT_MS_MAX + 1))).toBeUndefined(); +}); diff --git a/gui/tests/vision-sidecar-dashboard.test.tsx b/gui/tests/vision-sidecar-dashboard.test.tsx new file mode 100644 index 0000000000..dc762de58f --- /dev/null +++ b/gui/tests/vision-sidecar-dashboard.test.tsx @@ -0,0 +1,385 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + Window, + type HTMLButtonElement as HappyHTMLButtonElement, + type HTMLElement as HappyHTMLElement, + type HTMLInputElement as HappyHTMLInputElement, +} from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { en } from "../src/i18n/en"; +import { LanguageProvider } from "../src/i18n/provider"; +import { DashboardSidecarPanels } from "../src/pages/dashboard-overview-sections"; +import type { SidecarData, SidecarPatch } from "../src/pages/dashboard-shared"; +import { mergeSidecarSetting } from "../src/pages/dashboard-shared"; +import type { useDashboardData } from "../src/pages/use-dashboard-data"; + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let testWindow: Window; +let host: HTMLElement; +let root: Root | null = null; + +type Dash = ReturnType; + +const initialSidecar: SidecarData = { + webSearch: { model: "gpt-5.6-luna", streamRoutedModelOutput: false }, + vision: { + model: "gpt-5.6-luna", + backend: "openai", + reasoning: "medium", + enabled: true, + maxDescriptionsPerTurn: 12, + timeoutMs: 30_000, + }, + visionModels: [ + { value: "gpt-5.6-luna", label: "gpt-5.6-luna", backend: "openai", baseline: true }, + { value: "gpt-5.4-mini", label: "gpt-5.4-mini", backend: "openai", baseline: true }, + ], +}; + +beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + root = null; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + host = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(host as never); +}); + +afterEach(async () => { + try { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + } + } finally { + root = null; + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } +}); + +function harness(sidecar: SidecarData = initialSidecar) { + const patches: SidecarPatch[] = []; + let current = sidecar; + const listeners: Array<() => void> = []; + const saveSidecar = async (patch: SidecarPatch) => { + patches.push(patch); + current = { + webSearch: mergeSidecarSetting(current.webSearch, patch.webSearch), + vision: mergeSidecarSetting(current.vision, patch.vision), + ...(current.visionModels ? { visionModels: current.visionModels } : {}), + }; + for (const listener of listeners) listener(); + }; + const d = { + t: (key: keyof typeof en, vars?: Record) => { + let out: string = en[key]; + if (vars) { + for (const [name, value] of Object.entries(vars)) out = out.split(`{${name}}`).join(String(value)); + } + return out; + }, + settings: { codexAutoStart: true, port: 10100, hostname: "127.0.0.1" }, + settingsSaving: false, + toggleCodexAutoStart: () => {}, + sidecar, + sidecarSaving: false, + sidecarModels: [{ value: "gpt-5.6-luna", label: "gpt-5.6-luna" }], + visionModels: sidecar.visionModels ?? [], + models: [ + { id: "gpt-5.6-luna", provider: "openai", namespaced: "gpt-5.6-luna", reasoningEfforts: ["low", "medium", "high", "xhigh", "max"] }, + { id: "gpt-5.4-mini", provider: "openai", namespaced: "gpt-5.4-mini", reasoningEfforts: ["low", "medium", "high", "xhigh", "max"] }, + ], + saveSidecar, + shadowCall: { enabled: false, model: "" }, + shadowCallSaving: false, + shadowCallHelpTriggerRef: { current: null }, + shadowCallHelpOpen: false, + setShadowCallHelpOpen: () => {}, + saveShadowCall: async () => {}, + } as unknown as Dash; + listeners.push(() => { + d.sidecar = current; + }); + return { d, patches, getSidecar: () => current }; +} + +async function mount(d: Dash) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + if (!root) root = createRoot(host); + root.render(); + }); +} + +function visionCard() { + return [...host.querySelectorAll(".dash-sidecar-row-card")].find(card => + card.textContent?.includes(en["dash.visionSidecar"]), + ) as HTMLElement; +} + +function advancedTrigger() { + const card = visionCard(); + return [...card.querySelectorAll("button")] + .find(button => button.textContent?.includes(en["dash.visionAdvanced"])) as HTMLButtonElement; +} + +function modelTrigger() { + const card = visionCard(); + return card.querySelector( + `button[role="combobox"][aria-label="${en["dash.sidecarModel"]}"]`, + ) as HTMLButtonElement; +} + +function advancedInput(label: string): HappyHTMLInputElement | null { + return testWindow.document.querySelector( + `input[aria-label="${label}"]`, + ) as HappyHTMLInputElement | null; +} + +async function openAdvanced() { + await act(async () => { advancedTrigger().click(); }); +} + +function popover(): HappyHTMLElement | null { + return testWindow.document.querySelector( + ".dash-vision-advanced-popover", + ) as HappyHTMLElement | null; +} + +test("Advanced settings opens a floating popover that closes on outside click and Escape", async () => { + await mount(harness().d); + expect(popover()).toBeNull(); + + await openAdvanced(); + expect(popover()).toBeTruthy(); + expect(advancedInput(en["dash.visionMaxDescriptions"])).toBeTruthy(); + expect(advancedInput(en["dash.visionTimeout"])).toBeTruthy(); + + // Click outside closes. + await act(async () => { + testWindow.document.body.dispatchEvent(new testWindow.MouseEvent("mousedown", { bubbles: true })); + }); + expect(popover()).toBeNull(); + + // Escape closes. + await openAdvanced(); + expect(popover()).toBeTruthy(); + await act(async () => { + testWindow.document.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + expect(popover()).toBeNull(); +}); + +test("opening the popover focuses its first input and Escape returns focus to the trigger", async () => { + await mount(harness().d); + const trigger = advancedTrigger(); + + await openAdvanced(); + expect(testWindow.document.activeElement).toBe(advancedInput(en["dash.visionMaxDescriptions"])); + + await act(async () => { + testWindow.document.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + expect(popover()).toBeNull(); + expect(testWindow.document.activeElement).toBe(trigger); +}); + +test("clicking outside commits a dirty numeric input before closing the popover", async () => { + const { d, patches } = harness(); + await mount(d); + await openAdvanced(); + + const maxInput = advancedInput(en["dash.visionMaxDescriptions"])!; + maxInput.value = "9"; + + await act(async () => { + maxInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + + expect(patches).toEqual([]); + + await act(async () => { + testWindow.document.body.dispatchEvent( + new testWindow.MouseEvent("mousedown", { bubbles: true }), + ); + }); + + expect(popover()).toBeNull(); + expect(patches).toEqual([ + { vision: { maxDescriptionsPerTurn: 9 } }, + ]); +}); + +test("reopening the Advanced popover preserves the server-backed values", async () => { + const { d, patches } = harness(); + await mount(d); + + await openAdvanced(); + const maxInput = advancedInput(en["dash.visionMaxDescriptions"])!; + maxInput.value = "9"; + await act(async () => { maxInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); }); + await act(async () => { maxInput.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); }); + expect(patches).toEqual([{ vision: { maxDescriptionsPerTurn: 9 } }]); + + // Close then reopen: the value reflects the saved server state. + await act(async () => { + testWindow.document.body.dispatchEvent(new testWindow.MouseEvent("mousedown", { bubbles: true })); + }); + expect(popover()).toBeNull(); + await openAdvanced(); + expect(advancedInput(en["dash.visionMaxDescriptions"])?.value).toBe("9"); + expect(advancedInput(en["dash.visionTimeout"])?.value).toBe("30000"); +}); + +test("timeout edit saves only timeoutMs", async () => { + const { d, patches } = harness(); + await mount(d); + await openAdvanced(); + + const timeoutInput = advancedInput(en["dash.visionTimeout"])!; + timeoutInput.value = "60000"; + await act(async () => { timeoutInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); }); + await act(async () => { timeoutInput.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); }); + expect(patches).toEqual([{ vision: { timeoutMs: 60_000 } }]); +}); + +test("Dashboard hydrates max descriptions, timeout, and the selected model from the server", async () => { + await mount(harness().d); + const card = visionCard(); + // enabled:true renders the actual selected model, not Off. + expect(modelTrigger().textContent).toContain("gpt-5.6-luna"); + // No standalone Vision switch remains in the DOM. + expect([...card.querySelectorAll("button.switch")]).toHaveLength(0); + // Advanced numeric controls are hidden behind the trigger until opened. + expect(advancedInput(en["dash.visionMaxDescriptions"])).toBeNull(); + expect(advancedInput(en["dash.visionTimeout"])).toBeNull(); + + await openAdvanced(); + expect(advancedInput(en["dash.visionMaxDescriptions"])?.value).toBe("12"); + expect(advancedInput(en["dash.visionTimeout"])?.value).toBe("30000"); +}); + +test("enabled:false hydrates the model control as Off", async () => { + const { d } = harness({ ...initialSidecar, vision: { ...initialSidecar.vision, enabled: false } }); + await mount(d); + expect(modelTrigger().textContent).toContain(en["dash.visionOff"]); +}); + +test("choosing Off sends only enabled:false and keeps the other Vision fields", async () => { + const { d, patches, getSidecar } = harness(); + await mount(d); + await act(async () => { modelTrigger().click(); }); + const off = pickOption(en["dash.visionOff"]); + expect(off).toBeTruthy(); + await act(async () => { off!.click(); }); + expect(patches).toEqual([{ vision: { enabled: false } }]); + expect(getSidecar().vision).toMatchObject({ + enabled: false, + model: "gpt-5.6-luna", + backend: "openai", + reasoning: "medium", + maxDescriptionsPerTurn: 12, + timeoutMs: 30_000, + }); +}); + +test("choosing a model from Off sends enabled:true plus that model and backend", async () => { + const { d, patches } = harness({ ...initialSidecar, vision: { ...initialSidecar.vision, enabled: false } }); + await mount(d); + await act(async () => { modelTrigger().click(); }); + const next = pickOption("gpt-5.4-mini"); + expect(next).toBeTruthy(); + await act(async () => { next!.click(); }); + expect(patches).toEqual([ + { vision: { model: "gpt-5.4-mini", backend: "openai", reasoning: "medium", enabled: true } }, + ]); +}); + +test("no standalone Vision switch remains in the DOM", async () => { + await mount(harness().d); + expect([...visionCard().querySelectorAll("button.switch")]).toHaveLength(0); +}); + +test("editing the limit or timeout saves only that field", async () => { + const { d, patches } = harness(); + await mount(d); + await openAdvanced(); + + const maxInput = advancedInput(en["dash.visionMaxDescriptions"])!; + maxInput.value = "11"; + await act(async () => { maxInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); }); + await act(async () => { maxInput.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); }); + expect(patches).toEqual([{ vision: { maxDescriptionsPerTurn: 11 } }]); + + const timeoutInput = advancedInput(en["dash.visionTimeout"])!; + timeoutInput.value = "31000"; + await act(async () => { timeoutInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); }); + await act(async () => { timeoutInput.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); }); + expect(patches[1]).toEqual({ vision: { timeoutMs: 31_000 } }); +}); + +test("an unrelated web-search save does not include Vision fields", async () => { + const { d, patches } = harness(); + await mount(d); + const streamToggle = [...host.querySelectorAll("button.switch")].find(button => + button.getAttribute("aria-label") === en["dash.webSearchStream"], + ) as HTMLButtonElement; + await act(async () => { streamToggle.click(); }); + expect(patches).toEqual([{ webSearch: { streamRoutedModelOutput: true } }]); +}); + +function pickOption(label: string): HappyHTMLButtonElement | undefined { + return [...testWindow.document.querySelectorAll('[role="option"]')] + .find(option => option.textContent === label) as HappyHTMLButtonElement | undefined; +} + +function assertVisionControlFieldsOmitted(patch: SidecarPatch) { + expect(patch.vision).toBeDefined(); + expect(patch.vision).not.toHaveProperty("enabled"); + expect(patch.vision).not.toHaveProperty("maxDescriptionsPerTurn"); + expect(patch.vision).not.toHaveProperty("timeoutMs"); +} + +test("model and reasoning saves still omit enabled, limit, and timeout", async () => { + const { d, patches } = harness(); + await mount(d); + const card = visionCard(); + const modelTrigger = card.querySelector( + `button[role="combobox"][aria-label="${en["dash.sidecarModel"]}"]`, + ) as HTMLButtonElement; + const reasoningTrigger = card.querySelector( + `button[role="combobox"][aria-label="${en["dash.visionSidecar"]} — ${en["dash.injectionEffortLabel"]}"]`, + ) as HTMLButtonElement; + + await act(async () => { modelTrigger.click(); }); + const nextModel = pickOption("gpt-5.4-mini"); + expect(nextModel).toBeTruthy(); + await act(async () => { nextModel!.click(); }); + expect(patches).toHaveLength(1); + expect(patches[0]).toEqual({ + vision: { model: "gpt-5.4-mini", backend: "openai", reasoning: "medium" }, + }); + assertVisionControlFieldsOmitted(patches[0]!); + + await act(async () => { reasoningTrigger.click(); }); + const nextReasoning = pickOption("high"); + expect(nextReasoning).toBeTruthy(); + await act(async () => { nextReasoning!.click(); }); + expect(patches).toHaveLength(2); + expect(patches[1]).toEqual({ vision: { reasoning: "high" } }); + assertVisionControlFieldsOmitted(patches[1]!); +}); \ No newline at end of file diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index ca37248f36..0306a02f4b 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -54,7 +54,16 @@ import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; import { getProviderRegistryEntry } from "../../providers/registry"; import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../../reasoning-effort"; import { normalizeVisionReasoningForModel } from "../../vision/reasoning"; -import { findAnthropicVisionProvider, resolveEffectiveVisionModel, resolveVisionBackend } from "../../vision"; +import { + findAnthropicVisionProvider, + isValidVisionTimeoutMs, + MAX_VISION_TIMEOUT_MS, + MIN_VISION_TIMEOUT_MS, + resolveEffectiveVisionModel, + resolveMaxDescriptionsPerTurn, + resolveVisionBackend, + resolveVisionTimeoutMs, +} from "../../vision"; import { visionCandidateRows, visionDescriberIsProvablyBlind, @@ -108,6 +117,21 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{ return { model, reasoning, models }; } +function publicVisionSidecarSettings( + config: OcxConfig, + vision: Awaited>, +) { + const vs = config.visionSidecar ?? {}; + return { + enabled: vs.enabled !== false, + model: vision.model, + backend: vs.backend, + reasoning: vision.reasoning, + maxDescriptionsPerTurn: resolveMaxDescriptionsPerTurn(vs.maxDescriptionsPerTurn), + timeoutMs: resolveVisionTimeoutMs(vs.timeoutMs), + }; +} + export async function handleConfigRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/config" && req.method === "GET") { @@ -407,7 +431,6 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise= MIN_VISION_TIMEOUT_MS + && value <= MAX_VISION_TIMEOUT_MS; +} + +/** Runtime config is permissive: malformed or out-of-range values fall back to the default. */ +export function resolveVisionTimeoutMs(value: unknown): number { + return isValidVisionTimeoutMs(value) ? value : DEFAULT_VISION_TIMEOUT_MS; +} + /** Run `worker` over `items` with bounded concurrency, preserving input order in the result array. */ async function runBounded(items: T[], limit: number, worker: (item: T) => Promise): Promise { const results = new Array(items.length); @@ -271,7 +292,7 @@ export function planVisionSidecar( settings: { model, reasoning: normalizeVisionReasoningForModel(model, cfg.reasoning) ?? DEFAULT_REASONING, - timeoutMs: cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS, + timeoutMs: resolveVisionTimeoutMs(cfg.timeoutMs), }, maxDescriptionsPerTurn, }; @@ -284,7 +305,7 @@ export function planVisionSidecar( settings: { model, reasoning: normalizeVisionReasoningForModel(model, cfg.reasoning) ?? DEFAULT_REASONING, - timeoutMs: cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS, + timeoutMs: resolveVisionTimeoutMs(cfg.timeoutMs), }, maxDescriptionsPerTurn, }; diff --git a/src/vision/timeout-bounds.ts b/src/vision/timeout-bounds.ts new file mode 100644 index 0000000000..103f478b5d --- /dev/null +++ b/src/vision/timeout-bounds.ts @@ -0,0 +1,9 @@ +/** + * Inclusive integer bounds for `visionSidecar.timeoutMs`. + * The ceiling is the 32-bit timer delay used by `signalWithTimeout` / `setTimeout`. + * This module is the single authority: the runtime, management API, and Dashboard + * import these numbers rather than restating them. + */ +export const DEFAULT_VISION_TIMEOUT_MS = 45_000; +export const MIN_VISION_TIMEOUT_MS = 1; +export const MAX_VISION_TIMEOUT_MS = 2_147_483_647; diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 90729a12d7..7646bd3c40 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -108,7 +108,7 @@ this document owns is which module holds which area and what invariant that area | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | -| Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend plus optional `webSearch.reasoning` and `vision.maxDescriptionsPerTurn`; the read and PUT-response payload reports model, backend, and the vision per-turn limit. Credentials live in the provider and OAuth stores instead. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-5.4-mini` + `gpt-5.6-luna`), so no client hard-codes a helper slug that a Codex release can invalidate. | +| Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend plus optional `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Credentials live in the provider and OAuth stores instead. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-5.4-mini` + `gpt-5.6-luna`), so no client hard-codes a helper slug that a Codex release can invalidate. | | Storage | `src/server/management/logs-usage-routes.ts` — `GET /api/storage`, `POST /api/storage/cleanup/preview` and `/api/storage/cleanup`, `GET /api/storage/trash`, `POST /api/storage/trash/restore`, and `GET/PUT /api/storage/cleanup-policy` plus `POST /api/storage/cleanup-policy/run`. `GET /api/storage/cleanup-policy/test-stream` and `GET /api/storage/trash/restore/test-stream` exist for progress-stream testing. Cleanup takes an explicit `mode`: `quarantine` moves to trash and is restorable, `permanent` is not. The caller must name the mode — there is no default that silently deletes. | | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | diff --git a/tests/sidecar-settings-vision-controls.test.ts b/tests/sidecar-settings-vision-controls.test.ts new file mode 100644 index 0000000000..34c2305184 --- /dev/null +++ b/tests/sidecar-settings-vision-controls.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { + DEFAULT_VISION_TIMEOUT_MS, + MAX_VISION_TIMEOUT_MS, + MIN_VISION_TIMEOUT_MS, + resolveMaxDescriptionsPerTurn, + resolveVisionTimeoutMs, +} from "../src/vision"; +import { ManagementRequest as Request } from "./helpers/management-auth"; + +async function getSidecarSettings(config: OcxConfig): Promise { + const url = new URL("http://localhost/api/sidecar-settings"); + const response = await handleManagementAPI(new Request(url), url, config); + if (!response) throw new Error("sidecar settings route did not handle GET"); + return response; +} + +async function putSidecarSettings( + config: OcxConfig, + body: Record, +): Promise { + const url = new URL("http://localhost/api/sidecar-settings"); + const response = await handleManagementAPI( + new Request(url, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + url, + config, + ); + if (!response) throw new Error("sidecar settings route did not handle PUT"); + return response; +} + +function emptyConfig(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "none", + providers: {}, + ...overrides, + } as OcxConfig; +} + +const FULL_VISION = { + enabled: true, + model: "gpt-5.6-luna", + backend: "openai" as const, + reasoning: "medium" as const, + maxDescriptionsPerTurn: 6, + timeoutMs: 30_000, +}; + +describe("sidecar-settings remaining vision controls", () => { + let previousHome: string | undefined; + let isolatedHome: string | undefined; + + function persistedVision(): Record | undefined { + const raw = JSON.parse(readFileSync(join(isolatedHome!, "config.json"), "utf8")) as { + visionSidecar?: Record; + }; + return raw.visionSidecar; + } + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedHome = mkdtempSync(join(tmpdir(), "ocx-sidecar-vision-controls-")); + process.env.OPENCODEX_HOME = isolatedHome; + }); + + afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (isolatedHome) rmSync(isolatedHome, { recursive: true, force: true }); + isolatedHome = undefined; + }); + + test("GET returns every Dashboard-managed Vision setting, including defaults", async () => { + const unset = await getSidecarSettings(emptyConfig()); + expect(unset.status).toBe(200); + expect((await unset.json() as { vision: Record }).vision).toMatchObject({ + enabled: true, + model: "gpt-5.4-mini", + reasoning: "low", + maxDescriptionsPerTurn: resolveMaxDescriptionsPerTurn(undefined), + timeoutMs: DEFAULT_VISION_TIMEOUT_MS, + }); + + const configured = await getSidecarSettings(emptyConfig({ + visionSidecar: { ...FULL_VISION, enabled: false }, + })); + expect((await configured.json() as { vision: Record }).vision).toMatchObject({ + enabled: false, + model: "gpt-5.6-luna", + backend: "openai", + reasoning: "medium", + maxDescriptionsPerTurn: 6, + timeoutMs: 30_000, + }); + }); + + test("PUT can update enabled, maxDescriptionsPerTurn, and timeoutMs independently", async () => { + const config = emptyConfig({ visionSidecar: { ...FULL_VISION } }); + + const enabled = await putSidecarSettings(config, { vision: { enabled: false } }); + expect(enabled.status).toBe(200); + expect((await enabled.json() as { vision: { enabled: boolean } }).vision.enabled).toBe(false); + expect(config.visionSidecar).toMatchObject({ + enabled: false, + model: "gpt-5.6-luna", + backend: "openai", + reasoning: "medium", + maxDescriptionsPerTurn: 6, + timeoutMs: 30_000, + }); + + const limit = await putSidecarSettings(config, { vision: { maxDescriptionsPerTurn: 3 } }); + expect(limit.status).toBe(200); + expect((await limit.json() as { vision: { maxDescriptionsPerTurn: number } }).vision.maxDescriptionsPerTurn).toBe(3); + expect(config.visionSidecar?.enabled).toBe(false); + expect(config.visionSidecar?.timeoutMs).toBe(30_000); + + const timeout = await putSidecarSettings(config, { vision: { timeoutMs: 12_000 } }); + expect(timeout.status).toBe(200); + expect((await timeout.json() as { vision: { timeoutMs: number } }).vision.timeoutMs).toBe(12_000); + expect(config.visionSidecar?.maxDescriptionsPerTurn).toBe(3); + expect(persistedVision()?.timeoutMs).toBe(12_000); + }); + + test("partial updates preserve fields that were not supplied", async () => { + const config = emptyConfig({ visionSidecar: { ...FULL_VISION } }); + const response = await putSidecarSettings(config, { vision: { reasoning: "high" } }); + expect(response.status).toBe(200); + expect(config.visionSidecar).toMatchObject({ + enabled: true, + model: "gpt-5.6-luna", + backend: "openai", + reasoning: "high", + maxDescriptionsPerTurn: 6, + timeoutMs: 30_000, + }); + }); + + test("PUT rejects invalid booleans, integers, and out-of-range timeoutMs without mutation", async () => { + const config = emptyConfig({ visionSidecar: { ...FULL_VISION } }); + const snapshot = { ...config.visionSidecar }; + + for (const vision of [ + { enabled: "true" }, + { enabled: 1 }, + { enabled: 0 }, + { enabled: null }, + { maxDescriptionsPerTurn: 0 }, + { maxDescriptionsPerTurn: -1 }, + { maxDescriptionsPerTurn: 1.5 }, + { maxDescriptionsPerTurn: "8" }, + { timeoutMs: 0 }, + { timeoutMs: -1 }, + { timeoutMs: 1.5 }, + { timeoutMs: "45000" }, + { timeoutMs: MIN_VISION_TIMEOUT_MS - 1 }, + { timeoutMs: MAX_VISION_TIMEOUT_MS + 1 }, + ]) { + const response = await putSidecarSettings(config, { vision }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: expect.any(String) }); + expect(config.visionSidecar).toEqual(snapshot); + } + }); + + test("disable then re-enable keeps model, backend, reasoning, timeout, and limit", async () => { + const config = emptyConfig({ visionSidecar: { ...FULL_VISION } }); + + const disabled = await putSidecarSettings(config, { vision: { enabled: false } }); + expect(disabled.status).toBe(200); + expect(config.visionSidecar).toMatchObject({ + enabled: false, + model: "gpt-5.6-luna", + backend: "openai", + reasoning: "medium", + maxDescriptionsPerTurn: 6, + timeoutMs: 30_000, + }); + + const enabled = await putSidecarSettings(config, { vision: { enabled: true } }); + expect(enabled.status).toBe(200); + expect((await enabled.json() as { vision: { enabled: boolean } }).vision.enabled).toBe(true); + // true is the default — drop the key so a disable/re-enable cycle does not rewrite the file. + expect("enabled" in (config.visionSidecar ?? {})).toBe(false); + expect(config.visionSidecar).toMatchObject({ + model: "gpt-5.6-luna", + backend: "openai", + reasoning: "medium", + maxDescriptionsPerTurn: 6, + timeoutMs: 30_000, + }); + expect(persistedVision()).toMatchObject({ + model: "gpt-5.6-luna", + backend: "openai", + reasoning: "medium", + maxDescriptionsPerTurn: 6, + timeoutMs: 30_000, + }); + }); + + test("an unrelated web-search save does not overwrite Vision settings", async () => { + const config = emptyConfig({ + visionSidecar: { ...FULL_VISION, enabled: false }, + webSearchSidecar: { model: "gpt-5.6-luna" }, + }); + const response = await putSidecarSettings(config, { + webSearch: { streamRoutedModelOutput: true }, + }); + expect(response.status).toBe(200); + expect(config.webSearchSidecar?.streamRoutedModelOutput).toBe(true); + expect(config.visionSidecar).toEqual({ ...FULL_VISION, enabled: false }); + }); + + test("timeoutMs validation reuses the runtime bounds rather than a second contract", async () => { + expect(resolveVisionTimeoutMs(undefined)).toBe(DEFAULT_VISION_TIMEOUT_MS); + expect(resolveVisionTimeoutMs(MIN_VISION_TIMEOUT_MS)).toBe(MIN_VISION_TIMEOUT_MS); + expect(resolveVisionTimeoutMs(MAX_VISION_TIMEOUT_MS)).toBe(MAX_VISION_TIMEOUT_MS); + expect(resolveVisionTimeoutMs(MIN_VISION_TIMEOUT_MS - 1)).toBe(DEFAULT_VISION_TIMEOUT_MS); + expect(resolveVisionTimeoutMs(MAX_VISION_TIMEOUT_MS + 1)).toBe(DEFAULT_VISION_TIMEOUT_MS); + + const config = emptyConfig(); + const minOk = await putSidecarSettings(config, { vision: { timeoutMs: MIN_VISION_TIMEOUT_MS } }); + expect(minOk.status).toBe(200); + const maxOk = await putSidecarSettings(config, { vision: { timeoutMs: MAX_VISION_TIMEOUT_MS } }); + expect(maxOk.status).toBe(200); + expect(config.visionSidecar?.timeoutMs).toBe(MAX_VISION_TIMEOUT_MS); + }); +}); diff --git a/tests/vision-anthropic.test.ts b/tests/vision-anthropic.test.ts index 2fbb3d141b..bfa7d02664 100644 --- a/tests/vision-anthropic.test.ts +++ b/tests/vision-anthropic.test.ts @@ -241,10 +241,12 @@ describe("Anthropic vision planning and management config", () => { ); expect(put.status).toBe(200); expect((await put.json()).vision).toEqual({ + enabled: true, model: "claude-sonnet-5", backend: "anthropic", reasoning: "low", maxDescriptionsPerTurn: 4, + timeoutMs: 45_000, }); expect(config.webSearchSidecar).toEqual({ model: "claude-search", backend: "anthropic", reasoning: "high" }); @@ -256,10 +258,12 @@ describe("Anthropic vision planning and management config", () => { const getBody = await get!.json() as Record; expect(getBody.webSearch).toEqual({ model: "claude-search", backend: "anthropic", streamRoutedModelOutput: false }); expect(getBody.vision).toEqual({ + enabled: true, model: "claude-sonnet-5", backend: "anthropic", reasoning: "low", maxDescriptionsPerTurn: 4, + timeoutMs: 45_000, }); const clear = await handleManagementAPI( @@ -277,7 +281,13 @@ describe("Anthropic vision planning and management config", () => { expect(clear.status).toBe(200); const clearBody = await clear.json() as Record; expect(clearBody.webSearch).toEqual({ model: "gpt-5.6-luna", streamRoutedModelOutput: false }); - expect(clearBody.vision).toEqual({ model: "gpt-5.4-mini", reasoning: "low", maxDescriptionsPerTurn: 4 }); + expect(clearBody.vision).toEqual({ + enabled: true, + model: "gpt-5.4-mini", + reasoning: "low", + maxDescriptionsPerTurn: 4, + timeoutMs: 45_000, + }); expect(config.webSearchSidecar).toEqual({ reasoning: "high" }); expect(config.visionSidecar).toEqual({ maxDescriptionsPerTurn: 4 }); diff --git a/tests/vision-cache.test.ts b/tests/vision-cache.test.ts index da8f4278fe..b2369bc00f 100644 --- a/tests/vision-cache.test.ts +++ b/tests/vision-cache.test.ts @@ -10,6 +10,10 @@ import { evictOldestVisionDescriptionForBudget, resetVisionDescriptionCache, resolveMaxDescriptionsPerTurn, + resolveVisionTimeoutMs, + DEFAULT_VISION_TIMEOUT_MS, + MAX_VISION_TIMEOUT_MS, + MIN_VISION_TIMEOUT_MS, setVisionDescriptionCache, setVisionDescriptionCacheLimitsForTests, shouldResolveOpenAiVisionSidecar, @@ -134,6 +138,17 @@ describe("vision description cache and per-turn cap", () => { expect(resolveMaxDescriptionsPerTurn(Number.NaN)).toBe(8); }); + test("normalizes vision timeoutMs to the runtime bounds", () => { + expect(resolveVisionTimeoutMs(undefined)).toBe(DEFAULT_VISION_TIMEOUT_MS); + expect(resolveVisionTimeoutMs(12_000)).toBe(12_000); + expect(resolveVisionTimeoutMs(MIN_VISION_TIMEOUT_MS)).toBe(MIN_VISION_TIMEOUT_MS); + expect(resolveVisionTimeoutMs(MAX_VISION_TIMEOUT_MS)).toBe(MAX_VISION_TIMEOUT_MS); + expect(resolveVisionTimeoutMs(0)).toBe(DEFAULT_VISION_TIMEOUT_MS); + expect(resolveVisionTimeoutMs(-1)).toBe(DEFAULT_VISION_TIMEOUT_MS); + expect(resolveVisionTimeoutMs(1.5)).toBe(DEFAULT_VISION_TIMEOUT_MS); + expect(resolveVisionTimeoutMs(MAX_VISION_TIMEOUT_MS + 1)).toBe(DEFAULT_VISION_TIMEOUT_MS); + }); + test("maxDescriptionsPerTurn=0 emits a cap marker without calling an executor", async () => { let calls = 0; globalThis.fetch = (async () => { calls += 1; return openaiSse("unexpected"); }) as typeof fetch; diff --git a/tests/vision-sidecar-timeout-bounds.test.ts b/tests/vision-sidecar-timeout-bounds.test.ts new file mode 100644 index 0000000000..8ab436537d --- /dev/null +++ b/tests/vision-sidecar-timeout-bounds.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test"; +import { + DEFAULT_MAX_DESCRIPTIONS_PER_TURN, + DEFAULT_VISION_TIMEOUT_MS, + MAX_VISION_TIMEOUT_MS, + MIN_VISION_TIMEOUT_MS, +} from "../src/vision"; +import { + VISION_MAX_DESCRIPTIONS_DEFAULT, + VISION_TIMEOUT_MS_DEFAULT, + VISION_TIMEOUT_MS_MAX, + VISION_TIMEOUT_MS_MIN, +} from "../gui/src/pages/dashboard-shared"; + +test("Dashboard timeout bounds are the runtime vision sidecar contract", () => { + expect(VISION_TIMEOUT_MS_MIN).toBe(MIN_VISION_TIMEOUT_MS); + expect(VISION_TIMEOUT_MS_MAX).toBe(MAX_VISION_TIMEOUT_MS); + expect(VISION_TIMEOUT_MS_DEFAULT).toBe(DEFAULT_VISION_TIMEOUT_MS); + expect(VISION_MAX_DESCRIPTIONS_DEFAULT).toBe(DEFAULT_MAX_DESCRIPTIONS_PER_TURN); +});