From d102715bb2320e1bec88ff97e0ebd924d3d5f9d5 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sat, 4 Jul 2026 12:10:06 -0700 Subject: [PATCH] refactor(models): share strict model ref parsing (#99790) --- .../media-generation-core/src/model-ref.ts | 22 +++++---------- .../src/model-catalog-refs.test.ts | 8 ++++++ .../src/model-catalog-refs.ts | 27 +++++++++++++++---- packages/speech-core/voice-models.ts | 15 +++-------- src/agents/model-runtime-policy.ts | 9 ++----- .../shared/context-engine-host-compat.ts | 15 ++--------- 6 files changed, 44 insertions(+), 52 deletions(-) diff --git a/packages/media-generation-core/src/model-ref.ts b/packages/media-generation-core/src/model-ref.ts index b103ad183b7..2bf10505dfe 100644 --- a/packages/media-generation-core/src/model-ref.ts +++ b/packages/media-generation-core/src/model-ref.ts @@ -1,23 +1,13 @@ // Media Generation Core module implements model ref behavior. -import { normalizeOptionalString } from "./string.js"; +import { + parseProviderModelRef, + type ProviderModelRef, +} from "@openclaw/model-catalog-core/model-catalog-refs"; /** Provider/model pair parsed from a generation model reference like `provider/model`. */ -export type ParsedGenerationModelRef = { - provider: string; - model: string; -}; +export type ParsedGenerationModelRef = ProviderModelRef; /** Parses strict generation model refs and rejects missing provider or model segments. */ export function parseGenerationModelRef(raw: string | undefined): ParsedGenerationModelRef | null { - const trimmed = normalizeOptionalString(raw); - if (!trimmed) { - return null; - } - const slashIndex = trimmed.indexOf("/"); - if (slashIndex <= 0 || slashIndex === trimmed.length - 1) { - return null; - } - const provider = normalizeOptionalString(trimmed.slice(0, slashIndex)); - const model = normalizeOptionalString(trimmed.slice(slashIndex + 1)); - return provider && model ? { provider, model } : null; + return raw === undefined ? null : parseProviderModelRef(raw); } diff --git a/packages/model-catalog-core/src/model-catalog-refs.test.ts b/packages/model-catalog-core/src/model-catalog-refs.test.ts index ef10cacd4f2..293abc789c8 100644 --- a/packages/model-catalog-core/src/model-catalog-refs.test.ts +++ b/packages/model-catalog-core/src/model-catalog-refs.test.ts @@ -4,6 +4,7 @@ import { buildModelCatalogMergeKey, buildModelCatalogRef, parseModelCatalogRef, + parseProviderModelRef, } from "./model-catalog-refs.js"; describe("model catalog refs", () => { @@ -19,6 +20,13 @@ describe("model catalog refs", () => { }); }); + it("parses strict refs without normalizing provider or model casing", () => { + expect(parseProviderModelRef(" OpenRouter / Meta-Llama/Llama-3.3 ")).toEqual({ + provider: "OpenRouter", + model: "Meta-Llama/Llama-3.3", + }); + }); + it.each(["", "openai", "/gpt-5.4", "openai/", " / gpt-5.4 "])( "rejects incomplete ref %j", (value) => { diff --git a/packages/model-catalog-core/src/model-catalog-refs.ts b/packages/model-catalog-core/src/model-catalog-refs.ts index eede86185eb..92f65015ae7 100644 --- a/packages/model-catalog-core/src/model-catalog-refs.ts +++ b/packages/model-catalog-core/src/model-catalog-refs.ts @@ -8,6 +8,11 @@ export type ModelCatalogRef = { modelId: string; }; +export type ProviderModelRef = { + provider: string; + model: string; +}; + /** Normalize provider ids for catalog refs. */ export function normalizeModelCatalogProviderId(provider: string): string { return normalizeLowercaseStringOrEmpty(provider); @@ -18,16 +23,28 @@ export function buildModelCatalogRef(provider: string, modelId: string): string return `${normalizeModelCatalogProviderId(provider)}/${modelId}`; } -/** Parse a strict provider/model catalog reference. */ -export function parseModelCatalogRef(value: string): ModelCatalogRef | null { +/** Parse a strict provider/model reference without normalizing either segment. */ +export function parseProviderModelRef(value: string): ProviderModelRef | null { const trimmed = value.trim(); const slashIndex = trimmed.indexOf("/"); if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) { return null; } - const provider = normalizeModelCatalogProviderId(trimmed.slice(0, slashIndex)); - const modelId = trimmed.slice(slashIndex + 1).trim(); - return provider && modelId ? { provider, modelId } : null; + const provider = trimmed.slice(0, slashIndex).trim(); + const model = trimmed.slice(slashIndex + 1).trim(); + return provider && model ? { provider, model } : null; +} + +/** Parse a strict provider/model catalog reference. */ +export function parseModelCatalogRef(value: string): ModelCatalogRef | null { + const parsed = parseProviderModelRef(value); + if (!parsed) { + return null; + } + return { + provider: normalizeModelCatalogProviderId(parsed.provider), + modelId: parsed.model, + }; } /** Build a case-insensitive merge key for provider/model rows. */ diff --git a/packages/speech-core/voice-models.ts b/packages/speech-core/voice-models.ts index df6bcdee937..391f3934062 100644 --- a/packages/speech-core/voice-models.ts +++ b/packages/speech-core/voice-models.ts @@ -1,4 +1,6 @@ // Voice model catalog helpers shared by TTS and realtime voice plugins. +import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs"; + export type VoiceModelCapability = "tts" | "realtime_transcription" | "realtime_voice"; /** Capability flags advertised by a voice model catalog entry. */ @@ -61,17 +63,8 @@ function normalizeTimeoutMs(value: unknown): number | undefined { } function parseVoiceModelRef(value: unknown): VoiceModelRef | undefined { - const raw = normalizeString(value); - if (!raw) { - return undefined; - } - const slashIndex = raw.indexOf("/"); - if (slashIndex <= 0 || slashIndex === raw.length - 1) { - return undefined; - } - const provider = normalizeLowercaseString(raw.slice(0, slashIndex)); - const model = normalizeString(raw.slice(slashIndex + 1)); - return provider && model ? { provider, model } : undefined; + const parsed = typeof value === "string" ? parseModelCatalogRef(value) : null; + return parsed ? { provider: parsed.provider, model: parsed.modelId } : undefined; } function sameProvider(left: string | undefined, right: string | undefined): boolean { diff --git a/src/agents/model-runtime-policy.ts b/src/agents/model-runtime-policy.ts index 131a67c12cd..97c03cb62ab 100644 --- a/src/agents/model-runtime-policy.ts +++ b/src/agents/model-runtime-policy.ts @@ -4,6 +4,7 @@ * Agent execution uses this to choose a model/provider-specific runtime policy * from agent entries, model catalog config, provider config, or QA overrides. */ +import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js"; import type { AgentRuntimePolicyConfig } from "../config/types.agents-shared.js"; @@ -79,13 +80,7 @@ function normalizeModelIdForProvider( } function parseProviderModelKey(key: string): { provider: string; modelId: string } | undefined { - const slash = key.indexOf("/"); - if (slash <= 0) { - return undefined; - } - const provider = normalizeProviderId(key.slice(0, slash)); - const modelId = key.slice(slash + 1).trim(); - return provider && modelId ? { provider, modelId } : undefined; + return parseModelCatalogRef(key) ?? undefined; } function resolveEffectiveProvider( diff --git a/src/commands/doctor/shared/context-engine-host-compat.ts b/src/commands/doctor/shared/context-engine-host-compat.ts index aa23c1144df..735778e1a2c 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.ts @@ -1,5 +1,5 @@ // Doctor checks for context engine host requirements against configured agent runtimes. -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { normalizeEmbeddedAgentRuntime } from "../../../agents/agent-runtime-id.js"; import { resolveDefaultAgentDir } from "../../../agents/agent-scope-config.js"; @@ -53,18 +53,7 @@ function normalizeRuntimeId(value: unknown): string | undefined { } function parseModelRef(value: unknown): { provider: string; modelId: string } | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - const slash = trimmed.indexOf("/"); - if (slash <= 0 || slash >= trimmed.length - 1) { - return undefined; - } - return { - provider: normalizeProviderId(trimmed.slice(0, slash)), - modelId: trimmed.slice(slash + 1).trim(), - }; + return typeof value === "string" ? (parseModelCatalogRef(value) ?? undefined) : undefined; } function listModelRefs(value: unknown): string[] {