Skip to content
584 changes: 584 additions & 0 deletions src/cli/__snapshots__/model-fallback.test.ts.snap

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/cli/cli-installer.telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe("runCliInstaller telemetry isolation", () => {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
Expand Down
2 changes: 2 additions & 0 deletions src/cli/cli-installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe("runCliInstaller", () => {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"),
Expand Down Expand Up @@ -83,6 +84,7 @@ describe("runCliInstaller", () => {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
Expand Down
3 changes: 2 additions & 1 deletion src/cli/cli-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
!config.hasOpenAI &&
!config.hasGemini &&
!config.hasCopilot &&
!config.hasOpencodeZen
!config.hasOpencodeZen &&
!config.hasVercelAiGateway
) {
printWarning("No model providers configured. Using opencode/big-pickle as fallback.")
}
Expand Down
7 changes: 5 additions & 2 deletions src/cli/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,23 @@ program
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
.option("--opencode-go <value>", "OpenCode Go subscription: no, yes (default: no)")
.option("--vercel-ai-gateway <value>", "Vercel AI Gateway: no, yes (default: no)")
.option("--skip-auth", "Skip authentication setup hints")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode install
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no
$ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes

Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Vercel):
Claude Native anthropic/ models (Opus, Sonnet, Haiku)
OpenAI Native openai/ models (GPT-5.4 for Oracle)
Gemini Native google/ models (Gemini 3.1 Pro, Flash)
Copilot github-copilot/ models (fallback)
OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.)
Z.ai zai-coding-plan/glm-5 (visual-engineering fallback)
Z.ai zai-coding-plan/glm-5 (visual-engineering fallback)
Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback)
Vercel vercel/ models (universal proxy, always last fallback)
`)
.action(async (options) => {
const args: InstallArgs = {
Expand All @@ -60,6 +62,7 @@ Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
zaiCodingPlan: options.zaiCodingPlan,
kimiForCoding: options.kimiForCoding,
opencodeGo: options.opencodeGo,
vercelAiGateway: options.vercelAiGateway,
skipAuth: options.skipAuth ?? false,
}
const exitCode = await install(args)
Expand Down
11 changes: 9 additions & 2 deletions src/cli/config-manager/detect-current-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function detectProvidersFromOmoConfig(): {
hasZaiCodingPlan: boolean
hasKimiForCoding: boolean
hasOpencodeGo: boolean
hasVercelAiGateway: boolean
} {
const omoConfigPath = getOmoConfigPath()
if (!existsSync(omoConfigPath)) {
Expand All @@ -21,6 +22,7 @@ function detectProvidersFromOmoConfig(): {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}
}

Expand All @@ -34,6 +36,7 @@ function detectProvidersFromOmoConfig(): {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}
}

Expand All @@ -43,15 +46,17 @@ function detectProvidersFromOmoConfig(): {
const hasZaiCodingPlan = configStr.includes('"zai-coding-plan/')
const hasKimiForCoding = configStr.includes('"kimi-for-coding/')
const hasOpencodeGo = configStr.includes('"opencode-go/')
const hasVercelAiGateway = configStr.includes('"vercel/')

return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding, hasOpencodeGo }
return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding, hasOpencodeGo, hasVercelAiGateway }
} catch {
return {
hasOpenAI: true,
hasOpencodeZen: true,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}
}
}
Expand All @@ -78,6 +83,7 @@ export function detectCurrentConfig(): DetectedConfig {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}

const { format, path } = detectConfigFormat()
Expand Down Expand Up @@ -106,12 +112,13 @@ export function detectCurrentConfig(): DetectedConfig {
const providers = openCodeConfig.provider as Record<string, unknown> | undefined
result.hasGemini = providers ? "google" in providers : false

const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding, hasOpencodeGo } = detectProvidersFromOmoConfig()
const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding, hasOpencodeGo, hasVercelAiGateway } = detectProvidersFromOmoConfig()
result.hasOpenAI = hasOpenAI
result.hasOpencodeZen = hasOpencodeZen
result.hasZaiCodingPlan = hasZaiCodingPlan
result.hasKimiForCoding = hasKimiForCoding
result.hasOpencodeGo = hasOpencodeGo
result.hasVercelAiGateway = hasVercelAiGateway

return result
}
13 changes: 10 additions & 3 deletions src/cli/config-manager/generate-omo-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}

//#when
Expand All @@ -42,6 +43,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}

//#when
Expand All @@ -64,14 +66,15 @@ describe("generateOmoConfig - model fallback system", () => {
hasZaiCodingPlan: true,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}

//#when
const result = generateOmoConfig(config)

//#then
expect((result.agents as Record<string, { model: string }>).librarian.model).toBe("zai-coding-plan/glm-4.7")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-6")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4.6")
})

test("uses native OpenAI models when only ChatGPT available", () => {
Expand All @@ -86,6 +89,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}

//#when
Expand All @@ -110,6 +114,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}

//#when
Expand All @@ -126,7 +131,7 @@ describe("generateOmoConfig - model fallback system", () => {
}>

//#then
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4.6")
expect(agents.sisyphus.fallback_models).toEqual([
{
model: "openai/gpt-5.4",
Expand All @@ -136,7 +141,7 @@ describe("generateOmoConfig - model fallback system", () => {
expect(categories.deep.model).toBe("openai/gpt-5.4")
expect(categories.deep.fallback_models).toEqual([
{
model: "anthropic/claude-opus-4-6",
model: "anthropic/claude-opus-4.6",
variant: "max",
},
])
Expand All @@ -154,6 +159,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}

//#when
Expand All @@ -175,6 +181,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}

//#when
Expand Down
1 change: 1 addition & 0 deletions src/cli/config-manager/write-omo-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const installConfig: InstallConfig = {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
}

function getRecord(value: unknown): Record<string, unknown> {
Expand Down
8 changes: 8 additions & 0 deletions src/cli/install-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function formatConfigSummary(config: InstallConfig): string {
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback"))
lines.push(formatProvider("Vercel AI Gateway", config.hasVercelAiGateway, "universal proxy"))

lines.push("")
lines.push(color.dim("─".repeat(40)))
Expand Down Expand Up @@ -153,6 +154,10 @@ export function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors:
errors.push(`Invalid --kimi-for-coding value: ${args.kimiForCoding} (expected: no, yes)`)
}

if (args.vercelAiGateway !== undefined && !["no", "yes"].includes(args.vercelAiGateway)) {
errors.push(`Invalid --vercel-ai-gateway value: ${args.vercelAiGateway} (expected: no, yes)`)
}

return { valid: errors.length === 0, errors }
}

Expand All @@ -167,6 +172,7 @@ export function argsToConfig(args: InstallArgs): InstallConfig {
hasZaiCodingPlan: args.zaiCodingPlan === "yes",
hasKimiForCoding: args.kimiForCoding === "yes",
hasOpencodeGo: args.opencodeGo === "yes",
hasVercelAiGateway: args.vercelAiGateway === "yes",
}
}

Expand All @@ -179,6 +185,7 @@ export function detectedToInitialValues(detected: DetectedConfig): {
zaiCodingPlan: BooleanArg
kimiForCoding: BooleanArg
opencodeGo: BooleanArg
vercelAiGateway: BooleanArg
} {
let claude: ClaudeSubscription = "no"
if (detected.hasClaude) {
Expand All @@ -194,5 +201,6 @@ kimiForCoding: BooleanArg
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
opencodeGo: detected.hasOpencodeGo ? "yes" : "no",
vercelAiGateway: detected.hasVercelAiGateway ? "yes" : "no",
}
}
1 change: 1 addition & 0 deletions src/cli/model-fallback-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface ProviderAvailability {
zai: boolean
kimiForCoding: boolean
opencodeGo: boolean
vercelAiGateway: boolean
isMaxPlan: boolean
}

Expand Down
69 changes: 69 additions & 0 deletions src/cli/model-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function createConfig(overrides: Partial<InstallConfig> = {}): InstallConfig {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
...overrides,
}
}
Expand Down Expand Up @@ -607,6 +608,74 @@ describe("generateModelConfig", () => {
})
})

describe("Vercel AI Gateway provider", () => {
test("uses vercel/ model strings when only Vercel AI Gateway is available", () => {
// #given only Vercel AI Gateway is available
const config = createConfig({ hasVercelAiGateway: true })

// #when generateModelConfig is called
const result = generateModelConfig(config)

// #then should use vercel/<sub-provider>/<model> format
expect(result).toMatchSnapshot()
})

test("uses vercel/ model strings with isMax20 flag", () => {
// #given Vercel AI Gateway is available with Max 20 plan
const config = createConfig({ hasVercelAiGateway: true, isMax20: true })

// #when generateModelConfig is called
const result = generateModelConfig(config)

// #then should use higher capability models via gateway
expect(result).toMatchSnapshot()
})

test("explore uses vercel/minimax/minimax-m2.7-highspeed when only gateway available", () => {
// #given only Vercel AI Gateway is available
const config = createConfig({ hasVercelAiGateway: true })

// #when generateModelConfig is called
const result = generateModelConfig(config)

// #then explore should use gateway-routed minimax (preferred over claude-haiku)
expect(result.agents?.explore?.model).toBe("vercel/minimax/minimax-m2.7-highspeed")
})

test("librarian uses vercel/minimax/minimax-m2.7 when only gateway available", () => {
// #given only Vercel AI Gateway is available
const config = createConfig({ hasVercelAiGateway: true })

// #when generateModelConfig is called
const result = generateModelConfig(config)

// #then librarian should use gateway-routed minimax (preferred over claude-haiku)
expect(result.agents?.librarian?.model).toBe("vercel/minimax/minimax-m2.7")
})

test("Hephaestus is created when only Vercel AI Gateway is available", () => {
// #given only Vercel AI Gateway is available
const config = createConfig({ hasVercelAiGateway: true })

// #when generateModelConfig is called
const result = generateModelConfig(config)

// #then hephaestus should be created with gateway-routed gpt-5.4
expect(result.agents?.hephaestus?.model).toBe("vercel/openai/gpt-5.4")
})

test("native providers take priority over gateway", () => {
// #given Claude and Vercel AI Gateway are both available
const config = createConfig({ hasClaude: true, hasVercelAiGateway: true })

// #when generateModelConfig is called
const result = generateModelConfig(config)

// #then should prefer native anthropic over gateway
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4.6")
})
})

describe("schema URL", () => {
test("always includes correct schema URL", () => {
// #given any config
Expand Down
7 changes: 6 additions & 1 deletion src/cli/model-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
avail.copilot ||
avail.zai ||
avail.kimiForCoding ||
avail.opencodeGo
avail.opencodeGo ||
avail.vercelAiGateway
if (!hasAnyProvider) {
return {
$schema: SCHEMA_URL,
Expand All @@ -130,6 +131,8 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
agentConfig = { model: "opencode-go/minimax-m2.7" }
} else if (avail.zai) {
agentConfig = { model: ZAI_MODEL }
} else if (avail.vercelAiGateway) {
agentConfig = { model: "vercel/minimax/minimax-m2.7" }
}
if (agentConfig) {
agents[role] = attachAllFallbackModels(agentConfig, req.fallbackChain, avail)
Expand All @@ -147,6 +150,8 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
agentConfig = { model: "opencode-go/minimax-m2.7" }
} else if (avail.copilot) {
agentConfig = { model: "github-copilot/gpt-5-mini" }
} else if (avail.vercelAiGateway) {
agentConfig = { model: "vercel/minimax/minimax-m2.7-highspeed" }
} else {
agentConfig = { model: "opencode/gpt-5-nano" }
}
Expand Down
1 change: 1 addition & 0 deletions src/cli/openai-only-model-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function createConfig(overrides: Partial<InstallConfig> = {}): InstallConfig {
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
hasVercelAiGateway: false,
...overrides,
}
}
Expand Down
Loading
Loading