diff --git a/devlog/_plan/260807_untouched_bug_stack/110_context_window_controls.md b/devlog/_plan/260807_untouched_bug_stack/110_context_window_controls.md new file mode 100644 index 000000000..4e55a8598 --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/110_context_window_controls.md @@ -0,0 +1,201 @@ +# 110 — #1073: metadata 없는 프로바이더의 context window 를 GUI 에서 정할 수 없다 + +## 이슈가 요청한 것 + +`/models` 가 id 만 주는 프로바이더(`{"data":[{"id":"gpt-5.6-luna"}]}`)에서 routed +catalog 가 `128000 / 128000 / 115200` 보수 fallback 을 쓴다. 사용자는 그 라우트가 +350K 를 지원한다는 걸 알지만 Models GUI 로 지정할 방법이 없다. + +## 백엔드는 이미 동작한다 + +조사에서 확인된 사실이고, 이게 이 유닛의 범위를 결정한다. + +- `OcxProviderConfig.contextWindow` 와 `modelContextWindows` 는 이미 존재한다 + (`src/types.ts:1178`), provider 스키마가 `.passthrough()` 라 보존된다. +- `configuredContextWindow()` (`src/codex/catalog/provider-fetch.ts:528`) 가 + `modelContextWindows[id] ?? contextWindow` 로 명시값을 고르고, + `applyProviderConfigHints()` (`:551-575`) 가 upstream 값이 있으면 + `Math.min` 으로 낮추고 **없으면 설정값을 그대로 `contextWindow` 로 만든다.** +- `providerContextCaps` 는 별개다. 기존 값이 있을 때만 낮추고 없는 값을 만들지 + 않는다 (`src/providers/context-cap.ts:24`). + +즉 손으로 `config.json` 을 편집하면 오늘도 된다. 이슈는 **backend bug 가 아니라 +enhancement** 다. 라벨이 맞다. + +빠진 것은 두 개다: +- `GET /api/providers` 가 두 필드를 반환하지 않는다 + (`src/server/management/provider-routes.ts:243`) +- PATCH mask 가 두 필드를 인정하지 않는다 (`:90`) + +그래서 Models UI 에는 저장할 경로 자체가 없다. + +## 기여자 PR #1203 을 채택한다 + +[#1203](https://github.com/lidge-jun/opencodex/pull/1203) (`estelledc`, +`fix/1073-context-window-gui`, head `d648818cf`, 3 커밋, +469/-4, 16 파일). +조사 시점에는 `CONFLICTING` 이었으나 저자가 리베이스해서 지금은 `MERGEABLE`. + +접근이 옳다. **catalog derivation 코드를 건드리지 않고** management/API 와 UI +계층에서만 노출한다. `providerContextCaps` 의 ceiling 의미도 그대로 둔다. +6개 로케일과 문서, 스크린샷까지 갖췄다. + +재작성할 구조적 문제는 없다. 국소적인 보정과 테스트가 필요하다. + +### 보정 A — 여러 모델 draft 중 하나만 저장된다 + +`gui/src/pages/Models.tsx` 의 `saveContextSettings()`: + +```ts +const modelWindow = parseContextWindowDraft(contextModelDrafts[contextModelId] ?? ""); +... +if (contextModelId) { + body.modelContextWindows = { [contextModelId]: modelWindow }; +} +``` + +`contextModelDrafts` 는 **모든** 모델의 편집 내용을 들고 있는데 PATCH 에는 +현재 선택된 `contextModelId` 하나만 실린다. 사용자가 모델 A 를 고쳐 입력하고 +B 로 옮겨 고친 뒤 Apply 하면 A 의 변경이 조용히 사라진다. 오류도 경고도 없다. + +PR 의 테스트가 이 동작을 정상값으로 고정하고 있어서 더 나쁘다. + +**해결:** dirty 한 draft 를 전부 보낸다. 값이 바뀌지 않은 모델은 payload 에서 +빼서 불필요한 쓰기를 피한다. + +### 보정 B — 철회 + +첫 판은 "저장 성공 후 refresh 실패가 닫힌 모달의 오류 상태를 쓴다" 고 했다. +감사가 되돌렸고 맞다: `load()` (`gui/src/pages/Models.tsx:301-315`) 는 fetch +오류를 잡아 `false` 를 반환하며 **던지지 않는다.** 그리고 기여자의 3번째 커밋이 +이미 모달을 닫고 성공 피드백을 게시한 뒤에 `load(true)` 를 부른다. +PR 테스트(`gui/tests/models-empty-provider.test.tsx:263-275`)가 refresh 실패 후 +성공 상태 유지까지 검증한다. + +예외 경계를 정리하는 것 자체는 방어적으로 유효하지만, 없는 결함을 고쳤다고 +말할 수는 없다. 기준 10 은 "PR 에서 이미 충족" 으로 기록한다. + +### 보정 B' — 편집하지 않은 모델을 되돌리지 않는다 + +보정 A 를 "현재 `groups` 와 draft 를 비교해서 다르면 dirty" 로 구현하면 새 +결함이 생긴다. 모달이 열린 동안 폴링이나 다른 관리 요청이 모델 A 를 +64K → 96K 로 갱신했는데 사용자는 B 만 고친 경우, 최신 값과 낡은 draft 를 +비교하면 A 도 dirty 로 판정되어 64K 로 되돌린다. 사용자가 건드리지도 않은 +모델을 되돌리는 셈이다. + +**해결:** 두 조건을 **모두** 만족할 때만 보낸다 — 사용자가 그 필드를 건드렸고 +(`touched`), 값이 모달을 열 때의 스냅샷과 다르다. + +둘 중 하나만으로는 부족하다. `touched` 만 보면 "입력했다가 원래 값으로 +되돌린" 경우에 낡은 값을 보내서 그 사이 바뀐 값을 덮는다. 스냅샷 비교만 보면 +사용자가 건드리지도 않은 필드가 dirty 로 잡힌다. + +**provider default 도 같다.** 이게 감사가 두 번째로 잡은 것이다. 첫 구현은 +`contextWindow` 를 항상 payload 에 실었으므로, 모달이 열린 사이 다른 요청이 +default 를 256K → 300K 로 바꿨는데 사용자가 모델만 편집했다면 낡은 256K 가 +300K 를 되돌린다. default 에도 `touched` + 스냅샷 비교를 적용한다. + +모든 편집이 되돌려져 payload 가 비면 PATCH 자체를 보내지 않는다. + +### 보정 D — 기존 override 가 모델 목록에서 사라질 수 있다 + +live discovery 에서 빠지고 `providers..models` 에도 없는 모델에 +`modelContextWindows` 항목만 남아 있으면, draft 에는 들어가지만 +`contextModalModels` 에는 없어서 사용자가 그 값을 보거나 지울 수 없다. +목록에 `Object.keys(group.modelContextWindows ?? {})` 를 합친다. + +### 보정 E — `1e100` 이 정수 검증을 통과한다 + +`Number.isFinite(1e100) && Number.isInteger(1e100)` 는 참이다. management PATCH +(`src/server/management/provider-routes.ts:174,194`) 와 GUI 파서 +(`gui/src/pages/Models.tsx:373-379`) 가 둘 다 통과시킨다. 저장된 뒤 catalog 에 +거대한 수로 직렬화되면 downstream Codex 의 정수 타입이 카탈로그를 거부할 수 +있다. `Number.isSafeInteger` 로 좁힌다. + +### 보정 F — 번역 문서가 새 의미와 모순된다 + +ko/ja/ru/zh-cn 의 provider 문서가 두 필드를 여전히 "상한" 으로만 설명한다. +metadata 가 없을 때 값을 **공급**한다는 의미가 빠져 있어서, 비영어 사용자는 +#1073 의 해법을 정반대로 읽는다. + +### 보정 C — #1073 의 정확한 재현이 테스트에 없다 + +PR 은 management 영속화와 GUI 를 테스트하지만, 이슈가 신고한 그 경로 — +`{data:[{id:"gpt-5.6-luna"}]}` + 명시 350K → catalog `350000/350000/315000` — +를 단언하지 않는다. 구성 요소가 각각 검증돼도 조립된 결과는 별개다. + +`auto_compact_token_limit` 은 `min(floor(contextWindow * 0.9), maxInputTokens)` +(`src/codex/catalog/effort.ts:112`) 이므로 350000 → 315000. + +**단, 테스트를 하나로 쓰면 안 된다.** 감사가 잡은 P1 이다. `modelContextWindows` +로만 350K 를 주면 `?? prov.contextWindow` 를 지워도 per-model 값이 그대로 +선택되어 결과가 변하지 않는다. provider-wide fallback 결함을 놓치는 통과 전용 +테스트가 된다. + +두 케이스로 나눈다: + +1. `contextWindow: 350000` **만** (per-model 없음) → 350K. + `?? prov.contextWindow` 를 지우면 red. +2. provider default 와 **다른** `modelContextWindows[id]` → per-model 우선. + `modelRecordValue(...)` 를 지우면 red. + +fixture 에 `modelMaxInputTokens` 를 두지 않는다. 있으면 +`min(315000, maxInputTokens)` 가 되어 기대값이 달라진다. + +## 변경 파일 + +PR 커밋을 cherry-pick 해서 저작을 보존하고, 그 위에 보정 커밋을 얹는다. + +- `gui/src/pages/Models.tsx` — 보정 A, B', D, E(파서) +- `gui/src/i18n/{en,ko,ja,zh,de,ru}.ts` — no-op 피드백 문구 `models.contextUnchanged` +- `src/server/management/provider-routes.ts` — 보정 E +- `gui/tests/models-empty-provider.test.tsx` — 다중 모델 저장, 중간 refresh 케이스 +- `tests/management-provider-validation.test.ts` — unsafe integer 거부 +- `tests/codex-catalog.test.ts` — 보정 C 의 두 acceptance 테스트 +- `docs-site/src/content/docs/{ko,ja,ru,zh-cn}/reference/configuration/providers.md` — 보정 F + +## 수용 기준 + +1. id-only `/models` + `contextWindow: 350000` **만** → catalog 가 + `350000 / 350000 / 315000`. (fixture 에 `modelMaxInputTokens` 없음) +2. id-only `/models` + provider default 와 다른 `modelContextWindows[id]` → + per-model 값이 이긴다. +3. 설정 없는 id-only 모델은 그대로 `128000 / 128000 / 115200`. +4. upstream 이 64K 를 주면 configured 350K 가 있어도 64K 유지 (`Math.min` 방향). +5. 모델 A 와 B 를 각각 편집한 뒤 한 번의 Apply 로 **둘 다** PATCH 에 실린다. +6. 편집하지 않은 모델은 payload 에 없다. **모달이 열린 동안 A 의 persisted + 값이 바뀌어도** 사용자가 A 를 건드리지 않았으면 여전히 없다. + provider default 도 마찬가지 — 사용자가 default 를 건드리지 않았으면 + 중간에 갱신됐어도 `contextWindow` 가 payload 에 없다. +7. 입력했다가 원래 값으로 되돌린 필드는 payload 에 없다. 모든 편집이 되돌려지면 + PATCH 를 아예 보내지 않는다. +8. `1e100` 같은 unsafe integer 는 management PATCH 가 거부하고, GUI 도 인라인 + 오류를 띄우며 PATCH 를 보내지 않는다. +9. live discovery 에 없지만 `modelContextWindows` 에 있는 모델이 선택 목록에 뜬다. +10. (PR 에서 이미 충족) PATCH 성공 후 refresh 실패해도 성공 피드백 유지. +11. ko/ja/ru/zh-cn 의 provider 문서가 두 필드를 "상한" 만이 아니라 "메타데이터가 + 없을 때 값을 공급" 하는 의미까지 설명한다. `rg` 대조로 확인한다. +12. ablation — 전부 **실제 결함 형태**로 되돌려서 red 를 확인한다. 인위적으로 + 강한 mutant(예: touched 가드까지 제거)는 통과 근거가 되지 못한다: + - `configuredContextWindow` 에서 `?? prov.contextWindow` 제거 → 1 이 red. + - `modelRecordValue(prov.modelContextWindows, id)` 제거 → 2 가 red. + - 보정 A 를 되돌려 선택된 모델만 전송 → 5 가 red. + - **touched 가드는 유지한 채** 비교 대상만 스냅샷 → 라이브 `groups` 로 교체 + → 6·7 이 red. 이걸 잡으려면 "건드렸다가 되돌린 필드 + 그 사이 서버가 값을 + 바꿈" 시나리오가 필요하다. 사용자의 값이 양쪽 모두와 다른 케이스로는 + 두 비교가 같은 답을 내므로 탐지되지 않는다. + - 값 비교를 문자열 비교로 되돌리면 → 7 의 재포맷 케이스가 red. + provider default 와 per-model 이 별개 분기이므로 양쪽 다 케이스가 있어야 한다. + - default 검증을 무조건 실행하도록 되돌리면 → 8 의 untouched-unsafe 케이스가 red. + - management 의 `Number.isSafeInteger` 를 `Number.isInteger` 로 → 8 이 red. + - GUI 파서의 `Number.isSafeInteger` 를 되돌리면 → 8 의 GUI 절반이 red. + - `Object.keys(group.modelContextWindows ?? {})` 를 목록에서 제거 → 9 가 red. + +## GUI 게이트 + +이 PR 은 **실제로 GUI 를 바꾼다.** 저장소 게이트가 요구하는 UI 스크린샷을 +본문에 포함해야 하고, `gui` 언급을 피해서 우회하면 안 된다. PR #1203 이 이미 +`docs-site/public/pr-screenshots/1073-context-window-controls.jpg` 를 갖고 있으므로 +cherry-pick 하면 따라온다. + +`bun run lint:gui` 와 `bun run build:gui` 가 필요하므로 `cd gui && bun install` +선행. diff --git a/docs-site/public/pr-screenshots/1073-context-window-controls.jpg b/docs-site/public/pr-screenshots/1073-context-window-controls.jpg new file mode 100644 index 000000000..fe157cf92 Binary files /dev/null and b/docs-site/public/pr-screenshots/1073-context-window-controls.jpg differ diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index d2ac1dfc8..82b5d2cb6 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -60,8 +60,8 @@ namespace 付き combo または routing-profile alias はその namespace prefi | `models?` | `string[]` |シード/フォールバック モデルのリスト。 `liveModels: false` では、発見されたモデルはこれらのみです。 | | `liveModels?` | `boolean` |開始/同期時にライブ カタログをフェッチします (デフォルトは `true`)。カスタムプロバイダーは `${baseUrl}/models` を使用します。組み込みはレジストリ URL とフィルターを使用する場合があります。 | | `selectedModels?` | `string[]` |検出後のカタログ許可リスト。空でない場合は、それらの ID のみが公開されます。空または省略すると、検出されたすべてのモデルが公開されます。 | -| `contextWindow?` | `number` |プロバイダー全体の Codex に表示されるコンテキストの上限。より小さいライブメタデータが保持されます。 | -| `modelContextWindows?` | `Record` |モデルごとのコンテキストの上限。これらは `contextWindow` をオーバーライドし、より小さなライブ メタデータを生成することはありません。 | +| `contextWindow?` | `number` | アップストリームのメタデータが無い場合に使うプロバイダー全体のコンテキスト値。メタデータがある場合は上限として働き、より小さいライブ値をそのまま残します。Models ダッシュボードでは `providerContextCaps` とは別に設定します。 | +| `modelContextWindows?` | `Record` | モデルごとのコンテキスト値および上限。`contextWindow` より優先され、ウィンドウが不明なら設定値を使い、より小さいライブメタデータがあればそちらが優先されます。 | | `modelInputModalities?` | `Record` | `["text"]` や `["text", "image"]` などのモデルごとの入力ヒント。 | | `modelMaxInputTokens?` | `Record` |カタログの自動圧縮ヒントに使用されるモデルごとの正の最大入力制限。 | | `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 77ba9a95f..e10536e21 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -60,8 +60,8 @@ target도 selector로 재사용할 수 없습니다. raw account id와 email은 | `models?` | `string[]` | 시드/폴백 모델 목록입니다. `liveModels: false`이면 이 목록만 발견된 모델로 취급합니다. | | `liveModels?` | `boolean` | 시작 또는 동기화 시 라이브 카탈로그를 가져옵니다. 기본값은 `true`입니다. 사용자 지정 공급자는 `${baseUrl}/models`를 사용하고, 내장은 레지스트리 URL을 사용한 뒤 필터링할 수 있습니다. | | `selectedModels?` | `string[]` | 발견 후 카탈로그 허용 목록입니다. 값이 비어 있지 않으면 그 id만 노출하고, 비어 있거나 생략하면 발견된 모델을 모두 노출합니다. | -| `contextWindow?` | `number` | 공급자 전반의 Codex 표시 컨텍스트 상한입니다. 더 작은 라이브 메타데이터는 그대로 유지합니다. | -| `modelContextWindows?` | `Record` | 모델별 컨텍스트 상한입니다. 이 값은 `contextWindow`를 덮어쓰며, 더 작은 라이브 메타데이터를 절대 올리지 않습니다. | +| `contextWindow?` | `number` | 업스트림 메타데이터가 없을 때 쓰이는 공급자 전반의 컨텍스트 값입니다. 메타데이터가 있으면 상한으로 동작해 더 작은 라이브 값을 그대로 둡니다. Models 대시보드에서 `providerContextCaps`와 별도로 설정합니다. | +| `modelContextWindows?` | `Record` | 모델별 컨텍스트 값이자 상한입니다. `contextWindow`보다 우선하며, 창 크기를 알 수 없으면 설정값을 쓰고 더 작은 라이브 메타데이터가 있으면 그쪽을 따릅니다. | | `modelInputModalities?` | `Record` | `["text"]` 또는 `["text", "image"]` 같은 모델별 입력 힌트입니다. | | `modelMaxInputTokens?` | `Record` | 카탈로그 자동 압축 힌트에 쓰는 양수 모델별 최대 입력 한도입니다. | | `defaultMaxOutputTokens?` | `number` | 클라이언트가 `max_output_tokens`를 생략했을 때 쓰는 공급자 전반의 `openai-chat` 폴백입니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 4849f1d3a..4289d5aec 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -68,8 +68,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, these are the only discovered models. | | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | | `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. | -| `contextWindow?` | `number` | Provider-wide Codex-visible context cap. Smaller live metadata is retained. | -| `modelContextWindows?` | `Record` | Per-model context caps. These override `contextWindow` and never raise smaller live metadata. | +| `contextWindow?` | `number` | Provider-wide context fallback when upstream metadata is absent; otherwise a cap that retains smaller live metadata. The Models dashboard exposes this separately from `providerContextCaps`. | +| `modelContextWindows?` | `Record` | Per-model context fallbacks/caps. These override `contextWindow`: an unknown window uses the configured value, while smaller live metadata remains authoritative. | | `modelInputModalities?` | `Record` | Per-model input hints such as `["text"]` or `["text", "image"]`. | | `modelMaxInputTokens?` | `Record` | Positive per-model max input limits used for catalog auto-compaction hints. | | `defaultMaxOutputTokens?` | `number` | Provider-wide `openai-chat` fallback when the client omits `max_output_tokens`. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 99ab4fab1..0cde31c85 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -70,8 +70,8 @@ cross-route credential fallback не существует. Строки API GPT- | `models?` | `string[]` | Seed/fallback-список моделей. При `liveModels: false` это и есть единственный список обнаруженных моделей. | | `liveModels?` | `boolean` | Получать live-каталог на start/sync (по умолчанию `true`). Custom-провайдеры используют `${baseUrl}/models`; built-in могут использовать registry URL и дополнительно фильтровать результат. | | `selectedModels?` | `string[]` | Allowlist каталога после discovery. Непустой список показывает только эти id; пустой или отсутствующий показывает всё, что было обнаружено. | -| `contextWindow?` | `number` | Provider-wide context cap, видимый Codex. Более маленькая live-metadata сохраняется. | -| `modelContextWindows?` | `Record` | Context cap'ы по отдельным моделям. Они перекрывают `contextWindow` и никогда не поднимают более маленькую live-metadata. | +| `contextWindow?` | `number` | Значение контекста для всего провайдера, применяемое когда upstream не отдаёт metadata; при наличии metadata работает как cap и сохраняет более маленькое live-значение. Панель Models настраивает его отдельно от `providerContextCaps`. | +| `modelContextWindows?` | `Record` | Значения и cap'ы контекста по отдельным моделям. Перекрывают `contextWindow`: если окно неизвестно, берётся заданное значение, а более маленькая live-metadata остаётся авторитетной. | | `modelInputModalities?` | `Record` | Подсказки modality по модели, например `["text"]` или `["text", "image"]`. | | `modelMaxInputTokens?` | `Record` | Положительные лимиты max input по моделям, используемые для подсказок auto-compaction в каталоге. | | `defaultMaxOutputTokens?` | `number` | Provider-wide fallback для `openai-chat`, когда клиент не передал `max_output_tokens`. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index becca0706..00a34e126 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -59,8 +59,8 @@ pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex | `models?` | `string[]` | 种子/回退模型列表。配合 `liveModels: false` 时,这些就是唯一发现到的模型。 | | `liveModels?` | `boolean` | 启动/同步时获取实时目录(默认 `true`)。自定义提供者使用 `${baseUrl}/models`;内置项可能使用注册表 URL 并进行过滤。 | | `selectedModels?` | `string[]` | 发现之后的目录允许列表。非空时只暴露这些 id;为空或省略时则暴露全部发现到的模型。 | -| `contextWindow?` | `number` | 该提供者范围内、对 Codex 可见的上下文上限。会保留更小的实时元数据。 | -| `modelContextWindows?` | `Record` | 按模型设置的上下文上限。它们会覆盖 `contextWindow`,且绝不会抬高更小的实时元数据。 | +| `contextWindow?` | `number` | 上游缺少元数据时使用的提供者级上下文数值;有元数据时作为上限,保留更小的实时数值。Models 面板中与 `providerContextCaps` 分开设置。 | +| `modelContextWindows?` | `Record` | 按模型设置的上下文数值与上限。优先于 `contextWindow`:窗口未知时采用所配置的数值,而更小的实时元数据仍然优先。 | | `modelInputModalities?` | `Record` | 按模型设置的输入提示,例如 `["text"]` 或 `["text", "image"]`。 | | `modelMaxInputTokens?` | `Record` | 正数型、按模型设置的最大输入限制,用于目录自动压缩提示。 | | `defaultMaxOutputTokens?` | `number` | 当客户端省略 `max_output_tokens` 时,`openai-chat` 的提供者级回退值。 | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index bc23cf5a2..84bdf6eb8 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -465,6 +465,17 @@ export const de: Record = { "models.v2ThreadsInvalid": "Thread-Limit muss eine ganze Zahl >= 1 sein", "models.v2ThreadsApply": "Anwenden", "models.capValue": "Limit {value}", + "models.contextSettings": "Kontextfenster", + "models.contextSettingsTitle": "Kontextfenster — {provider}", + "models.contextDefault": "Anbieterstandard", + "models.contextModel": "Modell", + "models.contextModelOverride": "Modellüberschreibung", + "models.contextHint": "Wird verwendet, wenn Upstream-Metadaten fehlen; andernfalls begrenzt der Wert ein größeres gemeldetes Fenster. Leer lassen für automatische Erkennung.", + "models.contextAutomatic": "Automatische Erkennung", + "models.contextSaved": "Kontextfenster aktualisiert — gilt ab der nächsten Codex-Runde.", + "models.contextUnchanged": "Keine Änderungen am Kontextfenster zu speichern.", + "models.contextSaveFailed": "Kontextfenster konnten nicht gespeichert werden", + "models.contextInvalid": "Kontextfenster müssen positive ganze Zahlen sein", "models.contextCappedValue": "{value}-Limit", "models.setAll": "Alle setzen", "models.setAllHint": "Wendet das {value}-Kontext-Limit auf alle gerouteten Anbieter an. Native Anbieter bleiben unberührt.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c63a33495..7d1c7964e 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -484,6 +484,17 @@ export const en = { "models.v2ThreadsInvalid": "Thread limit must be an integer >= 1", "models.v2ThreadsApply": "Apply", "models.capValue": "Cap {value}", + "models.contextSettings": "Context windows", + "models.contextSettingsTitle": "Context windows — {provider}", + "models.contextDefault": "Provider default", + "models.contextModel": "Model", + "models.contextModelOverride": "Model override", + "models.contextHint": "Used when upstream metadata is missing; otherwise limits a larger reported window. Leave blank for automatic discovery.", + "models.contextAutomatic": "Automatic discovery", + "models.contextSaved": "Context windows updated — takes effect on the next Codex turn.", + "models.contextUnchanged": "No context window changes to save.", + "models.contextSaveFailed": "Failed to save context windows", + "models.contextInvalid": "Context windows must be positive whole numbers", "models.contextCappedValue": "{value} cap", "models.setAll": "Set all", "models.setAllHint": "Apply the {value} context cap to every routed provider. Native providers are unaffected.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 8bef10acc..b569245db 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -473,6 +473,17 @@ export const ja: Record = { "models.v2ThreadsInvalid": "スレッド上限は 1 以上の整数にしてください", "models.v2ThreadsApply": "適用", "models.capValue": "上限 {value}", + "models.contextSettings": "コンテキストウィンドウ", + "models.contextSettingsTitle": "コンテキストウィンドウ — {provider}", + "models.contextDefault": "プロバイダーのデフォルト", + "models.contextModel": "モデル", + "models.contextModelOverride": "モデル別の上書き", + "models.contextHint": "上流メタデータがない場合に使われ、メタデータがある場合は報告値の上限になります。自動検出に戻すには空欄にします。", + "models.contextAutomatic": "自動検出", + "models.contextSaved": "コンテキストウィンドウを更新しました — 次回の Codex ターンから有効です。", + "models.contextUnchanged": "保存するコンテキストウィンドウの変更はありません。", + "models.contextSaveFailed": "コンテキストウィンドウを保存できませんでした", + "models.contextInvalid": "コンテキストウィンドウは正の整数で指定してください", "models.contextCappedValue": "{value} 上限", "models.setAll": "すべて設定", "models.setAllHint": "{value} のコンテキスト上限をすべてのルーティング済みプロバイダーに適用します。ネイティブプロバイダーには影響しません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 4112cbd79..92918b62e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -476,6 +476,17 @@ export const ko: Record = { "models.v2ThreadsInvalid": "스레드 한도는 1 이상 정수여야 합니다", "models.v2ThreadsApply": "적용", "models.capValue": "{value} 제한", + "models.contextSettings": "컨텍스트 윈도우", + "models.contextSettingsTitle": "컨텍스트 윈도우 — {provider}", + "models.contextDefault": "프로바이더 기본값", + "models.contextModel": "모델", + "models.contextModelOverride": "모델별 재정의", + "models.contextHint": "업스트림 메타데이터가 없을 때 사용하며, 메타데이터가 있으면 더 큰 보고값의 상한으로 적용합니다. 자동 검색을 사용하려면 비워 두세요.", + "models.contextAutomatic": "자동 검색", + "models.contextSaved": "컨텍스트 윈도우가 업데이트되었습니다 — 다음 Codex 턴부터 적용됩니다.", + "models.contextUnchanged": "저장할 컨텍스트 윈도우 변경이 없습니다.", + "models.contextSaveFailed": "컨텍스트 윈도우를 저장하지 못했습니다", + "models.contextInvalid": "컨텍스트 윈도우는 양의 정수여야 합니다", "models.contextCappedValue": "{value} 제한", "models.setAll": "전체 적용", "models.setAllHint": "{value} 컨텍스트 상한을 라우팅된 모든 프로바이더에 적용합니다. 네이티브 프로바이더는 영향을 받지 않습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d38a236bc..b78d79add 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -478,6 +478,17 @@ export const ru: Record = { "models.v2ThreadsInvalid": "Лимит потоков должен быть целым числом >= 1", "models.v2ThreadsApply": "Применить", "models.capValue": "Лимит {value}", + "models.contextSettings": "Контекстные окна", + "models.contextSettingsTitle": "Контекстные окна — {provider}", + "models.contextDefault": "Значение провайдера", + "models.contextModel": "Модель", + "models.contextModelOverride": "Переопределение модели", + "models.contextHint": "Используется, если вышестоящие метаданные отсутствуют; иначе ограничивает большее заявленное окно. Оставьте поле пустым для автоматического определения.", + "models.contextAutomatic": "Автоматическое определение", + "models.contextSaved": "Контекстные окна обновлены — изменения вступят в силу на следующем ходе Codex.", + "models.contextUnchanged": "Нет изменений контекстных окон для сохранения.", + "models.contextSaveFailed": "Не удалось сохранить контекстные окна", + "models.contextInvalid": "Контекстные окна должны быть положительными целыми числами", "models.contextCappedValue": "Лимит {value}", "models.setAll": "Применить ко всем", "models.setAllHint": "Применяет лимит контекста {value} ко всем маршрутизируемым провайдерам. Нативные провайдеры не затрагиваются.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ba9ca825e..dff991dc6 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -473,6 +473,17 @@ export const zh: Record = { "models.v2ThreadsInvalid": "线程上限必须为 >= 1 的整数", "models.v2ThreadsApply": "应用", "models.capValue": "限制 {value}", + "models.contextSettings": "上下文窗口", + "models.contextSettingsTitle": "上下文窗口 — {provider}", + "models.contextDefault": "提供方默认值", + "models.contextModel": "模型", + "models.contextModelOverride": "模型覆盖值", + "models.contextHint": "上游缺少元数据时使用该值;上游已有元数据时,它只限制更大的报告值。留空则恢复自动发现。", + "models.contextAutomatic": "自动发现", + "models.contextSaved": "上下文窗口已更新 — 将在下一个 Codex 回合生效。", + "models.contextUnchanged": "没有需要保存的上下文窗口更改。", + "models.contextSaveFailed": "保存上下文窗口失败", + "models.contextInvalid": "上下文窗口必须为正整数", "models.contextCappedValue": "{value} 限制", "models.setAll": "全部设置", "models.setAllHint": "将 {value} 上下文上限应用到所有已路由的提供方。原生提供方不受影响。", diff --git a/gui/src/models-groups.ts b/gui/src/models-groups.ts index 193ae1e6a..e6cce7f5c 100644 --- a/gui/src/models-groups.ts +++ b/gui/src/models-groups.ts @@ -14,6 +14,8 @@ export interface ConfiguredProviderSummary { disabled?: boolean; liveModels?: boolean; models?: string[]; + contextWindow?: number; + modelContextWindows?: Record; discovery?: ProviderDiscoverySummary; } @@ -23,6 +25,8 @@ export interface ProviderModelGroup { native: boolean; liveModels: boolean; configuredModels: string[]; + contextWindow?: number; + modelContextWindows?: Record; discovery?: ProviderDiscoverySummary; } @@ -56,6 +60,8 @@ export function buildProviderModelGroups 0 && providerRows.every(row => row.native === true), liveModels: configured?.liveModels !== false, configuredModels: configured?.models ?? [], + contextWindow: configured?.contextWindow, + modelContextWindows: configured?.modelContextWindows, discovery: configured?.discovery, }; }) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 3c94b66a8..1872503f4 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -73,6 +73,22 @@ const SUBTITLE_TKEY: Record = { routing: "models.subtitle.routing", }; +/** + * Parse a context-window field: a number, `null` for "unset", or `undefined` when the text is + * not usable. Separators are cosmetic, so "64,000" and "64_000" and "64000" are one value. + * + * Safe-integer rather than integer: `Number.isInteger(1e100)` is true, the server rejects it, + * and accepting it here would turn a typo into a round-trip error instead of inline feedback. + * + * Module scope because it closes over nothing — rebuilding it every render is wasted work. + */ +function parseContextWindowDraft(raw: string): number | null | undefined { + const normalized = raw.replace(/[_,\s]/g, ""); + if (!normalized) return null; + const value = Number(normalized); + return Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + export default function Models({ apiBase }: { apiBase: string }) { /* * Tab state. The hash is the source of truth, so refresh, bookmark, and @@ -174,6 +190,24 @@ export default function Models({ apiBase }: { apiBase: string }) { const [customFormModalities, setCustomFormModalities] = useState(["text"]); const [customSaving, setCustomSaving] = useState(false); const [customError, setCustomError] = useState(""); + const [contextModalProvider, setContextModalProvider] = useState(null); + const [contextModalModels, setContextModalModels] = useState([]); + const [contextModelId, setContextModelId] = useState(""); + const [contextDefaultDraft, setContextDefaultDraft] = useState(""); + const [contextModelDrafts, setContextModelDrafts] = useState>({}); + // What the modal showed when it opened. Every payload decision compares against THIS, not + // against the live `groups`, because the 10s poll can refresh a value mid-modal: diffing + // against current state would mark an untouched field dirty and revert someone else's change. + const [contextSnapshot, setContextSnapshot] = useState<{ + contextWindow: number | null; + modelContextWindows: Record; + }>({ contextWindow: null, modelContextWindows: {} }); + // Which fields the USER typed into. Touch alone is not enough to send — a value typed and + // then restored is not a change — but it is what makes an untouched field ineligible. + const [contextTouchedModels, setContextTouchedModels] = useState>(new Set()); + const [contextDefaultTouched, setContextDefaultTouched] = useState(false); + const [contextSaving, setContextSaving] = useState(false); + const [contextError, setContextError] = useState(""); const [hoveredModel, setHoveredModel] = useState<{ namespaced: string; rect: DOMRect } | null>(null); const hoverTimerRef = useRef | null>(null); const [shadowCall, setShadowCall] = useState(null); @@ -342,6 +376,123 @@ export default function Models({ apiBase }: { apiBase: string }) { */ const catalogCountReady = models.length > 0 || catalogState.data !== undefined; + const openContextSettings = (group: ProviderModelGroup) => { + const modelIds = [...new Set([ + ...group.rows.map(model => model.id), + ...group.configuredModels, + // A model that vanished from live discovery can still hold an override. Without this it + // would sit in the drafts map, invisible in the picker, with no way to inspect or clear it. + ...Object.keys(group.modelContextWindows ?? {}), + ])].sort(); + const modelId = modelIds[0] ?? ""; + setContextModalProvider(group.provider); + setContextModalModels(modelIds); + setContextModelId(modelId); + const defaultDraft = group.contextWindow ? String(group.contextWindow) : ""; + const modelDrafts = Object.fromEntries( + Object.entries(group.modelContextWindows ?? {}) + .map(([model, window]) => [model, String(window)]), + ); + setContextDefaultDraft(defaultDraft); + setContextModelDrafts(modelDrafts); + // Canonical numbers, not the raw strings. "64,000" and "64_000" and "64000" are the same + // value, and comparing text would treat a reformat as an edit — then Apply would send a + // stale number over whatever changed while the modal was open. + setContextSnapshot({ + contextWindow: group.contextWindow ?? null, + modelContextWindows: Object.fromEntries( + Object.entries(group.modelContextWindows ?? {}).map(([model, window]) => [model, window]), + ), + }); + setContextTouchedModels(new Set()); + setContextDefaultTouched(false); + setContextError(""); + }; + + const selectContextModel = (modelId: string) => { + setContextModelId(modelId); + }; + + const saveContextSettings = async () => { + if (!contextModalProvider) return; + const providerWindow = parseContextWindowDraft(contextDefaultDraft); + const group = groups.find(candidate => candidate.provider === contextModalProvider); + if (!group) { + setContextError(t("models.contextSaveFailed")); + return; + } + + // A field is sent only when the user touched it AND its value actually differs from what + // the modal opened with. Both halves matter, and each one alone is wrong. + // + // Sending only the selected model — what this did before — silently dropped any model + // edited before switching the picker. No error, no warning, the value just did not save. + // + // Sending everything that differs from the LIVE state is wrong the other way: the 10s poll + // can refresh a field mid-modal, and a stale draft would then look dirty and revert a + // change the user never made. Comparing against the opening snapshot instead means a value + // typed and then restored sends nothing at all. + // Only validate the default when the user touched it. A malformed value inherited from a + // hand-edited config would otherwise block a save that never intended to touch it. + if (contextDefaultTouched && providerWindow === undefined) { + setContextError(t("models.contextInvalid")); + return; + } + const modelWindows: Record = {}; + for (const modelId of contextTouchedModels) { + const draft = contextModelDrafts[modelId] ?? ""; + const parsed = parseContextWindowDraft(draft); + if (parsed === undefined) { + setContextError(t("models.contextInvalid")); + return; + } + // Compare VALUES, not text. Retyping 64000 as "64,000" is not a change. + if (parsed === (contextSnapshot.modelContextWindows[modelId] ?? null)) continue; + modelWindows[modelId] = parsed; + } + const defaultChanged = contextDefaultTouched + && providerWindow !== contextSnapshot.contextWindow; + + // Nothing survived the comparison: every edit was reverted before Apply. Writing an + // unchanged payload would still stamp over concurrent edits. + if (!defaultChanged && Object.keys(modelWindows).length === 0) { + setContextModalProvider(null); + // Not "updated" — nothing was. Saying otherwise would be a small lie the user could + // act on, e.g. believing a value they typed and reverted had been written. + publishFeedback(true, t("models.contextUnchanged")); + return; + } + + setContextSaving(true); + setContextError(""); + try { + const body: Record = {}; + if (defaultChanged) body.contextWindow = providerWindow; + if (Object.keys(modelWindows).length > 0) body.modelContextWindows = modelWindows; + const response = await fetch( + `${apiBase}/api/providers?name=${encodeURIComponent(contextModalProvider)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + await readJsonOrThrow(response, t("models.contextSaveFailed")); + } catch (error) { + setContextError(error instanceof Error ? error.message : t("models.contextSaveFailed")); + return; + } finally { + setContextSaving(false); + } + + // Past the write boundary: the values ARE saved. A refresh that fails afterwards is a + // display problem, and reporting it through `contextError` would set an error on a modal + // that is already closed — invisible to the user, and it contradicts the success they just + // saw. Let the ordinary load error surface handle it. + setContextModalProvider(null); + publishFeedback(true, t("models.contextSaved")); + await load(true); + }; // One-shot default collapse. It stays an effect on `groups` so CACHED groups collapse // immediately on first paint, even when revalidation is slow or fails; moving it into @@ -783,6 +934,14 @@ export default function Models({ apiBase }: { apiBase: string }) { {t("models.active", { active: activeCount, total: rows.length })}
+ {!isNative && ( + + )} {!isNative && ( +
+ + {contextError && {contextError}} +

{t("models.contextHint")}

+ +
+ + + {contextModalModels.length > 0 && ( + <> +
+ {t("models.contextModel")} + { + setContextModelDrafts(current => ({ + ...current, + [contextModelId]: event.target.value, + })); + setContextTouchedModels(current => new Set(current).add(contextModelId)); + }} + disabled={contextSaving} + placeholder={t("models.contextAutomatic")} + /> + + + )} +
+ +
+ + +
+
+ + )} + {customModalOpen && (
; enabled: boolean }> = []; + const contextBodies: Array<{ + contextWindow: number | null; + modelContextWindows: Record; + }> = []; + let providerContextWindow: number | undefined = 256_000; + // `retired-model` is deliberately NOT in `ids` and not a configured model: it only exists as + // an override. Without merging the override keys into the picker it would be invisible and + // unclearable, and an assertion using `claude-opus` alone could not tell the difference. + let providerModelContextWindows: Record = { + "claude-opus": 64_000, + "retired-model": 72_000, + }; let failNext = false; let failCatalog = false; let modelFetches = 0; @@ -142,9 +154,22 @@ test("Models page combines final visibility, atomic actions, discovery status, a name: provider, liveModels: true, models: ids, + contextWindow: providerContextWindow, + modelContextWindows: providerModelContextWindows, discovery: { status: "failed", reason: "http", httpStatus: 401 }, }]); } + if (url.includes("/api/providers?name=") && init?.method === "PATCH") { + const body = JSON.parse(String(init.body)) as (typeof contextBodies)[number]; + contextBodies.push(body); + if (body.contextWindow === null) providerContextWindow = undefined; + else if (typeof body.contextWindow === "number") providerContextWindow = body.contextWindow; + for (const [model, value] of Object.entries(body.modelContextWindows ?? {})) { + if (value === null) delete providerModelContextWindows[model]; + else providerModelContextWindows = { ...providerModelContextWindows, [model]: value }; + } + return Response.json({ success: true }); + } if (url.endsWith("/api/selected-models")) return Response.json({ selected: { [provider]: selected }, available: { [provider]: ids } }); if (url.endsWith("/api/provider-context-caps")) return Response.json({ caps: {} }); if (url.endsWith("/api/combos")) return Response.json({ combos: [] }); @@ -196,6 +221,248 @@ test("Models page combines final visibility, atomic actions, discovery status, a expect(container.querySelector(".badge.badge-amber")?.textContent).toContain("Discovery failed"); expect(container.textContent).not.toContain("Not selected"); + await act(async () => buttonText("Context windows").click()); + const contextDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + const contextInputs = contextDialog.querySelectorAll("input"); + expect([...contextInputs].map(input => input.value)).toEqual(["256000", "64000"]); + const setValue = Object.getOwnPropertyDescriptor( + testWindow.HTMLInputElement.prototype, + "value", + )!.set!; + await act(async () => { + setValue.call(contextInputs[0]!, "350000"); + contextInputs[0]!.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + setValue.call(contextInputs[1]!, "100000"); + contextInputs[1]!.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + const pickContextModel = async (modelId: string, dialog: HTMLElement = contextDialog) => { + await act(async () => { + dialog.querySelector('button.select-trigger[aria-label="Model"]')!.click(); + }); + const option = [...testWindow.document.querySelectorAll('[role="option"]')] + .find(candidate => candidate.textContent === modelId)!; + await act(async () => option.click()); + }; + await pickContextModel("claude-sonnet"); + expect(contextInputs[1]!.value).toBe(""); + await act(async () => { + setValue.call(contextInputs[1]!, "80000"); + contextInputs[1]!.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await pickContextModel("claude-opus"); + expect(contextInputs[1]!.value).toBe("100000"); + await pickContextModel("claude-sonnet"); + expect(contextInputs[1]!.value).toBe("80000"); + // An override for a model that live discovery no longer returns must still be selectable, + // or the user can neither see nor clear it. + await pickContextModel("retired-model"); + expect(contextInputs[1]!.value).toBe("72000"); + await pickContextModel("claude-opus"); + const applyContext = [...contextDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")!; + await act(async () => { + applyContext.click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + // Both edits must survive. This assertion previously named only `claude-opus`, which + // pinned the defect as correct behaviour: the user typed 80000 into claude-sonnet, moved + // the picker to claude-opus, hit Apply, and the sonnet value vanished with no error. + // + // `gemini-pro` is absent because it was never typed into. The payload follows what the + // user TOUCHED, not what differs from current state — a poll refreshing an untouched + // model mid-modal must not make Apply revert it. + expect(contextBodies.at(-1)).toEqual({ + contextWindow: 350_000, + modelContextWindows: { "claude-opus": 100_000, "claude-sonnet": 80_000 }, + }); + expect(container.querySelector('[role="dialog"][aria-label="Context windows"]')).toBeNull(); + + await act(async () => buttonText("Context windows").click()); + const refreshFailureDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + failCatalog = true; + // Make an actual edit. Apply now compares against the values the modal opened with, so a + // reopened-and-untouched dialog sends nothing — which would leave this case asserting the + // refresh behaviour of a request that never happened. + const refreshFailureInput = refreshFailureDialog.querySelectorAll("input.input")[0]!; + await act(async () => { + setValue.call(refreshFailureInput, "360000"); + refreshFailureInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + [...refreshFailureDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies).toHaveLength(2); + expect(contextBodies.at(-1)).toEqual({ contextWindow: 360_000 }); + expect(container.querySelector('[role="dialog"][aria-label="Context windows"]')).toBeNull(); + expect(container.textContent).toContain("Context windows updated"); + failCatalog = false; + + // An edit that is typed and then restored is not a change — and neither is retyping the + // same number in a different shape. Comparing raw text instead of parsed values would + // treat "64,000" as an edit and stamp a stale number over whatever else moved. + await act(async () => buttonText("Context windows").click()); + const revertDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + const revertInput = revertDialog.querySelectorAll("input.input")[0]!; + const openingValue = revertInput.value; + await act(async () => { + setValue.call(revertInput, "999000"); + revertInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + setValue.call(revertInput, openingValue); + revertInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + [...revertDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies).toHaveLength(2); + expect(container.querySelector('[role="dialog"][aria-label="Context windows"]')).toBeNull(); + + await act(async () => buttonText("Context windows").click()); + const reformatDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + const reformatInput = reformatDialog.querySelectorAll("input.input")[0]!; + const commaFormatted = reformatInput.value.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + await act(async () => { + setValue.call(reformatInput, commaFormatted); + reformatInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + // The per-model branch has its own comparison, so exercise it too: a raw-string mutant + // reverted only there would otherwise slip past the provider-default case above. + await pickContextModel("claude-opus", reformatDialog); + const reformatModelInput = reformatDialog.querySelectorAll("input.input")[1]!; + await act(async () => { + setValue.call( + reformatModelInput, + reformatModelInput.value.replace(/\B(?=(\d{3})+(?!\d))/g, "_"), + ); + reformatModelInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + [...reformatDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies).toHaveLength(2); + + // A value the user never touched must not ride along, even after a real poll refreshed it. + // The poll has to actually run: mutating the mock alone leaves React's `groups` on the + // opening values, and then comparing drafts against LIVE state — the defect — would look + // identical to comparing against the snapshot. + await act(async () => buttonText("Context windows").click()); + const concurrentDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + providerContextWindow = 300_000; + providerModelContextWindows = { ...providerModelContextWindows, "claude-opus": 96_000 }; + await act(async () => { poll(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + // Edit ONLY claude-sonnet. The refreshed default and the refreshed claude-opus are both + // untouched, so neither may appear in the payload. + await pickContextModel("claude-sonnet", concurrentDialog); + const concurrentModelInput = concurrentDialog.querySelectorAll("input.input")[1]!; + await act(async () => { + setValue.call(concurrentModelInput, "70000"); + concurrentModelInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + [...concurrentDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies).toHaveLength(3); + expect(contextBodies.at(-1)).toEqual({ modelContextWindows: { "claude-sonnet": 70_000 } }); + + // The precise mutant this defends: keep the `touched` guard but compare against the LIVE + // `groups` instead of the opening snapshot. The cases above cannot see that swap, because + // in each of them the user's value genuinely differs from both. This one does — the user + // touches a field and puts it back, while the server moves underneath. + await act(async () => buttonText("Context windows").click()); + const staleDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + const staleDefaultInput = staleDialog.querySelectorAll("input.input")[0]!; + const staleOpeningDefault = staleDefaultInput.value; + await act(async () => { + setValue.call(staleDefaultInput, "111000"); + staleDefaultInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + setValue.call(staleDefaultInput, staleOpeningDefault); + staleDefaultInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await pickContextModel("claude-opus", staleDialog); + const staleModelInput = staleDialog.querySelectorAll("input.input")[1]!; + const staleOpeningModel = staleModelInput.value; + await act(async () => { + setValue.call(staleModelInput, "123000"); + staleModelInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + // Restored, and also reformatted — the value is unchanged either way. + setValue.call(staleModelInput, staleOpeningModel.replace(/\B(?=(\d{3})+(?!\d))/g, "_")); + staleModelInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + // Now the server moves both fields, and the poll lands while the modal is still open. + providerContextWindow = 411_000; + providerModelContextWindows = { ...providerModelContextWindows, "claude-opus": 88_000 }; + await act(async () => { poll(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + await act(async () => { + [...staleDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + // Nothing was written: both fields are back at what the modal opened with. Comparing + // against the refreshed `groups` would have called both dirty and reverted 411K and 88K. + expect(contextBodies).toHaveLength(3); + expect(container.textContent).toContain("No context window changes to save"); + + // A default the user never touches must not block a per-model save, even when the stored + // value is one the validator would reject. Validating it unconditionally would strand + // anyone whose config was hand-edited before the safe-integer bound existed. + providerContextWindow = 1e100; + await act(async () => { poll(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + await act(async () => buttonText("Context windows").click()); + const unsafeDefaultDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + await pickContextModel("claude-sonnet", unsafeDefaultDialog); + const unsafeSiblingInput = unsafeDefaultDialog.querySelectorAll("input.input")[1]!; + await act(async () => { + setValue.call(unsafeSiblingInput, "55000"); + unsafeSiblingInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + [...unsafeDefaultDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(contextBodies.at(-1)).toEqual({ modelContextWindows: { "claude-sonnet": 55_000 } }); + + // `Number.isInteger(1e100)` is true, and the server rejects it. Accepting it in the form + // would turn a typo into a round-trip error instead of inline feedback. + const patchesBeforeUnsafe = contextBodies.length; + await act(async () => buttonText("Context windows").click()); + const unsafeDialog = container.querySelector('[role="dialog"][aria-label="Context windows"]')!; + const unsafeInput = unsafeDialog.querySelectorAll("input.input")[0]!; + await act(async () => { + setValue.call(unsafeInput, "1e100"); + unsafeInput.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + [...unsafeDialog.querySelectorAll("button")] + .find(button => button.textContent === "Apply")! + .click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + // Relative, not absolute: an absolute count silently re-targets whenever a case is added + // above, and the property under test is "this Apply wrote nothing". + expect(contextBodies).toHaveLength(patchesBeforeUnsafe); + expect(container.querySelector('[role="dialog"][aria-label="Context windows"]')).not.toBeNull(); + // The modal staying open is not the point — the user has to be TOLD why. Without this the + // test passes on a silent no-op that looks identical to a hang. + expect(unsafeDialog.textContent).toContain("Context windows must be positive whole numbers"); + await act(async () => { + [...unsafeDialog.querySelectorAll("button")] + .find(button => button.textContent === "Cancel")?.click(); + }); + await act(async () => container.querySelector('button.select-trigger[aria-label="Shadow Call Intercept"]')?.click()); // The workspace Select portals its listbox to document.body, so the options are not inside // `container`. Query the document instead of the mount node. diff --git a/gui/tests/models-provider-head.test.ts b/gui/tests/models-provider-head.test.ts index 101906f17..bfa33beb0 100644 --- a/gui/tests/models-provider-head.test.ts +++ b/gui/tests/models-provider-head.test.ts @@ -30,3 +30,18 @@ test("Models workspace stacks via content-width container query before mobile dr // Mobile media rule retained for drawer layouts. expect(css).toContain("@media (max-width: 768px)"); }); + +test("Models exposes provider and per-model context-window controls (#1073)", async () => { + const page = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); + const groups = await Bun.file(new URL("../src/models-groups.ts", import.meta.url)).text(); + + expect(groups).toContain("contextWindow?: number"); + expect(groups).toContain("modelContextWindows?: Record"); + expect(page).toContain('t("models.contextSettings")'); + expect(page).toContain("modelContextWindows"); + expect(page).toMatch(/\/api\/providers\?name=.*method:\s*"PATCH"/s); + expect(page).toContain('className="models-context-fields"'); + + const css = await Bun.file(new URL("../src/styles-models-workspace.css", import.meta.url)).text(); + expect(css).toMatch(/\.models-context-fields\s*\{[^}]*gap:\s*var\(--space-4\)/s); +}); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 85c5a9400..d3f0fc704 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -165,6 +165,45 @@ function applyProviderPatchFields( next.liveModels = rawBody.liveModels; touched = true; } + // The Models page edits the catalog hints in place; keep them on the existing + // provider mutation path so validation, cache invalidation, and convergence stay unified (#1073). + if (Object.hasOwn(rawBody, "contextWindow")) { + const value = rawBody.contextWindow; + if (value === null) { + delete next.contextWindow; + // `Number.isInteger(1e100)` is true, so an integer check alone admits a value that + // serializes into the catalog as an enormous number and can make Codex reject the whole + // file. Safe-integer is the real bound for something that ends up in a JSON int field. + } else if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) { + next.contextWindow = value; + } else { + return { error: "contextWindow must be a positive safe integer or null" }; + } + touched = true; + } + if (Object.hasOwn(rawBody, "modelContextWindows")) { + const value = rawBody.modelContextWindows; + if (value === null) { + delete next.modelContextWindows; + } else { + if (!isPlainRecord(value)) return { error: "modelContextWindows must be a plain object or null" }; + const windows: Record = { ...(next.modelContextWindows ?? {}) }; + for (const [model, window] of Object.entries(value)) { + if (!model.trim()) return { error: "modelContextWindows keys must be nonblank model ids" }; + if (window === null) { + delete windows[model]; + continue; + } + if (typeof window !== "number" || !Number.isSafeInteger(window) || window <= 0) { + return { error: "modelContextWindows values must be positive safe integers or null" }; + } + windows[model] = window; + } + if (Object.keys(windows).length > 0) next.modelContextWindows = windows; + else delete next.modelContextWindows; + } + touched = true; + } // headers is the one object-valued field in the mask. PATCH semantics merge it // shallowly into the existing block so a single fingerprint header can be added @@ -249,6 +288,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; /** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */ modelInputModalities?: Record; diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 7e515f841..6e262535c 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2614,6 +2614,117 @@ describe("Codex catalog routed normalization", () => { expect(routed?.input_modalities).toEqual(["text", "image"]); }); + // #1073's exact reproduction: a provider whose /models returns nothing but ids. Two cases, + // deliberately not one — a single test that sets `modelContextWindows` would keep passing + // with the provider-wide `?? prov.contextWindow` fallback deleted, because the per-model + // value is chosen first. Each ablation needs its own oracle. + // + // No `modelMaxInputTokens` in either fixture: auto_compact_token_limit is + // min(floor(contextWindow * 0.9), maxInputTokens), so setting one would move the expectation. + test("an id-only /models honors the provider-wide contextWindow fallback (#1073)", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify({ data: [{ id: "gpt-5.6-luna" }] }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "sub2api", + providers: { + sub2api: { + adapter: "openai-chat", + baseUrl: "https://sub2api.test/v1", + apiKey: "sk-test", + contextWindow: 350_000, + }, + }, + }); + const routed = buildCatalogEntries(nativeTemplate(), [], models) + .find(e => e.slug === "sub2api/gpt-5.6-luna"); + + expect(routed?.context_window).toBe(350_000); + expect(routed?.max_context_window).toBe(350_000); + expect(routed?.auto_compact_token_limit).toBe(315_000); + }); + + test("a per-model contextWindow outranks the provider-wide one (#1073)", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify({ data: [{ id: "gpt-5.6-luna" }, { id: "other-model" }] }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "sub2api", + providers: { + sub2api: { + adapter: "openai-chat", + baseUrl: "https://sub2api.test/v1", + apiKey: "sk-test", + contextWindow: 256_000, + modelContextWindows: { "gpt-5.6-luna": 350_000 }, + }, + }, + }); + const entries = buildCatalogEntries(nativeTemplate(), [], models); + + expect(entries.find(e => e.slug === "sub2api/gpt-5.6-luna")?.context_window).toBe(350_000); + // The model without an override still gets the provider default, which is what makes this + // a comparison rather than a restatement of the previous test. + expect(entries.find(e => e.slug === "sub2api/other-model")?.context_window).toBe(256_000); + }); + + test("an id-only model with no configured window keeps the conservative fallback (#1073)", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify({ data: [{ id: "gpt-5.6-luna" }] }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "sub2api", + providers: { + sub2api: { + adapter: "openai-chat", + baseUrl: "https://sub2api.test/v1", + apiKey: "sk-test", + }, + }, + }); + const routed = buildCatalogEntries(nativeTemplate(), [], models) + .find(e => e.slug === "sub2api/gpt-5.6-luna"); + + expect(routed?.context_window).toBe(128_000); + expect(routed?.max_context_window).toBe(128_000); + expect(routed?.auto_compact_token_limit).toBe(115_200); + }); + + test("upstream metadata smaller than the configured window wins (#1073)", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify({ data: [{ id: "gpt-5.6-luna", context_length: 64_000 }] }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "sub2api", + providers: { + sub2api: { + adapter: "openai-chat", + baseUrl: "https://sub2api.test/v1", + apiKey: "sk-test", + contextWindow: 350_000, + }, + }, + }); + const routed = buildCatalogEntries(nativeTemplate(), [], models) + .find(e => e.slug === "sub2api/gpt-5.6-luna"); + + // The configured value supplies capacity when upstream has none; it never inflates a + // capacity upstream actually reported. + expect(routed?.context_window).toBe(64_000); + }); + test("liveModels false preserves configured catalog metadata without live fetch", async () => { let fetchCalls = 0; globalThis.fetch = (() => { diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index e850d3f84..884a955bc 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -1973,6 +1973,97 @@ describe("provider management validation", () => { // Unknown-only bodies are rejected. expect((await patch("extra", { bogus: 1 }))?.status).toBe(400); }); + + test("provider management exposes and persists context-window hints for Models GUI (#1073)", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalDirect }, + relay: { + adapter: "openai-chat", + baseUrl: "https://relay.example.test/v1", + apiKey: "sk-existing", + models: ["wide", "narrow"], + contextWindow: 256_000, + modelContextWindows: { narrow: 64_000 }, + }, + }, + }; + saveConfig(liveConfig); + + const request = async (method: "GET" | "PATCH", body?: unknown) => { + const req = new Request("http://127.0.0.1/api/providers?name=relay", { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(() => {}), + }); + }; + + const listed = await request("GET"); + expect(listed?.status).toBe(200); + const rows = await listed!.json() as Array<{ + name: string; + contextWindow?: number; + modelContextWindows?: Record; + }>; + expect(rows.find(row => row.name === "relay")).toMatchObject({ + contextWindow: 256_000, + modelContextWindows: { narrow: 64_000 }, + }); + + const updated = await request("PATCH", { + contextWindow: 350_000, + modelContextWindows: { wide: 350_000 }, + }); + expect(updated?.status).toBe(200); + expect(liveConfig.providers.relay).toMatchObject({ + contextWindow: 350_000, + modelContextWindows: { wide: 350_000, narrow: 64_000 }, + }); + expect(loadConfig().providers.relay).toMatchObject({ + contextWindow: 350_000, + modelContextWindows: { wide: 350_000, narrow: 64_000 }, + }); + + for (const invalid of [ + { contextWindow: 0 }, + { contextWindow: 1.5 }, + // `Number.isInteger(1e100)` is true, so an integer check alone lets this through. It + // would then serialize into the catalog as an enormous number and can make Codex reject + // the whole file — the failure surfaces far from the PATCH that caused it. + { contextWindow: 1e100 }, + { modelContextWindows: { wide: 1e100 } }, + { modelContextWindows: { "": 100_000 } }, + { modelContextWindows: { wide: -1 } }, + ]) { + expect((await request("PATCH", invalid))?.status).toBe(400); + } + expect(liveConfig.providers.relay).toMatchObject({ + contextWindow: 350_000, + modelContextWindows: { wide: 350_000, narrow: 64_000 }, + }); + + expect((await request("PATCH", { modelContextWindows: { wide: null } }))?.status).toBe(200); + expect(liveConfig.providers.relay.modelContextWindows).toEqual({ narrow: 64_000 }); + + const cleared = await request("PATCH", { + contextWindow: null, + modelContextWindows: null, + }); + expect(cleared?.status).toBe(200); + expect(liveConfig.providers.relay.contextWindow).toBeUndefined(); + expect(liveConfig.providers.relay.modelContextWindows).toBeUndefined(); + }); + test("provider PATCH manages custom headers with merge and clear semantics", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true });