diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index f17c88683..740b66e66 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -92,6 +92,7 @@ export default defineConfig({ { label: "Grok Build", translations: { ko: "Grok Build", "zh-CN": "Grok Build", ru: "Grok Build", ja: "Grok Build" }, slug: "guides/grok-build" }, { label: "opencode", translations: { ko: "opencode", "zh-CN": "opencode", ru: "opencode", ja: "opencode" }, slug: "guides/opencode" }, { label: "Pi", translations: { ko: "Pi", "zh-CN": "Pi", ru: "Pi", ja: "Pi" }, slug: "guides/pi" }, + { label: "Oh My Pi", translations: { ko: "Oh My Pi", "zh-CN": "Oh My Pi", ru: "Oh My Pi", ja: "Oh My Pi" }, slug: "guides/oh-my-pi" }, { label: "Integrations", translations: { ko: "연동", "zh-CN": "集成", ru: "Интеграции", ja: "連携" }, slug: "guides/integrations" }, { label: "Sidecars: Web Search & Vision", translations: { ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン" }, slug: "guides/sidecars" }, { label: "Image Bridge", translations: { ko: "이미지 브릿지", "zh-CN": "图像桥接", ru: "Image Bridge", ja: "画像ブリッジ" }, slug: "guides/image-bridge" }, diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index c917fd273..edc225cd3 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -1,15 +1,16 @@ --- 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, Oh My Pi, 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` | +| Oh My Pi | `~/.omp/agent/models.yml` | YAML | new sessions | `OPENCODEX_OMP_API_KEY` | | 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 | @@ -75,7 +76,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, Oh My Pi, 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/oh-my-pi.md b/docs-site/src/content/docs/guides/oh-my-pi.md new file mode 100644 index 000000000..445818bf3 --- /dev/null +++ b/docs-site/src/content/docs/guides/oh-my-pi.md @@ -0,0 +1,63 @@ +--- +title: Oh My Pi +description: Use any routed model from Oh My Pi (omp) — ocx export prints a custom provider block for omp's ~/.omp/agent/models.yml, wired to the running proxy. +--- + +Oh My Pi (`omp`) reads custom providers from `~/.omp/agent/models.yml`, so +opencodex does not launch it. Instead, `ocx export` serializes the `opencodex` +provider block — base URL, model list, and the env reference omp interpolates — +and you merge it into that file. + +## Quickstart + +Start the proxy, then print the config: + +```bash +ocx start +ocx export --client omp +``` + +The output leads with the YAML, then prints the destination path, the merge +warning, the env export line, and how many models carry authoritative context +limits. + +```yaml +providers: + opencodex: + baseUrl: http://127.0.0.1:10100/v1 + api: openai-completions + apiKey: "$OPENCODEX_OMP_API_KEY" + models: + - id: anthropic/claude-opus-5 + name: "Claude Opus 5 (anthropic)" + input: + - text + contextWindow: 200000 + maxTokens: 32000 +``` + +`api: openai-completions` selects omp's OpenAI-compatible adapter, which talks +to the proxy's `/v1` surface. `$OPENCODEX_OMP_API_KEY` is only omp's local +provider key for the `opencodex` entry — it is not an opencodex OAuth or +access-token credential, and opencodex never sees its value. + +Merge the `providers.opencodex` block into `~/.omp/agent/models.yml` (do not +replace the rest of the file), then start a new omp session. The models appear +under the `opencodex/` prefix. + +## Credential + +The config references `$OPENCODEX_OMP_API_KEY` and never holds the real key. +Export it before launching omp: + +```bash +export OPENCODEX_OMP_API_KEY= +``` + +Like Pi, omp's provider block has no place for the `x-opencodex-api-key` header +a non-loopback bind requires, so this integration works against a loopback +bind. Reach a remote opencodex through an SSH tunnel or a local forwarder that +adds the header instead. + +The dashboard's **Integrations → Oh My Pi** tab does the same thing with a +switch, and takes a backup before every write so the change can be undone. diff --git a/gui-screenshot.png b/gui-screenshot.png new file mode 100644 index 000000000..3294e5900 Binary files /dev/null and b/gui-screenshot.png differ diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 7fc5cfea2..0d7df41ec 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/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index dca09d856..dc98871db 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 7703f6c09..cb3f1d772 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -751,6 +751,7 @@ export const de: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.omp": "Oh My Pi", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", @@ -860,6 +861,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": "Gilt für neue Sitzungen.", "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).", @@ -1121,21 +1123,22 @@ export const de: Record = { "api.clientConfig.rowsLabel": "Client verbinden", "api.clientConfig.details": "Details", "api.clientConfig.detailsAria": "Details zur {client}-Konfiguration", - "api.clientConfig.copyAria": "{client}-Konfigurations-JSON kopieren", + "api.clientConfig.copyAria": "{client}-Konfiguration kopieren", "api.clientConfig.downloadAria": "{client}-Konfiguration herunterladen", "api.clientConfig.rowMeta": "{destination} · {count} Modell(e)", "api.clientConfig.rowError": "Die {client}-Konfiguration konnte nicht erstellt werden.", - "api.clientConfig.copiedAnnounceClient": "{client}-Konfigurations-JSON in die Zwischenablage kopiert.", + "api.clientConfig.copiedAnnounceClient": "{client}-Konfiguration in die Zwischenablage kopiert.", "api.clientConfig.clientOpencode": "OpenCode", "api.clientConfig.clientPi": "Pi", + "api.clientConfig.clientOmp": "Oh My Pi", "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", - "api.clientConfig.copy": "JSON kopieren", + "api.clientConfig.copy": "Konfiguration kopieren", "api.clientConfig.download": "Herunterladen", "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", - "api.clientConfig.jsonLabel": "{client}-Konfigurations-JSON", + "api.clientConfig.jsonLabel": "{client}-Konfiguration", "api.clientConfig.destination": "Zieldatei", "api.clientConfig.envHint": "Schlüssel vor dem Start setzen", "api.clientConfig.mergeWarning": "Führe dies in die Zieldatei ein. Ein Ersetzen würde deine anderen Provider und MCP-Einstellungen entfernen.", @@ -1143,8 +1146,8 @@ export const de: Record = { "api.clientConfig.missingLimits": "{count} von {total} Modell(en) haben kein Kontextlimit; der Client verwendet dafür seine eigenen Vorgaben.", "api.clientConfig.noKeyYet": "Für {env} existiert noch kein Schlüssel. Erzeuge oben einen Schlüssel, bevor du diese Konfiguration außerhalb von Loopback nutzt.", "api.clientConfig.loadFailed": "Die Modellliste konnte nicht gelesen werden, daher wurde keine Client-Konfiguration erzeugt.", - "api.clientConfig.copiedAnnounce": "Client-Konfigurations-JSON in die Zwischenablage kopiert.", - "api.clientConfig.copyFailed": "Client-Konfigurations-JSON konnte nicht kopiert werden.", + "api.clientConfig.copiedAnnounce": "Client-Konfiguration in die Zwischenablage kopiert.", + "api.clientConfig.copyFailed": "Die Client-Konfiguration konnte nicht kopiert werden.", "api.clientConfig.downloadedAnnounce": "{filename} heruntergeladen. Es hat sich noch nichts geändert — führe die Datei selbst in {destination} ein.", "api.clientConfig.whereDisclosure": "Wohin diese Datei gehört", "api.clientConfig.whereBody": "Der Pfad oben ist der globale Speicherort. Eine projektlokale Konfigurationsdatei im Arbeitsverzeichnis hat Vorrang, und der Schlüssel wird aus der in der Konfiguration genannten Umgebungsvariable gelesen — nie aus dieser Datei.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9ae886124..d0911b93a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1206,6 +1206,7 @@ export const en = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.omp": "Oh My Pi", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", @@ -1315,6 +1316,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": "Applies to new sessions.", "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).", @@ -1584,21 +1586,22 @@ export const en = { "api.clientConfig.rowsLabel": "Connect a client", "api.clientConfig.details": "Details", "api.clientConfig.detailsAria": "{client} config details", - "api.clientConfig.copyAria": "Copy {client} config JSON", + "api.clientConfig.copyAria": "Copy {client} config", "api.clientConfig.downloadAria": "Download {client} config", "api.clientConfig.rowMeta": "{destination} · {count} model(s)", "api.clientConfig.rowError": "Could not build the {client} config.", - "api.clientConfig.copiedAnnounceClient": "{client} config JSON copied to the clipboard.", + "api.clientConfig.copiedAnnounceClient": "{client} config copied to the clipboard.", "api.clientConfig.clientOpencode": "OpenCode", "api.clientConfig.clientPi": "Pi", + "api.clientConfig.clientOmp": "Oh My Pi", "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", - "api.clientConfig.copy": "Copy JSON", + "api.clientConfig.copy": "Copy config", "api.clientConfig.download": "Download", "api.clientConfig.loading": "Building client config…", - "api.clientConfig.jsonLabel": "{client} config JSON", + "api.clientConfig.jsonLabel": "{client} config", "api.clientConfig.destination": "Destination file", "api.clientConfig.envHint": "Set the key before launching", "api.clientConfig.mergeWarning": "Merge this into the destination file. Replacing it would drop your other providers and MCP settings.", @@ -1606,8 +1609,8 @@ export const en = { "api.clientConfig.missingLimits": "{count} of {total} model(s) ship without a context limit; the client applies its own defaults.", "api.clientConfig.noKeyYet": "{env} has no key behind it yet. Generate a key above before using this config off loopback.", "api.clientConfig.loadFailed": "Could not read the model list, so no client config was produced.", - "api.clientConfig.copiedAnnounce": "Client config JSON copied to the clipboard.", - "api.clientConfig.copyFailed": "Could not copy the client config JSON.", + "api.clientConfig.copiedAnnounce": "Client config copied to the clipboard.", + "api.clientConfig.copyFailed": "Could not copy the client config.", "api.clientConfig.downloadedAnnounce": "Downloaded {filename}. Nothing changed yet — merge it into {destination} yourself.", "api.clientConfig.whereDisclosure": "Where this file goes", "api.clientConfig.whereBody": "The destination above is the global path. A project-local config file in the working directory takes precedence over it, and the client reads the key from the environment variable named in the config — never from this file.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d5f7a915b..2872ace1b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1154,6 +1154,7 @@ export const ja: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.omp": "Oh My Pi", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", @@ -1263,6 +1264,7 @@ export const ja: Record = { "integrations.kind.restore": "復元", "integrations.semantics.opencode": "ディスクから直接起動した場合にのみ適用されます。ocx opencode の環境注入が優先されます。", "integrations.semantics.pi": "新しいセッションから適用されます。", + "integrations.semantics.omp": "新しいセッションから適用されます。", "integrations.semantics.hermes": "新しいセッションから適用されます。", "integrations.semantics.openclaw": "実行中のゲートウェイにすぐ適用されます。", "integrations.semantics.kimi": "再起動するか /reload を実行すると適用されます(v2 はファイルを監視します)。", @@ -1529,21 +1531,22 @@ export const ja: Record = { "api.clientConfig.rowsLabel": "クライアントを接続", "api.clientConfig.details": "詳細", "api.clientConfig.detailsAria": "{client} 設定の詳細", - "api.clientConfig.copyAria": "{client} 設定 JSON をコピー", + "api.clientConfig.copyAria": "{client} 設定をコピー", "api.clientConfig.downloadAria": "{client} 設定をダウンロード", "api.clientConfig.rowMeta": "{destination} · モデル {count} 件", "api.clientConfig.rowError": "{client} の設定を生成できませんでした。", - "api.clientConfig.copiedAnnounceClient": "{client} の設定 JSON をクリップボードにコピーしました。", + "api.clientConfig.copiedAnnounceClient": "{client} の設定をクリップボードにコピーしました。", "api.clientConfig.clientOpencode": "OpenCode", "api.clientConfig.clientPi": "Pi", + "api.clientConfig.clientOmp": "Oh My Pi", "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", - "api.clientConfig.copy": "JSON をコピー", + "api.clientConfig.copy": "設定をコピー", "api.clientConfig.download": "ダウンロード", "api.clientConfig.loading": "クライアント設定を生成中…", - "api.clientConfig.jsonLabel": "{client} 設定 JSON", + "api.clientConfig.jsonLabel": "{client} 設定", "api.clientConfig.destination": "配置先ファイル", "api.clientConfig.envHint": "起動前にキーを設定", "api.clientConfig.mergeWarning": "配置先ファイルにマージしてください。置き換えると既存のプロバイダーや MCP 設定が失われます。", @@ -1551,8 +1554,8 @@ export const ja: Record = { "api.clientConfig.missingLimits": "{total} 件中 {count} 件のモデルにコンテキスト上限がないため、クライアント側の既定値が使われます。", "api.clientConfig.noKeyYet": "{env} に対応するキーがまだありません。ループバック外で使う前に上でキーを発行してください。", "api.clientConfig.loadFailed": "モデル一覧を読み取れなかったため、クライアント設定を生成できませんでした。", - "api.clientConfig.copiedAnnounce": "クライアント設定 JSON をクリップボードにコピーしました。", - "api.clientConfig.copyFailed": "クライアント設定 JSON をコピーできませんでした。", + "api.clientConfig.copiedAnnounce": "クライアント設定をクリップボードにコピーしました。", + "api.clientConfig.copyFailed": "クライアント設定をコピーできませんでした。", "api.clientConfig.downloadedAnnounce": "{filename} をダウンロードしました。まだ何も変わっていません。{destination} に自分でマージしてください。", "api.clientConfig.whereDisclosure": "このファイルの置き場所", "api.clientConfig.whereBody": "上のパスはグローバル設定の場所です。作業ディレクトリのプロジェクト設定ファイルが優先され、キーは設定に書かれた環境変数から読み込まれ、このファイルには保存されません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index e0ee1af6a..8c1c87608 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -775,6 +775,7 @@ export const ko: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.omp": "Oh My Pi", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", @@ -884,6 +885,7 @@ export const ko: Record = { "integrations.kind.restore": "복원", "integrations.semantics.opencode": "디스크에서 직접 실행할 때만 적용됩니다. ocx opencode의 환경 주입이 우선합니다.", "integrations.semantics.pi": "새 세션부터 적용됩니다.", + "integrations.semantics.omp": "새 세션부터 적용됩니다.", "integrations.semantics.hermes": "새 세션부터 적용됩니다.", "integrations.semantics.openclaw": "실행 중인 게이트웨이에 즉시 반영됩니다.", "integrations.semantics.kimi": "재시작 또는 /reload 시 적용됩니다 (v2는 파일 변경을 감지합니다).", @@ -1148,21 +1150,22 @@ export const ko: Record = { "api.clientConfig.rowsLabel": "클라이언트 연결", "api.clientConfig.details": "자세히", "api.clientConfig.detailsAria": "{client} 설정 자세히 보기", - "api.clientConfig.copyAria": "{client} 설정 JSON 복사", + "api.clientConfig.copyAria": "{client} 설정 복사", "api.clientConfig.downloadAria": "{client} 설정 다운로드", "api.clientConfig.rowMeta": "{destination} · 모델 {count}개", "api.clientConfig.rowError": "{client} 설정을 만들지 못했습니다.", - "api.clientConfig.copiedAnnounceClient": "{client} 설정 JSON을 클립보드에 복사했습니다.", + "api.clientConfig.copiedAnnounceClient": "{client} 설정을 클립보드에 복사했습니다.", "api.clientConfig.clientOpencode": "OpenCode", "api.clientConfig.clientPi": "Pi", + "api.clientConfig.clientOmp": "Oh My Pi", "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", - "api.clientConfig.copy": "JSON 복사", + "api.clientConfig.copy": "설정 복사", "api.clientConfig.download": "다운로드", "api.clientConfig.loading": "클라이언트 설정 생성 중…", - "api.clientConfig.jsonLabel": "{client} 설정 JSON", + "api.clientConfig.jsonLabel": "{client} 설정", "api.clientConfig.destination": "대상 파일", "api.clientConfig.envHint": "실행 전 키 설정", "api.clientConfig.mergeWarning": "대상 파일에 병합하세요. 덮어쓰면 기존 프로바이더와 MCP 설정이 사라집니다.", @@ -1170,8 +1173,8 @@ export const ko: Record = { "api.clientConfig.missingLimits": "{total}개 중 {count}개 모델에 컨텍스트 한도가 없어 클라이언트 기본값이 적용됩니다.", "api.clientConfig.noKeyYet": "{env}에 연결된 키가 아직 없습니다. 루프백 밖에서 쓰려면 위에서 키를 발급하세요.", "api.clientConfig.loadFailed": "모델 목록을 읽지 못해 클라이언트 설정을 만들지 못했습니다.", - "api.clientConfig.copiedAnnounce": "클라이언트 설정 JSON을 클립보드에 복사했습니다.", - "api.clientConfig.copyFailed": "클라이언트 설정 JSON을 복사하지 못했습니다.", + "api.clientConfig.copiedAnnounce": "클라이언트 설정을 클립보드에 복사했습니다.", + "api.clientConfig.copyFailed": "클라이언트 설정을 복사하지 못했습니다.", "api.clientConfig.downloadedAnnounce": "{filename} 파일을 다운로드했습니다. 아직 아무것도 바뀌지 않았으니 {destination}에 직접 병합하세요.", "api.clientConfig.whereDisclosure": "이 파일이 들어갈 위치", "api.clientConfig.whereBody": "위 경로는 전역 설정 경로입니다. 작업 디렉터리의 프로젝트 설정 파일이 우선하며, 키는 설정에 적힌 환경 변수에서 읽고 이 파일에는 저장되지 않습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f707bb5f5..7f24c4e62 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1196,6 +1196,7 @@ export const ru: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.omp": "Oh My Pi", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", @@ -1305,6 +1306,7 @@ export const ru: Record = { "integrations.kind.restore": "Восстановлено", "integrations.semantics.opencode": "Действует только при прямом запуске с диска; внедрение окружения через ocx opencode имеет приоритет.", "integrations.semantics.pi": "Применяется к новым сеансам.", + "integrations.semantics.omp": "Применяется к новым сеансам.", "integrations.semantics.hermes": "Применяется к новым сеансам.", "integrations.semantics.openclaw": "Немедленно применяется к работающему шлюзу.", "integrations.semantics.kimi": "Чтобы применить, перезапустите клиент или выполните /reload (v2 отслеживает файл).", @@ -1571,21 +1573,22 @@ export const ru: Record = { "api.clientConfig.rowsLabel": "Подключение клиента", "api.clientConfig.details": "Подробнее", "api.clientConfig.detailsAria": "Подробности конфигурации {client}", - "api.clientConfig.copyAria": "Скопировать JSON конфигурации {client}", + "api.clientConfig.copyAria": "Скопировать конфигурацию {client}", "api.clientConfig.downloadAria": "Скачать конфигурацию {client}", "api.clientConfig.rowMeta": "{destination} · моделей: {count}", "api.clientConfig.rowError": "Не удалось собрать конфигурацию {client}.", - "api.clientConfig.copiedAnnounceClient": "JSON конфигурации {client} скопирован в буфер обмена.", + "api.clientConfig.copiedAnnounceClient": "Конфигурация {client} скопирована в буфер обмена.", "api.clientConfig.clientOpencode": "OpenCode", "api.clientConfig.clientPi": "Pi", + "api.clientConfig.clientOmp": "Oh My Pi", "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", - "api.clientConfig.copy": "Копировать JSON", + "api.clientConfig.copy": "Скопировать конфигурацию", "api.clientConfig.download": "Скачать", "api.clientConfig.loading": "Формируется конфигурация клиента…", - "api.clientConfig.jsonLabel": "JSON конфигурации {client}", + "api.clientConfig.jsonLabel": "Конфигурация {client}", "api.clientConfig.destination": "Целевой файл", "api.clientConfig.envHint": "Задайте ключ перед запуском", "api.clientConfig.mergeWarning": "Объедините это с целевым файлом. Замена удалит ваши другие провайдеры и настройки MCP.", @@ -1593,8 +1596,8 @@ export const ru: Record = { "api.clientConfig.missingLimits": "У {count} из {total} моделей нет лимита контекста; клиент применит свои значения по умолчанию.", "api.clientConfig.noKeyYet": "Для {env} пока нет ключа. Создайте ключ выше, прежде чем использовать конфигурацию вне loopback.", "api.clientConfig.loadFailed": "Не удалось прочитать список моделей, поэтому конфигурация клиента не создана.", - "api.clientConfig.copiedAnnounce": "JSON конфигурации клиента скопирован в буфер обмена.", - "api.clientConfig.copyFailed": "Не удалось скопировать JSON конфигурации клиента.", + "api.clientConfig.copiedAnnounce": "Конфигурация клиента скопирована в буфер обмена.", + "api.clientConfig.copyFailed": "Не удалось скопировать конфигурацию клиента.", "api.clientConfig.downloadedAnnounce": "Файл {filename} скачан. Пока ничего не изменилось — объедините его с {destination} самостоятельно.", "api.clientConfig.whereDisclosure": "Куда положить этот файл", "api.clientConfig.whereBody": "Путь выше — глобальное расположение. Файл конфигурации проекта в рабочем каталоге имеет приоритет, а ключ читается из переменной окружения, указанной в конфигурации, и никогда не хранится в этом файле.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 14113c611..6b202fab7 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -768,6 +768,7 @@ export const zh: Record = { "integrations.tab.grok": "Grok Build", "integrations.tab.opencode": "OpenCode", "integrations.tab.pi": "Pi", + "integrations.tab.omp": "Oh My Pi", "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", @@ -877,6 +878,7 @@ export const zh: Record = { "integrations.kind.restore": "已恢复", "integrations.semantics.opencode": "仅适用于直接从磁盘启动;ocx opencode 的环境注入优先。", "integrations.semantics.pi": "对新会话生效。", + "integrations.semantics.omp": "对新会话生效。", "integrations.semantics.hermes": "对新会话生效。", "integrations.semantics.openclaw": "立即应用到正在运行的网关。", "integrations.semantics.kimi": "重启或运行 /reload 以应用(v2 会监视该文件)。", @@ -1141,21 +1143,22 @@ export const zh: Record = { "api.clientConfig.rowsLabel": "连接客户端", "api.clientConfig.details": "详情", "api.clientConfig.detailsAria": "{client} 配置详情", - "api.clientConfig.copyAria": "复制 {client} 配置 JSON", + "api.clientConfig.copyAria": "复制 {client} 配置", "api.clientConfig.downloadAria": "下载 {client} 配置", "api.clientConfig.rowMeta": "{destination} · {count} 个模型", "api.clientConfig.rowError": "无法生成 {client} 配置。", - "api.clientConfig.copiedAnnounceClient": "已将 {client} 配置 JSON 复制到剪贴板。", + "api.clientConfig.copiedAnnounceClient": "已将 {client} 配置复制到剪贴板。", "api.clientConfig.clientOpencode": "OpenCode", "api.clientConfig.clientPi": "Pi", + "api.clientConfig.clientOmp": "Oh My Pi", "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", - "api.clientConfig.copy": "复制 JSON", + "api.clientConfig.copy": "复制配置", "api.clientConfig.download": "下载", "api.clientConfig.loading": "正在生成客户端配置…", - "api.clientConfig.jsonLabel": "{client} 配置 JSON", + "api.clientConfig.jsonLabel": "{client} 配置", "api.clientConfig.destination": "目标文件", "api.clientConfig.envHint": "启动前设置密钥", "api.clientConfig.mergeWarning": "请合并到目标文件中。直接替换会丢失你已有的其他提供商和 MCP 配置。", @@ -1163,8 +1166,8 @@ export const zh: Record = { "api.clientConfig.missingLimits": "{total} 个模型中有 {count} 个没有上下文上限,客户端将使用自己的默认值。", "api.clientConfig.noKeyYet": "{env} 目前还没有对应的密钥。离开回环地址使用前,请先在上方生成密钥。", "api.clientConfig.loadFailed": "无法读取模型列表,因此没有生成客户端配置。", - "api.clientConfig.copiedAnnounce": "客户端配置 JSON 已复制到剪贴板。", - "api.clientConfig.copyFailed": "无法复制客户端配置 JSON。", + "api.clientConfig.copiedAnnounce": "客户端配置已复制到剪贴板。", + "api.clientConfig.copyFailed": "无法复制客户端配置。", "api.clientConfig.downloadedAnnounce": "已下载 {filename}。目前还没有任何改动,请自行将其合并到 {destination}。", "api.clientConfig.whereDisclosure": "该文件应放在哪里", "api.clientConfig.whereBody": "上面的路径是全局配置位置。工作目录中的项目级配置文件优先级更高;密钥由配置中指定的环境变量读取,不会写入该文件。", diff --git a/gui/src/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx index 772598af2..27584595d 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 32f408d80..4783a4ef2 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 2d6830da6..0d7b0d3ad 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 73811976b..cce205390 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -136,6 +136,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 ce2336c7d..d94185807 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -190,7 +190,7 @@ test("each row fetches its own client and its dialog renders that client's exact await act(async () => { root.unmount(); }); }); -test("the config JSON is not rendered at rest", async () => { +test("the config body is not rendered at rest", async () => { // The core of the request: rows carry the actions, not the payload. stubRoute(client => Response.json(client === "pi" ? PI_ENVELOPE : OPENCODE_ENVELOPE)); const { root, container } = await mountPanel(); @@ -199,7 +199,7 @@ test("the config JSON is not rendered at rest", async () => { expect(container.querySelector(".awi-clientconfig-json")).toBeNull(); expect(container.querySelector("dialog")).toBeNull(); // Both transport actions stay on the surface; only inspection is demoted. - expect(rowButton(container, "OpenCode", "Copy JSON").disabled).toBe(false); + expect(rowButton(container, "OpenCode", "Copy config").disabled).toBe(false); expect(rowButton(container, "OpenCode", "Download").disabled).toBe(false); await act(async () => { rowButton(container, "OpenCode", "Details").click(); }); @@ -233,7 +233,7 @@ test("row actions carry client-qualified accessible names", async () => { stubRoute(client => Response.json(client === "pi" ? PI_ENVELOPE : OPENCODE_ENVELOPE)); const { root, container } = await mountPanel(); - expect(rowButton(container, "Pi", "Copy JSON").getAttribute("aria-label")).toBe("Copy Pi config JSON"); + expect(rowButton(container, "Pi", "Copy config").getAttribute("aria-label")).toBe("Copy Pi config"); expect(rowButton(container, "Pi", "Download").getAttribute("aria-label")).toBe("Download Pi config"); expect(rowButton(container, "Pi", "Details").getAttribute("aria-label")).toBe("Pi config details"); expect(container.querySelector(".awi-clientconfig-rows")?.getAttribute("aria-label")).toBe("Connect a client"); @@ -434,14 +434,14 @@ test("one client's failure isolates to its row, with no partial JSON and the bas .toEqual(["Retry"]); // The sibling row is untouched by its neighbour's 503. - expect(rowButton(container, "OpenCode", "Copy JSON").disabled).toBe(false); + expect(rowButton(container, "OpenCode", "Copy config").disabled).toBe(false); expect(rowButton(container, "OpenCode", "Download").disabled).toBe(false); await act(async () => { rowButton(container, "Pi", "Retry").click(); }); expect(piAttempts).toBe(2); expect(row(container, "Pi").textContent).toContain(PI_ENVELOPE.destination); - expect(rowButton(container, "Pi", "Copy JSON").disabled).toBe(false); + expect(rowButton(container, "Pi", "Copy config").disabled).toBe(false); await act(async () => { root.unmount(); }); }); @@ -468,7 +468,7 @@ test("no-key state is informational and leaves copy and download enabled", async // Row actions never block on a missing key: an agent may legitimately want the // shape first. - expect(rowButton(container, "OpenCode", "Copy JSON").disabled).toBe(false); + expect(rowButton(container, "OpenCode", "Copy config").disabled).toBe(false); expect(rowButton(container, "OpenCode", "Download").disabled).toBe(false); await act(async () => { rowButton(container, "OpenCode", "Details").click(); }); diff --git a/gui/tests/integrations-routing.test.ts b/gui/tests/integrations-routing.test.ts index ad26e3d50..a0c5564d2 100644 --- a/gui/tests/integrations-routing.test.ts +++ b/gui/tests/integrations-routing.test.ts @@ -84,6 +84,15 @@ describe("registered nested hashes", () => { expect(resolveAppHashChange("integrations/claude/desktop").replaceTo).toBeNull(); }); + test("the Oh My Pi route is registered instead of falling back to Overview", () => { + expect(INTEGRATION_TAB_HASHES).toContain("integrations/omp"); + expect(hashBelongsToPage("integrations/omp", "integrations")).toBe(true); + expect(resolveAppHashChange("integrations/omp")).toEqual({ + page: "integrations", + replaceTo: null, + }); + }); + test("bare #integrations is Overview and has no suffix of its own", () => { expect(readPageFromHash("integrations")).toBe("integrations"); expect(hashBelongsToPage("integrations", "integrations")).toBe(true); diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index ce20edf33..b866635be 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -8,7 +8,8 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./bas import type { TranslatorBudget } from "../lib/translator-budget"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { configuredReasoningEfforts } from "../reasoning-effort"; -import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from "../providers/command-code-efforts"; +import { COMMAND_CODE_MODEL_REASONING_EFFORTS, commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from "../providers/command-code-efforts"; +import { decodeRoutedModelId } from "../providers/slug-codec"; import { identifyRoutedModel } from "./identity"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { parseDataUrl } from "./image"; @@ -318,7 +319,18 @@ function supportedCommandCodeEffort(provider: OcxProviderConfig, modelId: string // Compatibility ids (deepseek-v4-flash / glm-5.2) must resolve to their canonical // Command Code id before the effort lookup, or legacy requests silently lose the // reasoning effort because the official table is keyed by the canonical ids. - const canonicalId = COMMAND_CODE_MODEL_ALIASES[modelId] ?? modelId; + // omp and other OpenAI-compatible clients dial with the namespaced selector + // (command-code/deepseek-deepseek-v4-flash, inner "/" encoded as "-"), so + // decode it back against the official effort table before the lookup. + const aliased = COMMAND_CODE_MODEL_ALIASES[modelId] ?? modelId; + const knownIds = [...new Set([ + ...Object.keys(COMMAND_CODE_MODEL_REASONING_EFFORTS), + ...Object.values(COMMAND_CODE_MODEL_ALIASES), + ])]; + // Strip the provider prefix first: decodeRoutedModelId expects the encoded + // id portion (deepseek-deepseek-v4-flash), not the full command-code/ slug. + const encodedId = aliased.startsWith("command-code/") ? aliased.slice("command-code/".length) : aliased; + const canonicalId = decodeRoutedModelId(encodedId, knownIds); const supported = commandCodeReasoningEfforts(canonicalId) ?? configuredReasoningEfforts(provider, canonicalId); if (!supported) return undefined; // Command Code's official profiles describe xhigh and ultra as the CLI labels that map to diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index d7d074fb5..2d359502c 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -62,7 +62,11 @@ export interface ExportCommandDeps extends RuntimeApiDeps { * `/api/models` row plus the modality list Pi consumes. The launcher's row type predates * the Pi exporter and stops at the fields OpenCode needs. */ -type ExportProxyModelRow = OpencodeProxyModelRow & { inputModalities?: string[] }; +type ExportProxyModelRow = OpencodeProxyModelRow & { + inputModalities?: string[]; + reasoningEfforts?: string[]; + defaultReasoningEffort?: string; +}; /** Same authoritativeness rule the serializers apply, for the degraded-count line. */ function hasContextLimit(model: ExportModel): boolean { @@ -101,6 +105,9 @@ export function exportModelsFromProxyRows( if (entry.contextWindow !== undefined) model.contextWindow = entry.contextWindow; const input = modalities.get(entry.namespaced); if (input) model.inputModalities = input; + const source = rows.find(row => row.namespaced?.trim() === entry.namespaced && row.disabled !== true); + if (source?.reasoningEfforts) model.reasoningEfforts = [...source.reasoningEfforts]; + if (source?.defaultReasoningEffort) model.defaultReasoningEffort = source.defaultReasoningEffort; return model; }); } diff --git a/src/cli/help.ts b/src/cli/help.ts index f76849447..c26ed38d1 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -173,8 +173,8 @@ 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, Oh My Pi, 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.", "--out writes the config there and refuses to replace an existing file without --force.", @@ -313,7 +313,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 0a4a84ed0..2fa42a22e 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -139,6 +139,12 @@ export const KIMI_LOOPBACK_PLACEHOLDER = "opencodex-loopback"; */ export const GAJAE_API_KEY_ENV = "OPENCODEX_GAJAE_API_KEY"; +/** Env var omp reads for the proxy admission key (bare `$VAR` interpolation). */ +export const OMP_API_KEY_ENV = "OPENCODEX_OMP_API_KEY"; + +/** omp's reference form for the admission key. Never the value. */ +export const OMP_API_KEY_ENV_REF = `$${OMP_API_KEY_ENV}`; + /** Pi's wire-dialect selector for an OpenAI-compatible endpoint. */ const PI_API_DIALECT = "openai-completions"; @@ -339,6 +345,49 @@ export function gajaeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(gajaeHomeDir(env, home), "agent", "models.yml"); } +/** omp's config root, matching oh-my-pi's `path.join(homedir(), PI_CONFIG_DIR || ".omp")`. */ +export function ompHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const override = env.PI_CONFIG_DIR?.trim(); + return join(home, override && override.length > 0 ? override : ".omp"); +} + +/** + * The agent directory omp actually reads settings from. + * + * omp's DirResolver computes: config root (`PI_CONFIG_DIR` or `~/.omp`), then + * `agent` beneath it, with `PI_CODING_AGENT_DIR` overriding the agent + * directory outright and `OMP_PROFILE`/`PI_PROFILE` switching to + * `/profiles//agent`. Mirroring the environment selectors keeps + * the toggle writing where the running agent reads. + */ +export function ompAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const root = ompHomeDir(env, home); + // A blank OMP_PROFILE is treated as unset so PI_PROFILE can fall through, + // matching omp's own env precedence (OMP_PROFILE wins when it names a + // profile; otherwise the legacy PI_PROFILE is consulted). + const profile = env.OMP_PROFILE?.trim() || env.PI_PROFILE?.trim(); + const agentOverride = env.PI_CODING_AGENT_DIR?.trim(); + if (profile && profile !== "default") return join(root, "profiles", profile, "agent"); + if (agentOverride && agentOverride.length > 0) return absoluteClientPath(agentOverride, home, "PI_CODING_AGENT_DIR"); + return join(root, "agent"); +} + +/** + * The models config omp actually reads: `~/.omp/agent/models.yml`. + * + * omp 17.x reads its custom provider list from `models.yml` (or `models.yaml`) + * in the agent directory. This is where a user's hand-written + * `providers.` blocks live, so a toggle must write here — not to the + * legacy `~/.pi/agent/models.json` the old Pi exporter targets. + */ +export function ompConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const agentDir = ompAgentDir(env, home); + const yml = join(agentDir, "models.yml"); + if (existsSync(yml)) return yml; + const yaml = join(agentDir, "models.yaml"); + return existsSync(yaml) ? yaml : yml; +} + /** * One proxy-routed model destined for a client config. Deliberately narrower than * `CatalogModel` so a serializer cannot reach for a field that does not survive the @@ -354,6 +403,10 @@ export interface ExportModel { displayName?: string; contextWindow?: number; inputModalities?: string[]; + /** Reasoning levels the proxy will accept for this model (wire vocabulary). */ + reasoningEfforts?: string[]; + /** Default reasoning level, when the model carries one. */ + defaultReasoningEffort?: string; } export interface ExportContext { @@ -370,6 +423,7 @@ export interface ExportContext { export type ExportClientId = | "opencode" | "pi" + | "omp" | "hermes" | "openclaw" | "kimi" @@ -587,6 +641,14 @@ export interface PiModelEntry { input: string[]; contextWindow?: number; maxTokens?: number; + /** omp requires `reasoning: true` before it honors a thinking block. */ + reasoning?: boolean; + /** omp thinking block; present only when the model carries reasoning efforts. */ + thinking?: { + mode: "effort"; + efforts: string[]; + defaultLevel?: string; + }; } export interface PiProviderBlock { @@ -600,6 +662,35 @@ export interface PiGeneratedConfig { providers: Record; } +/** + * omp's effort vocabulary is `minimal|low|medium|high|xhigh|max`; anything else + * would fail its schema and take the whole provider down, so out-of-range wire + * values (e.g. `ultra` or `none`) are dropped rather than emitted. + */ +const OMP_EFFORT_VOCABULARY = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]); + +function ompAcceptsEffort(effort: string): boolean { + return OMP_EFFORT_VOCABULARY.has(effort.trim().toLowerCase()); +} + +/** + * omp's `~/.omp/agent/models.yml` custom-provider shape. + * + * `providers` is a keyed object; each entry declares the endpoint and the + * models the agent may route to. opencodex owns the `opencodex` key, exactly + * as it owns the `opencodex` provider key in every other client's config. + */ +export interface OmpProviderBlock { + baseUrl: string; + api: string; + apiKey: string; + models: PiModelEntry[]; +} + +export interface OmpGeneratedConfig { + providers: Record; +} + /** * Hermes `~/.hermes/config.yaml`. We emit ONLY the provider entry — never * `model.default` — because hijacking the user's main model is not what a @@ -735,6 +826,54 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { }; } +/** + * omp's `~/.omp/agent/models.yml` shape. Same model entries as Pi (id, name, + * input) plus the provider's baseUrl/api/apiKey. omp validates strictly and + * drops the whole provider over one invalid model, so the same + * text|image-enum guard applies. + */ +function buildOmpClientConfig(ctx: ExportContext): OmpGeneratedConfig { + const models: PiModelEntry[] = []; + for (const model of normalizeExportModels(ctx.models)) { + const input = inputModalitiesForClient("pi", model.inputModalities); + if (input === null) continue; + const entry: PiModelEntry = { + id: model.namespaced, + name: exportModelLabel(model), + input, + }; + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) { + entry.contextWindow = context; + entry.maxTokens = outputBudgetFor(context); + } + // omp only exposes reasoning-effort controls when the model declares a + // thinking block; without it omp sends no reasoning_effort at all. + const efforts = model.reasoningEfforts?.filter(effort => ompAcceptsEffort(effort)) ?? []; + if (efforts.length > 0) { + entry.reasoning = true; + entry.thinking = { + mode: "effort", + efforts, + ...(model.defaultReasoningEffort && ompAcceptsEffort(model.defaultReasoningEffort) + ? { defaultLevel: model.defaultReasoningEffort } + : {}), + }; + } + models.push(entry); + } + return { + providers: { + [OPENCODE_PROVIDER_ID]: { + baseUrl: ctx.baseUrl, + api: PI_API_DIALECT, + apiKey: OMP_API_KEY_ENV_REF, + models, + }, + }, + }; +} + /** Extra headers a non-loopback bind needs, or nothing on loopback. */ function proxyAdmissionHeaders(config: OcxConfig | undefined, envRef: string): Record | undefined { return shouldInjectApiAuthHeader(config) ? { "x-opencodex-api-key": envRef } : undefined; @@ -862,6 +1001,11 @@ function summarizePi(document: unknown): { modelCount: number; modelsWithoutLimi return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; } +function summarizeOmp(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = (document as OmpGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; +} + function summarizeHermes(document: unknown): { modelCount: number; modelsWithoutLimits: number } { const models = (document as HermesGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; // Hermes carries selectors only; it has no per-model limit to be missing. @@ -900,6 +1044,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 = buildOmpClientConfig(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]); @@ -960,6 +1109,22 @@ export const EXPORT_CLIENTS: Record = { // admission header a remote bind requires. loopbackOnly: true, }, + omp: { + id: "omp", + // The download name uses the canonical `.yaml` extension; the real file + // oh-my-pi reads stays `~/.omp/agent/models.yml` (same split Gajae uses). + filename: "omp-models.yaml", + destination: env => ompConfigPath(env), + apiKeyEnv: OMP_API_KEY_ENV, + exportHint: `export ${OMP_API_KEY_ENV}=`, + build: buildOmpClientConfig, + format: "yaml", + summarize: summarizeOmp, + buildContribution: buildOmpContribution, + // omp's provider block has no header field for the dedicated admission + // header, so a remote bind is refused the same way Pi's is. + loopbackOnly: true, + }, hermes: { id: "hermes", filename: "hermes-config.yaml", diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index b4c086bdd..b16122fd8 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -18,6 +18,8 @@ import { hermesHomeDir, kimiConfigPath, kimiHomeDir, + ompConfigPath, + ompHomeDir, 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()) => ompConfigPath(env, home), + detectDir: (env = process.env, home = homedir()) => ompHomeDir(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 71c200574..ae4af40d9 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -9,7 +9,13 @@ * Bodies are unchanged from their previous home; only `export` was added. */ import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { + catalogModelSlug, + nativeDefaultReasoningEffort, + nativeModelRows, + nativeReasoningEfforts, + uniqueCatalogModelsForPublicList, +} from "../../codex/catalog"; import type { ExportModel } from "../../clients/config-export"; import { providerContextCap } from "../../providers/context-cap"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; @@ -41,14 +47,20 @@ export async function listManagementModelRows(config: OcxConfig): Promise ({ - provider: "openai", - id: row.slug, - namespaced: row.slug, - disabled: row.disabled, - native: true, - ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), - })); + const native: ManagementModelRow[] = nativeModelRows(config).map(row => { + const reasoningEfforts = nativeReasoningEfforts(row.slug); + const defaultReasoningEffort = nativeDefaultReasoningEffort(row.slug); + return { + provider: "openai", + id: row.slug, + namespaced: row.slug, + disabled: row.disabled, + native: true, + ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), + ...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}), + ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), + }; + }); const customModels: ManagementModelRow[] = (config.customModels ?? []).map(cm => { const namespaced = routedSlug(cm.provider, cm.modelId); return { @@ -98,6 +110,8 @@ export function toExportModel(row: ManagementModelRow): ExportModel { ...(row.displayName ? { displayName: row.displayName } : {}), ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}), + ...(row.reasoningEfforts ? { reasoningEfforts: row.reasoningEfforts } : {}), + ...(row.defaultReasoningEffort ? { defaultReasoningEffort: row.defaultReasoningEffort } : {}), }; } diff --git a/tests/cli-export-command.test.ts b/tests/cli-export-command.test.ts index 108dbcaa3..40df59a93 100644 --- a/tests/cli-export-command.test.ts +++ b/tests/cli-export-command.test.ts @@ -145,6 +145,13 @@ describe("ocx export human output (accept criterion 2)", () => { expect(result.stdout).toContain(join(".pi", "agent", "models.json")); expect(result.stdout).toContain("export OPENCODEX_API_KEY="); }); + + test("Oh My Pi names its own destination and env var", async () => { + const proxy = fakeProxy(); + const result = await run(["--client", "omp"], { baseUrl: proxy.baseUrl }); + expect(result.stdout).toContain(join(".omp", "agent", "models.yml")); + expect(result.stdout).toContain("export OPENCODEX_OMP_API_KEY="); + }); }); describe("ocx export --out (accept criterion 3)", () => { @@ -202,7 +209,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(""); @@ -322,4 +329,19 @@ describe("export row filtering", () => { expect(models[0]!.namespaced).toBe("a/two"); expect(models[0]!.inputModalities).toEqual(["text", "image"]); }); + + test("preserves reasoning metadata from /api/models rows", () => { + const [model] = exportModelsFromProxyRows([ + { + provider: "command-code", + id: "deepseek/deepseek-v4-flash", + namespaced: "command-code/deepseek-deepseek-v4-flash", + disabled: false, + reasoningEfforts: ["high", "max"], + defaultReasoningEffort: "high", + }, + ], config()); + expect(model?.reasoningEfforts).toEqual(["high", "max"]); + expect(model?.defaultReasoningEffort).toBe("high"); + }); }); diff --git a/tests/client-config-export-new-clients.test.ts b/tests/client-config-export-new-clients.test.ts index 9de47024f..7089b2480 100644 --- a/tests/client-config-export-new-clients.test.ts +++ b/tests/client-config-export-new-clients.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { EXPORT_CLIENTS, @@ -7,6 +9,9 @@ import { HERMES_API_KEY_ENV_REF, KIMI_LOOPBACK_PLACEHOLDER, OPENCLAW_API_KEY_ENV_REF, + ompAgentDir, + ompConfigPath, + ompHomeDir, OPENCODE_PROVIDER_ID, buildClientConfig, buildClientConfigText, @@ -22,6 +27,7 @@ import { type HermesGeneratedConfig, type KimiGeneratedConfig, type OpenclawGeneratedConfig, + type OmpGeneratedConfig, } from "../src/clients/config-export"; import type { OcxConfig } from "../src/types"; @@ -63,7 +69,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", () => { @@ -266,9 +272,98 @@ describe("gajae", () => { }); }); +describe("omp", () => { + test("with no override the agent config is ~/.omp/agent/models.yml", () => { + expect(ompHomeDir({}, "/home/u")).toBe(join("/home/u", ".omp")); + expect(ompAgentDir({}, "/home/u")).toBe(join("/home/u", ".omp", "agent")); + expect(ompConfigPath({}, "/home/u")).toBe(join("/home/u", ".omp", "agent", "models.yml")); + }); + + test("PI_CONFIG_DIR relocates the whole root, agent directory included", () => { + expect(ompConfigPath({ PI_CONFIG_DIR: "custom-omp" }, "/home/u")) + .toBe(join("/home/u", "custom-omp", "agent", "models.yml")); + }); + + test("an existing models.yaml is honored when models.yml is absent", () => { + const home = join(tmpdir(), `ocx-omp-${crypto.randomUUID()}`); + const agent = join(home, ".omp", "agent"); + mkdirSync(agent, { recursive: true }); + writeFileSync(join(agent, "models.yaml"), "providers: {}\n", "utf8"); + try { + expect(ompConfigPath({}, home)).toBe(join(agent, "models.yaml")); + writeFileSync(join(agent, "models.yml"), "providers: {}\n", "utf8"); + expect(ompConfigPath({}, home)).toBe(join(agent, "models.yml")); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("PI_CODING_AGENT_DIR overrides the agent directory outright", () => { + expect(ompAgentDir({ PI_CODING_AGENT_DIR: "/data/agent" }, "/home/u")).toBe("/data/agent"); + expect(ompConfigPath({ PI_CODING_AGENT_DIR: "/data/agent" }, "/home/u")) + .toBe(join("/data/agent", "models.yml")); + }); + + test("OMP_PROFILE wins over the agent-directory override, default stays unnamed", () => { + // omp's DirResolver drops the agent-dir override when a profile is active. + expect(ompAgentDir({ OMP_PROFILE: "work", PI_CODING_AGENT_DIR: "/data/agent" }, "/home/u")) + .toBe(join("/home/u", ".omp", "profiles", "work", "agent")); + // `default` is the unnamed profile, not a directory suffix. + expect(ompAgentDir({ OMP_PROFILE: "default" }, "/home/u")) + .toBe(join("/home/u", ".omp", "agent")); + }); + + test("PI_PROFILE is the legacy fallback for OMP_PROFILE", () => { + expect(ompAgentDir({ PI_PROFILE: "legacy" }, "/home/u")) + .toBe(join("/home/u", ".omp", "profiles", "legacy", "agent")); + }); + + test("a blank OMP_PROFILE is treated as unset so PI_PROFILE still applies", () => { + // omp's own precedence: OMP_PROFILE wins only when it names a profile; a + // trimmed blank value must fall through to the legacy PI_PROFILE. + expect(ompAgentDir({ OMP_PROFILE: " ", PI_PROFILE: "legacy" }, "/home/u")) + .toBe(join("/home/u", ".omp", "profiles", "legacy", "agent")); + }); + + test("a relative PI_CODING_AGENT_DIR is refused, not silently anchored", () => { + // A relative agent-dir override would resolve against each process's own + // cwd — opencodex and omp could disagree about which models.yml is live. + expect(() => ompAgentDir({ PI_CODING_AGENT_DIR: "relative/agent" }, "/home/u")) + .toThrow(/PI_CODING_AGENT_DIR/); + }); + + test("emits an omp thinking block when the model carries reasoning efforts", () => { + const doc = buildClientConfig("omp", { + baseUrl: "http://127.0.0.1:10100/v1", + models: [ + { namespaced: "command-code/deepseek-deepseek-v4-flash", provider: "command-code", id: "deepseek/deepseek-v4-flash", contextWindow: 1_000_000, reasoningEfforts: ["high", "max"], defaultReasoningEffort: "high" }, + // Kimi K3 is reasoning-capable in Command Code but has no effort + // selector, so no thinking block is emitted. + { namespaced: "command-code/moonshotai-Kimi-K3", provider: "command-code", id: "moonshotai/Kimi-K3", contextWindow: 1_000_000, reasoningEfforts: [] }, + // No reasoning metadata: the model stays plain so omp applies its own defaults. + { namespaced: "command-code/claude-fable-5", provider: "command-code", id: "claude-fable-5", contextWindow: 1_000_000 }, + ], + config: LOOPBACK, + }) as OmpGeneratedConfig; + const models = doc.providers[OPENCODE_PROVIDER_ID]!.models; + const deepseek = models.find(m => m.id === "command-code/deepseek-deepseek-v4-flash")!; + expect(deepseek.reasoning).toBe(true); + expect(deepseek.thinking).toEqual({ + mode: "effort", + efforts: ["high", "max"], + defaultLevel: "high", + }); + const kimi = models.find(m => m.id === "command-code/moonshotai-Kimi-K3")!; + expect(kimi.reasoning).toBeUndefined(); + expect(kimi.thinking).toBeUndefined(); + const fable = models.find(m => m.id === "command-code/claude-fable-5")!; + expect(fable.thinking).toBeUndefined(); + }); +}); + 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 8ab7faeab..817ed767c 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -8,6 +8,8 @@ import { OPENCODE_API_KEY_ENV_REF, PI_API_KEY_ENV, PI_API_KEY_ENV_REF, + OMP_API_KEY_ENV, + OMP_API_KEY_ENV_REF, SCHEMA_REQUIRED_OUTPUT_BUDGET, buildClientConfig, buildClientConfigText, @@ -17,6 +19,7 @@ import { type ExportModel, type OpencodeGeneratedConfig, type PiGeneratedConfig, + type OmpGeneratedConfig, } from "../src/clients/config-export"; import { buildOpencodeProviderBlockFromCatalog, opencodeGlobalConfigPath } from "../src/cli/opencode"; import type { OcxConfig } from "../src/types"; @@ -74,6 +77,10 @@ function piConfig(context: ExportContext = ctx()): PiGeneratedConfig { return buildClientConfig("pi", context) as PiGeneratedConfig; } +function ompConfig(context: ExportContext = ctx()): OmpGeneratedConfig { + return buildClientConfig("omp", context) as OmpGeneratedConfig; +} + 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"); @@ -282,8 +289,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); @@ -391,6 +398,40 @@ describe("EXPORT_CLIENTS registry", () => { `); }); + test("omp bytes are a yaml provider block for ~/.omp/agent/models.yml", () => { + const built = buildClientConfigText("omp", ctx({ config: cfg() })); + expect(built.format).toBe("yaml"); + expect(built.text).toBe(`providers: + opencodex: + baseUrl: "http://127.0.0.1:10100/v1" + api: openai-completions + apiKey: "$OPENCODEX_OMP_API_KEY" + models: + - id: anthropic/claude-opus-5 + name: "Claude Opus 5 (anthropic)" + input: + - text + contextWindow: 200000 + maxTokens: 32000 + - id: custom/no-context + name: "no-context (custom)" + input: + - text + - id: gpt-5.6-luna + name: "gpt-5.6-luna (native)" + input: + - text + contextWindow: 272000 + maxTokens: 32000 + - id: tiny/small-ctx + name: "small-ctx (tiny)" + input: + - text + contextWindow: 8000 + maxTokens: 8000 +`); + }); + test("every spec declares a format, a summarizer and a contribution", () => { for (const id of EXPORT_CLIENT_IDS) { const spec = EXPORT_CLIENTS[id]; @@ -428,6 +469,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 +488,17 @@ describe("EXPORT_CLIENTS registry", () => { expect(EXPORT_CLIENTS.pi.destination({} as NodeJS.ProcessEnv)).toBe(join(homedir(), ".pi", "agent", "models.json")); }); + test("the omp destination is the agent models.yml oh-my-pi 17 reads", () => { + expect(EXPORT_CLIENTS.omp.destination({} as NodeJS.ProcessEnv)).toBe(join(homedir(), ".omp", "agent", "models.yml")); + }); + 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); + expect(EXPORT_CLIENTS.omp.apiKeyEnv).toBe(OMP_API_KEY_ENV); + expect(EXPORT_CLIENTS.omp.exportHint).toContain(OMP_API_KEY_ENV); for (const id of EXPORT_CLIENT_IDS) { expect(EXPORT_CLIENTS[id].exportHint).not.toContain("ocx_"); } @@ -466,5 +514,6 @@ describe("EXPORT_CLIENTS registry", () => { const empty = ctx({ models: [] }); expect(opencodeConfig(empty).provider.opencodex!.models).toEqual({}); expect(piConfig(empty).providers.opencodex!.models).toEqual([]); + expect(ompConfig(empty).providers.opencodex!.models).toEqual([]); }); }); diff --git a/tests/command-code-provider.test.ts b/tests/command-code-provider.test.ts index 4220cbcbd..997548843 100644 --- a/tests/command-code-provider.test.ts +++ b/tests/command-code-provider.test.ts @@ -212,6 +212,10 @@ describe("Command Code provider", () => { // Legacy compatibility id resolves to the canonical effort table before the lookup. const legacy = await builtRequest({ ...parsed(), modelId: "deepseek-v4-flash" }); expect(JSON.parse(legacy.body).params.reasoning_effort).toBe("high"); + // omp dials with the namespaced selector (inner "/" encoded as "-"); the + // effort lookup must decode it back against the official table. + const ompNamespaced = await builtRequest({ ...parsed(), modelId: "command-code/deepseek-deepseek-v4-flash" }); + expect(JSON.parse(ompNamespaced.body).params.reasoning_effort).toBe("high"); }); test("filters tool declarations when tool_choice disables tools", async () => { diff --git a/tests/integrations-invariants.test.ts b/tests/integrations-invariants.test.ts index 4178b908b..dae3eabc6 100644 --- a/tests/integrations-invariants.test.ts +++ b/tests/integrations-invariants.test.ts @@ -66,7 +66,7 @@ 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 * hand: the GUI cannot import the backend registry, because that would @@ -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', diff --git a/tests/integrations-state.test.ts b/tests/integrations-state.test.ts index 8ef851018..d1ebb00ad 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("pi, omp, 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 c5fa46912..a3c5b2411 100644 --- a/tests/management-client-config-route.test.ts +++ b/tests/management-client-config-route.test.ts @@ -4,6 +4,8 @@ import { OPENCODE_API_KEY_ENV, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, + OMP_API_KEY_ENV, + OMP_API_KEY_ENV_REF, PI_API_KEY_ENV, PI_API_KEY_ENV_REF, buildClientConfig, @@ -12,6 +14,7 @@ import { type ExportModel, type OpencodeGeneratedConfig, type PiGeneratedConfig, + type OmpGeneratedConfig, } from "../src/clients/config-export"; import type { OcxConfig } from "../src/types"; import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; @@ -42,6 +45,8 @@ interface ModelRow { displayName?: string; contextWindow?: number; inputModalities?: string[]; + reasoningEfforts?: string[]; + defaultReasoningEffort?: string; } /** @@ -108,6 +113,8 @@ function toExportModel(row: ModelRow): ExportModel { ...(row.displayName ? { displayName: row.displayName } : {}), ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}), + ...(row.reasoningEfforts ? { reasoningEfforts: row.reasoningEfforts } : {}), + ...(row.defaultReasoningEffort ? { defaultReasoningEffort: row.defaultReasoningEffort } : {}), }; } @@ -158,6 +165,38 @@ describe("GET /api/client-config", () => { expect(provider.models.map(model => model.id)).toContain("a/m1"); }, 15_000); + test("omp returns the oh-my-pi models.yml provider block", 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.apiKeyEnv).toBe(OMP_API_KEY_ENV); + expect(body.format).toBe("yaml"); + + const provider = (body.config as OmpGeneratedConfig).providers[OPENCODE_PROVIDER_ID]; + expect(Array.isArray(provider.models)).toBe(true); + expect(provider.api).toBe("openai-completions"); + expect(provider.apiKey).toBe(OMP_API_KEY_ENV_REF); + expect(provider.baseUrl).toBe("http://127.0.0.1:10100/v1"); + expect(provider.models.map(model => model.id)).toContain("a/m1"); + }, 15_000); + + test("native model rows carry reasoning metadata into omp exports", async () => { + const rows = await modelRows(baseConfig()); + const native = rows.find(row => row.native && (row.reasoningEfforts?.length ?? 0) > 0); + expect(native).toBeDefined(); + const doc = buildClientConfig("omp", { + baseUrl: "http://127.0.0.1:10100/v1", + models: [toExportModel(native!)], + config: baseConfig(), + }) as OmpGeneratedConfig; + const exported = doc.providers[OPENCODE_PROVIDER_ID]!.models[0]!; + expect(exported.reasoning).toBe(true); + expect(exported.thinking?.efforts.length).toBeGreaterThan(0); + }, 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;