From a8d580b1c7ffefcd37c1bbc9f9e6fb5a0b5a68c6 Mon Sep 17 00:00:00 2001
From: n3wr1ch <40690535+n3wr1ch@users.noreply.github.com>
Date: Thu, 6 Aug 2026 08:10:33 +0900
Subject: [PATCH 01/16] fix(pi): keep loopback models visible
---
.../src/content/docs/guides/integrations.md | 2 +-
docs-site/src/content/docs/guides/pi.md | 42 ++++++-----------
docs-site/src/content/docs/ja/guides/pi.md | 27 ++++-------
.../content/docs/ja/reference/cli/agents.md | 6 +--
docs-site/src/content/docs/ko/guides/pi.md | 45 ++++++++----------
.../content/docs/ko/reference/cli/agents.md | 8 ++--
.../src/content/docs/reference/cli/agents.md | 19 ++++----
docs-site/src/content/docs/ru/guides/pi.md | 46 +++++++------------
.../content/docs/ru/reference/cli/agents.md | 8 ++--
docs-site/src/content/docs/zh-cn/guides/pi.md | 27 ++++-------
.../docs/zh-cn/reference/cli/agents.md | 6 +--
.../apikeys-workspace/ClientConfigDialog.tsx | 6 ++-
gui/tests/client-config-panel.test.tsx | 9 ++--
src/clients/config-export.ts | 38 +++++++--------
tests/cli-export-command.test.ts | 8 ++--
.../client-config-export-new-clients.test.ts | 4 +-
tests/client-config-export.test.ts | 19 ++++----
tests/client-config-new-clients.test.ts | 4 +-
tests/management-client-config-route.test.ts | 7 ++-
19 files changed, 143 insertions(+), 188 deletions(-)
diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md
index c917fd273a..f69063acf5 100644
--- a/docs-site/src/content/docs/guides/integrations.md
+++ b/docs-site/src/content/docs/guides/integrations.md
@@ -9,7 +9,7 @@ file, and removes it again. Six 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 |
| 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 |
diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md
index fa44d2754f..7b96a21ca7 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",
@@ -70,27 +69,16 @@ provider or changing model visibility, and merge the new block over the old one.
## The admission key
-Two different keys are easy to confuse here, and only the first one appears in this file:
-
-| 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/) |
-
-The exported config carries only the reference, never a secret. Pi interpolates a bare `$NAME`, so
-the variable is:
-
-```bash
-export OPENCODEX_API_KEY=
-```
+**A loopback proxy needs no key at all.** opencodex binds `127.0.0.1` by default and authenticates
+nothing there, so the exported block carries the literal placeholder `opencodex-loopback` rather
+than a real credential — no environment variable is involved.
-That name is Pi's alone. opencode uses a different variable
-(`OPENCODEX_OPENCODE_API_KEY`, in `{env:…}` form) — see the [opencode guide](/guides/opencode/).
+The placeholder 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. A literal keeps
+every routed model visible, and the proxy never checks it on loopback.
-**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 a different matter — 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 +97,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..865c981796 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",
@@ -60,22 +60,11 @@ ocx export --client pi --json > ~/opencodex-pi-models.json # or redirect the b
## アドミッションキー
-ここでは 2 つの異なるキーが混同されやすいため、このファイルには最初のキーのみが表示されます。
+**ループバック プロキシにはキーはまったく必要ありません。** opencodex はデフォルトで `127.0.0.1` をバインドし、そこでは何も認証しないため、エクスポートされたブロックには実際の認証情報ではなくリテラルのプレースホルダー `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..7fec7f33da 100644
--- a/docs-site/src/content/docs/ja/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ja/reference/cli/agents.md
@@ -149,15 +149,15 @@ ocx export --client opencode --out ~/opencodex-opencode.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` が入ります |
-2 つの環境変数名は異なり、各クライアントは独自の名前のみを補間します。 opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を読み取ります。 Pi は `$OPENCODEX_API_KEY` を読み取ります。
+opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。Pi は環境変数をまったく使いません。Pi はモデル リストを構築する際に `apiKey` を解決し、その値が未設定の環境変数参照である場合はプロバイダー全体を隠すため、エクスポートされたブロックにはリテラルのプレースホルダー `opencodex-loopback` が入ります。ループバックではプロキシがこの値を検査することはありません。
:::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/ko/guides/pi.md b/docs-site/src/content/docs/ko/guides/pi.md
index 648d71060e..7fa8222640 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",
@@ -70,28 +70,19 @@ ocx export --client pi --json > ~/opencodex-pi-models.json # or redirect the b
## 인증 키
-여기서는 서로 헷갈리기 쉬운 키가 두 개 있고, 이 파일에 등장하는 것은 첫 번째뿐입니다.
-
-| 키 | 무엇인지 | 어디에 있는지 |
-| --- | --- | --- |
-| Proxy admission key | opencodex의 자체 인증 정보이며, 대시보드의 **API** 탭에서 생성됩니다 | `apiKey`로 `$OPENCODEX_API_KEY`를 참조하며, 값은 환경 변수에 둡니다 |
-| Provider key | Anthropic / OpenAI / OpenRouter 키입니다 | opencodex의 자체 config에 있으며, [Providers](/guides/providers/)마다 따로 둡니다 |
-
-내보낸 config에는 비밀값이 아니라 참조만 들어갑니다. Pi는 `$NAME` 형태를 그대로
-치환하므로 변수는 다음과 같습니다.
-
-```bash
-export OPENCODEX_API_KEY=
-```
+**루프백 프록시는 키가 전혀 필요 없습니다.** opencodex는 기본적으로 `127.0.0.1`에
+바인드하고 그곳에서는 아무 것도 인증하지 않으므로, 내보낸 블록에는 실제 인증 정보가
+아니라 리터럴 placeholder인 `opencodex-loopback`이 들어갑니다. 환경 변수는 전혀
+관여하지 않습니다.
-이 이름은 Pi 전용입니다. opencode는 다른 변수를 씁니다
-(`OPENCODEX_OPENCODE_API_KEY`, `{env:…}` 형식) - 자세한 내용은 [opencode 가이드](/guides/opencode/)를 보세요.
+이 placeholder는 겉치레가 아니라 필수입니다. Pi는 모델 목록을 만들 때 `apiKey`를
+해석하는데, 값이 설정되지 않은 환경 변수 참조이면 provider 전체를 숨겨 버립니다.
+리터럴이어야 라우팅된 모든 모델이 보이며, 루프백에서 프록시는 이 값을 검사하지
+않습니다.
-**루프백 프록시는 키가 전혀 필요 없습니다.** 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 +101,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..0b1cc0c434 100644
--- a/docs-site/src/content/docs/ko/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ko/reference/cli/agents.md
@@ -150,20 +150,20 @@ ocx export --client pi --json > pi-models.json # byte-exact JSON for a pipe or
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`이 들어갑니다 |
-두 환경 변수 이름은 서로 다르며, 각 client는 자기 것만 보간합니다. opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 읽고, Pi는 `$OPENCODEX_API_KEY`를 읽습니다.
+opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. Pi는 환경 변수를 전혀 쓰지 않습니다. Pi는 모델 목록을 만들 때 `apiKey`를 해석하는데, 그 값이 설정되지 않은 env 참조이면 provider 전체를 숨겨 버리므로, 내보낸 블록에는 리터럴 placeholder인 `opencodex-loopback`이 들어갑니다. 루프백에서 proxy는 이 값을 검사하지 않습니다.
:::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/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md
index 4944a483d2..d2dfc236c9 100644
--- a/docs-site/src/content/docs/reference/cli/agents.md
+++ b/docs-site/src/content/docs/reference/cli/agents.md
@@ -170,16 +170,18 @@ 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).
+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` |
+| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | none — the block carries the literal `opencodex-loopback` |
-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`.
+opencode interpolates `{env:OPENCODEX_OPENCODE_API_KEY}`. Pi does not use a variable at all: it
+resolves `apiKey` while building its model list and hides the whole provider when the value is an
+unset env reference, so the exported block carries the literal placeholder `opencodex-loopback`,
+which the proxy never checks 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 +189,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. An opencode config carries only the env reference, so the secret stays
+in your environment, and a Pi config carries a placeholder rather than any credential. A loopback
+proxy (`127.0.0.1`, the default) requires no admission key at all. Set the opencode variable only
+when 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/ru/guides/pi.md b/docs-site/src/content/docs/ru/guides/pi.md
index 0960ecf49a..2615064231 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",
@@ -71,28 +71,16 @@ ocx export --client pi --json > ~/opencodex-pi-models.json # or redirect the b
## Admission key
-Здесь легко перепутать два разных ключа, и в этом файле появляется только первый:
-
-| Ключ | Что это | Где хранится |
-| --- | --- | --- |
-| Ключ допуска прокси | собственная учётная запись opencodex, генерируемая на вкладке **API** в дашборде | указывается в `apiKey` как `$OPENCODEX_API_KEY`; само значение остаётся в окружении |
-| Ключ провайдера | ваш ключ Anthropic / OpenAI / OpenRouter | хранится в конфигурации самого opencodex, см. [Провайдеры](/guides/providers/) |
-
-Экспортируемая конфигурация несёт только ссылку, а не секрет. Pi интерполирует голый `$NAME`,
-поэтому переменная должна выглядеть так:
-
-```bash
-export OPENCODEX_API_KEY=
-```
+**Прокси на loopback вообще не требует ключа.** По умолчанию opencodex привязывается к
+`127.0.0.1` и ничего там не аутентифицирует, поэтому экспортируемый блок несёт не настоящие
+учётные данные, а литеральную заглушку `opencodex-loopback`; переменные окружения тут ни при чём.
-Это имя переменной относится только к Pi. opencode использует другую переменную
-(`OPENCODEX_OPENCODE_API_KEY` в форме `{env:…}`) — см. [руководство по opencode](/guides/opencode/).
+Заглушка нужна не для красоты: Pi разрешает `apiKey`, когда строит список моделей, и прячет
+провайдера целиком, если значение — ссылка на незаданную переменную окружения. Литерал
+сохраняет видимость всех маршрутизируемых моделей, а на loopback прокси это значение не проверяет.
-**Прокси на loopback вообще не требует ключа.** По умолчанию opencodex привязывается к
-`127.0.0.1` и ничего там не аутентифицирует, поэтому ссылка `$OPENCODEX_API_KEY` инертна и
-переменную можно не задавать. Она нужна только когда `hostname` выходит за пределы loopback — а
-именно в этом случае прокси и отказывается запускаться без токена; см.
-[Удалённый доступ](/reference/configuration/#remote-access).
+Ключи провайдеров — отдельная история: ключ Anthropic / OpenAI / OpenRouter хранится в
+конфигурации самого opencodex, см. [Провайдеры](/guides/providers/), и в этом файле не появляется.
## Метаданные моделей
@@ -111,10 +99,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..66181fd468 100644
--- a/docs-site/src/content/docs/ru/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ru/reference/cli/agents.md
@@ -182,10 +182,12 @@ limit'а (для них клиент применяет собственные d
| Клиент | Канонический путь | Имя скачиваемого файла | Переменная окружения |
| --- | --- | --- | --- |
| `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` |
-Имена этих двух env-переменных различаются, и каждый клиент интерполирует только свою. opencode
-читает `{env:OPENCODEX_OPENCODE_API_KEY}`; Pi читает `$OPENCODEX_API_KEY`.
+opencode интерполирует `{env:OPENCODEX_OPENCODE_API_KEY}`. Pi не использует переменную вовсе: он
+разрешает `apiKey`, когда строит список моделей, и прячет провайдера целиком, если значение —
+ссылка на незаданную переменную окружения, поэтому экспортируемый блок несёт литеральную заглушку
+`opencodex-loopback`, которую прокси на loopback никогда не проверяет.
:::caution[Сливать, а не заменять]
`ocx export` никогда не пишет в ваш реальный клиентский конфиг. Путь назначения лишь
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..9b7982ac00 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",
@@ -60,22 +60,11 @@ ocx export --client pi --json > ~/opencodex-pi-models.json # or redirect the b
## 准入密钥
-这里有两个很容易混淆的 key,但这个文件里只会出现第一个:
+**回环代理根本不需要 key。** opencodex 默认绑定 `127.0.0.1`,在那里不做任何认证,所以导出的块携带的是字面占位值 `opencodex-loopback`,而不是真实凭据 - 完全不涉及环境变量。
-| Key | 它是什么 | 它存放在哪里 |
-| --- | --- | --- |
-| 代理准入密钥 | opencodex 自己的凭据,在仪表盘的 **API** 选项卡中生成 | 通过 `apiKey` 以 `$OPENCODEX_API_KEY` 形式引用;实际值保存在你的环境中 |
-| Provider key | 你的 Anthropic / OpenAI / OpenRouter key | opencodex 自己的配置中,见 [Providers](/guides/providers/) |
+这个占位值不是装饰,而是必需的: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..20e0b70b53 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
@@ -150,15 +150,15 @@ ocx export --client opencode --out ~/opencodex-opencode.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` |
-这两个环境变量名称不同,而且每个客户端只会插入自己的那个。opencode 读取 `{env:OPENCODEX_OPENCODE_API_KEY}`;Pi 读取 `$OPENCODEX_API_KEY`。
+opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。Pi 完全不使用环境变量:它在构建模型列表时会解析 `apiKey`,一旦该值是未设置的环境变量引用,就会隐藏整个 provider,因此导出的块携带的是字面占位值 `opencodex-loopback`,而回环上的代理从不校验这个值。
:::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/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/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/src/clients/config-export.ts b/src/clients/config-export.ts
index 0a4a84ed00..0853ea85c6 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`
@@ -728,7 +727,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 +807,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,
@@ -949,15 +948,16 @@ 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,
},
hermes: {
diff --git a/tests/cli-export-command.test.ts b/tests/cli-export-command.test.ts
index 108dbcaa3d..3c2089aa38 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");
});
});
@@ -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..8f5173b14c 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,
@@ -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", () => {
diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts
index 8ab7faeab5..274f7fd4ea 100644
--- a/tests/client-config-export.test.ts
+++ b/tests/client-config-export.test.ts
@@ -6,8 +6,7 @@ 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,
@@ -160,12 +159,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", () => {
@@ -236,7 +236,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:");
});
});
@@ -348,7 +348,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",
@@ -449,8 +449,9 @@ describe("EXPORT_CLIENTS registry", () => {
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/management-client-config-route.test.ts b/tests/management-client-config-route.test.ts
index c5fa46912b..1e20c446ee 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,
@@ -149,11 +148,11 @@ 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);
From 1b2d57b465bfa4c93d84daece4fd6c9878ac77d9 Mon Sep 17 00:00:00 2001
From: n3wr1ch <40690535+n3wr1ch@users.noreply.github.com>
Date: Thu, 6 Aug 2026 08:12:17 +0900
Subject: [PATCH 02/16] docs(devlog): add Pi config preview evidence
---
.../evidence/pi-config-preview.png | Bin 0 -> 63468 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 devlog/_fin/260806_pi_loopback_models/evidence/pi-config-preview.png
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 0000000000000000000000000000000000000000..26693304e2b086d39fa4334968131f6072356415
GIT binary patch
literal 63468
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