Merge pull request #4385 from code-yeongyu/refactor/prompts-core-foundation
refactor(prompts-core): add packages/prompts-core foundation with model-family detector relocation
This commit is contained in:
@@ -108,6 +108,14 @@
|
||||
"@oh-my-opencode/utils": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/prompts-core": {
|
||||
"name": "@oh-my-opencode/prompts-core",
|
||||
"version": "0.1.0",
|
||||
"peerDependencies": {
|
||||
"@oh-my-opencode/model-core": "workspace:*",
|
||||
"@oh-my-opencode/utils": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/rules-engine": {
|
||||
"name": "@oh-my-opencode/rules-engine",
|
||||
"version": "0.1.0",
|
||||
@@ -209,6 +217,8 @@
|
||||
|
||||
"@oh-my-opencode/model-core": ["@oh-my-opencode/model-core@workspace:packages/model-core"],
|
||||
|
||||
"@oh-my-opencode/prompts-core": ["@oh-my-opencode/prompts-core@workspace:packages/prompts-core"],
|
||||
|
||||
"@oh-my-opencode/rules-engine": ["@oh-my-opencode/rules-engine@workspace:packages/rules-engine"],
|
||||
|
||||
"@oh-my-opencode/utils": ["@oh-my-opencode/utils@workspace:packages/utils"],
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@
|
||||
"packages/ast-grep-mcp",
|
||||
"packages/utils",
|
||||
"packages/model-core",
|
||||
"packages/prompts-core",
|
||||
"packages/comment-checker-core",
|
||||
"packages/hashline-core",
|
||||
"packages/boulder-state",
|
||||
@@ -49,7 +50,7 @@
|
||||
"prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
|
||||
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
|
||||
"typecheck": "tsgo --noEmit && bun run typecheck:packages",
|
||||
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json",
|
||||
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json",
|
||||
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
|
||||
"test": "bun test",
|
||||
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./model-requirements"
|
||||
export * from "./model-family-detectors"
|
||||
export * from "./model-capability-aliases"
|
||||
export * from "./model-capability-heuristics"
|
||||
export * from "./model-capability-guardrails"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
isClaudeOpus47Model,
|
||||
isGeminiModel,
|
||||
isGlmModel,
|
||||
isGptModel,
|
||||
isKimiK2Model,
|
||||
isMiniMaxModel,
|
||||
} from "./model-family-detectors"
|
||||
|
||||
describe("model family detectors", () => {
|
||||
test("#given GPT model ids #then detects GPT family only", () => {
|
||||
expect(isGptModel("openai/gpt-5.5")).toBe(true)
|
||||
expect(isGptModel("github-copilot/gpt-4o")).toBe(true)
|
||||
expect(isGptModel("openai/o3-mini")).toBe(false)
|
||||
expect(isGptModel("anthropic/claude-opus-4-7")).toBe(false)
|
||||
})
|
||||
|
||||
test("#given Gemini model ids #then detects Gemini family only", () => {
|
||||
expect(isGeminiModel("google/gemini-3.1-pro")).toBe(true)
|
||||
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true)
|
||||
expect(isGeminiModel("github-copilot/gemini-3.1-pro")).toBe(true)
|
||||
expect(isGeminiModel("openai/gpt-5.5")).toBe(false)
|
||||
})
|
||||
|
||||
test("#given Kimi K2 model ids #then detects Kimi K2 family only", () => {
|
||||
expect(isKimiK2Model("moonshotai/kimi-k2.6")).toBe(true)
|
||||
expect(isKimiK2Model("opencode/k2p5")).toBe(true)
|
||||
expect(isKimiK2Model("opencode/k2-p6")).toBe(true)
|
||||
expect(isKimiK2Model("anthropic/claude-opus-4-7")).toBe(false)
|
||||
})
|
||||
|
||||
test("#given GLM model ids #then detects GLM family only", () => {
|
||||
expect(isGlmModel("z-ai/glm-5.1")).toBe(true)
|
||||
expect(isGlmModel("opencode/glm-4.6v")).toBe(true)
|
||||
expect(isGlmModel("google/gemini-3.1-pro")).toBe(false)
|
||||
})
|
||||
|
||||
test("#given Claude Opus 4.7 model ids #then detects Opus 4.7 only", () => {
|
||||
expect(isClaudeOpus47Model("anthropic/claude-opus-4-7")).toBe(true)
|
||||
expect(isClaudeOpus47Model("anthropic/claude-opus-4.7")).toBe(true)
|
||||
expect(isClaudeOpus47Model("anthropic/claude-sonnet-4-6")).toBe(false)
|
||||
})
|
||||
|
||||
test("#given MiniMax model ids #then detects MiniMax family only", () => {
|
||||
expect(isMiniMaxModel("opencode/minimax-m2.7")).toBe(true)
|
||||
expect(isMiniMaxModel("minimax-m2.7-highspeed")).toBe(true)
|
||||
expect(isMiniMaxModel("moonshotai/kimi-k2.6")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
function extractModelName(model: string): string {
|
||||
return model.includes("/") ? (model.split("/").pop() ?? model) : model
|
||||
}
|
||||
|
||||
export function isGptModel(model: string): boolean {
|
||||
const modelName = extractModelName(model).toLowerCase()
|
||||
return modelName.includes("gpt")
|
||||
}
|
||||
|
||||
export function isClaudeOpus47Model(model: string): boolean {
|
||||
const modelName = extractModelName(model).toLowerCase().replaceAll(".", "-")
|
||||
return modelName.includes("claude-opus-4-7")
|
||||
}
|
||||
|
||||
export function isKimiK2Model(model: string): boolean {
|
||||
const modelName = extractModelName(model).toLowerCase()
|
||||
if (modelName.includes("kimi")) return true
|
||||
if (/k2[-.]?p[56]/.test(modelName)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function isMiniMaxModel(model: string): boolean {
|
||||
const modelName = extractModelName(model).toLowerCase()
|
||||
return modelName.includes("minimax")
|
||||
}
|
||||
|
||||
export function isGlmModel(model: string): boolean {
|
||||
const modelName = extractModelName(model).toLowerCase()
|
||||
return modelName.includes("glm")
|
||||
}
|
||||
|
||||
const GEMINI_PROVIDERS = ["google/", "google-vertex/"] as const
|
||||
|
||||
export function isGeminiModel(model: string): boolean {
|
||||
if (GEMINI_PROVIDERS.some((prefix) => model.startsWith(prefix))) return true
|
||||
|
||||
if (
|
||||
model.startsWith("github-copilot/") &&
|
||||
extractModelName(model).toLowerCase().startsWith("gemini")
|
||||
)
|
||||
return true
|
||||
|
||||
const modelName = extractModelName(model).toLowerCase()
|
||||
return modelName.startsWith("gemini-")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@oh-my-opencode/prompts-core",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Harness-agnostic markdown prompt loading and model-variant routing for oh-my-opencode.",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"types": "./index.d.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
||||
"test": "bun test src/*.test.ts test/*.test.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@oh-my-opencode/model-core": "workspace:*",
|
||||
"@oh-my-opencode/utils": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Test Prompt
|
||||
enabled: true
|
||||
---
|
||||
Default prompt body with {X}.
|
||||
Second line remains verbatim.
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
---
|
||||
GPT prompt body with {A} and {B}.
|
||||
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
LoadedPrompt,
|
||||
LoadPromptInput,
|
||||
ModelVariant,
|
||||
PromptSource,
|
||||
RuntimeInjection,
|
||||
VariantTable,
|
||||
} from "./types"
|
||||
export { resolveVariant } from "./variant-resolver"
|
||||
export type { ResolveVariantInput } from "./variant-resolver"
|
||||
export { loadPrompt, PromptFileNotFoundError, PromptPathTraversalError } from "./loader"
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { loadPrompt, PromptFileNotFoundError, PromptPathTraversalError } from "./loader"
|
||||
import type { PromptSource } from "./types"
|
||||
|
||||
const fixtureSource: PromptSource = {
|
||||
baseDir: join(dirname(fileURLToPath(import.meta.url)), "__test_fixtures__"),
|
||||
}
|
||||
|
||||
class ResolverFailureError extends Error {
|
||||
readonly name = "ResolverFailureError"
|
||||
}
|
||||
|
||||
class ExpectedErrorMissingError extends Error {
|
||||
readonly name = "ExpectedErrorMissingError"
|
||||
}
|
||||
|
||||
describe("loadPrompt", () => {
|
||||
test("#given markdown fixture #then returns markdown body verbatim", async () => {
|
||||
const prompt = await loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "default" })
|
||||
|
||||
expect(prompt.body).toBe("Default prompt body with {X}.\nSecond line remains verbatim.\n")
|
||||
})
|
||||
|
||||
test("#given frontmatter fixture #then returns parsed frontmatter", async () => {
|
||||
const prompt = await loadPrompt<{ readonly title: string; readonly enabled: boolean }>({
|
||||
source: fixtureSource,
|
||||
name: "test-prompt",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
expect(prompt.frontmatter.title).toBe("Test Prompt")
|
||||
expect(prompt.frontmatter.enabled).toBe(true)
|
||||
})
|
||||
|
||||
test("#given empty frontmatter #then parses without crashing", async () => {
|
||||
const prompt = await loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "gpt" })
|
||||
|
||||
expect(prompt.frontmatter).toEqual({})
|
||||
expect(prompt.body).toBe("GPT prompt body with {A} and {B}.\n")
|
||||
})
|
||||
|
||||
test("#given missing file #then error mentions prompt name and variant", async () => {
|
||||
const error = await captureError(() =>
|
||||
loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "missing" })
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(PromptFileNotFoundError)
|
||||
expect(expectError(error).message).toContain("test-prompt/missing")
|
||||
})
|
||||
|
||||
test("#given prompt name escapes source directory #then rejects path traversal", async () => {
|
||||
const error = await captureError(() =>
|
||||
loadPrompt({ source: fixtureSource, name: "../test-prompt", variant: "default" })
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(PromptPathTraversalError)
|
||||
})
|
||||
|
||||
test("#given variant escapes source directory #then rejects path traversal", async () => {
|
||||
const error = await captureError(() =>
|
||||
loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "../../outside" })
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(PromptPathTraversalError)
|
||||
})
|
||||
|
||||
test("#given runtime injection #then replaces placeholder in body", async () => {
|
||||
const prompt = await loadPrompt({
|
||||
source: fixtureSource,
|
||||
name: "test-prompt",
|
||||
variant: "default",
|
||||
inject: [{ placeholder: "{X}", resolver: () => "Y" }],
|
||||
})
|
||||
|
||||
expect(prompt.body).toBe("Default prompt body with Y.\nSecond line remains verbatim.\n")
|
||||
})
|
||||
|
||||
test("#given multiple runtime injections #then applies all and ignores absent placeholders", async () => {
|
||||
const prompt = await loadPrompt({
|
||||
source: fixtureSource,
|
||||
name: "test-prompt",
|
||||
variant: "gpt",
|
||||
inject: [
|
||||
{ placeholder: "{A}", resolver: () => "Alpha" },
|
||||
{ placeholder: "{B}", resolver: () => "Beta" },
|
||||
{ placeholder: "{ABSENT}", resolver: () => "No-op" },
|
||||
],
|
||||
})
|
||||
|
||||
expect(prompt.body).toBe("GPT prompt body with Alpha and Beta.\n")
|
||||
})
|
||||
|
||||
test("#given injection resolver throws #then propagates the error", async () => {
|
||||
const error = await captureError(() =>
|
||||
loadPrompt({
|
||||
source: fixtureSource,
|
||||
name: "test-prompt",
|
||||
variant: "default",
|
||||
inject: [
|
||||
{
|
||||
placeholder: "{X}",
|
||||
resolver: () => {
|
||||
throw new ResolverFailureError("resolver failed")
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(ResolverFailureError)
|
||||
})
|
||||
})
|
||||
|
||||
async function captureError(operation: () => Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await operation()
|
||||
return undefined
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
function expectError(error: unknown): Error {
|
||||
if (error instanceof Error) return error
|
||||
throw new ExpectedErrorMissingError("Expected operation to throw an Error instance")
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { parseFrontmatter } from "@oh-my-opencode/utils"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { isAbsolute, relative, resolve } from "node:path"
|
||||
import type { LoadedPrompt, LoadPromptInput, RuntimeInjection } from "./types"
|
||||
|
||||
export class PromptFileNotFoundError extends Error {
|
||||
readonly name = "PromptFileNotFoundError"
|
||||
|
||||
constructor(
|
||||
readonly promptName: string,
|
||||
readonly variant: string,
|
||||
readonly filePath: string,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(`Prompt file not found for ${promptName}/${variant}: ${filePath}`, options)
|
||||
}
|
||||
}
|
||||
|
||||
export class PromptPathTraversalError extends Error {
|
||||
readonly name = "PromptPathTraversalError"
|
||||
|
||||
constructor(
|
||||
readonly promptName: string,
|
||||
readonly variant: string
|
||||
) {
|
||||
super(`Prompt path escapes source directory for ${promptName}/${variant}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPrompt<TFrontmatter = Record<string, unknown>>(
|
||||
input: LoadPromptInput
|
||||
): Promise<LoadedPrompt<TFrontmatter>> {
|
||||
const filePath = resolvePromptFilePath(input.source.baseDir, input.name, input.variant)
|
||||
const content = await readPromptFile(input.name, input.variant, filePath)
|
||||
const parsed = parseFrontmatter<TFrontmatter>(content)
|
||||
const body = await applyRuntimeInjections(parsed.body, input.inject ?? [])
|
||||
|
||||
return {
|
||||
frontmatter: parsed.data,
|
||||
body,
|
||||
hadFrontmatter: parsed.hadFrontmatter,
|
||||
parseError: parsed.parseError,
|
||||
filePath,
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromptFilePath(baseDir: string, promptName: string, variant: string): string {
|
||||
const resolvedBaseDir = resolve(baseDir)
|
||||
const filePath = resolve(resolvedBaseDir, promptName, `${variant}.md`)
|
||||
const relativePath = relative(resolvedBaseDir, filePath)
|
||||
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
||||
throw new PromptPathTraversalError(promptName, variant)
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function readPromptFile(promptName: string, variant: string, filePath: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(filePath, "utf8")
|
||||
} catch (error) {
|
||||
if (error instanceof Error && getErrorCode(error) === "ENOENT") {
|
||||
throw new PromptFileNotFoundError(promptName, variant, filePath, { cause: error })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRuntimeInjections(
|
||||
body: string,
|
||||
injections: readonly RuntimeInjection[]
|
||||
): Promise<string> {
|
||||
let renderedBody = body
|
||||
for (const injection of injections) {
|
||||
renderedBody = renderedBody.replaceAll(injection.placeholder, await injection.resolver())
|
||||
}
|
||||
return renderedBody
|
||||
}
|
||||
|
||||
function getErrorCode(error: Error): string | undefined {
|
||||
if (!("code" in error)) return undefined
|
||||
return typeof error.code === "string" ? error.code : undefined
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { LoadPromptInput, LoadedPrompt, PromptSource, RuntimeInjection } from "./types"
|
||||
|
||||
describe("prompt core types", () => {
|
||||
test("#given loader input shape #then accepts source name variant and runtime injections", () => {
|
||||
const source: PromptSource = { baseDir: "/tmp/prompts" }
|
||||
const injection: RuntimeInjection = { placeholder: "{X}", resolver: () => "Y" }
|
||||
|
||||
const input = {
|
||||
source,
|
||||
name: "test",
|
||||
variant: "default",
|
||||
inject: [injection],
|
||||
} satisfies LoadPromptInput
|
||||
|
||||
expect(input.source.baseDir).toBe("/tmp/prompts")
|
||||
expect(input.inject[0]?.placeholder).toBe("{X}")
|
||||
})
|
||||
|
||||
test("#given loaded prompt shape #then carries frontmatter and rendered body", () => {
|
||||
const loaded = {
|
||||
frontmatter: { title: "Fixture" },
|
||||
body: "Prompt body",
|
||||
hadFrontmatter: true,
|
||||
parseError: false,
|
||||
filePath: "/tmp/prompts/test/default.md",
|
||||
} satisfies LoadedPrompt<{ readonly title: string }>
|
||||
|
||||
expect(loaded.frontmatter.title).toBe("Fixture")
|
||||
expect(loaded.body).toBe("Prompt body")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
export type ModelVariant =
|
||||
| "default"
|
||||
| "gpt"
|
||||
| "gemini"
|
||||
| "kimi"
|
||||
| "glm"
|
||||
| "planner"
|
||||
| "opus-4-7"
|
||||
| "minimax"
|
||||
|
||||
export type PromptSource = {
|
||||
readonly baseDir: string
|
||||
}
|
||||
|
||||
export type RuntimeInjection = {
|
||||
readonly placeholder: string
|
||||
readonly resolver: () => string | Promise<string>
|
||||
}
|
||||
|
||||
export type LoadPromptInput = {
|
||||
readonly source: PromptSource
|
||||
readonly name: string
|
||||
readonly variant: string
|
||||
readonly inject?: readonly RuntimeInjection[]
|
||||
}
|
||||
|
||||
export type LoadedPrompt<TFrontmatter = Record<string, unknown>> = {
|
||||
readonly frontmatter: TFrontmatter
|
||||
readonly body: string
|
||||
readonly hadFrontmatter: boolean
|
||||
readonly parseError: boolean
|
||||
readonly filePath: string
|
||||
}
|
||||
|
||||
export type VariantTable = Readonly<Record<string, PromptSource>>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PromptSource, VariantTable } from "./types"
|
||||
import { resolveVariant } from "./variant-resolver"
|
||||
|
||||
const promptSource = (baseDir: string): PromptSource => ({ baseDir })
|
||||
|
||||
const variants = {
|
||||
planner: promptSource("/prompts/planner"),
|
||||
gpt: promptSource("/prompts/gpt"),
|
||||
gemini: promptSource("/prompts/gemini"),
|
||||
kimi: promptSource("/prompts/kimi"),
|
||||
glm: promptSource("/prompts/glm"),
|
||||
default: promptSource("/prompts/default"),
|
||||
} satisfies VariantTable
|
||||
|
||||
describe("resolveVariant", () => {
|
||||
test("#given Claude Opus 4.7 model #then resolves default variant", () => {
|
||||
expect(resolveVariant({ modelID: "claude-opus-4-7", variants })).toBe("default")
|
||||
})
|
||||
|
||||
test("#given GPT model #then resolves gpt variant", () => {
|
||||
expect(resolveVariant({ modelID: "gpt-5-5", variants })).toBe("gpt")
|
||||
})
|
||||
|
||||
test("#given Gemini model #then resolves gemini variant", () => {
|
||||
expect(resolveVariant({ modelID: "gemini-3-1-pro", variants })).toBe("gemini")
|
||||
})
|
||||
|
||||
test("#given Kimi K2 model #then resolves kimi variant", () => {
|
||||
expect(resolveVariant({ modelID: "kimi-k2-6", variants })).toBe("kimi")
|
||||
})
|
||||
|
||||
test("#given GLM model #then resolves glm variant", () => {
|
||||
expect(resolveVariant({ modelID: "glm-5-1", variants })).toBe("glm")
|
||||
})
|
||||
|
||||
test("#given Prometheus agent #then planner overrides model variant", () => {
|
||||
expect(resolveVariant({ agentName: "prometheus", modelID: "gpt-5-5", variants })).toBe(
|
||||
"planner"
|
||||
)
|
||||
})
|
||||
|
||||
test("#given unknown model #then falls back to default variant", () => {
|
||||
expect(resolveVariant({ modelID: "claude-haiku-4-5", variants })).toBe("default")
|
||||
})
|
||||
|
||||
test("#given empty variants table #then throws TypeError", () => {
|
||||
expect(() => resolveVariant({ modelID: "gpt-5-5", variants: {} })).toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
isClaudeOpus47Model,
|
||||
isGeminiModel,
|
||||
isGlmModel,
|
||||
isGptModel,
|
||||
isKimiK2Model,
|
||||
isMiniMaxModel,
|
||||
} from "@oh-my-opencode/model-core"
|
||||
import type { VariantTable } from "./types"
|
||||
|
||||
type ModelMatcher = (modelID: string) => boolean
|
||||
|
||||
export type ResolveVariantInput = {
|
||||
readonly modelID?: string
|
||||
readonly agentName?: string
|
||||
readonly variants: VariantTable
|
||||
}
|
||||
|
||||
const PLANNER_AGENT_NAMES: ReadonlySet<string> = new Set(["prometheus"] as const)
|
||||
|
||||
const MODEL_MATCHERS: Readonly<Record<string, ModelMatcher>> = {
|
||||
gpt: isGptModel,
|
||||
gemini: isGeminiModel,
|
||||
kimi: isKimiK2Model,
|
||||
glm: isGlmModel,
|
||||
"opus-4-7": isClaudeOpus47Model,
|
||||
minimax: isMiniMaxModel,
|
||||
}
|
||||
|
||||
export function resolveVariant(input: ResolveVariantInput): string {
|
||||
const variantNames = Object.keys(input.variants)
|
||||
if (variantNames.length === 0) {
|
||||
throw new TypeError("resolveVariant requires at least one prompt variant")
|
||||
}
|
||||
|
||||
if (isPlannerAgent(input.agentName) && variantNames.includes("planner")) {
|
||||
return "planner"
|
||||
}
|
||||
|
||||
if (input.modelID !== undefined) {
|
||||
for (const variantName of variantNames) {
|
||||
if (matchesModelVariant(variantName, input.modelID)) return variantName
|
||||
}
|
||||
}
|
||||
|
||||
if (variantNames.includes("default")) return "default"
|
||||
|
||||
return variantNames[0]
|
||||
}
|
||||
|
||||
function isPlannerAgent(agentName: string | undefined): boolean {
|
||||
return agentName !== undefined && PLANNER_AGENT_NAMES.has(agentName.toLowerCase())
|
||||
}
|
||||
|
||||
function matchesModelVariant(variantName: string, modelID: string): boolean {
|
||||
const matcher = MODEL_MATCHERS[variantName]
|
||||
return matcher?.(modelID) ?? false
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const SOURCE_DIR = join(dirname(fileURLToPath(import.meta.url)), "../src")
|
||||
|
||||
describe("opencode coupling audit", () => {
|
||||
test("#given prompts-core source #then no file imports @opencode-ai packages", async () => {
|
||||
const offenders = await findOpenCodeImports(SOURCE_DIR)
|
||||
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
async function findOpenCodeImports(sourceDir: string): Promise<readonly string[]> {
|
||||
const files = await collectTypeScriptFiles(sourceDir)
|
||||
const offenders: string[] = []
|
||||
|
||||
for (const filePath of files) {
|
||||
const source = await readFile(filePath, "utf8")
|
||||
if (source.includes("@opencode-ai")) offenders.push(filePath)
|
||||
}
|
||||
|
||||
return offenders
|
||||
}
|
||||
|
||||
async function collectTypeScriptFiles(directory: string): Promise<readonly string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const files: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectTypeScriptFiles(entryPath)))
|
||||
} else if (entry.isFile() && entry.name.endsWith(".ts")) {
|
||||
files.push(entryPath)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["ESNext"],
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"]
|
||||
}
|
||||
+9
-52
@@ -1,5 +1,14 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk";
|
||||
|
||||
export {
|
||||
isClaudeOpus47Model,
|
||||
isGeminiModel,
|
||||
isGlmModel,
|
||||
isGptModel,
|
||||
isKimiK2Model,
|
||||
isMiniMaxModel,
|
||||
} from "@oh-my-opencode/model-core";
|
||||
|
||||
/**
|
||||
* Agent mode determines UI model selection behavior:
|
||||
* - "primary": Respects user's UI-selected model (sisyphus, atlas)
|
||||
@@ -74,11 +83,6 @@ function extractModelName(model: string): string {
|
||||
return model.includes("/") ? (model.split("/").pop() ?? model) : model;
|
||||
}
|
||||
|
||||
export function isGptModel(model: string): boolean {
|
||||
const modelName = extractModelName(model).toLowerCase();
|
||||
return modelName.includes("gpt");
|
||||
}
|
||||
|
||||
const GPT_NATIVE_SISYPHUS_RE = /gpt-5[.-](?:[4-9]|\d{2,})/i;
|
||||
|
||||
export function isGptNativeSisyphusModel(model: string): boolean {
|
||||
@@ -101,53 +105,6 @@ export function isGpt5_2Model(model: string): boolean {
|
||||
return modelName.includes("gpt-5.2") || modelName.includes("gpt-5-2");
|
||||
}
|
||||
|
||||
export function isClaudeOpus47Model(model: string): boolean {
|
||||
const modelName = extractModelName(model).toLowerCase().replaceAll(".", "-");
|
||||
return modelName.includes("claude-opus-4-7");
|
||||
}
|
||||
|
||||
/**
|
||||
* Kimi K2.x model detection (K2.5 / K2.6 family).
|
||||
*
|
||||
* Matches model IDs containing any of:
|
||||
* - "kimi" (provider/family signal — kimi-k2.6, moonshotai/Kimi-K2.6, etc.)
|
||||
* - "k2p5" / "k2-p5" / "k2.p5"
|
||||
* - "k2p6" / "k2-p6" / "k2.p6"
|
||||
*
|
||||
* Match is case-insensitive on the model name (last path segment).
|
||||
*/
|
||||
export function isKimiK2Model(model: string): boolean {
|
||||
const modelName = extractModelName(model).toLowerCase();
|
||||
if (modelName.includes("kimi")) return true;
|
||||
if (/k2[-.]?p[56]/.test(modelName)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const GEMINI_PROVIDERS = ["google/", "google-vertex/"];
|
||||
|
||||
export function isMiniMaxModel(model: string): boolean {
|
||||
const modelName = extractModelName(model).toLowerCase();
|
||||
return modelName.includes("minimax");
|
||||
}
|
||||
|
||||
export function isGlmModel(model: string): boolean {
|
||||
const modelName = extractModelName(model).toLowerCase();
|
||||
return modelName.includes("glm");
|
||||
}
|
||||
|
||||
export function isGeminiModel(model: string): boolean {
|
||||
if (GEMINI_PROVIDERS.some((prefix) => model.startsWith(prefix))) return true;
|
||||
|
||||
if (
|
||||
model.startsWith("github-copilot/") &&
|
||||
extractModelName(model).toLowerCase().startsWith("gemini")
|
||||
)
|
||||
return true;
|
||||
|
||||
const modelName = extractModelName(model).toLowerCase();
|
||||
return modelName.startsWith("gemini-");
|
||||
}
|
||||
|
||||
export type BuiltinAgentName =
|
||||
| "sisyphus"
|
||||
| "hephaestus"
|
||||
|
||||
Reference in New Issue
Block a user