From d99015f8cb1302f0bd0287f8f6a180b6a0754145 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Fri, 6 Mar 2026 11:56:03 +0900 Subject: [PATCH 1/5] map Claude Code model strings to OpenCode format when importing agents --- .../claude-model-mapper.test.ts | 80 +++++++++++++++++++ .../claude-model-mapper.ts | 13 +++ .../claude-code-agent-loader/loader.ts | 4 + .../claude-code-plugin-loader/agent-loader.ts | 4 + 4 files changed, 101 insertions(+) create mode 100644 src/features/claude-code-agent-loader/claude-model-mapper.test.ts create mode 100644 src/features/claude-code-agent-loader/claude-model-mapper.ts diff --git a/src/features/claude-code-agent-loader/claude-model-mapper.test.ts b/src/features/claude-code-agent-loader/claude-model-mapper.test.ts new file mode 100644 index 000000000..30a3312a3 --- /dev/null +++ b/src/features/claude-code-agent-loader/claude-model-mapper.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "bun:test" +import { mapClaudeModelToOpenCode } from "./claude-model-mapper" + +describe("mapClaudeModelToOpenCode", () => { + describe("#given undefined or empty input", () => { + it("#when called with undefined #then returns undefined", () => { + expect(mapClaudeModelToOpenCode(undefined)).toBeUndefined() + }) + + it("#when called with empty string #then returns undefined", () => { + expect(mapClaudeModelToOpenCode("")).toBeUndefined() + }) + + it("#when called with whitespace-only string #then returns undefined", () => { + expect(mapClaudeModelToOpenCode(" ")).toBeUndefined() + }) + }) + + describe("#given model with date suffix", () => { + it("#when called with claude-sonnet-4-5-20250514 #then strips date suffix", () => { + expect(mapClaudeModelToOpenCode("claude-sonnet-4-5-20250514")).toBe("claude-sonnet-4-5") + }) + + it("#when called with claude-opus-4-20250414 #then strips date suffix", () => { + expect(mapClaudeModelToOpenCode("claude-opus-4-20250414")).toBe("claude-opus-4") + }) + + it("#when called with claude-haiku-4-5-20250514 #then strips date suffix", () => { + expect(mapClaudeModelToOpenCode("claude-haiku-4-5-20250514")).toBe("claude-haiku-4-5") + }) + + it("#when called with claude-3-5-sonnet-20241022 #then strips date suffix", () => { + expect(mapClaudeModelToOpenCode("claude-3-5-sonnet-20241022")).toBe("claude-3-5-sonnet") + }) + }) + + describe("#given model with dot version numbers", () => { + it("#when called with claude-3.5-sonnet #then normalizes dots to dashes", () => { + expect(mapClaudeModelToOpenCode("claude-3.5-sonnet")).toBe("claude-3-5-sonnet") + }) + + it("#when called with claude-3.5-sonnet-20241022 #then normalizes dots and strips date", () => { + expect(mapClaudeModelToOpenCode("claude-3.5-sonnet-20241022")).toBe("claude-3-5-sonnet") + }) + }) + + describe("#given already-normalized model", () => { + it("#when called with claude-sonnet-4-6 #then returns unchanged", () => { + expect(mapClaudeModelToOpenCode("claude-sonnet-4-6")).toBe("claude-sonnet-4-6") + }) + + it("#when called with claude-opus-4-6 #then returns unchanged", () => { + expect(mapClaudeModelToOpenCode("claude-opus-4-6")).toBe("claude-opus-4-6") + }) + + it("#when called with claude-haiku-4-5 #then returns unchanged", () => { + expect(mapClaudeModelToOpenCode("claude-haiku-4-5")).toBe("claude-haiku-4-5") + }) + }) + + describe("#given non-Claude model", () => { + it("#when called with gpt-5.2 #then normalizes dots only", () => { + expect(mapClaudeModelToOpenCode("gpt-5.2")).toBe("gpt-5-2") + }) + + it("#when called with gemini-3-flash #then returns unchanged", () => { + expect(mapClaudeModelToOpenCode("gemini-3-flash")).toBe("gemini-3-flash") + }) + + it("#when called with a custom model name #then returns unchanged", () => { + expect(mapClaudeModelToOpenCode("my-custom-model")).toBe("my-custom-model") + }) + }) + + describe("#given model with leading/trailing whitespace", () => { + it("#when called with padded string #then trims before mapping", () => { + expect(mapClaudeModelToOpenCode(" claude-sonnet-4-5-20250514 ")).toBe("claude-sonnet-4-5") + }) + }) +}) diff --git a/src/features/claude-code-agent-loader/claude-model-mapper.ts b/src/features/claude-code-agent-loader/claude-model-mapper.ts new file mode 100644 index 000000000..4bf173234 --- /dev/null +++ b/src/features/claude-code-agent-loader/claude-model-mapper.ts @@ -0,0 +1,13 @@ +import { normalizeModelID } from "../../shared/model-normalization" + +const DATE_SUFFIX_PATTERN = /-\d{8}$/ + +export function mapClaudeModelToOpenCode(model: string | undefined): string | undefined { + if (!model) return undefined + + const trimmed = model.trim() + if (trimmed.length === 0) return undefined + + const withoutDate = trimmed.replace(DATE_SUFFIX_PATTERN, "") + return normalizeModelID(withoutDate) +} diff --git a/src/features/claude-code-agent-loader/loader.ts b/src/features/claude-code-agent-loader/loader.ts index 407525687..e15aa512c 100644 --- a/src/features/claude-code-agent-loader/loader.ts +++ b/src/features/claude-code-agent-loader/loader.ts @@ -5,6 +5,7 @@ import { parseFrontmatter } from "../../shared/frontmatter" import { isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types" +import { mapClaudeModelToOpenCode } from "./claude-model-mapper" function parseToolsConfig(toolsStr?: string): Record | undefined { if (!toolsStr) return undefined @@ -42,10 +43,13 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] const formattedDescription = `(${scope}) ${originalDescription}` + const mappedModel = mapClaudeModelToOpenCode(data.model) + const config: AgentConfig = { description: formattedDescription, mode: "subagent", prompt: body.trim(), + ...(mappedModel && { model: mappedModel }), } const toolsConfig = parseToolsConfig(data.tools) diff --git a/src/features/claude-code-plugin-loader/agent-loader.ts b/src/features/claude-code-plugin-loader/agent-loader.ts index 0f52dac52..8e292ebe8 100644 --- a/src/features/claude-code-plugin-loader/agent-loader.ts +++ b/src/features/claude-code-plugin-loader/agent-loader.ts @@ -5,6 +5,7 @@ import { parseFrontmatter } from "../../shared/frontmatter" import { isMarkdownFile } from "../../shared/file-utils" import { log } from "../../shared/logger" import type { AgentFrontmatter } from "../claude-code-agent-loader/types" +import { mapClaudeModelToOpenCode } from "../claude-code-agent-loader/claude-model-mapper" import type { LoadedPlugin } from "./types" function parseToolsConfig(toolsStr?: string): Record | undefined { @@ -46,10 +47,13 @@ export function loadPluginAgents(plugins: LoadedPlugin[]): Record Date: Fri, 6 Mar 2026 12:00:54 +0900 Subject: [PATCH 2/5] add anthropic/ provider prefix for claude models, preserve date suffixes, passthrough provider-prefixed models --- .../claude-model-mapper.test.ts | 46 +++++++++---------- .../claude-model-mapper.ts | 15 ++++-- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/features/claude-code-agent-loader/claude-model-mapper.test.ts b/src/features/claude-code-agent-loader/claude-model-mapper.test.ts index 30a3312a3..df2ab8d25 100644 --- a/src/features/claude-code-agent-loader/claude-model-mapper.test.ts +++ b/src/features/claude-code-agent-loader/claude-model-mapper.test.ts @@ -16,50 +16,46 @@ describe("mapClaudeModelToOpenCode", () => { }) }) - describe("#given model with date suffix", () => { - it("#when called with claude-sonnet-4-5-20250514 #then strips date suffix", () => { - expect(mapClaudeModelToOpenCode("claude-sonnet-4-5-20250514")).toBe("claude-sonnet-4-5") + describe("#given bare Claude model name", () => { + it("#when called with claude-sonnet-4-5-20250514 #then adds anthropic prefix", () => { + expect(mapClaudeModelToOpenCode("claude-sonnet-4-5-20250514")).toBe("anthropic/claude-sonnet-4-5-20250514") }) - it("#when called with claude-opus-4-20250414 #then strips date suffix", () => { - expect(mapClaudeModelToOpenCode("claude-opus-4-20250414")).toBe("claude-opus-4") + it("#when called with claude-opus-4-6 #then adds anthropic prefix", () => { + expect(mapClaudeModelToOpenCode("claude-opus-4-6")).toBe("anthropic/claude-opus-4-6") }) - it("#when called with claude-haiku-4-5-20250514 #then strips date suffix", () => { - expect(mapClaudeModelToOpenCode("claude-haiku-4-5-20250514")).toBe("claude-haiku-4-5") + it("#when called with claude-haiku-4-5 #then adds anthropic prefix", () => { + expect(mapClaudeModelToOpenCode("claude-haiku-4-5")).toBe("anthropic/claude-haiku-4-5") }) - it("#when called with claude-3-5-sonnet-20241022 #then strips date suffix", () => { - expect(mapClaudeModelToOpenCode("claude-3-5-sonnet-20241022")).toBe("claude-3-5-sonnet") + it("#when called with claude-3-5-sonnet-20241022 #then adds anthropic prefix", () => { + expect(mapClaudeModelToOpenCode("claude-3-5-sonnet-20241022")).toBe("anthropic/claude-3-5-sonnet-20241022") }) }) describe("#given model with dot version numbers", () => { - it("#when called with claude-3.5-sonnet #then normalizes dots to dashes", () => { - expect(mapClaudeModelToOpenCode("claude-3.5-sonnet")).toBe("claude-3-5-sonnet") + it("#when called with claude-3.5-sonnet #then normalizes dots and adds prefix", () => { + expect(mapClaudeModelToOpenCode("claude-3.5-sonnet")).toBe("anthropic/claude-3-5-sonnet") }) - it("#when called with claude-3.5-sonnet-20241022 #then normalizes dots and strips date", () => { - expect(mapClaudeModelToOpenCode("claude-3.5-sonnet-20241022")).toBe("claude-3-5-sonnet") + it("#when called with claude-3.5-sonnet-20241022 #then normalizes dots and adds prefix", () => { + expect(mapClaudeModelToOpenCode("claude-3.5-sonnet-20241022")).toBe("anthropic/claude-3-5-sonnet-20241022") }) }) - describe("#given already-normalized model", () => { - it("#when called with claude-sonnet-4-6 #then returns unchanged", () => { - expect(mapClaudeModelToOpenCode("claude-sonnet-4-6")).toBe("claude-sonnet-4-6") + describe("#given model already in provider/model format", () => { + it("#when called with anthropic/claude-sonnet-4-6 #then passes through unchanged", () => { + expect(mapClaudeModelToOpenCode("anthropic/claude-sonnet-4-6")).toBe("anthropic/claude-sonnet-4-6") }) - it("#when called with claude-opus-4-6 #then returns unchanged", () => { - expect(mapClaudeModelToOpenCode("claude-opus-4-6")).toBe("claude-opus-4-6") - }) - - it("#when called with claude-haiku-4-5 #then returns unchanged", () => { - expect(mapClaudeModelToOpenCode("claude-haiku-4-5")).toBe("claude-haiku-4-5") + it("#when called with openai/gpt-5.2 #then passes through unchanged", () => { + expect(mapClaudeModelToOpenCode("openai/gpt-5.2")).toBe("openai/gpt-5.2") }) }) - describe("#given non-Claude model", () => { - it("#when called with gpt-5.2 #then normalizes dots only", () => { + describe("#given non-Claude bare model", () => { + it("#when called with gpt-5.2 #then normalizes dots without adding prefix", () => { expect(mapClaudeModelToOpenCode("gpt-5.2")).toBe("gpt-5-2") }) @@ -74,7 +70,7 @@ describe("mapClaudeModelToOpenCode", () => { describe("#given model with leading/trailing whitespace", () => { it("#when called with padded string #then trims before mapping", () => { - expect(mapClaudeModelToOpenCode(" claude-sonnet-4-5-20250514 ")).toBe("claude-sonnet-4-5") + expect(mapClaudeModelToOpenCode(" claude-sonnet-4-6 ")).toBe("anthropic/claude-sonnet-4-6") }) }) }) diff --git a/src/features/claude-code-agent-loader/claude-model-mapper.ts b/src/features/claude-code-agent-loader/claude-model-mapper.ts index 4bf173234..7d1c5dc5f 100644 --- a/src/features/claude-code-agent-loader/claude-model-mapper.ts +++ b/src/features/claude-code-agent-loader/claude-model-mapper.ts @@ -1,6 +1,6 @@ import { normalizeModelID } from "../../shared/model-normalization" -const DATE_SUFFIX_PATTERN = /-\d{8}$/ +const ANTHROPIC_PREFIX = "anthropic/" export function mapClaudeModelToOpenCode(model: string | undefined): string | undefined { if (!model) return undefined @@ -8,6 +8,15 @@ export function mapClaudeModelToOpenCode(model: string | undefined): string | un const trimmed = model.trim() if (trimmed.length === 0) return undefined - const withoutDate = trimmed.replace(DATE_SUFFIX_PATTERN, "") - return normalizeModelID(withoutDate) + if (trimmed.includes("/")) { + return trimmed + } + + const normalized = normalizeModelID(trimmed) + + if (normalized.startsWith("claude-")) { + return `${ANTHROPIC_PREFIX}${normalized}` + } + + return normalized } From fec37e2409363db37431ba03d0f8370ec5f28386 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Fri, 6 Mar 2026 12:06:57 +0900 Subject: [PATCH 3/5] handle Claude Code official model aliases (sonnet, opus, haiku, inherit) --- .../claude-model-mapper.test.ts | 32 +++++++++++++++---- .../claude-model-mapper.ts | 15 +++++++-- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/features/claude-code-agent-loader/claude-model-mapper.test.ts b/src/features/claude-code-agent-loader/claude-model-mapper.test.ts index df2ab8d25..b3da88759 100644 --- a/src/features/claude-code-agent-loader/claude-model-mapper.test.ts +++ b/src/features/claude-code-agent-loader/claude-model-mapper.test.ts @@ -16,6 +16,30 @@ describe("mapClaudeModelToOpenCode", () => { }) }) + describe("#given Claude Code alias", () => { + it("#when called with sonnet #then maps to anthropic/claude-sonnet-4-6", () => { + expect(mapClaudeModelToOpenCode("sonnet")).toBe("anthropic/claude-sonnet-4-6") + }) + + it("#when called with opus #then maps to anthropic/claude-opus-4-6", () => { + expect(mapClaudeModelToOpenCode("opus")).toBe("anthropic/claude-opus-4-6") + }) + + it("#when called with haiku #then maps to anthropic/claude-haiku-4-5", () => { + expect(mapClaudeModelToOpenCode("haiku")).toBe("anthropic/claude-haiku-4-5") + }) + + it("#when called with Sonnet (capitalized) #then maps case-insensitively", () => { + expect(mapClaudeModelToOpenCode("Sonnet")).toBe("anthropic/claude-sonnet-4-6") + }) + }) + + describe("#given inherit", () => { + it("#when called with inherit #then returns undefined", () => { + expect(mapClaudeModelToOpenCode("inherit")).toBeUndefined() + }) + }) + describe("#given bare Claude model name", () => { it("#when called with claude-sonnet-4-5-20250514 #then adds anthropic prefix", () => { expect(mapClaudeModelToOpenCode("claude-sonnet-4-5-20250514")).toBe("anthropic/claude-sonnet-4-5-20250514") @@ -25,8 +49,8 @@ describe("mapClaudeModelToOpenCode", () => { expect(mapClaudeModelToOpenCode("claude-opus-4-6")).toBe("anthropic/claude-opus-4-6") }) - it("#when called with claude-haiku-4-5 #then adds anthropic prefix", () => { - expect(mapClaudeModelToOpenCode("claude-haiku-4-5")).toBe("anthropic/claude-haiku-4-5") + it("#when called with claude-haiku-4-5-20251001 #then adds anthropic prefix", () => { + expect(mapClaudeModelToOpenCode("claude-haiku-4-5-20251001")).toBe("anthropic/claude-haiku-4-5-20251001") }) it("#when called with claude-3-5-sonnet-20241022 #then adds anthropic prefix", () => { @@ -62,10 +86,6 @@ describe("mapClaudeModelToOpenCode", () => { it("#when called with gemini-3-flash #then returns unchanged", () => { expect(mapClaudeModelToOpenCode("gemini-3-flash")).toBe("gemini-3-flash") }) - - it("#when called with a custom model name #then returns unchanged", () => { - expect(mapClaudeModelToOpenCode("my-custom-model")).toBe("my-custom-model") - }) }) describe("#given model with leading/trailing whitespace", () => { diff --git a/src/features/claude-code-agent-loader/claude-model-mapper.ts b/src/features/claude-code-agent-loader/claude-model-mapper.ts index 7d1c5dc5f..6d9b120d2 100644 --- a/src/features/claude-code-agent-loader/claude-model-mapper.ts +++ b/src/features/claude-code-agent-loader/claude-model-mapper.ts @@ -2,15 +2,24 @@ import { normalizeModelID } from "../../shared/model-normalization" const ANTHROPIC_PREFIX = "anthropic/" +const CLAUDE_CODE_ALIAS_MAP: Record = { + sonnet: `${ANTHROPIC_PREFIX}claude-sonnet-4-6`, + opus: `${ANTHROPIC_PREFIX}claude-opus-4-6`, + haiku: `${ANTHROPIC_PREFIX}claude-haiku-4-5`, +} + export function mapClaudeModelToOpenCode(model: string | undefined): string | undefined { if (!model) return undefined const trimmed = model.trim() if (trimmed.length === 0) return undefined - if (trimmed.includes("/")) { - return trimmed - } + if (trimmed === "inherit") return undefined + + const aliasResult = CLAUDE_CODE_ALIAS_MAP[trimmed.toLowerCase()] + if (aliasResult) return aliasResult + + if (trimmed.includes("/")) return trimmed const normalized = normalizeModelID(trimmed) From 0f999cdc0113e54bb5712882ba7f20a5be127f23 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Fri, 6 Mar 2026 12:16:34 +0900 Subject: [PATCH 4/5] use Map for alias lookup to prevent prototype pollution, return undefined for non-Claude bare models --- .../claude-model-mapper.test.ts | 18 ++++++++++++++---- .../claude-model-mapper.ts | 14 +++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/features/claude-code-agent-loader/claude-model-mapper.test.ts b/src/features/claude-code-agent-loader/claude-model-mapper.test.ts index b3da88759..c01075d60 100644 --- a/src/features/claude-code-agent-loader/claude-model-mapper.test.ts +++ b/src/features/claude-code-agent-loader/claude-model-mapper.test.ts @@ -79,12 +79,22 @@ describe("mapClaudeModelToOpenCode", () => { }) describe("#given non-Claude bare model", () => { - it("#when called with gpt-5.2 #then normalizes dots without adding prefix", () => { - expect(mapClaudeModelToOpenCode("gpt-5.2")).toBe("gpt-5-2") + it("#when called with gpt-5.2 #then returns undefined", () => { + expect(mapClaudeModelToOpenCode("gpt-5.2")).toBeUndefined() }) - it("#when called with gemini-3-flash #then returns unchanged", () => { - expect(mapClaudeModelToOpenCode("gemini-3-flash")).toBe("gemini-3-flash") + it("#when called with gemini-3-flash #then returns undefined", () => { + expect(mapClaudeModelToOpenCode("gemini-3-flash")).toBeUndefined() + }) + }) + + describe("#given prototype property name", () => { + it("#when called with constructor #then returns undefined", () => { + expect(mapClaudeModelToOpenCode("constructor")).toBeUndefined() + }) + + it("#when called with toString #then returns undefined", () => { + expect(mapClaudeModelToOpenCode("toString")).toBeUndefined() }) }) diff --git a/src/features/claude-code-agent-loader/claude-model-mapper.ts b/src/features/claude-code-agent-loader/claude-model-mapper.ts index 6d9b120d2..ebdbd3c86 100644 --- a/src/features/claude-code-agent-loader/claude-model-mapper.ts +++ b/src/features/claude-code-agent-loader/claude-model-mapper.ts @@ -2,11 +2,11 @@ import { normalizeModelID } from "../../shared/model-normalization" const ANTHROPIC_PREFIX = "anthropic/" -const CLAUDE_CODE_ALIAS_MAP: Record = { - sonnet: `${ANTHROPIC_PREFIX}claude-sonnet-4-6`, - opus: `${ANTHROPIC_PREFIX}claude-opus-4-6`, - haiku: `${ANTHROPIC_PREFIX}claude-haiku-4-5`, -} +const CLAUDE_CODE_ALIAS_MAP = new Map([ + ["sonnet", `${ANTHROPIC_PREFIX}claude-sonnet-4-6`], + ["opus", `${ANTHROPIC_PREFIX}claude-opus-4-6`], + ["haiku", `${ANTHROPIC_PREFIX}claude-haiku-4-5`], +]) export function mapClaudeModelToOpenCode(model: string | undefined): string | undefined { if (!model) return undefined @@ -16,7 +16,7 @@ export function mapClaudeModelToOpenCode(model: string | undefined): string | un if (trimmed === "inherit") return undefined - const aliasResult = CLAUDE_CODE_ALIAS_MAP[trimmed.toLowerCase()] + const aliasResult = CLAUDE_CODE_ALIAS_MAP.get(trimmed.toLowerCase()) if (aliasResult) return aliasResult if (trimmed.includes("/")) return trimmed @@ -27,5 +27,5 @@ export function mapClaudeModelToOpenCode(model: string | undefined): string | un return `${ANTHROPIC_PREFIX}${normalized}` } - return normalized + return undefined } From 42447d75a4ba071aa2ed13bfa66b23ee27795fd1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Mar 2026 17:07:23 +0900 Subject: [PATCH 5/5] map Claude Code model strings to OpenCode format with proper object structure --- .../claude-model-mapper.test.ts | 54 ++++++++++--------- .../claude-model-mapper.ts | 10 +++- .../claude-code-agent-loader/loader.ts | 17 +++--- .../claude-code-agent-loader/types.ts | 6 ++- .../claude-code-plugin-loader/agent-loader.ts | 13 +++-- .../claude-code-plugin-loader/loader.ts | 4 +- 6 files changed, 58 insertions(+), 46 deletions(-) diff --git a/src/features/claude-code-agent-loader/claude-model-mapper.test.ts b/src/features/claude-code-agent-loader/claude-model-mapper.test.ts index c01075d60..e0a9ec638 100644 --- a/src/features/claude-code-agent-loader/claude-model-mapper.test.ts +++ b/src/features/claude-code-agent-loader/claude-model-mapper.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, it, expect } from "bun:test" import { mapClaudeModelToOpenCode } from "./claude-model-mapper" @@ -17,20 +19,20 @@ describe("mapClaudeModelToOpenCode", () => { }) describe("#given Claude Code alias", () => { - it("#when called with sonnet #then maps to anthropic/claude-sonnet-4-6", () => { - expect(mapClaudeModelToOpenCode("sonnet")).toBe("anthropic/claude-sonnet-4-6") + it("#when called with sonnet #then maps to anthropic claude-sonnet-4-6 object", () => { + expect(mapClaudeModelToOpenCode("sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" }) }) - it("#when called with opus #then maps to anthropic/claude-opus-4-6", () => { - expect(mapClaudeModelToOpenCode("opus")).toBe("anthropic/claude-opus-4-6") + it("#when called with opus #then maps to anthropic claude-opus-4-6 object", () => { + expect(mapClaudeModelToOpenCode("opus")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" }) }) - it("#when called with haiku #then maps to anthropic/claude-haiku-4-5", () => { - expect(mapClaudeModelToOpenCode("haiku")).toBe("anthropic/claude-haiku-4-5") + it("#when called with haiku #then maps to anthropic claude-haiku-4-5 object", () => { + expect(mapClaudeModelToOpenCode("haiku")).toEqual({ providerID: "anthropic", modelID: "claude-haiku-4-5" }) }) - it("#when called with Sonnet (capitalized) #then maps case-insensitively", () => { - expect(mapClaudeModelToOpenCode("Sonnet")).toBe("anthropic/claude-sonnet-4-6") + it("#when called with Sonnet (capitalized) #then maps case-insensitively to object", () => { + expect(mapClaudeModelToOpenCode("Sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" }) }) }) @@ -41,40 +43,40 @@ describe("mapClaudeModelToOpenCode", () => { }) describe("#given bare Claude model name", () => { - it("#when called with claude-sonnet-4-5-20250514 #then adds anthropic prefix", () => { - expect(mapClaudeModelToOpenCode("claude-sonnet-4-5-20250514")).toBe("anthropic/claude-sonnet-4-5-20250514") + it("#when called with claude-sonnet-4-5-20250514 #then adds anthropic object format", () => { + expect(mapClaudeModelToOpenCode("claude-sonnet-4-5-20250514")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-5-20250514" }) }) - it("#when called with claude-opus-4-6 #then adds anthropic prefix", () => { - expect(mapClaudeModelToOpenCode("claude-opus-4-6")).toBe("anthropic/claude-opus-4-6") + it("#when called with claude-opus-4-6 #then adds anthropic object format", () => { + expect(mapClaudeModelToOpenCode("claude-opus-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" }) }) - it("#when called with claude-haiku-4-5-20251001 #then adds anthropic prefix", () => { - expect(mapClaudeModelToOpenCode("claude-haiku-4-5-20251001")).toBe("anthropic/claude-haiku-4-5-20251001") + it("#when called with claude-haiku-4-5-20251001 #then adds anthropic object format", () => { + expect(mapClaudeModelToOpenCode("claude-haiku-4-5-20251001")).toEqual({ providerID: "anthropic", modelID: "claude-haiku-4-5-20251001" }) }) - it("#when called with claude-3-5-sonnet-20241022 #then adds anthropic prefix", () => { - expect(mapClaudeModelToOpenCode("claude-3-5-sonnet-20241022")).toBe("anthropic/claude-3-5-sonnet-20241022") + it("#when called with claude-3-5-sonnet-20241022 #then adds anthropic object format", () => { + expect(mapClaudeModelToOpenCode("claude-3-5-sonnet-20241022")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" }) }) }) describe("#given model with dot version numbers", () => { - it("#when called with claude-3.5-sonnet #then normalizes dots and adds prefix", () => { - expect(mapClaudeModelToOpenCode("claude-3.5-sonnet")).toBe("anthropic/claude-3-5-sonnet") + it("#when called with claude-3.5-sonnet #then normalizes dots and returns object format", () => { + expect(mapClaudeModelToOpenCode("claude-3.5-sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet" }) }) - it("#when called with claude-3.5-sonnet-20241022 #then normalizes dots and adds prefix", () => { - expect(mapClaudeModelToOpenCode("claude-3.5-sonnet-20241022")).toBe("anthropic/claude-3-5-sonnet-20241022") + it("#when called with claude-3.5-sonnet-20241022 #then normalizes dots and returns object format", () => { + expect(mapClaudeModelToOpenCode("claude-3.5-sonnet-20241022")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" }) }) }) describe("#given model already in provider/model format", () => { - it("#when called with anthropic/claude-sonnet-4-6 #then passes through unchanged", () => { - expect(mapClaudeModelToOpenCode("anthropic/claude-sonnet-4-6")).toBe("anthropic/claude-sonnet-4-6") + it("#when called with anthropic/claude-sonnet-4-6 #then splits into object format", () => { + expect(mapClaudeModelToOpenCode("anthropic/claude-sonnet-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" }) }) - it("#when called with openai/gpt-5.2 #then passes through unchanged", () => { - expect(mapClaudeModelToOpenCode("openai/gpt-5.2")).toBe("openai/gpt-5.2") + it("#when called with openai/gpt-5.2 #then splits into object format", () => { + expect(mapClaudeModelToOpenCode("openai/gpt-5.2")).toEqual({ providerID: "openai", modelID: "gpt-5.2" }) }) }) @@ -99,8 +101,8 @@ describe("mapClaudeModelToOpenCode", () => { }) describe("#given model with leading/trailing whitespace", () => { - it("#when called with padded string #then trims before mapping", () => { - expect(mapClaudeModelToOpenCode(" claude-sonnet-4-6 ")).toBe("anthropic/claude-sonnet-4-6") + it("#when called with padded string #then trims before returning object format", () => { + expect(mapClaudeModelToOpenCode(" claude-sonnet-4-6 ")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" }) }) }) }) diff --git a/src/features/claude-code-agent-loader/claude-model-mapper.ts b/src/features/claude-code-agent-loader/claude-model-mapper.ts index ebdbd3c86..bee1be6f9 100644 --- a/src/features/claude-code-agent-loader/claude-model-mapper.ts +++ b/src/features/claude-code-agent-loader/claude-model-mapper.ts @@ -1,3 +1,4 @@ +import { normalizeModelFormat } from "../../shared/model-format-normalizer" import { normalizeModelID } from "../../shared/model-normalization" const ANTHROPIC_PREFIX = "anthropic/" @@ -8,7 +9,7 @@ const CLAUDE_CODE_ALIAS_MAP = new Map([ ["haiku", `${ANTHROPIC_PREFIX}claude-haiku-4-5`], ]) -export function mapClaudeModelToOpenCode(model: string | undefined): string | undefined { +function mapClaudeModelString(model: string | undefined): string | undefined { if (!model) return undefined const trimmed = model.trim() @@ -29,3 +30,10 @@ export function mapClaudeModelToOpenCode(model: string | undefined): string | un return undefined } + +export function mapClaudeModelToOpenCode( + model: string | undefined +): { providerID: string; modelID: string } | undefined { + const mappedModel = mapClaudeModelString(model) + return mappedModel ? normalizeModelFormat(mappedModel) : undefined +} diff --git a/src/features/claude-code-agent-loader/loader.ts b/src/features/claude-code-agent-loader/loader.ts index e15aa512c..f74d27cc6 100644 --- a/src/features/claude-code-agent-loader/loader.ts +++ b/src/features/claude-code-agent-loader/loader.ts @@ -1,10 +1,9 @@ import { existsSync, readdirSync, readFileSync } from "fs" import { join, basename } from "path" -import type { AgentConfig } from "@opencode-ai/sdk" import { parseFrontmatter } from "../../shared/frontmatter" import { isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" -import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types" +import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types" import { mapClaudeModelToOpenCode } from "./claude-model-mapper" function parseToolsConfig(toolsStr?: string): Record | undefined { @@ -43,13 +42,13 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] const formattedDescription = `(${scope}) ${originalDescription}` - const mappedModel = mapClaudeModelToOpenCode(data.model) + const mappedModelOverride = mapClaudeModelToOpenCode(data.model) - const config: AgentConfig = { + const config: ClaudeCodeAgentConfig = { description: formattedDescription, mode: "subagent", prompt: body.trim(), - ...(mappedModel && { model: mappedModel }), + ...(mappedModelOverride ? { model: mappedModelOverride } : {}), } const toolsConfig = parseToolsConfig(data.tools) @@ -71,22 +70,22 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] return agents } -export function loadUserAgents(): Record { +export function loadUserAgents(): Record { const userAgentsDir = join(getClaudeConfigDir(), "agents") const agents = loadAgentsFromDir(userAgentsDir, "user") - const result: Record = {} + const result: Record = {} for (const agent of agents) { result[agent.name] = agent.config } return result } -export function loadProjectAgents(directory?: string): Record { +export function loadProjectAgents(directory?: string): Record { const projectAgentsDir = join(directory ?? process.cwd(), ".claude", "agents") const agents = loadAgentsFromDir(projectAgentsDir, "project") - const result: Record = {} + const result: Record = {} for (const agent of agents) { result[agent.name] = agent.config } diff --git a/src/features/claude-code-agent-loader/types.ts b/src/features/claude-code-agent-loader/types.ts index 4ffd9de40..3ccad3ccb 100644 --- a/src/features/claude-code-agent-loader/types.ts +++ b/src/features/claude-code-agent-loader/types.ts @@ -2,6 +2,10 @@ import type { AgentConfig } from "@opencode-ai/sdk" export type AgentScope = "user" | "project" +export type ClaudeCodeAgentConfig = Omit & { + model?: string | { providerID: string; modelID: string } +} + export interface AgentFrontmatter { name?: string description?: string @@ -12,6 +16,6 @@ export interface AgentFrontmatter { export interface LoadedAgent { name: string path: string - config: AgentConfig + config: ClaudeCodeAgentConfig scope: AgentScope } diff --git a/src/features/claude-code-plugin-loader/agent-loader.ts b/src/features/claude-code-plugin-loader/agent-loader.ts index 8e292ebe8..215e29d1b 100644 --- a/src/features/claude-code-plugin-loader/agent-loader.ts +++ b/src/features/claude-code-plugin-loader/agent-loader.ts @@ -1,10 +1,9 @@ import { existsSync, readdirSync, readFileSync } from "fs" import { basename, join } from "path" -import type { AgentConfig } from "@opencode-ai/sdk" import { parseFrontmatter } from "../../shared/frontmatter" import { isMarkdownFile } from "../../shared/file-utils" import { log } from "../../shared/logger" -import type { AgentFrontmatter } from "../claude-code-agent-loader/types" +import type { AgentFrontmatter, ClaudeCodeAgentConfig } from "../claude-code-agent-loader/types" import { mapClaudeModelToOpenCode } from "../claude-code-agent-loader/claude-model-mapper" import type { LoadedPlugin } from "./types" @@ -25,8 +24,8 @@ function parseToolsConfig(toolsStr?: string): Record | undefine return result } -export function loadPluginAgents(plugins: LoadedPlugin[]): Record { - const agents: Record = {} +export function loadPluginAgents(plugins: LoadedPlugin[]): Record { + const agents: Record = {} for (const plugin of plugins) { if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue @@ -47,13 +46,13 @@ export function loadPluginAgents(plugins: LoadedPlugin[]): Record skills: Record - agents: Record + agents: Record mcpServers: Record hooksConfigs: HooksConfig[] plugins: LoadedPlugin[]