Consolidate duplicate patterns and simplify codebase (#1317)
* refactor(shared): unify binary downloader and session path storage - Create binary-downloader.ts for common download/extract logic - Create session-injected-paths.ts for unified path tracking - Refactor comment-checker, ast-grep, grep downloaders to use shared util - Consolidate directory injector types into shared module * feat(shared): implement unified model resolution pipeline - Create ModelResolutionPipeline for centralized model selection - Refactor model-resolver to use pipeline - Update delegate-task and config-handler to use unified logic - Ensure consistent model resolution across all agent types * refactor(agents): simplify agent utils and metadata management - Extract helper functions for config merging and env context - Register prompt metadata for all agents - Simplify agent variant detection logic * cleanup: inline utilities and remove unused exports - Remove case-insensitive.ts (inline with native JS) - Simplify opencode-version helpers - Remove unused getModelLimit, createCompactionContextInjector exports - Inline transcript entry creation in claude-code-hooks - Update tests accordingly --------- Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
This commit is contained in:
@@ -4,8 +4,6 @@
|
||||
* true = tool allowed, false = tool denied.
|
||||
*/
|
||||
|
||||
import { findCaseInsensitive } from "./case-insensitive"
|
||||
|
||||
const EXPLORATION_AGENT_DENYLIST: Record<string, boolean> = {
|
||||
write: false,
|
||||
edit: false,
|
||||
@@ -37,10 +35,13 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
|
||||
}
|
||||
|
||||
export function getAgentToolRestrictions(agentName: string): Record<string, boolean> {
|
||||
return findCaseInsensitive(AGENT_RESTRICTIONS, agentName) ?? {}
|
||||
return AGENT_RESTRICTIONS[agentName]
|
||||
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
?? {}
|
||||
}
|
||||
|
||||
export function hasAgentToolRestrictions(agentName: string): boolean {
|
||||
const restrictions = findCaseInsensitive(AGENT_RESTRICTIONS, agentName)
|
||||
const restrictions = AGENT_RESTRICTIONS[agentName]
|
||||
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
return restrictions !== undefined && Object.keys(restrictions).length > 0
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import { findCaseInsensitive } from "./case-insensitive"
|
||||
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "./model-requirements"
|
||||
|
||||
export function resolveAgentVariant(
|
||||
@@ -13,7 +12,10 @@ export function resolveAgentVariant(
|
||||
const agentOverrides = config.agents as
|
||||
| Record<string, { variant?: string; category?: string }>
|
||||
| undefined
|
||||
const agentOverride = agentOverrides ? findCaseInsensitive(agentOverrides, agentName) : undefined
|
||||
const agentOverride = agentOverrides
|
||||
? agentOverrides[agentName]
|
||||
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
: undefined
|
||||
if (!agentOverride) {
|
||||
return undefined
|
||||
}
|
||||
@@ -43,7 +45,10 @@ export function resolveVariantForModel(
|
||||
const agentOverrides = config.agents as
|
||||
| Record<string, { category?: string }>
|
||||
| undefined
|
||||
const agentOverride = agentOverrides ? findCaseInsensitive(agentOverrides, agentName) : undefined
|
||||
const agentOverride = agentOverrides
|
||||
? agentOverrides[agentName]
|
||||
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
: undefined
|
||||
const categoryName = agentOverride?.category
|
||||
if (categoryName) {
|
||||
const categoryRequirement = CATEGORY_MODEL_REQUIREMENTS[categoryName]
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { spawn } from "bun";
|
||||
import { extractZip } from "./zip-extractor";
|
||||
|
||||
export function getCachedBinaryPath(cacheDir: string, binaryName: string): string | null {
|
||||
const binaryPath = path.join(cacheDir, binaryName);
|
||||
return existsSync(binaryPath) ? binaryPath : null;
|
||||
}
|
||||
|
||||
export function ensureCacheDir(cacheDir: string): void {
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadArchive(downloadUrl: string, archivePath: string): Promise<void> {
|
||||
const response = await fetch(downloadUrl, { redirect: "follow" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
await Bun.write(archivePath, arrayBuffer);
|
||||
}
|
||||
|
||||
export async function extractTarGz(
|
||||
archivePath: string,
|
||||
destDir: string,
|
||||
options?: { args?: string[]; cwd?: string }
|
||||
): Promise<void> {
|
||||
const args = options?.args ?? ["tar", "-xzf", archivePath, "-C", destDir];
|
||||
const proc = spawn(args, {
|
||||
cwd: options?.cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
throw new Error(`tar extraction failed (exit ${exitCode}): ${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractZipArchive(archivePath: string, destDir: string): Promise<void> {
|
||||
await extractZip(archivePath, destDir);
|
||||
}
|
||||
|
||||
export function cleanupArchive(archivePath: string): void {
|
||||
if (existsSync(archivePath)) {
|
||||
unlinkSync(archivePath);
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureExecutable(binaryPath: string): void {
|
||||
if (process.platform !== "win32" && existsSync(binaryPath)) {
|
||||
chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import {
|
||||
findCaseInsensitive,
|
||||
includesCaseInsensitive,
|
||||
findByNameCaseInsensitive,
|
||||
equalsIgnoreCase,
|
||||
} from "./case-insensitive"
|
||||
|
||||
describe("findCaseInsensitive", () => {
|
||||
test("returns undefined for empty/undefined object", () => {
|
||||
// #given - undefined object
|
||||
const obj = undefined
|
||||
|
||||
// #when - lookup any key
|
||||
const result = findCaseInsensitive(obj, "key")
|
||||
|
||||
// #then - returns undefined
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("finds exact match first", () => {
|
||||
// #given - object with exact key
|
||||
const obj = { Oracle: "value1", oracle: "value2" }
|
||||
|
||||
// #when - lookup with exact case
|
||||
const result = findCaseInsensitive(obj, "Oracle")
|
||||
|
||||
// #then - returns exact match
|
||||
expect(result).toBe("value1")
|
||||
})
|
||||
|
||||
test("finds case-insensitive match when no exact match", () => {
|
||||
// #given - object with lowercase key
|
||||
const obj = { oracle: "value" }
|
||||
|
||||
// #when - lookup with uppercase
|
||||
const result = findCaseInsensitive(obj, "ORACLE")
|
||||
|
||||
// #then - returns case-insensitive match
|
||||
expect(result).toBe("value")
|
||||
})
|
||||
|
||||
test("returns undefined when key not found", () => {
|
||||
// #given - object without target key
|
||||
const obj = { other: "value" }
|
||||
|
||||
// #when - lookup missing key
|
||||
const result = findCaseInsensitive(obj, "oracle")
|
||||
|
||||
// #then - returns undefined
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("includesCaseInsensitive", () => {
|
||||
test("returns true for exact match", () => {
|
||||
// #given - array with exact value
|
||||
const arr = ["explore", "librarian"]
|
||||
|
||||
// #when - check exact match
|
||||
const result = includesCaseInsensitive(arr, "explore")
|
||||
|
||||
// #then - returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for case-insensitive match", () => {
|
||||
// #given - array with lowercase values
|
||||
const arr = ["explore", "librarian"]
|
||||
|
||||
// #when - check uppercase value
|
||||
const result = includesCaseInsensitive(arr, "EXPLORE")
|
||||
|
||||
// #then - returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for mixed case match", () => {
|
||||
// #given - array with mixed case values
|
||||
const arr = ["Oracle", "Sisyphus"]
|
||||
|
||||
// #when - check different case
|
||||
const result = includesCaseInsensitive(arr, "oracle")
|
||||
|
||||
// #then - returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when value not found", () => {
|
||||
// #given - array without target value
|
||||
const arr = ["explore", "librarian"]
|
||||
|
||||
// #when - check missing value
|
||||
const result = includesCaseInsensitive(arr, "oracle")
|
||||
|
||||
// #then - returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for empty array", () => {
|
||||
// #given - empty array
|
||||
const arr: string[] = []
|
||||
|
||||
// #when - check any value
|
||||
const result = includesCaseInsensitive(arr, "explore")
|
||||
|
||||
// #then - returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("findByNameCaseInsensitive", () => {
|
||||
test("finds element by exact name", () => {
|
||||
// #given - array with named objects
|
||||
const arr = [{ name: "Oracle", value: 1 }, { name: "explore", value: 2 }]
|
||||
|
||||
// #when - find by exact name
|
||||
const result = findByNameCaseInsensitive(arr, "Oracle")
|
||||
|
||||
// #then - returns matching element
|
||||
expect(result).toEqual({ name: "Oracle", value: 1 })
|
||||
})
|
||||
|
||||
test("finds element by case-insensitive name", () => {
|
||||
// #given - array with named objects
|
||||
const arr = [{ name: "Oracle", value: 1 }, { name: "explore", value: 2 }]
|
||||
|
||||
// #when - find by different case
|
||||
const result = findByNameCaseInsensitive(arr, "oracle")
|
||||
|
||||
// #then - returns matching element
|
||||
expect(result).toEqual({ name: "Oracle", value: 1 })
|
||||
})
|
||||
|
||||
test("returns undefined when name not found", () => {
|
||||
// #given - array without target name
|
||||
const arr = [{ name: "Oracle", value: 1 }]
|
||||
|
||||
// #when - find missing name
|
||||
const result = findByNameCaseInsensitive(arr, "librarian")
|
||||
|
||||
// #then - returns undefined
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("equalsIgnoreCase", () => {
|
||||
test("returns true for same case", () => {
|
||||
// #given - same strings
|
||||
// #when - compare
|
||||
// #then - returns true
|
||||
expect(equalsIgnoreCase("oracle", "oracle")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for different case", () => {
|
||||
// #given - strings with different case
|
||||
// #when - compare
|
||||
// #then - returns true
|
||||
expect(equalsIgnoreCase("Oracle", "ORACLE")).toBe(true)
|
||||
expect(equalsIgnoreCase("Sisyphus-Junior", "sisyphus-junior")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for different strings", () => {
|
||||
// #given - different strings
|
||||
// #when - compare
|
||||
// #then - returns false
|
||||
expect(equalsIgnoreCase("oracle", "explore")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Case-insensitive lookup and comparison utilities for agent/config names.
|
||||
* Used throughout the codebase to allow "Oracle", "oracle", "ORACLE" to work the same.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Find a value in an object using case-insensitive key matching.
|
||||
* First tries exact match, then falls back to lowercase comparison.
|
||||
*/
|
||||
export function findCaseInsensitive<T>(obj: Record<string, T> | undefined, key: string): T | undefined {
|
||||
if (!obj) return undefined
|
||||
const exactMatch = obj[key]
|
||||
if (exactMatch !== undefined) return exactMatch
|
||||
const lowerKey = key.toLowerCase()
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (k.toLowerCase() === lowerKey) return v
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an array includes a value using case-insensitive comparison.
|
||||
*/
|
||||
export function includesCaseInsensitive(arr: string[], value: string): boolean {
|
||||
const lowerValue = value.toLowerCase()
|
||||
return arr.some((item) => item.toLowerCase() === lowerValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an element in array using case-insensitive name matching.
|
||||
* Useful for finding agents/categories by name.
|
||||
*/
|
||||
export function findByNameCaseInsensitive<T extends { name: string }>(
|
||||
arr: T[],
|
||||
name: string
|
||||
): T | undefined {
|
||||
const lowerName = name.toLowerCase()
|
||||
return arr.find((item) => item.name.toLowerCase() === lowerName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two strings are equal (case-insensitive).
|
||||
*/
|
||||
export function equalsIgnoreCase(a: string, b: string): boolean {
|
||||
return a.toLowerCase() === b.toLowerCase()
|
||||
}
|
||||
+7
-1
@@ -20,6 +20,7 @@ export * from "./opencode-version"
|
||||
export * from "./permission-compat"
|
||||
export * from "./external-plugin-detector"
|
||||
export * from "./zip-extractor"
|
||||
export * from "./binary-downloader"
|
||||
export * from "./agent-variant"
|
||||
export * from "./session-cursor"
|
||||
export * from "./shell-env"
|
||||
@@ -27,9 +28,14 @@ export * from "./system-directive"
|
||||
export * from "./agent-tool-restrictions"
|
||||
export * from "./model-requirements"
|
||||
export * from "./model-resolver"
|
||||
export {
|
||||
resolveModelPipeline,
|
||||
type ModelResolutionRequest,
|
||||
type ModelResolutionResult as ModelResolutionPipelineResult,
|
||||
type ModelResolutionProvenance,
|
||||
} from "./model-resolution-pipeline"
|
||||
export * from "./model-availability"
|
||||
export * from "./connected-providers-cache"
|
||||
export * from "./case-insensitive"
|
||||
export * from "./session-utils"
|
||||
export * from "./tmux"
|
||||
export * from "./model-suggestion-retry"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { log } from "./logger"
|
||||
import { readConnectedProvidersCache } from "./connected-providers-cache"
|
||||
import { fuzzyMatchModel } from "./model-availability"
|
||||
import type { FallbackEntry } from "./model-requirements"
|
||||
|
||||
export type ModelResolutionRequest = {
|
||||
intent?: {
|
||||
uiSelectedModel?: string
|
||||
userModel?: string
|
||||
categoryDefaultModel?: string
|
||||
}
|
||||
constraints: {
|
||||
availableModels: Set<string>
|
||||
}
|
||||
policy?: {
|
||||
fallbackChain?: FallbackEntry[]
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ModelResolutionProvenance =
|
||||
| "override"
|
||||
| "category-default"
|
||||
| "provider-fallback"
|
||||
| "system-default"
|
||||
|
||||
export type ModelResolutionResult = {
|
||||
model: string
|
||||
provenance: ModelResolutionProvenance
|
||||
variant?: string
|
||||
attempted?: string[]
|
||||
reason?: string
|
||||
}
|
||||
|
||||
function normalizeModel(model?: string): string | undefined {
|
||||
const trimmed = model?.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
export function resolveModelPipeline(
|
||||
request: ModelResolutionRequest,
|
||||
): ModelResolutionResult | undefined {
|
||||
const attempted: string[] = []
|
||||
const { intent, constraints, policy } = request
|
||||
const availableModels = constraints.availableModels
|
||||
const fallbackChain = policy?.fallbackChain
|
||||
const systemDefaultModel = policy?.systemDefaultModel
|
||||
|
||||
const normalizedUiModel = normalizeModel(intent?.uiSelectedModel)
|
||||
if (normalizedUiModel) {
|
||||
log("Model resolved via UI selection", { model: normalizedUiModel })
|
||||
return { model: normalizedUiModel, provenance: "override" }
|
||||
}
|
||||
|
||||
const normalizedUserModel = normalizeModel(intent?.userModel)
|
||||
if (normalizedUserModel) {
|
||||
log("Model resolved via config override", { model: normalizedUserModel })
|
||||
return { model: normalizedUserModel, provenance: "override" }
|
||||
}
|
||||
|
||||
const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel)
|
||||
if (normalizedCategoryDefault) {
|
||||
attempted.push(normalizedCategoryDefault)
|
||||
if (availableModels.size > 0) {
|
||||
const parts = normalizedCategoryDefault.split("/")
|
||||
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
|
||||
const match = fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint)
|
||||
if (match) {
|
||||
log("Model resolved via category default (fuzzy matched)", {
|
||||
original: normalizedCategoryDefault,
|
||||
matched: match,
|
||||
})
|
||||
return { model: match, provenance: "category-default", attempted }
|
||||
}
|
||||
} else {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
if (connectedProviders === null) {
|
||||
log("Model resolved via category default (no cache, first run)", {
|
||||
model: normalizedCategoryDefault,
|
||||
})
|
||||
return { model: normalizedCategoryDefault, provenance: "category-default", attempted }
|
||||
}
|
||||
const parts = normalizedCategoryDefault.split("/")
|
||||
if (parts.length >= 2) {
|
||||
const provider = parts[0]
|
||||
if (connectedProviders.includes(provider)) {
|
||||
log("Model resolved via category default (connected provider)", {
|
||||
model: normalizedCategoryDefault,
|
||||
})
|
||||
return { model: normalizedCategoryDefault, provenance: "category-default", attempted }
|
||||
}
|
||||
}
|
||||
}
|
||||
log("Category default model not available, falling through to fallback chain", {
|
||||
model: normalizedCategoryDefault,
|
||||
})
|
||||
}
|
||||
|
||||
if (fallbackChain && fallbackChain.length > 0) {
|
||||
if (availableModels.size === 0) {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||
|
||||
if (connectedSet === null) {
|
||||
log("Model fallback chain skipped (no connected providers cache) - falling through to system default")
|
||||
} else {
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
if (connectedSet.has(provider)) {
|
||||
const model = `${provider}/${entry.model}`
|
||||
log("Model resolved via fallback chain (connected provider)", {
|
||||
provider,
|
||||
model: entry.model,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return {
|
||||
model,
|
||||
provenance: "provider-fallback",
|
||||
variant: entry.variant,
|
||||
attempted,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log("No connected provider found in fallback chain, falling through to system default")
|
||||
}
|
||||
} else {
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
const fullModel = `${provider}/${entry.model}`
|
||||
const match = fuzzyMatchModel(fullModel, availableModels, [provider])
|
||||
if (match) {
|
||||
log("Model resolved via fallback chain (availability confirmed)", {
|
||||
provider,
|
||||
model: entry.model,
|
||||
match,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return {
|
||||
model: match,
|
||||
provenance: "provider-fallback",
|
||||
variant: entry.variant,
|
||||
attempted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const crossProviderMatch = fuzzyMatchModel(entry.model, availableModels)
|
||||
if (crossProviderMatch) {
|
||||
log("Model resolved via fallback chain (cross-provider fuzzy match)", {
|
||||
model: entry.model,
|
||||
match: crossProviderMatch,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return {
|
||||
model: crossProviderMatch,
|
||||
provenance: "provider-fallback",
|
||||
variant: entry.variant,
|
||||
attempted,
|
||||
}
|
||||
}
|
||||
}
|
||||
log("No available model found in fallback chain, falling through to system default")
|
||||
}
|
||||
}
|
||||
|
||||
if (systemDefaultModel === undefined) {
|
||||
log("No model resolved - systemDefaultModel not configured")
|
||||
return undefined
|
||||
}
|
||||
|
||||
log("Model resolved via system default", { model: systemDefaultModel })
|
||||
return { model: systemDefaultModel, provenance: "system-default", attempted }
|
||||
}
|
||||
+12
-101
@@ -1,7 +1,6 @@
|
||||
import { log } from "./logger"
|
||||
import { fuzzyMatchModel } from "./model-availability"
|
||||
import type { FallbackEntry } from "./model-requirements"
|
||||
import { readConnectedProvidersCache } from "./connected-providers-cache"
|
||||
import { resolveModelPipeline } from "./model-resolution-pipeline"
|
||||
|
||||
export type ModelResolutionInput = {
|
||||
userModel?: string
|
||||
@@ -47,107 +46,19 @@ export function resolveModelWithFallback(
|
||||
input: ExtendedModelResolutionInput,
|
||||
): ModelResolutionResult | undefined {
|
||||
const { uiSelectedModel, userModel, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input
|
||||
const resolved = resolveModelPipeline({
|
||||
intent: { uiSelectedModel, userModel, categoryDefaultModel },
|
||||
constraints: { availableModels },
|
||||
policy: { fallbackChain, systemDefaultModel },
|
||||
})
|
||||
|
||||
// Step 1: UI Selection (highest priority - respects user's model choice in OpenCode UI)
|
||||
const normalizedUiModel = normalizeModel(uiSelectedModel)
|
||||
if (normalizedUiModel) {
|
||||
log("Model resolved via UI selection", { model: normalizedUiModel })
|
||||
return { model: normalizedUiModel, source: "override" }
|
||||
}
|
||||
|
||||
// Step 2: Config Override (from oh-my-opencode.json user config)
|
||||
const normalizedUserModel = normalizeModel(userModel)
|
||||
if (normalizedUserModel) {
|
||||
log("Model resolved via config override", { model: normalizedUserModel })
|
||||
return { model: normalizedUserModel, source: "override" }
|
||||
}
|
||||
|
||||
// Step 2.5: Category Default Model (from DEFAULT_CATEGORIES, with fuzzy matching)
|
||||
const normalizedCategoryDefault = normalizeModel(categoryDefaultModel)
|
||||
if (normalizedCategoryDefault) {
|
||||
if (availableModels.size > 0) {
|
||||
const parts = normalizedCategoryDefault.split("/")
|
||||
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
|
||||
const match = fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint)
|
||||
if (match) {
|
||||
log("Model resolved via category default (fuzzy matched)", { original: normalizedCategoryDefault, matched: match })
|
||||
return { model: match, source: "category-default" }
|
||||
}
|
||||
} else {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
if (connectedProviders === null) {
|
||||
log("Model resolved via category default (no cache, first run)", { model: normalizedCategoryDefault })
|
||||
return { model: normalizedCategoryDefault, source: "category-default" }
|
||||
}
|
||||
const parts = normalizedCategoryDefault.split("/")
|
||||
if (parts.length >= 2) {
|
||||
const provider = parts[0]
|
||||
if (connectedProviders.includes(provider)) {
|
||||
log("Model resolved via category default (connected provider)", { model: normalizedCategoryDefault })
|
||||
return { model: normalizedCategoryDefault, source: "category-default" }
|
||||
}
|
||||
}
|
||||
}
|
||||
log("Category default model not available, falling through to fallback chain", { model: normalizedCategoryDefault })
|
||||
}
|
||||
|
||||
// Step 3: Provider fallback chain (exact match → fuzzy match → next provider)
|
||||
if (fallbackChain && fallbackChain.length > 0) {
|
||||
if (availableModels.size === 0) {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||
|
||||
if (connectedSet === null) {
|
||||
log("Model fallback chain skipped (no connected providers cache) - falling through to system default")
|
||||
} else {
|
||||
for (const entry of fallbackChain) {
|
||||
for (const provider of entry.providers) {
|
||||
if (connectedSet.has(provider)) {
|
||||
const model = `${provider}/${entry.model}`
|
||||
log("Model resolved via fallback chain (connected provider)", {
|
||||
provider,
|
||||
model: entry.model,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return { model, source: "provider-fallback", variant: entry.variant }
|
||||
}
|
||||
}
|
||||
}
|
||||
log("No connected provider found in fallback chain, falling through to system default")
|
||||
}
|
||||
} else {
|
||||
for (const entry of fallbackChain) {
|
||||
// Step 1: Try with provider filter (preferred providers first)
|
||||
for (const provider of entry.providers) {
|
||||
const fullModel = `${provider}/${entry.model}`
|
||||
const match = fuzzyMatchModel(fullModel, availableModels, [provider])
|
||||
if (match) {
|
||||
log("Model resolved via fallback chain (availability confirmed)", { provider, model: entry.model, match, variant: entry.variant })
|
||||
return { model: match, source: "provider-fallback", variant: entry.variant }
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Try without provider filter (cross-provider fuzzy match)
|
||||
const crossProviderMatch = fuzzyMatchModel(entry.model, availableModels)
|
||||
if (crossProviderMatch) {
|
||||
log("Model resolved via fallback chain (cross-provider fuzzy match)", {
|
||||
model: entry.model,
|
||||
match: crossProviderMatch,
|
||||
variant: entry.variant,
|
||||
})
|
||||
return { model: crossProviderMatch, source: "provider-fallback", variant: entry.variant }
|
||||
}
|
||||
}
|
||||
log("No available model found in fallback chain, falling through to system default")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: System default (if provided)
|
||||
if (systemDefaultModel === undefined) {
|
||||
log("No model resolved - systemDefaultModel not configured")
|
||||
if (!resolved) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
log("Model resolved via system default", { model: systemDefaultModel })
|
||||
return { model: systemDefaultModel, source: "system-default" }
|
||||
return {
|
||||
model: resolved.model,
|
||||
source: resolved.provenance,
|
||||
variant: resolved.variant,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import {
|
||||
parseVersion,
|
||||
compareVersions,
|
||||
isVersionGte,
|
||||
isVersionLt,
|
||||
getOpenCodeVersion,
|
||||
isOpenCodeVersionAtLeast,
|
||||
resetVersionCache,
|
||||
@@ -103,32 +101,6 @@ describe("opencode-version", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("isVersionGte", () => {
|
||||
test("returns true when a >= b", () => {
|
||||
expect(isVersionGte("1.1.1", "1.1.1")).toBe(true)
|
||||
expect(isVersionGte("1.1.2", "1.1.1")).toBe(true)
|
||||
expect(isVersionGte("1.2.0", "1.1.1")).toBe(true)
|
||||
expect(isVersionGte("2.0.0", "1.1.1")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when a < b", () => {
|
||||
expect(isVersionGte("1.1.0", "1.1.1")).toBe(false)
|
||||
expect(isVersionGte("1.0.9", "1.1.1")).toBe(false)
|
||||
expect(isVersionGte("0.9.9", "1.1.1")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isVersionLt", () => {
|
||||
test("returns true when a < b", () => {
|
||||
expect(isVersionLt("1.1.0", "1.1.1")).toBe(true)
|
||||
expect(isVersionLt("1.0.150", "1.1.1")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when a >= b", () => {
|
||||
expect(isVersionLt("1.1.1", "1.1.1")).toBe(false)
|
||||
expect(isVersionLt("1.1.2", "1.1.1")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOpenCodeVersion", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -37,13 +37,6 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
|
||||
return 0
|
||||
}
|
||||
|
||||
export function isVersionGte(a: string, b: string): boolean {
|
||||
return compareVersions(a, b) >= 0
|
||||
}
|
||||
|
||||
export function isVersionLt(a: string, b: string): boolean {
|
||||
return compareVersions(a, b) < 0
|
||||
}
|
||||
|
||||
export function getOpenCodeVersion(): string | null {
|
||||
if (cachedVersion !== NOT_CACHED) {
|
||||
@@ -69,7 +62,7 @@ export function getOpenCodeVersion(): string | null {
|
||||
export function isOpenCodeVersionAtLeast(version: string): boolean {
|
||||
const current = getOpenCodeVersion()
|
||||
if (!current) return true
|
||||
return isVersionGte(current, version)
|
||||
return compareVersions(current, version) >= 0
|
||||
}
|
||||
|
||||
export function resetVersionCache(): void {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface InjectedPathsData {
|
||||
sessionID: string;
|
||||
injectedPaths: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export function createInjectedPathsStorage(storageDir: string) {
|
||||
const getStoragePath = (sessionID: string): string =>
|
||||
join(storageDir, `${sessionID}.json`);
|
||||
|
||||
const loadInjectedPaths = (sessionID: string): Set<string> => {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (!existsSync(filePath)) return new Set();
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const data: InjectedPathsData = JSON.parse(content);
|
||||
return new Set(data.injectedPaths);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const saveInjectedPaths = (sessionID: string, paths: Set<string>): void => {
|
||||
if (!existsSync(storageDir)) {
|
||||
mkdirSync(storageDir, { recursive: true });
|
||||
}
|
||||
|
||||
const data: InjectedPathsData = {
|
||||
sessionID,
|
||||
injectedPaths: [...paths],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
|
||||
};
|
||||
|
||||
const clearInjectedPaths = (sessionID: string): void => {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loadInjectedPaths,
|
||||
saveInjectedPaths,
|
||||
clearInjectedPaths,
|
||||
};
|
||||
}
|
||||
+25
-30
@@ -8,42 +8,37 @@ export function snakeToCamel(str: string): string {
|
||||
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
export function transformObjectKeys(
|
||||
obj: Record<string, unknown>,
|
||||
transformer: (key: string) => string,
|
||||
deep: boolean = true
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const transformedKey = transformer(key)
|
||||
if (deep && isPlainObject(value)) {
|
||||
result[transformedKey] = transformObjectKeys(value, transformer, true)
|
||||
} else if (deep && Array.isArray(value)) {
|
||||
result[transformedKey] = value.map((item) =>
|
||||
isPlainObject(item) ? transformObjectKeys(item, transformer, true) : item
|
||||
)
|
||||
} else {
|
||||
result[transformedKey] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function objectToSnakeCase(
|
||||
obj: Record<string, unknown>,
|
||||
deep: boolean = true
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const snakeKey = camelToSnake(key)
|
||||
if (deep && isPlainObject(value)) {
|
||||
result[snakeKey] = objectToSnakeCase(value, true)
|
||||
} else if (deep && Array.isArray(value)) {
|
||||
result[snakeKey] = value.map((item) =>
|
||||
isPlainObject(item) ? objectToSnakeCase(item, true) : item
|
||||
)
|
||||
} else {
|
||||
result[snakeKey] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return transformObjectKeys(obj, camelToSnake, deep)
|
||||
}
|
||||
|
||||
export function objectToCamelCase(
|
||||
obj: Record<string, unknown>,
|
||||
deep: boolean = true
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const camelKey = snakeToCamel(key)
|
||||
if (deep && isPlainObject(value)) {
|
||||
result[camelKey] = objectToCamelCase(value, true)
|
||||
} else if (deep && Array.isArray(value)) {
|
||||
result[camelKey] = value.map((item) =>
|
||||
isPlainObject(item) ? objectToCamelCase(item, true) : item
|
||||
)
|
||||
} else {
|
||||
result[camelKey] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return transformObjectKeys(obj, snakeToCamel, deep)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user