Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ reviews:
instructions: |
PAI Agent Definitions — Persona, traits, voice configuration.
Verify:
- Frontmatter has required fields (name, description, model_tier mapping)
- Frontmatter has required fields (name, description, model)
- First-person voice convention followed ("I can help" not "Jeremy can help")
- Color codes in hex format for UI consistency
- path: ".opencode/plugins/**"
Expand Down
15 changes: 1 addition & 14 deletions .opencode/plugins/handlers/agent-execution-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export async function validateAgentExecution(args: any): Promise<GuardResult> {
try {
const subagentType = args?.subagent_type || "unknown";
const prompt = args?.prompt || "";
const modelTier = args?.model_tier || "standard";

// Check 1: Explore agents for simple operations
// If the prompt suggests a simple grep/glob/read, warn
Expand Down Expand Up @@ -65,19 +64,7 @@ export async function validateAgentExecution(args: any): Promise<GuardResult> {
};
}

// Check 3: Quick tier for simple tasks
if (modelTier === "advanced" && prompt.length < 200) {
fileLog(
`[AgentGuard] Warning: Advanced tier for short prompt — consider quick/standard tier`,
"warn"
);
return {
allowed: true,
reason: "Advanced model tier may be overkill for this task size",
};
}

fileLog(`[AgentGuard] Agent execution OK: ${subagentType} (${modelTier})`, "debug");
fileLog(`[AgentGuard] Agent execution OK: ${subagentType}`, "debug");
return { allowed: true };
} catch (error) {
fileLogError("[AgentGuard] Validation failed", error);
Expand Down
7 changes: 1 addition & 6 deletions .opencode/plugins/handlers/session-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ interface SubagentEntry {
sessionId: string;
agentType: string;
description: string;
modelTier?: string;
spawnedAt: string;
status: "running" | "completed" | "failed";
}
Expand Down Expand Up @@ -206,12 +205,10 @@ export function extractSessionId(output: { output?: string; metadata?: any }): s
export function extractTaskInfo(args: any): {
agentType: string;
description: string;
modelTier?: string;
} {
return {
agentType: args?.subagent_type || args?.agent || "unknown",
description: args?.description || args?.prompt?.substring(0, 100) || "unknown task",
modelTier: args?.model_tier,
};
}

Expand Down Expand Up @@ -251,7 +248,6 @@ export async function captureSubagentSession(
sessionId: childSessionId,
agentType: taskInfo.agentType,
description: taskInfo.description,
modelTier: taskInfo.modelTier,
spawnedAt: new Date().toISOString(),
status: "completed",
});
Expand Down Expand Up @@ -346,7 +342,7 @@ export const sessionRegistryTool = tool({
export const sessionResultsTool = tool({
description:
"Get registry metadata for a specific subagent session by session_id. " +
"Returns: agent type, description, model tier, status, and resume instructions. " +
"Returns: agent type, description, status, and resume instructions. " +
"Use this to identify what a subagent worked on and how to access its full results. " +
"The full conversation history is in OpenCode's database — use Task tool with session_id to retrieve it.",
args: {
Expand Down Expand Up @@ -379,7 +375,6 @@ export const sessionResultsTool = tool({
"",
`**Agent:** ${entry.agentType}`,
`**Description:** ${entry.description}`,
`**Model Tier:** ${entry.modelTier || "default"}`,
`**Spawned:** ${entry.spawnedAt}`,
`**Status:** ${entry.status}`,
"",
Expand Down
182 changes: 136 additions & 46 deletions .opencode/plugins/lib/model-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,19 @@ import { fileLog } from "./file-logger";
* - "anthropic": Claude models (requires ANTHROPIC_API_KEY)
* - "openai": GPT models (requires OPENAI_API_KEY)
*
* ZEN Free Models (as of Jan 2026):
* - opencode/big-pickle (Free)
* - opencode/grok-code (Free - Grok Code Fast 1)
* - opencode/glm-4.7-free (Free - GLM 4.7)
* - opencode/minimax-m2-1-free (Free - MiniMax M2.1)
* ZEN Free Models (as of April 2026):
* - opencode/big-pickle (Free — Zen flagship)
* - opencode/qwen3.6-plus-free (Free — Qwen 3.6 Plus)
* - opencode/nemotron-3-super-free (Free — Nemotron 3 Super)
* - opencode/minimax-m2.5-free (Free MiniMax M2.5)
* - opencode/gpt-5-nano (Free)
*
* See: https://opencode.ai/docs/zen/
* See: https://opencode.ai/docs/zen/ (and /pricing for the authoritative list)
*/
export type PaiProvider = "zen" | "anthropic" | "openai" | "local";

export interface PaiModelConfig {
model_provider: "zen" | "anthropic" | "openai";
model_provider: PaiProvider;
models: {
default: string;
validation: string;
Expand All @@ -34,23 +36,25 @@ export interface PaiModelConfig {
};
}

const VALID_PROVIDERS: readonly PaiProvider[] = ["zen", "anthropic", "openai", "local"] as const;

/**
* Provider Presets
* Default model configurations for each provider
*
* ZEN models are FREE and don't require API keys!
*/
const PROVIDER_PRESETS: Record<"zen" | "anthropic" | "openai", PaiModelConfig["models"]> = {
const PROVIDER_PRESETS: Record<PaiProvider, PaiModelConfig["models"]> = {
zen: {
// Using grok-code as default (fast, free, good for coding)
default: "opencode/grok-code",
validation: "opencode/grok-code",
// Qwen 3.6 Plus Free as default fast and capable for general coding
default: "opencode/qwen3.6-plus-free",
validation: "opencode/qwen3.6-plus-free",
agents: {
intern: "opencode/gpt-5-nano", // Fast, lightweight
architect: "opencode/big-pickle", // Best reasoning
engineer: "opencode/grok-code", // Optimized for code
explorer: "opencode/grok-code", // Fast exploration
reviewer: "opencode/big-pickle", // Thorough review
intern: "opencode/gpt-5-nano", // Free, lightweight, fast
architect: "opencode/big-pickle", // Zen flagship, best reasoning
engineer: "opencode/qwen3.6-plus-free", // Capable code-focused free model
explorer: "opencode/gpt-5-nano", // Fast codebase exploration
reviewer: "opencode/big-pickle", // Thorough review, heaviest free model
},
},
anthropic: {
Expand All @@ -60,29 +64,41 @@ const PROVIDER_PRESETS: Record<"zen" | "anthropic" | "openai", PaiModelConfig["m
intern: "anthropic/claude-haiku-4-5",
architect: "anthropic/claude-sonnet-4-5",
engineer: "anthropic/claude-sonnet-4-5",
explorer: "anthropic/claude-sonnet-4-5",
reviewer: "anthropic/claude-opus-4-5",
explorer: "anthropic/claude-haiku-4-5",
reviewer: "anthropic/claude-opus-4-6",
},
},
openai: {
default: "openai/gpt-4o",
validation: "openai/gpt-4o",
default: "openai/gpt-5.1",
validation: "openai/gpt-5.1",
agents: {
intern: "openai/gpt-5.1-codex-mini",
architect: "openai/gpt-5.1",
engineer: "openai/gpt-5.1-codex",
explorer: "openai/gpt-5.1-codex-mini",
reviewer: "openai/gpt-5.1",
},
},
// Local Ollama preset. Mirrors the shipping .opencode/profiles/local.yaml
// but with sensible defaults — users are expected to override these to
// match the models they have actually pulled via `ollama pull`.
local: {
default: "ollama/qwen3.5:9b",
validation: "ollama/qwen3.5:9b",
agents: {
intern: "openai/gpt-4o-mini",
architect: "openai/gpt-4o",
engineer: "openai/gpt-4o",
explorer: "openai/gpt-4o",
reviewer: "openai/gpt-4o",
intern: "ollama/qwen3.5:2b",
architect: "ollama/qwen3.5:27b",
engineer: "ollama/qwen3.5:9b",
explorer: "ollama/qwen3.5:2b",
reviewer: "ollama/qwen3.5:27b",
},
},
};

/**
* Get the provider preset configuration
*/
export function getProviderPreset(
provider: "zen" | "anthropic" | "openai"
): PaiModelConfig["models"] {
export function getProviderPreset(provider: PaiProvider): PaiModelConfig["models"] {
return PROVIDER_PRESETS[provider];
}

Expand Down Expand Up @@ -135,55 +151,121 @@ function readOpencodeConfig(): any | null {
/**
* Detect provider from model name
* @example "anthropic/claude-sonnet-4-5" -> "anthropic"
* @example "openai/gpt-4o" -> "openai"
* @example "openai/gpt-5.1" -> "openai"
* @example "opencode/kimi-k2.5" -> "zen"
* @example "ollama/qwen3.5:9b" -> "local"
*/
function detectProviderFromModel(model: string): "zen" | "anthropic" | "openai" | null {
function detectProviderFromModel(model: string): PaiProvider | null {
if (model.startsWith("anthropic/")) return "anthropic";
if (model.startsWith("openai/")) return "openai";
if (model.startsWith("opencode/")) return "zen";
// Local Ollama profile uses the `ollama/` prefix. Accept `local/` as an
// alias so either prefix can appear in `.opencode/profiles/local.yaml`
// or `opencode.json` without being misdetected as zen.
if (model.startsWith("ollama/") || model.startsWith("local/")) return "local";
return null;
}

/**
* Map from opencode.json `agent` keys (the runtime identifiers written by
* `switch-provider.ts`) to PAI's canonical preset keys. Multiple candidates
* are tried per key to be tolerant of case variations and historical names.
*
* When `getModelConfig()` merges opencode.json.agent into the preset, it
* walks this map and uses the first matching opencode agent's model.
*/
const AGENT_KEY_MAP: Record<keyof PaiModelConfig["models"]["agents"], readonly string[]> = {
intern: ["Intern", "intern"],
architect: ["Architect", "architect"],
engineer: ["Engineer", "engineer"],
explorer: ["explore", "Explore", "explorer"],
reviewer: ["QATester", "Reviewer", "reviewer"],
};

/**
* Read per-agent model overrides from opencode.json.agent and layer them
* on top of a preset's agents map. The opencode.json agent block is the
* authoritative source of runtime model routing (written by
* switch-provider.ts and hand-edited by users); PROVIDER_PRESETS is the
* fallback when a given PAI role is not present in opencode.json.
*
* Accepts both shapes written by switch-provider:
* { "Engineer": { "model": "..." } }
* { "Engineer": { "model": "...", "permission": {...} } }
*/
function mergeOpencodeAgents(
preset: PaiModelConfig["models"]["agents"],
opencodeAgents: Record<string, unknown> | undefined
): PaiModelConfig["models"]["agents"] {
if (!opencodeAgents || typeof opencodeAgents !== "object") return { ...preset };

const resolved: PaiModelConfig["models"]["agents"] = { ...preset };

for (const paiKey of Object.keys(AGENT_KEY_MAP) as (keyof typeof AGENT_KEY_MAP)[]) {
for (const candidate of AGENT_KEY_MAP[paiKey]) {
const entry = opencodeAgents[candidate];
if (entry && typeof entry === "object" && "model" in entry) {
const model = (entry as { model?: unknown }).model;
if (typeof model === "string" && model.length > 0) {
resolved[paiKey] = model;
break; // first match wins
}
}
}
}

return resolved;
}

/**
* Get the full model configuration
* Reads from opencode.json or uses "zen" defaults
*
* Supports multiple configuration formats:
* 1. Explicit PAI config: { "pai": { "model_provider": "anthropic" } }
* 2. OpenCode standard: { "model": "anthropic/claude-sonnet-4-5" } - auto-detects provider
* 3. No config: falls back to "zen" free models
* 3. Per-agent overrides: { "agent": { "Engineer": { "model": "..." } } }
* are layered on top of the selected preset.
* 4. No config: falls back to "zen" free models
*/
export function getModelConfig(): PaiModelConfig {
const config = readOpencodeConfig();
const opencodeAgents = (config?.agent ?? undefined) as Record<string, unknown> | undefined;

// Check for PAI section in config (preferred method)
const paiConfig = config?.pai;

if (paiConfig?.model_provider) {
const provider = paiConfig.model_provider as "zen" | "anthropic" | "openai";
const raw = paiConfig.model_provider as string;

// Validate provider
if (!["zen", "anthropic", "openai"].includes(provider)) {
fileLog("model-config", `Invalid provider "${provider}", falling back to zen`);
if (!VALID_PROVIDERS.includes(raw as PaiProvider)) {
fileLog("model-config", `Invalid provider "${raw}", falling back to zen`);
return {
model_provider: "zen",
models: PROVIDER_PRESETS.zen,
models: {
...PROVIDER_PRESETS.zen,
agents: mergeOpencodeAgents(PROVIDER_PRESETS.zen.agents, opencodeAgents),
},
};
}

// If user provided custom models, merge with preset
const provider = raw as PaiProvider;
const preset = PROVIDER_PRESETS[provider];
const customModels = paiConfig.models || {};

// Precedence: explicit customModels > opencode.json.agent > preset.
const agentsFromOpencode = mergeOpencodeAgents(preset.agents, opencodeAgents);

const models: PaiModelConfig["models"] = {
default: customModels.default || preset.default,
default: customModels.default || config?.model || preset.default,
validation: customModels.validation || preset.validation,
agents: {
intern: customModels.agents?.intern || preset.agents.intern,
architect: customModels.agents?.architect || preset.agents.architect,
engineer: customModels.agents?.engineer || preset.agents.engineer,
explorer: customModels.agents?.explorer || preset.agents.explorer,
reviewer: customModels.agents?.reviewer || preset.agents.reviewer,
intern: customModels.agents?.intern || agentsFromOpencode.intern,
architect: customModels.agents?.architect || agentsFromOpencode.architect,
engineer: customModels.agents?.engineer || agentsFromOpencode.engineer,
explorer: customModels.agents?.explorer || agentsFromOpencode.explorer,
reviewer: customModels.agents?.reviewer || agentsFromOpencode.reviewer,
},
};

Expand All @@ -207,18 +289,26 @@ export function getModelConfig(): PaiModelConfig {
"model-config",
`Auto-detected provider "${detectedProvider}" from model field: ${config.model}`
);
const preset = PROVIDER_PRESETS[detectedProvider];
return {
model_provider: detectedProvider,
models: PROVIDER_PRESETS[detectedProvider],
models: {
default: config.model,
validation: preset.validation,
agents: mergeOpencodeAgents(preset.agents, opencodeAgents),
},
};
}
}

// Final fallback: zen defaults
// Final fallback: zen defaults, still merged with any opencode.json.agent overrides.
fileLog("model-config", "No PAI config or model field found, using zen defaults");
return {
model_provider: "zen",
models: PROVIDER_PRESETS.zen,
models: {
...PROVIDER_PRESETS.zen,
agents: mergeOpencodeAgents(PROVIDER_PRESETS.zen.agents, opencodeAgents),
},
};
}

Expand Down Expand Up @@ -274,7 +364,7 @@ export function requiresApiKey(): boolean {
/**
* Get the current provider name
*/
export function getProvider(): "zen" | "anthropic" | "openai" {
export function getProvider(): PaiProvider {
const config = getModelConfig();
return config.model_provider;
}
Loading
Loading