diff --git a/docs-site/public/pr-screenshots/mobile-auth-model-picker-order.png b/docs-site/public/pr-screenshots/mobile-auth-model-picker-order.png new file mode 100644 index 0000000000..84dee8454e Binary files /dev/null and b/docs-site/public/pr-screenshots/mobile-auth-model-picker-order.png differ diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index 696f631a58..25517ced51 100644 --- a/docs-site/src/content/docs/guides/model-ordering.md +++ b/docs-site/src/content/docs/guides/model-ordering.md @@ -1,11 +1,12 @@ --- title: Model Ordering -description: How opencodex determines model order in the Codex picker and spawn_agent model overrides. +description: How opencodex determines routed-model order in Codex and Claude pickers and spawn_agent model overrides. --- -The Codex model picker does not preserve the order of provider declarations or model arrays in the -opencodex configuration. Its final order comes from catalog priorities, with a deterministic -alphabetical order for routed models that share the same priority. +Codex does not preserve the order of provider declarations or model arrays in the opencodex +configuration. Its final order comes from catalog priorities, with a deterministic alphabetical +order for routed models that share the same priority. Claude receives the same routed-model order +from its model-discovery response. ## The rule Codex applies @@ -23,13 +24,12 @@ priorities `i * N + j`, where `j` is the selector's zero-based position; a route rows are moved outside those selector groups. Codex still advertises only the first five picker-visible rows. -The relevant no-selector priorities are: +When `modelPickerOrder` is unset, the relevant no-selector priorities are: | Catalog entry | Priority | Source | | --- | ---: | --- | | `subagentModels[i]` | `i` (`0` through `4`) | The featured rank map in `src/codex/catalog/sync.ts` | | Other routed models | `5` | Routed entry creation in `src/codex/catalog/sync.ts` | -| Non-featured routed models listed in `modelPickerOrder` | `1000 + i` | Display-only picker rank in `src/codex/catalog/sync.ts` | | Native GPT slugs by default | `9` | Native entry creation in `src/codex/catalog/sync.ts` | | Unselected native models while a featured list exists | At least `featured.length + 100` | Native catalog merge in `src/codex/catalog/sync.ts` | @@ -66,7 +66,8 @@ only change whether a model is included. ## Effective picker pattern -With no eligible account selectors and a non-empty featured list, the resulting order is: +When `modelPickerOrder` is unset, no eligible account selectors are present, and the featured list +is non-empty, the resulting Codex order is: 1. Models in the exact configured `subagentModels` order, with priorities `0` through `4`. 2. All remaining routed models, ordered alphabetically by provider and then model id, at priority `5`. @@ -117,8 +118,8 @@ if it saves the roster. Use at most five configured ids. With account selectors, choice can expand into multiple selector-qualified catalog rows, so configured choices and advertised rows are not necessarily one-to-one. -Use `modelPickerOrder` for display-only ordering of routed `/` rows beyond that -featured block: +Use `modelPickerOrder` for display-only ordering of routed `/` rows in the Codex +and Claude model pickers: ```json { @@ -130,13 +131,17 @@ featured block: } ``` +The dashboard's **Models** page can write this setting for all currently visible routed models. +Choose **Default** to keep the normal provider/model order, **A–Z** to sort by model id, +**Provider** to group by provider, or **Most used** to rank by all recorded request history. The +dashboard fetches usage only when you apply the Most used order. + Listed routed rows appear in the configured order. A routed row omitted from the array keeps its normal priority, so it remains ahead of the `modelPickerOrder` display band; list every routed row -whose relative position you want to control. A row also present in `subagentModels` keeps its -featured priority. Bare native and account-qualified native rows are not reordered by -`modelPickerOrder`; use `subagentModels` for those rows. - -`modelPickerOrder` never changes the `spawn_agent` candidate set. It changes only the -Codex-visible picker priority while opencodex retains each moved row's natural priority for -sub-agent selection. `disabledModels` and each provider's `selectedModels` remain visibility fields, -not ordering controls. There is no separate `modelOrder`, `providerOrder`, or priority-map setting. +whose relative position you want to control. Native and account-qualified native rows are not +reordered by `modelPickerOrder`. + +`modelPickerOrder` never changes the `spawn_agent` candidate set. It changes only harness-visible +picker order while opencodex retains each moved row's natural priority for sub-agent selection. +`disabledModels` and each provider's `selectedModels` remain visibility fields, not ordering +controls. There is no separate `modelOrder`, `providerOrder`, or priority-map setting. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 94ce7e9103..cb64b11470 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -526,7 +526,7 @@ export const de: Record = { "models.setAllHint": "Schaltet das Standardfenster {value} für alle gerouteten Anbieter ein. Fehlen context_window / context_length, wird dieser Wert das tatsächliche Codex-Fenster. Für ein einzelnes Modell nutzen Sie «Eigene Fenster» in derselben Zeile. Native Anbieter bleiben unberührt.", "models.collapseAll": "Alle einklappen", "models.expandAll": "Alle ausklappen", - "models.orderHint": "Reihenfolge in der Modellauswahl: Subagents-Auswahl (in der festgelegten Reihenfolge) → übrige geroutete Modelle alphabetisch nach Anbieter, dann Modell-ID → native Modelle. Sichtbarkeitsschalter filtern nur; sie ändern diese Reihenfolge nicht.", + "models.orderHint": "Reihenfolge in der Modellauswahl: Gilt für geroutete Modelle in Codex und Claude. Sichtbarkeitsschalter filtern nur; sie ändern diese Reihenfolge nicht.", "models.custom": "Benutzerdefiniert…", "models.customApply": "Anwenden", "models.customPlaceholder": "Tokens (z. B. 420000)", @@ -2246,6 +2246,18 @@ export const de: Record = { "claudeDesktop.effort.supported": "effort", "claudeDesktop.effort.displayOnly": "effort (nur Anzeige)", "sub.settings": "Einstellungen", + "models.pickerOrder.title": "Reihenfolge der Modellauswahl", + "models.pickerOrder.hint": "Steuert die Reihenfolge weitergeleiteter Modelle in den Auswahlen von Codex und Claude.", + "models.pickerOrder.label": "Weitergeleitete Modelle sortieren", + "models.pickerOrder.default": "Standard", + "models.pickerOrder.alphabetical": "A–Z nach Modell", + "models.pickerOrder.provider": "Nach Anbieter", + "models.pickerOrder.mostUsed": "Am häufigsten verwendet", + "models.pickerOrder.custom": "Benutzerdefinierte Reihenfolge (Konfiguration)", + "models.pickerOrder.apply": "Anwenden", + "models.pickerOrder.applying": "Wird angewendet…", + "models.pickerOrder.saved": "Reihenfolge der Modellauswahl gespeichert. Gilt, wenn Codex oder Claude die Modellliste aktualisiert.", + "models.pickerOrder.usageFailed": "Modellnutzung konnte nicht geladen werden", "sub.sections": "Subagent-Abschnitte", "sub.delegation.model": "Zuerst aufgerufenes Modell", "sub.delegation.modelHint": "Das Modell, zu dem Codex zuerst greift, wenn es Arbeit übergibt. Oben steht, wen es überhaupt aufrufen darf; hier wählst du den Ersten davon.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 917aba8e2b..d2184dd752 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -550,7 +550,7 @@ export const en = { "models.setAllHint": "Turn on the {value} default window for every routed provider. Relays that omit context_window / context_length get this as the actual Codex window. Use Custom windows on a provider row to set one model by hand. Native providers are unaffected.", "models.collapseAll": "Collapse all", "models.expandAll": "Expand all", - "models.orderHint": "Picker order: Subagents picks (in the selected order) → remaining routed models alphabetically by provider, then model ID → native models. Visibility switches only filter models; they do not change this order.", + "models.orderHint": "Model picker order: applies to routed models in Codex and Claude. Visibility switches only filter models; they do not change this order.", "models.custom": "Custom…", "models.customApply": "Apply", "models.customPlaceholder": "Tokens (e.g. 420000)", @@ -623,6 +623,18 @@ export const en = { "sub.models": "Models", "sub.search": "Search models (native gpt + routed)…", "sub.settings": "Settings", + "models.pickerOrder.title": "Model picker order", + "models.pickerOrder.hint": "Controls routed-model order in Codex and Claude pickers.", + "models.pickerOrder.label": "Order routed models", + "models.pickerOrder.default": "Default (provider, then model)", + "models.pickerOrder.alphabetical": "A–Z by model name", + "models.pickerOrder.provider": "Group by provider", + "models.pickerOrder.mostUsed": "Most used", + "models.pickerOrder.custom": "Custom order (config)", + "models.pickerOrder.apply": "Apply", + "models.pickerOrder.applying": "Applying…", + "models.pickerOrder.saved": "Model picker order saved. Applies when Codex or Claude refreshes its model list.", + "models.pickerOrder.usageFailed": "Failed to load model usage", "sub.sections": "Subagent sections", "sub.delegation.model": "Model to call first", "sub.delegation.modelHint": "The model Codex reaches for first when it hands off work. Featured above is the list it may call; this is the one it calls first.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index c7ad8b6ee5..1c10dfa12b 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -535,7 +535,7 @@ export const fr: Record = { "models.setAllHint": "Active la fenêtre par défaut {value} pour chaque fournisseur routé. Si un relais omet context_window / context_length, cette valeur devient la fenêtre Codex réelle. Pour un seul modèle, utilisez « Fenêtres perso » sur la même ligne. Les fournisseurs natifs ne sont pas affectés.", "models.collapseAll": "Tout réduire", "models.expandAll": "Tout développer", - "models.orderHint": "Ordre du sélecteur : choix des sous-agents (dans l’ordre sélectionné) → autres modèles routés, classés par ordre alphabétique du fournisseur puis par ID de modèle → modèles natifs. Les options de visibilité ne font que filtrer les modèles ; elles ne modifient pas cet ordre.", + "models.orderHint": "Ordre du sélecteur de modèles : s’applique aux modèles routés dans Codex et Claude. Les options de visibilité ne font que filtrer les modèles ; elles ne modifient pas cet ordre.", "models.custom": "Personnalisé…", "models.customApply": "Appliquer", "models.customPlaceholder": "Jetons (p. ex. 420000)", @@ -606,6 +606,18 @@ export const fr: Record = { "sub.models": "Modèles", "sub.search": "Rechercher des modèles (gpt natifs + routés)…", "sub.settings": "Paramètres", + "models.pickerOrder.title": "Ordre du sélecteur de modèles", + "models.pickerOrder.hint": "Contrôle l’ordre des modèles routés dans les sélecteurs Codex et Claude.", + "models.pickerOrder.label": "Trier les modèles routés", + "models.pickerOrder.default": "Par défaut", + "models.pickerOrder.alphabetical": "A–Z par modèle", + "models.pickerOrder.provider": "Par fournisseur", + "models.pickerOrder.mostUsed": "Les plus utilisés", + "models.pickerOrder.custom": "Ordre personnalisé (configuration)", + "models.pickerOrder.apply": "Appliquer", + "models.pickerOrder.applying": "Application…", + "models.pickerOrder.saved": "Ordre du sélecteur de modèles enregistré. S’applique lorsque Codex ou Claude actualise sa liste de modèles.", + "models.pickerOrder.usageFailed": "Échec du chargement de l'utilisation des modèles", "sub.sections": "Sections des sous-agents", "sub.delegation.model": "Modèle à appeler en premier", "sub.delegation.modelHint": "Le modèle que Codex sollicite en premier lorsqu’il délègue une tâche. La liste À la une ci-dessus contient les modèles qu’il peut appeler ; celui-ci est appelé en premier.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c26bfc7902..f5e99a5ce1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -534,7 +534,7 @@ export const ja: Record = { "models.setAllHint": "すべてのルーティング済みプロバイダーに {value} のデフォルトウィンドウをオンにします。中継が context_window / context_length を返さない場合、この値が実際の Codex ウィンドウになります。1 モデルだけ手で書くときは同じ行の「カスタムウィンドウ」を使います。ネイティブプロバイダーには影響しません。", "models.collapseAll": "すべて折りたたむ", "models.expandAll": "すべて展開", - "models.orderHint": "ピッカーの順序: サブエージェントの選択(選択順) → 残りのルーティングモデルはプロバイダー別、次にモデル ID 別のアルファベット順 → ネイティブモデル。表示切り替えはモデルをフィルタするだけで、この順序は変更しません。", + "models.orderHint": "モデルピッカーの順序: Codex と Claude のルーティングモデルに適用されます。表示切り替えはモデルをフィルタするだけで、この順序は変更しません。", "models.custom": "カスタム…", "models.customApply": "適用", "models.customPlaceholder": "トークン (例: 420000)", @@ -2267,6 +2267,18 @@ export const ja: Record = { "pws.tokenInput": "Input", "pws.tokenOutput": "Output", "sub.settings": "設定", + "models.pickerOrder.title": "モデルピッカーの順序", + "models.pickerOrder.hint": "Codex と Claude のピッカーにあるルーティング済みモデルの順序を制御します。", + "models.pickerOrder.label": "ルーティング済みモデルの順序", + "models.pickerOrder.default": "デフォルト", + "models.pickerOrder.alphabetical": "モデル名で A–Z", + "models.pickerOrder.provider": "プロバイダー別", + "models.pickerOrder.mostUsed": "使用頻度順", + "models.pickerOrder.custom": "カスタム順序(設定)", + "models.pickerOrder.apply": "適用", + "models.pickerOrder.applying": "適用中…", + "models.pickerOrder.saved": "モデルピッカーの順序を保存しました。Codex または Claude がモデル一覧を更新すると適用されます。", + "models.pickerOrder.usageFailed": "モデル使用状況を読み込めませんでした", "sub.sections": "サブエージェントのセクション", "sub.delegation.model": "最初に呼ぶモデル", "sub.delegation.modelHint": "Codex が作業を任せるとき、最初に呼ぶモデルです。上のおすすめが呼べる候補で、ここで選んだものがその中の第一候補になります。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 709bfeb3ca..8b9954d048 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -536,7 +536,7 @@ export const ko: Record = { "models.setAllHint": "라우팅된 모든 프로바이더에 {value} 기본 창을 켭니다. 중계가 context_window / context_length 를 주지 않으면 이 값이 실제 Codex 창이 됩니다. 모델 하나만 손으로 쓰려면 같은 줄의 「사용자 지정 창」을 쓰세요. 네이티브 프로바이더는 영향을 받지 않습니다.", "models.collapseAll": "모두 접기", "models.expandAll": "모두 펼치기", - "models.orderHint": "피커 순서: Subagents에서 지정한 순서 → 나머지 라우팅 모델(프로바이더, 모델 ID 순 알파벳 정렬) → 네이티브 모델. 노출 토글은 모델을 필터링할 뿐 이 순서를 바꾸지 않습니다.", + "models.orderHint": "모델 피커 순서: Codex와 Claude의 라우팅 모델에 적용됩니다. 노출 토글은 모델을 필터링할 뿐 이 순서를 바꾸지 않습니다.", "models.custom": "직접 입력…", "models.customApply": "적용", "models.customPlaceholder": "토큰 (예: 420000)", @@ -609,6 +609,18 @@ export const ko: Record = { "sub.models": "모델", "sub.search": "모델 검색(네이티브 gpt + 라우팅)…", "sub.settings": "설정", + "models.pickerOrder.title": "모델 선택기 순서", + "models.pickerOrder.hint": "Codex와 Claude 선택기에서 라우팅된 모델 순서를 제어합니다.", + "models.pickerOrder.label": "라우팅된 모델 순서", + "models.pickerOrder.default": "기본값", + "models.pickerOrder.alphabetical": "모델별 A–Z", + "models.pickerOrder.provider": "공급자별", + "models.pickerOrder.mostUsed": "가장 많이 사용됨", + "models.pickerOrder.custom": "사용자 지정 순서(구성)", + "models.pickerOrder.apply": "적용", + "models.pickerOrder.applying": "적용 중…", + "models.pickerOrder.saved": "모델 선택기 순서를 저장했습니다. Codex 또는 Claude가 모델 목록을 새로 고치면 적용됩니다.", + "models.pickerOrder.usageFailed": "모델 사용량을 불러오지 못했습니다", "sub.sections": "서브에이전트 구역", "sub.delegation.model": "먼저 부를 모델", "sub.delegation.modelHint": "Codex가 일을 나눠 맡길 때 가장 먼저 부를 모델입니다. 위 추천 목록이 부를 수 있는 후보라면, 여기서 고른 모델이 그중 1순위가 됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index bc699216e9..c1c7cb21b1 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -539,7 +539,7 @@ export const ru: Record = { "models.setAllHint": "Включает окно по умолчанию {value} для всех маршрутизируемых провайдеров. Если релей не отдаёт context_window / context_length, это значение становится реальным окном Codex. Чтобы задать одну модель вручную, используйте «Пользовательские окна» в той же строке. Нативные провайдеры не затрагиваются.", "models.collapseAll": "Свернуть все", "models.expandAll": "Развернуть все", - "models.orderHint": "Порядок в селекторе: модели, выбранные на странице «Подагенты» (в заданном порядке) → остальные маршрутизируемые модели по алфавиту — сначала по провайдеру, затем по ID модели → нативные модели. Переключатели видимости лишь фильтруют модели и не меняют этот порядок.", + "models.orderHint": "Порядок в селекторе моделей: применяется к маршрутизируемым моделям в Codex и Claude. Переключатели видимости лишь фильтруют модели и не меняют этот порядок.", "models.custom": "Другое…", "models.customApply": "Применить", "models.customPlaceholder": "Токены (напр. 420000)", @@ -2267,6 +2267,18 @@ export const ru: Record = { "cws.err.invalidWeight": "Каждый вес round-robin должен быть целым числом от 1 до 10000.", "cws.err.noEnabledTarget": "Хотя бы одна цель должна использовать включённого провайдера.", "sub.settings": "Настройки", + "models.pickerOrder.title": "Порядок выбора моделей", + "models.pickerOrder.hint": "Управляет порядком маршрутизируемых моделей в селекторах Codex и Claude.", + "models.pickerOrder.label": "Сортировать маршрутизируемые модели", + "models.pickerOrder.default": "По умолчанию", + "models.pickerOrder.alphabetical": "A–Z по модели", + "models.pickerOrder.provider": "По провайдеру", + "models.pickerOrder.mostUsed": "Чаще всего используемые", + "models.pickerOrder.custom": "Пользовательский порядок (конфигурация)", + "models.pickerOrder.apply": "Применить", + "models.pickerOrder.applying": "Применение…", + "models.pickerOrder.saved": "Порядок выбора моделей сохранён. Применяется, когда Codex или Claude обновляет список моделей.", + "models.pickerOrder.usageFailed": "Не удалось загрузить использование моделей", "sub.sections": "Разделы подагентов", "sub.delegation.model": "Модель, которую вызывать первой", "sub.delegation.modelHint": "Модель, к которой Codex обращается первой, когда передаёт работу. Список выше — кого он вообще может вызвать, а здесь выбирается первый в очереди.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 622ebb3c19..e5b6d885e6 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -541,7 +541,7 @@ export const tr: Record = { "models.setAllHint": "Her yönlendirilen sağlayıcıda {value} varsayılan pencereyi açar. Röle context_window / context_length vermezse bu değer gerçek Codex penceresi olur. Tek bir modeli elle yazmak için aynı satırdaki «Özel pencereler»i kullanın.", "models.collapseAll": "Tümünü daralt", "models.expandAll": "Tümünü genişlet", - "models.orderHint": "Seçici sırası: Alt ajan seçimleri → kalan modeller.", + "models.orderHint": "Model seçici sırası: Codex ve Claude'daki yönlendirilmiş modellere uygulanır. Görünürlük anahtarları yalnızca modelleri filtreler; bu sırayı değiştirmez.", "models.custom": "Özel…", "models.customApply": "Uygula", "models.customPlaceholder": "Jetonlar (örn. 420000)", @@ -614,6 +614,18 @@ export const tr: Record = { "sub.models": "Modeller", "sub.search": "Modellerde ara…", "sub.settings": "Ayarlar", + "models.pickerOrder.title": "Model seçici sırası", + "models.pickerOrder.hint": "Codex ve Claude seçicilerindeki yönlendirilmiş model sırasını kontrol eder.", + "models.pickerOrder.label": "Yönlendirilmiş modelleri sırala", + "models.pickerOrder.default": "Varsayılan", + "models.pickerOrder.alphabetical": "Modele göre A–Z", + "models.pickerOrder.provider": "Sağlayıcıya göre", + "models.pickerOrder.mostUsed": "En çok kullanılan", + "models.pickerOrder.custom": "Özel sıra (yapılandırma)", + "models.pickerOrder.apply": "Uygula", + "models.pickerOrder.applying": "Uygulanıyor…", + "models.pickerOrder.saved": "Model seçici sırası kaydedildi. Codex veya Claude model listesini yenilediğinde uygulanır.", + "models.pickerOrder.usageFailed": "Model kullanımı yüklenemedi", "sub.sections": "Alt ajan bölümleri", "sub.delegation.model": "İlk çağrılacak model", "sub.delegation.modelHint": "Codex'in iş devrederken ilk ulaştığı model.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f5f94c4e31..61ef63d4ad 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -407,7 +407,7 @@ export const zhTW: Record = { "models.setAllHint": "為所有已路由供應商打開 {value} 預設視窗。中繼站沒回報 context_window / context_length 時,這個值就是 Codex 實際視窗。要幫單一模型手寫,用同一列上的「自訂視窗」。原生供應商不受影響。", "models.collapseAll": "全部摺疊", "models.expandAll": "全部展開", - "models.orderHint": "選擇器順序:Subagents 中的選擇(按所選順序)→ 其餘已路由模型(依次按供應商、模型 ID 字母排序)→ 原生模型。可見性開關僅用於篩選,不會改變此順序。", + "models.orderHint": "模型選擇器順序:適用於 Codex 和 Claude 中的已路由模型。可見性開關僅用於篩選,不會改變此順序。", "models.custom": "自訂…", "models.customApply": "套用", "models.customPlaceholder": "tokens (例如 420000)", @@ -1843,6 +1843,18 @@ export const zhTW: Record = { "dash.syncModelsHint": "根據已連接的供應商重寫 Codex 的模型目錄。", "dash.syncRun": "立即同步", "sub.settings": "設定", + "models.pickerOrder.title": "模型選擇器排序", + "models.pickerOrder.hint": "控制 Codex 和 Claude 選擇器中的已路由模型順序。", + "models.pickerOrder.label": "排序已路由模型", + "models.pickerOrder.default": "預設", + "models.pickerOrder.alphabetical": "依模型 A–Z", + "models.pickerOrder.provider": "依提供者", + "models.pickerOrder.mostUsed": "最常使用", + "models.pickerOrder.custom": "自訂順序(設定)", + "models.pickerOrder.apply": "套用", + "models.pickerOrder.applying": "正在套用…", + "models.pickerOrder.saved": "模型選擇器排序已儲存。會在 Codex 或 Claude 重新整理模型清單時套用。", + "models.pickerOrder.usageFailed": "無法載入模型使用情況", "sub.sections": "子代理分區", "sub.delegation.model": "優先調用的模型", "sub.delegation.modelHint": "Codex 分派工作時最先調用的模型。上面的推薦是可調用的名單,這裡選的是其中第一順位。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c1a4ceaa25..f7d9391ac3 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -534,7 +534,7 @@ export const zh: Record = { "models.setAllHint": "给所有已路由提供方打开 {value} 默认窗口。中转站没报 context_window / context_length 时,这个值就是 Codex 实际窗口。要给单个模型手写,用同一行上的「自定义窗口」。原生提供方不受影响。", "models.collapseAll": "全部折叠", "models.expandAll": "全部展开", - "models.orderHint": "选择器顺序:Subagents 中的选择(按所选顺序)→ 其余已路由模型(依次按提供方、模型 ID 字母排序)→ 原生模型。可见性开关仅用于筛选,不会改变此顺序。", + "models.orderHint": "模型选择器顺序:适用于 Codex 和 Claude 中的已路由模型。可见性开关仅用于筛选,不会改变此顺序。", "models.custom": "自定义…", "models.customApply": "应用", "models.customPlaceholder": "令牌 (例如 420000)", @@ -2266,6 +2266,18 @@ export const zh: Record = { "claudeDesktop.effort.supported": "effort", "claudeDesktop.effort.displayOnly": "effort (仅显示)", "sub.settings": "设置", + "models.pickerOrder.title": "模型选择器排序", + "models.pickerOrder.hint": "控制 Codex 和 Claude 选择器中的已路由模型顺序。", + "models.pickerOrder.label": "排序已路由模型", + "models.pickerOrder.default": "默认", + "models.pickerOrder.alphabetical": "按模型 A–Z", + "models.pickerOrder.provider": "按提供商", + "models.pickerOrder.mostUsed": "最常使用", + "models.pickerOrder.custom": "自定义顺序(配置)", + "models.pickerOrder.apply": "应用", + "models.pickerOrder.applying": "正在应用…", + "models.pickerOrder.saved": "模型选择器排序已保存。将在 Codex 或 Claude 刷新模型列表时应用。", + "models.pickerOrder.usageFailed": "无法加载模型使用情况", "sub.sections": "子代理分区", "sub.delegation.model": "优先调用的模型", "sub.delegation.modelHint": "Codex 分派工作时最先调用的模型。上面的推荐是可调用的名单,这里选的是其中第一顺位。", diff --git a/gui/src/model-picker-order.ts b/gui/src/model-picker-order.ts new file mode 100644 index 0000000000..89a86624ad --- /dev/null +++ b/gui/src/model-picker-order.ts @@ -0,0 +1,70 @@ +export type ModelPickerOrderMode = "default" | "alphabetical" | "provider" | "most-used" | "custom"; +export type SavedModelPickerOrderMode = Exclude; + +export interface ModelPickerUsage { + provider: string; + model: string; + resolvedModel?: string; + requests: number; +} + +function providerAndModel(slug: string): [string, string] { + const slash = slug.indexOf("/"); + return slash < 0 ? ["", slug] : [slug.slice(0, slash), slug.slice(slash + 1)]; +} + +function compareProviderAndModel(a: string, b: string): number { + const [aProvider, aModel] = providerAndModel(a); + const [bProvider, bModel] = providerAndModel(b); + return aProvider.localeCompare(bProvider) || aModel.localeCompare(bModel); +} + +function usageBySlug(usage: readonly ModelPickerUsage[], models: readonly string[]): Map { + const requests = new Map(); + const pickerModels = new Set(models); + for (const row of usage) { + const slug = row.model.includes("/") ? row.model : `${row.provider}/${row.model}`; + const resolved = row.resolvedModel + ? (row.resolvedModel.includes("/") ? row.resolvedModel : `${row.provider}/${row.resolvedModel}`) + : undefined; + const target = pickerModels.has(slug) ? slug : resolved && pickerModels.has(resolved) ? resolved : undefined; + if (target) requests.set(target, (requests.get(target) ?? 0) + row.requests); + } + return requests; +} + +export function modelPickerOrder( + mode: Exclude, + models: readonly string[], + usage: readonly ModelPickerUsage[] = [], +): string[] | null { + if (mode === "default") return null; + const unique = [...new Set(models)]; + if (mode === "alphabetical") { + return unique.toSorted((a, b) => { + const [, aModel] = providerAndModel(a); + const [, bModel] = providerAndModel(b); + return aModel.localeCompare(bModel) || compareProviderAndModel(a, b); + }); + } + if (mode === "provider") return unique.toSorted(compareProviderAndModel); + + const requests = usageBySlug(usage, unique); + return unique.toSorted((a, b) => (requests.get(b) ?? 0) - (requests.get(a) ?? 0) || compareProviderAndModel(a, b)); +} + +export function modelPickerOrderMode( + models: readonly string[], + saved: readonly string[], + savedMode?: SavedModelPickerOrderMode | null, +): ModelPickerOrderMode { + if (saved.length === 0) return "default"; + const normalized = [...new Set(saved)]; + if (normalized.length !== models.length || normalized.some(model => !models.includes(model))) return "custom"; + if (savedMode) return savedMode; + for (const mode of ["alphabetical", "provider"] as const) { + const expected = modelPickerOrder(mode, models); + if (expected?.every((model, index) => model === normalized[index])) return mode; + } + return "custom"; +} diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 4c00641422..8780d84998 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -68,6 +68,7 @@ import { import { EmptyProviderHint } from "./models-provider-hints"; import { shadowCallModelOptions } from "./dashboard-shared"; import { shadowSourceModelBadge, shadowSourceModelLabel } from "./shadow-call-source"; +import { modelPickerOrder, modelPickerOrderMode, type ModelPickerOrderMode, type ModelPickerUsage, type SavedModelPickerOrderMode } from "../model-picker-order"; type CachedModelsPage = { models: ModelRow[]; @@ -76,8 +77,13 @@ type CachedModelsPage = { disabled: string[]; contextCaps: Record; contextCapValue: number; + pickerAvailable?: string[]; + pickerOrder?: string[]; + pickerOrderMode?: SavedModelPickerOrderMode | null; }; +type PickerOrderResponse = { pickerAvailable?: string[]; pickerOrder?: string[]; pickerOrderMode?: SavedModelPickerOrderMode | null }; + /** One subtitle per tab: only one panel is visible, so only one description applies. */ const SUBTITLE_TKEY: Record = { catalog: "models.subtitle", @@ -214,6 +220,9 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const [limit, setLimit] = useState>({}); const [contextCaps, setContextCaps] = useState>(() => cached?.contextCaps ?? {}); const [contextCapValue, setContextCapValue] = useState(() => cached?.contextCapValue ?? 350_000); + const [pickerAvailable, setPickerAvailable] = useState(() => cached?.pickerAvailable ?? []); + const [pickerMode, setPickerMode] = useState(() => modelPickerOrderMode(cached?.pickerAvailable ?? [], cached?.pickerOrder ?? [], cached?.pickerOrderMode)); + const [pickerBusy, setPickerBusy] = useState(false); const [customCap, setCustomCap] = useState(""); const [showCustom, setShowCustom] = useState(false); const [providerCapCustomOpen, setProviderCapCustomOpen] = useState>({}); @@ -399,18 +408,20 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; }, [apiBase]); const fetchCatalog = useCallback(async (signal: AbortSignal): Promise => { - const [modelsRes, capsRes, providersRes, selectionData] = await Promise.all([ + const [modelsRes, capsRes, providersRes, selectionData, pickerRes] = await Promise.all([ // Every request carries the resource signal, so leaving the catalog tab cancels // the work rather than only discarding its result. fetch(`${apiBase}/api/models`, { signal }), fetch(`${apiBase}/api/provider-context-caps`, { signal }), fetch(`${apiBase}/api/providers`, { signal }), fetchSelectedModels(apiBase, fetch, signal), + fetch(`${apiBase}/api/subagent-models`, { signal }), ]); - const [data, capsData, providerData] = await Promise.all([ + const [data, capsData, providerData, pickerData] = await Promise.all([ readJsonOrThrow(modelsRes), readJsonOrThrow(capsRes), readJsonOrThrow(providersRes), + readJsonIfOk(pickerRes), ]); if (data === undefined || capsData === undefined || providerData === undefined) { throw new Error("models payload missing"); @@ -428,6 +439,9 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; disabled: [...nextDisabled], contextCaps: capsData.caps ?? {}, contextCapValue: nextCapValue, + pickerAvailable: pickerData?.pickerAvailable ?? [], + pickerOrder: pickerData?.pickerOrder ?? [], + pickerOrderMode: pickerData?.pickerOrderMode ?? null, } satisfies CachedModelsPage; writeSessionListCache(cacheKey, next); return next; @@ -446,6 +460,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; setSelectedModels(next.selectedModels); setContextCapValue(next.contextCapValue); setContextCaps(next.contextCaps); + setPickerAvailable(next.pickerAvailable ?? []); + setPickerMode(modelPickerOrderMode(next.pickerAvailable ?? [], next.pickerOrder ?? [], next.pickerOrderMode)); }, []); const catalogResource = useDataSurface( @@ -1586,6 +1602,44 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; ? groups.filter(group => group.provider === selectedProvider) : groups; + const savePickerOrder = async () => { + if (pickerBusy || pickerMode === "custom") return; + setPickerBusy(true); + const bounded = createBoundedFetch(15_000); + try { + let usage: ModelPickerUsage[] = []; + if (pickerMode === "most-used") { + const usageResponse = await fetch(`${apiBase}/api/usage?range=all&surface=all`, { signal: bounded.signal }); + const usageData = await readJsonOrThrow<{ models?: ModelPickerUsage[] }>(usageResponse, t("models.pickerOrder.usageFailed")); + usage = usageData?.models ?? []; + } + const order = modelPickerOrder(pickerMode, pickerAvailable, usage); + const pickerOrderMode = pickerMode === "default" ? null : pickerMode; + const response = await fetch(`${apiBase}/api/subagent-models`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pickerOrder: order, pickerOrderMode }), + signal: bounded.signal, + }); + const data = await readJsonOrThrow<{ pickerOrder?: string[]; pickerOrderMode?: SavedModelPickerOrderMode | null }>(response, t("models.saveFailed")); + const saved = data?.pickerOrder ?? order ?? []; + const savedMode = data?.pickerOrderMode ?? pickerOrderMode; + const next = catalogState.data ?? cached; + if (next) { + const updated = { ...next, pickerAvailable, pickerOrder: saved, pickerOrderMode: savedMode }; + writeSessionListCache(cacheKey, updated); + setClientResourceData(cacheKey, updated); + } + setPickerMode(modelPickerOrderMode(pickerAvailable, saved, savedMode)); + publishFeedback(true, t("models.pickerOrder.saved")); + } catch (error) { + publishFeedback(false, error instanceof Error && error.message ? error.message : t("models.networkError")); + } finally { + bounded.clear(); + setPickerBusy(false); + } + }; + const controlsBlock = ( <>
@@ -1713,6 +1767,27 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; {t("models.setAllHint", { value: fmtK(contextCapValue) })}
+
+ {t("models.pickerOrder.label")} +