Merge remote-tracking branch 'origin/dev' into refactor/modular-code-enforcement
# Conflicts: # src/features/background-agent/manager.ts # src/features/background-agent/spawner.ts # src/features/tmux-subagent/manager.ts # src/shared/model-availability.test.ts # src/shared/model-availability.ts # src/shared/model-resolution-pipeline.ts # src/tools/delegate-task/executor.ts
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
const execSyncMock = mock(() => {
|
||||
throw new Error("execSync should not be called")
|
||||
})
|
||||
|
||||
const execFileSyncMock = mock((file: string, args: string[], _opts: { cwd?: string }) => {
|
||||
if (file !== "git") throw new Error(`unexpected file: ${file}`)
|
||||
const subcommand = args[0]
|
||||
|
||||
if (subcommand === "diff") {
|
||||
return "1\t2\tfile.ts\n"
|
||||
}
|
||||
|
||||
if (subcommand === "status") {
|
||||
return " M file.ts\n"
|
||||
}
|
||||
|
||||
throw new Error(`unexpected args: ${args.join(" ")}`)
|
||||
})
|
||||
|
||||
mock.module("node:child_process", () => ({
|
||||
execSync: execSyncMock,
|
||||
execFileSync: execFileSyncMock,
|
||||
}))
|
||||
|
||||
const { collectGitDiffStats } = await import("./collect-git-diff-stats")
|
||||
|
||||
describe("collectGitDiffStats", () => {
|
||||
test("uses execFileSync with arg arrays (no shell injection)", () => {
|
||||
//#given
|
||||
const directory = "/tmp/safe-repo;touch /tmp/pwn"
|
||||
|
||||
//#when
|
||||
const result = collectGitDiffStats(directory)
|
||||
|
||||
//#then
|
||||
expect(execSyncMock).not.toHaveBeenCalled()
|
||||
expect(execFileSyncMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
const [firstCallFile, firstCallArgs, firstCallOpts] = execFileSyncMock.mock
|
||||
.calls[0]! as unknown as [string, string[], { cwd?: string }]
|
||||
expect(firstCallFile).toBe("git")
|
||||
expect(firstCallArgs).toEqual(["diff", "--numstat", "HEAD"])
|
||||
expect(firstCallOpts.cwd).toBe(directory)
|
||||
expect(firstCallArgs.join(" ")).not.toContain(directory)
|
||||
|
||||
const [secondCallFile, secondCallArgs, secondCallOpts] = execFileSyncMock.mock
|
||||
.calls[1]! as unknown as [string, string[], { cwd?: string }]
|
||||
expect(secondCallFile).toBe("git")
|
||||
expect(secondCallArgs).toEqual(["status", "--porcelain"])
|
||||
expect(secondCallOpts.cwd).toBe(directory)
|
||||
expect(secondCallArgs.join(" ")).not.toContain(directory)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
path: "file.ts",
|
||||
added: 1,
|
||||
removed: 2,
|
||||
status: "modified",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -8,30 +8,32 @@ export function migrateConfigFile(
|
||||
configPath: string,
|
||||
rawConfig: Record<string, unknown>
|
||||
): boolean {
|
||||
// Work on a deep copy — only apply changes to rawConfig if file write succeeds
|
||||
const copy = structuredClone(rawConfig)
|
||||
let needsWrite = false
|
||||
|
||||
// Load previously applied migrations
|
||||
const existingMigrations = Array.isArray(rawConfig._migrations)
|
||||
? new Set(rawConfig._migrations as string[])
|
||||
const existingMigrations = Array.isArray(copy._migrations)
|
||||
? new Set(copy._migrations as string[])
|
||||
: new Set<string>()
|
||||
const allNewMigrations: string[] = []
|
||||
|
||||
if (rawConfig.agents && typeof rawConfig.agents === "object") {
|
||||
const { migrated, changed } = migrateAgentNames(rawConfig.agents as Record<string, unknown>)
|
||||
if (copy.agents && typeof copy.agents === "object") {
|
||||
const { migrated, changed } = migrateAgentNames(copy.agents as Record<string, unknown>)
|
||||
if (changed) {
|
||||
rawConfig.agents = migrated
|
||||
copy.agents = migrated
|
||||
needsWrite = true
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate model versions in agents (skip already-applied migrations)
|
||||
if (rawConfig.agents && typeof rawConfig.agents === "object") {
|
||||
if (copy.agents && typeof copy.agents === "object") {
|
||||
const { migrated, changed, newMigrations } = migrateModelVersions(
|
||||
rawConfig.agents as Record<string, unknown>,
|
||||
copy.agents as Record<string, unknown>,
|
||||
existingMigrations
|
||||
)
|
||||
if (changed) {
|
||||
rawConfig.agents = migrated
|
||||
copy.agents = migrated
|
||||
needsWrite = true
|
||||
log("Migrated model versions in agents config")
|
||||
}
|
||||
@@ -39,13 +41,13 @@ export function migrateConfigFile(
|
||||
}
|
||||
|
||||
// Migrate model versions in categories (skip already-applied migrations)
|
||||
if (rawConfig.categories && typeof rawConfig.categories === "object") {
|
||||
if (copy.categories && typeof copy.categories === "object") {
|
||||
const { migrated, changed, newMigrations } = migrateModelVersions(
|
||||
rawConfig.categories as Record<string, unknown>,
|
||||
copy.categories as Record<string, unknown>,
|
||||
existingMigrations
|
||||
)
|
||||
if (changed) {
|
||||
rawConfig.categories = migrated
|
||||
copy.categories = migrated
|
||||
needsWrite = true
|
||||
log("Migrated model versions in categories config")
|
||||
}
|
||||
@@ -56,20 +58,20 @@ export function migrateConfigFile(
|
||||
if (allNewMigrations.length > 0) {
|
||||
const updatedMigrations = Array.from(existingMigrations)
|
||||
updatedMigrations.push(...allNewMigrations)
|
||||
rawConfig._migrations = updatedMigrations
|
||||
copy._migrations = updatedMigrations
|
||||
needsWrite = true
|
||||
}
|
||||
|
||||
if (rawConfig.omo_agent) {
|
||||
rawConfig.sisyphus_agent = rawConfig.omo_agent
|
||||
delete rawConfig.omo_agent
|
||||
if (copy.omo_agent) {
|
||||
copy.sisyphus_agent = copy.omo_agent
|
||||
delete copy.omo_agent
|
||||
needsWrite = true
|
||||
}
|
||||
|
||||
if (rawConfig.disabled_agents && Array.isArray(rawConfig.disabled_agents)) {
|
||||
if (copy.disabled_agents && Array.isArray(copy.disabled_agents)) {
|
||||
const migrated: string[] = []
|
||||
let changed = false
|
||||
for (const agent of rawConfig.disabled_agents as string[]) {
|
||||
for (const agent of copy.disabled_agents as string[]) {
|
||||
const newAgent = AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent
|
||||
if (newAgent !== agent) {
|
||||
changed = true
|
||||
@@ -77,15 +79,15 @@ export function migrateConfigFile(
|
||||
migrated.push(newAgent)
|
||||
}
|
||||
if (changed) {
|
||||
rawConfig.disabled_agents = migrated
|
||||
copy.disabled_agents = migrated
|
||||
needsWrite = true
|
||||
}
|
||||
}
|
||||
|
||||
if (rawConfig.disabled_hooks && Array.isArray(rawConfig.disabled_hooks)) {
|
||||
const { migrated, changed, removed } = migrateHookNames(rawConfig.disabled_hooks as string[])
|
||||
if (copy.disabled_hooks && Array.isArray(copy.disabled_hooks)) {
|
||||
const { migrated, changed, removed } = migrateHookNames(copy.disabled_hooks as string[])
|
||||
if (changed) {
|
||||
rawConfig.disabled_hooks = migrated
|
||||
copy.disabled_hooks = migrated
|
||||
needsWrite = true
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
@@ -99,13 +101,25 @@ export function migrateConfigFile(
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
||||
const backupPath = `${configPath}.bak.${timestamp}`
|
||||
fs.copyFileSync(configPath, backupPath)
|
||||
try {
|
||||
fs.copyFileSync(configPath, backupPath)
|
||||
} catch {
|
||||
// Original file may not exist yet — skip backup
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n", "utf-8")
|
||||
fs.writeFileSync(configPath, JSON.stringify(copy, null, 2) + "\n", "utf-8")
|
||||
log(`Migrated config file: ${configPath} (backup: ${backupPath})`)
|
||||
} catch (err) {
|
||||
log(`Failed to write migrated config to ${configPath}:`, err)
|
||||
// File write failed — rawConfig is untouched, preserving user's original values
|
||||
return false
|
||||
}
|
||||
|
||||
// File write succeeded — apply changes to the original rawConfig
|
||||
for (const key of Object.keys(rawConfig)) {
|
||||
delete rawConfig[key]
|
||||
}
|
||||
Object.assign(rawConfig, copy)
|
||||
}
|
||||
|
||||
return needsWrite
|
||||
|
||||
@@ -5,198 +5,174 @@ import { tmpdir } from "os"
|
||||
import { join } from "path"
|
||||
|
||||
let __resetModelCache: () => void
|
||||
let fetchAvailableModels: (
|
||||
client?: unknown,
|
||||
options?: { connectedProviders?: string[] | null },
|
||||
) => Promise<Set<string>>
|
||||
let fetchAvailableModels: (client?: unknown, options?: { connectedProviders?: string[] | null }) => Promise<Set<string>>
|
||||
let fuzzyMatchModel: (target: string, available: Set<string>, providers?: string[]) => string | null
|
||||
let isModelAvailable: (targetModel: string, availableModels: Set<string>) => boolean
|
||||
let getConnectedProviders: (client: unknown) => Promise<string[]>
|
||||
|
||||
beforeAll(async () => {
|
||||
;({
|
||||
__resetModelCache,
|
||||
fetchAvailableModels,
|
||||
fuzzyMatchModel,
|
||||
isModelAvailable,
|
||||
getConnectedProviders,
|
||||
} = await import("./model-availability"))
|
||||
;({
|
||||
__resetModelCache,
|
||||
fetchAvailableModels,
|
||||
fuzzyMatchModel,
|
||||
isModelAvailable,
|
||||
getConnectedProviders,
|
||||
} = await import("./model-availability"))
|
||||
})
|
||||
|
||||
describe("fetchAvailableModels", () => {
|
||||
let tempDir: string
|
||||
let tempDir: string
|
||||
let originalXdgCache: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelCache()
|
||||
tempDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelCache()
|
||||
tempDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
|
||||
originalXdgCache = process.env.XDG_CACHE_HOME
|
||||
process.env.XDG_CACHE_HOME = tempDir
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalXdgCache !== undefined) {
|
||||
afterEach(() => {
|
||||
if (originalXdgCache !== undefined) {
|
||||
process.env.XDG_CACHE_HOME = originalXdgCache
|
||||
} else {
|
||||
delete process.env.XDG_CACHE_HOME
|
||||
}
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeModelsCache(data: Record<string, any>) {
|
||||
const cacheDir = join(tempDir, "opencode")
|
||||
require("fs").mkdirSync(cacheDir, { recursive: true })
|
||||
writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data))
|
||||
}
|
||||
function writeModelsCache(data: Record<string, any>) {
|
||||
const cacheDir = join(tempDir, "opencode")
|
||||
require("fs").mkdirSync(cacheDir, { recursive: true })
|
||||
writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data))
|
||||
}
|
||||
|
||||
it("#given cache file with models #when fetchAvailableModels called with connectedProviders #then returns Set of model IDs", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: {
|
||||
id: "anthropic",
|
||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6" } },
|
||||
},
|
||||
google: { id: "google", models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
|
||||
})
|
||||
it("#given cache file with models #when fetchAvailableModels called with connectedProviders #then returns Set of model IDs", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
google: { id: "google", models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["openai", "anthropic", "google"],
|
||||
})
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["openai", "anthropic", "google"]
|
||||
})
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(3)
|
||||
expect(result.has("openai/gpt-5.2")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
||||
})
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(3)
|
||||
expect(result.has("openai/gpt-5.2")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
||||
})
|
||||
|
||||
it("#given connectedProviders unknown #when fetchAvailableModels called without options #then returns empty Set", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
})
|
||||
it("#given connectedProviders unknown #when fetchAvailableModels called without options #then returns empty Set", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels()
|
||||
const result = await fetchAvailableModels()
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("#given connectedProviders unknown but client can list #when fetchAvailableModels called with client #then returns models from API filtered by connected providers", async () => {
|
||||
const client = {
|
||||
provider: {
|
||||
list: async () => ({ data: { connected: ["openai"] } }),
|
||||
},
|
||||
model: {
|
||||
list: async () => ({
|
||||
data: [
|
||||
{ id: "gpt-5.3-codex", provider: "openai" },
|
||||
{ id: "gemini-3-pro", provider: "google" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
it("#given connectedProviders unknown but client can list #when fetchAvailableModels called with client #then returns models from API filtered by connected providers", async () => {
|
||||
const client = {
|
||||
provider: {
|
||||
list: async () => ({ data: { connected: ["openai"] } }),
|
||||
},
|
||||
model: {
|
||||
list: async () => ({
|
||||
data: [
|
||||
{ id: "gpt-5.3-codex", provider: "openai" },
|
||||
{ id: "gemini-3-pro", provider: "google" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await fetchAvailableModels(client)
|
||||
const result = await fetchAvailableModels(client)
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
||||
})
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
||||
})
|
||||
|
||||
it("#given cache file not found #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => {
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["openai"],
|
||||
})
|
||||
it("#given cache file not found #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => {
|
||||
const result = await fetchAvailableModels(undefined, { connectedProviders: ["openai"] })
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("#given cache missing but client can list #when fetchAvailableModels called with connectedProviders #then returns models from API", async () => {
|
||||
const client = {
|
||||
provider: {
|
||||
list: async () => ({ data: { connected: ["openai", "google"] } }),
|
||||
},
|
||||
model: {
|
||||
list: async () => ({
|
||||
data: [
|
||||
{ id: "gpt-5.3-codex", provider: "openai" },
|
||||
{ id: "gemini-3-pro", provider: "google" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
it("#given cache missing but client can list #when fetchAvailableModels called with connectedProviders #then returns models from API", async () => {
|
||||
const client = {
|
||||
provider: {
|
||||
list: async () => ({ data: { connected: ["openai", "google"] } }),
|
||||
},
|
||||
model: {
|
||||
list: async () => ({
|
||||
data: [
|
||||
{ id: "gpt-5.3-codex", provider: "openai" },
|
||||
{ id: "gemini-3-pro", provider: "google" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await fetchAvailableModels(client, {
|
||||
connectedProviders: ["openai", "google"],
|
||||
})
|
||||
const result = await fetchAvailableModels(client, { connectedProviders: ["openai", "google"] })
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
||||
})
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
||||
})
|
||||
|
||||
it("#given cache read twice #when second call made with same providers #then reads fresh each time", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: {
|
||||
id: "anthropic",
|
||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6" } },
|
||||
},
|
||||
})
|
||||
it("#given cache read twice #when second call made with same providers #then reads fresh each time", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
})
|
||||
|
||||
const result1 = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["openai"],
|
||||
})
|
||||
const result2 = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["openai"],
|
||||
})
|
||||
const result1 = await fetchAvailableModels(undefined, { connectedProviders: ["openai"] })
|
||||
const result2 = await fetchAvailableModels(undefined, { connectedProviders: ["openai"] })
|
||||
|
||||
expect(result1.size).toBe(result2.size)
|
||||
expect(result1.has("openai/gpt-5.2")).toBe(true)
|
||||
})
|
||||
expect(result1.size).toBe(result2.size)
|
||||
expect(result1.has("openai/gpt-5.2")).toBe(true)
|
||||
})
|
||||
|
||||
it("#given empty providers in cache #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => {
|
||||
writeModelsCache({})
|
||||
it("#given empty providers in cache #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => {
|
||||
writeModelsCache({})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["openai"],
|
||||
})
|
||||
const result = await fetchAvailableModels(undefined, { connectedProviders: ["openai"] })
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("#given cache file with various providers #when fetchAvailableModels called with all providers #then extracts all IDs correctly", async () => {
|
||||
writeModelsCache({
|
||||
openai: {
|
||||
id: "openai",
|
||||
models: { "gpt-5.3-codex": { id: "gpt-5.3-codex" } },
|
||||
},
|
||||
anthropic: {
|
||||
id: "anthropic",
|
||||
models: { "claude-sonnet-4-5": { id: "claude-sonnet-4-5" } },
|
||||
},
|
||||
google: {
|
||||
id: "google",
|
||||
models: { "gemini-3-flash": { id: "gemini-3-flash" } },
|
||||
},
|
||||
opencode: { id: "opencode", models: { "gpt-5-nano": { id: "gpt-5-nano" } } },
|
||||
})
|
||||
it("#given cache file with various providers #when fetchAvailableModels called with all providers #then extracts all IDs correctly", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.3-codex": { id: "gpt-5.3-codex" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-sonnet-4-5": { id: "claude-sonnet-4-5" } } },
|
||||
google: { id: "google", models: { "gemini-3-flash": { id: "gemini-3-flash" } } },
|
||||
opencode: { id: "opencode", models: { "gpt-5-nano": { id: "gpt-5-nano" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["openai", "anthropic", "google", "opencode"],
|
||||
})
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["openai", "anthropic", "google", "opencode"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(4)
|
||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||
expect(result.has("anthropic/claude-sonnet-4-5")).toBe(true)
|
||||
expect(result.has("google/gemini-3-flash")).toBe(true)
|
||||
expect(result.has("opencode/gpt-5-nano")).toBe(true)
|
||||
})
|
||||
expect(result.size).toBe(4)
|
||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||
expect(result.has("anthropic/claude-sonnet-4-5")).toBe(true)
|
||||
expect(result.has("google/gemini-3-flash")).toBe(true)
|
||||
expect(result.has("opencode/gpt-5-nano")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fuzzyMatchModel", () => {
|
||||
// given available models from multiple providers
|
||||
// when searching for a substring match
|
||||
// then return the matching model
|
||||
it("should match substring in model name", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -207,6 +183,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// given available model with preview suffix
|
||||
// when searching with provider-prefixed base model
|
||||
// then return preview model
|
||||
it("should match preview suffix for gemini-3-flash", () => {
|
||||
const available = new Set(["google/gemini-3-flash-preview"])
|
||||
const result = fuzzyMatchModel(
|
||||
@@ -217,6 +196,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("google/gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
// given available models with partial matches
|
||||
// when searching for a substring
|
||||
// then return exact match if it exists
|
||||
it("should prefer exact match over substring match", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -227,6 +209,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// given available models with multiple substring matches
|
||||
// when searching for a substring
|
||||
// then return the shorter model name (more specific)
|
||||
it("should prefer shorter model name when multiple matches exist", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2-ultra",
|
||||
@@ -236,6 +221,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2-ultra")
|
||||
})
|
||||
|
||||
// given available models with claude variants
|
||||
// when searching for claude-opus
|
||||
// then return matching claude-opus model
|
||||
it("should match claude-opus to claude-opus-4-6", () => {
|
||||
const available = new Set([
|
||||
"anthropic/claude-opus-4-6",
|
||||
@@ -245,6 +233,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
// given available models from multiple providers
|
||||
// when providers filter is specified
|
||||
// then only search models from specified providers
|
||||
it("should filter by provider when providers array is given", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -255,6 +246,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// given available models from multiple providers
|
||||
// when providers filter excludes matching models
|
||||
// then return null
|
||||
it("should return null when provider filter excludes all matches", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -264,6 +258,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// given available models
|
||||
// when no substring match exists
|
||||
// then return null
|
||||
it("should return null when no match found", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -273,6 +270,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// given available models with different cases
|
||||
// when searching with different case
|
||||
// then match case-insensitively
|
||||
it("should match case-insensitively", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -282,6 +282,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// given available models with exact match and longer variants
|
||||
// when searching for exact match
|
||||
// then return exact match first
|
||||
it("should prioritize exact match over longer variants", () => {
|
||||
const available = new Set([
|
||||
"anthropic/claude-opus-4-6",
|
||||
@@ -291,6 +294,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
// given available models with similar model IDs (e.g., glm-4.7 and glm-4.7-free)
|
||||
// when searching for the longer variant (glm-4.7-free)
|
||||
// then return exact model ID match, not the shorter one
|
||||
it("should prefer exact model ID match over shorter substring match", () => {
|
||||
const available = new Set([
|
||||
"zai-coding-plan/glm-4.7",
|
||||
@@ -300,6 +306,9 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("zai-coding-plan/glm-4.7-free")
|
||||
})
|
||||
|
||||
// given available models with similar model IDs
|
||||
// when searching for the shorter variant
|
||||
// then return the shorter match (existing behavior preserved)
|
||||
it("should still prefer shorter match when searching for shorter variant", () => {
|
||||
const available = new Set([
|
||||
"zai-coding-plan/glm-4.7",
|
||||
@@ -309,12 +318,21 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("zai-coding-plan/glm-4.7")
|
||||
})
|
||||
|
||||
// given same model ID from multiple providers
|
||||
// when searching for exact model ID
|
||||
// then return shortest full string (preserves tie-break behavior)
|
||||
it("should use shortest tie-break when multiple providers have same model ID", () => {
|
||||
const available = new Set(["opencode/gpt-5.2", "openai/gpt-5.2"])
|
||||
const available = new Set([
|
||||
"opencode/gpt-5.2",
|
||||
"openai/gpt-5.2",
|
||||
])
|
||||
const result = fuzzyMatchModel("gpt-5.2", available)
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// given available models with multiple providers
|
||||
// when multiple providers are specified
|
||||
// then search all specified providers
|
||||
it("should search all specified providers", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
@@ -325,12 +343,21 @@ describe("fuzzyMatchModel", () => {
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// given available models with provider prefix
|
||||
// when searching with provider filter
|
||||
// then only match models with correct provider prefix
|
||||
it("should only match models with correct provider prefix", () => {
|
||||
const available = new Set(["openai/gpt-5.2", "anthropic/gpt-something"])
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
"anthropic/gpt-something",
|
||||
])
|
||||
const result = fuzzyMatchModel("gpt", available, ["openai"])
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
// given empty available set
|
||||
// when searching
|
||||
// then return null
|
||||
it("should return null for empty available set", () => {
|
||||
const available = new Set<string>()
|
||||
const result = fuzzyMatchModel("gpt", available)
|
||||
@@ -339,13 +366,16 @@ describe("fuzzyMatchModel", () => {
|
||||
})
|
||||
|
||||
describe("getConnectedProviders", () => {
|
||||
// given SDK client with connected providers
|
||||
// when provider.list returns data
|
||||
// then returns connected array
|
||||
it("should return connected providers from SDK", async () => {
|
||||
const mockClient = {
|
||||
provider: {
|
||||
list: async () => ({
|
||||
data: { connected: ["anthropic", "opencode", "google"] },
|
||||
}),
|
||||
},
|
||||
data: { connected: ["anthropic", "opencode", "google"] }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getConnectedProviders(mockClient)
|
||||
@@ -353,13 +383,14 @@ describe("getConnectedProviders", () => {
|
||||
expect(result).toEqual(["anthropic", "opencode", "google"])
|
||||
})
|
||||
|
||||
// given SDK client
|
||||
// when provider.list throws error
|
||||
// then returns empty array
|
||||
it("should return empty array on SDK error", async () => {
|
||||
const mockClient = {
|
||||
provider: {
|
||||
list: async () => {
|
||||
throw new Error("Network error")
|
||||
},
|
||||
},
|
||||
list: async () => { throw new Error("Network error") }
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getConnectedProviders(mockClient)
|
||||
@@ -367,11 +398,14 @@ describe("getConnectedProviders", () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// given SDK client with empty connected array
|
||||
// when provider.list returns empty
|
||||
// then returns empty array
|
||||
it("should return empty array when no providers connected", async () => {
|
||||
const mockClient = {
|
||||
provider: {
|
||||
list: async () => ({ data: { connected: [] } }),
|
||||
},
|
||||
list: async () => ({ data: { connected: [] } })
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getConnectedProviders(mockClient)
|
||||
@@ -379,6 +413,9 @@ describe("getConnectedProviders", () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// given SDK client without provider.list method
|
||||
// when getConnectedProviders called
|
||||
// then returns empty array
|
||||
it("should return empty array when client.provider.list not available", async () => {
|
||||
const mockClient = {}
|
||||
|
||||
@@ -387,17 +424,23 @@ describe("getConnectedProviders", () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// given null client
|
||||
// when getConnectedProviders called
|
||||
// then returns empty array
|
||||
it("should return empty array for null client", async () => {
|
||||
const result = await getConnectedProviders(null)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
// given SDK client with missing data.connected
|
||||
// when provider.list returns without connected field
|
||||
// then returns empty array
|
||||
it("should return empty array when data.connected is undefined", async () => {
|
||||
const mockClient = {
|
||||
provider: {
|
||||
list: async () => ({ data: {} }),
|
||||
},
|
||||
list: async () => ({ data: {} })
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getConnectedProviders(mockClient)
|
||||
@@ -432,6 +475,9 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data))
|
||||
}
|
||||
|
||||
// given cache with multiple providers
|
||||
// when connectedProviders specifies one provider
|
||||
// then only returns models from that provider
|
||||
it("should filter models by connected providers", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -440,7 +486,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["anthropic"],
|
||||
connectedProviders: ["anthropic"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(1)
|
||||
@@ -449,6 +495,9 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
||||
})
|
||||
|
||||
// given cache with multiple providers
|
||||
// when connectedProviders specifies multiple providers
|
||||
// then returns models from all specified providers
|
||||
it("should filter models by multiple connected providers", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -457,7 +506,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["anthropic", "google"],
|
||||
connectedProviders: ["anthropic", "google"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(2)
|
||||
@@ -466,6 +515,9 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
expect(result.has("openai/gpt-5.2")).toBe(false)
|
||||
})
|
||||
|
||||
// given cache with models
|
||||
// when connectedProviders is empty array
|
||||
// then returns empty set
|
||||
it("should return empty set when connectedProviders is empty", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -473,12 +525,15 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: [],
|
||||
connectedProviders: []
|
||||
})
|
||||
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
// given cache with models
|
||||
// when connectedProviders is undefined (no options)
|
||||
// then returns empty set (triggers fallback in resolver)
|
||||
it("should return empty set when connectedProviders not specified", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -490,18 +545,24 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
// given cache with models
|
||||
// when connectedProviders contains provider not in cache
|
||||
// then returns empty set for that provider
|
||||
it("should handle provider not in cache gracefully", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["azure"],
|
||||
connectedProviders: ["azure"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
// given cache with models and mixed connected providers
|
||||
// when some providers exist in cache and some don't
|
||||
// then returns models only from matching providers
|
||||
it("should return models from providers that exist in both cache and connected list", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -509,31 +570,39 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["anthropic", "azure", "unknown"],
|
||||
connectedProviders: ["anthropic", "azure", "unknown"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(1)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
})
|
||||
|
||||
// given filtered fetch
|
||||
// when called twice with different filters
|
||||
// then does NOT use cache (dynamic per-session)
|
||||
it("should not cache filtered results", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
})
|
||||
|
||||
// First call with anthropic
|
||||
const result1 = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["anthropic"],
|
||||
connectedProviders: ["anthropic"]
|
||||
})
|
||||
expect(result1.size).toBe(1)
|
||||
|
||||
// Second call with openai - should work, not cached
|
||||
const result2 = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["openai"],
|
||||
connectedProviders: ["openai"]
|
||||
})
|
||||
expect(result2.size).toBe(1)
|
||||
expect(result2.has("openai/gpt-5.2")).toBe(true)
|
||||
})
|
||||
|
||||
// given connectedProviders unknown
|
||||
// when called twice without connectedProviders
|
||||
// then always returns empty set (triggers fallback)
|
||||
it("should return empty set when connectedProviders unknown", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
@@ -567,19 +636,13 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeProviderModelsCache(data: {
|
||||
models: Record<string, string[] | any[]>
|
||||
connected: string[]
|
||||
}) {
|
||||
function writeProviderModelsCache(data: { models: Record<string, string[] | any[]>; connected: string[] }) {
|
||||
const cacheDir = join(tempDir, "oh-my-opencode")
|
||||
require("fs").mkdirSync(cacheDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(cacheDir, "provider-models.json"),
|
||||
JSON.stringify({
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
writeFileSync(join(cacheDir, "provider-models.json"), JSON.stringify({
|
||||
...data,
|
||||
updatedAt: new Date().toISOString()
|
||||
}))
|
||||
}
|
||||
|
||||
function writeModelsCache(data: Record<string, any>) {
|
||||
@@ -588,21 +651,24 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data))
|
||||
}
|
||||
|
||||
// given provider-models cache exists (whitelist-filtered)
|
||||
// when fetchAvailableModels called
|
||||
// then uses provider-models cache instead of models.json
|
||||
it("should prefer provider-models cache over models.json", async () => {
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
opencode: ["glm-4.7-free", "gpt-5-nano"],
|
||||
anthropic: ["claude-opus-4-6"],
|
||||
anthropic: ["claude-opus-4-6"]
|
||||
},
|
||||
connected: ["opencode", "anthropic"],
|
||||
connected: ["opencode", "anthropic"]
|
||||
})
|
||||
writeModelsCache({
|
||||
opencode: { models: { "glm-4.7-free": {}, "gpt-5-nano": {}, "gpt-5.2": {} } },
|
||||
anthropic: { models: { "claude-opus-4-6": {}, "claude-sonnet-4-5": {} } },
|
||||
anthropic: { models: { "claude-opus-4-6": {}, "claude-sonnet-4-5": {} } }
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["opencode", "anthropic"],
|
||||
connectedProviders: ["opencode", "anthropic"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(3)
|
||||
@@ -613,9 +679,13 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
expect(result.has("anthropic/claude-sonnet-4-5")).toBe(false)
|
||||
})
|
||||
|
||||
// given provider-models cache exists but has no models (API failure)
|
||||
// when fetchAvailableModels called
|
||||
// then falls back to models.json so fuzzy matching can still work
|
||||
it("should fall back to models.json when provider-models cache is empty", async () => {
|
||||
writeProviderModelsCache({
|
||||
models: {},
|
||||
models: {
|
||||
},
|
||||
connected: ["google"],
|
||||
})
|
||||
writeModelsCache({
|
||||
@@ -625,22 +695,21 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
const availableModels = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["google"],
|
||||
})
|
||||
const match = fuzzyMatchModel(
|
||||
"google/gemini-3-flash",
|
||||
availableModels,
|
||||
["google"],
|
||||
)
|
||||
const match = fuzzyMatchModel("google/gemini-3-flash", availableModels, ["google"])
|
||||
|
||||
expect(match).toBe("google/gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
// given only models.json exists (no provider-models cache)
|
||||
// when fetchAvailableModels called
|
||||
// then falls back to models.json (no whitelist filtering)
|
||||
it("should fallback to models.json when provider-models cache not found", async () => {
|
||||
writeModelsCache({
|
||||
opencode: { models: { "glm-4.7-free": {}, "gpt-5-nano": {}, "gpt-5.2": {} } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["opencode"],
|
||||
connectedProviders: ["opencode"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(3)
|
||||
@@ -649,18 +718,21 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
expect(result.has("opencode/gpt-5.2")).toBe(true)
|
||||
})
|
||||
|
||||
// given provider-models cache with whitelist
|
||||
// when connectedProviders filters to subset
|
||||
// then only returns models from connected providers
|
||||
it("should filter by connectedProviders even with provider-models cache", async () => {
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
opencode: ["glm-4.7-free"],
|
||||
anthropic: ["claude-opus-4-6"],
|
||||
google: ["gemini-3-pro"],
|
||||
google: ["gemini-3-pro"]
|
||||
},
|
||||
connected: ["opencode", "anthropic", "google"],
|
||||
connected: ["opencode", "anthropic", "google"]
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["opencode"],
|
||||
connectedProviders: ["opencode"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(1)
|
||||
@@ -673,25 +745,15 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
ollama: [
|
||||
{
|
||||
id: "ministral-3:14b-32k-agent",
|
||||
provider: "ollama",
|
||||
context: 32768,
|
||||
output: 8192,
|
||||
},
|
||||
{
|
||||
id: "qwen3-coder:32k-agent",
|
||||
provider: "ollama",
|
||||
context: 32768,
|
||||
output: 8192,
|
||||
},
|
||||
],
|
||||
{ id: "ministral-3:14b-32k-agent", provider: "ollama", context: 32768, output: 8192 },
|
||||
{ id: "qwen3-coder:32k-agent", provider: "ollama", context: 32768, output: 8192 }
|
||||
]
|
||||
},
|
||||
connected: ["ollama"],
|
||||
connected: ["ollama"]
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["ollama"],
|
||||
connectedProviders: ["ollama"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(2)
|
||||
@@ -705,14 +767,14 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
anthropic: ["claude-opus-4-6", "claude-sonnet-4-5"],
|
||||
ollama: [
|
||||
{ id: "ministral-3:14b-32k-agent", provider: "ollama" },
|
||||
{ id: "qwen3-coder:32k-agent", provider: "ollama" },
|
||||
],
|
||||
{ id: "qwen3-coder:32k-agent", provider: "ollama" }
|
||||
]
|
||||
},
|
||||
connected: ["anthropic", "ollama"],
|
||||
connected: ["anthropic", "ollama"]
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["anthropic", "ollama"],
|
||||
connectedProviders: ["anthropic", "ollama"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(4)
|
||||
@@ -730,14 +792,14 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
{ provider: "ollama" },
|
||||
{ id: "", provider: "ollama" },
|
||||
null,
|
||||
"string-model",
|
||||
],
|
||||
"string-model"
|
||||
]
|
||||
},
|
||||
connected: ["ollama"],
|
||||
connected: ["ollama"]
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["ollama"],
|
||||
connectedProviders: ["ollama"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(2)
|
||||
@@ -749,10 +811,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
describe("isModelAvailable", () => {
|
||||
it("returns true when model exists via fuzzy match", () => {
|
||||
// given
|
||||
const available = new Set([
|
||||
"openai/gpt-5.3-codex",
|
||||
"anthropic/claude-opus-4-6",
|
||||
])
|
||||
const available = new Set(["openai/gpt-5.3-codex", "anthropic/claude-opus-4-6"])
|
||||
|
||||
// when
|
||||
const result = isModelAvailable("gpt-5.3-codex", available)
|
||||
|
||||
@@ -1,4 +1,358 @@
|
||||
export { fetchAvailableModels, getConnectedProviders } from "./available-models-fetcher"
|
||||
export { isAnyFallbackModelAvailable, isAnyProviderConnected } from "./fallback-model-availability"
|
||||
export { __resetModelCache, isModelCacheAvailable } from "./model-cache-availability"
|
||||
export { fuzzyMatchModel, isModelAvailable } from "./model-name-matcher"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { log } from "./logger"
|
||||
import { getOpenCodeCacheDir } from "./data-path"
|
||||
import * as connectedProvidersCache from "./connected-providers-cache"
|
||||
|
||||
/**
|
||||
* Fuzzy match a target model name against available models
|
||||
*
|
||||
* @param target - The model name or substring to search for (e.g., "gpt-5.2", "claude-opus")
|
||||
* @param available - Set of available model names in format "provider/model-name"
|
||||
* @param providers - Optional array of provider names to filter by (e.g., ["openai", "anthropic"])
|
||||
* @returns The matched model name or null if no match found
|
||||
*
|
||||
* Matching priority:
|
||||
* 1. Exact match (if exists)
|
||||
* 2. Shorter model name (more specific)
|
||||
*
|
||||
* Matching is case-insensitive substring match.
|
||||
* If providers array is given, only models starting with "provider/" are considered.
|
||||
*
|
||||
* @example
|
||||
* const available = new Set(["openai/gpt-5.2", "openai/gpt-5.3-codex", "anthropic/claude-opus-4-6"])
|
||||
* fuzzyMatchModel("gpt-5.2", available) // → "openai/gpt-5.2"
|
||||
* fuzzyMatchModel("claude", available, ["openai"]) // → null (provider filter excludes anthropic)
|
||||
*/
|
||||
function normalizeModelName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/claude-(opus|sonnet|haiku)-4-5/g, "claude-$1-4.5")
|
||||
.replace(/claude-(opus|sonnet|haiku)-4\.5/g, "claude-$1-4.5")
|
||||
}
|
||||
|
||||
export function fuzzyMatchModel(
|
||||
target: string,
|
||||
available: Set<string>,
|
||||
providers?: string[],
|
||||
): string | null {
|
||||
log("[fuzzyMatchModel] called", { target, availableCount: available.size, providers })
|
||||
|
||||
if (available.size === 0) {
|
||||
log("[fuzzyMatchModel] empty available set")
|
||||
return null
|
||||
}
|
||||
|
||||
const targetNormalized = normalizeModelName(target)
|
||||
|
||||
// Filter by providers if specified
|
||||
let candidates = Array.from(available)
|
||||
if (providers && providers.length > 0) {
|
||||
const providerSet = new Set(providers)
|
||||
candidates = candidates.filter((model) => {
|
||||
const [provider] = model.split("/")
|
||||
return providerSet.has(provider)
|
||||
})
|
||||
log("[fuzzyMatchModel] filtered by providers", { candidateCount: candidates.length, candidates: candidates.slice(0, 10) })
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
log("[fuzzyMatchModel] no candidates after filter")
|
||||
return null
|
||||
}
|
||||
|
||||
// Find all matches (case-insensitive substring match with normalization)
|
||||
const matches = candidates.filter((model) =>
|
||||
normalizeModelName(model).includes(targetNormalized),
|
||||
)
|
||||
|
||||
log("[fuzzyMatchModel] substring matches", { targetNormalized, matchCount: matches.length, matches })
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Priority 1: Exact match (normalized full model string)
|
||||
const exactMatch = matches.find((model) => normalizeModelName(model) === targetNormalized)
|
||||
if (exactMatch) {
|
||||
log("[fuzzyMatchModel] exact match found", { exactMatch })
|
||||
return exactMatch
|
||||
}
|
||||
|
||||
// Priority 2: Exact model ID match (part after provider/)
|
||||
// This ensures "glm-4.7-free" matches "zai-coding-plan/glm-4.7-free" over "zai-coding-plan/glm-4.7"
|
||||
// Use filter + shortest to handle multi-provider cases (e.g., openai/gpt-5.2 + opencode/gpt-5.2)
|
||||
const exactModelIdMatches = matches.filter((model) => {
|
||||
const modelId = model.split("/").slice(1).join("/")
|
||||
return normalizeModelName(modelId) === targetNormalized
|
||||
})
|
||||
if (exactModelIdMatches.length > 0) {
|
||||
const result = exactModelIdMatches.reduce((shortest, current) =>
|
||||
current.length < shortest.length ? current : shortest,
|
||||
)
|
||||
log("[fuzzyMatchModel] exact model ID match found", { result, candidateCount: exactModelIdMatches.length })
|
||||
return result
|
||||
}
|
||||
|
||||
// Priority 3: Shorter model name (more specific, fallback for partial matches)
|
||||
const result = matches.reduce((shortest, current) =>
|
||||
current.length < shortest.length ? current : shortest,
|
||||
)
|
||||
log("[fuzzyMatchModel] shortest match", { result })
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a target model is available (fuzzy match by model name, no provider filtering)
|
||||
*
|
||||
* @param targetModel - Model name to check (e.g., "gpt-5.3-codex")
|
||||
* @param availableModels - Set of available models in "provider/model" format
|
||||
* @returns true if model is available, false otherwise
|
||||
*/
|
||||
export function isModelAvailable(
|
||||
targetModel: string,
|
||||
availableModels: Set<string>,
|
||||
): boolean {
|
||||
return fuzzyMatchModel(targetModel, availableModels) !== null
|
||||
}
|
||||
|
||||
export async function getConnectedProviders(client: any): Promise<string[]> {
|
||||
if (!client?.provider?.list) {
|
||||
log("[getConnectedProviders] client.provider.list not available")
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.provider.list()
|
||||
const connected = result.data?.connected ?? []
|
||||
log("[getConnectedProviders] connected providers", { count: connected.length, providers: connected })
|
||||
return connected
|
||||
} catch (err) {
|
||||
log("[getConnectedProviders] SDK error", { error: String(err) })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAvailableModels(
|
||||
client?: any,
|
||||
options?: { connectedProviders?: string[] | null }
|
||||
): Promise<Set<string>> {
|
||||
let connectedProviders = options?.connectedProviders ?? null
|
||||
let connectedProvidersUnknown = connectedProviders === null
|
||||
|
||||
log("[fetchAvailableModels] CALLED", {
|
||||
connectedProvidersUnknown,
|
||||
connectedProviders: options?.connectedProviders
|
||||
})
|
||||
|
||||
if (connectedProvidersUnknown && client) {
|
||||
const liveConnected = await getConnectedProviders(client)
|
||||
if (liveConnected.length > 0) {
|
||||
connectedProviders = liveConnected
|
||||
connectedProvidersUnknown = false
|
||||
log("[fetchAvailableModels] connected providers fetched from client", { count: liveConnected.length })
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedProvidersUnknown) {
|
||||
if (client?.model?.list) {
|
||||
const modelSet = new Set<string>()
|
||||
try {
|
||||
const modelsResult = await client.model.list()
|
||||
const models = modelsResult.data ?? []
|
||||
for (const model of models) {
|
||||
if (model?.provider && model?.id) {
|
||||
modelSet.add(`${model.provider}/${model.id}`)
|
||||
}
|
||||
}
|
||||
log("[fetchAvailableModels] fetched models from client without provider filter", {
|
||||
count: modelSet.size,
|
||||
})
|
||||
return modelSet
|
||||
} catch (err) {
|
||||
log("[fetchAvailableModels] client.model.list error", { error: String(err) })
|
||||
}
|
||||
}
|
||||
log("[fetchAvailableModels] connected providers unknown, returning empty set for fallback resolution")
|
||||
return new Set<string>()
|
||||
}
|
||||
|
||||
const connectedProvidersList = connectedProviders ?? []
|
||||
const connectedSet = new Set(connectedProvidersList)
|
||||
const modelSet = new Set<string>()
|
||||
|
||||
const providerModelsCache = connectedProvidersCache.readProviderModelsCache()
|
||||
if (providerModelsCache) {
|
||||
const providerCount = Object.keys(providerModelsCache.models).length
|
||||
if (providerCount === 0) {
|
||||
log("[fetchAvailableModels] provider-models cache empty, falling back to models.json")
|
||||
} else {
|
||||
log("[fetchAvailableModels] using provider-models cache (whitelist-filtered)")
|
||||
|
||||
const modelsByProvider = providerModelsCache.models as Record<string, Array<string | { id?: string }>>
|
||||
for (const [providerId, modelIds] of Object.entries(modelsByProvider)) {
|
||||
if (!connectedSet.has(providerId)) {
|
||||
continue
|
||||
}
|
||||
for (const modelItem of modelIds) {
|
||||
// Handle both string[] (legacy) and object[] (with metadata) formats
|
||||
const modelId = typeof modelItem === 'string'
|
||||
? modelItem
|
||||
: (modelItem as any)?.id
|
||||
|
||||
if (modelId) {
|
||||
modelSet.add(`${providerId}/${modelId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("[fetchAvailableModels] parsed from provider-models cache", {
|
||||
count: modelSet.size,
|
||||
connectedProviders: connectedProvidersList.slice(0, 5)
|
||||
})
|
||||
|
||||
if (modelSet.size > 0) {
|
||||
return modelSet
|
||||
}
|
||||
log("[fetchAvailableModels] provider-models cache produced no models for connected providers, falling back to models.json")
|
||||
}
|
||||
}
|
||||
|
||||
log("[fetchAvailableModels] provider-models cache not found, falling back to models.json")
|
||||
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
|
||||
|
||||
if (!existsSync(cacheFile)) {
|
||||
log("[fetchAvailableModels] models.json cache file not found, falling back to client")
|
||||
} else {
|
||||
try {
|
||||
const content = readFileSync(cacheFile, "utf-8")
|
||||
const data = JSON.parse(content) as Record<string, { id?: string; models?: Record<string, { id?: string }> }>
|
||||
|
||||
const providerIds = Object.keys(data)
|
||||
log("[fetchAvailableModels] providers found in models.json", { count: providerIds.length, providers: providerIds.slice(0, 10) })
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
if (!connectedSet.has(providerId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const provider = data[providerId]
|
||||
const models = provider?.models
|
||||
if (!models || typeof models !== "object") continue
|
||||
|
||||
for (const modelKey of Object.keys(models)) {
|
||||
modelSet.add(`${providerId}/${modelKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
log("[fetchAvailableModels] parsed models from models.json (NO whitelist filtering)", {
|
||||
count: modelSet.size,
|
||||
connectedProviders: connectedProvidersList.slice(0, 5)
|
||||
})
|
||||
|
||||
if (modelSet.size > 0) {
|
||||
return modelSet
|
||||
}
|
||||
} catch (err) {
|
||||
log("[fetchAvailableModels] error", { error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
if (client?.model?.list) {
|
||||
try {
|
||||
const modelsResult = await client.model.list()
|
||||
const models = modelsResult.data ?? []
|
||||
|
||||
for (const model of models) {
|
||||
if (!model?.provider || !model?.id) continue
|
||||
if (connectedSet.has(model.provider)) {
|
||||
modelSet.add(`${model.provider}/${model.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
log("[fetchAvailableModels] fetched models from client (filtered)", {
|
||||
count: modelSet.size,
|
||||
connectedProviders: connectedProvidersList.slice(0, 5),
|
||||
})
|
||||
} catch (err) {
|
||||
log("[fetchAvailableModels] client.model.list error", { error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
return modelSet
|
||||
}
|
||||
|
||||
export function isAnyFallbackModelAvailable(
|
||||
fallbackChain: Array<{ providers: string[]; model: string }>,
|
||||
availableModels: Set<string>,
|
||||
): boolean {
|
||||
// If we have models, check them first
|
||||
if (availableModels.size > 0) {
|
||||
for (const entry of fallbackChain) {
|
||||
const hasAvailableProvider = entry.providers.some((provider) => {
|
||||
return fuzzyMatchModel(entry.model, availableModels, [provider]) !== null
|
||||
})
|
||||
if (hasAvailableProvider) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check if any provider in the chain is connected
|
||||
// This handles race conditions where availableModels is empty or incomplete
|
||||
// but we know the provider is connected.
|
||||
const connectedProviders = connectedProvidersCache.readConnectedProvidersCache()
|
||||
if (connectedProviders) {
|
||||
const connectedSet = new Set(connectedProviders)
|
||||
for (const entry of fallbackChain) {
|
||||
if (entry.providers.some((p) => connectedSet.has(p))) {
|
||||
log("[isAnyFallbackModelAvailable] model not in available set, but provider is connected", {
|
||||
model: entry.model,
|
||||
availableCount: availableModels.size,
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isAnyProviderConnected(
|
||||
providers: string[],
|
||||
availableModels: Set<string>,
|
||||
): boolean {
|
||||
if (availableModels.size > 0) {
|
||||
const providerSet = new Set(providers)
|
||||
for (const model of availableModels) {
|
||||
const [provider] = model.split("/")
|
||||
if (providerSet.has(provider)) {
|
||||
log("[isAnyProviderConnected] found model from required provider", { provider, model })
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connectedProviders = connectedProvidersCache.readConnectedProvidersCache()
|
||||
if (connectedProviders) {
|
||||
const connectedSet = new Set(connectedProviders)
|
||||
for (const provider of providers) {
|
||||
if (connectedSet.has(provider)) {
|
||||
log("[isAnyProviderConnected] provider connected via cache", { provider })
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function __resetModelCache(): void {}
|
||||
|
||||
export function isModelCacheAvailable(): boolean {
|
||||
if (connectedProvidersCache.hasProviderModelsCache()) {
|
||||
return true
|
||||
}
|
||||
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
|
||||
return existsSync(cacheFile)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import { log } from "./logger"
|
||||
import { readConnectedProvidersCache } from "./connected-providers-cache"
|
||||
import * as connectedProvidersCache from "./connected-providers-cache"
|
||||
import { fuzzyMatchModel } from "./model-availability"
|
||||
import type {
|
||||
ModelResolutionRequest,
|
||||
ModelResolutionResult,
|
||||
} from "./model-resolution-types"
|
||||
import type { FallbackEntry } from "./model-requirements"
|
||||
|
||||
export type {
|
||||
ModelResolutionProvenance,
|
||||
ModelResolutionRequest,
|
||||
ModelResolutionResult,
|
||||
} from "./model-resolution-types"
|
||||
export type ModelResolutionRequest = {
|
||||
intent?: {
|
||||
uiSelectedModel?: string
|
||||
userModel?: string
|
||||
categoryDefaultModel?: string
|
||||
}
|
||||
constraints: {
|
||||
availableModels: Set<string>
|
||||
connectedProviders?: string[] | null
|
||||
}
|
||||
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()
|
||||
@@ -53,7 +74,7 @@ export function resolveModelPipeline(
|
||||
return { model: match, provenance: "category-default", attempted }
|
||||
}
|
||||
} else {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
const connectedProviders = constraints.connectedProviders ?? connectedProvidersCache.readConnectedProvidersCache()
|
||||
if (connectedProviders === null) {
|
||||
log("Model resolved via category default (no cache, first run)", {
|
||||
model: normalizedCategoryDefault,
|
||||
@@ -78,7 +99,7 @@ export function resolveModelPipeline(
|
||||
|
||||
if (fallbackChain && fallbackChain.length > 0) {
|
||||
if (availableModels.size === 0) {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
const connectedProviders = constraints.connectedProviders ?? connectedProvidersCache.readConnectedProvidersCache()
|
||||
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||
|
||||
if (connectedSet === null) {
|
||||
|
||||
Reference in New Issue
Block a user