diff --git a/.github/pr-assets/xai-responses-optin-switch.png b/.github/pr-assets/xai-responses-optin-switch.png new file mode 100644 index 0000000000..8f0947ace7 Binary files /dev/null and b/.github/pr-assets/xai-responses-optin-switch.png differ diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index edf41cccc0..336627de70 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -98,6 +98,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `modelSupportsReasoningSummaries?` | `Record` | Définissez un modèle sur `false` pour arrêter la publicité des résumés et supprimer les champs de livraison du résumé. | | `modelReasoningSummaryDelivery?` | `Record` | Énumération de livraison des réponses par modèle ; réécrit un champ de livraison existant. | | `modelAdapters?` | `Record` | Remplacement du protocole `openai-chat` ou `openai-responses` par modèle pour les passerelles multiprotocoles. Les entrées explicites priment sur les valeurs par défaut du registre. Le préréglage OpenCode Go sélectionne Responses pour `gpt-5.6-luna` tout en laissant les modèles apparentés sur leurs protocoles documentés ; DeepSeek peut sélectionner Responses natif pour `deepseek-v4-flash` ; GitHub Copilot déclare des valeurs par défaut limitées à Responses pour sa famille GPT-5 (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`), car ces modèles rejettent `/chat/completions` pour le trafic des agents. Les modèles sans valeur intégrée par défaut, comme `gpt-5.4-nano`, peuvent être activés ici. Les services en amont à protocole unique et le transfert canonique ChatGPT rejettent ces remplacements. | +| Activation Responses xAI (tableau de bord) | interrupteur | Pour `xai` uniquement, définit ou efface atomiquement les entrées `modelAdapters` de `grok-4.5` et `grok-4.6`. Une seule entrée apparaît comme un état mixte jusqu’à la prochaine écriture. Les autres remplacements et le comportement des tiers restent inchangés. | | `modelPreferHostedTools?` | `Record` | Activation explicite par modèle exact pour les passerelles Responses hors transfert qui réservent un espace de noms aux outils hébergés. Seul `["image_generation"]` est actuellement accepté ; le modèle correspondant doit utiliser le protocole `openai-responses` et prendre en charge cet outil hébergé. Le proxy supprime les déclarations clientes `image_gen` en conflit et réécrit leurs sélecteurs afin de préserver le choix d'outil de l'appelant. Pour les modèles virtuels `-pro` de l'API OpenAI, l'identifiant public sélectionné est comparé en premier et l'identifiant résolu du modèle de base sur le protocole sert de repli. `modelAdapters` résout d'abord l'identifiant public, puis celui de base ; la seconde résolution détermine le protocole final. Les autres modèles conservent le comportement normal des alias. | | `reasoningEffortMap?` | `Record` | Alias ​​de fil à l’échelle du fournisseur pour les étiquettes de raisonnement. | | `modelReasoningEffortMap?` | `Record>` | Alias ​​de fil par modèle pour les étiquettes de raisonnement. | 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 5a02ccc2f5..27cf1406ec 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -86,6 +86,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `modelSupportsReasoningSummaries?` | `Record` |モデルを `false` に設定して、概要の広告を停止し、概要配信フィールドを削除します。 | | `modelReasoningSummaryDelivery?` | `Record` |モデルごとの応答配信列挙型。既存の配信フィールドを書き換えます。 | | `modelAdapters?` | `Record` | 混合配線ゲートウェイのモデルごとの `openai-chat` または `openai-responses` 配線オーバーライド。明示的なエントリはレジストリのデフォルトを破ります。DeepSeek のプリセットは `deepseek-v4-flash` のネイティブ Responses を選択でき、GitHub Copilot は GPT-5 ファミリー (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) を Responses 専用デフォルトとして宣言します。これらのモデルはエージェント トラフィックで `/chat/completions` を拒否するためです。`gpt-5.4-nano` のようなビルトイン デフォルトのないモデルはここでオプトインできます。単線アップストリーム ピンと正規の ChatGPT 転送はオーバーライドを拒否します。 | +| xAI Responses オプトイン(ダッシュボード) | スイッチ | `xai` のみで、`grok-4.5` と `grok-4.6` の `modelAdapters` エントリを原子的に設定または削除します。片方だけの場合は、次のスイッチ操作で両方が正規化されるまで混合状態を表示します。他のオーバーライドと tier 動作は変わりません。 | | `modelPreferHostedTools?` | `Record` | hosted tool namespace を予約する非 forward Responses gateway 向けの完全一致モデル opt-in。現在は `["image_generation"]` のみを受け付けます。一致したモデルは `openai-responses` wire を使い、その hosted tool をサポートする必要があります。競合するクライアント `image_gen` 宣言を除去し、呼び出し元の tool choice を維持するため selector も書き換えます。OpenAI API の仮想 `-pro` モデルでは、まず選択した公開 ID に一致させ、解決後のベース wire-model ID をフォールバックとして使用します。`modelAdapters` は公開 ID、次にベース ID の順に解決し、後者の結果が最終 wire を決めます。未設定のモデルは通常の alias 動作を維持します。 | | `reasoningEffortMap?` | `Record` |ラベルを推論するためのプロバイダー全体のワイヤ エイリアス。 | | `modelReasoningEffortMap?` | `Record>` |推論ラベルのモデルごとのワイヤ エイリアス。 | 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 d33312e176..707129b2ed 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -86,6 +86,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `modelSupportsReasoningSummaries?` | `Record` | 모델을 `false`로 두면 summary 광고를 멈추고 summary 전달 필드를 제거합니다. | | `modelReasoningSummaryDelivery?` | `Record` | 모델별 Responses 전달 enum입니다. 기존 delivery 필드를 다시 씁니다. | | `modelAdapters?` | `Record` | 혼합 와이어 게이트웨이를 위한 모델별 `openai-chat` 또는 `openai-responses` 와이어 재정의입니다. 명시적 항목이 레지스트리 기본값보다 우선합니다. DeepSeek 프리셋은 `deepseek-v4-flash`에 네이티브 Responses를 선택할 수 있고, GitHub Copilot은 GPT-5 계열(`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`)을 Responses 전용 기본값으로 선언합니다. 이 모델들은 에이전트 트래픽에서 `/chat/completions`를 거부하기 때문입니다. `gpt-5.4-nano`처럼 기본값이 없는 모델은 여기서 직접 옵트인할 수 있습니다. 단일 와이어 상위 항목과 정식 ChatGPT forward는 재정의를 거부합니다. | +| xAI Responses 옵트인(대시보드) | 스위치 | `xai`에서만 `grok-4.5`와 `grok-4.6`의 `modelAdapters` 항목을 원자적으로 설정하거나 지웁니다. 한 항목만 있으면 다음 스위치 쓰기가 둘을 정규화할 때까지 혼합 상태로 표시됩니다. 다른 재정의와 티어 동작은 바뀌지 않습니다. | | `modelPreferHostedTools?` | `Record` | hosted tool namespace를 예약하는 non-forward Responses gateway용 정확한 모델 ID opt-in입니다. 현재 `["image_generation"]`만 허용하며, 일치하는 모델은 `openai-responses` wire를 사용하고 해당 hosted tool을 지원해야 합니다. 충돌하는 클라이언트 `image_gen` 선언을 제거하고 호출자의 tool choice를 유지하도록 selector도 다시 씁니다. OpenAI API 가상 `-pro` 모델은 선택한 공개 ID를 먼저 일치시키고, 해석된 기본 wire-model ID를 대체값으로 사용합니다. `modelAdapters`는 공개 ID를 먼저, 그 다음 기본 ID를 해석하며, 두 번째 결과가 최종 wire를 결정합니다. 설정하지 않은 모델은 일반 alias 동작을 유지합니다. | | `reasoningEffortMap?` | `Record` | reasoning 레이블의 공급자 전반 와이어 별칭입니다. | | `modelReasoningEffortMap?` | `Record>` | reasoning 레이블의 모델별 와이어 별칭입니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a12ed68f3b..135f8f9632 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -99,6 +99,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `modelSupportsReasoningSummaries?` | `Record` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. | | `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery enum; rewrites an existing delivery field. | | `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults. The OpenCode Go preset selects Responses for `gpt-5.6-luna` while leaving sibling models on their documented wires; DeepSeek can select native Responses for `deepseek-v4-flash`; and GitHub Copilot declares Responses-only defaults for its GPT-5 family (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | +| xAI Responses opt-in (dashboard) | switch | For `xai` only, atomically sets or clears the `grok-4.5` and `grok-4.6` `modelAdapters` entries. A hand-edited single entry appears as mixed until the next switch write normalizes both. Other overrides and tier behavior are unchanged. | | `modelPreferHostedTools?` | `Record` | Exact-model opt-in for non-forward Responses gateways that reserve a hosted-tool namespace. Currently accepts only `["image_generation"]`; a matching model must use the `openai-responses` wire and support that hosted tool. It removes colliding client `image_gen` declarations and rewrites their selectors to preserve caller tool choice. For OpenAI API virtual `-pro` models, the selected public ID is matched first and the resolved base wire-model ID is a fallback. `modelAdapters` resolves the public ID first, then the base ID; the second resolution determines the final wire. Other models retain normal alias behavior. | | `reasoningEffortMap?` | `Record` | Provider-wide wire aliases for reasoning labels. | | `modelReasoningEffortMap?` | `Record>` | Per-model wire aliases for reasoning labels. | 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 56ad0154bf..21b70bebb5 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -99,6 +99,7 @@ cross-route credential fallback не существует. Строки API GPT- | `modelSupportsReasoningSummaries?` | `Record` | Установите `false` для модели, чтобы перестать рекламировать summary и вырезать поля доставки summary. | | `modelReasoningSummaryDelivery?` | `Record` | Responses delivery enum по моделям; переписывает уже существующее поле delivery. | | `modelAdapters?` | `Record` | Wire-override по модели для `openai-chat` или `openai-responses` в gateway с несколькими wire-форматами. Явные записи имеют приоритет над default'ами registry; preset DeepSeek может выбирать native Responses для `deepseek-v4-flash`, а GitHub Copilot объявляет Responses-only default'ы для семейства GPT-5 (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`), потому что эти модели отклоняют `/chat/completions` для агентного трафика. Модели без встроенного default'а (например, `gpt-5.4-nano`) можно включить здесь. Single-wire upstream pin'ы и canonical ChatGPT forward override не принимают. | +| Opt-in xAI Responses (панель) | переключатель | Только для `xai`: атомарно задаёт или удаляет записи `modelAdapters` для `grok-4.5` и `grok-4.6`. Одна запись отображается как смешанное состояние до следующего переключения. Остальные override и поведение tier не меняются. | | `modelPreferHostedTools?` | `Record` | Opt-in для точного model ID в non-forward Responses gateway, который резервирует namespace hosted tool. Сейчас допускается только `["image_generation"]`; совпавшая модель должна использовать wire `openai-responses` и поддерживать этот hosted tool. Прокси удаляет конфликтующие клиентские объявления `image_gen` и переписывает их selectors, сохраняя caller tool choice. Для виртуальных моделей OpenAI API `-pro` сначала сопоставляется выбранный публичный ID, а затем в качестве fallback используется ID базовой wire-модели. `modelAdapters` сначала разрешается по публичному ID, затем по базовому ID; второй результат определяет итоговый wire. Остальные модели сохраняют обычное alias-поведение. | | `reasoningEffortMap?` | `Record` | Provider-wide wire-alias'ы для reasoning-label'ов. | | `modelReasoningEffortMap?` | `Record>` | Wire-alias'ы для reasoning-label'ов по отдельным моделям. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index c7b13d56c9..d4e414700a 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -105,6 +105,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `modelSupportsReasoningSummaries?` | `Record` | Özetlerin bildirilmesini durdurmak ve özet teslim alanlarını kaldırmak için bir modeli `false` olarak ayarlayın. | | `modelReasoningSummaryDelivery?` | `Record` | Model başına Responses teslim enum'ı; mevcut bir teslim alanını yeniden yazar. | | `modelAdapters?` | `Record` | Karışık hatlı ağ geçitleri için model başına `openai-chat` veya `openai-responses` hat geçersiz kılma. Açık girdiler kayıt defteri varsayılanlarını yener. OpenCode Go önayarı, kardeş modelleri belgelenmiş hatlarında bırakırken `gpt-5.6-luna` için Responses'ı seçer; DeepSeek, `deepseek-v4-flash` için yerel Responses seçebilir; ve GitHub Copilot, GPT-5 ailesi (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) için yalnızca Responses varsayılanlarını bildirir çünkü bu modeller ajan trafiği için `/chat/completions`'ı reddeder. Yerleşik varsayılanı olmayan modeller (örneğin `gpt-5.4-nano`) burada dahil edilebilir. Tek hatlı yukarı akış pinleri ve kurallı ChatGPT iletme geçersiz kılmaları reddeder. | +| xAI Responses katılımı (panel) | anahtar | Yalnızca `xai` için `grok-4.5` ve `grok-4.6` `modelAdapters` girdilerini atomik olarak ayarlar veya temizler. Tek girdi, sonraki anahtar yazımı ikisini eşitleyene kadar karma durum olarak görünür. Diğer geçersiz kılmalar ve katman davranışı değişmez. | | `modelPreferHostedTools?` | `Record` | Barındırılan bir araç ad alanı ayıran iletme harici Responses ağ geçitleri için tam model dahil etme. Şu anda yalnızca `["image_generation"]` kabul eder; eşleşen bir model `openai-responses` hattını kullanmalı ve bu barındırılan aracı desteklemelidir. Çakışan istemci `image_gen` bildirimlerini kaldırır ve arayan araç seçimini korumak için seçicilerini yeniden yazar. OpenAI API sanal `-pro` modelleri için önce seçilen genel kimlik eşleştirilir ve çözümlenen temel hat model kimliği bir geri dönüştür. `modelAdapters` önce genel kimliği, ardından temel kimliği çözer; ikinci çözümleme son hattı belirler. Diğer modeller normal takma ad davranışını korur. | | `reasoningEffortMap?` | `Record` | Akıl yürütme etiketleri için sağlayıcı genelinde hat takma adları. | | `modelReasoningEffortMap?` | `Record>` | Akıl yürütme etiketleri için model başına hat takma adları. | @@ -479,4 +480,3 @@ bildirir; senkronize edilen katalog `xhigh`'ı ayrı tutarken `max` bildirir. } ``` - 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 f37f372cfd..1564842cbb 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 @@ -86,6 +86,7 @@ selector,而不是分配一个新名称。 | `modelSupportsReasoningSummaries?` | `Record` | 将某个模型设为 `false`,即可停止暴露摘要并移除摘要交付字段。 | | `modelReasoningSummaryDelivery?` | `Record` | 按模型设置的 Responses 交付枚举;会重写现有的 delivery 字段。 | | `modelAdapters?` | `Record` | 按模型设置的 `openai-chat` 或 `openai-responses` 线协议覆盖项,用于混合线协议网关。显式条目优先于注册表默认值;DeepSeek 预设可以为 `deepseek-v4-flash` 选择原生 Responses,GitHub Copilot 则为 GPT-5 系列(`gpt-5.3-codex`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`)声明了 Responses 专用默认值,因为这些模型在代理流量下会拒绝 `/chat/completions`。没有内置默认值的模型(例如 `gpt-5.4-nano`)可以在此手动启用。单一线协议上游固定项和规范 ChatGPT forward 会拒绝覆盖。 | +| xAI Responses 启用项(仪表板) | 开关 | 仅用于 `xai`,以原子方式设置或清除 `grok-4.5` 和 `grok-4.6` 的 `modelAdapters` 条目。若只存在一个条目,则显示混合状态,直到下次开关写入将两者统一。其他覆盖项和层级行为不变。 | | `modelPreferHostedTools?` | `Record` | 非 forward Responses gateway 的精确模型 ID opt-in,用于上游预留 hosted tool namespace 的情况。目前只支持 `["image_generation"]`;匹配模型必须使用 `openai-responses` wire 且支持该 hosted 工具。它会移除冲突的客户端 `image_gen` 声明,并改写其 selector 以保持调用方的 tool choice。对于 OpenAI API 的虚拟 `-pro` 模型,先匹配所选公开 ID,未命中时才使用解析出的基础 wire-model ID 作为回退。`modelAdapters` 会先按公开 ID、再按基础 ID 解析;后一次结果决定最终 wire。未配置模型保持普通 alias 行为。 | | `reasoningEffortMap?` | `Record` | 提供者级、用于推理标签的线协议别名。 | | `modelReasoningEffortMap?` | `Record>` | 按模型设置的推理标签线协议别名。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 326aa348d5..f47b5bef05 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -67,6 +67,7 @@ description: 供應商項目、認證、端點、模型目錄、配額、context | `modelSupportsReasoningSummaries?` | `Record` | 將模型設為 `false` 以停止廣告摘要並剝離 summary-delivery 欄位。 | | `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery 列舉;重寫既有的 delivery 欄位。 | | `modelAdapters?` | `Record` | 混合 wire 閘道的 Per-model `openai-chat` 或 `openai-responses` wire 覆寫。明確項目勝過 registry 預設;DeepSeek 的預設可為 `deepseek-v4-flash` 選擇原生 Responses。單一 wire 上游 pin 與規範 ChatGPT forward 拒絕覆寫。 | +| xAI Responses 選用(儀表板) | 開關 | 僅用於 `xai`,以原子方式設定或清除 `grok-4.5` 與 `grok-4.6` 的 `modelAdapters` 項目。若只有一個項目,會顯示混合狀態,直到下次開關寫入統一兩者。其他覆寫與層級行為不變。 | | `reasoningEffortMap?` | `Record` | 供應商範圍的 reasoning 標籤 wire 別名。 | | `modelReasoningEffortMap?` | `Record>` | Per-model 的 reasoning 標籤 wire 別名。 | | `noReasoningModels?` | `string[]` | 拒絕 reasoning/thinking 參數的模型。 | diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 60c15225de..928e15d93c 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -22,13 +22,80 @@ import { LoginUrlBlock } from "../login-url-block"; import QuotaBars from "../QuotaBars"; import { useCopyFeedback } from "../use-copy-feedback"; import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool"; -import type { AccountLoadState, OAuthAccountRow, ApiKeyRow, LoginHint, ProviderAuthHandlers } from "./types"; +import { Switch } from "../../ui"; +import type { + AccountLoadState, + OAuthAccountRow, + ApiKeyRow, + LoginHint, + ProviderAuthHandlers, + ProviderUpdatePatch, + ProviderUpdateResult, +} from "./types"; const QUOTA_ENRICH_RESERVE_MS = 4_000; const COCKPIT_IMPORT_MAX_BYTES = 256 * 1024; const EMPTY_OAUTH_ACCOUNTS: OAuthAccountRow[] = []; const EMPTY_API_KEYS: ApiKeyRow[] = []; +function XaiResponsesOptInControl({ + initialState, + onUpdateProvider, +}: { + initialState: NonNullable; + onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise; +}) { + const t = useT(); + const [state, setState] = useState(initialState); + const [seenInitialState, setSeenInitialState] = useState(initialState); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + if (initialState !== seenInitialState) { + setSeenInitialState(initialState); + setState(initialState); + } + const mixed = state === "mixed"; + + const toggle = async () => { + if (!onUpdateProvider || saving) return; + const next = state !== true; + setSaving(true); + setError(""); + try { + const result = await onUpdateProvider("xai", { xaiResponsesOptIn: next }); + if (!result.ok) { + setError(result.error ?? t("prov.updateFail")); + return; + } + setState(result.xaiResponsesOptInState ?? next); + } catch { + setError(t("prov.networkError")); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ {t("pws.xaiResponsesOptIn")} + + {t("pws.xaiResponsesOptInDesc")} + {mixed && {t("pws.xaiResponsesOptInMixed")}} + + {error && {error}} +
+ { void toggle(); }} + disabled={!onUpdateProvider || saving} + label={t("pws.xaiResponsesOptIn")} + /> +
+ ); +} + type CockpitImportResult = { importedCount: number; updatedCount: number; @@ -99,7 +166,7 @@ function safeCockpitImportResult(value: unknown): CockpitImportResult | null { export default function ProviderAuthPanel({ item, apiBase, oauth, accounts = EMPTY_OAUTH_ACCOUNTS, keys = EMPTY_API_KEYS, accountLoadState = "ready", switchingAccountId = null, busy = false, loginHint, authHandlers, onCodexActiveNeedsReauthChange, - codexController, + codexController, onUpdateProvider, }: { item: WorkspaceItem; apiBase: string; @@ -112,6 +179,7 @@ export default function ProviderAuthPanel({ loginHint?: LoginHint | null; authHandlers?: ProviderAuthHandlers; onCodexActiveNeedsReauthChange?: (needs: boolean) => void; + onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise; /** Shared Codex account state owned by Providers (WP3). */ codexController?: CodexAccountPoolController; }) { @@ -248,6 +316,12 @@ export default function ProviderAuthPanel({

{isOauth ? t("pws.availableAccounts") : t("pws.apiKeys")}

+ {item.name === "xai" && ( + + )} {isOauth && ( <> {item.name === "anthropic" && ( diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 62cf94cad8..1b02517835 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -20,7 +20,7 @@ import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool import ProviderSettings from "./ProviderSettings"; import { UnsavedLeaveDialog } from "./ProviderDialogs"; import type { ProviderQuotaReportView } from "../../provider-workspace/report"; -import type { AccountLoadState, ProviderModelUsageRow, ProviderUsageTotals, OAuthAccountRow, ApiKeyRow, LoginHint, ProviderAuthHandlers, ProviderUpdatePatch } from "./types"; +import type { AccountLoadState, ProviderModelUsageRow, ProviderUsageTotals, OAuthAccountRow, ApiKeyRow, LoginHint, ProviderAuthHandlers, ProviderUpdatePatch, ProviderUpdateResult } from "./types"; type Tab = "overview" | "models" | "usage" | "accounts" | "settings"; @@ -85,7 +85,7 @@ export default function ProviderDetails({ onCodexActiveNeedsReauthChange?: (needs: boolean) => void; /** Shared Codex account state owned by Providers (WP3). */ codexController?: CodexAccountPoolController; - onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; + onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise; isDefault?: boolean; onRemoveProvider?: (name: string) => void; onSetDisabled?: (name: string, disabled: boolean) => void; @@ -310,6 +310,7 @@ export default function ProviderDetails({ busy={busyProvider === item.name} loginHint={loginHint} authHandlers={authHandlers} + onUpdateProvider={onUpdateProvider} onCodexActiveNeedsReauthChange={onCodexActiveNeedsReauthChange} codexController={codexController} /> diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index d4b7f3619d..5c5e7468cf 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -11,7 +11,7 @@ import { formatRelativeTime, relativeTimeLabelsFromT, formatRequestCount, format import { accountQuotaFromReport, formatQuotaSourceLabel, type ProviderQuotaReportView } from "../../provider-workspace/report"; import type { ProviderUsageTotals } from "./types"; import { authModeLabel } from "./ProviderRail"; -import type { ProviderUpdatePatch } from "./types"; +import type { ProviderUpdatePatch, ProviderUpdateResult } from "./types"; import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; type ConnectionTestResult = { @@ -46,7 +46,7 @@ export default function ProviderOverview({ connectionIdentity?: string; onEditSettings?: () => void; onViewUsage?: () => void; - onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; + onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise; onReauthenticate?: () => void; onCancelLogin?: () => void; reauthBusy?: boolean; @@ -306,7 +306,7 @@ export default function ProviderOverview({ function NotesSection({ item, onUpdateProvider }: { item: WorkspaceItem; - onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; + onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise; }) { const t = useT(); const [editing, setEditing] = useState(false); diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index 1509e13a14..847fe7e712 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -19,7 +19,7 @@ import { openAiAccountProviderState } from "../../provider-payload"; import { providerSupportsLiveModelDiscovery } from "../../provider-workspace/catalog"; import type { CatalogPreset } from "../provider-catalog/provider-presets"; import { authModeLabel } from "./ProviderRail"; -import type { WorkspaceItem, ProviderUpdatePatch } from "./types"; +import type { WorkspaceItem, ProviderUpdatePatch, ProviderUpdateResult } from "./types"; const ADAPTERS = ["openai-responses", "openai-chat", "anthropic", "google", "azure-openai", "cursor"] as const; const EMPTY_MODELS: string[] = []; @@ -63,7 +63,7 @@ export default function ProviderSettings({ availableModels?: string[]; /** When set, load endpoint choices for catalog providers that expose baseUrlChoices. */ apiBase?: string; - onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; + onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise; onDirtyChange?: (dirty: boolean) => void; /** Lets parent dialogs trigger the same save path as the sticky bar. */ onRegisterSave?: (save: (() => Promise) | null) => void; diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index 24dd218f3b..d23464500e 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -102,4 +102,12 @@ export type ProviderUpdatePatch = { requestPacing?: WorkspaceItem["requestPacing"] | null; /** Dedicated field: the API PATCHes it alone for the canonical `openai` provider. */ codexAccountMode?: "direct" | "pool"; + /** Management-only write that atomically owns the two supported xAI Grok adapter rows. */ + xaiResponsesOptIn?: boolean; +}; + +export type ProviderUpdateResult = { + ok: boolean; + error?: string; + xaiResponsesOptInState?: WorkspaceItem["xaiResponsesOptInState"]; }; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 57e0d93f79..8e64e29b97 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1695,6 +1695,9 @@ export const de: Record = { "pws.allowPrivateNetwork": "Lokales/privates Netzwerk erlauben", "pws.liveModels": "Modelle beim Anbieter erkennen", "pws.liveModelsDesc": "Lädt den Live-Modellkatalog des Anbieters. Ausschalten, um nur konfigurierte statische Modelle zu verwenden.", + "pws.xaiResponsesOptIn": "Responses API für Grok 4.5 und 4.6 verwenden", + "pws.xaiResponsesOptInDesc": "Leitet beide Modelle über openai-responses. Andere Grok-Modelle und das Tier-Verhalten bleiben unverändert.", + "pws.xaiResponsesOptInMixed": "Teilweise aktiviert.", "pws.cursorTransport": "Cursor-Transport", "pws.cursorTransportHttp2": "HTTP/2 (Standard)", "pws.cursorTransportHttp1": "HTTP/1.1 (Proxy-Kompatibilität)", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ae3650e545..262e913162 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1193,6 +1193,9 @@ export const en = { "pws.allowPrivateNetwork": "Allow local/private network", "pws.liveModels": "Discover models from provider", "pws.liveModelsDesc": "Fetch the provider's live model catalog. Turn this off to use only configured/static models.", + "pws.xaiResponsesOptIn": "Use Responses API for Grok 4.5 and 4.6", + "pws.xaiResponsesOptInDesc": "Routes both models through openai-responses. Other Grok models and tier behavior are unchanged.", + "pws.xaiResponsesOptInMixed": "Partially enabled.", "pws.cursorTransport": "Cursor transport", "pws.cursorTransportHttp2": "HTTP/2 (default)", "pws.cursorTransportHttp1": "HTTP/1.1 (proxy compatibility)", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index cbd5b0b526..147773fd93 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1166,6 +1166,9 @@ export const fr: Record = { "pws.allowPrivateNetwork": "Autoriser le réseau local/privé", "pws.liveModels": "Détecter les modèles auprès du fournisseur", "pws.liveModelsDesc": "Récupérez le catalogue de modèles en direct du fournisseur. Désactivez cette option pour utiliser uniquement les modèles configurés/statiques.", + "pws.xaiResponsesOptIn": "Utiliser l’API Responses pour Grok 4.5 et 4.6", + "pws.xaiResponsesOptInDesc": "Achemine les deux modèles via openai-responses. Les autres modèles Grok et le comportement des tiers restent inchangés.", + "pws.xaiResponsesOptInMixed": "Activation partielle.", "pws.cursorTransport": "Transport Cursor", "pws.cursorTransportHttp2": "HTTP/2 (par défaut)", "pws.cursorTransportHttp1": "HTTP/1.1 (compatibilité proxy)", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3031ae4f34..11a85127ea 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1126,6 +1126,9 @@ export const ja: Record = { "pws.allowPrivateNetwork": "ローカル/プライベートネットワークを許可", "pws.liveModels": "プロバイダーからモデルを検出", "pws.liveModelsDesc": "プロバイダーのライブモデルカタログを取得します。オフにすると設定済みの静的モデルのみを使用します。", + "pws.xaiResponsesOptIn": "Grok 4.5 と 4.6 で Responses API を使用", + "pws.xaiResponsesOptInDesc": "両モデルを openai-responses 経由でルーティングします。他の Grok モデルと tier 動作は変わりません。", + "pws.xaiResponsesOptInMixed": "一部のみ有効です。", "pws.cursorTransport": "Cursor トランスポート", "pws.cursorTransportHttp2": "HTTP/2(デフォルト)", "pws.cursorTransportHttp1": "HTTP/1.1(プロキシ互換)", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 6536e54cd1..5bbc14ae7d 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1722,6 +1722,9 @@ export const ko: Record = { "pws.allowPrivateNetwork": "로컬/사설 네트워크 허용", "pws.liveModels": "프로바이더에서 모델 검색", "pws.liveModelsDesc": "프로바이더의 실시간 모델 카탈로그를 가져옵니다. 끄면 설정된 정적 모델만 사용합니다.", + "pws.xaiResponsesOptIn": "Grok 4.5와 4.6에 Responses API 사용", + "pws.xaiResponsesOptInDesc": "두 모델을 openai-responses로 라우팅합니다. 다른 Grok 모델과 티어 동작은 바뀌지 않습니다.", + "pws.xaiResponsesOptInMixed": "일부만 활성화됨.", "pws.cursorTransport": "Cursor 전송", "pws.cursorTransportHttp2": "HTTP/2 (기본값)", "pws.cursorTransportHttp1": "HTTP/1.1 (프록시 호환)", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 5d31cc95de..396ccf3ed0 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1177,6 +1177,9 @@ export const ru: Record = { "pws.allowPrivateNetwork": "Разрешить локальную/частную сеть", "pws.liveModels": "Обнаруживать модели провайдера", "pws.liveModelsDesc": "Загружать актуальный каталог моделей провайдера. Выключите, чтобы использовать только настроенные статические модели.", + "pws.xaiResponsesOptIn": "Использовать Responses API для Grok 4.5 и 4.6", + "pws.xaiResponsesOptInDesc": "Направляет обе модели через openai-responses. Другие модели Grok и поведение tier не меняются.", + "pws.xaiResponsesOptInMixed": "Включено частично.", "pws.cursorTransport": "Транспорт Cursor", "pws.cursorTransportHttp2": "HTTP/2 (по умолчанию)", "pws.cursorTransportHttp1": "HTTP/1.1 (совместимость с прокси)", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 2fb2ca52fc..f460bbeb36 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1184,6 +1184,9 @@ export const tr: Record = { "pws.allowPrivateNetwork": "Yerel/özel ağa izin ver", "pws.liveModels": "Sağlayıcıdan canlı model keşfet", "pws.liveModelsDesc": "Sağlayıcının canlı model kataloğunu çekin.", + "pws.xaiResponsesOptIn": "Grok 4.5 ve 4.6 için Responses API kullan", + "pws.xaiResponsesOptInDesc": "İki modeli de openai-responses üzerinden yönlendirir. Diğer Grok modelleri ve katman davranışı değişmez.", + "pws.xaiResponsesOptInMixed": "Kısmen etkin.", "pws.cursorTransport": "Cursor aktarımı", "pws.cursorTransportHttp2": "HTTP/2 (varsayılan)", "pws.cursorTransportHttp1": "HTTP/1.1 (proxy uyumluluğu)", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index db77768547..942e21e61f 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -972,6 +972,9 @@ export const zhTW: Record = { "pws.allowPrivateNetwork": "允許本地/私有網路", "pws.liveModels": "從供應商發現模型", "pws.liveModelsDesc": "取得供應商的即時模型目錄。關閉後僅使用已配置的靜態模型。", + "pws.xaiResponsesOptIn": "讓 Grok 4.5 與 4.6 使用 Responses API", + "pws.xaiResponsesOptInDesc": "透過 openai-responses 路由這兩個模型。其他 Grok 模型與層級行為不變。", + "pws.xaiResponsesOptInMixed": "已部分啟用。", "pws.cursorTransport": "Cursor 傳輸協定", "pws.cursorTransportHttp2": "HTTP/2(預設)", "pws.cursorTransportHttp1": "HTTP/1.1(代理相容)", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 38d6499c4b..b9cd4a3573 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1715,6 +1715,9 @@ export const zh: Record = { "pws.allowPrivateNetwork": "允许本地/私有网络", "pws.liveModels": "从提供方发现模型", "pws.liveModelsDesc": "获取提供方的实时模型目录。关闭后仅使用已配置的静态模型。", + "pws.xaiResponsesOptIn": "为 Grok 4.5 和 4.6 使用 Responses API", + "pws.xaiResponsesOptInDesc": "通过 openai-responses 路由这两个模型。其他 Grok 模型和层级行为不变。", + "pws.xaiResponsesOptInMixed": "已部分启用。", "pws.cursorTransport": "Cursor 传输协议", "pws.cursorTransportHttp2": "HTTP/2(默认)", "pws.cursorTransportHttp1": "HTTP/1.1(代理兼容)", diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index b87b66bd9e..3b8607dc75 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -16,6 +16,7 @@ export interface ProvidersConfig { disabled?: boolean; note?: string; codexAccountMode?: "direct" | "pool"; + xaiResponsesOptInState?: boolean | "mixed"; }>; } diff --git a/gui/src/pages/use-providers-crud.ts b/gui/src/pages/use-providers-crud.ts index 20794ad582..926db4451a 100644 --- a/gui/src/pages/use-providers-crud.ts +++ b/gui/src/pages/use-providers-crud.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; import type { TFn } from "../i18n/shared"; -import type { ProviderUpdatePatch } from "../components/provider-workspace/types"; +import type { ProviderUpdatePatch, ProviderUpdateResult } from "../components/provider-workspace/types"; import { apiErrorMessage } from "../api-error"; type ProviderError = { code?: unknown; combos?: unknown; error?: unknown }; @@ -93,7 +93,7 @@ export function useProvidersCrud({ fetchProviderQuotas(true); }, [apiBase, fetchConfig, fetchOauth, fetchProviderQuotas, notify, t]); - const updateProvider = useCallback(async (name: string, patch: ProviderUpdatePatch): Promise<{ ok: boolean; error?: string }> => { + const updateProvider = useCallback(async (name: string, patch: ProviderUpdatePatch): Promise => { try { const res = await fetch(`${apiBase}/api/providers?name=${encodeURIComponent(name)}`, { method: "PATCH", @@ -103,6 +103,7 @@ export function useProvidersCrud({ if (!res.ok) { return { ok: false, error: await apiErrorMessage(res, t("prov.updateFail")) }; } + const data = await res.json().catch(() => ({})) as { xaiResponsesOptInState?: unknown }; // Await refresh so callers (e.g. notes editor) only leave edit mode once // item.note reflects the saved value. await fetchConfig(); @@ -113,7 +114,13 @@ export function useProvidersCrud({ if (refreshCodexAccount) refreshes.push(Promise.resolve(refreshCodexAccount())); await Promise.all(refreshes); } - return { ok: true }; + const state = data.xaiResponsesOptInState; + return { + ok: true, + ...(state === true || state === false || state === "mixed" + ? { xaiResponsesOptInState: state } + : {}), + }; } catch { return { ok: false, error: t("prov.networkError") }; } diff --git a/gui/src/provider-workspace/catalog.ts b/gui/src/provider-workspace/catalog.ts index d4e4a5e674..522aa32857 100644 --- a/gui/src/provider-workspace/catalog.ts +++ b/gui/src/provider-workspace/catalog.ts @@ -55,6 +55,8 @@ export interface WorkspaceProvider { }; /** Codex account routing mode for the canonical `openai` forward provider. */ codexAccountMode?: "direct" | "pool"; + /** Derived state of the two xAI Grok Responses model-adapter entries. */ + xaiResponsesOptInState?: boolean | "mixed"; } /** Three-way pricing/ownership tier for a ready provider row. */ diff --git a/gui/src/styles.css b/gui/src/styles.css index 667a477866..dadf818cad 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1159,6 +1159,7 @@ select.input { appearance: none; } -webkit-appearance: none; } .switch.on { background: var(--toggle-on-bg); border-color: var(--toggle-on-bg); } +.switch.mixed { background: var(--amber-soft); border-color: var(--amber); } .switch:disabled { opacity: 0.6; cursor: default; } .switch .knob { width: 14px; @@ -1179,6 +1180,7 @@ select.input { appearance: none; } background: var(--toggle-dot-color); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12); } +.switch.mixed .knob { transform: translateX(7px); } /* ---- misc ---- */ .muted { color: var(--muted); } diff --git a/gui/src/styles/provider-workspace-settings.css b/gui/src/styles/provider-workspace-settings.css index 3429106e7f..9f233c477d 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -26,6 +26,16 @@ .pwi-auth-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.pwi-auth-optin-row { + display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 10px 12px; border: 1px solid var(--border-soft); border-radius: var(--radius-xs); + background: var(--raised); +} +.pwi-auth-optin-copy { min-width: 0; display: flex; flex-direction: column; gap: 3px; } +.pwi-auth-optin-label { font-size: var(--text-control); font-weight: 600; color: var(--text); } +.pwi-auth-optin-mixed { color: var(--amber); font-weight: 600; } +.pwi-auth-optin-error { color: var(--red); font-size: var(--text-caption); } + .pwi-auth-wait { display: flex; flex-direction: column; gap: 6px; } .pwi-auth-wait-title { font-size: var(--text-control); font-weight: 600; color: var(--text); } .pwi-auth-wait-copy { font-size: var(--text-label); color: var(--muted); line-height: 1.45; } diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index 409bd3e16b..b5db510953 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -5,10 +5,10 @@ import { IconCheck, IconAlert } from "./icons"; import { IconChevron } from "./icons"; import { computeSelectMenuStyle } from "./select-position"; -export function Switch({ on, onClick, disabled, label }: { on: boolean; onClick: () => void; disabled?: boolean; label?: string }) { +export function Switch({ on, mixed = false, onClick, disabled, label }: { on: boolean; mixed?: boolean; onClick: () => void; disabled?: boolean; label?: string }) { return ( - ); diff --git a/gui/tests/provider-xai-responses-optin.test.tsx b/gui/tests/provider-xai-responses-optin.test.tsx new file mode 100644 index 0000000000..bdece63dc8 --- /dev/null +++ b/gui/tests/provider-xai-responses-optin.test.tsx @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import ProviderAuthPanel from "../src/components/provider-workspace/ProviderAuthPanel"; +import type { + ProviderAuthHandlers, + ProviderUpdatePatch, + ProviderUpdateResult, +} from "../src/components/provider-workspace/types"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let root: Root | null = null; +let container: HTMLElement; + +const handlers: ProviderAuthHandlers = { + onLogin: () => {}, + onLogout: () => {}, + onReauth: () => {}, + onSwitchAccount: () => {}, + onRemoveAccount: () => {}, + onAddApiKey: async () => true, + onSwitchApiKey: () => {}, + onRemoveApiKey: () => {}, + onEditAlias: () => {}, +}; + +beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map(key => [key, Reflect.get(globalThis, key)]), + ) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#providers/workspace" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +function xaiItem( + authMode: "oauth" | "key", + state: boolean | "mixed", +): WorkspaceItem { + return { + name: "xai", + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode, + hasApiKey: authMode === "key", + xaiResponsesOptInState: state, + }; +} + +async function mount( + item: WorkspaceItem, + onUpdateProvider: (name: string, patch: ProviderUpdatePatch) => Promise, +) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); +} + +function optInSwitch(): HTMLButtonElement { + const switches = container.querySelectorAll(".pwi-auth-optin-row .switch"); + expect(switches).toHaveLength(1); + return switches[0]!; +} + +test("OAuth xAI renders one mixed switch and applies the PATCH echoed effective state", async () => { + const patches: Array<{ name: string; patch: ProviderUpdatePatch }> = []; + await mount(xaiItem("oauth", "mixed"), async (name, patch) => { + patches.push({ name, patch }); + return { ok: true, xaiResponsesOptInState: true }; + }); + + expect(container.textContent).toContain("Available accounts"); + expect(container.textContent).toContain("Use Responses API for Grok 4.5 and 4.6"); + expect(container.textContent).toContain("Partially enabled."); + expect(optInSwitch().getAttribute("aria-pressed")).toBe("mixed"); + expect(optInSwitch().classList.contains("mixed")).toBe(true); + + await act(async () => { optInSwitch().click(); }); + + expect(patches).toEqual([{ name: "xai", patch: { xaiResponsesOptIn: true } }]); + expect(optInSwitch().getAttribute("aria-pressed")).toBe("true"); + expect(optInSwitch().classList.contains("mixed")).toBe(false); +}); + +test("API-key xAI renders the same single Responses opt-in switch", async () => { + await mount(xaiItem("key", false), async () => ({ + ok: true, + xaiResponsesOptInState: true, + })); + + expect(container.textContent).toContain("API Keys"); + expect(container.textContent).toContain("Use Responses API for Grok 4.5 and 4.6"); + expect(optInSwitch().getAttribute("aria-pressed")).toBe("false"); +}); diff --git a/gui/tests/use-providers-crud-update.test.tsx b/gui/tests/use-providers-crud-update.test.tsx index ef5a7ea1ea..6b72d233b8 100644 --- a/gui/tests/use-providers-crud-update.test.tsx +++ b/gui/tests/use-providers-crud-update.test.tsx @@ -3,7 +3,7 @@ import { act, useEffect, useRef, useState } from "react"; import type { Root } from "react-dom/client"; import { Window } from "happy-dom"; import { useProvidersCrud } from "../src/pages/use-providers-crud"; -import type { ProviderUpdatePatch } from "../src/components/provider-workspace/types"; +import type { ProviderUpdatePatch, ProviderUpdateResult } from "../src/components/provider-workspace/types"; const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; const originalFetch = globalThis.fetch; @@ -60,6 +60,22 @@ test("updateProvider awaits fetchConfig before returning success", async () => { await act(async () => { root.unmount(); }); }); +test("updateProvider returns the xAI opt-in effective state echoed by PATCH", async () => { + globalThis.fetch = (async () => Response.json({ + success: true, + xaiResponsesOptInState: "mixed", + })) as typeof fetch; + const { root, updateProvider } = await mountCrud(); + + let result: ProviderUpdateResult | undefined; + await act(async () => { + result = await updateProvider("xai", { xaiResponsesOptIn: true }); + }); + + expect(result).toEqual({ ok: true, xaiResponsesOptInState: "mixed" }); + await act(async () => { root.unmount(); }); +}); + async function mountCrud(overrides: { fetchConfig?: () => Promise; fetchProviderQuotas?: (refresh?: boolean) => Promise; diff --git a/src/providers/xai-responses-opt-in.ts b/src/providers/xai-responses-opt-in.ts new file mode 100644 index 0000000000..34cbc5dccc --- /dev/null +++ b/src/providers/xai-responses-opt-in.ts @@ -0,0 +1,15 @@ +import type { OcxProviderConfig } from "../types"; + +export const XAI_RESPONSES_OPT_IN_MODELS = ["grok-4.6", "grok-4.5"] as const; + +export type XaiResponsesOptInState = boolean | "mixed"; + +/** Derived dashboard/API state for the two modelAdapters entries owned by the xAI opt-in. */ +export function xaiResponsesOptInState(provider: OcxProviderConfig): XaiResponsesOptInState { + const enabled = XAI_RESPONSES_OPT_IN_MODELS.map( + model => provider.modelAdapters?.[model] === "openai-responses", + ); + if (enabled.every(Boolean)) return true; + if (enabled.some(Boolean)) return "mixed"; + return false; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 5fb03f3d0c..77ffa085c2 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -25,6 +25,7 @@ import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; +import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in"; let _corsOrigin = "http://localhost:10100"; export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; } @@ -688,6 +689,9 @@ export function safeConfigDTO(config: OcxConfig): unknown { hasApiKey: !!provider.apiKey, hasHeaders: !!provider.headers && Object.keys(provider.headers).length > 0, }; + if (name === "xai") { + dto.xaiResponsesOptInState = xaiResponsesOptInState(provider); + } for (const key of [ "defaultModel", "disabled", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 702f4c853d..47909e50f0 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -82,6 +82,10 @@ import { LOCAL_PROVIDER_RELOAD_PATH, } from "../../lib/local-provider-reload-contract"; import { refreshUserCostOverlays } from "../../usage/user-cost-overlays"; +import { + XAI_RESPONSES_OPT_IN_MODELS, + xaiResponsesOptInState, +} from "../../providers/xai-responses-opt-in"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; @@ -182,6 +186,20 @@ function applyProviderPatchFields( next.liveModels = rawBody.liveModels; touched = true; } + if (Object.hasOwn(rawBody, "xaiResponsesOptIn")) { + if (name !== "xai") return { error: "xaiResponsesOptIn is valid only for provider xai" }; + if (typeof rawBody.xaiResponsesOptIn !== "boolean") { + return { error: "xaiResponsesOptIn must be a boolean" }; + } + const modelAdapters = { ...(next.modelAdapters ?? {}) }; + for (const model of XAI_RESPONSES_OPT_IN_MODELS) { + if (rawBody.xaiResponsesOptIn) modelAdapters[model] = "openai-responses"; + else delete modelAdapters[model]; + } + if (Object.keys(modelAdapters).length > 0) next.modelAdapters = modelAdapters; + else delete next.modelAdapters; + touched = true; + } if (Object.hasOwn(rawBody, "requestPacing")) { const value = rawBody.requestPacing; if (value === null) { @@ -394,6 +412,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { }); }); + test("xAI Responses opt-in reports mixed state and atomically normalizes both model adapters", 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: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + modelAdapters: { + "grok-4.6": "openai-responses", + "other-model": "openai-chat", + }, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + }, + }, + }; + saveConfig(liveConfig); + const destinationProbe = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockResolvedValue(null); + const request = async (name: string, body: unknown) => { + const req = new Request(`http://127.0.0.1/api/providers?name=${name}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(), + }); + }; + + try { + const listedRequest = new Request("http://127.0.0.1/api/providers"); + const listedResponse = await handleManagementAPI( + listedRequest, + new URL(listedRequest.url), + liveConfig, + { createManagementConvergeCodex: catalogConvergenceFactory() }, + ); + const listed = await listedResponse!.json() as Array>; + expect(listed.find(row => row.name === "xai")?.xaiResponsesOptInState).toBe("mixed"); + expect(listed.find(row => row.name === "extra")).not.toHaveProperty("xaiResponsesOptInState"); + const configDto = safeConfigDTO(liveConfig) as { + providers: Record; + }; + expect(configDto.providers.xai?.xaiResponsesOptInState).toBe("mixed"); + + const wrongProvider = await request("extra", { xaiResponsesOptIn: true }); + expect(wrongProvider?.status).toBe(400); + expect(await wrongProvider?.json()).toEqual({ + error: "xaiResponsesOptIn is valid only for provider xai", + }); + expect((await request("xai", { xaiResponsesOptIn: "true" }))?.status).toBe(400); + + const enabled = await request("xai", { xaiResponsesOptIn: true }); + expect(enabled?.status).toBe(200); + expect(await enabled?.json()).toMatchObject({ + success: true, + name: "xai", + xaiResponsesOptInState: true, + }); + expect(liveConfig.providers.xai?.modelAdapters).toEqual({ + "grok-4.6": "openai-responses", + "grok-4.5": "openai-responses", + "other-model": "openai-chat", + }); + expect(loadConfig().providers.xai?.modelAdapters).toEqual(liveConfig.providers.xai?.modelAdapters); + + for (const model of ["grok-4.6", "grok-4.5"]) { + expect(fastPolicyForModel(liveConfig.providers.xai!, model, "xai").adapter) + .toBe("openai-responses"); + expect(resolveWireProtocolOverride("xai", model, liveConfig.providers.xai!).adapter) + .toBe("openai-responses"); + } + + const cleared = await request("xai", { xaiResponsesOptIn: false }); + expect(cleared?.status).toBe(200); + expect(await cleared?.json()).toMatchObject({ + success: true, + name: "xai", + xaiResponsesOptInState: false, + }); + expect(liveConfig.providers.xai?.modelAdapters).toEqual({ "other-model": "openai-chat" }); + expect(loadConfig().providers.xai?.modelAdapters).toEqual({ "other-model": "openai-chat" }); + } finally { + destinationProbe.mockRestore(); + } + }); + test("provider PATCH field-mask edits non-reserved providers and rejects unsafe fields (WP040)", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true });