diff --git a/docs/solutions/integrations/cross-platform-model-field-normalization-2026-03-29.md b/docs/solutions/integrations/cross-platform-model-field-normalization-2026-03-29.md new file mode 100644 index 0000000..dc59534 --- /dev/null +++ b/docs/solutions/integrations/cross-platform-model-field-normalization-2026-03-29.md @@ -0,0 +1,159 @@ +--- +title: "Cross-platform model field normalization for target converters" +date: 2026-03-29 +category: integration-issues +module: src/converters +problem_type: integration_issue +component: tooling +symptoms: + - "Target platforms received raw Claude model aliases (e.g., 'sonnet') they could not resolve" + - "Qwen converter mapped model aliases to wrong canonical names (claude-sonnet instead of claude-sonnet-4-6)" + - "OpenClaw and Copilot passed through unnormalized model values in formats the target could not use" + - "Duplicated CLAUDE_FAMILY_ALIASES and normalizeModel logic across converters with divergent alias values" +root_cause: config_error +resolution_type: code_fix +severity: medium +tags: + - model-normalization + - converters + - cross-platform + - opencode + - qwen + - droid + - copilot + - openclaw + - codex +--- + +# Cross-platform model field normalization for target converters + +## Problem + +Claude Code uses bare model aliases (`model: sonnet`, `model: haiku`, `model: opus`) in agent and command frontmatter. Each target platform expects a different format for the model field, but the converters handled this inconsistently — some passed through raw values, others had duplicated normalization logic with wrong alias mappings. + +## Symptoms + +- OpenClaw passed `model: sonnet` through raw — invalid on a platform expecting `anthropic/claude-sonnet-4-6` +- Qwen mapped `sonnet` to `anthropic/claude-sonnet` instead of `anthropic/claude-sonnet-4-6` (wrong alias in its local copy of `CLAUDE_FAMILY_ALIASES`) +- Copilot passed through raw Claude model IDs like `claude-sonnet-4-20250514` — Copilot uses display-name format ("Claude Opus 4.5"), not model IDs +- Codex emitted no model field — correct behavior, but accidental (no deliberate handling) +- Droid passed through as-is — correct behavior, but undocumented as intentional +- Two copies of `CLAUDE_FAMILY_ALIASES` existed in OpenCode and Qwen converters with divergent values + +## What Didn't Work + +- **Passing model through as-is**: works for Droid (Factory natively resolves bare aliases), breaks OpenClaw/Qwen/OpenCode +- **Mapping bare aliases to incomplete model names**: Qwen's `sonnet` -> `claude-sonnet` was wrong; correct is `claude-sonnet-4-6` +- **Assuming all targets want the same model format**: each platform has fundamentally different expectations +- **Assuming Codex skills support model overrides in frontmatter**: they don't — confirmed by the Rust source `SkillFrontmatter` struct which only has `name` and `description` +- **Initial assumption that Qwen should drop model entirely**: wrong — Qwen is multi-provider and supports Anthropic models via `settings.json` with `anthropic` provider config +- **Initial assumption that Copilot doesn't support models**: wrong — Copilot supports multi-model including Claude, but the exact format is uncertain (display names vs model IDs) + +## Solution + +Created `src/utils/model.ts` with shared normalization utilities: + +```typescript +// Single source of truth for bare Claude family aliases +export const CLAUDE_FAMILY_ALIASES: Record = { + haiku: "claude-haiku-4-5", + sonnet: "claude-sonnet-4-6", + opus: "claude-opus-4-6", +} + +// Resolve bare alias without provider prefix (used by Droid) +export function resolveClaudeFamilyAlias(model: string): string + +// Add provider prefix based on naming conventions +export function addProviderPrefix(model: string): string + +// Combined: resolve + prefix (used by OpenCode, Qwen, OpenClaw) +export function normalizeModelWithProvider(model: string): string +``` + +Each converter now uses the appropriate shared utility: + +| Target | Behavior | Output for `model: sonnet` | +|--------|----------|----------------------------| +| OpenCode | Resolve alias + add provider prefix | `anthropic/claude-sonnet-4-6` | +| Qwen | Resolve alias + add provider prefix | `anthropic/claude-sonnet-4-6` | +| OpenClaw | Resolve alias + add provider prefix | `anthropic/claude-sonnet-4-6` | +| Droid | Pass through as-is | `sonnet` | +| Copilot | Drop entirely | (omitted) | +| Codex | Drop entirely | (omitted) | + +--- + +## Why This Works + +Each platform has fundamentally different model handling requirements: + +**Platforms that normalize (OpenCode, Qwen, OpenClaw):** These are multi-provider platforms that support Anthropic, OpenAI, Google, and other model providers. They need provider-prefixed IDs like `anthropic/claude-sonnet-4-6` to route requests to the correct backend. The `normalizeModelWithProvider` function resolves bare aliases and adds the appropriate prefix. + +**Droid (Factory) — pass-through:** Factory is multi-provider but natively resolves Claude's bare aliases (`sonnet`, `opus`, `haiku`) internally. Pass-through is correct and simpler than normalizing to a format Factory would also accept but doesn't require. Factory also accepts full dated model IDs like `claude-sonnet-4-5-20250929` and non-Anthropic models prefixed with `custom:`. + +**Copilot — drop:** Copilot supports a `model` field in `.agent.md` frontmatter (documented in `docs/specs/copilot.md`), but the expected values are Copilot-specific display names like "Claude Opus 4.5" — not Claude model IDs like `claude-sonnet-4-20250514` or bare aliases like `sonnet`. Passing through Claude-specific values would emit a field Copilot can't use. Unlike Droid (which natively resolves `sonnet`), Copilot has no documented resolution for Claude model IDs. Dropping is safer: the spec says "If unset, inherits the default model." + +**Codex — drop:** Codex skill frontmatter (`SKILL.md`) only supports `name` and `description` fields. This was confirmed by examining the Rust source code (`SkillFrontmatter` struct in `codex-rs/core-skills/src/loader.rs`). Model selection in Codex is global via `config.toml` or runtime `/model` command, not per-skill. + +--- + +## Target platform model field reference + +This reference captures research findings as of 2026-03-29. + +### OpenCode +- **Model format:** `provider/model-id` (e.g., `anthropic/claude-sonnet-4-6`) +- **Provider prefixes:** `anthropic/`, `openai/`, `google/` +- **Docs:** Agents defined in `.opencode/agents/*.md` + +### Qwen +- **Model format:** `provider/model-id` (e.g., `anthropic/claude-sonnet-4-6`) +- **Multi-provider:** Yes — supports Anthropic, OpenAI, Google GenAI via `settings.json` +- **Configuration example:** `"anthropic": [{"id": "claude-sonnet-4-20250514", "name": "Claude Sonnet 4", "envKey": "ANTHROPIC_API_KEY"}]` +- **Common misconception:** Qwen is NOT limited to its own foundation model + +### Droid (Factory) +- **Model format:** Bare names (`sonnet`, `claude-sonnet-4-5-20250929`) or `custom:` for BYOK +- **Native alias resolution:** Factory resolves `sonnet`, `opus`, `haiku` internally +- **Multi-provider:** Yes — supports Anthropic, OpenAI, Google, and Factory's own `droid-core` +- **Docs:** Custom droids defined in `.factory/droids/*.md` + +### Copilot +- **Model format:** Display names (e.g., "Claude Opus 4.5", "GPT-5.2"), possibly array syntax `model: ['Claude Opus 4.5', 'GPT-5.2']` +- **Multi-provider:** Yes — supports Claude and GPT models +- **Current converter behavior:** Drop (Claude model IDs don't map to Copilot's expected format) +- **Note:** Spec says "may be ignored on github.com" — model selection works in IDE but may not apply on the GitHub web platform +- **Docs:** Agents defined in `.github/agents/*.agent.md` + +### OpenClaw +- **Model format:** `provider/model-id` (same as OpenCode) +- **Docs:** Skills defined in `skills/*/SKILL.md` + +### Codex +- **Model field in skill frontmatter:** NOT SUPPORTED +- **Supported frontmatter fields:** `name`, `description` only +- **Model configuration:** Global `config.toml` (`model = "gpt-5.4"`) or runtime `/model` command +- **Valid model IDs (as of 2026-03):** `gpt-5.4` (flagship), `gpt-5.4-mini` (fast), `gpt-5.3-codex` (coding-specialized) +- **Deprecated:** `codex-mini-latest` (removed Feb 2026) +- **Docs:** Skills defined in `.codex/skills/*/SKILL.md` or `.agents/skills/*/SKILL.md` + +--- + +## Prevention + +1. **Research before implementing:** When adding a new converter target, research its model field format with external documentation before assuming pass-through or copying from another converter. The format varies significantly between platforms. + +2. **Single source of truth:** The `CLAUDE_FAMILY_ALIASES` map in `src/utils/model.ts` is the canonical alias map. Update it there — not in individual converters — when new Claude model generations are released. + +3. **Test coverage:** Run `bun test` after model-related changes. The test suite covers model handling across all converters (`tests/model-utils.test.ts` plus each converter's test file). + +4. **Don't assume format from the field name:** A `model` field in frontmatter doesn't mean the format is the same across platforms. OpenCode wants `anthropic/claude-sonnet-4-6`, Factory wants `sonnet`, Copilot wants "Claude Sonnet 4", and Codex doesn't support the field at all. + +5. **When in doubt, drop:** If you can't confidently produce the target's expected format, omit the field rather than emitting a potentially invalid value. Most platforms fall back to a sensible default when model is unset. + +## Related Issues + +- `docs/solutions/adding-converter-target-providers.md` — Converter architecture doc; should be updated to reference model normalization as part of the conversion pattern +- `docs/solutions/integrations/colon-namespaced-names-break-windows-paths-2026-03-26.md` — Structural analog: same pattern of per-target boundary normalization +- `docs/specs/codex.md` — Platform spec (last verified 2026-01-21); confirms skill frontmatter limitations diff --git a/src/converters/claude-to-copilot.ts b/src/converters/claude-to-copilot.ts index cd377e3..22d4aeb 100644 --- a/src/converters/claude-to-copilot.ts +++ b/src/converters/claude-to-copilot.ts @@ -54,10 +54,6 @@ function convertAgent(agent: ClaudeAgent, usedNames: Set): CopilotAgent infer: true, } - if (agent.model && agent.model !== "inherit") { - frontmatter.model = agent.model - } - let body = transformContentForCopilot(agent.body.trim()) if (agent.capabilities && agent.capabilities.length > 0) { const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n") diff --git a/src/converters/claude-to-openclaw.ts b/src/converters/claude-to-openclaw.ts index 677b40c..0143564 100644 --- a/src/converters/claude-to-openclaw.ts +++ b/src/converters/claude-to-openclaw.ts @@ -1,4 +1,5 @@ import { formatFrontmatter } from "../utils/frontmatter" +import { normalizeModelWithProvider } from "../utils/model" import { sanitizePathName } from "../utils/files" import type { ClaudeAgent, @@ -104,7 +105,7 @@ function convertAgentToSkill(agent: ClaudeAgent): OpenClawSkillFile { } if (agent.model && agent.model !== "inherit") { - frontmatter.model = agent.model + frontmatter.model = normalizeModelWithProvider(agent.model) } const body = rewritePaths(agent.body) @@ -124,7 +125,7 @@ function convertCommandToSkill(command: ClaudeCommand): OpenClawSkillFile { } if (command.model && command.model !== "inherit") { - frontmatter.model = command.model + frontmatter.model = normalizeModelWithProvider(command.model) } const body = rewritePaths(command.body) diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index 3f81e7a..4b58e83 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -1,4 +1,5 @@ import { formatFrontmatter } from "../utils/frontmatter" +import { normalizeModelWithProvider } from "../utils/model" import type { ClaudeAgent, ClaudeCommand, @@ -93,7 +94,7 @@ function convertAgent(agent: ClaudeAgent, options: ClaudeToOpenCodeOptions) { } if (agent.model && agent.model !== "inherit") { - frontmatter.model = normalizeModel(agent.model) + frontmatter.model = normalizeModelWithProvider(agent.model) } if (options.inferTemperature) { @@ -121,7 +122,7 @@ function convertCommands(commands: ClaudeCommand[]): OpenCodeCommandFile[] { description: command.description, } if (command.model && command.model !== "inherit") { - frontmatter.model = normalizeModel(command.model) + frontmatter.model = normalizeModelWithProvider(command.model) } const content = formatFrontmatter(frontmatter, rewriteClaudePaths(command.body)) files.push({ name: command.name, content }) @@ -260,30 +261,6 @@ function rewriteClaudePaths(body: string): string { .replace(/\.claude\//g, ".opencode/") } -// Bare Claude family aliases used in Claude Code (e.g. `model: haiku`). -// Update these when new model generations are released. -const CLAUDE_FAMILY_ALIASES: Record = { - haiku: "claude-haiku-4-5", - sonnet: "claude-sonnet-4-6", - opus: "claude-opus-4-6", -} - -function normalizeModel(model: string): string { - if (model.includes("/")) return model - if (CLAUDE_FAMILY_ALIASES[model]) { - const resolved = `anthropic/${CLAUDE_FAMILY_ALIASES[model]}` - console.warn( - `Warning: bare model alias "${model}" mapped to "${resolved}". ` + - `Update CLAUDE_FAMILY_ALIASES if a newer version is available.`, - ) - return resolved - } - if (/^claude-/.test(model)) return `anthropic/${model}` - if (/^(gpt-|o1-|o3-)/.test(model)) return `openai/${model}` - if (/^gemini-/.test(model)) return `google/${model}` - return `anthropic/${model}` -} - function inferTemperature(agent: ClaudeAgent): number | undefined { const sample = `${agent.name} ${agent.description ?? ""}`.toLowerCase() if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) { diff --git a/src/converters/claude-to-qwen.ts b/src/converters/claude-to-qwen.ts index c07b177..204e424 100644 --- a/src/converters/claude-to-qwen.ts +++ b/src/converters/claude-to-qwen.ts @@ -1,4 +1,5 @@ import { formatFrontmatter } from "../utils/frontmatter" +import { normalizeModelWithProvider } from "../utils/model" import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude" import type { QwenAgentFile, @@ -54,7 +55,7 @@ function convertAgent(agent: ClaudeAgent, options: ClaudeToQwenOptions): QwenAge } if (agent.model && agent.model !== "inherit") { - frontmatter.model = normalizeModel(agent.model) + frontmatter.model = normalizeModelWithProvider(agent.model) } if (options.inferTemperature) { @@ -83,7 +84,7 @@ function convertCommands(commands: ClaudeCommand[]): QwenCommandFile[] { description: command.description, } if (command.model && command.model !== "inherit") { - frontmatter.model = normalizeModel(command.model) + frontmatter.model = normalizeModelWithProvider(command.model) } if (command.allowedTools && command.allowedTools.length > 0) { frontmatter.allowedTools = command.allowedTools @@ -198,28 +199,6 @@ function rewriteQwenPaths(body: string): string { .replace(/(?<=^|\s|["'`])\.claude\//gm, ".qwen/") } -const CLAUDE_FAMILY_ALIASES: Record = { - haiku: "claude-haiku", - sonnet: "claude-sonnet", - opus: "claude-opus", -} - -function normalizeModel(model: string): string { - if (model.includes("/")) return model - if (CLAUDE_FAMILY_ALIASES[model]) { - const resolved = `anthropic/${CLAUDE_FAMILY_ALIASES[model]}` - console.warn( - `Warning: bare model alias "${model}" mapped to "${resolved}".`, - ) - return resolved - } - if (/^claude-/.test(model)) return `anthropic/${model}` - if (/^(gpt-|o1-|o3-)/.test(model)) return `openai/${model}` - if (/^gemini-/.test(model)) return `google/${model}` - if (/^qwen-/.test(model)) return `qwen/${model}` - return `anthropic/${model}` -} - function inferTemperature(agent: ClaudeAgent): number | undefined { const sample = `${agent.name} ${agent.description ?? ""}`.toLowerCase() if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) { diff --git a/src/utils/model.ts b/src/utils/model.ts new file mode 100644 index 0000000..1d525c7 --- /dev/null +++ b/src/utils/model.ts @@ -0,0 +1,67 @@ +/** + * Shared model normalization utilities for cross-platform conversion. + * + * Claude Code uses bare family aliases (`model: sonnet`) that must be + * resolved differently depending on the target platform. + */ + +/** + * Bare Claude family aliases used in Claude Code (e.g. `model: haiku`). + * Maps alias -> canonical model name (without provider prefix). + * Update these when new model generations are released. + */ +export const CLAUDE_FAMILY_ALIASES: Record = { + haiku: "claude-haiku-4-5", + sonnet: "claude-sonnet-4-6", + opus: "claude-opus-4-6", +} + +/** + * Resolve a bare Claude family alias to its canonical model name. + * Returns the input unchanged if not a recognized alias. + * + * "sonnet" -> "claude-sonnet-4-6" + * "claude-sonnet-4-20250514" -> "claude-sonnet-4-20250514" (unchanged) + */ +export function resolveClaudeFamilyAlias(model: string): string { + return CLAUDE_FAMILY_ALIASES[model] ?? model +} + +/** + * Add a provider prefix based on model naming conventions. + * Returns the input unchanged if already prefixed (contains "/"). + * + * "claude-sonnet-4-6" -> "anthropic/claude-sonnet-4-6" + * "gpt-5.4" -> "openai/gpt-5.4" + * "gemini-2.0" -> "google/gemini-2.0" + * "anthropic/foo" -> "anthropic/foo" (unchanged) + */ +export function addProviderPrefix(model: string): string { + if (model.includes("/")) return model + if (/^claude-/.test(model)) return `anthropic/${model}` + if (/^(gpt-|o1-|o3-)/.test(model)) return `openai/${model}` + if (/^gemini-/.test(model)) return `google/${model}` + if (/^qwen-/.test(model)) return `qwen/${model}` + return `anthropic/${model}` +} + +/** + * Normalize a model for targets that use provider-prefixed IDs + * (OpenCode, OpenClaw). Resolves bare aliases and adds provider prefix. + * + * "sonnet" -> "anthropic/claude-sonnet-4-6" + * "claude-sonnet-4-20250514" -> "anthropic/claude-sonnet-4-20250514" + * "anthropic/claude-opus" -> "anthropic/claude-opus" (unchanged) + */ +export function normalizeModelWithProvider(model: string): string { + if (model.includes("/")) return model + const resolved = resolveClaudeFamilyAlias(model) + if (resolved !== model) { + console.warn( + `Warning: bare model alias "${model}" mapped to "anthropic/${resolved}". ` + + `Update CLAUDE_FAMILY_ALIASES if a newer version is available.`, + ) + } + return addProviderPrefix(resolved) +} + diff --git a/tests/codex-converter.test.ts b/tests/codex-converter.test.ts index 826be9f..0460e8b 100644 --- a/tests/codex-converter.test.ts +++ b/tests/codex-converter.test.ts @@ -79,6 +79,32 @@ describe("convertClaudeToCodex", () => { expect(parsedSkill.body).toContain("Threat modeling") }) + test("drops model field (Codex skill frontmatter does not support model)", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "fast-agent", + description: "Fast agent", + model: "sonnet", + body: "Do things quickly.", + sourcePath: "/tmp/plugin/agents/fast.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToCodex(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const skill = bundle.generatedSkills.find((s) => s.name === "fast-agent") + expect(parseFrontmatter(skill!.content).data.model).toBeUndefined() + }) + test("generates prompt wrappers for canonical ce workflow skills and omits workflows aliases", () => { const plugin: ClaudePlugin = { ...fixturePlugin, diff --git a/tests/copilot-converter.test.ts b/tests/copilot-converter.test.ts index 80ba80a..9c88fa9 100644 --- a/tests/copilot-converter.test.ts +++ b/tests/copilot-converter.test.ts @@ -103,27 +103,9 @@ describe("convertClaudeToCopilot", () => { expect(parsed.body).toMatch(/## Capabilities\n- Threat modeling\n- OWASP/) }) - test("agent model field is passed through", () => { + test("model field is dropped (Copilot model format differs from Claude model IDs)", () => { const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) const parsed = parseFrontmatter(bundle.agents[0].content) - expect(parsed.data.model).toBe("claude-sonnet-4-20250514") - }) - - test("agent without model omits model field", () => { - const plugin: ClaudePlugin = { - ...fixturePlugin, - agents: [ - { - name: "no-model", - description: "No model agent", - body: "Content.", - sourcePath: "/tmp/plugin/agents/no-model.md", - }, - ], - } - - const bundle = convertClaudeToCopilot(plugin, defaultOptions) - const parsed = parseFrontmatter(bundle.agents[0].content) expect(parsed.data.model).toBeUndefined() }) diff --git a/tests/droid-converter.test.ts b/tests/droid-converter.test.ts index a28c11f..94a19e6 100644 --- a/tests/droid-converter.test.ts +++ b/tests/droid-converter.test.ts @@ -89,6 +89,30 @@ describe("convertClaudeToDroid", () => { expect(bundle.skillDirs[0].sourceDir).toBe("/tmp/plugin/skills/existing-skill") }) + test("passes through model as-is (Factory resolves bare aliases natively)", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "fast-agent", + description: "Fast agent", + model: "sonnet", + body: "Do things quickly.", + sourcePath: "/tmp/plugin/agents/fast.md", + }, + ], + } + + const bundle = convertClaudeToDroid(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const parsed = parseFrontmatter(bundle.droids[0].content) + expect(parsed.data.model).toBe("sonnet") + }) + test("omits model when set to inherit", () => { const plugin: ClaudePlugin = { ...fixturePlugin, diff --git a/tests/model-utils.test.ts b/tests/model-utils.test.ts new file mode 100644 index 0000000..4aa53ba --- /dev/null +++ b/tests/model-utils.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import { + resolveClaudeFamilyAlias, + normalizeModelWithProvider, + addProviderPrefix, + CLAUDE_FAMILY_ALIASES, +} from "../src/utils/model" + +describe("resolveClaudeFamilyAlias", () => { + test("resolves bare aliases to full Claude model names", () => { + expect(resolveClaudeFamilyAlias("haiku")).toBe("claude-haiku-4-5") + expect(resolveClaudeFamilyAlias("sonnet")).toBe("claude-sonnet-4-6") + expect(resolveClaudeFamilyAlias("opus")).toBe("claude-opus-4-6") + }) + + test("passes through non-alias model names unchanged", () => { + expect(resolveClaudeFamilyAlias("claude-sonnet-4-20250514")).toBe("claude-sonnet-4-20250514") + expect(resolveClaudeFamilyAlias("gpt-5.4")).toBe("gpt-5.4") + expect(resolveClaudeFamilyAlias("anthropic/claude-opus")).toBe("anthropic/claude-opus") + }) +}) + +describe("addProviderPrefix", () => { + test("prefixes Claude models with anthropic/", () => { + expect(addProviderPrefix("claude-sonnet-4-6")).toBe("anthropic/claude-sonnet-4-6") + expect(addProviderPrefix("claude-haiku-4-5")).toBe("anthropic/claude-haiku-4-5") + }) + + test("prefixes OpenAI models with openai/", () => { + expect(addProviderPrefix("gpt-5.4")).toBe("openai/gpt-5.4") + expect(addProviderPrefix("o3-mini")).toBe("openai/o3-mini") + }) + + test("prefixes Google models with google/", () => { + expect(addProviderPrefix("gemini-2.0")).toBe("google/gemini-2.0") + }) + + test("prefixes Qwen models with qwen/", () => { + expect(addProviderPrefix("qwen-max")).toBe("qwen/qwen-max") + expect(addProviderPrefix("qwen-3.5-plus")).toBe("qwen/qwen-3.5-plus") + }) + + test("defaults unknown models to anthropic/ prefix", () => { + expect(addProviderPrefix("some-model")).toBe("anthropic/some-model") + }) + + test("passes through already-prefixed models unchanged", () => { + expect(addProviderPrefix("anthropic/claude-opus")).toBe("anthropic/claude-opus") + expect(addProviderPrefix("openai/gpt-5.4")).toBe("openai/gpt-5.4") + expect(addProviderPrefix("google/gemini-2.0")).toBe("google/gemini-2.0") + }) +}) + +describe("normalizeModelWithProvider", () => { + test("resolves bare aliases and adds provider prefix", () => { + expect(normalizeModelWithProvider("sonnet")).toBe("anthropic/claude-sonnet-4-6") + expect(normalizeModelWithProvider("haiku")).toBe("anthropic/claude-haiku-4-5") + expect(normalizeModelWithProvider("opus")).toBe("anthropic/claude-opus-4-6") + }) + + test("adds provider prefix to full Claude model names", () => { + expect(normalizeModelWithProvider("claude-sonnet-4-20250514")).toBe("anthropic/claude-sonnet-4-20250514") + }) + + test("passes through already-prefixed models unchanged", () => { + expect(normalizeModelWithProvider("anthropic/claude-opus")).toBe("anthropic/claude-opus") + expect(normalizeModelWithProvider("google/gemini-2.0")).toBe("google/gemini-2.0") + }) +}) + +describe("exported constants", () => { + test("CLAUDE_FAMILY_ALIASES covers all three tiers", () => { + expect(Object.keys(CLAUDE_FAMILY_ALIASES)).toEqual(["haiku", "sonnet", "opus"]) + }) +}) diff --git a/tests/openclaw-converter.test.ts b/tests/openclaw-converter.test.ts index ab2ed5e..2e7501e 100644 --- a/tests/openclaw-converter.test.ts +++ b/tests/openclaw-converter.test.ts @@ -67,10 +67,30 @@ describe("convertClaudeToOpenClaw", () => { const parsed = parseFrontmatter(skill!.content) expect(parsed.data.name).toBe("security-reviewer") expect(parsed.data.description).toBe("Security-focused agent") - expect(parsed.data.model).toBe("claude-sonnet-4-20250514") + expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514") expect(parsed.body).toContain("Focus on vulnerabilities") }) + test("resolves bare model aliases to provider-prefixed IDs", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "fast-agent", + description: "Fast agent", + model: "sonnet", + body: "Do things quickly.", + sourcePath: "/tmp/plugin/agents/fast.md", + }, + ], + } + + const bundle = convertClaudeToOpenClaw(plugin, defaultOptions) + const skill = bundle.skills.find((s) => s.name === "fast-agent") + const parsed = parseFrontmatter(skill!.content) + expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-6") + }) + test("converts commands to skill files (excluding disableModelInvocation)", () => { const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions) diff --git a/tests/qwen-converter.test.ts b/tests/qwen-converter.test.ts index b9690a3..9752e0c 100644 --- a/tests/qwen-converter.test.ts +++ b/tests/qwen-converter.test.ts @@ -216,7 +216,17 @@ describe("convertClaudeToQwen", () => { expect(skill!.sourceDir).toBe("/tmp/plugin/skills/existing-skill") }) - test("normalizeModel prefixes claude models with anthropic/", () => { + test("normalizes bare aliases to provider-prefixed model IDs", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [{ name: "a", description: "d", model: "sonnet", body: "b", sourcePath: "/tmp/a.md" }], + } + const bundle = convertClaudeToQwen(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-6") + }) + + test("prefixes claude models with anthropic/", () => { const plugin: ClaudePlugin = { ...fixturePlugin, agents: [{ name: "a", description: "d", model: "claude-opus-4-5", body: "b", sourcePath: "/tmp/a.md" }], @@ -226,7 +236,17 @@ describe("convertClaudeToQwen", () => { expect(parsed.data.model).toBe("anthropic/claude-opus-4-5") }) - test("normalizeModel passes through already-namespaced models unchanged", () => { + test("prefixes qwen models with qwen/ provider", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [{ name: "a", description: "d", model: "qwen-max", body: "b", sourcePath: "/tmp/a.md" }], + } + const bundle = convertClaudeToQwen(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.model).toBe("qwen/qwen-max") + }) + + test("passes through already-namespaced models unchanged", () => { const plugin: ClaudePlugin = { ...fixturePlugin, agents: [{ name: "a", description: "d", model: "google/gemini-2.0", body: "b", sourcePath: "/tmp/a.md" }],