From 628c9a895823b90c2c3238583a053f451d6887c9 Mon Sep 17 00:00:00 2001 From: ismeth Date: Fri, 13 Feb 2026 15:05:03 +0100 Subject: [PATCH] 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. --- .../__snapshots__/model-fallback.test.ts.snap | 213 ++++++++++++++++++ src/cli/council-members-generator.test.ts | 137 +++++++++++ src/cli/council-members-generator.ts | 54 +++++ src/cli/model-fallback.ts | 7 + 4 files changed, 411 insertions(+) create mode 100644 src/cli/council-members-generator.test.ts create mode 100644 src/cli/council-members-generator.ts diff --git a/src/cli/__snapshots__/model-fallback.test.ts.snap b/src/cli/__snapshots__/model-fallback.test.ts.snap index 2468aa4a..d5c721ed 100644 --- a/src/cli/__snapshots__/model-fallback.test.ts.snap +++ b/src/cli/__snapshots__/model-fallback.test.ts.snap @@ -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", }, diff --git a/src/cli/council-members-generator.test.ts b/src/cli/council-members-generator.test.ts new file mode 100644 index 00000000..3ad925cb --- /dev/null +++ b/src/cli/council-members-generator.test.ts @@ -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 + 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) + } + }) +}) diff --git a/src/cli/council-members-generator.ts b/src/cli/council-members-generator.ts new file mode 100644 index 00000000..e7ae39d5 --- /dev/null +++ b/src/cli/council-members-generator.ts @@ -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 +} diff --git a/src/cli/model-fallback.ts b/src/cli/model-fallback.ts index 4d91ace5..c594805c 100644 --- a/src/cli/model-fallback.ts +++ b/src/cli/model-fallback.ts @@ -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,