-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(claude): map Claude Code model strings to OpenCode format when importing agents #2333
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
77a2ab7
5e25f55
567f507
96b5811
c6ea3f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| /// <reference types="bun-types" /> | ||
|
|
||
| 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 Claude Code alias", () => { | ||
| 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 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 object", () => { | ||
| expect(mapClaudeModelToOpenCode("haiku")).toEqual({ providerID: "anthropic", modelID: "claude-haiku-4-5" }) | ||
| }) | ||
|
|
||
| it("#when called with Sonnet (capitalized) #then maps case-insensitively to object", () => { | ||
| expect(mapClaudeModelToOpenCode("Sonnet")).toEqual({ providerID: "anthropic", modelID: "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 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 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 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 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 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 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 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 splits into object format", () => { | ||
| expect(mapClaudeModelToOpenCode("openai/gpt-5.2")).toEqual({ providerID: "openai", modelID: "gpt-5.2" }) | ||
| }) | ||
| }) | ||
|
|
||
| describe("#given non-Claude bare model", () => { | ||
| 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 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() | ||
| }) | ||
| }) | ||
|
|
||
| describe("#given model with leading/trailing whitespace", () => { | ||
| 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" }) | ||
| }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { normalizeModelFormat } from "../../shared/model-format-normalizer" | ||
| import { normalizeModelID } from "../../shared/model-normalization" | ||
|
|
||
| const ANTHROPIC_PREFIX = "anthropic/" | ||
|
|
||
| const CLAUDE_CODE_ALIAS_MAP = new Map<string, string>([ | ||
| ["sonnet", `${ANTHROPIC_PREFIX}claude-sonnet-4-6`], | ||
| ["opus", `${ANTHROPIC_PREFIX}claude-opus-4-6`], | ||
| ["haiku", `${ANTHROPIC_PREFIX}claude-haiku-4-5`], | ||
| ]) | ||
|
|
||
| function mapClaudeModelString(model: string | undefined): string | undefined { | ||
| if (!model) return undefined | ||
|
|
||
| const trimmed = model.trim() | ||
| if (trimmed.length === 0) return undefined | ||
|
|
||
| if (trimmed === "inherit") return undefined | ||
|
|
||
| const aliasResult = CLAUDE_CODE_ALIAS_MAP.get(trimmed.toLowerCase()) | ||
| if (aliasResult) return aliasResult | ||
|
|
||
| if (trimmed.includes("/")) return trimmed | ||
|
|
||
| const normalized = normalizeModelID(trimmed) | ||
|
|
||
| if (normalized.startsWith("claude-")) { | ||
| return `${ANTHROPIC_PREFIX}${normalized}` | ||
| } | ||
|
|
||
| return undefined | ||
| } | ||
|
|
||
| export function mapClaudeModelToOpenCode( | ||
| model: string | undefined | ||
| ): { providerID: string; modelID: string } | undefined { | ||
| const mappedModel = mapClaudeModelString(model) | ||
| return mappedModel ? normalizeModelFormat(mappedModel) : undefined | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,10 +1,10 @@ | ||||||
| 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<string, boolean> | undefined { | ||||||
| if (!toolsStr) return undefined | ||||||
|
|
@@ -42,10 +42,13 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] | |||||
|
|
||||||
| const formattedDescription = `(${scope}) ${originalDescription}` | ||||||
|
|
||||||
| const config: AgentConfig = { | ||||||
| const mappedModelOverride = mapClaudeModelToOpenCode(data.model) | ||||||
|
|
||||||
| const config: ClaudeCodeAgentConfig = { | ||||||
| description: formattedDescription, | ||||||
| mode: "subagent", | ||||||
| prompt: body.trim(), | ||||||
| ...(mappedModelOverride ? { model: mappedModelOverride } : {}), | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Custom agent: Opencode Compatibility The OpenCode SDK's Prompt for AI agents
Suggested change
|
||||||
| } | ||||||
|
|
||||||
| const toolsConfig = parseToolsConfig(data.tools) | ||||||
|
|
@@ -67,22 +70,22 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] | |||||
| return agents | ||||||
| } | ||||||
|
|
||||||
| export function loadUserAgents(): Record<string, AgentConfig> { | ||||||
| export function loadUserAgents(): Record<string, ClaudeCodeAgentConfig> { | ||||||
| const userAgentsDir = join(getClaudeConfigDir(), "agents") | ||||||
| const agents = loadAgentsFromDir(userAgentsDir, "user") | ||||||
|
|
||||||
| const result: Record<string, AgentConfig> = {} | ||||||
| const result: Record<string, ClaudeCodeAgentConfig> = {} | ||||||
| for (const agent of agents) { | ||||||
| result[agent.name] = agent.config | ||||||
| } | ||||||
| return result | ||||||
| } | ||||||
|
|
||||||
| export function loadProjectAgents(directory?: string): Record<string, AgentConfig> { | ||||||
| export function loadProjectAgents(directory?: string): Record<string, ClaudeCodeAgentConfig> { | ||||||
| const projectAgentsDir = join(directory ?? process.cwd(), ".claude", "agents") | ||||||
| const agents = loadAgentsFromDir(projectAgentsDir, "project") | ||||||
|
|
||||||
| const result: Record<string, AgentConfig> = {} | ||||||
| const result: Record<string, ClaudeCodeAgentConfig> = {} | ||||||
| for (const agent of agents) { | ||||||
| result[agent.name] = agent.config | ||||||
| } | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,10 @@ | ||
| 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" | ||
|
|
||
| function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined { | ||
|
|
@@ -24,8 +24,8 @@ function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefine | |
| return result | ||
| } | ||
|
|
||
| export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, AgentConfig> { | ||
| const agents: Record<string, AgentConfig> = {} | ||
| export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, ClaudeCodeAgentConfig> { | ||
| const agents: Record<string, ClaudeCodeAgentConfig> = {} | ||
|
|
||
| for (const plugin of plugins) { | ||
| if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue | ||
|
|
@@ -46,10 +46,13 @@ export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, AgentC | |
| const originalDescription = data.description || "" | ||
| const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}` | ||
|
|
||
| const config: AgentConfig = { | ||
| const mappedModelOverride = mapClaudeModelToOpenCode(data.model) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Runtime type validation missing in Prompt for AI agents |
||
|
|
||
| const config: ClaudeCodeAgentConfig = { | ||
| description: formattedDescription, | ||
| mode: "subagent", | ||
| prompt: body.trim(), | ||
| ...(mappedModelOverride ? { model: mappedModelOverride } : {}), | ||
| } | ||
|
|
||
| const toolsConfig = parseToolsConfig(data.tools) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: Non-string YAML values in
data.modelcan cause mapping to throw, silently skipping the agentPrompt for AI agents