diff --git a/docs-site/src/content/docs/fr/guides/grok-build.md b/docs-site/src/content/docs/fr/guides/grok-build.md index e1e8ccc977..1adeb25c59 100644 --- a/docs-site/src/content/docs/fr/guides/grok-build.md +++ b/docs-site/src/content/docs/fr/guides/grok-build.md @@ -15,13 +15,16 @@ en `~/.grok/config.toml` : ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -32,7 +35,7 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... autres niveaux de ce modèle, puis une table [model.ocx-*] par modèle visible ... +# ... autres niveaux de ce modèle, puis une table [model.ocx-*] par modèle visible, chacun référençant model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -77,12 +80,12 @@ en amont fixes, et non les métadonnées configurées pour les modèles routés. `none` et `minimal`, sont conservés lorsqu’ils sont annoncés. Les niveaux non pris en charge ou en double, notamment `ultra`, propre à Codex, sont omis du fichier afin que chaque option générée reste sélectionnable. -Grok Build communique avec opencodex au moyen de Chat Completions et envoie `reasoning_effort` lorsque -l’échelle est annoncée. Dans ce cas, le traducteur Chat Completions entrant définit par défaut le champ Responses -`reasoning.summary` sur `auto` ; les traces de raisonnement parviennent donc à Grok sous la forme -`delta.reasoning_content` au lieu d’être masquées. Réglez `include_reasoning: false` (ou -`reasoning.summary: "none"`) si un client souhaite que le modèle réfléchisse sans renvoyer le -tracé. Une valeur explicite de `reasoning.summary` prévaut lorsque les deux options sont présentes. +Grok Build communique avec opencodex au moyen de l’API Responses. Lorsque la route annonce une +échelle de raisonnement, le relais Responses transmet `reasoning.summary` tel que configuré, si bien +que les traces de raisonnement parviennent à Grok nativement sous forme d’éléments de raisonnement +Responses. Réglez `reasoning.summary: "none"` si un client souhaite que le modèle réfléchisse sans +renvoyer le tracé. Une valeur explicite de `reasoning.summary` prévaut sur la valeur par défaut de la +route. ## Note d'authentification @@ -101,46 +104,49 @@ dehors des marqueurs gérés, où aucune opération opencodex ne peut les écras `base_url` (une adresse réellement accessible depuis l’endroit où vous exécutez `grok`) et `api_key` (votre `OPENCODEX_API_AUTH_TOKEN`). -Ne remplacez pas `api_key` par `env_key` ici. En l’absence de `model_provider`, un `env_key` qui ne peut pas être +Ne remplacez pas `api_key` par `env_key` ici. Un `env_key` qui ne peut pas être résolu n’interrompt pas la requête : Grok utilise alors votre jeton de session xAI et l’envoie à l’adresse `base_url` indiquée par l’entrée. Pour un déploiement sur le réseau local, cette adresse est un point de terminaison HTTP en clair qui n’appartient pas à xAI. -La valeur `api_key` injectée pour chaque modèle se trouve en tête de la chaîne d’identifiants de Grok. Les requêtes +La valeur `api_key` injectée sur l’entrée du fournisseur se trouve en tête de la chaîne d’identifiants de Grok. Les requêtes adressées à opencodex ne nécessitent donc aucune connexion Grok supplémentaire. Conservez votre configuration habituelle `grok login` / `XAI_API_KEY` pour les modèles Grok natifs et les fonctions qui contactent directement xAI. ## Recette manuelle (sans enregistrement automatique) -Si vous gérez `~/.grok/config.toml` vous-même — ou si opencodex est sur une liaison sans bouclage — ajoutez -tables par modèle avec **champs directs**, en dehors des marqueurs `# >>> opencodex managed block` : +Si vous gérez `~/.grok/config.toml` vous-même — ou si opencodex est sur une liaison sans bouclage — ajoutez un bloc +`[model_providers.opencodex]` et des tables par modèle qui le référencent via `model_provider`, en dehors des +marqueurs `# >>> opencodex managed block` : ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -Pour un proxy joignable sur le réseau, pointer `base_url` à l'adresse `grok` peut effectivement -composez et utilisez votre jeton d'entrée : +Pour un proxy joignable sur le réseau, pointez `base_url` vers l’adresse que `grok` peut réellement +joindre et utilisez votre jeton d’entrée : ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -Ne comptez pas sur l'héritage `[model_providers.]` pour le point de terminaison : à partir de Grok Build -0.2.101 le `base_url` hérité n'est pas appliqué au routage d'inférence (les requêtes tombent -jusqu'au proxy xAI par défaut et échoue avec 401). Itinéraire direct des champs par modèle -correctement. +Le bloc géré utilise désormais l’héritage `[model_providers.]`, ce qui nécessite Grok Build 0.2.109 ou ultérieur (publié le 2026-07-21). Sur les versions antérieures, le `base_url` hérité n’est pas appliqué au routage d’inférence — mettez à niveau, ou utilisez des champs directs par modèle (`base_url`/`api_backend`/`api_key` sur chaque table `[model.*]`). -Placez entre guillemets tout alias contenant un point : `[model.grok-4.5]` sans guillemets est un chemin de clé à trois segments, et non -l'identifiant `grok-4.5`. Les alias générés évitent entièrement les points pour cette raison. +Placez entre guillemets tout alias contenant un point : `[model.grok-4.5]` sans guillemets est un chemin de clé à trois segments, et non l'identifiant `grok-4.5`. Les alias générés évitent entièrement les points pour cette raison. ## Limitations connues diff --git a/docs-site/src/content/docs/guides/grok-build.md b/docs-site/src/content/docs/guides/grok-build.md index f331f608d2..eb60ed3f4c 100644 --- a/docs-site/src/content/docs/guides/grok-build.md +++ b/docs-site/src/content/docs/guides/grok-build.md @@ -15,13 +15,16 @@ into `~/.grok/config.toml`: ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -32,7 +35,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -102,44 +106,51 @@ outside the managed markers, where nothing opencodex does can clobber them. See `base_url` (a host that is actually reachable from where you run `grok`) and `api_key` (your `OPENCODEX_API_AUTH_TOKEN`). -Do not replace `api_key` with `env_key` here. With no `model_provider` set, an `env_key` -that fails to resolve does not stop the request — Grok falls through to your xAI session +Do not replace `api_key` with `env_key` here. An `env_key` that fails to resolve does not +stop the request — Grok falls through to your xAI session token and sends it to whatever `base_url` the entry names, which for a LAN deployment is a plaintext HTTP endpoint that is not xAI. -The injected per-model `api_key` sits first in Grok's credential chain for these models, -so turns against opencodex need no additional Grok login. Keep your normal `grok login` / -`XAI_API_KEY` setup for native grok models and any harness features that contact xAI -directly. +The injected `api_key` on the provider entry sits first in Grok's credential chain for +these models, so turns against opencodex need no additional Grok login. Keep your normal +`grok login` / `XAI_API_KEY` setup for native grok models and any harness features that +contact xAI directly. ## Manual recipe (without auto-registration) If you manage `~/.grok/config.toml` yourself — or opencodex is on a non-loopback bind — add -per-model tables with **direct fields**, outside the `# >>> opencodex managed block` markers: +a `[model_providers.opencodex]` block and per-model tables that reference it, outside the +`# >>> opencodex managed block` markers: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` For a proxy reachable over the network, point `base_url` at the address `grok` can actually dial and use your admission token: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -Do not rely on `[model_providers.]` inheritance for the endpoint: as of Grok Build -0.2.101 the inherited `base_url` is not applied to inference routing (requests fall -through to the default xAI proxy and fail with 401). Direct per-model fields route -correctly. +This uses `[model_providers.]` inheritance, which requires Grok Build 0.2.109 or later +(released 2026-07-21). On older versions the inherited `base_url` is not applied to inference +routing — upgrade, or fall back to per-model direct fields (`base_url`/`api_backend`/`api_key` +on each `[model.*]` table). Quote any alias containing a dot: bare `[model.grok-4.5]` is a three-segment key path, not the id `grok-4.5`. Generated aliases avoid dots entirely for this reason. diff --git a/docs-site/src/content/docs/ja/guides/grok-build.md b/docs-site/src/content/docs/ja/guides/grok-build.md index d40efd2633..b05edc14d7 100644 --- a/docs-site/src/content/docs/ja/guides/grok-build.md +++ b/docs-site/src/content/docs/ja/guides/grok-build.md @@ -11,13 +11,16 @@ opencodex はローカル ポート上で OpenAI 互換の `POST /v1/chat/comple ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -28,7 +31,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -67,13 +71,11 @@ Grok 互換に投影した内容が、管理対象の各 `[model.*]` テーブ 保持されます。Codex 固有の `ultra` を含む、未対応または重複する段階はファイルから 除外され、出力された選択肢はすべて実行できます。 -Grok Build は Chat Completions 経由で opencodex と通信し、ラダーが公開されている -場合は `reasoning_effort` を送ります。Chat Completions の入力変換は、この場合に -内部 Responses の `reasoning.summary` を `auto` に設定するため、推論トレースは -`delta.reasoning_content` として Grok に届きます。トレースを返さずにモデルに -推論させるクライアントは、`include_reasoning: false`(または -`reasoning.summary: "none"`)を設定できます。両方が指定された場合は、明示的な -`reasoning.summary` が優先されます。 +Grok Build は Responses API 経由で opencodex と通信します。ルートが推論ラダーを +公開している場合、Responses パススルーは設定どおりに `reasoning.summary` を転送するため、 +推論トレースは Responses の reasoning 項目としてそのまま Grok に届きます。トレースを +返さずにモデルに推論させるクライアントは、`reasoning.summary: "none"` を設定できます。 +明示的な `reasoning.summary` はルートの既定値より優先されます。 ## 認証メモ @@ -81,33 +83,39 @@ Grok Build では、ループバックでもカスタム モデルに対して **自動登録はループバックのみです。** opencodex が非ループバック ホスト (すべてのインターフェイスを公開するワイルドカード `0.0.0.0` および `::` を含む) をバインドする場合、リクエストには実際のアドミッション トークンが必要であり、マネージド ブロックはそれを安全に運ぶことができません。リテラルトークンを書き込むと、シークレットが `~/.grok/config.toml` に設定され、そこで設定した内容が次の `ocx start`/`ensure`/`restart` に上書きされます。したがって、その場合、opencodex は何も書き込みません (そして、以前のループバック バインドで残ったブロックはすべて削除します)。また、管理対象マーカーの外側でモデルを自分で設定します。opencodex が何をしてもモデルを破壊することはありません。正確なテーブルについては [マニュアルレシピ](#manual-recipe-without-auto-registration) を参照し、`base_url` (`grok` を実行する場所から実際に到達可能なホスト) と `api_key` (`OPENCODEX_API_AUTH_TOKEN`) の両方を設定します。 -ここで `api_key` を `env_key` に置き換えないでください。 `model_provider` が設定されていない場合、解決に失敗した `env_key` はリクエストを停止しません。Grok は xAI セッション トークンに到達し、それをエントリ名が `base_url` に送信します。LAN デプロイメントの場合、これは xAI ではないプレーンテキスト HTTP エンドポイントです。 +ここで `api_key` を `env_key` に置き換えないでください。解決に失敗した `env_key` はリクエストを停止しません。Grok は xAI セッション トークンに到達し、それをエントリ名が `base_url` に送信します。LAN デプロイメントの場合、これは xAI ではないプレーンテキスト HTTP エンドポイントです。 -注入されたモデルごとの `api_key` は、これらのモデルの Grok 資格情報チェーンの最初に位置するため、opencodex に対抗する場合は追加の Grok ログインは必要ありません。ネイティブ grok モデルおよび xAI に直接接続するハーネス機能については、通常の `grok login` / `XAI_API_KEY` セットアップを維持します。 +プロバイダー エントリに注入された `api_key` は、これらのモデルの Grok 資格情報チェーンの最初に位置するため、opencodex に対抗する場合は追加の Grok ログインは必要ありません。ネイティブ grok モデルおよび xAI に直接接続するハーネス機能については、通常の `grok login` / `XAI_API_KEY` セットアップを維持します。 ## 手動レシピ(自動登録なし) -`~/.grok/config.toml` を自分で管理する場合、または opencodex が非ループバック バインド上にある場合は、**直接フィールド**を持つモデルごとのテーブルを `# >>> opencodex managed block` マーカーの外側に追加します。 +`~/.grok/config.toml` を自分で管理する場合、または opencodex が非ループバック バインド上にある場合は、`[model_providers.opencodex]` ブロックとそれを参照するモデルごとのテーブルを `# >>> opencodex managed block` マーカーの外側に追加します。 ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` ネットワーク経由で到達可能なプロキシの場合は、`grok` が実際にダイヤルしてアドミッション トークンを使用できるアドレスに `base_url` を指定します。 ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -エンドポイントの `[model_providers.]` 継承に依存しないでください。Grok Build 0.2.101 では、継承された `base_url` は推論ルーティングに適用されません (リクエストはデフォルトの xAI プロキシにフォールスルーされ、401 で失敗します)。モデルごとのフィールドを正しくルーティングします。 +管理ブロックは `[model_providers.]` 継承を使用するようになり、Grok Build 0.2.109 以降(2026-07-21 リリース)が必要です。旧バージョンでは継承された `base_url` は推論ルーティングに適用されません——アップグレードするか、各 `[model.*]` テーブルでモデルごとの直接フィールド(`base_url`/`api_backend`/`api_key`)を使用してください。 ドットを含むエイリアスを引用符で囲みます。裸の `[model.grok-4.5]` は 3 セグメントのキー パスであり、ID `grok-4.5` ではありません。この理由により、生成されたエイリアスではドットが完全に回避されます。 diff --git a/docs-site/src/content/docs/ko/guides/grok-build.md b/docs-site/src/content/docs/ko/guides/grok-build.md index c91481e0e1..e8d56cd555 100644 --- a/docs-site/src/content/docs/ko/guides/grok-build.md +++ b/docs-site/src/content/docs/ko/guides/grok-build.md @@ -11,13 +11,16 @@ opencodex는 로컬 포트에서 OpenAI 호환 `POST /v1/chat/completions`(및 ` ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -28,7 +31,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -62,13 +66,11 @@ Grok 호환 형태로 투영한 결과가 각 관리형 `[model.*]` 테이블에 Codex 전용 `ultra`를 포함해 지원되지 않거나 중복된 단계는 파일에서 제외되어 기록된 모든 선택지는 실행 가능합니다. -Grok Build는 Chat Completions를 통해 opencodex와 통신하고 단계 목록이 제공되면 -`reasoning_effort`를 보냅니다. 이 경우 Chat Completions 입력 변환기는 내부 Responses의 -`reasoning.summary` 기본값을 `auto`로 설정하므로 추론 트레이스가 -`delta.reasoning_content`로 Grok에 전달됩니다. 모델은 추론하되 트레이스를 반환하지 -않도록 하려는 클라이언트는 `include_reasoning: false`(또는 -`reasoning.summary: "none"`)를 설정할 수 있습니다. 두 값이 함께 있으면 명시적인 -`reasoning.summary`가 우선합니다. +Grok Build는 Responses API를 통해 opencodex와 통신합니다. 라우트가 추론 단계 목록을 +광고하면 Responses passthrough가 설정된 대로 `reasoning.summary`를 전달하므로 추론 +트레이스가 Responses reasoning 항목으로 Grok에 그대로 도착합니다. 모델은 추론하되 +트레이스를 반환하지 않게 하려는 클라이언트는 `reasoning.summary: "none"`을 설정할 수 +있습니다. 명시적인 `reasoning.summary`는 라우트 기본값보다 우선합니다. ## 인증 참고 @@ -76,33 +78,39 @@ Grok Build는 루프백에서도 사용자 정의 모델에 비어 있지 않은 **자동 등록은 루프백 전용입니다.** opencodex가 비루프백 호스트에 바인드하면, 모든 인터페이스를 노출하는 와일드카드 `0.0.0.0`와 `::`를 포함해 요청은 실제 admission token을 필요로 하고, 관리 블록은 그 값을 안전하게 담을 수 없습니다. 토큰을 그대로 쓰면 비밀값이 `~/.grok/config.toml`에 들어가고, 다음 `ocx start`/`ensure`/`restart` 때 그 자리에 있던 값이 덮어써집니다. 그래서 opencodex는 그런 경우 아무 것도 쓰지 않고(이전에 루프백 바인드가 남긴 블록도 제거합니다), 사용자는 관리 마커 바깥에서 모델을 직접 설정해야 합니다. 이 위치에서는 opencodex가 어떤 일을 해도 그 설정을 덮어쓸 수 없습니다. 정확한 테이블은 [수동 설정](#manual-recipe-without-auto-registration)을 보시고, `base_url`(실제로 `grok`가 도달할 수 있는 호스트)과 `api_key`(사용자의 `OPENCODEX_API_AUTH_TOKEN`)를 함께 설정합니다. -여기서는 `api_key`를 `env_key`로 바꾸지 마십시오. `model_provider`를 설정하지 않은 상태에서 `env_key`가 해결되지 않아도 요청은 멈추지 않습니다. Grok가 사용자의 xAI 세션 토큰으로 넘어가서 항목이 가리키는 `base_url`로 보냅니다. LAN 배포에서는 그 `base_url`이 xAI가 아닌 평문 HTTP 엔드포인트입니다. +여기서는 `api_key`를 `env_key`로 바꾸지 마십시오. `env_key`가 해결되지 않아도 요청은 멈추지 않습니다. Grok가 사용자의 xAI 세션 토큰으로 넘어가서 항목이 가리키는 `base_url`로 보냅니다. LAN 배포에서는 그 `base_url`이 xAI가 아닌 평문 HTTP 엔드포인트입니다. -주입된 모델별 `api_key`는 이 모델들에 대한 Grok의 자격 증명 체인에서 가장 먼저 사용되므로, opencodex를 대상으로 하는 요청에는 추가 Grok 로그인이 필요하지 않습니다. xAI에 직접 접속하는 네이티브 grok 모델과 모든 하니스 기능에는 평소 쓰던 `grok login` / `XAI_API_KEY` 구성을 그대로 유지합니다. +provider 항목에 주입된 `api_key`는 이 모델들에 대한 Grok의 자격 증명 체인에서 가장 먼저 사용되므로, opencodex를 대상으로 하는 요청에는 추가 Grok 로그인이 필요하지 않습니다. xAI에 직접 접속하는 네이티브 grok 모델과 모든 하니스 기능에는 평소 쓰던 `grok login` / `XAI_API_KEY` 구성을 그대로 유지합니다. ## 수동 설정 (자동 등록 없음) -직접 `~/.grok/config.toml`를 관리하거나 opencodex가 비루프백 호스트에 바인드되어 있다면, `# >>> opencodex managed block` 마커 바깥에 모델별 테이블을 직접 필드 형태로 작성합니다: +직접 `~/.grok/config.toml`를 관리하거나 opencodex가 비루프백 호스트에 바인드되어 있다면, `# >>> opencodex managed block` 마커 바깥에 `[model_providers.opencodex]` 블록과 이를 참조하는 모델별 테이블을 추가합니다: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` 네트워크에서 닿을 수 있는 프록시라면 `base_url`을 `grok`가 실제로 연결할 수 있는 주소로 두고 승인 토큰을 사용합니다: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -`[model_providers.]` 상속에 엔드포인트를 맡기지 마십시오. Grok Build 0.2.101 기준으로 상속된 `base_url`은 추론 라우팅에 적용되지 않습니다(요청은 기본 xAI 프록시로 넘어가고 401로 실패합니다). 직접 넣은 모델별 필드는 정상적으로 라우팅됩니다. +관리 블록은 이제 `[model_providers.]` 상속을 사용하며, Grok Build 0.2.109 이상(2026-07-21 출시)이 필요합니다. 이전 버전에서는 상속된 `base_url`이 추론 라우팅에 적용되지 않습니다 — 업그레이드하거나, 각 `[model.*]` 테이블에 모델별 직접 필드(`base_url`/`api_backend`/`api_key`)를 사용하세요. 점이 들어간 별칭은 반드시 따옴표로 감쌉니다. 대괄호만 쓴 `[model.grok-4.5]`는 id `grok-4.5`가 아니라 세 구간짜리 키 경로입니다. 생성된 별칭은 이런 이유로 점을 아예 쓰지 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/grok-build.md b/docs-site/src/content/docs/ru/guides/grok-build.md index 4fd3ee9c21..bd67ff17ce 100644 --- a/docs-site/src/content/docs/ru/guides/grok-build.md +++ b/docs-site/src/content/docs/ru/guides/grok-build.md @@ -15,13 +15,16 @@ Grok Build — вручную редактировать конфигураци ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -32,7 +35,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -74,12 +78,12 @@ Grok проекция этой шкалы записывается в кажду когда модель их объявляет. Неподдерживаемые или повторяющиеся уровни, в том числе предназначенный для Codex `ultra`, исключаются из файла; каждый записанный пункт остаётся доступным для выбора. -Grok Build обращается к opencodex через Chat Completions и отправляет `reasoning_effort`, когда -шкала опубликована. В этом случае входной преобразователь Chat Completions задаёт внутреннему -Responses `reasoning.summary` значение `auto`, поэтому трассировка рассуждений приходит в Grok -как `delta.reasoning_content`. Клиент может оставить рассуждение модели и скрыть трассировку с -помощью `include_reasoning: false` (или `reasoning.summary: "none"`). При наличии обоих -параметров приоритет имеет явно заданный `reasoning.summary`. +Grok Build обращается к opencodex через Responses API. Когда маршрут объявляет шкалу +рассуждений, passthrough Responses пересылает `reasoning.summary` в соответствии с настройкой, +поэтому трассировка рассуждений доходит до Grok нативно в виде элементов reasoning Responses. +Клиент может оставить рассуждение модели и скрыть трассировку с помощью +`reasoning.summary: "none"`. Явно заданный `reasoning.summary` имеет приоритет над значением +по умолчанию для маршрута. ## Замечание об аутентификации @@ -98,12 +102,12 @@ admission token, а управляемый блок не может безопа действительно достижим из того места, где вы запускаете `grok`, и в `api_key` укажите `OPENCODEX_API_AUTH_TOKEN`. -Не заменяйте здесь `api_key` на `env_key`. Если `model_provider` не задан, `env_key`, который не +Не заменяйте здесь `api_key` на `env_key`. `env_key`, который не разрешился, не останавливает запрос — Grok откатывается к вашему session token xAI и отправляет его на любой `base_url`, указанный в записи, а для LAN-развёртывания это plaintext HTTP-endpoint, который не является xAI. -Внедрённый `api_key` на уровне модели стоит первым в цепочке учётных данных Grok для этих моделей, +Внедрённый в запись провайдера `api_key` стоит первым в цепочке учётных данных Grok для этих моделей, поэтому ходам через opencodex не нужен дополнительный `grok login`. Обычную настройку `grok login` / `XAI_API_KEY` сохраняйте для нативных grok-моделей и любых harness-функций, которые напрямую обращаются к xAI. @@ -111,31 +115,35 @@ admission token, а управляемый блок не может безопа ## Ручной рецепт без авторегистрации Если вы управляете `~/.grok/config.toml` сами — либо opencodex привязан не к loopback, — -добавляйте таблицы по одной модели с **прямыми полями**, вне маркеров -`# >>> opencodex managed block`: +добавляйте блок `[model_providers.opencodex]` и таблицы по одной модели, которые его +ссылают, вне маркеров `# >>> opencodex managed block`: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` Для прокси, доступного по сети, укажите в `base_url` адрес, до которого `grok` реально может дозвониться, и используйте свой admission token: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -Не полагайтесь на наследование `[model_providers.]` для endpoint'а: по состоянию на Grok Build -0.2.101 унаследованный `base_url` не применяется к маршрутизации inference (запросы откатываются к -прокси xAI по умолчанию и падают с 401). Прямые поля на уровне модели маршрутизируются правильно. +Управляемый блок теперь использует наследование `[model_providers.]`, что требует Grok Build 0.2.109 или новее (выпущен 2026-07-21). На более старых версиях унаследованный `base_url` не применяется к маршрутизации inference — обновитесь, либо используйте прямые поля на уровне модели (`base_url`/`api_backend`/`api_key` в каждой таблице `[model.*]`). Любой alias, содержащий точку, берите в кавычки: голый `[model.grok-4.5]` — это путь из трёх сегментов, а не id `grok-4.5`. Сгенерированные alias по этой причине вообще избегают точек. diff --git a/docs-site/src/content/docs/tr/guides/grok-build.md b/docs-site/src/content/docs/tr/guides/grok-build.md index d23689dd8a..f9e8a7d6c5 100644 --- a/docs-site/src/content/docs/tr/guides/grok-build.md +++ b/docs-site/src/content/docs/tr/guides/grok-build.md @@ -16,13 +16,16 @@ gerekmez. ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -33,7 +36,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -80,13 +84,12 @@ geçerli Grok katmanları, `none` ve `minimal` dahil olmak üzere korunur. Codex `ultra` dahil desteklenmeyen veya yinelenen katmanlar dosyadan çıkarılır; yazılan her seçenek seçilebilir durumda kalır. -Grok Build, opencodex ile Chat Completions üzerinden konuşur ve merdiven -bildirildiğinde `reasoning_effort` gönderir. Bu durumda Chat Completions giriş -dönüştürücüsü, dahili Responses `reasoning.summary` değerini varsayılan olarak -`auto` yapar; böylece akıl yürütme izleri Grok'a `delta.reasoning_content` -olarak ulaşır. Modelin akıl yürütmesini sürdürüp izi gizlemek isteyen bir istemci -`include_reasoning: false` (veya `reasoning.summary: "none"`) ayarlayabilir. Her -iki seçenek de bulunduğunda açıkça belirtilen `reasoning.summary` önceliklidir. +Grok Build, opencodex ile Responses API üzerinden konuşur. Bir rota akıl yürütme +merdivenini bildirdiğinde, Responses passthrough `reasoning.summary` değerini +yapılandırıldığı şekilde iletir; böylece akıl yürütme izleri Responses reasoning +öğeleri olarak Grok'a doğrudan ulaşır. Modelin akıl yürütmesini sürdürüp izi +gizlemek isteyen bir istemci `reasoning.summary: "none"` ayarlayabilir. +Açıkça belirtilen `reasoning.summary`, rotanın varsayılan değerine üstünlük tanır. ## Kimlik doğrulama notu @@ -109,13 +112,13 @@ tarif](#otomatik-kayit-olmadan-manuel-tarif) bölümüne bakın ve hem `base_url (gerçekte `grok` çalıştırdığınız yerden erişilebilen bir ana bilgisayar) hem de `api_key` (`OPENCODEX_API_AUTH_TOKEN` değeriniz) ayarlayın. -Burada `api_key`'i `env_key` ile değiştirmeyin. `model_provider` -ayarlanmadığında, çözümlenemeyen bir `env_key` isteği durdurmaz — Grok, xAI +Burada `api_key`'i `env_key` ile değiştirmeyin. Çözümlenemeyen bir +`env_key` isteği durdurmaz — Grok, xAI oturum belirtecinize geri döner ve bunu girdinin adlandırdığı `base_url`'e gönderir; bu da bir LAN dağıtımı için xAI olmayan düz metin bir HTTP uç noktasıdır. -Enjekte edilen model başına `api_key`, bu modeller için Grok'un kimlik bilgisi +Provider girdisine enjekte edilen `api_key`, bu modeller için Grok'un kimlik bilgisi zincirinde ilk sırada yer alır; bu nedenle opencodex'e karşı yapılan dönüşler ek bir Grok girişi gerektirmez. Yerel grok modelleri ve doğrudan xAI ile iletişim kuran herhangi bir donanım özelliği için normal `grok login` / `XAI_API_KEY` @@ -125,31 +128,35 @@ kurulumunuzu koruyun. `~/.grok/config.toml` dosyasını kendiniz yönetiyorsanız — veya opencodex geri döngü olmayan bir bağlantıdaysa — `# >>> opencodex managed block` -işaretçilerinin dışına **doğrudan alanlarla** model başına tablolar ekleyin: +işaretçilerinin dışına bir `[model_providers.opencodex]` bloğu ve bunu +referans alan model başına tablolar ekleyin: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` Ağ üzerinden erişilebilen bir proxy için `base_url`'i `grok`'un gerçekten -çevirebileceği adrese yönlendirin ve kabul belirtecinizi kullanın: +bağlanabileceği adrese yönlendirin ve kabul belirtecinizi kullanın: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # 127.0.0.1 değil, erişilebilir ana bilgisayar api_backend = "responses" api_key = "OPENCODEX_API_AUTH_TOKEN_DEGERINIZ" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -Uç nokta için `[model_providers.]` kalıtımına güvenmeyin: Grok Build 0.2.101 -itibarıyla devralınan `base_url` çıkarım yönlendirmesine uygulanmaz (istekler -varsayılan xAI proxy'sine düşer ve 401 ile başarısız olur). Doğrudan model -başına alanlar doğru şekilde yönlendirilir. +Yönetilen blok artık `[model_providers.]` kalıtımını kullanıyor, bu da Grok Build 0.2.109 veya sonrasını gerektirir (2026-07-21'de yayınlandı). Eski sürümlerde devralınan `base_url` çıkarım yönlendirmesine uygulanmaz — yükseltin veya her `[model.*]` tablosunda model başına doğrudan alanlar (`base_url`/`api_backend`/`api_key`) kullanın. Nokta içeren herhangi bir takma adı tırnak içine alın: yalın `[model.grok-4.5]`, `grok-4.5` kimliği değil, üç segmentli bir anahtar yoludur. Oluşturulan takma diff --git a/docs-site/src/content/docs/zh-cn/guides/grok-build.md b/docs-site/src/content/docs/zh-cn/guides/grok-build.md index 97ed853d54..2c7babb4c3 100644 --- a/docs-site/src/content/docs/zh-cn/guides/grok-build.md +++ b/docs-site/src/content/docs/zh-cn/guides/grok-build.md @@ -11,13 +11,16 @@ opencodex 在本地端口提供一个与 OpenAI 兼容的 `POST /v1/chat/complet ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -28,7 +31,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -59,12 +63,10 @@ opencodex 会映射已配置的提供方档位(`reasoningEfforts` / 模型声明的有效 Grok 档位(包括 `none` 和 `minimal`)都会保留。不受支持或重复的档位 (包括 Codex 专用的 `ultra`)会从文件中省略,从而确保写出的每个选项都可执行。 -Grok Build 通过 Chat Completions 与 opencodex 通信,并在声明档位时发送 -`reasoning_effort`。在这种情况下,Chat Completions 入站转换器会将内部 Responses 的 -`reasoning.summary` 默认设为 `auto`,因此推理轨迹会以 `delta.reasoning_content` -到达 Grok。需要模型执行推理且不返回轨迹的客户端,可以设置 -`include_reasoning: false`(或 `reasoning.summary: "none"`)。两个选项同时出现时, -显式的 `reasoning.summary` 优先。 +Grok Build 通过 Responses API 与 opencodex 通信。当路由声明推理档位时,Responses +直通会按配置转发 `reasoning.summary`,因此推理轨迹会以 Responses reasoning 项的形式 +原生到达 Grok。需要模型执行推理且不返回轨迹的客户端,可以设置 +`reasoning.summary: "none"`。显式设置的 `reasoning.summary` 优先于路由默认值。 ## 认证说明 @@ -72,33 +74,39 @@ Grok Build 通过 Chat Completions 与 opencodex 通信,并在声明档位时 **自动注册仅限 loopback。** 当 opencodex 绑定到非 loopback 主机时——包括通配符 `0.0.0.0` 和 `::`,它们会暴露所有网卡——请求需要你的真实接入令牌,而受管理区块无法安全地携带它。把字面令牌写进去会把你的密钥放进 `~/.grok/config.toml`,并在下次 `ocx start`/`ensure`/`restart` 时覆盖你在那里设置的内容。所以在这种情况下,opencodex 根本不会写入任何内容(并且会移除早先 loopback 绑定留下的任何区块),然后你需要在受管理标记之外自己配置这些模型,因为 opencodex 在那里做的任何事都不会覆盖它们。精确表结构见[手动方案](#manual-recipe-without-auto-registration),并同时设置 `base_url`(从你运行 `grok` 的位置实际可达的主机)和 `api_key`(你的 `OPENCODEX_API_AUTH_TOKEN`)。 -不要在这里把 `api_key` 换成 `env_key`。在未设置 `model_provider` 的情况下,解析失败的 `env_key` 不会阻止请求——Grok 会回退到你的 xAI 会话令牌,并把它发送到该条目指定的 `base_url`,而对于局域网部署来说,这通常是一个并非 xAI 的明文 HTTP 端点。 +不要在这里把 `api_key` 换成 `env_key`。解析失败的 `env_key` 不会阻止请求——Grok 会回退到你的 xAI 会话令牌,并把它发送到该条目指定的 `base_url`,而对于局域网部署来说,这通常是一个并非 xAI 的明文 HTTP 端点。 -这些模型注入的逐模型 `api_key` 会在 Grok 的凭据链中排在首位,因此对接 opencodex 时不需要额外登录 Grok。原生 grok 模型以及任何会直接联系 xAI 的 harness 功能,仍然保留你正常的 `grok login` / `XAI_API_KEY` 配置。 +注入在 provider 条目上的 `api_key` 会在这些模型的 Grok 凭据链中排在首位,因此对接 opencodex 时不需要额外登录 Grok。原生 grok 模型以及任何会直接联系 xAI 的 harness 功能,仍然保留你正常的 `grok login` / `XAI_API_KEY` 配置。 ## 手动方案(不使用自动注册) -如果你自己管理 `~/.grok/config.toml`——或者 opencodex 绑定在非 loopback 地址上——请在 `# >>> opencodex managed block` 标记之外,添加带有**直接字段**的逐模型表: +如果你自己管理 `~/.grok/config.toml`——或者 opencodex 绑定在非 loopback 地址上——请在 `# >>> opencodex managed block` 标记之外,添加一个 `[model_providers.opencodex]` 区块以及引用它的逐模型表: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` 如果代理可通过网络访问,请把 `base_url` 指向 `grok` 实际可以连接的地址,并使用你的接入令牌: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -不要依赖 `[model_providers.]` 继承来提供端点:截至 Grok Build 0.2.101,继承下来的 `base_url` 不会应用到推理路由(请求会落回默认的 xAI 代理,并以 401 失败)。直接在逐模型字段中配置可以正确路由。 +托管区块现在使用 `[model_providers.]` 继承,需要 Grok Build 0.2.109 或更高版本(发布于 2026-07-21)。在更早的版本上,继承的 `base_url` 不会应用到推理路由——请升级,或在每个 `[model.*]` 表上使用逐模型直接字段(`base_url`/`api_backend`/`api_key`)。 任何包含点号的别名都要加引号:裸写的 `[model.grok-4.5]` 是一个三段式键路径,而不是 id `grok-4.5`。为此,生成的别名会完全避免使用点号。 diff --git a/docs-site/src/content/docs/zh-tw/guides/grok-build.md b/docs-site/src/content/docs/zh-tw/guides/grok-build.md index e0d2320df4..a8f4350873 100644 --- a/docs-site/src/content/docs/zh-tw/guides/grok-build.md +++ b/docs-site/src/content/docs/zh-tw/guides/grok-build.md @@ -11,13 +11,16 @@ opencodex 在本機埠提供 OpenAI 相容的 `POST /v1/chat/completions`(以 ```toml # >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> -[model.ocx-gpt-5-6-sol] -model = "gpt-5.6-sol" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" -name = "OCX gpt-5.6-sol" extra_headers = { "x-opencodex-grok" = "1" } + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" context_window = 272000 supports_reasoning_effort = true reasoning_effort = "low" @@ -28,7 +31,8 @@ value = "low" label = "Low" description = "Quick, fast implementations" default = true -# ... remaining rungs for this model, then one [model.ocx-*] table per visible model ... +# ... remaining rungs for this model, then one [model.ocx-*] table per visible model, +# each referencing model_provider = "opencodex" ... # <<< opencodex managed block <<< ``` @@ -58,11 +62,10 @@ reasoning,或把檔位對映到供應商專用欄位。階梯清單為空的 條目會保留固定於上游的 reasoning 階梯。模型宣告的有效 Grok 檔位(包括 `none` 與 `minimal`)都會 保留。不受支援或重複的檔位(包括 Codex 專用的 `ultra`)會從檔案省略,確保寫出的每個選項都能實際使用。 -Grok Build 透過 Chat Completions 與 opencodex 通訊,並在條目宣告階梯時送出 -`reasoning_effort`。在這種情況下,Chat Completions 入站轉換器會把內部 Responses 的 -`reasoning.summary` 預設設為 `auto`,因此推理軌跡會以 `delta.reasoning_content` 傳給 Grok。 -需要模型執行推理且不回傳軌跡的用戶端,可以設定 `include_reasoning: false`(或 -`reasoning.summary: "none"`)。兩個選項同時出現時,明確設定的 `reasoning.summary` 優先。 +Grok Build 透過 Responses API 與 opencodex 通訊。當路由宣告推理階梯時,Responses 直通會按 +設定轉發 `reasoning.summary`,因此推理軌跡會以 Responses reasoning 項目的形式原生送達 Grok。 +需要模型執行推理且不回傳軌跡的用戶端,可以設定 `reasoning.summary: "none"`。明確設定的 +`reasoning.summary` 優先於路由預設值。 ## 認證注意事項 @@ -70,33 +73,39 @@ Grok Build 透過 Chat Completions 與 opencodex 通訊,並在條目宣告階 **自動註冊僅限 loopback。** 當 opencodex 綁定非 loopback 主機時——包含會暴露所有介面的萬用字元 `0.0.0.0` 與 `::`——請求需要你的真實 admission token,而受管理區塊無法安全地承載它。把字面 token 寫進去會把你的金鑰放進 `~/.grok/config.toml`,並在下一次 `ocx start`/`ensure`/`restart` 時覆寫你在那裡設定的任何內容。因此在這種情況下 opencodex 完全不寫入(並會移除先前 loopback 綁定留下的任何區塊),而你要在受管理標記之外自行設定模型,opencodex 就無法覆寫它們。精確的表格請見[手動配方](#manual-recipe-without-auto-registration),並同時設定 `base_url`(你執行 `grok` 之處實際可達的主機)與 `api_key`(你的 `OPENCODEX_API_AUTH_TOKEN`)。 -此處不要用 `env_key` 取代 `api_key`。在未設定 `model_provider` 時,無法解析的 `env_key` 不會中止請求——Grok 會回退到你的 xAI 工作階段 token,並把它送到該項目所命名的任何 `base_url`;對 LAN 部署而言,那是一個並非 xAI 的明文 HTTP 端點。 +此處不要用 `env_key` 取代 `api_key`。無法解析的 `env_key` 不會中止請求——Grok 會回退到你的 xAI 工作階段 token,並把它送到該項目所命名的任何 `base_url`;對 LAN 部署而言,那是一個並非 xAI 的明文 HTTP 端點。 -注入的 per-model `api_key` 在這些模型的 Grok 憑證鏈中排在第一位,因此對 opencodex 的回合不需要額外的 Grok 登入。請為原生 grok 模型,以及任何直接聯絡 xAI 的 harness 功能,保留你平常的 `grok login` / `XAI_API_KEY` 設定。 +注入在 provider 條目上的 `api_key` 在這些模型的 Grok 憑證鏈中排在第一位,因此對 opencodex 的回合不需要額外的 Grok 登入。請為原生 grok 模型,以及任何直接聯絡 xAI 的 harness 功能,保留你平常的 `grok login` / `XAI_API_KEY` 設定。 ## 手動配方(不使用自動註冊) {#manual-recipe-without-auto-registration} -若你自行管理 `~/.grok/config.toml`——或 opencodex 綁定在非 loopback——請在 `# >>> opencodex managed block` 標記之外,以**直接欄位**新增 per-model 表格: +若你自行管理 `~/.grok/config.toml`——或 opencodex 綁定在非 loopback——請在 `# >>> opencodex managed block` 標記之外,新增一個 `[model_providers.opencodex]` 區塊以及引用它的 per-model 表格: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://127.0.0.1:10100/v1" api_backend = "responses" api_key = "opencodex-loopback" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` 對於可經由網路連線的代理程式,將 `base_url` 指向 `grok` 實際可撥號的位址,並使用你的 admission token: ```toml -[model.ocx-opus] -model = "anthropic/claude-opus-4-8" +[model_providers.opencodex] base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" + +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +model_provider = "opencodex" ``` -不要依賴 `[model_providers.]` 繼承端點:截至 Grok Build 0.2.101,繼承的 `base_url` 並不會套用到推論路由(請求會回退到預設 xAI 代理並以 401 失敗)。直接的 per-model 欄位才能正確路由。 +託管區塊現在使用 `[model_providers.]` 繼承,需要 Grok Build 0.2.109 或更高版本(發布於 2026-07-21)。在更早的版本上,繼承的 `base_url` 不會套用到推論路由——請升級,或在每個 `[model.*]` 表上使用逐模型直接欄位(`base_url`/`api_backend`/`api_key`)。 含有點號的別名請加上引號:裸的 `[model.grok-4.5]` 是三段式鍵路徑,而不是 id `grok-4.5`。產生的別名因此完全避免點號。 diff --git a/src/grok/inject.ts b/src/grok/inject.ts index 4f0f71964f..2e7681024b 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -27,10 +27,11 @@ export interface GrokInjectResult { const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>"; const END_MARKER = "# <<< opencodex managed block <<<"; -// grok 0.2.101 verified live (2026-07-23): [model_providers.] inheritance parses but the -// inherited base_url is NOT applied to inference routing — the turn falls through to the default -// cli-chat-proxy and 401s. Per-model direct fields DO route. So every [model.*] block carries its -// own base_url/api_backend/api_key and no [model_providers] table is emitted at all. +// Grok 0.2.109 (2026-07-21) shipped working [model_providers.] inheritance: base_url, +// api_backend, api_key, and extra_headers declared on the provider are applied to inference +// routing for inheriting models (verified in grok-build's with_provider_defaults → +// resolve_model_list → sampling_config_for_model → SamplingClient chain). We emit one shared +// [model_providers.opencodex] table and each [model.*] references it via model_provider. /** * INTERNAL API shared with `./inspect` (WP2, devlog 260803_integrations_toggle_all/012). @@ -325,6 +326,9 @@ function userModelAliases(content: string, region: ManagedRegion | null): Set]` table outside the fence that opencodex itself wrote. */ interface OrphanTable { alias: string; @@ -343,16 +347,39 @@ interface OrphanTable { function tableBodyKeys(body: string): Map { const keys = new Map(); const structure = analyzeTomlStructure(body); - const assignment = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t]*$/gm; + // Bare keys only: a quoted dotted segment could otherwise split a quoted value + // containing a dot. + const assignment = + /^[ \t]*([A-Za-z0-9_-]+(?:[ \t]*\.[ \t]*[A-Za-z0-9_-]+)*)[ \t]*=[ \t]*(.*?)[ \t]*$/gm; for (const match of structure.view.matchAll(assignment)) { if (!structure.containerRootLineStarts.has(match.index!)) continue; + const path = match[1]!.split(".").map(part => part.trim()); const raw = match[2]!; const value = raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"') ? decodeTomlBasicString(raw.slice(1, -1)) : raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'") ? raw.slice(1, -1) // TOML literal strings do not process escapes. : raw; - if (!keys.has(match[1]!)) keys.set(match[1]!, value); + if (path.length === 1) { + if (!keys.has(path[0]!)) keys.set(path[0]!, value); + continue; + } + // Dotted keys re-open a nested namespace: `extra_headers.k = v` is the key `k` of the + // sub-table `extra_headers`, which a folded-body reader must be able to see. Rebuild + // the inline-table spelling that hasInlineOwnershipMarker matches (bare word booleans + // and numbers keep their TOML spelling — no quoting, so the regex is unchanged). + let suffix = value; + for (let level = path.length - 1; level >= 1; level -= 1) { + const prefixKey = path.slice(0, level).join("."); + const inner = `${JSON.stringify(path[level]!)} = ${suffix}`; + suffix = `{ ${inner} }`; + const existing = keys.get(prefixKey); + if (existing === undefined || !existing.startsWith("{")) { + keys.set(prefixKey, suffix); + } else { + keys.set(prefixKey, `{ ${existing.slice(2, -2)}, ${inner} }`); + } + } } return keys; } @@ -369,8 +396,10 @@ function isLoopbackBaseUrl(value: string | undefined): boolean { /** Exact marker emitted inside every modern generated model table. */ function hasInlineOwnershipMarker(value: string | undefined): boolean { + // The reconstructed fold of a Grok dotted re-serialization writes bare `1` for the + // boolean literal, so both `= "1"` and `= 1` spellings are accepted here. return value !== undefined - && /^\{[ \t]*["']x-opencodex-grok["'][ \t]*=[ \t]*["']1["'][ \t]*\}$/.test(value); + && /^\{[ \t]*["']x-opencodex-grok["'][ \t]*=[ \t]*(?:"1"|'1'|1)[ \t]*\}$/.test(value); } /** Historical deterministic alias, including collision suffixes allocated by the writer. */ @@ -425,8 +454,24 @@ function isDisabledProviderModelId( * remote host is left alone. * - `x-opencodex-grok = "1"` in generated inline/child extra_headers, OR the historical * chat_completions + `name = "OCX "` + deterministic generated alias shape. + * - PROVIDER-INHERITANCE shape: `model_provider = "opencodex"` with no api_key/base_url of + * its own, adopting the verdict of the `[model_providers.opencodex]` table it references + * (the current block shape carries no per-model evidence; this mirrors Codex-side + * classifyCodexRouting). * A loopback base_url ALONE is not enough: aiming your own model at the local proxy is a * legitimate thing to do. + * + * Also sweeps orphaned `[model_providers.opencodex]` blocks from a previous managed block + * that used the provider-inheritance shape. Grok's re-serializer promotes the inline + * `extra_headers = { ... }` into a separate `[model_providers..extra_headers]` + * sub-table, which can split the parent's body (its own keys then live inside the child's + * span) and may interleave user tables between the parent and its children, so each + * provider's body is FOLDED with all its same-provider descendants before judging, and + * the removal span covers them by their exact ranges. The fenced provider is excluded + * from that sweep (the splice owns it) but still counts as ownership evidence, so + * teardown does not orphan models that inherit from it. The + * durable marker lives on the provider (never on the inheriting model), so the sweep is + * what keeps explicit ownership of inherited entries verifiable after a rewrite. */ function findOpencodexOrphans(content: string, region: ManagedRegion | null): OrphanTable[] { const orphans: OrphanTable[] = []; @@ -442,20 +487,94 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or fenceStart >= 0 && start < fenceStart ? Math.min(end, fenceStart) : end; // Collect every table header first: a table body runs to the NEXT header, whatever it is. const headers = analyzeTomlStructure(content).headers; + // [model_providers.] tables outside the fence, folded with their own sub-tables (see + // the function doc). A table passing the predicate (our api_key literal + a loopback + // base_url + the durable marker inline or in a re-serialized child) contributes to + // `ownedProviderIds` for the model scan below; one with OUR id is additionally swept as + // an orphan of a previous managed block (a leftover here collides with the regenerated + // block's provider table — duplicate key — and alias rewriting skips provider orphans + // because they have no alias and no model id). The dot-terminated prefix keeps a user's + // `[model_providers.opencodex_backup]` out of scope. + const ownedProviderIds = new Set(); + for (const [position, header] of headers.entries()) { + if (header.array || header.segments.length !== 2 || header.segments[0] !== "model_providers") continue; + // Inside the fence the regular splice owns the table, but it is still ownership + // evidence: models kept outside the fence after a Grok rewrite (retired ids) inherit + // their verdict from the fenced provider, so classification must happen while the + // fence still exists or teardown leaves them with a dangling model_provider reference. + const insideRegion = region !== null + && header.index >= region.start && header.index < region.end; + const end = clampEnd(header.index, headers[position + 1]?.index ?? content.length); + let body = content.slice(header.index + header.length, end); + // Re-serialized children may sit non-contiguously (a user table can interleave), so + // fold every same-provider descendant globally, like the model scan below, and remove + // them by their exact ranges. Never fold across the fence: a pre-fence parent must + // judge on pre-fence bytes only, and fenced or below-fence content is not the orphan's. + const additionalRanges: Array<{ start: number; end: number }> = []; + for (let next = 0; next < headers.length; next += 1) { + if (next === position) continue; + const child = headers[next]!; + if (region && child.index >= region.start && child.index < region.end) continue; + if (fenceStart >= 0 + && (header.index < fenceStart) !== (child.index < fenceStart)) continue; + if (child.segments.length <= 2 + || child.segments[0] !== "model_providers" + || child.segments[1] !== header.segments[1]) continue; + const childEnd = clampEnd(child.index, headers[next + 1]?.index ?? content.length); + body += "\n" + content.slice(child.index + child.length, childEnd); + additionalRanges.push({ start: child.index, end: childEnd }); + } + const keys = tableBodyKeys(body); + if (keys.get("api_key") !== OPENCODEX_API_KEY) continue; + if (!isLoopbackBaseUrl(keys.get("base_url"))) continue; + // The durable marker may sit inline on the provider, or be promoted by Grok's + // re-serializer into `[model_providers..extra_headers]` — where the folded body + // shows it as a bare `x-opencodex-grok = "1"` assignment. Both forms decide. + if (!hasInlineOwnershipMarker(keys.get("extra_headers")) + && keys.get(OPENCODEX_GROK_MARKER) !== "1") continue; + ownedProviderIds.add(header.segments[1]!); + if (insideRegion) continue; + if (header.segments[1] === OPENCODEX_PROVIDER_ID) { + orphans.push({ + alias: "", + modelId: "", + ownership: "explicit", + start: header.index, + end, + additionalRanges, + }); + } + } for (const [position, header] of headers.entries()) { if (header.array || header.segments.length !== 2 || header.segments[0] !== "model") continue; // Inside the fence the regular splice already owns it. if (region && header.index >= region.start && header.index < region.end) continue; const bodyEnd = clampEnd(header.index, headers[position + 1]?.index ?? content.length); const keys = tableBodyKeys(content.slice(header.index + header.length, bodyEnd)); - if (keys.get("api_key") !== OPENCODEX_API_KEY) continue; - if (!isLoopbackBaseUrl(keys.get("base_url"))) continue; const modelId = keys.get("model"); if (!modelId) continue; + // Two shapes carry our ownership signal. The current managed block routes every model + // through a shared provider table (`model_provider = "opencodex"`), so a re-serialized + // unfenced entry has NO api_key/base_url of its own — the evidence lives on the provider + // table it references (Codex-side precedent: classifyCodexRouting follows model_provider + // for the same reason). Inheritance is accepted only from a provider that itself passed + // the strict predicate above, and only for rows whose alias carries the generated + // fingerprint: a user is free to reference the managed provider from their own + // [model.*] table, and inheritance alone must not grant removal authority over it. + const providerId = keys.get("model_provider"); + const inheritedOwned = + providerId === OPENCODEX_PROVIDER_ID + && ownedProviderIds.has(OPENCODEX_PROVIDER_ID) + && isGeneratedAliasForModel(header.segments[1]!, modelId); + if (!inheritedOwned) { + if (keys.get("api_key") !== OPENCODEX_API_KEY) continue; + if (!isLoopbackBaseUrl(keys.get("base_url"))) continue; + } let hasOwnershipMarker = hasInlineOwnershipMarker(keys.get("extra_headers")); - // Swallow the entry's OWN sub-tables (`[model..extra_headers]`). Grok may - // re-serialize them non-contiguously, so collect exact descendant spans globally rather - // than stopping at the first unrelated table. + // Swallow the entry's OWN sub-tables (`[model..extra_headers]`, and after #1756 + // `[[model..reasoning_efforts]]`). Grok may re-serialize them non-contiguously, + // so collect exact descendant spans globally rather than stopping at the first + // unrelated table. const additionalRanges: Array<{ start: number; end: number }> = []; for (let next = 0; next < headers.length; next += 1) { if (next === position) continue; @@ -472,11 +591,17 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or } } const legacyGenerated = isLegacyGeneratedTable(header.segments[1]!, keys); - if (!hasOwnershipMarker && !legacyGenerated) continue; + // An inherited model has no per-model marker; its verdict comes from the provider + // table it references, which only lands here when that provider proved durable + // ownership. A legacy-fingerprint model keeps dev's conservative classification. + const ownership: "explicit" | "legacy" = inheritedOwned || hasOwnershipMarker + ? "explicit" + : "legacy"; + if (!hasOwnershipMarker && !legacyGenerated && !inheritedOwned) continue; orphans.push({ alias: header.segments[1]!, modelId, - ownership: hasOwnershipMarker ? "explicit" : "legacy", + ownership, start: header.index, end: bodyEnd, additionalRanges, @@ -877,6 +1002,15 @@ export function buildGrokManagedBlock( const baseUrl = `http://${host}:${port}/v1`; const lines = [ BEGIN_MARKER, + "", + `[model_providers.${OPENCODEX_PROVIDER_ID}]`, + `base_url = ${tomlString(baseUrl)}`, + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + // Best-effort attribution tag for the usage dashboard. Upstream Grok sends + // extra_headers verbatim on inference calls (11-custom-models.md). This is NOT a + // security boundary — any loopback client could send the same header. + 'extra_headers = { "x-opencodex-grok" = "1" }', ]; const aliasCounts = new Map(); const taken = new Set(reservedAliases ?? []); @@ -896,19 +1030,12 @@ export function buildGrokManagedBlock( // Slot consumed, table not written: this is what keeps every other alias stable // across selection changes. if (excluded?.has(model.id)) continue; - const isFirst = lines.length === 1; lines.push( - ...(isFirst ? [] : [""]), + "", `[model.${alias}]`, `model = ${tomlString(model.id)}`, - `base_url = ${tomlString(baseUrl)}`, - 'api_backend = "responses"', - 'api_key = "opencodex-loopback"', + `model_provider = ${tomlString(OPENCODEX_PROVIDER_ID)}`, `name = ${tomlString(model.name ?? `OCX ${model.id}`)}`, - // Best-effort attribution tag for the usage dashboard. Upstream Grok sends - // extra_headers verbatim on inference calls (11-custom-models.md). This is NOT a - // security boundary — any loopback client could send the same header. - 'extra_headers = { "x-opencodex-grok" = "1" }', ); if (Number.isFinite(model.contextWindow) && (model.contextWindow ?? 0) > 0) { lines.push(`context_window = ${model.contextWindow}`); @@ -1015,16 +1142,21 @@ export function injectGrokConfig( .filter(model => !opts.excluded?.has(model.id)) .map(model => model.id)); const orphans = findOpencodexOrphans(originalContent, originalRegion) - .filter(orphan => orphan.ownership === "legacy" - // A legacy fingerprint is not durable deletion authority. Migrate it only when this - // same write will replace the row with a marked managed table. - ? emittedModelIds.has(orphan.modelId) - : catalogModelIds.has(orphan.modelId) - || isDisabledProviderModelId( - orphan.modelId, - opts.disabledProviderNamespaces, - opts.comboPublicModelIds, - )); + .filter(orphan => + // A provider table carries no alias and no model id: its strict predicate (our key + // + loopback + durable marker) is itself the deletion authority, and a leftover + // collides with the regenerated provider table (duplicate key). + orphan.alias === "" + || (orphan.ownership === "legacy" + // A legacy fingerprint is not durable deletion authority. Migrate it only when this + // same write will replace the row with a marked managed table. + ? emittedModelIds.has(orphan.modelId) + : catalogModelIds.has(orphan.modelId) + || isDisabledProviderModelId( + orphan.modelId, + opts.disabledProviderNamespaces, + opts.comboPublicModelIds, + ))); const content = removeOrphanTables(originalContent, orphans); // Removing bytes above the fence MOVES it: recompute rather than adjust arithmetic, // so the splice below cannot cut the file in the wrong place. @@ -1054,7 +1186,10 @@ export function injectGrokConfig( } const replacements = new Map(); for (const removed of [ - ...orphans.map(orphan => ({ alias: orphan.alias, modelId: orphan.modelId })), + // Provider orphans carry no alias and no model id: there is nothing to repoint, and + // an empty alias must never enter the rename map. + ...orphans.filter(orphan => orphan.alias !== "") + .map(orphan => ({ alias: orphan.alias, modelId: orphan.modelId })), ...[...previousManagedModels].map(([alias, modelId]) => ({ alias, modelId })), ]) { if (nextManagedModels.get(removed.alias) === removed.modelId) continue; @@ -1133,9 +1268,17 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes const tailOrphans = findOpencodexOrphans(restOfFile, null) .filter(orphan => orphan.ownership === "explicit"); const removedAliases = new Set( - [...fullOrphans, ...prefixOrphans, ...tailOrphans].map(orphan => orphan.alias), + [...fullOrphans, ...prefixOrphans, ...tailOrphans] + .map(orphan => orphan.alias) + // Provider orphans carry no alias; their ranges above already removed the table. + .filter(alias => alias !== ""), ); - orphanCount = removedAliases.size; + // The backup must cover every removed TABLE, not just aliased rows: provider-only + // orphans carry no alias and would otherwise be swept without any backup. + orphanCount = new Set( + [...fullOrphans, ...prefixOrphans, ...tailOrphans] + .flatMap(orphan => orphanRanges([orphan])), + ).size; const fullRanges = orphanRanges(fullOrphans); const prefixRanges = [ ...orphanRanges(prefixOrphans), @@ -1168,7 +1311,10 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes } orphanCount = orphans.length; stripped = removeOrphanTables(content, orphans); - stripped = removeAliasReferences(stripped, new Set(orphans.map(orphan => orphan.alias))); + stripped = removeAliasReferences( + stripped, + new Set(orphans.map(orphan => orphan.alias).filter(alias => alias !== "")), + ); } if (orphanCount > 0) copyBackupOnce(configPath, join(grokHome, "config.toml.bak-opencodex")); atomicWriteFile(configPath, applyEol(stripped, eol)); diff --git a/src/grok/status.ts b/src/grok/status.ts index f42182063b..663c723a6f 100644 --- a/src/grok/status.ts +++ b/src/grok/status.ts @@ -65,22 +65,35 @@ export function readGrokStatus(opts: { grokHome?: string } = {}): GrokStatus { let baseUrl: string | null = null; let current: GrokStatusModel | null = null; + // The provider block carries base_url in the current shape; per-model base_url is the + // legacy fallback for fences written before the model_providers migration. + let inProviderBlock = false; + for (const rawLine of region.split("\n")) { const line = rawLine.trim(); + const providerHeader = /^\[model_providers\.([^\]]+)\]$/.exec(line); + if (providerHeader) { + inProviderBlock = true; + continue; + } const header = /^\[model\.([^\]]+)\]$/.exec(line); if (header) { + inProviderBlock = false; current = { alias: header[1]!, id: "" }; models.push(current); continue; } - if (!current) continue; - if (line.startsWith("model =")) { - current.id = tomlStringValue(line) ?? ""; - } else if (line.startsWith("base_url =")) { - baseUrl ??= tomlStringValue(line) ?? null; - } else if (line.startsWith("context_window =")) { - const value = Number(line.slice(line.indexOf("=") + 1).trim()); - if (Number.isFinite(value) && value > 0) current.contextWindow = value; + if (line.startsWith("base_url =")) { + // Prefer the provider block's base_url; fall back to per-model (legacy shape). + if (inProviderBlock) baseUrl ??= tomlStringValue(line) ?? null; + else if (current && baseUrl === null) baseUrl = tomlStringValue(line) ?? null; + } else if (!inProviderBlock && current) { + if (line.startsWith("model =")) { + current.id = tomlStringValue(line) ?? ""; + } else if (line.startsWith("context_window =")) { + const value = Number(line.slice(line.indexOf("=") + 1).trim()); + if (Number.isFinite(value) && value > 0) current.contextWindow = value; + } } } diff --git a/tests/grok-attribution.test.ts b/tests/grok-attribution.test.ts index 7af76d12a7..661f786c23 100644 --- a/tests/grok-attribution.test.ts +++ b/tests/grok-attribution.test.ts @@ -16,10 +16,10 @@ import type { OcxConfig } from "../src/types"; test("the managed fence stamps the grok attribution header on every model", () => { const block = buildGrokManagedBlock(10100, [{ id: "kimi/k3", contextWindow: 262_144 }]); expect(block).toContain('extra_headers = { "x-opencodex-grok" = "1" }'); - // One line per model, after the api_key line, so Grok parses it inside the table. - const modelSections = block.split("[model.").slice(1); - expect(modelSections.length).toBe(1); - expect(modelSections[0]).toContain("x-opencodex-grok"); + // The header lives in the shared [model_providers.opencodex] block, inherited by every + // [model.*] table that references it via model_provider. + const providerBlock = block.slice(block.indexOf("[model_providers.opencodex]"), block.indexOf("[model.")); + expect(providerBlock).toContain("x-opencodex-grok"); }); test("the fence survives a write and keeps the header line parseable", () => { diff --git a/tests/grok-config-inject.test.ts b/tests/grok-config-inject.test.ts index 65b2319624..1949e06c64 100644 --- a/tests/grok-config-inject.test.ts +++ b/tests/grok-config-inject.test.ts @@ -63,15 +63,20 @@ describe("Grok config injection", () => { expect(content).toContain("[model.ocx-newer-model]"); }); - test("emits per-model direct fields (grok 0.2.101 ignores model_providers inheritance)", () => { + test("emits a shared model_providers block and per-model references (grok 0.2.109+)", () => { const block = buildGrokManagedBlock(10190, [{ id: "cursor/grok-4.5", contextWindow: 500_000 }]); - expect(block).not.toContain("[model_providers"); - expect(block).not.toContain("model_provider ="); + expect(block).toContain("[model_providers.opencodex]"); + const providerBlock = block.slice(block.indexOf("[model_providers.opencodex]"), block.indexOf("[model.")); + expect(providerBlock).toContain('base_url = "http://127.0.0.1:10190/v1"'); + expect(providerBlock).toContain('api_backend = "responses"'); + expect(providerBlock).toContain('api_key = "opencodex-loopback"'); + expect(providerBlock).toContain('extra_headers = { "x-opencodex-grok" = "1" }'); const table = block.slice(block.indexOf("[model.ocx-cursor-grok-4-5]")); expect(table).toContain('model = "cursor/grok-4.5"'); - expect(table).toContain('base_url = "http://127.0.0.1:10190/v1"'); - expect(table).toContain('api_backend = "responses"'); - expect(table).toContain('api_key = "opencodex-loopback"'); + expect(table).toContain('model_provider = "opencodex"'); + expect(table).not.toContain('base_url ='); + expect(table).not.toContain('api_key ='); + expect(table).not.toContain('api_backend ='); expect(table).toContain("context_window = 500000"); }); diff --git a/tests/grok-orphan-adoption.test.ts b/tests/grok-orphan-adoption.test.ts index 2d7ffaf2af..f055b9f68e 100644 --- a/tests/grok-orphan-adoption.test.ts +++ b/tests/grok-orphan-adoption.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { injectGrokConfig, stripGrokConfig } from "../src/grok/inject"; @@ -1326,4 +1326,358 @@ describe("Grok orphan adoption — fence boundary (#511 follow-up)", () => { expect(backup).toContain("[model.ocx-gpt-5-6-sol]"); expect(backup).toContain("[model.ocx-gpt-5-6-sol-2]"); }); + + // A stale [model_providers.opencodex] block from a previous managed fence (written by + // the provider-inheritance shape) sits outside the current fence if the fence was + // removed and re-added. The sweep must remove it just like a per-model orphan, or the + // next sync writes a second [model_providers.opencodex] and Grok rejects the duplicate. + // The fenced writer always emits the durable marker on the provider, so a real leftover + // carries it even after a fence removal. + test("sweeps a stale model_providers.opencodex block outside the fence", () => { + writeFileSync(configPath, [ + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + 'extra_headers = { "x-opencodex-grok" = "1" }', + "", + ...fence("ocx-gpt-5-6-sol"), + "", + ].join("\n")); + + injectGrokConfig(10100, MODELS, { grokHome }); + + const content = readFileSync(configPath, "utf8"); + // Exactly one [model_providers.opencodex] table survives, inside the fence. + expect(content.match(/\[model_providers\.opencodex\]/g) ?? []).toHaveLength(1); + expect(content.indexOf("[model_providers.opencodex]")).toBeGreaterThan(content.indexOf(BEGIN_MARKER)); + }); + + test("does not sweep a user-authored model_providers block with a different id", () => { + writeFileSync(configPath, [ + "[model_providers.my-gateway]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "opencodex-loopback"', + "", + ...fence("ocx-gpt-5-6-sol"), + "", + ].join("\n")); + + injectGrokConfig(10100, MODELS, { grokHome }); + + const content = readFileSync(configPath, "utf8"); + expect(content).toContain("[model_providers.my-gateway]"); + // And the managed block's own provider table is separate. + expect(content.match(/\[model_providers\.opencodex\]/g) ?? []).toHaveLength(1); + }); + + test("does not sweep a model_providers.opencodex with a non-loopback base_url", () => { + writeFileSync(configPath, [ + "[model_providers.opencodex]", + 'base_url = "https://example.com/v1"', + 'api_key = "opencodex-loopback"', + "", + ...fence("ocx-gpt-5-6-sol"), + "", + ].join("\n")); + + injectGrokConfig(10100, MODELS, { grokHome }); + + // A remote base_url with our key is not ours to delete. + expect(readFileSync(configPath, "utf8")).toContain('base_url = "https://example.com/v1"'); + }); + + // Field state from a real machine (2026-08-27): Grok re-serialized the provider block, + // promoting the inline `extra_headers` into a sub-table placed BETWEEN the provider + // header and its own keys. The provider-only body then looks empty, and a leftover + // child collides with the regenerated block's inline `extra_headers` + // ("Cannot redefine key") — the whole TOML layer is rejected. + test("sweeps a reserialized provider block whose sub-table precedes its keys", () => { + writeFileSync(configPath, [ + "[model_providers.opencodex]", + "[model_providers.opencodex.extra_headers]", + 'x-opencodex-grok = "1"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + "", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-sol"', + "", + "[models]", + 'default = "ocx-gpt-5-6-sol"', + ].join("\n")); + + const result = injectGrokConfig(10100, MODELS, { grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + + const content = readFileSync(configPath, "utf8"); + // The unfenced provider + model are adopted; exactly one of each survives, inside the fence. + expect(content.match(/\[model_providers\.opencodex\]/g) ?? []).toHaveLength(1); + expect(content.match(/\[model_providers\.opencodex\.extra_headers\]/g) ?? []).toHaveLength(0); + expect(tables(content).filter(alias => alias.startsWith("ocx-"))).toHaveLength(1); + expect(content.indexOf("[model_providers.opencodex]")).toBeGreaterThan(content.indexOf(BEGIN_MARKER)); + // default still resolves. + const survivor = /^default = "([^"]+)"/m.exec(content)?.[1]; + expect(content).toContain(`[model.${survivor}]`); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + + // The other re-serialization order: keys first, sub-table after. The provider's own + // body still judges, and the child must be swallowed or the same key collision returns. + test("sweeps a reserialized provider block whose sub-table follows its keys", () => { + writeFileSync(configPath, [ + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + "", + "[model_providers.opencodex.extra_headers]", + 'x-opencodex-grok = "1"', + "", + ...fence("ocx-gpt-5-6-sol"), + "", + ].join("\n")); + + injectGrokConfig(10100, MODELS, { grokHome }); + + const content = readFileSync(configPath, "utf8"); + expect(content.match(/\[model_providers\.opencodex\]/g) ?? []).toHaveLength(1); + expect(content.match(/\[model_providers\.opencodex\.extra_headers\]/g) ?? []).toHaveLength(0); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + + // The migration's real regression: model tables in the provider-inheritance shape carry + // NO api_key/base_url of their own, so the legacy predicate missed them and every sync + // after a Grok rewrite allocated a -2 duplicate beside the stale original. Adoption + // must follow the model_provider reference to the owned provider table. + test("adopts model_provider-referencing entries left unfenced by a Grok rewrite", () => { + writeFileSync(configPath, [ + "[ui]", + 'fork_secondary_model = "grok-build"', + "", + "[model_providers.opencodex]", + "[model_providers.opencodex.extra_headers]", + 'x-opencodex-grok = "1"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + "", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-sol"', + "", + "[model.ocx-gpt-5-6-terra]", + 'model = "gpt-5.6-terra"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-terra"', + "", + "[models]", + 'default = "ocx-gpt-5-6-sol"', + ].join("\n")); + + const result = injectGrokConfig(10100, MODELS, { grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + + const content = readFileSync(configPath, "utf8"); + // The sol entry collapses into the single regenerated one — no -2 duplicates. The + // terra entry is genuinely retired (not in MODELS): explicitly-owned rows are kept + // outside the fence rather than deleted by an inject, so it remains — unfenced but + // adopted (its alias is reserved and never re-suffixed). One table survives per + // model id. + expect(tables(content)).toEqual(["ocx-gpt-5-6-terra", "ocx-gpt-5-6-sol"]); + expect(content).not.toContain("[model.ocx-gpt-5-6-sol-2]"); + expect(content).not.toContain("[model.ocx-gpt-5-6-terra-2]"); + // default still resolves: it names the sol entry, which survives inside the fence. + const survivor = /^default = "([^"]+)"/m.exec(content)?.[1]; + expect(content).toContain(`[model.${survivor}]`); + expect(content).toContain("context_window = 372000"); + // User content survives. + expect(content).toContain('fork_secondary_model = "grok-build"'); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + + test("does not adopt a model referencing a provider that fails the ownership predicate", () => { + writeFileSync(configPath, [ + "[model_providers.my-gateway]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_key = "user-secret"', + "", + "[model.ocx-mine]", + 'model = "user/model"', + 'model_provider = "my-gateway"', + 'name = "OCX mine"', + ].join("\n")); + + const result = injectGrokConfig(10100, MODELS, { grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + + const content = readFileSync(configPath, "utf8"); + // A referenced-but-user-owned provider keeps its model table untouched, and ours + // takes a suffixed alias instead of clobbering it. + expect(content).toContain("[model.ocx-mine]"); + expect(content).toContain('model_provider = "my-gateway"'); + expect(content).not.toContain("[model.ocx-mine-2]"); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + + test("folds provider sub-tables separated from their parent by a user table", () => { + // TOML allows the re-serialized child to sit after an unrelated table. A first-mismatch + // stop left the stale provider unfolded: without the marker evidence it stayed, and the + // next sync declared a duplicate [model_providers.opencodex] — invalid TOML for Grok. + writeFileSync(configPath, [ + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + "", + "[ui.detailed]", + "verbose = true", + "", + "[model_providers.opencodex.extra_headers]", + 'x-opencodex-grok = "1"', + "", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-sol"', + "", + "[models]", + 'default = "ocx-gpt-5-6-sol"', + ].join("\n")); + + const result = injectGrokConfig(10100, MODELS, { grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + + const content = readFileSync(configPath, "utf8"); + expect(content.match(/\[model_providers\.opencodex\]/g) ?? []).toHaveLength(1); + expect(content.match(/\[model_providers\.opencodex\.extra_headers\]/g) ?? []).toHaveLength(0); + // The interleaved user table survives. + expect(content).toContain("[ui.detailed]"); + expect(tables(content).filter(alias => alias.startsWith("ocx-"))).toHaveLength(1); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + + test("teardown resolves inherited ownership through the fenced provider", () => { + // A retired model kept outside the fence inherits its verdict from the provider table + // INSIDE it. Classification must see the fenced provider, or strip removes the fence + // but leaves the model with a dangling `model_provider = "opencodex"` reference. + writeFileSync(configPath, [ + "[ui]", + 'fork_secondary_model = "grok-build"', + "", + BEGIN_MARKER, + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-sol"', + END_MARKER, + "", + "[model.ocx-gpt-5-6-terra]", + 'model = "gpt-5.6-terra"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-terra"', + "", + "[models]", + 'default = "ocx-gpt-5-6-terra"', + ].join("\n")); + + const result = stripGrokConfig({ grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + + const content = readFileSync(configPath, "utf8"); + // The fence and the retired model are both gone; no dangling reference survives. + expect(content).not.toContain("model_provider = \"opencodex\""); + expect(content).not.toContain("[model_providers.opencodex]"); + expect(content).toContain('fork_secondary_model = "grok-build"'); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + + test("does not adopt a user-written model that references the managed provider", () => { + // Inheritance must not grant removal authority over every model that references + // opencodex: a user is free to write their own [model.*] table that inherits the + // managed provider, and adoption without a generated alias deletes it. + for (const operation of ["inject", "teardown"] as const) { + writeFileSync(configPath, [ + BEGIN_MARKER, + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + OWNERSHIP_MARKER, + "", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-sol"', + END_MARKER, + "", + "[model.custom-variant]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "my fast variant"', + "context_window = 128000", + "", + ].join("\n")); + + if (operation === "inject") { + expect(injectGrokConfig(10100, MODELS, { grokHome })) + .toMatchObject({ ok: true, changed: true }); + } else { + expect(stripGrokConfig({ grokHome })).toMatchObject({ ok: true, changed: true }); + } + const content = readFileSync(configPath, "utf8"); + expect(content).toContain("[model.custom-variant]"); + expect(content).toContain('name = "my fast variant"'); + expect(content).toContain("context_window = 128000"); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + } + }); + + test("sweeping a provider-only orphan backs the user's config up first", () => { + // Provider orphans carry no alias, so an alias-count backup condition skipped the + // backup entirely even though teardown removed the table. + writeFileSync(configPath, [ + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + 'extra_headers = { "x-opencodex-grok" = "1" }', + "", + BEGIN_MARKER, + "[model_providers.opencodex]", + 'base_url = "http://127.0.0.1:10100/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + 'extra_headers = { "x-opencodex-grok" = "1" }', + "", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'model_provider = "opencodex"', + 'name = "OCX gpt-5.6-sol"', + END_MARKER, + "", + "[models]", + 'default = "ocx-gpt-5-6-sol"', + "", + ].join("\n")); + + const result = stripGrokConfig({ grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + + expect(existsSync(join(grokHome, "config.toml.bak-opencodex"))).toBe(true); + const content = readFileSync(configPath, "utf8"); + expect(content).not.toContain("[model_providers.opencodex]"); + expect(content).toContain("[models]"); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); }); diff --git a/tests/grok-selection.test.ts b/tests/grok-selection.test.ts index ce338e1bda..42b749e8c5 100644 --- a/tests/grok-selection.test.ts +++ b/tests/grok-selection.test.ts @@ -67,7 +67,8 @@ test("excluding a colliding model leaves the survivor's alias unchanged", () => test("excluding the first model keeps the TOML shape valid", () => { const block = buildGrokManagedBlock(10100, MODELS, undefined, undefined, new Set(["kimi/k3"])); const afterMarker = block.split("do not edit (removed by `ocx stop`) >>>\n")[1]!; - expect(afterMarker.startsWith("[model.")).toBe(true); + // The provider block is always present; the first [model.*] table may be excluded. + expect(afterMarker.trimStart().startsWith("[model_providers.")).toBe(true); expect(block).not.toContain("\n\n\n"); }); diff --git a/tests/grok-status.test.ts b/tests/grok-status.test.ts index 2f57d5f6c9..32fff429df 100644 --- a/tests/grok-status.test.ts +++ b/tests/grok-status.test.ts @@ -78,6 +78,31 @@ describe("readGrokStatus", () => { rmSync(root, { recursive: true, force: true }); } }); + + // A fence written by the old per-model shape (before model_providers migration) carries + // base_url on each [model.*] table and has no [model_providers.opencodex] block. + test("falls back to per-model base_url for a legacy-shape fence", () => { + const { root, grokHome } = tempGrokHome(); + try { + const legacy = [ + "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>", + "[model.ocx-gpt-5-6-sol]", + 'model = "gpt-5.6-sol"', + 'base_url = "http://127.0.0.1:10190/v1"', + 'api_backend = "responses"', + 'api_key = "opencodex-loopback"', + "# <<< opencodex managed block <<<", + ].join("\n"); + writeFileSync(join(grokHome, "config.toml"), legacy, "utf8"); + + const status = readGrokStatus({ grokHome }); + expect(status.present).toBe(true); + expect(status.baseUrl).toBe("http://127.0.0.1:10190/v1"); + expect(status.models.map(m => m.id)).toEqual(["gpt-5.6-sol"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); /**