diff --git a/src/cli/__snapshots__/model-fallback.test.ts.snap b/src/cli/__snapshots__/model-fallback.test.ts.snap index 88f86f97b..1612eee5a 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-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "opencode/glm-4.7-free", + }, "atlas": { "model": "opencode/gpt-5-nano", }, @@ -68,6 +71,10 @@ exports[`generateModelConfig single native provider uses Claude models when only { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "anthropic/claude-opus-4-6", + "variant": "max", + }, "atlas": { "model": "anthropic/claude-sonnet-4.6", }, @@ -139,6 +146,10 @@ exports[`generateModelConfig single native provider uses Claude models with isMa { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "anthropic/claude-opus-4-6", + "variant": "max", + }, "atlas": { "model": "anthropic/claude-sonnet-4.6", }, @@ -211,6 +222,10 @@ exports[`generateModelConfig single native provider uses OpenAI models when only { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "openai/gpt-5.2", + "variant": "high", + }, "atlas": { "model": "openai/gpt-5.4", "variant": "medium", @@ -301,6 +316,10 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "openai/gpt-5.2", + "variant": "high", + }, "atlas": { "model": "openai/gpt-5.4", "variant": "medium", @@ -391,6 +410,10 @@ exports[`generateModelConfig single native provider uses Gemini models when only { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "google/gemini-3-pro", + "variant": "high", + }, "atlas": { "model": "opencode/gpt-5-nano", }, @@ -455,6 +478,10 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "google/gemini-3-pro", + "variant": "high", + }, "atlas": { "model": "opencode/gpt-5-nano", }, @@ -519,6 +546,26 @@ exports[`generateModelConfig all native providers uses preferred models from fal { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/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": { "fallback_models": [ { @@ -723,6 +770,26 @@ exports[`generateModelConfig all native providers uses preferred models with isM { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/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": { "fallback_models": [ { @@ -925,6 +992,10 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "opencode/claude-opus-4-6", + "variant": "max", + }, "atlas": { "fallback_models": [ { @@ -1150,6 +1221,10 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "opencode/claude-opus-4-6", + "variant": "max", + }, "atlas": { "fallback_models": [ { @@ -1379,6 +1454,10 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "github-copilot/claude-opus-4.6", + "variant": "max", + }, "atlas": { "fallback_models": [ { @@ -1565,6 +1644,10 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "github-copilot/claude-opus-4.6", + "variant": "max", + }, "atlas": { "fallback_models": [ { @@ -1753,6 +1836,9 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "zai-coding-plan/glm-4.7", + }, "atlas": { "model": "opencode/gpt-5-nano", }, @@ -1814,6 +1900,9 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "zai-coding-plan/glm-4.7", + }, "atlas": { "model": "opencode/gpt-5-nano", }, @@ -1875,6 +1964,22 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/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": { "fallback_models": [ { @@ -2160,6 +2265,22 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/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": { "fallback_models": [ { @@ -2411,6 +2532,10 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { + "athena": { + "model": "anthropic/claude-opus-4-6", + "variant": "max", + }, "atlas": { "model": "anthropic/claude-sonnet-4.6", }, @@ -2500,6 +2625,22 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/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.6", }, @@ -2636,6 +2777,22 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/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": { "fallback_models": [ { @@ -3027,6 +3184,34 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/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": { "fallback_models": [ { @@ -3581,6 +3766,34 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is { "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/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": { "fallback_models": [ { diff --git a/src/cli/council-members-generator.test.ts b/src/cli/council-members-generator.test.ts new file mode 100644 index 000000000..3ad925cbe --- /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 000000000..e7ae39d5c --- /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 088c4515e..93ff4d652 100644 --- a/src/cli/model-fallback.ts +++ b/src/cli/model-fallback.ts @@ -17,6 +17,7 @@ import { resolveModelFromChain, } from "./fallback-chain-resolution" import { transformModelForProvider } from "./provider-model-id-transform" +import { generateCouncilMembers } from "./council-members-generator" export type { GeneratedOmoConfig } from "./model-fallback-types" @@ -214,6 +215,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 + } + const generatedConfig: GeneratedOmoConfig = { $schema: SCHEMA_URL, agents,