Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/clients/config-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, HermesModelEntry>;
extra_headers?: Record<string, string>;
}

/** Capability metadata Hermes cannot discover for a custom local provider. */
export interface HermesModelEntry {
supports_vision?: boolean;
}

export interface HermesGeneratedConfig {
providers: Record<string, HermesProviderBlock>;
}
Expand Down Expand Up @@ -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<string, HermesModelEntry> = {};
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: {
Expand Down Expand Up @@ -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 } {
Expand Down
17 changes: 17 additions & 0 deletions structure/09_client-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions tests/cli-export-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
];
Expand Down Expand Up @@ -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<string, { supports_vision?: boolean }> } };
};
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 });
Expand Down
18 changes: 15 additions & 3 deletions tests/client-config-export-new-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

Expand Down Expand Up @@ -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();
Expand Down
10 changes: 7 additions & 3 deletions tests/client-config-new-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];
Expand Down Expand Up @@ -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", () => {
Expand Down
17 changes: 17 additions & 0 deletions tests/client-export-modality-enum.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand All @@ -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"]);
});
Expand Down
16 changes: 16 additions & 0 deletions tests/management-client-config-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
opencodeGlobalConfigPath,
type DshGeneratedConfig,
type ExportModel,
type HermesGeneratedConfig,
type McodeGeneratedConfig,
type OpencodeGeneratedConfig,
type PiGeneratedConfig,
Expand Down Expand Up @@ -102,6 +103,7 @@ function baseConfig(overrides: Partial<OcxConfig> = {}): OcxConfig {
liveModels: false,
models: ["m1", "m2"],
modelContextWindows: { m1: 128_000 },
modelInputModalities: { m1: ["text", "image"], m2: ["text"] },
modelReasoningEfforts: { m1: ["none", "minimal", "low", "high"] },
},
b: {
Expand Down Expand Up @@ -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<string, unknown>);
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" },
Expand Down
Loading