feat(installer): auto-configure athena council members based on available providers

The installer now detects which providers the user has (Anthropic, OpenAI,
Google, Copilot, OpenCode Zen) and generates council member config for Athena.
Requires at least 2 distinct providers; skips council config otherwise.
This implements the documented claim in configurations.md.
This commit is contained in:
ismeth 2026-02-13 15:05:03 +01:00 committed by YeonGyu-Kim
parent 5a72f21fc8
commit 628c9a8958
4 changed files with 411 additions and 0 deletions

View File

@ -4,6 +4,9 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "opencode/glm-4.7-free",
},
"atlas": {
"model": "opencode/glm-4.7-free",
},
@ -65,6 +68,10 @@ exports[`generateModelConfig single native provider uses Claude models when only
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "anthropic/claude-opus-4-6",
"variant": "max",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
},
@ -127,6 +134,10 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "anthropic/claude-opus-4-6",
"variant": "max",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
},
@ -190,6 +201,10 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "openai/gpt-5.2",
"variant": "high",
},
"atlas": {
"model": "openai/gpt-5.2",
},
@ -257,6 +272,10 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "openai/gpt-5.2",
"variant": "high",
},
"atlas": {
"model": "openai/gpt-5.2",
},
@ -324,6 +343,10 @@ exports[`generateModelConfig single native provider uses Gemini models when only
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"atlas": {
"model": "google/gemini-3-pro-preview",
},
@ -385,6 +408,10 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"atlas": {
"model": "google/gemini-3-pro-preview",
},
@ -446,6 +473,26 @@ exports[`generateModelConfig all native providers uses preferred models from fal
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "anthropic/claude-sonnet-4-5",
"name": "Claude",
},
{
"model": "openai/gpt-5.2",
"name": "GPT",
},
{
"model": "google/gemini-3-flash",
"name": "Gemini",
},
],
},
"model": "anthropic/claude-opus-4-6",
"variant": "max",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
},
@ -520,6 +567,26 @@ exports[`generateModelConfig all native providers uses preferred models with isM
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "anthropic/claude-sonnet-4-5",
"name": "Claude",
},
{
"model": "openai/gpt-5.2",
"name": "GPT",
},
{
"model": "google/gemini-3-flash",
"name": "Gemini",
},
],
},
"model": "anthropic/claude-opus-4-6",
"variant": "max",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
},
@ -595,6 +662,10 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "opencode/claude-opus-4-6",
"variant": "max",
},
"atlas": {
"model": "opencode/kimi-k2.5-free",
},
@ -669,6 +740,10 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "opencode/claude-opus-4-6",
"variant": "max",
},
"atlas": {
"model": "opencode/kimi-k2.5-free",
},
@ -744,6 +819,10 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "github-copilot/claude-opus-4.6",
"variant": "max",
},
"atlas": {
"model": "github-copilot/claude-sonnet-4.5",
},
@ -818,6 +897,10 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "github-copilot/claude-opus-4.6",
"variant": "max",
},
"atlas": {
"model": "github-copilot/claude-sonnet-4.5",
},
@ -893,6 +976,9 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "zai-coding-plan/glm-4.7",
},
"atlas": {
"model": "opencode/glm-4.7-free",
},
@ -948,6 +1034,9 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "zai-coding-plan/glm-4.7",
},
"atlas": {
"model": "opencode/glm-4.7-free",
},
@ -1003,6 +1092,22 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "anthropic/claude-sonnet-4-5",
"name": "Claude",
},
{
"model": "opencode/claude-sonnet-4-5",
"name": "OpenCode Claude",
},
],
},
"model": "anthropic/claude-opus-4-6",
"variant": "max",
},
"atlas": {
"model": "opencode/kimi-k2.5-free",
},
@ -1077,6 +1182,22 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "openai/gpt-5.2",
"name": "GPT",
},
{
"model": "github-copilot/gpt-5.2",
"name": "Copilot GPT",
},
],
},
"model": "github-copilot/claude-opus-4.6",
"variant": "max",
},
"atlas": {
"model": "github-copilot/claude-sonnet-4.5",
},
@ -1151,6 +1272,10 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "anthropic/claude-opus-4-6",
"variant": "max",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
},
@ -1212,6 +1337,22 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "anthropic/claude-sonnet-4-5",
"name": "Claude",
},
{
"model": "google/gemini-3-flash",
"name": "Gemini",
},
],
},
"model": "anthropic/claude-opus-4-6",
"variant": "max",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
},
@ -1278,6 +1419,22 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "github-copilot/gpt-5.2",
"name": "Copilot GPT",
},
{
"model": "opencode/claude-sonnet-4-5",
"name": "OpenCode Claude",
},
],
},
"model": "github-copilot/claude-opus-4.6",
"variant": "max",
},
"atlas": {
"model": "opencode/kimi-k2.5-free",
},
@ -1352,6 +1509,34 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "anthropic/claude-sonnet-4-5",
"name": "Claude",
},
{
"model": "openai/gpt-5.2",
"name": "GPT",
},
{
"model": "google/gemini-3-flash",
"name": "Gemini",
},
{
"model": "github-copilot/gpt-5.2",
"name": "Copilot GPT",
},
{
"model": "opencode/claude-sonnet-4-5",
"name": "OpenCode Claude",
},
],
},
"model": "anthropic/claude-opus-4-6",
"variant": "max",
},
"atlas": {
"model": "opencode/kimi-k2.5-free",
},
@ -1426,6 +1611,34 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "anthropic/claude-sonnet-4-5",
"name": "Claude",
},
{
"model": "openai/gpt-5.2",
"name": "GPT",
},
{
"model": "google/gemini-3-flash",
"name": "Gemini",
},
{
"model": "github-copilot/gpt-5.2",
"name": "Copilot GPT",
},
{
"model": "opencode/claude-sonnet-4-5",
"name": "OpenCode Claude",
},
],
},
"model": "anthropic/claude-opus-4-6",
"variant": "max",
},
"atlas": {
"model": "opencode/kimi-k2.5-free",
},

View File

@ -0,0 +1,137 @@
import { describe, test, expect } from "bun:test"
import { generateCouncilMembers } from "./council-members-generator"
import type { ProviderAvailability } from "./model-fallback-types"
function makeAvail(overrides: {
native?: Partial<ProviderAvailability["native"]>
opencodeZen?: boolean
copilot?: boolean
zai?: boolean
kimiForCoding?: boolean
isMaxPlan?: boolean
}): ProviderAvailability {
return {
native: {
claude: false,
openai: false,
gemini: false,
...(overrides.native ?? {}),
},
opencodeZen: overrides.opencodeZen ?? false,
copilot: overrides.copilot ?? false,
zai: overrides.zai ?? false,
kimiForCoding: overrides.kimiForCoding ?? false,
isMaxPlan: overrides.isMaxPlan ?? false,
}
}
describe("generateCouncilMembers", () => {
//#given all three native providers
//#when generating council members
//#then returns 3 members (one per provider)
test("returns 3 members when claude + openai + gemini available", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true, openai: true, gemini: true },
}))
expect(members).toHaveLength(3)
expect(members.some(m => m.model.startsWith("anthropic/"))).toBe(true)
expect(members.some(m => m.model.startsWith("openai/"))).toBe(true)
expect(members.some(m => m.model.startsWith("google/"))).toBe(true)
expect(members.every(m => m.name)).toBe(true)
})
//#given claude + openai only
//#when generating council members
//#then returns 2 members
test("returns 2 members when claude + openai available", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true, openai: true },
}))
expect(members).toHaveLength(2)
expect(members.some(m => m.model.startsWith("anthropic/"))).toBe(true)
expect(members.some(m => m.model.startsWith("openai/"))).toBe(true)
})
//#given claude + gemini only
//#when generating council members
//#then returns 2 members
test("returns 2 members when claude + gemini available", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true, gemini: true },
}))
expect(members).toHaveLength(2)
})
//#given openai + gemini only
//#when generating council members
//#then returns 2 members
test("returns 2 members when openai + gemini available", () => {
const members = generateCouncilMembers(makeAvail({
native: { openai: true, gemini: true },
}))
expect(members).toHaveLength(2)
})
//#given only one native provider
//#when copilot is also available
//#then returns 2 members (native + copilot)
test("uses copilot as second member when only one native provider", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true },
copilot: true,
}))
expect(members.length).toBeGreaterThanOrEqual(2)
})
//#given only one native provider
//#when opencode zen is also available
//#then returns 2 members
test("uses opencode zen as second member when only one native provider", () => {
const members = generateCouncilMembers(makeAvail({
native: { gemini: true },
opencodeZen: true,
}))
expect(members.length).toBeGreaterThanOrEqual(2)
})
//#given no providers at all
//#when generating council members
//#then returns empty array (can't meet minimum 2)
test("returns empty when no providers available", () => {
const members = generateCouncilMembers(makeAvail({}))
expect(members).toHaveLength(0)
})
//#given only one provider, no fallbacks
//#when generating council members
//#then returns empty (need at least 2 distinct models)
test("returns empty when only one provider and no fallbacks", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true },
}))
expect(members).toHaveLength(0)
})
//#given all members have names
//#when generating council
//#then each member has a human-readable name
test("all members have name field", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true, openai: true, gemini: true },
}))
for (const m of members) {
expect(m.name).toBeDefined()
expect(typeof m.name).toBe("string")
expect(m.name!.length).toBeGreaterThan(0)
}
})
})

View File

@ -0,0 +1,54 @@
import type { ProviderAvailability } from "./model-fallback-types"
export interface CouncilMember {
model: string
name: string
}
const COUNCIL_CANDIDATES: Array<{
provider: (avail: ProviderAvailability) => boolean
model: string
name: string
}> = [
{
provider: (a) => a.native.claude,
model: "anthropic/claude-sonnet-4-5",
name: "Claude",
},
{
provider: (a) => a.native.openai,
model: "openai/gpt-5.2",
name: "GPT",
},
{
provider: (a) => a.native.gemini,
model: "google/gemini-3-flash",
name: "Gemini",
},
{
provider: (a) => a.copilot,
model: "github-copilot/gpt-5.2",
name: "Copilot GPT",
},
{
provider: (a) => a.opencodeZen,
model: "opencode/claude-sonnet-4-5",
name: "OpenCode Claude",
},
]
export function generateCouncilMembers(avail: ProviderAvailability): CouncilMember[] {
const members: CouncilMember[] = []
for (const candidate of COUNCIL_CANDIDATES) {
if (candidate.provider(avail)) {
members.push({ model: candidate.model, name: candidate.name })
}
}
if (members.length < 2) {
return []
}
return members
}

View File

@ -13,6 +13,7 @@ import {
isRequiredProviderAvailable,
resolveModelFromChain,
} from "./fallback-chain-resolution"
import { generateCouncilMembers } from "./council-members-generator"
export type { GeneratedOmoConfig } from "./model-fallback-types"
@ -122,6 +123,12 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
}
}
const councilMembers = generateCouncilMembers(avail)
if (councilMembers.length >= 2) {
const athenaAgent = agents.athena ?? {}
agents.athena = { ...athenaAgent, council: { members: councilMembers } } as AgentConfig
}
return {
$schema: SCHEMA_URL,
agents,