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,