diff --git a/devlog/_fin/260806_pi_loopback_models/evidence/pi-config-preview.png b/devlog/_fin/260806_pi_loopback_models/evidence/pi-config-preview.png
new file mode 100644
index 0000000000..26693304e2
Binary files /dev/null and b/devlog/_fin/260806_pi_loopback_models/evidence/pi-config-preview.png differ
diff --git a/devlog/_plan/260806_omp_integration/evidence/omp-dashboard.png b/devlog/_plan/260806_omp_integration/evidence/omp-dashboard.png
new file mode 100644
index 0000000000..b132a5334b
Binary files /dev/null and b/devlog/_plan/260806_omp_integration/evidence/omp-dashboard.png differ
diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md
index c917fd273a..a11ebd2fa2 100644
--- a/docs-site/src/content/docs/guides/integrations.md
+++ b/docs-site/src/content/docs/guides/integrations.md
@@ -1,23 +1,25 @@
---
title: Integrations
-description: Connect opencodex to OpenCode, Pi, Hermes, OpenClaw, Kimi Code and Gajae Code from the dashboard — one switch per client, with a backup taken before every write.
+description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code and Gajae Code from the dashboard — one switch per client, with a backup taken before every write.
---
The **Integrations** tab writes opencodex's provider block into a client's own config
-file, and removes it again. Six clients work this way, each with a switch:
+file, and removes it again. Seven clients work this way, each with a switch:
| Client | Config file | Format | When the change takes effect | Credential |
|---|---|---|---|---|
| OpenCode | `~/.config/opencode/opencode.json` | JSON | next direct launch | `OPENCODEX_OPENCODE_API_KEY` |
-| Pi | `~/.pi/agent/models.json` | JSON | new sessions | `OPENCODEX_API_KEY` |
+| Pi | `~/.pi/agent/models.json` | JSON | new sessions | loopback placeholder |
+| OMP | `~/.omp/agent/models.yml` | YAML | after restarting OMP | loopback placeholder |
| Hermes | `~/.hermes/config.yaml` | YAML | new sessions | `OPENCODEX_HERMES_API_KEY` |
| OpenClaw | `~/.openclaw/openclaw.json` | JSON5 | immediately, on a running gateway | `OPENCODEX_OPENCLAW_API_KEY` |
| Kimi Code | `~/.kimi-code/config.toml` | TOML | on restart, or `/reload` | loopback placeholder |
| Gajae Code | `~/.gjc/agent/models.yml` | YAML | new sessions, or when you open `/model` |`OPENCODEX_GAJAE_API_KEY` |
Paths honor each client's own environment override where it has one, so a relocated
-`HERMES_HOME`, `KIMI_CODE_HOME` or `XDG_CONFIG_HOME` is followed rather than guessed
-at. The table lists each client's default; an override always wins.
+`HERMES_HOME`, `KIMI_CODE_HOME`, `XDG_CONFIG_HOME`, `PI_CODING_AGENT_DIR`, `PI_CONFIG_DIR`
+or active `OMP_PROFILE` is followed rather than guessed at. The table lists each client's
+default; an override always wins.
OpenClaw has several, and they do different jobs. `OPENCLAW_CONFIG_PATH` selects the
file; `OPENCLAW_STATE_DIR`, `OPENCLAW_PROFILE` and `OPENCLAW_HOME` select the state
@@ -75,7 +77,7 @@ changed value and calling it success. You will see the file named and nothing on
disk will have moved. Editing that file by hand still works; it is only our
automatic rewrite that declines.
-**Pi, Kimi Code and Gajae Code only work against a loopback bind.** None of their config
+**Pi, OMP, Kimi Code and Gajae Code only work against a loopback bind.** None of their config
schemas has a place for the `x-opencodex-api-key` header that a non-loopback bind
requires, so a generated config would simply be rejected — and writing one by hand does
not help, because there is nowhere in the file to put the header either. Reaching a
diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md
index fa44d2754f..76b0fe40bd 100644
--- a/docs-site/src/content/docs/guides/pi.md
+++ b/docs-site/src/content/docs/guides/pi.md
@@ -5,8 +5,7 @@ description: Use any routed model from Pi — ocx export writes a custom provide
Pi reads its providers from a single global JSON file rather than environment variables, so
opencodex does not launch it. Instead, `ocx export` serializes the `opencodex` provider block —
-base URL, model list, and the env reference Pi interpolates — and you merge it into your own
-config.
+base URL, model list, and a placeholder admission key — and you merge it into your own config.
## Quickstart
@@ -17,8 +16,8 @@ ocx start
ocx export --client pi
```
-The output leads with the JSON, then prints the destination path, the merge warning, the env
-export line, and how many models carry authoritative context limits.
+The output leads with the JSON, then prints the destination path, the merge warning, and how many
+models carry authoritative context limits.
```json
{
@@ -26,7 +25,7 @@ export line, and how many models carry authoritative context limits.
"opencodex": {
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
- "apiKey": "$OPENCODEX_API_KEY",
+ "apiKey": "opencodex-loopback",
"models": [
{
"id": "anthropic/claude-opus-5",
@@ -68,29 +67,19 @@ ocx export --client pi --json > ~/opencodex-pi-models.json # or redirect the b
The exported block is a static snapshot, not a live view. Re-run `ocx export` after adding a
provider or changing model visibility, and merge the new block over the old one.
-## The admission key
+## The Pi `apiKey` placeholder
-Two different keys are easy to confuse here, and only the first one appears in this file:
+Pi normally calls `/chat/completions` and sends its configured `apiKey` as a Bearer authorization
+value. The generated block therefore includes the non-secret literal `opencodex-loopback` in Pi's
+normal `apiKey` field.
-| Key | What it is | Where it lives |
-| --- | --- | --- |
-| Proxy admission key | opencodex's own credential, generated on the dashboard's **API** tab | referenced by `apiKey` as `$OPENCODEX_API_KEY`; the value stays in your environment |
-| Provider key | your Anthropic / OpenAI / OpenRouter key | opencodex's own config, per [Providers](/guides/providers/) |
+That literal is neither a proxy admission credential nor an upstream provider key. The loopback
+proxy ignores it and requires no credential at all. It is still load-bearing for discovery: Pi
+resolves `apiKey` while building its model list and hides the whole provider when the value is an
+unset env reference, so a literal keeps every routed model visible.
-The exported config carries only the reference, never a secret. Pi interpolates a bare `$NAME`, so
-the variable is:
-
-```bash
-export OPENCODEX_API_KEY=
-```
-
-That name is Pi's alone. opencode uses a different variable
-(`OPENCODEX_OPENCODE_API_KEY`, in `{env:…}` form) — see the [opencode guide](/guides/opencode/).
-
-**A loopback proxy needs no key at all.** opencodex binds `127.0.0.1` by default and authenticates
-nothing there, so the `$OPENCODEX_API_KEY` reference is inert and you can leave the variable unset.
-It matters only when `hostname` is set beyond loopback, which is also the case where the proxy
-refuses to start without a token — see [Remote access](/reference/configuration/#remote-access).
+Your provider keys are separate — the Anthropic / OpenAI / OpenRouter key lives in opencodex's own
+config, per [Providers](/guides/providers/), and never appears in this file.
## Model metadata
@@ -109,9 +98,9 @@ a guess.
## Schema status
-:::note[Unverified against a real install]
-The shape above follows Pi's published custom-provider documentation. It has **not** been verified
-against a real `~/.pi/agent/models.json` on a machine with Pi installed. If Pi rejects the exported
+:::note[Verified against a real install]
+The shape above has been verified against Pi 0.83.x on a real `~/.pi/agent/models.json`: the block
+validates and every routed model appears in Pi's picker. If a newer Pi rejects the exported
block, the mismatch is on our side — please
[open an issue](https://github.com/lidge-jun/opencodex/issues) with what Pi reported.
:::
diff --git a/docs-site/src/content/docs/ja/guides/pi.md b/docs-site/src/content/docs/ja/guides/pi.md
index 788fe48c60..4dfb17dcf1 100644
--- a/docs-site/src/content/docs/ja/guides/pi.md
+++ b/docs-site/src/content/docs/ja/guides/pi.md
@@ -3,7 +3,7 @@ title: 円周率
description: Pi からルーティングされたモデルを使用します。ocx エクスポートは、実行中のプロキシに接続された Pi の models.json のカスタム プロバイダー ブロックを書き込みます。
---
-Pi は環境変数ではなく単一のグローバル JSON ファイルからプロバイダーを読み取るため、opencodex はそれを起動しません。代わりに、`ocx export` は `opencodex` プロバイダー ブロック (ベース URL、モデル リスト、Pi が補間する環境参照) をシリアル化し、それを独自の設定にマージします。
+Pi は環境変数ではなく単一のグローバル JSON ファイルからプロバイダーを読み取るため、opencodex はそれを起動しません。代わりに、`ocx export` は `opencodex` プロバイダー ブロック (ベース URL、モデル リスト、プレースホルダーのアドミッション キー) をシリアル化し、それを独自の設定にマージします。
## クイックスタート
@@ -14,7 +14,7 @@ ocx start
ocx export --client pi
```
-出力は JSON で始まり、宛先パス、マージ警告、env エクスポート行、および権威コンテキスト制限を持つモデルの数を出力します。
+出力は JSON で始まり、宛先パス、マージ警告、および権威コンテキスト制限を持つモデルの数を出力します。
```json
{
@@ -22,7 +22,7 @@ ocx export --client pi
"opencodex": {
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
- "apiKey": "$OPENCODEX_API_KEY",
+ "apiKey": "opencodex-loopback",
"models": [
{
"id": "anthropic/claude-opus-5",
@@ -58,24 +58,13 @@ ocx export --client pi --json > ~/opencodex-pi-models.json # or redirect the b
エクスポートされたブロックは静的なスナップショットであり、ライブ ビューではありません。プロバイダーを追加するかモデルの可視性を変更した後、`ocx export` を再実行し、新しいブロックを古いブロックにマージします。
-## アドミッションキー
+## Pi の `apiKey` プレースホルダー
-ここでは 2 つの異なるキーが混同されやすいため、このファイルには最初のキーのみが表示されます。
+Pi は通常 `/chat/completions` を呼び出し、設定された `apiKey` を Bearer 認証値として送信します。そのため、生成されるブロックでは、Pi の通常の `apiKey` フィールドに非シークレットのリテラル `opencodex-loopback` を入れます。
-|キー |それは何ですか |それが住んでいる場所 |
-| --- | --- | --- |
-|プロキシ アドミッション キー | opencodex 自身の認証情報。ダッシュボードの **API** タブで生成されます。 `apiKey` では `$OPENCODEX_API_KEY` として参照されます。値は環境内に残ります。
-|プロバイダーキー | Anthropic / OpenAI / OpenRouter キー | opencodex 独自の設定、[プロバイダー](/guides/providers/) ごと |
+このリテラルは、プロキシのアドミッション認証情報でも上流プロバイダーのキーでもありません。ループバック プロキシはこの値を無視し、認証情報を一切要求しません。ただしモデル検出には必須です。Pi はモデル リストを構築する際に `apiKey` を解決し、その値が未設定の環境変数参照である場合はプロバイダー全体を隠すため、リテラルであればルーティングされたすべてのモデルが表示されます。
-エクスポートされた設定には参照のみが含まれ、シークレットは含まれません。 Pi は裸の `$NAME` を補間するため、変数は次のようになります。
-
-```bash
-export OPENCODEX_API_KEY=
-```
-
-その名前はパイだけです。 opencode は別の変数 (`OPENCODEX_OPENCODE_API_KEY`、`{env:…}` 形式) を使用します。[オープンコードガイド](/guides/opencode/) を参照してください。
-
-**ループバック プロキシにはキーはまったく必要ありません。** opencodex はデフォルトで `127.0.0.1` をバインドし、そこでは何も認証しないため、`$OPENCODEX_API_KEY` 参照は不活性であり、変数を設定しないままにすることができます。これは、`hostname` がループバックを超えて設定されている場合にのみ問題になります。これは、プロキシがトークンなしでの開始を拒否する場合でもあります。[リモートアクセス](/reference/configuration/#remote-access) を参照してください。
+プロバイダー キーは別のものです。Anthropic / OpenAI / OpenRouter のキーは opencodex 自身の設定にあり ([プロバイダー](/guides/providers/) を参照)、このファイルには決して現れません。
## モデルのメタデータ
@@ -87,8 +76,8 @@ export OPENCODEX_API_KEY=
## スキーマのステータス
-:::note[実際のインストールに対して未検証]
-上の形状は、Pi が公開しているカスタム プロバイダーのドキュメントに従っています。 Pi がインストールされたマシン上の実際の `~/.pi/agent/models.json` に対して検証されていません**。 Pi がエクスポートされたブロックを拒否した場合、不一致は私たちの側にあります。Pi が報告した内容を [問題を開く](https://github.com/lidge-jun/opencodex/issues) してください。
+:::note[実際のインストールで検証済み]
+上の形状は、Pi 0.83.x がインストールされたマシン上の実際の `~/.pi/agent/models.json` に対して検証されています。ブロックは検証を通過し、ルーティングされたすべてのモデルが Pi のピッカーに表示されます。より新しい Pi がエクスポートされたブロックを拒否した場合、不一致は私たちの側にあります。Pi が報告した内容を添えて [問題を開く](https://github.com/lidge-jun/opencodex/issues) してください。
:::
## 要件
diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md
index c970804e7b..783e274529 100644
--- a/docs-site/src/content/docs/ja/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ja/reference/cli/agents.md
@@ -125,7 +125,7 @@ Grok Build モデル フェンスを管理および適用します。
## クライアント設定のエクスポート
-### `ocx export --client `
+### `ocx export --client `
実行中のプロキシに接続されているクライアント設定を出力します。 opencode と [円周率](/guides/pi/) は環境変数ではなく独自の JSON 設定からプロバイダーを読み取るため、このコマンドは `opencodex` プロバイダー ブロック (ベース URL、モデル リスト、クライアントの環境参照) をシリアル化し、そのファイルにマージできるようにします。
@@ -141,23 +141,25 @@ Grok Build モデル フェンスを管理および適用します。
```bash
ocx export --client opencode # config plus destination, merge warning, and counts
ocx export --client pi --json > pi-models.json # byte-exact JSON for a pipe or a diff
+ocx export --client omp --json > omp-models.yml # OMP models.yml provider block
ocx export --client opencode --out ~/opencodex-opencode.json
```
-`--json` がない場合、JSON が先頭に続き、正規の宛先パス、マージ警告、環境エクスポート行、およびコンテキスト制限を省略する行数を含むモデル数が続きます (クライアントはこれらに対して独自のデフォルトを適用します)。
+`--json` がない場合、JSON が先頭に続き、正規の宛先パス、マージ警告、クライアント固有の起動前ガイダンス、およびコンテキスト制限を省略する行数を含むモデル数が続きます (クライアントはこれらに対して独自のデフォルトを適用します)。
|クライアント |正規の宛先 |ダウンロードファイル名 |環境変数 |
| --- | --- | --- | --- |
| `opencode` | `~/.config/opencode/opencode.json` (設定すると `XDG_CONFIG_HOME` が勝ち) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` |
-| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | `OPENCODEX_API_KEY` |
+| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | なし - ブロックにリテラル `opencodex-loopback` が入ります |
+| `omp` | `~/.omp/agent/models.yml` | `omp-models.yaml` | なし - loopback placeholder |
-2 つの環境変数名は異なり、各クライアントは独自の名前のみを補間します。 opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を読み取ります。 Pi は `$OPENCODEX_API_KEY` を読み取ります。
+opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex が生成する Pi のエクスポートには環境変数が不要で、リテラルのプレースホルダー `opencodex-loopback` が入ります。この値は必須です。Pi はモデル リストを構築する際に `apiKey` を解決し、既存の設定に未設定の環境変数参照がある場合はプロバイダー全体を隠すためです。ループバックでは、生成されたプレースホルダーをプロキシが検査することはありません。
:::caution[マージし、決して置き換えないでください]
`ocx export` は実際のクライアント設定を書き込むことはありません。宛先は手動でマージできるように出力されます。`--out` は、`--force` なしで既存のファイルを上書きすることを拒否します。これは、設定を置き換えると、その中にすでに含まれている他のプロバイダー、エージェント、および MCP エントリが破壊されるためです。
:::
-キーはシリアル化されません。設定にはクライアントの環境参照のみが含まれるため、シークレットは環境内に残ります。ループバック プロキシ (`127.0.0.1`、デフォルト) にはアドミッション キーはまったく必要ありません。参照は単に使用されないだけです。プロキシがループバックを超えてバインドする場合にのみ変数を設定します。アドミッションキーの発行方法については、[リモートアクセス](/reference/configuration/#remote-access) を参照してください。上流プロバイダー自体のキーは完全に別のものであり、[プロバイダー](/guides/providers/) ごとに構成されます。
+キーはシリアル化されません。opencode の設定には環境参照のみが含まれるためシークレットは環境内に残り、Pi の設定には認証情報ではなくプレースホルダーが入ります。ループバック プロキシ (`127.0.0.1`、デフォルト) にはアドミッション キーはまったく必要ありません。opencode の変数は、プロキシがループバックを超えてバインドする場合にのみ設定します。アドミッションキーの発行方法については、[リモートアクセス](/reference/configuration/#remote-access) を参照してください。上流プロバイダー自体のキーは完全に別のものであり、[プロバイダー](/guides/providers/) ごとに構成されます。
同じペイロードが `GET /api/client-config` によって提供され、ダッシュボードの [API] タブにレンダリングされるため、CLI、API、および GUI は同じバイトを使用します。
diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md
index 56f23e6146..262f2d0e54 100644
--- a/docs-site/src/content/docs/ja/reference/management-api.md
+++ b/docs-site/src/content/docs/ja/reference/management-api.md
@@ -130,7 +130,7 @@ Authorization: Bearer
| --- | --- | --- |
| `GET /api/catalog` |インストールされている Codex カタログ ドキュメントを返します | 404 カタログが見つかりません |
| `GET /api/models` |ダッシュボード/CLI モデルの行を返す |収集が飽和したときの `catalog_busy` |
-| `GET /api/client-config?client=...` |読み取り専用の OpenCode または Pi クライアント構成ドキュメントを作成する | 400 クライアントがサポートされていません。 503 カタログは利用できません |
+| `GET /api/client-config?client=...` |サポートされているファイル連携の読み取り専用クライアント設定を作成する | 400 クライアントがサポートされていません。 503 カタログは利用できません |
| `PUT /api/disabled-models` |共有の無効モデル リストを置き換える | 400 無効な JSON |
| `PUT /api/model-visibility` |プロバイダーレベルまたはモデルレベルの可視性をアトミックに変更 | 400 プロバイダー、スコープ、ターゲット、または本文が無効です。
| `GET, POST /api/custom-models` |カスタム モデルをリストするか追加する | 400 個の無効なフィールド。 404 プロバイダーがありません。 409 複製モデル |
diff --git a/docs-site/src/content/docs/ko/guides/pi.md b/docs-site/src/content/docs/ko/guides/pi.md
index 648d71060e..5b044f875c 100644
--- a/docs-site/src/content/docs/ko/guides/pi.md
+++ b/docs-site/src/content/docs/ko/guides/pi.md
@@ -5,7 +5,7 @@ description: Pi에서 라우팅된 모델을 그대로 쓸 수 있습니다. `oc
Pi는 provider를 환경 변수 대신 하나의 전역 JSON 파일에서 읽기 때문에,
opencodex가 Pi를 직접 실행하지 않습니다. 대신 `ocx export`가 `opencodex` provider 블록,
-즉 base URL, 모델 목록, 그리고 Pi가 치환하는 환경 변수 참조를 직렬화해서 사용자가
+즉 base URL, 모델 목록, 그리고 placeholder 인증 키를 직렬화해서 사용자가
자신의 설정에 병합하도록 합니다.
## 빠른 시작
@@ -17,8 +17,8 @@ ocx start
ocx export --client pi
```
-출력은 JSON으로 시작하고, 이어서 대상 경로, 병합 경고, 환경 변수 export 줄, 그리고
-공식 context limit이 있는 모델 수를 보여줍니다.
+출력은 JSON으로 시작하고, 이어서 대상 경로, 병합 경고, 그리고 공식 context limit이
+있는 모델 수를 보여줍니다.
```json
{
@@ -26,7 +26,7 @@ ocx export --client pi
"opencodex": {
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
- "apiKey": "$OPENCODEX_API_KEY",
+ "apiKey": "opencodex-loopback",
"models": [
{
"id": "anthropic/claude-opus-5",
@@ -68,30 +68,19 @@ ocx export --client pi --json > ~/opencodex-pi-models.json # or redirect the b
내보낸 블록은 실시간 뷰가 아니라 고정 스냅샷입니다. provider를 추가하거나 모델
가시성을 바꾼 뒤에는 `ocx export`를 다시 실행하고, 새 블록을 옛 블록 위에 병합하세요.
-## 인증 키
+## Pi의 `apiKey` placeholder
-여기서는 서로 헷갈리기 쉬운 키가 두 개 있고, 이 파일에 등장하는 것은 첫 번째뿐입니다.
+Pi는 일반적으로 `/chat/completions`를 호출하며, 설정된 `apiKey`를 Bearer 인증 값으로
+보냅니다. 따라서 생성된 블록은 Pi의 일반 `apiKey` 필드에 비밀이 아닌 리터럴
+`opencodex-loopback`을 넣습니다.
-| 키 | 무엇인지 | 어디에 있는지 |
-| --- | --- | --- |
-| Proxy admission key | opencodex의 자체 인증 정보이며, 대시보드의 **API** 탭에서 생성됩니다 | `apiKey`로 `$OPENCODEX_API_KEY`를 참조하며, 값은 환경 변수에 둡니다 |
-| Provider key | Anthropic / OpenAI / OpenRouter 키입니다 | opencodex의 자체 config에 있으며, [Providers](/guides/providers/)마다 따로 둡니다 |
+이 리터럴은 프록시 admission credential도 upstream provider 키도 아닙니다. 루프백
+프록시는 이 값을 무시하며 credential을 전혀 요구하지 않습니다. 다만 모델 탐색에는
+필수입니다. Pi는 모델 목록을 만들 때 `apiKey`를 해석하고, 값이 설정되지 않은 환경 변수
+참조이면 provider 전체를 숨기므로, 리터럴이어야 라우팅된 모든 모델이 보입니다.
-내보낸 config에는 비밀값이 아니라 참조만 들어갑니다. Pi는 `$NAME` 형태를 그대로
-치환하므로 변수는 다음과 같습니다.
-
-```bash
-export OPENCODEX_API_KEY=
-```
-
-이 이름은 Pi 전용입니다. opencode는 다른 변수를 씁니다
-(`OPENCODEX_OPENCODE_API_KEY`, `{env:…}` 형식) - 자세한 내용은 [opencode 가이드](/guides/opencode/)를 보세요.
-
-**루프백 프록시는 키가 전혀 필요 없습니다.** opencodex는 기본적으로 `127.0.0.1`에
-바인드하고 그곳에서는 아무 것도 인증하지 않으므로, `$OPENCODEX_API_KEY` 참조는
-실제로는 비어 있어도 됩니다. 이 값은 `hostname`이 루프백 바깥으로 설정될 때만
-의미가 있으며, 그 경우에는 프록시가 토큰 없이 시작하지 않습니다. 자세한 내용은
-[Remote access](/reference/configuration/#remote-access)를 보세요.
+Provider 키는 별개입니다. Anthropic / OpenAI / OpenRouter 키는 opencodex의 자체
+config에 있으며([Providers](/guides/providers/) 참조), 이 파일에는 절대 나타나지 않습니다.
## 모델 메타데이터
@@ -110,9 +99,9 @@ export OPENCODEX_API_KEY=
## 스키마 상태
-:::note[실제 설치에서 검증하지 않음]
-위의 형태는 Pi가 공개한 custom-provider 문서를 따른 것입니다. Pi가 설치된 머신의
-실제 `~/.pi/agent/models.json`으로는 아직 검증하지 않았습니다. Pi가 내보낸 블록을
+:::note[실제 설치에서 검증됨]
+위의 형태는 Pi 0.83.x가 설치된 머신의 실제 `~/.pi/agent/models.json`으로 검증했습니다.
+블록이 유효하고 라우팅된 모든 모델이 Pi 선택기에 표시됩니다. 더 새로운 Pi가 이 블록을
거부하면 문제는 우리 쪽에 있습니다. Pi가 무엇을 보고했는지와 함께
[issue를 열어주세요](https://github.com/lidge-jun/opencodex/issues).
:::
diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md
index 13f95f948f..c45aea4674 100644
--- a/docs-site/src/content/docs/ko/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ko/reference/cli/agents.md
@@ -131,7 +131,7 @@ Grok Build model fence를 관리하고 적용합니다.
## 클라이언트 설정 내보내기
-### `ocx export --client `
+### `ocx export --client `
실행 중인 프록시에 연결된 client config를 출력합니다. opencode와 [Pi](/guides/pi/)는 environment variable이 아니라 각자의 JSON config에서 provider를 읽으므로, 이 명령은 `opencodex` provider block, 즉 base URL, model list, 그리고 client의 env reference를 직렬화해서 해당 파일에 병합할 수 있게 해줍니다.
@@ -147,23 +147,25 @@ Grok Build model fence를 관리하고 적용합니다.
```bash
ocx export --client opencode # config plus destination, merge warning, and counts
ocx export --client pi --json > pi-models.json # byte-exact JSON for a pipe or a diff
+ocx export --client omp --json > omp-models.yml # OMP models.yml provider block
ocx export --client opencode --out ~/opencodex-opencode.json
```
-`--json`이 없으면 JSON이 먼저 나오고, 그다음 표준 대상 경로, merge 경고, env export 줄, 그리고 context limit을 생략한 row 수를 포함한 model count가 이어집니다(이 경우 client는 자체 기본값을 적용합니다).
+`--json`이 없으면 JSON이 먼저 나오고, 그다음 표준 대상 경로, merge 경고, 해당 client에 env 변수가 있는 경우 env export 줄, 그리고 context limit을 생략한 row 수를 포함한 model count가 이어집니다(이 경우 client는 자체 기본값을 적용합니다).
| 클라이언트 | 표준 대상 경로 | 다운로드 파일명 | 환경 변수 |
| --- | --- | --- | --- |
| `opencode` | `~/.config/opencode/opencode.json` (`XDG_CONFIG_HOME`이 설정되어 있으면 우선합니다) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` |
-| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | `OPENCODEX_API_KEY` |
+| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | 없음 - 블록에 리터럴 `opencodex-loopback`이 들어갑니다 |
+| `omp` | `~/.omp/agent/models.yml` | `omp-models.yaml` | 없음 - loopback placeholder |
-두 환경 변수 이름은 서로 다르며, 각 client는 자기 것만 보간합니다. opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 읽고, Pi는 `$OPENCODEX_API_KEY`를 읽습니다.
+opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 생성한 Pi 블록에는 환경 변수가 필요 없으며, 리터럴 placeholder인 `opencodex-loopback`이 들어갑니다. 이 값은 필수입니다. Pi는 모델 목록을 만들 때 `apiKey`를 해석하고, 기존 config에 설정되지 않은 env 참조가 있으면 provider 전체를 숨기기 때문입니다. 루프백에서 proxy는 생성된 placeholder를 검사하지 않습니다.
:::caution[Merge, never replace]
`ocx export`는 실제 client config를 절대 쓰지 않습니다. 대상 경로는 손으로 병합하라고 출력되며, `--out`은 `--force` 없이 기존 파일을 덮어쓰지 않습니다. config를 바꾸어 덮어쓰면 이미 들어 있던 다른 provider, agent, MCP entry가 사라지기 때문입니다.
:::
-어떤 key도 직렬화되지 않습니다. config에는 client의 env reference만 들어가므로 secret은 환경 변수에 남습니다. loopback proxy(`127.0.0.1`, 기본값)는 admission key가 전혀 필요하지 않습니다. reference는 단지 사용되지 않을 뿐입니다. proxy가 loopback을 넘어 바인딩할 때만 변수를 설정하십시오. admission key가 어떻게 발급되는지는 [Remote access](/reference/configuration/#remote-access)를 보십시오. upstream provider 자체의 key는 완전히 별개의 것으로, 각 [Providers](/guides/providers/)에 맞게 설정합니다.
+어떤 key도 직렬화되지 않습니다. opencode config에는 env reference만 들어가므로 secret은 환경 변수에 남고, Pi config에는 인증 정보가 아니라 placeholder가 들어갑니다. loopback proxy(`127.0.0.1`, 기본값)는 admission key가 전혀 필요하지 않습니다. opencode 변수는 proxy가 loopback을 넘어 바인딩할 때만 설정하십시오. admission key가 어떻게 발급되는지는 [Remote access](/reference/configuration/#remote-access)를 보십시오. upstream provider 자체의 key는 완전히 별개의 것으로, 각 [Providers](/guides/providers/)에 맞게 설정합니다.
같은 payload는 `GET /api/client-config`로 제공되고 dashboard의 API 탭에도 렌더링되므로, CLI, API, GUI가 모두 같은 바이트를 사용합니다.
diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md
index 334c73f094..2df6d9ccfc 100644
--- a/docs-site/src/content/docs/ko/reference/management-api.md
+++ b/docs-site/src/content/docs/ko/reference/management-api.md
@@ -130,7 +130,7 @@ Authorization: Bearer
| --- | --- | --- |
| `GET /api/catalog` | 설치된 Codex catalog 문서를 반환합니다 | 404 catalog 없음 |
| `GET /api/models` | 대시보드/CLI model 행을 반환합니다 | 수집이 포화 상태이면 `catalog_busy` |
-| `GET /api/client-config?client=...` | 읽기 전용 OpenCode 또는 Pi client-config 문서를 만듭니다 | 400 지원되지 않는 client; 503 catalog 사용 불가 |
+| `GET /api/client-config?client=...` | 지원되는 파일 연동의 읽기 전용 client config를 만듭니다 | 400 지원되지 않는 client; 503 catalog 사용 불가 |
| `PUT /api/disabled-models` | 공유 disabled-model 목록을 교체합니다 | 400 잘못된 JSON |
| `PUT /api/model-visibility` | provider 또는 model 수준의 visibility를 원자적으로 변경합니다 | 400 잘못된 provider, scope, target, 또는 본문 |
| `GET, POST /api/custom-models` | custom model을 나열하거나 하나를 추가합니다 | 400 잘못된 필드; 404 provider 없음; 409 중복 model |
diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md
index 4944a483d2..3b23b95a2d 100644
--- a/docs-site/src/content/docs/reference/cli/agents.md
+++ b/docs-site/src/content/docs/reference/cli/agents.md
@@ -146,40 +146,48 @@ Manage and apply the Grok Build model fence.
## Client config export
-### `ocx export --client `
+### `ocx export --client `
-Print a client config wired to the running proxy. opencode and [Pi](/guides/pi/) read providers
-from their own JSON config rather than environment variables, so this command serializes the
-`opencodex` provider block — base URL, model list, and the client's env reference — for you to
-merge into that file.
+Print a client config wired to the running proxy. The command serializes the
+`opencodex` provider block — base URL, model list, and the client's credential
+reference or loopback placeholder — in the selected client's native format.
The proxy must be running; the command resolves its live port, reads `/api/models`, and emits only
models Codex can currently see.
| Flag | Action |
| --- | --- |
-| `--client ` | Required. Selects the client dialect: opencode's keyed `provider` object or Pi's `providers` array. |
-| `--json` | Print only the config JSON on stdout, so a redirect captures byte-exact output. Every diagnostic, including the `--out` write note, goes to stderr. |
+| `--client ` | Required. Selects the client config dialect. |
+| `--json` | Print only the generated config bytes on stdout, so a redirect captures byte-exact output. Every diagnostic, including the `--out` write note, goes to stderr. |
| `--out ` | Write the config to ``. Refuses to replace an existing file. |
| `--force` | Allow `--out` to replace an existing file. |
```bash
ocx export --client opencode # config plus destination, merge warning, and counts
ocx export --client pi --json > pi-models.json # byte-exact JSON for a pipe or a diff
+ocx export --client omp --json > omp-models.yml # OMP models.yml provider block
ocx export --client opencode --out ~/opencodex-opencode.json
```
-Without `--json` the JSON leads, then the canonical destination path, the merge warning, the env
-export line, and a model count with how many rows omit context limits (the client applies its own
-defaults for those).
+Without `--json` the generated config leads, then the canonical destination path, the merge warning, the env
+export line where the client has one, and a model count with how many rows omit context limits (the
+client applies its own defaults for those).
| Client | Canonical destination | Download filename | Env var |
| --- | --- | --- | --- |
| `opencode` | `~/.config/opencode/opencode.json` (`XDG_CONFIG_HOME` wins when set) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` |
-| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | `OPENCODEX_API_KEY` |
-
-The two env var names are different, and each client only interpolates its own. opencode reads
-`{env:OPENCODEX_OPENCODE_API_KEY}`; Pi reads `$OPENCODEX_API_KEY`.
+| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | none — the block carries the literal `opencodex-loopback` |
+| `omp` | `~/.omp/agent/models.yml` (`OMP_PROFILE`, `PI_CONFIG_DIR`, and `PI_CODING_AGENT_DIR` are honored) | `omp-models.yaml` | none — loopback placeholder |
+| `hermes` | `~/.hermes/config.yaml` | `hermes-config.yaml` | `OPENCODEX_HERMES_API_KEY` |
+| `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` |
+| `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | none — loopback placeholder |
+| `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` |
+
+opencode interpolates `{env:OPENCODEX_OPENCODE_API_KEY}`. The generated Pi and OMP exports do
+not require an environment variable: each carries the literal `opencodex-loopback` placeholder.
+This is load-bearing because both clients resolve `apiKey` while building their model lists and
+hide the whole provider when an existing config contains an unset env reference. The proxy never
+checks the generated placeholder on loopback.
:::caution[Merge, never replace]
`ocx export` never writes your real client config. The destination is printed for you to merge by
@@ -187,9 +195,10 @@ hand, and `--out` refuses to overwrite an existing file without `--force`, becau
config destroys the other providers, agents, and MCP entries already in it.
:::
-No key is ever serialized. The config carries only the client's env reference, so the secret stays
-in your environment. A loopback proxy (`127.0.0.1`, the default) requires no admission key at all —
-the reference is simply unused. Set the variable only when the proxy binds beyond loopback; see
+No key is ever serialized. Configs carry either a documented environment reference or a
+non-secret loopback placeholder. A loopback proxy (`127.0.0.1`, the default) requires no
+admission key at all. Set a referenced variable only when the client schema supports it and
+the proxy binds beyond loopback; see
[Remote access](/reference/configuration/#remote-access) for how admission keys are issued. Keys for
the upstream providers themselves are a separate thing entirely, configured per
[Providers](/guides/providers/).
diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md
index 2635b719af..49f299a621 100644
--- a/docs-site/src/content/docs/reference/management-api.md
+++ b/docs-site/src/content/docs/reference/management-api.md
@@ -146,7 +146,7 @@ first and submit the returned digest. Prefer quarantine when recovery may be nee
| --- | --- | --- |
| `GET /api/catalog` | Return the installed Codex catalog document | 404 catalog not found |
| `GET /api/models` | Return the dashboard/CLI model rows | `catalog_busy` when gathering is saturated |
-| `GET /api/client-config?client=...` | Build a read-only OpenCode or Pi client-config document | 400 unsupported client; 503 catalog unavailable |
+| `GET /api/client-config?client=...` | Build a read-only client config for any supported file integration | 400 unsupported client; 503 catalog unavailable |
| `PUT /api/disabled-models` | Replace the shared disabled-model list | 400 invalid JSON |
| `PUT /api/model-visibility` | Atomically change provider- or model-level visibility | 400 invalid provider, scope, target, or body |
| `GET, POST /api/custom-models` | List custom models or add one | 400 invalid fields; 404 provider missing; 409 duplicate model |
diff --git a/docs-site/src/content/docs/ru/guides/pi.md b/docs-site/src/content/docs/ru/guides/pi.md
index 0960ecf49a..ab6407f2c9 100644
--- a/docs-site/src/content/docs/ru/guides/pi.md
+++ b/docs-site/src/content/docs/ru/guides/pi.md
@@ -5,8 +5,8 @@ description: Используйте любую маршрутизируемую
Pi читает провайдеров из одного глобального JSON-файла, а не из переменных окружения, поэтому
opencodex не запускает его сам. Вместо этого `ocx export` сериализует блок провайдера
-`opencodex` — base URL, список моделей и env-ссылку, которую интерполирует Pi, — а вы сливаете
-его в свою конфигурацию.
+`opencodex` — base URL, список моделей и ключ-заглушку допуска, — а вы сливаете его в свою
+конфигурацию.
## Быстрый старт
@@ -17,8 +17,8 @@ ocx start
ocx export --client pi
```
-Сначала выводится JSON, затем путь назначения, предупреждение о merge, строка `export` для
-переменной окружения и число моделей, для которых есть авторитетные контекстные лимиты.
+Сначала выводится JSON, затем путь назначения, предупреждение о merge и число моделей, для
+которых есть авторитетные контекстные лимиты.
```json
{
@@ -26,7 +26,7 @@ ocx export --client pi
"opencodex": {
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
- "apiKey": "$OPENCODEX_API_KEY",
+ "apiKey": "opencodex-loopback",
"models": [
{
"id": "anthropic/claude-opus-5",
@@ -69,30 +69,20 @@ ocx export --client pi --json > ~/opencodex-pi-models.json # or redirect the b
провайдера или изменения видимости моделей заново выполняйте `ocx export`, а новый блок вливайте
поверх старого.
-## Admission key
+## Заглушка Pi `apiKey`
-Здесь легко перепутать два разных ключа, и в этом файле появляется только первый:
+Pi обычно вызывает `/chat/completions` и отправляет настроенный `apiKey` как значение авторизации
+Bearer. Поэтому сгенерированный блок помещает несекретный литерал `opencodex-loopback` в
+обычное поле Pi `apiKey`.
-| Ключ | Что это | Где хранится |
-| --- | --- | --- |
-| Ключ допуска прокси | собственная учётная запись opencodex, генерируемая на вкладке **API** в дашборде | указывается в `apiKey` как `$OPENCODEX_API_KEY`; само значение остаётся в окружении |
-| Ключ провайдера | ваш ключ Anthropic / OpenAI / OpenRouter | хранится в конфигурации самого opencodex, см. [Провайдеры](/guides/providers/) |
+Этот литерал — не credential допуска прокси и не ключ upstream-провайдера. Loopback-прокси
+игнорирует его и вообще не требует credential. При этом значение необходимо для обнаружения
+моделей: Pi разрешает `apiKey`, когда строит список, и прячет провайдера целиком, если значение —
+ссылка на незаданную переменную окружения. Литерал сохраняет видимость всех маршрутизируемых
+моделей.
-Экспортируемая конфигурация несёт только ссылку, а не секрет. Pi интерполирует голый `$NAME`,
-поэтому переменная должна выглядеть так:
-
-```bash
-export OPENCODEX_API_KEY=
-```
-
-Это имя переменной относится только к Pi. opencode использует другую переменную
-(`OPENCODEX_OPENCODE_API_KEY` в форме `{env:…}`) — см. [руководство по opencode](/guides/opencode/).
-
-**Прокси на loopback вообще не требует ключа.** По умолчанию opencodex привязывается к
-`127.0.0.1` и ничего там не аутентифицирует, поэтому ссылка `$OPENCODEX_API_KEY` инертна и
-переменную можно не задавать. Она нужна только когда `hostname` выходит за пределы loopback — а
-именно в этом случае прокси и отказывается запускаться без токена; см.
-[Удалённый доступ](/reference/configuration/#remote-access).
+Ключи провайдеров — отдельная история: ключ Anthropic / OpenAI / OpenRouter хранится в
+конфигурации самого opencodex, см. [Провайдеры](/guides/providers/), и в этом файле не появляется.
## Метаданные моделей
@@ -111,10 +101,10 @@ export OPENCODEX_API_KEY=
## Статус схемы
-:::note[Не проверено на реальной установке]
-Форма выше соответствует опубликованной документации Pi по custom-провайдерам. Она **не была
-проверена** на реальном `~/.pi/agent/models.json` на машине с установленным Pi. Если Pi отвергнет
-экспортированный блок, несоответствие на нашей стороне — пожалуйста,
+:::note[Проверено на реальной установке]
+Форма выше проверена на реальном `~/.pi/agent/models.json` с установленным Pi 0.83.x: блок
+проходит валидацию, и все маршрутизируемые модели видны в выборе моделей Pi. Если более новый Pi
+отвергнет экспортированный блок, несоответствие на нашей стороне — пожалуйста,
[создайте issue](https://github.com/lidge-jun/opencodex/issues) и приложите то, что сообщил Pi.
:::
diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md
index 2d738ab9b4..6f69806235 100644
--- a/docs-site/src/content/docs/ru/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ru/reference/cli/agents.md
@@ -152,7 +152,7 @@ override, но файлы на диске никогда не меняются.
## Экспорт client config
-### `ocx export --client `
+### `ocx export --client `
Печатает client config, направленный на работающий прокси. opencode и [Pi](/guides/pi/) читают
провайдеров из собственных JSON-конфигов, а не из переменных окружения, поэтому команда
@@ -172,20 +172,25 @@ override, но файлы на диске никогда не меняются.
```bash
ocx export --client opencode # config plus destination, merge warning, and counts
ocx export --client pi --json > pi-models.json # byte-exact JSON for a pipe or a diff
+ocx export --client omp --json > omp-models.yml # OMP models.yml provider block
ocx export --client opencode --out ~/opencodex-opencode.json
```
-Без `--json` сначала идёт JSON, затем канонический путь назначения, предупреждение о merge, строка
-экспорта переменной окружения и количество моделей с указанием, сколько строк не имеют context
-limit'а (для них клиент применяет собственные default'ы).
+Без `--json` сначала идёт JSON, затем канонический путь назначения, предупреждение о merge,
+клиентская подсказка перед запуском и количество моделей с указанием, сколько строк не имеют
+context limit'а (для них клиент применяет собственные default'ы).
| Клиент | Канонический путь | Имя скачиваемого файла | Переменная окружения |
| --- | --- | --- | --- |
| `opencode` | `~/.config/opencode/opencode.json` (`XDG_CONFIG_HOME` имеет приоритет, если задан) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` |
-| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | `OPENCODEX_API_KEY` |
+| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | нет — блок несёт литерал `opencodex-loopback` |
+| `omp` | `~/.omp/agent/models.yml` | `omp-models.yaml` | нет — loopback placeholder |
-Имена этих двух env-переменных различаются, и каждый клиент интерполирует только свою. opencode
-читает `{env:OPENCODEX_OPENCODE_API_KEY}`; Pi читает `$OPENCODEX_API_KEY`.
+opencode интерполирует `{env:OPENCODEX_OPENCODE_API_KEY}`. Сгенерированный opencodex экспорт для
+Pi не требует переменной окружения и несёт литеральную заглушку `opencodex-loopback`. Это значение
+обязательно: Pi разрешает `apiKey`, когда строит список моделей, и прячет провайдера целиком, если
+существующий конфиг содержит ссылку на незаданную переменную окружения. На loopback прокси не
+проверяет сгенерированную заглушку.
:::caution[Сливать, а не заменять]
`ocx export` никогда не пишет в ваш реальный клиентский конфиг. Путь назначения лишь
@@ -194,10 +199,10 @@ limit'а (для них клиент применяет собственные d
MCP-записи.
:::
-Никакой ключ никогда не сериализуется. Конфиг несёт только env-reference клиента, так что секрет
-остаётся в вашем окружении. Loopback-прокси (`127.0.0.1`, по умолчанию) вообще не требует
-admission key — ссылка просто остаётся неиспользованной. Задавайте переменную только если прокси
-слушает не на loopback; как выдаются admission key, описано в
+Никакой ключ никогда не сериализуется. Конфиг opencode несёт только env-reference, так что секрет
+остаётся в вашем окружении, а конфиг Pi несёт заглушку вместо учётных данных. Loopback-прокси
+(`127.0.0.1`, по умолчанию) вообще не требует admission key. Задавайте переменную opencode только
+если прокси слушает не на loopback; как выдаются admission key, описано в
[Удалённом доступе](/reference/configuration/#remote-access). Ключи upstream-провайдеров — это
совсем отдельная история и настраиваются в [Провайдерах](/guides/providers/).
diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md
index dbd13ac09a..1479105715 100644
--- a/docs-site/src/content/docs/ru/reference/management-api.md
+++ b/docs-site/src/content/docs/ru/reference/management-api.md
@@ -148,7 +148,7 @@ Endpoint'ы storage cleanup могут перемещать или навсег
| --- | --- | --- |
| `GET /api/catalog` | Вернуть установленный документ каталога Codex | 404 catalog not found |
| `GET /api/models` | Вернуть model-row'ы для дашборда и CLI | `catalog_busy`, когда сборка перегружена |
-| `GET /api/client-config?client=...` | Собрать read-only client-config document для OpenCode или Pi | 400 unsupported client; 503 catalog unavailable |
+| `GET /api/client-config?client=...` | Собрать read-only client config для любой поддерживаемой файловой интеграции | 400 unsupported client; 503 catalog unavailable |
| `PUT /api/disabled-models` | Полностью заменить общий список disabled-models | 400 invalid JSON |
| `PUT /api/model-visibility` | Атомарно изменить видимость на уровне провайдера или модели | 400 invalid provider, scope, target or body |
| `GET, POST /api/custom-models` | Показать список custom-моделей или добавить одну | 400 invalid fields; 404 provider missing; 409 duplicate model |
diff --git a/docs-site/src/content/docs/zh-cn/guides/pi.md b/docs-site/src/content/docs/zh-cn/guides/pi.md
index ad868e3194..45926460d7 100644
--- a/docs-site/src/content/docs/zh-cn/guides/pi.md
+++ b/docs-site/src/content/docs/zh-cn/guides/pi.md
@@ -3,7 +3,7 @@ title: Pi
description: 在 Pi 中使用任意已路由模型 - `ocx export` 会为 Pi 的 `models.json` 写入一个自定义 provider 块,并连接到正在运行的代理。
---
-Pi 从一个全局 JSON 文件而不是环境变量中读取 providers,所以 opencodex 不会启动它。相反,`ocx export` 会序列化 `opencodex` provider 块 - 基础 URL、模型列表,以及 Pi 会插值的 env 引用 - 然后你把它合并到自己的配置中。
+Pi 从一个全局 JSON 文件而不是环境变量中读取 providers,所以 opencodex 不会启动它。相反,`ocx export` 会序列化 `opencodex` provider 块 - 基础 URL、模型列表,以及一个占位准入 key - 然后你把它合并到自己的配置中。
## 快速开始
@@ -14,7 +14,7 @@ ocx start
ocx export --client pi
```
-输出会先显示 JSON,然后打印目标路径、合并警告、env 导出行,以及有多少模型带有权威上下文窗口限制。
+输出会先显示 JSON,然后打印目标路径、合并警告,以及有多少模型带有权威上下文窗口限制。
```json
{
@@ -22,7 +22,7 @@ ocx export --client pi
"opencodex": {
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
- "apiKey": "$OPENCODEX_API_KEY",
+ "apiKey": "opencodex-loopback",
"models": [
{
"id": "anthropic/claude-opus-5",
@@ -58,24 +58,13 @@ ocx export --client pi --json > ~/opencodex-pi-models.json # or redirect the b
导出的块是静态快照,不是实时视图。新增 provider 或更改模型可见性后,请重新运行 `ocx export`,再用新的块覆盖旧块进行合并。
-## 准入密钥
+## Pi 的 `apiKey` 占位值
-这里有两个很容易混淆的 key,但这个文件里只会出现第一个:
+Pi 通常调用 `/chat/completions`,并将配置的 `apiKey` 作为 Bearer 认证值发送。因此,生成的块会在 Pi 的常规 `apiKey` 字段中写入非机密字面值 `opencodex-loopback`。
-| Key | 它是什么 | 它存放在哪里 |
-| --- | --- | --- |
-| 代理准入密钥 | opencodex 自己的凭据,在仪表盘的 **API** 选项卡中生成 | 通过 `apiKey` 以 `$OPENCODEX_API_KEY` 形式引用;实际值保存在你的环境中 |
-| Provider key | 你的 Anthropic / OpenAI / OpenRouter key | opencodex 自己的配置中,见 [Providers](/guides/providers/) |
+这个字面值既不是代理准入凭据,也不是上游 provider key。回环代理会忽略它,并且完全不需要凭据。不过它对模型发现是必需的:Pi 在构建模型列表时会解析 `apiKey`,如果该值是未设置的环境变量引用,它就会隐藏整个 provider;使用字面值才能让所有已路由模型保持可见。
-导出的配置只包含引用,从不包含 secret。Pi 会插值裸的 `$NAME`,所以变量是:
-
-```bash
-export OPENCODEX_API_KEY=
-```
-
-这个名字只属于 Pi。opencode 使用不同的变量(`OPENCODEX_OPENCODE_API_KEY`,以 `{env:…}` 形式出现) - 见 [opencode 指南](/guides/opencode/)。
-
-**回环代理根本不需要 key。** opencodex 默认绑定 `127.0.0.1`,在那里不做任何认证,所以 `$OPENCODEX_API_KEY` 引用是无效的,你可以不设置这个变量。它只在 `hostname` 超出回环范围时才有意义,而这也是代理会在没有 token 的情况下拒绝启动的时候 - 见 [远程访问](/reference/configuration/#remote-access)。
+Provider key 是另一回事:你的 Anthropic / OpenAI / OpenRouter key 保存在 opencodex 自己的配置中,见 [Providers](/guides/providers/),它绝不会出现在这个文件里。
## 模型元数据
@@ -87,8 +76,8 @@ export OPENCODEX_API_KEY=
## Schema 状态
-:::note[未在真实安装上验证]
-上面的结构遵循了 Pi 已公开的自定义 provider 文档。它**尚未**在一台安装了 Pi 的机器上、针对真实的 `~/.pi/agent/models.json` 进行验证。如果 Pi 拒绝这个导出块,问题在我们这边 - 请带上 Pi 的报错信息[提交 issue](https://github.com/lidge-jun/opencodex/issues)。
+:::note[已在真实安装上验证]
+上面的结构已在安装了 Pi 0.83.x 的机器上、针对真实的 `~/.pi/agent/models.json` 完成验证:该块通过校验,并且所有已路由模型都会出现在 Pi 的模型选择器中。如果更新版本的 Pi 拒绝这个导出块,问题在我们这边 - 请带上 Pi 的报错信息[提交 issue](https://github.com/lidge-jun/opencodex/issues)。
:::
## 需求
diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md
index dc1960adb3..ac29040be0 100644
--- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md
+++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md
@@ -126,7 +126,7 @@ ocx claude desktop import [--apply] Validate and import JSON
## Client config export
-### `ocx export --client `
+### `ocx export --client `
输出连接到正在运行代理的客户端配置。opencode 和 [Pi](/guides/pi/) 不是从环境变量,而是从各自的 JSON 配置中读取 providers,因此此命令会序列化 `opencodex` provider 块——基础 URL、模型列表以及客户端的环境引用——供你合并进那个文件。
@@ -142,23 +142,25 @@ ocx claude desktop import [--apply] Validate and import JSON
```bash
ocx export --client opencode # config plus destination, merge warning, and counts
ocx export --client pi --json > pi-models.json # byte-exact JSON for a pipe or a diff
+ocx export --client omp --json > omp-models.yml # OMP models.yml provider block
ocx export --client opencode --out ~/opencodex-opencode.json
```
-不使用 `--json` 时,JSON 会先输出,随后是规范目标路径、合并警告、环境变量导出行,以及一个模型计数,并标明有多少行省略了上下文限制(客户端会对这些项应用自己的默认值)。
+不使用 `--json` 时,JSON 会先输出,随后是规范目标路径、合并警告、客户端专属的启动前提示,以及一个模型计数,并标明有多少行省略了上下文限制(客户端会对这些项应用自己的默认值)。
| 客户端 | 规范目标路径 | 下载文件名 | 环境变量 |
| --- | --- | --- | --- |
| `opencode` | `~/.config/opencode/opencode.json`(设置了 `XDG_CONFIG_HOME` 时以其为准) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` |
-| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | `OPENCODEX_API_KEY` |
+| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | 无 - 块中携带字面值 `opencodex-loopback` |
+| `omp` | `~/.omp/agent/models.yml` | `omp-models.yaml` | 无 - loopback placeholder |
-这两个环境变量名称不同,而且每个客户端只会插入自己的那个。opencode 读取 `{env:OPENCODEX_OPENCODE_API_KEY}`;Pi 读取 `$OPENCODEX_API_KEY`。
+opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi 导出不需要环境变量,而是携带字面占位值 `opencodex-loopback`。这个值是必需的:Pi 在构建模型列表时会解析 `apiKey`,如果已有配置包含未设置的环境变量引用,它就会隐藏整个 provider。回环上的代理从不校验生成的占位值。
:::caution[合并,不要替换]
`ocx export` 从不写入你的真实客户端配置。该命令只会打印目标路径供你手动合并,而 `--out` 在没有 `--force` 的情况下拒绝覆盖已有文件,因为替换配置会破坏其中已有的其他 providers、agents 和 MCP 条目。
:::
-任何密钥都不会被序列化。配置里只包含客户端的环境引用,因此密钥仍保留在你的环境中。环回代理(`127.0.0.1`,默认值)根本不需要准入密钥——该引用只是不会被使用。只有当代理绑定到环回地址之外时才设置该变量;关于准入密钥如何签发,请参见 [远程访问](/reference/configuration/#remote-access)。上游 providers 自身的密钥则完全是另一回事,需要按 [Providers](/guides/providers/) 单独配置。
+任何密钥都不会被序列化。opencode 配置里只包含环境引用,因此密钥仍保留在你的环境中;Pi 配置里携带的是占位值而不是任何凭据。环回代理(`127.0.0.1`,默认值)根本不需要准入密钥。只有当代理绑定到环回地址之外时才需要设置 opencode 的那个变量;关于准入密钥如何签发,请参见 [远程访问](/reference/configuration/#remote-access)。上游 providers 自身的密钥则完全是另一回事,需要按 [Providers](/guides/providers/) 单独配置。
同一份负载会通过 `GET /api/client-config` 提供,并在仪表盘的 API 选项卡中渲染,因此 CLI、API 和 GUI 使用的是同一字节内容。
diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md
index 8f68041943..2822dcbf32 100644
--- a/docs-site/src/content/docs/zh-cn/reference/management-api.md
+++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md
@@ -130,7 +130,7 @@ Authorization: Bearer
| --- | --- | --- |
| `GET /api/catalog` | 返回已安装的 Codex 目录文档 | 404 未找到目录 |
| `GET /api/models` | 返回仪表板/CLI 模型行 | 收集饱和时返回 `catalog_busy` |
-| `GET /api/client-config?client=...` | 构建只读的 OpenCode 或 Pi client-config 文档 | 400 不支持的客户端;503 目录不可用 |
+| `GET /api/client-config?client=...` | 为任意支持的文件集成构建只读客户端配置 | 400 不支持的客户端;503 目录不可用 |
| `PUT /api/disabled-models` | 替换共享的禁用模型列表 | 400 无效 JSON |
| `PUT /api/model-visibility` | 原子性地更改 provider 级或 model 级可见性 | 400 provider、scope、target 或请求体无效 |
| `GET, POST /api/custom-models` | 列出自定义模型或添加一个 | 400 字段无效;404 provider 缺失;409 模型重复 |
diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts
index 7fc5cfea22..0d7df41ec8 100644
--- a/gui/src/app-routing.ts
+++ b/gui/src/app-routing.ts
@@ -76,6 +76,7 @@ export const INTEGRATION_TAB_HASHES = [
"integrations/grok",
"integrations/opencode",
"integrations/pi",
+ "integrations/omp",
"integrations/hermes",
"integrations/openclaw",
"integrations/kimi",
diff --git a/gui/src/components/apikeys-workspace/ClientConfigDialog.tsx b/gui/src/components/apikeys-workspace/ClientConfigDialog.tsx
index 0d315825f1..cad0dbcd25 100644
--- a/gui/src/components/apikeys-workspace/ClientConfigDialog.tsx
+++ b/gui/src/components/apikeys-workspace/ClientConfigDialog.tsx
@@ -78,9 +78,11 @@ export default function ClientConfigDialog({
{t("api.clientConfig.missingLimits", { count: envelope.modelsWithoutLimits, total: envelope.modelCount })}
)}
- {!hasKeys && (
+ {!hasKeys && envelope.apiKeyEnv !== "" && (
// Informational, never blocking: an agent may legitimately want the shape
- // first, so both actions stay enabled.
+ // first, so both actions stay enabled. A client with no env var at all
+ // (Pi, Kimi) reads its credential from its own file, so naming a
+ // variable there would render an empty name.
{t("api.clientConfig.noKeyYet", { env: envelope.apiKeyEnv })}
diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts
index dca09d8566..dc98871dbf 100644
--- a/gui/src/components/apikeys-workspace/client-config-clients.ts
+++ b/gui/src/components/apikeys-workspace/client-config-clients.ts
@@ -8,12 +8,13 @@
* with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row
* until this tuple changes.
*/
-export const CLIENTS = ["opencode", "pi", "hermes", "openclaw", "kimi", "gajae"] as const;
+export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae"] as const;
export type ExportClientId = (typeof CLIENTS)[number];
export const CLIENT_LABEL_KEYS = {
opencode: "api.clientConfig.clientOpencode",
pi: "api.clientConfig.clientPi",
+ omp: "api.clientConfig.clientOmp",
hermes: "api.clientConfig.clientHermes",
openclaw: "api.clientConfig.clientOpenclaw",
kimi: "api.clientConfig.clientKimi",
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index a4d633e10a..c39335a44c 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -744,6 +744,7 @@ export const de: Record = {
"integrations.tab.grok": "Grok Build",
"integrations.tab.opencode": "OpenCode",
"integrations.tab.pi": "Pi",
+ "integrations.tab.omp": "OMP",
"integrations.tab.hermes": "Hermes",
"integrations.tab.openclaw": "OpenClaw",
"integrations.tab.kimi": "Kimi Code",
@@ -837,6 +838,7 @@ export const de: Record = {
"integrations.kind.restore": "Wiederhergestellt",
"integrations.semantics.opencode": "Gilt nur für direkte Starts von der Festplatte; die Umgebungsinjektion von ocx opencode hat Vorrang.",
"integrations.semantics.pi": "Gilt für neue Sitzungen.",
+ "integrations.semantics.omp": "Starten Sie OMP neu, um den Katalog zu laden.",
"integrations.semantics.hermes": "Gilt für neue Sitzungen.",
"integrations.semantics.openclaw": "Wird sofort auf ein laufendes Gateway angewendet.",
"integrations.semantics.kimi": "Zum Anwenden neu starten oder /reload ausführen (v2 überwacht die Datei).",
@@ -1089,6 +1091,7 @@ export const de: Record = {
"api.clientConfig.copiedAnnounceClient": "{client}-Konfigurations-JSON in die Zwischenablage kopiert.",
"api.clientConfig.clientOpencode": "OpenCode",
"api.clientConfig.clientPi": "Pi",
+ "api.clientConfig.clientOmp": "OMP",
"api.clientConfig.clientHermes": "Hermes",
"api.clientConfig.clientOpenclaw": "OpenClaw",
"api.clientConfig.clientKimi": "Kimi Code",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index df67c75023..d3f0c6230c 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -1199,6 +1199,7 @@ export const en = {
"integrations.tab.grok": "Grok Build",
"integrations.tab.opencode": "OpenCode",
"integrations.tab.pi": "Pi",
+ "integrations.tab.omp": "OMP",
"integrations.tab.hermes": "Hermes",
"integrations.tab.openclaw": "OpenClaw",
"integrations.tab.kimi": "Kimi Code",
@@ -1292,6 +1293,7 @@ export const en = {
"integrations.kind.restore": "Restored",
"integrations.semantics.opencode": "Direct disk launches only; ocx opencode environment injection takes precedence.",
"integrations.semantics.pi": "Applies to new sessions.",
+ "integrations.semantics.omp": "Restart OMP to load the catalog.",
"integrations.semantics.hermes": "Applies to new sessions.",
"integrations.semantics.openclaw": "Applies immediately to a running gateway.",
"integrations.semantics.kimi": "Restart or run /reload to apply it (v2 watches the file).",
@@ -1551,6 +1553,7 @@ export const en = {
"api.clientConfig.copiedAnnounceClient": "{client} config JSON copied to the clipboard.",
"api.clientConfig.clientOpencode": "OpenCode",
"api.clientConfig.clientPi": "Pi",
+ "api.clientConfig.clientOmp": "OMP",
"api.clientConfig.clientHermes": "Hermes",
"api.clientConfig.clientOpenclaw": "OpenClaw",
"api.clientConfig.clientKimi": "Kimi Code",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index eb98a71eb6..7660006c90 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -1147,6 +1147,7 @@ export const ja: Record = {
"integrations.tab.grok": "Grok Build",
"integrations.tab.opencode": "OpenCode",
"integrations.tab.pi": "Pi",
+ "integrations.tab.omp": "OMP",
"integrations.tab.hermes": "Hermes",
"integrations.tab.openclaw": "OpenClaw",
"integrations.tab.kimi": "Kimi Code",
@@ -1240,6 +1241,7 @@ export const ja: Record = {
"integrations.kind.restore": "復元",
"integrations.semantics.opencode": "ディスクから直接起動した場合にのみ適用されます。ocx opencode の環境注入が優先されます。",
"integrations.semantics.pi": "新しいセッションから適用されます。",
+ "integrations.semantics.omp": "カタログを読み込むには OMP を再起動してください。",
"integrations.semantics.hermes": "新しいセッションから適用されます。",
"integrations.semantics.openclaw": "実行中のゲートウェイにすぐ適用されます。",
"integrations.semantics.kimi": "再起動するか /reload を実行すると適用されます(v2 はファイルを監視します)。",
@@ -1497,6 +1499,7 @@ export const ja: Record = {
"api.clientConfig.copiedAnnounceClient": "{client} の設定 JSON をクリップボードにコピーしました。",
"api.clientConfig.clientOpencode": "OpenCode",
"api.clientConfig.clientPi": "Pi",
+ "api.clientConfig.clientOmp": "OMP",
"api.clientConfig.clientHermes": "Hermes",
"api.clientConfig.clientOpenclaw": "OpenClaw",
"api.clientConfig.clientKimi": "Kimi Code",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index 339cdbe970..55b4f48c3b 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -768,6 +768,7 @@ export const ko: Record = {
"integrations.tab.grok": "Grok Build",
"integrations.tab.opencode": "OpenCode",
"integrations.tab.pi": "Pi",
+ "integrations.tab.omp": "OMP",
"integrations.tab.hermes": "Hermes",
"integrations.tab.openclaw": "OpenClaw",
"integrations.tab.kimi": "Kimi Code",
@@ -861,6 +862,7 @@ export const ko: Record = {
"integrations.kind.restore": "복원",
"integrations.semantics.opencode": "디스크에서 직접 실행할 때만 적용됩니다. ocx opencode의 환경 주입이 우선합니다.",
"integrations.semantics.pi": "새 세션부터 적용됩니다.",
+ "integrations.semantics.omp": "카탈로그를 불러오려면 OMP를 재시작하세요.",
"integrations.semantics.hermes": "새 세션부터 적용됩니다.",
"integrations.semantics.openclaw": "실행 중인 게이트웨이에 즉시 반영됩니다.",
"integrations.semantics.kimi": "재시작 또는 /reload 시 적용됩니다 (v2는 파일 변경을 감지합니다).",
@@ -1116,6 +1118,7 @@ export const ko: Record = {
"api.clientConfig.copiedAnnounceClient": "{client} 설정 JSON을 클립보드에 복사했습니다.",
"api.clientConfig.clientOpencode": "OpenCode",
"api.clientConfig.clientPi": "Pi",
+ "api.clientConfig.clientOmp": "OMP",
"api.clientConfig.clientHermes": "Hermes",
"api.clientConfig.clientOpenclaw": "OpenClaw",
"api.clientConfig.clientKimi": "Kimi Code",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 7904e27c3e..5562165d01 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -1189,6 +1189,7 @@ export const ru: Record = {
"integrations.tab.grok": "Grok Build",
"integrations.tab.opencode": "OpenCode",
"integrations.tab.pi": "Pi",
+ "integrations.tab.omp": "OMP",
"integrations.tab.hermes": "Hermes",
"integrations.tab.openclaw": "OpenClaw",
"integrations.tab.kimi": "Kimi Code",
@@ -1282,6 +1283,7 @@ export const ru: Record = {
"integrations.kind.restore": "Восстановлено",
"integrations.semantics.opencode": "Действует только при прямом запуске с диска; внедрение окружения через ocx opencode имеет приоритет.",
"integrations.semantics.pi": "Применяется к новым сеансам.",
+ "integrations.semantics.omp": "Перезапустите OMP, чтобы загрузить каталог.",
"integrations.semantics.hermes": "Применяется к новым сеансам.",
"integrations.semantics.openclaw": "Немедленно применяется к работающему шлюзу.",
"integrations.semantics.kimi": "Чтобы применить, перезапустите клиент или выполните /reload (v2 отслеживает файл).",
@@ -1539,6 +1541,7 @@ export const ru: Record = {
"api.clientConfig.copiedAnnounceClient": "JSON конфигурации {client} скопирован в буфер обмена.",
"api.clientConfig.clientOpencode": "OpenCode",
"api.clientConfig.clientPi": "Pi",
+ "api.clientConfig.clientOmp": "OMP",
"api.clientConfig.clientHermes": "Hermes",
"api.clientConfig.clientOpenclaw": "OpenClaw",
"api.clientConfig.clientKimi": "Kimi Code",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 8b16dbe6c8..e56607ef12 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -761,6 +761,7 @@ export const zh: Record = {
"integrations.tab.grok": "Grok Build",
"integrations.tab.opencode": "OpenCode",
"integrations.tab.pi": "Pi",
+ "integrations.tab.omp": "OMP",
"integrations.tab.hermes": "Hermes",
"integrations.tab.openclaw": "OpenClaw",
"integrations.tab.kimi": "Kimi Code",
@@ -854,6 +855,7 @@ export const zh: Record = {
"integrations.kind.restore": "已恢复",
"integrations.semantics.opencode": "仅适用于直接从磁盘启动;ocx opencode 的环境注入优先。",
"integrations.semantics.pi": "对新会话生效。",
+ "integrations.semantics.omp": "重启 OMP 以加载模型目录。",
"integrations.semantics.hermes": "对新会话生效。",
"integrations.semantics.openclaw": "立即应用到正在运行的网关。",
"integrations.semantics.kimi": "重启或运行 /reload 以应用(v2 会监视该文件)。",
@@ -1109,6 +1111,7 @@ export const zh: Record = {
"api.clientConfig.copiedAnnounceClient": "已将 {client} 配置 JSON 复制到剪贴板。",
"api.clientConfig.clientOpencode": "OpenCode",
"api.clientConfig.clientPi": "Pi",
+ "api.clientConfig.clientOmp": "OMP",
"api.clientConfig.clientHermes": "Hermes",
"api.clientConfig.clientOpenclaw": "OpenClaw",
"api.clientConfig.clientKimi": "Kimi Code",
diff --git a/gui/src/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx
index 772598af26..27584595dd 100644
--- a/gui/src/pages/Integrations.tsx
+++ b/gui/src/pages/Integrations.tsx
@@ -31,6 +31,7 @@ const TABS: readonly TabDefinition[] = [
{ id: "grok", hash: "integrations/grok", labelKey: "integrations.tab.grok" },
{ id: "opencode", hash: "integrations/opencode", labelKey: "integrations.tab.opencode" },
{ id: "pi", hash: "integrations/pi", labelKey: "integrations.tab.pi" },
+ { id: "omp", hash: "integrations/omp", labelKey: "integrations.tab.omp" },
{ id: "hermes", hash: "integrations/hermes", labelKey: "integrations.tab.hermes" },
{ id: "openclaw", hash: "integrations/openclaw", labelKey: "integrations.tab.openclaw" },
{ id: "kimi", hash: "integrations/kimi", labelKey: "integrations.tab.kimi" },
@@ -40,6 +41,7 @@ const TABS: readonly TabDefinition[] = [
const FILE_CLIENTS = new Set([
"opencode",
"pi",
+ "omp",
"hermes",
"openclaw",
"kimi",
diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx
index 32f408d80f..4783a4ef2e 100644
--- a/gui/src/pages/integrations/FileIntegrationPage.tsx
+++ b/gui/src/pages/integrations/FileIntegrationPage.tsx
@@ -19,6 +19,7 @@ export type { FileIntegrationClientId };
const SEMANTICS_KEY: Record = {
opencode: "integrations.semantics.opencode",
pi: "integrations.semantics.pi",
+ omp: "integrations.semantics.omp",
hermes: "integrations.semantics.hermes",
openclaw: "integrations.semantics.openclaw",
kimi: "integrations.semantics.kimi",
@@ -28,6 +29,7 @@ const SEMANTICS_KEY: Record = {
const TAB_LABEL_KEY: Record = {
opencode: "integrations.tab.opencode",
pi: "integrations.tab.pi",
+ omp: "integrations.tab.omp",
hermes: "integrations.tab.hermes",
openclaw: "integrations.tab.openclaw",
kimi: "integrations.tab.kimi",
diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts
index 927f1417ad..c0a483a1fd 100644
--- a/gui/src/pages/integrations/integration-api.ts
+++ b/gui/src/pages/integrations/integration-api.ts
@@ -3,6 +3,7 @@ import { readJsonIfOk } from "../../fetch-json";
export const FILE_INTEGRATION_CLIENTS = [
"opencode",
"pi",
+ "omp",
"hermes",
"openclaw",
"kimi",
diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts
index 6fe89d5ec0..83e7520a66 100644
--- a/gui/src/pages/integrations/overview-clients.ts
+++ b/gui/src/pages/integrations/overview-clients.ts
@@ -2,7 +2,7 @@
* One row model for every client the Integrations page can reach.
*
* The overview used to read a single route, `/api/client-integrations`, which
- * answers only for the six file-toggle clients. A user with Claude Code
+ * answers only for the seven file-toggle clients. A user with Claude Code
* connected and a Grok fence written was told "applied: 0" while three
* integrations were live one tab away. Five more sources join the grid here,
* each with its own payload shape, mapped to the badge vocabulary the file
@@ -127,6 +127,7 @@ export interface OverviewSources {
const FILE_LABEL_KEY: Record = {
opencode: "integrations.tab.opencode",
pi: "integrations.tab.pi",
+ omp: "integrations.tab.omp",
hermes: "integrations.tab.hermes",
openclaw: "integrations.tab.openclaw",
kimi: "integrations.tab.kimi",
diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx
index ce2336c7d6..1dd7287f8d 100644
--- a/gui/tests/client-config-panel.test.tsx
+++ b/gui/tests/client-config-panel.test.tsx
@@ -47,14 +47,14 @@ const PI_ENVELOPE_BASE = {
client: "pi",
filename: "pi-models.json",
destination: "/home/dev/.pi/agent/models.json",
- apiKeyEnv: "OPENCODEX_PI_API_KEY",
- exportHint: "export OPENCODEX_PI_API_KEY=",
+ apiKeyEnv: "",
+ exportHint: "Pi reads credentials from its models.json; loopback needs no key.",
modelCount: 2,
modelsWithoutLimits: 1,
format: "json",
mediaType: "application/json",
// Pi keys its models as an ARRAY — the shape swap is what proves a real refetch.
- config: { providers: { opencodex: { models: [{ id: "gpt-5.4" }, { id: "claude-sonnet-4-6" }] } } },
+ config: { providers: { opencodex: { apiKey: "opencodex-loopback", models: [{ id: "gpt-5.4" }, { id: "claude-sonnet-4-6" }] } } },
};
/**
@@ -474,6 +474,9 @@ test("no-key state is informational and leaves copy and download enabled", async
await act(async () => { rowButton(container, "OpenCode", "Details").click(); });
expect(container.querySelector(".awi-clientconfig-nokey")?.textContent)
.toContain("OPENCODEX_OPENCODE_API_KEY has no key behind it yet");
+ await act(async () => { button(container, "Close").click(); });
+ await act(async () => { rowButton(container, "Pi", "Details").click(); });
+ expect(container.querySelector(".awi-clientconfig-nokey")).toBeNull();
await act(async () => { root.unmount(); });
});
diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts
index 4cd926dc94..24ee3a55aa 100644
--- a/gui/tests/integrations-overview-rows.test.ts
+++ b/gui/tests/integrations-overview-rows.test.ts
@@ -127,7 +127,7 @@ test("file clients keep their existing badge and applied semantics", () => {
expect(counts.stale).toBe(1);
});
-test("every client counts toward the summary, not just the file six", () => {
+test("every client counts toward the summary, not just the file seven", () => {
const rows = buildOverviewRows(sources({
clients: [fileStatus({ clientId: "opencode", state: "current" })],
codex: { routingInjected: true, status: "at-risk" },
@@ -146,7 +146,8 @@ test("every client counts toward the summary, not just the file six", () => {
test("an unsettled file list renders unknown rows instead of dropping them", () => {
const built = buildOverviewRows(sources({ clients: [], clientsSettled: false }));
- expect(built.rows).toHaveLength(10);
+ expect(built.rows).toHaveLength(11);
+ expect(rowById(built, "omp").state).toBe("unknown");
expect(rowById(built, "kimi").state).toBe("unknown");
// Once settled, a client the server omitted is genuinely gone.
@@ -162,4 +163,5 @@ test("each row points at its own tab", () => {
expect(rowById(rows, "claudeDesktop").hash).toBe("integrations/claude/desktop");
expect(rowById(rows, "grok").hash).toBe("integrations/grok");
expect(rowById(rows, "hermes").hash).toBe("integrations/hermes");
+ expect(rowById(rows, "omp").hash).toBe("integrations/omp");
});
diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts
index d7d074fb5f..64e3587a95 100644
--- a/src/cli/export-command.ts
+++ b/src/cli/export-command.ts
@@ -1,8 +1,8 @@
/**
* `ocx export --client ` — print a client config for the live proxy.
*
- * Six clients, four formats: opencode and Pi are JSON, Hermes and Gajae YAML,
- * OpenClaw JSON5, Kimi TOML.
+ * Seven clients, four formats: opencode and Pi are JSON, OMP, Hermes and Gajae
+ * are YAML, OpenClaw JSON5, Kimi TOML.
*
* Two consumers, one payload (devlog 260731_client_config_export/020):
*
diff --git a/src/cli/help.ts b/src/cli/help.ts
index c74ba64752..77d0afb84b 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -171,12 +171,12 @@ const helpEntries: Record = {
},
"api-key": { usage: "ocx api-key ...", summary: "Alias of ocx access key." },
export: {
- usage: "ocx export --client [--json] [--out ] [--force]",
- summary: "Print a client config (opencode, Pi, Hermes, OpenClaw, Kimi Code, Gajae Code) wired to the running proxy.",
+ usage: "ocx export --client [--json] [--out ] [--force]",
+ summary: "Print a client config (opencode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code) wired to the running proxy.",
details: [
- "--json prints only the config JSON on stdout, so it is safe to redirect to a file.",
+ "--json prints only the generated config bytes on stdout, so it is safe to redirect to a file.",
"--out writes the config there and refuses to replace an existing file without --force.",
- "The config never contains a key; it references the client's env var, which you export before launching. Kimi cannot hold an env reference, so it carries a loopback placeholder instead.",
+ "The config never contains a real key; it carries a documented env reference or a non-secret loopback placeholder.",
"The destination path is printed for merging by hand — ocx never writes your real client config.",
],
},
@@ -297,7 +297,7 @@ Usage:
ocx agent Subagents, injection, effort caps, and sidecars
ocx observe Logs, usage, storage, memory, and debug data
ocx access External API keys and endpoint information
- ocx export --client Print a client config wired to the running proxy (6 clients)
+ ocx export --client Print a client config wired to the running proxy (7 clients)
ocx integration client Enable, disable, inspect or roll back a client integration
ocx grok Grok Build model selection and apply
ocx system Runtime settings, startup, sync, and updates
diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts
index 0a4a84ed00..f18bce7a39 100644
--- a/src/clients/config-export.ts
+++ b/src/clients/config-export.ts
@@ -107,15 +107,9 @@ export const OPENCODE_API_KEY_ENV = "OPENCODEX_OPENCODE_API_KEY";
/** Env reference shared by apiKey and the dedicated proxy admission header. */
export const OPENCODE_API_KEY_ENV_REF = `{env:${OPENCODE_API_KEY_ENV}}`;
-/** Env var Pi interpolates. Pi takes bare `$NAME`, not opencode's `{env:NAME}`. */
-export const PI_API_KEY_ENV = "OPENCODEX_API_KEY";
-
-/** Pi's reference form for the admission key. Never the value. */
-export const PI_API_KEY_ENV_REF = `$${PI_API_KEY_ENV}`;
-
/**
* Hermes interpolates `${VAR}` anywhere in config.yaml, so the credential stays
- * in the environment exactly as it does for OpenCode and Pi.
+ * in the environment exactly as it does for OpenCode.
*/
export const HERMES_API_KEY_ENV = "OPENCODEX_HERMES_API_KEY";
export const HERMES_API_KEY_ENV_REF = `\${${HERMES_API_KEY_ENV}}`;
@@ -125,12 +119,17 @@ export const OPENCLAW_API_KEY_ENV = "OPENCODEX_OPENCLAW_API_KEY";
export const OPENCLAW_API_KEY_ENV_REF = `\${${OPENCLAW_API_KEY_ENV}}`;
/**
- * Kimi Code reads credentials ONLY from its config file — it never falls back
- * to the shell environment. A loopback bind needs no real admission key, so we
- * emit the same placeholder the Grok managed block uses rather than a user
- * secret; a non-loopback bind is refused by the writer instead of papered over.
+ * Placeholder credential for loopback-only clients (Kimi, Pi). A loopback
+ * bind requires no real admission key, so we emit the same placeholder the
+ * Grok managed block uses rather than a user secret; a non-loopback bind is
+ * refused by the writer instead of papered over.
+ *
+ * For Pi this is load-bearing, not cosmetic: Pi resolves `apiKey` while
+ * building its model list and hides the WHOLE provider when the value is an
+ * env reference that is not set, so `$OPENCODEX_API_KEY` left every routed
+ * model invisible until the user exported a variable nothing ever checked.
*/
-export const KIMI_LOOPBACK_PLACEHOLDER = "opencodex-loopback";
+export const LOOPBACK_API_KEY_PLACEHOLDER = "opencodex-loopback";
/**
* Gajae's `apiKeyEnv` is env-name-only and fail-closed. Its sibling `apiKey`
@@ -176,6 +175,45 @@ export function opencodeGlobalConfigPath(
return join(xdg, "opencode", "opencode.json");
}
+const OMP_PROFILE_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
+const OMP_WINDOWS_RESERVED_PROFILE_RE = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i;
+
+function ompProfileName(env: OpencodeLaunchEnv): string | undefined {
+ const raw = env.OMP_PROFILE !== undefined ? env.OMP_PROFILE : env.PI_PROFILE;
+ const profile = raw?.trim();
+ if (!profile || profile === "default") return undefined;
+ if (
+ profile === "."
+ || profile === ".."
+ || profile.endsWith(".")
+ || !OMP_PROFILE_NAME_RE.test(profile)
+ || OMP_WINDOWS_RESERVED_PROFILE_RE.test(profile)
+ ) {
+ throw new ClientPathError(`Invalid OMP profile "${raw}"`);
+ }
+ return profile;
+}
+
+/** Resolve the global Oh My Pi agent directory using OMP's own env precedence. */
+export function ompAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
+ const profile = ompProfileName(env);
+ if (!profile) {
+ const override = env.PI_CODING_AGENT_DIR?.trim();
+ if (override) return absoluteClientPath(override, home, "PI_CODING_AGENT_DIR");
+ }
+ const configDir = env.PI_CONFIG_DIR?.trim() || ".omp";
+ const root = join(home, configDir);
+ return profile ? join(root, "profiles", profile, "agent") : join(root, "agent");
+}
+
+/** OMP's canonical custom-provider catalog. */
+export function ompModelsConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
+ const agentDir = ompAgentDir(env, home);
+ const yamlFallback = join(agentDir, "models.yaml");
+ const canonical = join(agentDir, "models.yml");
+ return !existsSync(canonical) && existsSync(yamlFallback) ? yamlFallback : canonical;
+}
+
/** Compose the OpenAI-compatible proxy base URL from a live probe result. */
export function opencodeProxyBaseUrl(port: number, hostname?: string): string {
return `http://${probeHostname(hostname)}:${port}/v1`;
@@ -370,6 +408,7 @@ export interface ExportContext {
export type ExportClientId =
| "opencode"
| "pi"
+ | "omp"
| "hermes"
| "openclaw"
| "kimi"
@@ -728,7 +767,7 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig {
[OPENCODE_PROVIDER_ID]: {
baseUrl: ctx.baseUrl,
api: PI_API_DIALECT,
- apiKey: PI_API_KEY_ENV_REF,
+ apiKey: LOOPBACK_API_KEY_PLACEHOLDER,
models,
},
},
@@ -808,7 +847,7 @@ function buildKimiClientConfig(ctx: ExportContext): KimiGeneratedConfig {
[OPENCODE_PROVIDER_ID]: {
type: "openai",
base_url: ctx.baseUrl,
- api_key: KIMI_LOOPBACK_PLACEHOLDER,
+ api_key: LOOPBACK_API_KEY_PLACEHOLDER,
},
},
models,
@@ -900,6 +939,11 @@ function buildPiContribution(ctx: ExportContext): ManagedContribution {
return singleFragment("pi", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
}
+function buildOmpContribution(ctx: ExportContext): ManagedContribution {
+ const doc = buildPiClientConfig(ctx);
+ return singleFragment("omp", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
+}
+
function buildHermesContribution(ctx: ExportContext): ManagedContribution {
const doc = buildHermesClientConfig(ctx);
return singleFragment("hermes", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
@@ -949,15 +993,29 @@ export const EXPORT_CLIENTS: Record = {
id: "pi",
filename: "pi-models.json",
destination: () => join(homedir(), ".pi", "agent", "models.json"),
- apiKeyEnv: PI_API_KEY_ENV,
- exportHint: `export ${PI_API_KEY_ENV}=`,
+ // Pi carries the placeholder literal in models.json (see
+ // LOOPBACK_API_KEY_PLACEHOLDER), so there is no env var to export.
+ apiKeyEnv: "",
+ exportHint: "Pi reads credentials from its models.json; loopback needs no key.",
build: buildPiClientConfig,
format: "json",
summarize: summarizePi,
buildContribution: buildPiContribution,
- // No header field in Pi's provider block (and the schema is unverified
- // against a real install), so there is nowhere to put the dedicated
- // admission header a remote bind requires.
+ // No header field in Pi's provider block, so there is nowhere to put the
+ // dedicated admission header a remote bind requires.
+ loopbackOnly: true,
+ },
+ omp: {
+ id: "omp",
+ filename: "omp-models.yaml",
+ destination: env => ompModelsConfigPath(env),
+ apiKeyEnv: "",
+ exportHint: "OMP reads credentials from models.yml; loopback needs no key.",
+ build: buildPiClientConfig,
+ format: "yaml",
+ summarize: summarizePi,
+ buildContribution: buildOmpContribution,
+ // OMP's provider block has no dedicated admission-header field.
loopbackOnly: true,
},
hermes: {
diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts
index b4c086bdd0..889909a5ad 100644
--- a/src/integrations/registry.ts
+++ b/src/integrations/registry.ts
@@ -18,6 +18,8 @@ import {
hermesHomeDir,
kimiConfigPath,
kimiHomeDir,
+ ompAgentDir,
+ ompModelsConfigPath,
opencodeGlobalConfigPath,
openclawConfigPath,
openclawHomeDir,
@@ -68,6 +70,11 @@ export const INTEGRATION_CLIENTS: Record join(home, ".pi", "agent", "models.json"),
detectDir: (_env = process.env, home = homedir()) => join(home, ".pi"),
},
+ omp: {
+ id: "omp",
+ configPath: (env = process.env, home = homedir()) => ompModelsConfigPath(env, home),
+ detectDir: (env = process.env, home = homedir()) => ompAgentDir(env, home),
+ },
hermes: {
id: "hermes",
configPath: (env = process.env, home = homedir()) => hermesConfigPath(env, home),
diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts
index 71c200574b..c660994e17 100644
--- a/src/server/management/model-rows.ts
+++ b/src/server/management/model-rows.ts
@@ -13,6 +13,7 @@ import { catalogModelSlug, nativeModelRows, uniqueCatalogModelsForPublicList } f
import type { ExportModel } from "../../clients/config-export";
import { providerContextCap } from "../../providers/context-cap";
import { routedSlug, slugEquals } from "../../providers/slug-codec";
+import { providerCodexAccountMode } from "../../providers/registry";
import type { OcxConfig } from "../../types";
import { fetchAllModels } from "./shared";
@@ -102,16 +103,20 @@ export function toExportModel(row: ManagementModelRow): ExportModel {
}
/**
- * Visible (non-disabled) rows as export models — the ONE loader both
- * `/api/client-config` and the integration routes use, so the two can never
- * disagree about which models a client is told about.
+ * Visible rows as export models — the ONE loader both `/api/client-config`
+ * and the integration routes use, so the two can never disagree about which
+ * models a client is told about.
*
- * The visibility filter lives HERE rather than at each call site: the export
- * core serializes what it is given, so a model the user disabled in the Models
- * tab is absent from `/v1/models` and exporting it would hand the client a
- * selector the proxy refuses to route.
+ * Disabled rows are absent because the proxy refuses to route them. Native
+ * rows are also absent in Codex Direct mode: those routes require the caller's
+ * real ChatGPT bearer, while file integrations authenticate their provider
+ * with a generated loopback placeholder or admission credential. Advertising
+ * native rows there would forward the wrong Authorization value upstream.
*/
export async function loadExportModels(config: OcxConfig): Promise {
const rows = await listManagementModelRows(config);
- return rows.filter(row => !row.disabled).map(toExportModel);
+ const omitNative = providerCodexAccountMode("openai", config.providers?.openai) === "direct";
+ return rows
+ .filter(row => !row.disabled && !(omitNative && row.native === true))
+ .map(toExportModel);
}
diff --git a/tests/cli-export-command.test.ts b/tests/cli-export-command.test.ts
index 108dbcaa3d..1e0959e6db 100644
--- a/tests/cli-export-command.test.ts
+++ b/tests/cli-export-command.test.ts
@@ -139,11 +139,11 @@ describe("ocx export human output (accept criterion 2)", () => {
expect(result.stdout).toContain("3 models; 1 omit context limits");
});
- test("Pi names its own destination and env var", async () => {
+ test("Pi names its own destination and needs no env var", async () => {
const proxy = fakeProxy();
const result = await run(["--client", "pi"], { baseUrl: proxy.baseUrl });
expect(result.stdout).toContain(join(".pi", "agent", "models.json"));
- expect(result.stdout).toContain("export OPENCODEX_API_KEY=");
+ expect(result.stdout).toContain("loopback needs no key");
});
});
@@ -202,7 +202,7 @@ describe("ocx export argument validation (accept criterion 4)", () => {
const proxy = fakeProxy();
const result = await run(["--client", "cursor"], { baseUrl: proxy.baseUrl });
expect(result.code).toBe(2);
- for (const id of ["opencode", "pi", "hermes", "openclaw", "kimi", "gajae"]) {
+ for (const id of ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae"]) {
expect(result.stderr).toContain(id);
}
expect(result.stdout).toBe("");
@@ -297,8 +297,8 @@ describe("ocx export never serializes a key (accept criterion 6)", () => {
for (const [args, envRef] of [
[["--client", "opencode"], "{env:OPENCODEX_OPENCODE_API_KEY}"],
[["--client", "opencode", "--json"], "{env:OPENCODEX_OPENCODE_API_KEY}"],
- [["--client", "pi"], "$OPENCODEX_API_KEY"],
- [["--client", "pi", "--json"], "$OPENCODEX_API_KEY"],
+ [["--client", "pi"], "opencodex-loopback"],
+ [["--client", "pi", "--json"], "opencodex-loopback"],
] as Array<[string[], string]>) {
logs = [];
errors = [];
diff --git a/tests/client-config-export-new-clients.test.ts b/tests/client-config-export-new-clients.test.ts
index 9de47024f6..d509338050 100644
--- a/tests/client-config-export-new-clients.test.ts
+++ b/tests/client-config-export-new-clients.test.ts
@@ -5,7 +5,7 @@ import {
EXPORT_CLIENT_IDS,
GAJAE_API_KEY_ENV,
HERMES_API_KEY_ENV_REF,
- KIMI_LOOPBACK_PLACEHOLDER,
+ LOOPBACK_API_KEY_PLACEHOLDER,
OPENCLAW_API_KEY_ENV_REF,
OPENCODE_PROVIDER_ID,
buildClientConfig,
@@ -63,7 +63,7 @@ describe("no secret reaches a client config", () => {
// client whose schema has no header field cannot authenticate remotely at
// all. Saying so beats exporting a config that 401s.
const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly);
- expect(loopbackOnly).toEqual(["pi", "kimi", "gajae"]);
+ expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae"]);
});
test("every client that is not loopback-only carries the header on a remote bind", () => {
@@ -235,7 +235,7 @@ describe("kimi", () => {
test("uses the loopback placeholder because Kimi reads no environment", () => {
const doc = buildClientConfig("kimi", ctx()) as KimiGeneratedConfig;
- expect(doc.providers[OPENCODE_PROVIDER_ID]!.api_key).toBe(KIMI_LOOPBACK_PLACEHOLDER);
+ expect(doc.providers[OPENCODE_PROVIDER_ID]!.api_key).toBe(LOOPBACK_API_KEY_PLACEHOLDER);
});
test("never emits capabilities it cannot assert", () => {
@@ -268,7 +268,7 @@ describe("gajae", () => {
describe("contributions name every fragment we own", () => {
test("single-entry clients own exactly one path", () => {
- for (const id of ["opencode", "pi", "hermes", "openclaw", "gajae"] as const) {
+ for (const id of ["opencode", "pi", "omp", "hermes", "openclaw", "gajae"] as const) {
expect(buildClientContribution(id, ctx()).fragments).toHaveLength(1);
}
});
diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts
index 8ab7faeab5..a9fba35009 100644
--- a/tests/client-config-export.test.ts
+++ b/tests/client-config-export.test.ts
@@ -6,13 +6,13 @@ import {
EXPORT_CLIENT_IDS,
OPENCODE_API_KEY_ENV,
OPENCODE_API_KEY_ENV_REF,
- PI_API_KEY_ENV,
- PI_API_KEY_ENV_REF,
+ LOOPBACK_API_KEY_PLACEHOLDER,
SCHEMA_REQUIRED_OUTPUT_BUDGET,
buildClientConfig,
buildClientConfigText,
isExportClientId,
normalizeExportModels,
+ ompModelsConfigPath,
type ExportContext,
type ExportModel,
type OpencodeGeneratedConfig,
@@ -74,6 +74,7 @@ function piConfig(context: ExportContext = ctx()): PiGeneratedConfig {
return buildClientConfig("pi", context) as PiGeneratedConfig;
}
+
describe("relocated OpenCode serializer (accept criterion 1)", () => {
test("the moved builder reproduces the pre-refactor golden byte-for-byte", () => {
const block = buildOpencodeProviderBlockFromCatalog(10100, FIXTURE, "127.0.0.1");
@@ -160,12 +161,13 @@ describe("Pi serializer (accept criterion 2)", () => {
]);
});
- test("provider envelope names the OpenAI-compatible dialect and the env reference", () => {
+ test("provider envelope names the OpenAI-compatible dialect and the loopback placeholder", () => {
const provider = piConfig().providers.opencodex!;
expect(provider.baseUrl).toBe(BASE_URL);
expect(provider.api).toBe("openai-completions");
- expect(provider.apiKey).toBe(PI_API_KEY_ENV_REF);
- expect(provider.apiKey).toBe("$OPENCODEX_API_KEY");
+ // Pi hides the whole provider when apiKey is an unset env reference, so
+ // the loopback-only client carries the placeholder literal instead.
+ expect(provider.apiKey).toBe(LOOPBACK_API_KEY_PLACEHOLDER);
});
test("cost is omitted on every entry — zeros would assert routed models are free", () => {
@@ -221,6 +223,23 @@ describe("Pi serializer (accept criterion 2)", () => {
});
});
+describe("OMP serializer", () => {
+ test("writes the full routed catalog in OMP's models.yml provider shape", () => {
+ const built = buildClientConfigText("omp", ctx());
+ expect(built.format).toBe("yaml");
+ const document = buildClientConfig("omp", ctx()) as PiGeneratedConfig;
+ expect(document.providers.opencodex!.models.map(model => model.id)).toEqual([
+ "anthropic/claude-opus-5",
+ "custom/no-context",
+ "gpt-5.6-luna",
+ "tiny/small-ctx",
+ ]);
+ expect(built.text).toContain("providers:");
+ expect(built.text).toContain("anthropic/claude-opus-5");
+ expect(built.text).toContain("apiKey: opencodex-loopback");
+ });
+});
+
describe("no credential ever reaches the output (accept criterion 3)", () => {
const LIVE_KEY = "ocx_live_do_not_serialize_0123456789";
@@ -236,7 +255,7 @@ describe("no credential ever reaches the output (accept criterion 3)", () => {
test("each client emits only its own documented env reference", () => {
expect(JSON.stringify(opencodeConfig())).toContain(OPENCODE_API_KEY_ENV_REF);
- expect(JSON.stringify(piConfig())).toContain(PI_API_KEY_ENV_REF);
+ expect(JSON.stringify(piConfig())).toContain(LOOPBACK_API_KEY_PLACEHOLDER);
expect(JSON.stringify(piConfig())).not.toContain("{env:");
});
});
@@ -282,8 +301,8 @@ describe("stable ordering (accept criterion 4)", () => {
});
describe("EXPORT_CLIENTS registry", () => {
- test("covers exactly the six file-toggle clients", () => {
- expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "hermes", "openclaw", "kimi", "gajae"]);
+ test("covers exactly the seven file-toggle clients", () => {
+ expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae"]);
for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true);
// The exception clients keep their own surfaces and are not export clients.
expect(isExportClientId("claude-desktop")).toBe(false);
@@ -348,7 +367,7 @@ describe("EXPORT_CLIENTS registry", () => {
"opencodex": {
"baseUrl": "http://127.0.0.1:10100/v1",
"api": "openai-completions",
- "apiKey": "$OPENCODEX_API_KEY",
+ "apiKey": "opencodex-loopback",
"models": [
{
"id": "anthropic/claude-opus-5",
@@ -428,6 +447,7 @@ describe("EXPORT_CLIENTS registry", () => {
test("filenames name the destination file, not the product", () => {
expect(EXPORT_CLIENTS.opencode.filename).toBe("opencode.json");
expect(EXPORT_CLIENTS.pi.filename).toBe("pi-models.json");
+ expect(EXPORT_CLIENTS.omp.filename).toBe("omp-models.yaml");
expect(EXPORT_CLIENTS.hermes.filename).toBe("hermes-config.yaml");
expect(EXPORT_CLIENTS.openclaw.filename).toBe("openclaw.json5");
expect(EXPORT_CLIENTS.kimi.filename).toBe("kimi-config.toml");
@@ -446,11 +466,23 @@ describe("EXPORT_CLIENTS registry", () => {
expect(EXPORT_CLIENTS.pi.destination({} as NodeJS.ProcessEnv)).toBe(join(homedir(), ".pi", "agent", "models.json"));
});
+ test("the OMP destination follows its global agent directory and profile selectors", () => {
+ expect(EXPORT_CLIENTS.omp.destination({} as NodeJS.ProcessEnv)).toBe(
+ join(homedir(), ".omp", "agent", "models.yml"),
+ );
+ const profiled = { OMP_PROFILE: "work", PI_CONFIG_DIR: ".custom-omp" } as NodeJS.ProcessEnv;
+ expect(EXPORT_CLIENTS.omp.destination(profiled)).toBe(
+ join(homedir(), ".custom-omp", "profiles", "work", "agent", "models.yml"),
+ );
+ expect(EXPORT_CLIENTS.omp.destination(profiled)).toBe(ompModelsConfigPath(profiled));
+ });
+
test("apiKeyEnv and exportHint name the variable the config references", () => {
expect(EXPORT_CLIENTS.opencode.apiKeyEnv).toBe(OPENCODE_API_KEY_ENV);
expect(EXPORT_CLIENTS.opencode.exportHint).toContain(OPENCODE_API_KEY_ENV);
- expect(EXPORT_CLIENTS.pi.apiKeyEnv).toBe(PI_API_KEY_ENV);
- expect(EXPORT_CLIENTS.pi.exportHint).toContain(PI_API_KEY_ENV);
+ // Pi reads the placeholder literal from its own file — no env var exists.
+ expect(EXPORT_CLIENTS.pi.apiKeyEnv).toBe("");
+ expect(EXPORT_CLIENTS.pi.exportHint).toContain("loopback");
for (const id of EXPORT_CLIENT_IDS) {
expect(EXPORT_CLIENTS[id].exportHint).not.toContain("ocx_");
}
diff --git a/tests/client-config-new-clients.test.ts b/tests/client-config-new-clients.test.ts
index 51fd6de1f4..eaf500ca54 100644
--- a/tests/client-config-new-clients.test.ts
+++ b/tests/client-config-new-clients.test.ts
@@ -4,7 +4,7 @@ import {
EXPORT_CLIENT_IDS,
GAJAE_API_KEY_ENV,
HERMES_API_KEY_ENV_REF,
- KIMI_LOOPBACK_PLACEHOLDER,
+ LOOPBACK_API_KEY_PLACEHOLDER,
OPENCLAW_API_KEY_ENV_REF,
OPENCODE_PROVIDER_ID,
buildClientConfig,
@@ -56,7 +56,7 @@ describe("no client config ever carries a credential", () => {
test("kimi uses the loopback placeholder because it cannot read env vars", () => {
const doc = buildClientConfig("kimi", ctx()) as KimiGeneratedConfig;
- expect(doc.providers[OPENCODE_PROVIDER_ID]!.api_key).toBe(KIMI_LOOPBACK_PLACEHOLDER);
+ expect(doc.providers[OPENCODE_PROVIDER_ID]!.api_key).toBe(LOOPBACK_API_KEY_PLACEHOLDER);
});
test("gajae uses apiKeyEnv, not the apiKey footgun", () => {
diff --git a/tests/integrations-invariants.test.ts b/tests/integrations-invariants.test.ts
index 4178b908bd..f7524214aa 100644
--- a/tests/integrations-invariants.test.ts
+++ b/tests/integrations-invariants.test.ts
@@ -66,9 +66,9 @@ afterEach(() => {
});
describe("the client registries cannot drift apart", () => {
- test("every list of clients holds exactly the same six ids", async () => {
+ test("every list of clients holds exactly the same seven ids", async () => {
/*
- * Five lists name the same six clients, and two of them are maintained by
+ * Five lists name the same seven clients, and two of them are maintained by
* hand: the GUI cannot import the backend registry, because that would
* pull node:os and node:path into the browser bundle. A client added
* server-side renders no row until someone remembers the tuple, and the
@@ -78,7 +78,7 @@ describe("the client registries cannot drift apart", () => {
const guiIntegrations = await import("../gui/src/pages/integrations/integration-api");
const expected = [...EXPORT_CLIENT_IDS].sort();
- expect(expected).toHaveLength(6);
+ expect(expected).toHaveLength(7);
expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected);
expect([...gui.CLIENTS].sort()).toEqual(expected);
@@ -125,6 +125,7 @@ describe("every client survives a full lifecycle", () => {
const SEED: Record = {
opencode: '{\n "provider": {\n "mine": { "npm": "keep-me" }\n }\n}\n',
pi: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n',
+ omp: "providers:\n mine:\n api: http://keep-me\n",
hermes: "providers:\n mine:\n api: http://keep-me\n",
openclaw: '{\n models: {\n providers: {\n mine: { api: "http://keep-me" },\n },\n },\n}\n',
kimi: '[providers.mine]\napi = "http://keep-me"\n',
@@ -675,7 +676,7 @@ describe("the store's own root stays tidy", () => {
describe("the CLI names every client it supports", () => {
test("export help and the top-level list are not stuck on opencode and Pi", () => {
/*
- * The command has accepted six clients since WP1, but its help said two.
+ * The command has accepted several clients since WP1, but its help said two.
* A user reading it concluded the feature did not support their client —
* the one failure mode a help string has.
*
diff --git a/tests/integrations-state.test.ts b/tests/integrations-state.test.ts
index 8ef851018f..ef45405827 100644
--- a/tests/integrations-state.test.ts
+++ b/tests/integrations-state.test.ts
@@ -381,9 +381,9 @@ describe("installation detection is independent of config state", () => {
* from. Rationale and the per-client table: 020 §1 amendment.
*/
describe("the loopback-only set is one fact, read through one seam", () => {
- test("pi, kimi and gajae are loopback-only and nobody else is", () => {
+ test("omp, pi, kimi and gajae are loopback-only and nobody else is", () => {
const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id));
- expect(loopbackOnly).toEqual(["pi", "kimi", "gajae"]);
+ expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae"]);
});
test("the registry restates nothing — it reads the export spec", () => {
diff --git a/tests/management-client-config-route.test.ts b/tests/management-client-config-route.test.ts
index c5fa46912b..160bb7a7ad 100644
--- a/tests/management-client-config-route.test.ts
+++ b/tests/management-client-config-route.test.ts
@@ -4,8 +4,7 @@ import {
OPENCODE_API_KEY_ENV,
OPENCODE_CONFIG_SCHEMA,
OPENCODE_PROVIDER_ID,
- PI_API_KEY_ENV,
- PI_API_KEY_ENV_REF,
+ LOOPBACK_API_KEY_PLACEHOLDER,
buildClientConfig,
normalizeExportModels,
opencodeGlobalConfigPath,
@@ -13,6 +12,7 @@ import {
type OpencodeGeneratedConfig,
type PiGeneratedConfig,
} from "../src/clients/config-export";
+import { nativeModelRows } from "../src/codex/catalog";
import type { OcxConfig } from "../src/types";
import { catalogConvergenceFactory } from "./helpers/catalog-convergence";
@@ -30,6 +30,8 @@ interface ClientConfigEnvelope {
exportHint: string;
modelCount: number;
modelsWithoutLimits: number;
+ format: string;
+ text: string;
config: unknown;
}
@@ -149,15 +151,55 @@ describe("GET /api/client-config", () => {
expect(body.client).toBe("pi");
expect(body.filename).toBe("pi-models.json");
- expect(body.apiKeyEnv).toBe(PI_API_KEY_ENV);
+ expect(body.apiKeyEnv).toBe("");
const provider = (body.config as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID];
expect(Array.isArray(provider.models)).toBe(true);
- expect(provider.apiKey).toBe(PI_API_KEY_ENV_REF);
+ expect(provider.apiKey).toBe(LOOPBACK_API_KEY_PLACEHOLDER);
expect(provider.baseUrl).toBe("http://127.0.0.1:10100/v1");
expect(provider.models.map(model => model.id)).toContain("a/m1");
}, 15_000);
+ test("OMP returns the full routed catalog as models.yml YAML", async () => {
+ const response = await clientConfigApi(baseConfig(), "?client=omp");
+ expect(response.status).toBe(200);
+ const body = await response.json() as ClientConfigEnvelope;
+
+ expect(body.client).toBe("omp");
+ expect(body.filename).toBe("omp-models.yaml");
+ expect(body.format).toBe("yaml");
+ expect(body.text).toContain("providers:");
+ expect(body.text).toContain("a/m1");
+ const provider = (body.config as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID];
+ expect(provider.models.map(model => model.id)).toContain("a/m1");
+ expect(provider.apiKey).toBe(LOOPBACK_API_KEY_PLACEHOLDER);
+ }, 15_000);
+
+ test("pi omits native rows in Codex Direct mode", async () => {
+ const poolConfig = baseConfig();
+ const nativeIds = new Set(nativeModelRows(poolConfig).map(row => row.slug));
+ const pool = await (await clientConfigApi(poolConfig, "?client=pi")).json() as ClientConfigEnvelope;
+ const poolModels = (pool.config as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID].models;
+ expect(poolModels.some(model => nativeIds.has(model.id))).toBe(true);
+
+ const directConfig = baseConfig({
+ providers: {
+ ...poolConfig.providers,
+ openai: {
+ adapter: "openai-responses",
+ baseUrl: "https://chatgpt.com/backend-api/codex",
+ authMode: "forward",
+ codexAccountMode: "direct",
+ disabled: true,
+ },
+ },
+ });
+ const direct = await (await clientConfigApi(directConfig, "?client=pi")).json() as ClientConfigEnvelope;
+ const directModels = (direct.config as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID].models;
+ expect(directModels.some(model => nativeIds.has(model.id))).toBe(false);
+ expect(directModels.map(model => model.id)).toContain("a/m1");
+ }, 15_000);
+
test("counts describe the emitted document, including models without limits", async () => {
const config = baseConfig();
const opencode = await (await clientConfigApi(config, "?client=opencode")).json() as ClientConfigEnvelope;