From 5ced04dc0922760d5cb89963746112005bf9fa70 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 1 Sep 2026 10:31:50 +0000 Subject: [PATCH] fix(export): preserve Hermes vision capabilities --- src/clients/config-export.ts | 21 ++++++++++++++----- structure/09_client-integrations.md | 17 +++++++++++++++ tests/cli-export-command.test.ts | 11 ++++++++-- .../client-config-export-new-clients.test.ts | 18 +++++++++++++--- tests/client-config-new-clients.test.ts | 10 ++++++--- tests/client-export-modality-enum.test.ts | 17 +++++++++++++++ tests/management-client-config-route.test.ts | 16 ++++++++++++++ 7 files changed, 97 insertions(+), 13 deletions(-) diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 1ed156c425..50d6a201f1 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -1034,10 +1034,15 @@ export interface HermesProviderBlock { api_mode: "chat_completions"; /** We supply the list, so skip their live `/models` probe. */ discover_models: false; - models: string[]; + models: Record; extra_headers?: Record; } +/** Capability metadata Hermes cannot discover for a custom local provider. */ +export interface HermesModelEntry { + supports_vision?: boolean; +} + export interface HermesGeneratedConfig { providers: Record; } @@ -1311,7 +1316,13 @@ function proxyAdmissionHeaders(config: OcxConfig | undefined, envRef: string): R } function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig { - const models = normalizeExportModels(ctx.models).map(model => model.namespaced); + const models: Record = {}; + for (const model of normalizeExportModels(ctx.models)) { + const declared = model.inputModalities; + models[model.namespaced] = declared && declared.length > 0 + ? { supports_vision: declared.includes("image") } + : {}; + } const headers = proxyAdmissionHeaders(ctx.config, HERMES_API_KEY_ENV_REF); return { providers: { @@ -1606,9 +1617,9 @@ function summarizeOmp(document: unknown): { modelCount: number; modelsWithoutLim } 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. - return { modelCount: models.length, modelsWithoutLimits: 0 }; + const models = (document as HermesGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? {}; + // Hermes carries capability metadata but no per-model limit to be missing. + return { modelCount: Object.keys(models).length, modelsWithoutLimits: 0 }; } function summarizeOpenclaw(document: unknown): { modelCount: number; modelsWithoutLimits: number } { diff --git a/structure/09_client-integrations.md b/structure/09_client-integrations.md index 42f0f0df3a..728c93d195 100644 --- a/structure/09_client-integrations.md +++ b/structure/09_client-integrations.md @@ -36,6 +36,23 @@ Status and mutation must use the same classifier. A special case added only to a would be misleading because refresh or disable could still reject the same file; a special case added only to a writer would let a mutation bypass the state users saw. +## Hermes Model Capabilities + +Hermes cannot infer custom-provider capabilities from its built-in registry. The OpenCodex +provider therefore emits `models` as a mapping keyed by the canonical namespaced selector. An +explicit catalog modality list containing `image` becomes `supports_vision: true`; an explicit, +non-empty list without `image` becomes `false`; an absent or empty modality list keeps an empty +model object so Hermes receives no guessed capability. OpenCodex does not emit `supports_video` +because its authoritative input-modality vocabulary currently has no video value. + +[Decision Log] +- 목적과 의도: Preserve catalog-backed image routing when Hermes uses OpenCodex as a custom provider. +- 기존 구현 및 제약 조건: A string array preserved model selection but normalized to empty metadata in Hermes, while OpenCodex has authoritative text/image/audio facts but no video fact. +- 검토한 주요 대안: Keep the array; mark every model vision-capable; infer video from model names; emit a per-model metadata map from declared modalities. +- 선택한 방식: Emit a stable per-model map and include only the `supports_vision` boolean that the catalog can prove. +- 다른 대안 대신 이 방식을 선택한 이유: The map is the Hermes-supported capability boundary, while guesses would misroute attachments or advertise unsupported video. +- 장점, 단점 및 영향: Vision-capable custom models route correctly and text-only rows stay explicit; unknown rows remain unknown, and video routing waits for authoritative source metadata. + ## Ownership Axes `fileFingerprint` records the exact whole-file result for restore and for serializers that may lose diff --git a/tests/cli-export-command.test.ts b/tests/cli-export-command.test.ts index 0498a4df40..8aec793e1d 100644 --- a/tests/cli-export-command.test.ts +++ b/tests/cli-export-command.test.ts @@ -42,10 +42,11 @@ const ROWS = [ native: true, disabled: false, contextWindow: 272_000, + inputModalities: ["text", "image"], reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], defaultReasoningEffort: "high", }, - { provider: "anthropic", id: "claude-opus-5", namespaced: "anthropic/claude-opus-5", disabled: false, contextWindow: 200_000, displayName: "Claude Opus 5" }, + { provider: "anthropic", id: "claude-opus-5", namespaced: "anthropic/claude-opus-5", disabled: false, contextWindow: 200_000, displayName: "Claude Opus 5", inputModalities: ["text"] }, { provider: "custom", id: "no-context", namespaced: "custom/no-context", disabled: false }, { provider: "banned", id: "hidden", namespaced: "banned/hidden", disabled: true, contextWindow: 100_000 }, ]; @@ -308,7 +309,13 @@ describe("ocx export argument validation (accept criterion 4)", () => { expect(yaml.code).toBe(0); const yamlText = readFileSync(yamlTarget, "utf8"); expect(yamlText.startsWith("providers:")).toBe(true); - expect(Bun.YAML.parse(yamlText)).toHaveProperty("providers.opencodex"); + const parsedYaml = Bun.YAML.parse(yamlText) as { + providers: { opencodex: { models: Record } }; + }; + expect(parsedYaml).toHaveProperty("providers.opencodex"); + expect(parsedYaml.providers.opencodex.models["gpt-5.6-luna"]).toEqual({ supports_vision: true }); + expect(parsedYaml.providers.opencodex.models["anthropic/claude-opus-5"]).toEqual({ supports_vision: false }); + expect(parsedYaml.providers.opencodex.models["custom/no-context"]).toEqual({}); const tomlTarget = join(tempDir(), "kimi-config.toml"); const toml = await run(["--client", "kimi", "--out", tomlTarget], { baseUrl: proxy.baseUrl }); diff --git a/tests/client-config-export-new-clients.test.ts b/tests/client-config-export-new-clients.test.ts index 4444547aa7..81cd5379b6 100644 --- a/tests/client-config-export-new-clients.test.ts +++ b/tests/client-config-export-new-clients.test.ts @@ -47,8 +47,8 @@ const LOOPBACK: OcxConfig = { const REMOTE: OcxConfig = { ...LOOPBACK, hostname: "0.0.0.0" } as OcxConfig; const MODELS: ExportModel[] = [ - { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000, displayName: "Claude Opus 4.8" }, - { namespaced: "gpt-5.5", provider: "openai", id: "gpt-5.5", native: true, contextWindow: 400_000 }, + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000, displayName: "Claude Opus 4.8", inputModalities: ["text", "image"] }, + { namespaced: "gpt-5.5", provider: "openai", id: "gpt-5.5", native: true, contextWindow: 400_000, inputModalities: ["text"] }, { namespaced: "local/no-window", provider: "local", id: "no-window" }, ]; @@ -97,10 +97,22 @@ describe("hermes", () => { expect(block.api_key).toBe(HERMES_API_KEY_ENV_REF); expect(block.api_mode).toBe("chat_completions"); expect(block.discover_models).toBe(false); - expect(block.models).toEqual(["anthropic/claude-opus-4-8", "gpt-5.5", "local/no-window"]); + expect(block.models).toEqual({ + "anthropic/claude-opus-4-8": { supports_vision: true }, + "gpt-5.5": { supports_vision: false }, + "local/no-window": {}, + }); expect(doc).not.toHaveProperty("model"); }); + test("capability metadata survives the generated YAML round-trip", () => { + const built = buildClientConfigText("hermes", ctx()); + const parsed = Bun.YAML.parse(built.text) as HermesGeneratedConfig; + expect(parsed.providers[OPENCODE_PROVIDER_ID]!.models).toEqual( + (built.document as HermesGeneratedConfig).providers[OPENCODE_PROVIDER_ID]!.models, + ); + }); + test("a non-loopback bind adds the admission header, loopback does not", () => { const loopback = buildClientConfig("hermes", ctx()) as HermesGeneratedConfig; expect(loopback.providers[OPENCODE_PROVIDER_ID]!.extra_headers).toBeUndefined(); diff --git a/tests/client-config-new-clients.test.ts b/tests/client-config-new-clients.test.ts index eaf500ca54..00513d2047 100644 --- a/tests/client-config-new-clients.test.ts +++ b/tests/client-config-new-clients.test.ts @@ -24,8 +24,8 @@ import type { OcxConfig } from "../src/types"; * (devlog/_fin/260802_client_toggle_api/010 §2.4, 011 §3). */ const MODELS: ExportModel[] = [ - { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000, displayName: "Claude Opus 4.8" }, - { namespaced: "gpt-5.5", provider: "openai", id: "gpt-5.5", native: true, contextWindow: 400_000 }, + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000, displayName: "Claude Opus 4.8", inputModalities: ["text", "image"] }, + { namespaced: "gpt-5.5", provider: "openai", id: "gpt-5.5", native: true, contextWindow: 400_000, inputModalities: ["text"] }, // No authoritative context window: the "never guess metadata" case. { namespaced: "mystery/model", provider: "mystery", id: "model" }, ]; @@ -76,7 +76,11 @@ describe("hermes", () => { expect(provider.api_key).toBe(HERMES_API_KEY_ENV_REF); expect(provider.api_mode).toBe("chat_completions"); expect(provider.discover_models).toBe(false); - expect(provider.models).toEqual(["anthropic/claude-opus-4-8", "gpt-5.5", "mystery/model"]); + expect(provider.models).toEqual({ + "anthropic/claude-opus-4-8": { supports_vision: true }, + "gpt-5.5": { supports_vision: false }, + "mystery/model": {}, + }); }); test("adds the admission header only on a non-loopback bind", () => { diff --git a/tests/client-export-modality-enum.test.ts b/tests/client-export-modality-enum.test.ts index bcefd5039e..dd6fd224ee 100644 --- a/tests/client-export-modality-enum.test.ts +++ b/tests/client-export-modality-enum.test.ts @@ -5,6 +5,7 @@ import { type ExportContext, type ExportModel, type GajaeGeneratedConfig, + type HermesGeneratedConfig, type PiGeneratedConfig, } from "../src/clients/config-export"; import type { OcxConfig } from "../src/types"; @@ -46,6 +47,11 @@ function gajaeModels(models: ExportModel[]) { .providers[OPENCODE_PROVIDER_ID].models; } +function hermesModels(models: ExportModel[]) { + return (buildClientConfig("hermes", ctx(models)) as HermesGeneratedConfig) + .providers[OPENCODE_PROVIDER_ID].models; +} + /** The live failure, by its real id and real modality list. */ const MIXED: ExportModel = { namespaced: "zenmux/meta-muse-spark-1.1", @@ -68,6 +74,17 @@ const AUDIO_ONLY: ExportModel = { }; describe("exported modalities stay inside the enum each client accepts", () => { + test("Hermes receives only catalog-backed vision booleans", () => { + const bare: ExportModel = { namespaced: "p/bare", provider: "p", id: "bare" }; + const empty: ExportModel = { ...bare, namespaced: "p/empty", id: "empty", inputModalities: [] }; + expect(hermesModels([MIXED, AUDIO_ONLY, bare, empty])).toEqual({ + "zenmux/meta-muse-spark-1.1": { supports_vision: true }, + "p/audio-only": { supports_vision: false }, + "p/bare": {}, + "p/empty": {}, + }); + }); + test("audio is dropped from a mixed Gajae entry rather than written through", () => { expect(gajaeModels([MIXED])[0]?.input).toEqual(["text", "image"]); }); diff --git a/tests/management-client-config-route.test.ts b/tests/management-client-config-route.test.ts index 5490e66a44..1eec60d0a6 100644 --- a/tests/management-client-config-route.test.ts +++ b/tests/management-client-config-route.test.ts @@ -18,6 +18,7 @@ import { opencodeGlobalConfigPath, type DshGeneratedConfig, type ExportModel, + type HermesGeneratedConfig, type McodeGeneratedConfig, type OpencodeGeneratedConfig, type PiGeneratedConfig, @@ -102,6 +103,7 @@ function baseConfig(overrides: Partial = {}): OcxConfig { liveModels: false, models: ["m1", "m2"], modelContextWindows: { m1: 128_000 }, + modelInputModalities: { m1: ["text", "image"], m2: ["text"] }, modelReasoningEfforts: { m1: ["none", "minimal", "low", "high"] }, }, b: { @@ -251,6 +253,20 @@ describe("GET /api/client-config", () => { }); }, 15_000); + test("Hermes response projects catalog vision metadata through YAML", async () => { + const response = await clientConfigApi(baseConfig(), "?client=hermes"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + const models = (body.config as HermesGeneratedConfig).providers[OPENCODE_PROVIDER_ID]!.models; + + expect(Bun.YAML.parse(body.text)).toEqual(body.config as Record); + expect(models["a/m1"]).toEqual({ supports_vision: true }); + // Effective catalog hints may widen ordinary text rows to image-capable. + expect(models["a/m2"]).toEqual({ supports_vision: true }); + expect(models["b/no-context"]).toEqual({}); + expect(body.modelCount).toBe(Object.keys(models).length); + }, 15_000); + test("an expired management roster is refreshed once before client-config is projected", async () => { writeFileSync(join(entitlementCodexHome, "auth.json"), JSON.stringify({ tokens: { access_token: "client-config-token", account_id: "client-config-account" },