From 4b259ab81b2f0d7a8cfb5e1c1cccc302f3af8aa7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 14:31:57 +0100 Subject: [PATCH] fix(models): normalize trailing @profile parsing across resolver paths Co-authored-by: Vincent Koc Co-authored-by: Marcus Castro Co-authored-by: Brandon Wise --- CHANGELOG.md | 1 + src/agents/model-ref-profile.test.ts | 49 ++++++++++ src/agents/model-ref-profile.ts | 23 +++++ src/agents/model-selection.test.ts | 96 +++++++++++++++++++ src/agents/model-selection.ts | 11 ++- src/auto-reply/model.test.ts | 14 +++ src/auto-reply/model.ts | 13 +-- .../reply/directive-handling.model.test.ts | 15 +++ 8 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 src/agents/model-ref-profile.test.ts create mode 100644 src/agents/model-ref-profile.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6e480a6..fc44cf885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Podman/Default bind: change `run-openclaw-podman.sh` default gateway bind from `lan` to `loopback` and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla. - Auto-reply/Streaming: suppress only exact `NO_REPLY` final replies while still filtering streaming partial sentinel fragments (`NO_`, `NO_RE`, `HEARTBEAT_...`) so substantive replies ending with `NO_REPLY` are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim. - LINE/Inline directives auth: gate directive parsing (`/model`, `/think`, `/verbose`, `/reasoning`, `/queue`) on resolved authorization (`command.isAuthorizedSender`) so `commands.allowFrom`-authorized LINE senders are not silently stripped when raw `CommandAuthorized` is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240) +- Models/Profile suffix parsing: centralize trailing `@profile` parsing and only treat `@` as a profile separator when it appears after the final `/`, preserving model IDs like `openai/@cf/...` and `openrouter/@preset/...` across `/model` directive parsing and allowlist model resolution, with regression coverage. - Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so `openclaw doctor` no longer reports false-positive transcript-missing warnings for `*:slash:*` keys. (#27375) thanks @gumadeiras. - Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels..accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras. - Feishu/Doc tools: route `feishu_doc` and `feishu_app_scopes` through the active agent account context (with explicit `accountId` override support) so multi-account agents no longer default to the first configured app, with regression coverage for context routing and explicit override behavior. (#27338) thanks @AaronL725. diff --git a/src/agents/model-ref-profile.test.ts b/src/agents/model-ref-profile.test.ts new file mode 100644 index 000000000..68ba917c2 --- /dev/null +++ b/src/agents/model-ref-profile.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { splitTrailingAuthProfile } from "./model-ref-profile.js"; + +describe("splitTrailingAuthProfile", () => { + it("returns trimmed model when no profile suffix exists", () => { + expect(splitTrailingAuthProfile(" openai/gpt-5 ")).toEqual({ + model: "openai/gpt-5", + }); + }); + + it("splits trailing @profile suffix", () => { + expect(splitTrailingAuthProfile("openai/gpt-5@work")).toEqual({ + model: "openai/gpt-5", + profile: "work", + }); + }); + + it("keeps @-prefixed path segments in model ids", () => { + expect(splitTrailingAuthProfile("openai/@cf/openai/gpt-oss-20b")).toEqual({ + model: "openai/@cf/openai/gpt-oss-20b", + }); + }); + + it("supports trailing profile override after @-prefixed path segments", () => { + expect(splitTrailingAuthProfile("openai/@cf/openai/gpt-oss-20b@cf:default")).toEqual({ + model: "openai/@cf/openai/gpt-oss-20b", + profile: "cf:default", + }); + }); + + it("keeps openrouter preset paths without profile override", () => { + expect(splitTrailingAuthProfile("openrouter/@preset/kimi-2-5")).toEqual({ + model: "openrouter/@preset/kimi-2-5", + }); + }); + + it("supports openrouter preset profile overrides", () => { + expect(splitTrailingAuthProfile("openrouter/@preset/kimi-2-5@work")).toEqual({ + model: "openrouter/@preset/kimi-2-5", + profile: "work", + }); + }); + + it("does not split when suffix after @ contains slash", () => { + expect(splitTrailingAuthProfile("provider/foo@bar/baz")).toEqual({ + model: "provider/foo@bar/baz", + }); + }); +}); diff --git a/src/agents/model-ref-profile.ts b/src/agents/model-ref-profile.ts new file mode 100644 index 000000000..76f8108dd --- /dev/null +++ b/src/agents/model-ref-profile.ts @@ -0,0 +1,23 @@ +export function splitTrailingAuthProfile(raw: string): { + model: string; + profile?: string; +} { + const trimmed = raw.trim(); + if (!trimmed) { + return { model: "" }; + } + + const profileDelimiter = trimmed.lastIndexOf("@"); + const lastSlash = trimmed.lastIndexOf("/"); + if (profileDelimiter <= 0 || profileDelimiter <= lastSlash) { + return { model: trimmed }; + } + + const model = trimmed.slice(0, profileDelimiter).trim(); + const profile = trimmed.slice(profileDelimiter + 1).trim(); + if (!model || !profile) { + return { model: trimmed }; + } + + return { model, profile }; +} diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 8a80768c0..3e99cbe33 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -304,6 +304,30 @@ describe("model-selection", () => { ref: { provider: "anthropic", model: "claude-sonnet-4-6" }, }); }); + + it("strips trailing auth profile suffix before allowlist matching", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + models: { + "openai/@cf/openai/gpt-oss-20b": {}, + }, + }, + }, + } as OpenClawConfig; + + const result = resolveAllowedModelRef({ + cfg, + catalog: [], + raw: "openai/@cf/openai/gpt-oss-20b@cf:default", + defaultProvider: "anthropic", + }); + + expect(result).toEqual({ + key: "openai/@cf/openai/gpt-oss-20b", + ref: { provider: "openai", model: "@cf/openai/gpt-oss-20b" }, + }); + }); }); describe("resolveModelRefFromString", () => { @@ -332,6 +356,78 @@ describe("model-selection", () => { }); expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" }); }); + + it("strips trailing profile suffix for simple model refs", () => { + const resolved = resolveModelRefFromString({ + raw: "gpt-5@myprofile", + defaultProvider: "openai", + }); + expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-5" }); + }); + + it("strips trailing profile suffix for provider/model refs", () => { + const resolved = resolveModelRefFromString({ + raw: "google/gemini-flash-latest@google:bevfresh", + defaultProvider: "anthropic", + }); + expect(resolved?.ref).toEqual({ + provider: "google", + model: "gemini-flash-latest", + }); + }); + + it("preserves Cloudflare @cf model segments", () => { + const resolved = resolveModelRefFromString({ + raw: "openai/@cf/openai/gpt-oss-20b", + defaultProvider: "anthropic", + }); + expect(resolved?.ref).toEqual({ + provider: "openai", + model: "@cf/openai/gpt-oss-20b", + }); + }); + + it("preserves OpenRouter @preset model segments", () => { + const resolved = resolveModelRefFromString({ + raw: "openrouter/@preset/kimi-2-5", + defaultProvider: "anthropic", + }); + expect(resolved?.ref).toEqual({ + provider: "openrouter", + model: "@preset/kimi-2-5", + }); + }); + + it("splits trailing profile suffix after OpenRouter preset paths", () => { + const resolved = resolveModelRefFromString({ + raw: "openrouter/@preset/kimi-2-5@work", + defaultProvider: "anthropic", + }); + expect(resolved?.ref).toEqual({ + provider: "openrouter", + model: "@preset/kimi-2-5", + }); + }); + + it("strips profile suffix before alias resolution", () => { + const index = { + byAlias: new Map([ + ["kimi", { alias: "kimi", ref: { provider: "nvidia", model: "moonshotai/kimi-k2.5" } }], + ]), + byKey: new Map(), + }; + + const resolved = resolveModelRefFromString({ + raw: "kimi@nvidia:default", + defaultProvider: "openai", + aliasIndex: index, + }); + expect(resolved?.ref).toEqual({ + provider: "nvidia", + model: "moonshotai/kimi-k2.5", + }); + expect(resolved?.alias).toBe("kimi"); + }); }); describe("resolveConfiguredModelRef", () => { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index ac4520003..a094e7657 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -4,6 +4,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveAgentConfig, resolveAgentEffectiveModelPrimary } from "./agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.js"; +import { splitTrailingAuthProfile } from "./model-ref-profile.js"; import { normalizeGoogleModelId } from "./models-config.providers.js"; const log = createSubsystemLogger("model-selection"); @@ -283,18 +284,18 @@ export function resolveModelRefFromString(params: { defaultProvider: string; aliasIndex?: ModelAliasIndex; }): { ref: ModelRef; alias?: string } | null { - const trimmed = params.raw.trim(); - if (!trimmed) { + const { model } = splitTrailingAuthProfile(params.raw); + if (!model) { return null; } - if (!trimmed.includes("/")) { - const aliasKey = normalizeAliasKey(trimmed); + if (!model.includes("/")) { + const aliasKey = normalizeAliasKey(model); const aliasMatch = params.aliasIndex?.byAlias.get(aliasKey); if (aliasMatch) { return { ref: aliasMatch.ref, alias: aliasMatch.alias }; } } - const parsed = parseModelRef(trimmed, params.defaultProvider); + const parsed = parseModelRef(model, params.defaultProvider); if (!parsed) { return null; } diff --git a/src/auto-reply/model.test.ts b/src/auto-reply/model.test.ts index d96bc863b..2b4ae6469 100644 --- a/src/auto-reply/model.test.ts +++ b/src/auto-reply/model.test.ts @@ -50,6 +50,20 @@ describe("extractModelDirective", () => { expect(result.rawProfile).toBe("work"); }); + it("keeps Cloudflare @cf path segments inside model ids", () => { + const result = extractModelDirective("/model openai/@cf/openai/gpt-oss-20b"); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("openai/@cf/openai/gpt-oss-20b"); + expect(result.rawProfile).toBeUndefined(); + }); + + it("allows profile overrides after Cloudflare @cf path segments", () => { + const result = extractModelDirective("/model openai/@cf/openai/gpt-oss-20b@cf:default"); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("openai/@cf/openai/gpt-oss-20b"); + expect(result.rawProfile).toBe("cf:default"); + }); + it("returns no directive for plain text", () => { const result = extractModelDirective("hello world"); expect(result.hasDirective).toBe(false); diff --git a/src/auto-reply/model.ts b/src/auto-reply/model.ts index 2341f8059..237af130b 100644 --- a/src/auto-reply/model.ts +++ b/src/auto-reply/model.ts @@ -1,3 +1,4 @@ +import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { escapeRegExp } from "../utils.js"; export function extractModelDirective( @@ -34,15 +35,9 @@ export function extractModelDirective( let rawModel = raw; let rawProfile: string | undefined; if (raw) { - const atIndex = raw.lastIndexOf("@"); - if (atIndex > 0) { - const candidateModel = raw.slice(0, atIndex).trim(); - const candidateProfile = raw.slice(atIndex + 1).trim(); - if (candidateModel && candidateProfile && !candidateProfile.includes("/")) { - rawModel = candidateModel; - rawProfile = candidateProfile; - } - } + const split = splitTrailingAuthProfile(raw); + rawModel = split.model; + rawProfile = split.profile; } const cleaned = match ? body.replace(match[0], " ").replace(/\s+/g, " ").trim() : body.trim(); diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index 15317ca70..5d4a23f3e 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -172,6 +172,21 @@ describe("/model chat UX", () => { isDefault: false, }); }); + + it("keeps cloudflare @cf model segments for exact selections", () => { + const resolved = resolveModelSelectionForCommand({ + command: "/model openai/@cf/openai/gpt-oss-20b", + allowedModelKeys: new Set(["openai/@cf/openai/gpt-oss-20b"]), + allowedModelCatalog: [], + }); + + expect(resolved.errorText).toBeUndefined(); + expect(resolved.modelSelection).toEqual({ + provider: "openai", + model: "@cf/openai/gpt-oss-20b", + isDefault: false, + }); + }); }); describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {