diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 1485d49b2c..28d377f41d 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -266,11 +266,38 @@ name. ### External provider managers -If `config.toml` already selects a provider other than `openai` or `opencodex`, OpenCodex leaves the -file unchanged and skips profile writes, catalog/cache refresh, and both immediate and background -Codex history migration. Tools that manage a custom provider often tag existing sessions with that -provider id; replacing the active id can make those intact sessions disappear from Codex's history -view. The same protection applies to an external provider selected by a legacy root profile. +In the default `full` integration mode, if `config.toml` already selects a provider other than +`openai` or `opencodex`, OpenCodex leaves the file unchanged and skips profile writes, catalog/cache +refresh, and both immediate and background Codex history migration. Tools that manage a custom +provider often tag existing sessions with that provider id; replacing the active id can make those +intact sessions disappear from Codex's history view. The same protection applies to an external +provider selected by a legacy root profile. + +Use `catalog-only` when the external provider id must remain stable but OpenCodex should populate the +standard Codex model picker: + +```bash +ocx config set clientIntegrations.codex catalog-only +ocx sync +``` + +```toml +model = "gpt-5.6-sol" +model_provider = "global-infra" +model_catalog_json = "/absolute/path/to/.codex/opencodex-catalog.json" + +[model_providers.global-infra] +name = "global-infra" +base_url = "http://127.0.0.1:10100/v1" +wire_api = "responses" +``` + +In this mode OpenCodex updates the catalog selected by `model_catalog_json` and +`$CODEX_HOME/models_cache.json`, then returns before config injection. It does not change +`config.toml`, `opencodex.config.toml`, the OpenCodex journal, history databases, or session JSONL +files. After configuring a provider such as DeepSeek, run `ocx sync`; generated routes such as +`deepseek/deepseek-v4-flash` and `deepseek/deepseek-v4-pro` then enter the picker without changing +`model_provider = "global-infra"`. Keep one tool as the owner of Codex provider configuration. To use OpenCodex behind an existing provider manager, point that provider at `http://127.0.0.1:10100/v1` with Responses passthrough diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index ced7a0a112..77d521f983 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -182,7 +182,9 @@ ocx sync-cache ### 外部プロバイダーマネージャー -`config.toml` がすでに `openai` または `opencodex` 以外のプロバイダーを選択している場合、OpenCodex はファイルを変更しないままにし、プロファイルの書き込み、カタログ/キャッシュの更新、および即時およびバックグラウンドの両方の Codex 履歴の移行をスキップします。カスタム プロバイダーを管理するツールは、多くの場合、既存のセッションにそのプロバイダー ID をタグ付けします。アクティブな ID を置き換えると、それらの無傷のセッションが Codex の履歴ビューから消える可能性があります。同じ保護が、レガシー ルート プロファイルによって選択された外部プロバイダーにも適用されます。 +デフォルトの `full` モードで `config.toml` がすでに `openai` または `opencodex` 以外のプロバイダーを選択している場合、OpenCodex はファイルを変更しないままにし、プロファイルの書き込み、カタログ/キャッシュの更新、および即時およびバックグラウンドの両方の Codex 履歴の移行をスキップします。カスタム プロバイダーを管理するツールは、多くの場合、既存のセッションにそのプロバイダー ID をタグ付けします。アクティブな ID を置き換えると、それらの無傷のセッションが Codex の履歴ビューから消える可能性があります。同じ保護が、レガシー ルート プロファイルによって選択された外部プロバイダーにも適用されます。 + +外部 provider id を維持しながら標準モデル選択を更新するには、`ocx config set clientIntegrations.codex catalog-only` を実行してから `ocx sync` を実行します。このモードは `model_catalog_json` のカタログと `models_cache.json` だけを更新し、`config.toml`、`opencodex.config.toml`、journal、履歴データベース、session JSONL を変更しません。外部 provider は Responses passthrough (`wire_api = "responses"`) でローカルプロキシを参照させてください。 1 つのツールを Codex プロバイダー設定の所有者として保持します。既存のプロバイダー マネージャーの背後で OpenCodex を使用するには、チャット完了変換ではなく、応答パススルー (Codex TOML では `wire_api = "responses"`) を使用して、そのプロバイダーを `http://127.0.0.1:10100/v1` に指定します。プロキシ API 認証が有効な場合は、上記の非ループバック プロバイダー フォームと一致して、`OPENCODEX_API_AUTH_TOKEN` から `x-opencodex-api-key` も渡します。 OpenCodex にルーティングを直接挿入させるには、まず Codex を組み込みの `openai` プロバイダーに戻し、ユーザー所有のルート `openai_base_url` を削除してから、`ocx start` を再実行します。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index b1a15ea4c2..b321737787 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -172,7 +172,9 @@ ocx sync-cache ### 외부 provider manager -`config.toml`이 이미 `openai`나 `opencodex`가 아닌 provider를 선택하고 있으면, OpenCodex는 그 파일을 그대로 두고 profile write, catalog/cache refresh, 즉시 및 background Codex history migration을 건너뜁니다. custom provider를 관리하는 도구는 기존 session에 그 provider id를 붙이는 경우가 많고, 활성 id를 바꾸면 그 온전한 session이 Codex의 history view에서 사라질 수 있습니다. 이 보호는 legacy root profile이 선택한 외부 provider에도 동일하게 적용됩니다. +기본 `full` 모드에서 `config.toml`이 이미 `openai`나 `opencodex`가 아닌 provider를 선택하고 있으면, OpenCodex는 그 파일을 그대로 두고 profile write, catalog/cache refresh, 즉시 및 background Codex history migration을 건너뜁니다. custom provider를 관리하는 도구는 기존 session에 그 provider id를 붙이는 경우가 많고, 활성 id를 바꾸면 그 온전한 session이 Codex의 history view에서 사라질 수 있습니다. 이 보호는 legacy root profile이 선택한 외부 provider에도 동일하게 적용됩니다. + +외부 provider id를 유지하면서 표준 모델 선택기를 갱신하려면 `ocx config set clientIntegrations.codex catalog-only`를 실행한 다음 `ocx sync`를 실행하세요. 이 모드는 `model_catalog_json`의 catalog와 `models_cache.json`만 갱신하며 `config.toml`, `opencodex.config.toml`, journal, history database 또는 session JSONL을 변경하지 않습니다. 외부 provider는 Responses passthrough(`wire_api = "responses"`)로 로컬 proxy를 가리켜야 합니다. Codex provider configuration의 소유자는 한 도구만 맡게 하세요. 기존 provider manager 뒤에서 OpenCodex를 쓰려면, 그 provider를 `http://127.0.0.1:10100/v1`로 향하게 하고 Responses passthrough를 쓰세요(`wire_api = "responses"` in Codex TOML). Chat Completions translation은 쓰지 않습니다. proxy API auth가 켜져 있으면, 위의 non-loopback provider 형식과 맞추어 `OPENCODEX_API_AUTH_TOKEN`에서 `x-opencodex-api-key`도 함께 전달하세요. OpenCodex가 routing을 직접 주입하게 하려면 먼저 Codex를 built-in `openai` provider로 되돌리고, 사용자가 소유한 root `openai_base_url`을 지운 다음, `ocx start`를 다시 실행하세요. diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 10844a5da5..c2ed734eb8 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -37,6 +37,28 @@ resolve that variable at request time. For outbound proxying, an already-set `HT Routing has its own ordered resolution rules; see [Routing](/reference/configuration/routing/). +## Codex integration mode + +`clientIntegrations.codex` controls how much Codex state OpenCodex owns: + +| Value | Mode | Behavior | +| --- | --- | --- | +| missing or `true` | `full` | Manage routing, catalog, cache, and compatibility artifacts. | +| `"catalog-only"` | `catalog-only` | Refresh catalog/cache while preserving provider routing and Codex history. | +| `false` | `off` | Disable Codex integration. | + +```json +{ + "clientIntegrations": { + "codex": "catalog-only" + } +} +``` + +The dashboard exposes the same three modes under **Integrations → Codex CLI**. See +[Codex Integration](/guides/codex-integration/#external-provider-managers) for the external-provider +ownership boundary. + ## Configuration domains - [Providers](/reference/configuration/providers/) — provider entries, authentication, endpoints, diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 118c663870..dfddaa6dd6 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -269,13 +269,20 @@ upstream snapshot и никогда не перекрываются пользо ### Внешние provider manager'ы -Если `config.toml` уже выбирает провайдера, отличного от `openai` или `opencodex`, OpenCodex +Если в режиме `full` (по умолчанию) `config.toml` уже выбирает провайдера, отличного от `openai` или `opencodex`, OpenCodex оставляет файл без изменений и пропускает запись profile, обновление catalog/cache и как немедленную, так и фоновую миграцию истории Codex. Инструменты, управляющие custom-провайдером, часто помечают существующие сессии своим provider id; замена активного id может привести к тому, что рабочие сессии просто исчезнут из history view Codex. Та же защита действует и для внешнего провайдера, выбранного через legacy root profile. +Чтобы сохранить внешний provider id и при этом обновлять стандартный picker моделей, выполните +`ocx config set clientIntegrations.codex catalog-only`, затем `ocx sync`. В этом режиме меняются +только каталог из `model_catalog_json` и `models_cache.json`; `config.toml`, +`opencodex.config.toml`, journal, базы истории и session JSONL остаются без изменений. Внешний +провайдер должен указывать на локальный прокси через Responses passthrough +(`wire_api = "responses"`). + Держите владельцем конфигурации провайдера Codex только один инструмент. Если вы хотите использовать OpenCodex позади уже существующего provider manager'а, направьте этот провайдер на `http://127.0.0.1:10100/v1` с passthrough Responses (`wire_api = "responses"` в TOML Codex), а diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index ef693616f6..a12b0ed91a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -234,11 +234,36 @@ display name 是 **仅用于显示且在重新生成时保持稳定的**。每 ### 外部 provider 管理器 -如果 `config.toml` 已经选择了 `openai` 或 `opencodex` 之外的 provider,OpenCodex 会保持文件不变, -并跳过 profile 写入、catalog/cache 刷新,以及立即和后台两种 Codex 历史迁移。管理自定义 provider 的工具 +在默认的 `full` 集成模式下,如果 `config.toml` 已经选择了 `openai` 或 `opencodex` 之外的 provider, +OpenCodex 会保持文件不变,并跳过 profile 写入、catalog/cache 刷新,以及立即和后台两种 Codex 历史迁移。管理自定义 provider 的工具 通常会把现有会话标记为那个 provider id;如果替换活动 id,Codex 历史视图里那些完整会话可能会消失。 同样的保护也适用于由旧版 root profile 选择的外部 provider。 +如果必须保持外部 provider id 不变,同时希望 OpenCodex 填充标准 Codex 模型选择器,请使用 +`catalog-only`: + +```bash +ocx config set clientIntegrations.codex catalog-only +ocx sync +``` + +```toml +model = "gpt-5.6-sol" +model_provider = "global-infra" +model_catalog_json = "/absolute/path/to/.codex/opencodex-catalog.json" + +[model_providers.global-infra] +name = "global-infra" +base_url = "http://127.0.0.1:10100/v1" +wire_api = "responses" +``` + +在此模式下,OpenCodex 会更新 `model_catalog_json` 指向的目录和 +`$CODEX_HOME/models_cache.json`,随后在配置注入前返回。它不会修改 `config.toml`、 +`opencodex.config.toml`、OpenCodex journal、历史数据库或 session JSONL 文件。配置 DeepSeek +等 provider 后运行 `ocx sync`,`deepseek/deepseek-v4-flash`、`deepseek/deepseek-v4-pro` +等生成路由就会进入模型选择器,同时保持 `model_provider = "global-infra"` 不变。 + 只保留一个工具作为 Codex provider 配置的 owner。若要在现有 provider 管理器之后使用 OpenCodex, 请把那个 provider 指向 `http://127.0.0.1:10100/v1`,并使用 Responses passthrough(Codex TOML 中的 `wire_api = "responses"`),而不是 Chat Completions translation。当启用 proxy API auth 时,也要像上面的非 loopback diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 66327ddc41..d036815b84 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -34,6 +34,27 @@ opencodex 会把持久化配置存放在 `$OPENCODEX_HOME/config.json`,通常 路由有自己独立的顺序化解析规则;见 [Routing](/reference/configuration/routing/)。 +## Codex 集成模式 + +`clientIntegrations.codex` 控制 OpenCodex 对 Codex 状态的管理范围: + +| 值 | 模式 | 行为 | +| --- | --- | --- | +| 缺失或 `true` | `full` | 管理路由、目录、缓存和兼容性文件。 | +| `"catalog-only"` | `catalog-only` | 刷新目录/缓存,同时保留 provider 路由和 Codex 历史。 | +| `false` | `off` | 关闭 Codex 集成。 | + +```json +{ + "clientIntegrations": { + "codex": "catalog-only" + } +} +``` + +仪表盘的 **Integrations → Codex CLI** 提供相同的三种模式。外部 provider 的所有权边界见 +[Codex 集成](/zh-cn/guides/codex-integration/#外部-provider-管理器)。 + ## 配置域 - [Providers](/reference/configuration/providers/) — provider 条目、认证、端点、目录、allowlist、上下文限制、配额和 provider 特定选项。 diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index a8bbab8bfd..3c5d8e7410 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -26,6 +26,7 @@ export const FORWARD_HEADERS = [ "session_id", "session-id", "thread-id", + "user-agent", "x-client-request-id", "x-codex-beta-features", "x-codex-installation-id", @@ -1464,6 +1465,19 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): } if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`; if (provider.headers) Object.assign(headers, provider.headers); + // Forward client identity headers to the upstream so providers that + // enforce client validation (e.g. codex_only User-Agent checks) can + // inspect the original caller metadata. Authorization and + // chatgpt-account-id are deliberately excluded — API key mode uses + // the configured apiKey and has no ChatGPT account binding. + // The defensive name-based guard also blocks any future FORWARD_HEADERS + // entry whose name contains "key", "token", or "secret". + for (const h of FORWARD_HEADERS) { + if (h === "authorization" || h === "chatgpt-account-id") continue; + if (/key|token|secret/i.test(h)) continue; + const v = incoming?.headers.get(h); + if (v) headers[h] = v; + } } const forward = provider.authMode === "forward"; diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 7197391e1f..036efe1b7c 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -37,6 +37,7 @@ import type { import { visibleNativeSlugs } from "../codex/catalog"; import { commandInvocation } from "../lib/win-exec"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; +import { configuredAdminToken } from "../lib/admin-secrets"; import { providerCodexAccountMode } from "../providers/registry"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; @@ -85,6 +86,7 @@ export interface OpencodeProxyModelRow { disabled?: boolean; displayName?: string; contextWindow?: number; + inputModalities?: string[]; } const PROJECT_CONFIG_FILENAMES = ["opencode.json", "opencode.jsonc"] as const; @@ -315,6 +317,9 @@ export function opencodeCatalogFromProxyRows( id: row.id, contextWindow: row.contextWindow, displayName: row.displayName, + ...(Array.isArray(row.inputModalities) && row.inputModalities.length > 0 + ? { inputModalities: row.inputModalities } + : {}), }); } return catalog; @@ -529,6 +534,14 @@ export function opencodeNotFoundHint( return platform === "win32" && code === 9009 && !signal ? OPENCODE_INSTALL_HINT : null; } +export function requireOpencodeManagementToken( + readConfiguredAdminToken: () => string | null = configuredAdminToken, +): string { + const token = readConfiguredAdminToken()?.trim(); + if (token) return token; + throw new Error("opencodex admin token is not configured."); +} + export async function cmdOpencode(args: string[]): Promise { const config = loadConfig(); const live = await ensureProxyForOpencode(config); @@ -538,9 +551,20 @@ export async function cmdOpencode(args: string[]): Promise { } const apiKey = opencodeApiKey(config); + // `/api/models` is a management endpoint — it requires the admin token, not the + // data-plane admission key. The proxy is already running at this point so + // configuredAdminToken() finds the same token the server initialized with. + let managementToken: string; + try { + managementToken = requireOpencodeManagementToken(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); + return 1; + } let proxyModels: OpencodeProxyModelRow[]; try { - proxyModels = await fetchOpencodeProxyModels(live, apiKey); + proxyModels = await fetchOpencodeProxyModels(live, managementToken); } catch (error) { const reason = error instanceof Error ? error.message : String(error); console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 47f883159d..e11dc55863 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -64,11 +64,20 @@ export interface OpencodeCatalogModel { id?: string; contextWindow?: number; displayName?: string; + /** Input modalities declared by the model (e.g. `["text","image"]`). */ + inputModalities?: string[]; } export interface OpencodeModelEntry { name: string; limit?: { context: number; output: number }; + /** + * Opencode v1 modality declaration. Only emitted when the catalog row + * carries at least one declared modality, so text-only models (which + * have no stored `inputModalities`) do not emit the block and opencode + * keeps its own defaults. + */ + modalities?: { input: string[] }; } export interface OpencodeProviderBlock { @@ -642,6 +651,9 @@ function opencodeProviderBlock( if (context !== undefined) { entry.limit = { context, output: outputBudgetFor(context) }; } + if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) { + entry.modalities = { input: [...model.inputModalities] }; + } models[key] = entry; } return { diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index 5a7ef81b82..8999cc720c 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -154,6 +154,21 @@ describe("relocated OpenCode serializer (accept criterion 1)", () => { expect(entry.limit).toBeUndefined(); } }); + + test("modalities.input is emitted when inputModalities is non-empty, omitted otherwise", () => { + const block = buildOpencodeProviderBlockFromCatalog(10100, [ + { namespaced: "p/vision", provider: "p", id: "vision", inputModalities: ["text", "image"] }, + { namespaced: "p/audio", provider: "p", id: "audio", inputModalities: ["text", "audio"] }, + { namespaced: "p/text-only", provider: "p", id: "text-only" }, + { namespaced: "p/empty-arr", provider: "p", id: "empty-arr", inputModalities: [] }, + ], "127.0.0.1"); + expect(block.models["p/vision"]?.modalities).toEqual({ input: ["text", "image"] }); + expect(block.models["p/audio"]?.modalities).toEqual({ input: ["text", "audio"] }); + // No inputModalities → modalities block is omitted; opencode keeps its own defaults + expect(block.models["p/text-only"]?.modalities).toBeUndefined(); + // Empty array is treated as absent — no modalities block emitted + expect(block.models["p/empty-arr"]?.modalities).toBeUndefined(); + }); }); describe("Pi serializer (accept criterion 2)", () => { diff --git a/tests/codex-metadata-integrity.test.ts b/tests/codex-metadata-integrity.test.ts index 4182770d38..2e9e005089 100644 --- a/tests/codex-metadata-integrity.test.ts +++ b/tests/codex-metadata-integrity.test.ts @@ -38,6 +38,7 @@ describe("Codex metadata integrity", () => { "session_id", "session-id", "thread-id", + "user-agent", "chatgpt-account-id", "x-codex-parent-thread-id", ]) { @@ -160,4 +161,61 @@ describe("Codex metadata integrity", () => { expect(sync.headers.session_id).toBe("sess-real-2"); expect(sync.headers["thread-id"]).toBe("thread-real-2"); }); + + test("adapter API key mode forwards User-Agent", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://upstream.test/v1", + apiKey: "sk-test-key", + }; + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest(minimalParsed(), { + headers: new Headers({ + "user-agent": "codex_cli_rs/0.147.0", + originator: "codex_cli_rs", + }), + }); + const sync = request as { headers: Record }; + expect(sync.headers["user-agent"]).toBe("codex_cli_rs/0.147.0"); + expect(sync.headers.originator).toBe("codex_cli_rs"); + }); + + test("adapter API key mode does not forward authorization", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://upstream.test/v1", + apiKey: "sk-test-key", + }; + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest(minimalParsed(), { + headers: new Headers({ + authorization: "incoming-caller-token", + originator: "codex_cli_rs", + }), + }); + const sync = request as { headers: Record }; + // The incoming lowercase authorization must not be forwarded. + expect(sync.headers["authorization"]).toBeUndefined(); + // The api-key-derived Authorization header is still present. + expect(sync.headers["Authorization"]).toContain("sk-test-key"); + expect(sync.headers.originator).toBe("codex_cli_rs"); + }); + + test("adapter API key mode does not forward chatgpt-account-id", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://upstream.test/v1", + apiKey: "sk-test-key", + }; + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest(minimalParsed(), { + headers: new Headers({ + "chatgpt-account-id": "acct-should-not-leak", + originator: "codex_cli_rs", + }), + }); + const sync = request as { headers: Record }; + expect(sync.headers["chatgpt-account-id"]).toBeUndefined(); + expect(sync.headers.originator).toBe("codex_cli_rs"); + }); }); diff --git a/tests/opencode-cli.test.ts b/tests/opencode-cli.test.ts index 66a50cd6b3..c1b6b76f74 100644 --- a/tests/opencode-cli.test.ts +++ b/tests/opencode-cli.test.ts @@ -20,6 +20,7 @@ import { opencodeApiKey, opencodeCatalogFromProxyRows, opencodeGlobalConfigPath, + requireOpencodeManagementToken, opencodeLaunchNativeSlugs, opencodeModelKey, opencodeNotFoundHint, @@ -366,6 +367,43 @@ describe("ocx opencode proxy model catalog", () => { "kiro/glm-5", ]); }); + + test("opencodeCatalogFromProxyRows propagates inputModalities from proxy rows", () => { + const config = cfg(); + const rows = [ + { namespaced: "p/vision", provider: "p", id: "vision", disabled: false, inputModalities: ["text", "image"] }, + { namespaced: "p/audio", provider: "p", id: "audio", disabled: false, inputModalities: ["text", "audio"] }, + { namespaced: "p/text", provider: "p", id: "text", disabled: false }, + { namespaced: "p/empty-arr", provider: "p", id: "empty-arr", disabled: false, inputModalities: [] }, + ]; + const catalog = opencodeCatalogFromProxyRows(rows, config); + + const vision = catalog.find(m => m.namespaced === "p/vision")!; + expect(vision.inputModalities).toEqual(["text", "image"]); + + const audio = catalog.find(m => m.namespaced === "p/audio")!; + expect(audio.inputModalities).toEqual(["text", "audio"]); + + // No inputModalities field → absent in catalog (not replaced with []) + const text = catalog.find(m => m.namespaced === "p/text")!; + expect(text.inputModalities).toBeUndefined(); + + // Empty array → treated the same as absent + const emptyArr = catalog.find(m => m.namespaced === "p/empty-arr")!; + expect(emptyArr.inputModalities).toBeUndefined(); + }); + + test("buildOpencodeProviderBlockFromCatalog emits modalities.input for multimodal models", () => { + const block = buildOpencodeProviderBlockFromCatalog(10100, [ + { namespaced: "p/vision", provider: "p", id: "vision", inputModalities: ["text", "image"] }, + { namespaced: "p/audio", provider: "p", id: "audio", inputModalities: ["text", "audio"] }, + { namespaced: "p/text-only", provider: "p", id: "text-only" }, + ]); + expect(block.models["p/vision"]?.modalities).toEqual({ input: ["text", "image"] }); + expect(block.models["p/audio"]?.modalities).toEqual({ input: ["text", "audio"] }); + // Text-only model: no modalities block emitted + expect(block.models["p/text-only"]?.modalities).toBeUndefined(); + }); }); describe("ocx opencode native slug selection", () => { @@ -522,6 +560,22 @@ describe("ocx opencode admission key", () => { }); }); +describe("ocx opencode management token", () => { + test("returns the configured admin token for management API calls", () => { + expect(requireOpencodeManagementToken(() => "ocx_admin_token")).toBe("ocx_admin_token"); + expect(requireOpencodeManagementToken(() => " ocx_admin_token ")).toBe("ocx_admin_token"); + }); + + test("fails early when the admin token is unavailable", () => { + expect(() => requireOpencodeManagementToken(() => null)).toThrow( + "opencodex admin token is not configured.", + ); + expect(() => requireOpencodeManagementToken(() => " ")).toThrow( + "opencodex admin token is not configured.", + ); + }); +}); + describe("ocx opencode proxy auto-start env", () => { test("passes OCX_API_TOKEN_FILE to ocx start when only the hardened service token exists", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-start-"));