Compare commits

...

7 Commits

Author SHA1 Message Date
tgrosinger 2163ea45d2 OpenCode: Add OpenCode as a new provider
The OpenCode provider allows using a variety of models with an agent
harness that can gather more information from the codebase as required
(like with claude-code, codex, or gemini-cli).

This is an alternative to using OpenRouter directly, where the api
provider is more like a chatbot and cannot gather any additional context
beyond what was handed to it.
2026-05-29 16:19:13 -07:00
tgrosinger e4790ac77e Allow specifying tmp dir when preparing prompt 2026-05-29 16:19:13 -07:00
tgrosinger f642e58070 Claude: Use default xhigh effort
With opus-4.8, Claude defaults to "high". Bump up one level for review.
2026-05-28 10:24:02 -07:00
tgrosinger d666f7e08b Codex: Restrict permissions 2026-05-28 10:22:55 -07:00
tgrosinger 9e7989671a Add a flag to disable jokes 2026-05-28 10:22:53 -07:00
tgrosinger a8578beacd OpenRouter: Add OpenRouter as a new provider 2026-05-28 10:22:51 -07:00
tgrosinger 823333a4f5 Claude: Remove dangerously-skip-permissions
Instead, hard-code a list of allowed tools for claude that gives it
general read access.
2026-05-27 20:26:09 -07:00
15 changed files with 774 additions and 41 deletions
+50
View File
@@ -18,11 +18,13 @@ Multi-AI adversarial code review tool. Multiple AI models independently review y
| `claude-code` | CLI | Claude Code CLI (uses your subscription, no API key) |
| `codex-cli` | CLI | OpenAI Codex CLI (uses your subscription, no API key) |
| `gemini-cli` | CLI | Gemini CLI (uses Google account login, no API key) |
| `opencode-cli` | CLI | OpenCode CLI — runs any model (typically via OpenRouter) as a code-aware agent (requires backing provider's API key) |
| `qwen-code` | CLI | Alibaba Qwen Code CLI (uses OAuth login, no API key) |
| `claude-*` | API | Anthropic API (requires ANTHROPIC_API_KEY) |
| `gpt-*` | API | OpenAI API (requires OPENAI_API_KEY) |
| `gemini-*` | API | Google Gemini API (requires GOOGLE_API_KEY) |
| `minimax` | API | MiniMax API (requires MINIMAX_API_KEY) |
| `openrouter/*` | API | OpenRouter API, OpenAI-compatible (requires OPENROUTER_API_KEY) |
| `mock` | Debug | Mock provider for testing (no API key, see [Debug Mode](#debug-mode)) |
**Recommended**: Use CLI providers (claude-code, codex-cli, gemini-cli, qwen-code) - they're free with your subscriptions and don't require API keys.
@@ -41,6 +43,47 @@ providers:
base_url: https://my-proxy.example.com
```
### OpenRouter
OpenRouter exposes hundreds of models through a single OpenAI-compatible API. Magpie routes any model whose ID starts with `openrouter/` through OpenRouter:
```yaml
providers:
openrouter:
api_key: ${OPENROUTER_API_KEY}
# base_url: https://openrouter.ai/api/v1 # optional, this is the default
reviewers:
sonnet:
model: openrouter/anthropic/claude-3.5-sonnet
prompt: |
...
llama:
model: openrouter/meta-llama/llama-3-70b-instruct
prompt: |
...
```
The portion after `openrouter/` is sent to OpenRouter verbatim, so use any model ID listed at https://openrouter.ai/models.
### OpenCode CLI
Models routed through `openrouter/*` reach the model purely as a chat completion — the reviewer sees only the diff and prompt and cannot read source files. To get a code-aware agent on top of OpenRouter (or any other backing provider), use the `opencode-cli` provider, which wraps the [OpenCode](https://opencode.ai/) CLI:
```yaml
providers:
openrouter:
api_key: ${OPENROUTER_API_KEY}
reviewers:
sonnet-agent:
model: opencode-cli:openrouter/anthropic/claude-sonnet-4
prompt: |
...
```
The portion after `opencode-cli:` is passed verbatim to opencode's `-m provider/model` flag. Reviewers run with a read-only tool allowlist (Read, Grep, Glob, plus `gh`/`git`/`rg`) — matching the claude-code provider's permissions. API keys from `providers.openrouter.api_key` (and `anthropic`/`openai`/`google` if configured) are forwarded into opencode's environment, so you don't need a second copy of your keys.
## Installation
```bash
@@ -486,6 +529,13 @@ While waiting for AI reviewers, enjoy programmer jokes:
⠋ claude is thinking... | Why do programmers confuse Halloween and Christmas? Because Oct 31 = Dec 25
```
Disable them via config if you prefer a quieter spinner:
```yaml
defaults:
show_jokes: false
```
### Post-Review Discussion Phase (Interactive Mode)
In interactive mode (`-i`), after the debate concludes, you can enter a **discussion phase** to chat with any role (reviewers, analyzer, or summarizer) before the comment posting step:
+9 -7
View File
@@ -231,6 +231,7 @@ async function runDiscussion(
const isSoloDiscussion = reviewers.length === 1
const maxRounds = isSoloDiscussion ? 1 : parseInt(options.rounds, 10)
const checkConvergence = !isSoloDiscussion && options.converge !== false && (config.defaults.check_convergence !== false)
const showJokes = config.defaults.show_jokes !== false
const summarizer: Reviewer = {
id: 'summarizer',
@@ -322,29 +323,30 @@ async function runDiscussion(
`${reviewerId} is thinking`
const updateSpinner = () => {
const joke = getRandomJoke()
if (spinnerRef.spinner) {
if (!spinnerRef.spinner) return
const jokeSuffix = showJokes ? ` ${chalk.dim(`| ${getRandomJoke()}`)}` : ''
if (spinnerRef.parallelStatuses && isParallelRound) {
const round = parseInt(reviewerId.split('-')[1])
const statusLine = formatParallelStatus(round, spinnerRef.parallelStatuses)
spinnerRef.spinner.text = `${statusLine} ${chalk.dim(`| ${joke}`)}`
spinnerRef.spinner.text = `${statusLine}${jokeSuffix}`
} else {
spinnerRef.spinner.text = `${baseLabel}... ${chalk.dim(`| ${joke}`)}`
}
spinnerRef.spinner.text = `${baseLabel}...${jokeSuffix}`
}
}
spinnerRef.parallelStatuses = null
spinnerRef.spinner = ora({ text: `${baseLabel}...`, discardStdin: false }).start()
updateSpinner()
if (showJokes) {
spinnerRef.interval = setInterval(updateSpinner, 8000)
}
},
onParallelStatus: (round, statuses) => {
spinnerRef.parallelStatuses = statuses
if (spinnerRef.spinner) {
const joke = getRandomJoke()
const jokeSuffix = showJokes ? ` ${chalk.dim(`| ${getRandomJoke()}`)}` : ''
const statusLine = formatParallelStatus(round, statuses)
spinnerRef.spinner.text = `${statusLine} ${chalk.dim(`| ${joke}`)}`
spinnerRef.spinner.text = `${statusLine}${jokeSuffix}`
}
},
onMessage: (reviewerId, chunk) => {
+1
View File
@@ -79,6 +79,7 @@ export const initCommand = new Command('init')
if (r.provider === 'anthropic') envVars.add('ANTHROPIC_API_KEY')
if (r.provider === 'openai') envVars.add('OPENAI_API_KEY')
if (r.provider === 'google') envVars.add('GOOGLE_API_KEY')
if (r.provider === 'openrouter') envVars.add('OPENROUTER_API_KEY')
})
envVars.forEach(v => console.log(` - ${v}`))
}
+9 -8
View File
@@ -413,6 +413,7 @@ export const reviewCommand = new Command('review')
const maxRounds = isSoloReview ? 1 : parseInt(options.rounds, 10)
// Convergence: disable for solo review; otherwise default from config, CLI can override with --no-converge
const checkConvergence = !isSoloReview && options.converge !== false && (config.defaults.check_convergence !== false)
const showJokes = config.defaults.show_jokes !== false
console.log()
console.log(chalk.bgBlue.white.bold(` ${target.label} Review `))
@@ -493,31 +494,31 @@ export const reviewCommand = new Command('review')
// Show spinner with a joke (and parallel status if available)
const updateSpinner = () => {
const joke = getRandomJoke()
if (spinnerRef.spinner) {
if (!spinnerRef.spinner) return
const jokeSuffix = showJokes ? ` ${chalk.dim(`| ${getRandomJoke()}`)}` : ''
if (spinnerRef.parallelStatuses && isParallelRound) {
const round = parseInt(reviewerId.split('-')[1])
const statusLine = formatParallelStatus(round, spinnerRef.parallelStatuses)
spinnerRef.spinner.text = `${statusLine} ${chalk.dim(`| ${joke}`)}`
spinnerRef.spinner.text = `${statusLine}${jokeSuffix}`
} else {
spinnerRef.spinner.text = `${baseLabel}... ${chalk.dim(`| ${joke}`)}`
}
spinnerRef.spinner.text = `${baseLabel}...${jokeSuffix}`
}
}
spinnerRef.parallelStatuses = null // Reset for new waiting phase
spinnerRef.spinner = ora({ text: `${baseLabel}...`, discardStdin: false }).start()
updateSpinner()
// Update joke every 15 seconds
if (showJokes) {
spinnerRef.interval = setInterval(updateSpinner, 15000)
}
},
onParallelStatus: (round, statuses) => {
spinnerRef.parallelStatuses = statuses
// Immediately update spinner to show new status
if (spinnerRef.spinner) {
const joke = getRandomJoke()
const jokeSuffix = showJokes ? ` ${chalk.dim(`| ${getRandomJoke()}`)}` : ''
const statusLine = formatParallelStatus(round, statuses)
spinnerRef.spinner.text = `${statusLine} ${chalk.dim(`| ${joke}`)}`
spinnerRef.spinner.text = `${statusLine}${jokeSuffix}`
}
},
onMessage: (reviewerId, chunk) => {
+26 -2
View File
@@ -9,7 +9,7 @@ export interface ReviewerOption {
model: string
description: string
needsApiKey: boolean
provider?: 'anthropic' | 'openai' | 'google'
provider?: 'anthropic' | 'openai' | 'google' | 'openrouter'
}
export const AVAILABLE_REVIEWERS: ReviewerOption[] = [
@@ -34,6 +34,14 @@ export const AVAILABLE_REVIEWERS: ReviewerOption[] = [
description: 'Uses your Gemini CLI (Google account, no API key needed)',
needsApiKey: false
},
{
id: 'opencode-cli',
name: 'OpenCode (via OpenRouter)',
model: 'opencode-cli:openrouter/anthropic/claude-3.5-sonnet',
description: 'Runs any OpenRouter model as a code-aware agent via the OpenCode CLI (requires OPENROUTER_API_KEY)',
needsApiKey: true,
provider: 'openrouter'
},
{
id: 'claude-api',
name: 'Claude Sonnet 4.5',
@@ -57,6 +65,14 @@ export const AVAILABLE_REVIEWERS: ReviewerOption[] = [
description: 'Uses Google AI API (requires GOOGLE_API_KEY)',
needsApiKey: true,
provider: 'google'
},
{
id: 'openrouter',
name: 'OpenRouter (Claude 3.5 Sonnet)',
model: 'openrouter/anthropic/claude-3.5-sonnet',
description: 'Uses OpenRouter API (requires OPENROUTER_API_KEY). Change the model field to any OpenRouter-supported ID.',
needsApiKey: true,
provider: 'openrouter'
}
]
@@ -98,6 +114,7 @@ export function generateConfig(selectedReviewerIds: string[]): string {
const needsAnthropic = selectedReviewers.some(r => r.provider === 'anthropic')
const needsOpenai = selectedReviewers.some(r => r.provider === 'openai')
const needsGoogle = selectedReviewers.some(r => r.provider === 'google')
const needsOpenrouter = selectedReviewers.some(r => r.provider === 'openrouter')
// Build providers section
let providersSection = '# AI Provider API Keys (use environment variables)\nproviders:'
@@ -116,7 +133,13 @@ export function generateConfig(selectedReviewerIds: string[]): string {
google:
api_key: \${GOOGLE_API_KEY}`
}
if (!needsAnthropic && !needsOpenai && !needsGoogle) {
if (needsOpenrouter) {
providersSection += `
openrouter:
api_key: \${OPENROUTER_API_KEY}
# base_url: https://openrouter.ai/api/v1 # optional, this is the default`
}
if (!needsAnthropic && !needsOpenai && !needsGoogle && !needsOpenrouter) {
providersSection += ' {}' // Empty providers if only CLI tools are used
}
@@ -142,6 +165,7 @@ defaults:
max_rounds: 5
output_format: markdown
check_convergence: true # Stop early when reviewers reach consensus
show_jokes: true # Show rotating programmer jokes in the spinner while waiting
${reviewersSection}
+3
View File
@@ -15,6 +15,7 @@ export interface DefaultsConfig {
check_convergence: boolean
language?: string // Output language (e.g., 'zh', 'en', 'ja')
diff_exclude?: string[] // Glob patterns for files to exclude from diff (e.g., '*.pb.go', '*generated*')
show_jokes?: boolean // Show rotating programmer jokes in spinner text while waiting (default: true)
}
export interface ContextGathererConfigOptions {
@@ -41,8 +42,10 @@ export interface MagpieConfig {
google?: ProviderConfig
'claude-code'?: { enabled: boolean }
'codex-cli'?: { enabled: boolean }
'opencode-cli'?: { enabled: boolean }
'qwen-code'?: { enabled: boolean }
minimax?: ProviderConfig
openrouter?: ProviderConfig
}
mock?: boolean
defaults: DefaultsConfig
+9 -5
View File
@@ -4,6 +4,12 @@ import { CliSessionHelper } from './session-helper.js'
import { preparePromptForCli } from '../utils/prompt-file.js'
import { withRetry } from '../utils/retry.js'
// Tools magpie reviewers are pre-approved to use without prompting.
// Read-only file/code access plus the specific Bash commands needed
// to inspect PRs (gh), git history, and search (rg). General Bash,
// Edit, and Write are intentionally NOT included.
const ALLOWED_TOOLS = 'Read,Grep,Glob,Bash(gh:*),Bash(git:*),Bash(rg:*)'
export class ClaudeCodeProvider implements AIProvider {
name = 'claude-code'
private cwd: string
@@ -74,8 +80,7 @@ export class ClaudeCodeProvider implements AIProvider {
return new Promise((resolve, reject) => {
// Build args based on session state
// Use --dangerously-skip-permissions to allow network access (e.g., gh commands)
const args = ['-p', '-', '--dangerously-skip-permissions', '--effort', 'max']
const args = ['-p', '-', '--effort', 'xhigh', '--allowed-tools', ALLOWED_TOOLS]
if (this.cliModel) {
args.push('--model', this.cliModel)
}
@@ -137,12 +142,11 @@ export class ClaudeCodeProvider implements AIProvider {
private async *runClaudeStream(prompt: string, systemPrompt?: string): AsyncGenerator<string, void, unknown> {
const { prompt: stdinPrompt, cleanup } = preparePromptForCli(prompt)
// Build args based on session state
// Use --dangerously-skip-permissions to allow network access (e.g., gh commands)
// Build args based on session state.
// Use --output-format stream-json --verbose so that tool activity (Read, Bash, etc.)
// produces stdout events, preventing the inactivity timeout from killing Claude
// while it's actively investigating code.
const args = ['-p', '-', '--dangerously-skip-permissions', '--effort', 'max', '--output-format', 'stream-json', '--verbose']
const args = ['-p', '-', '--allowed-tools', ALLOWED_TOOLS, '--effort', 'xhigh', '--output-format', 'stream-json', '--verbose']
if (this.cliModel) {
args.push('--model', this.cliModel)
}
+8 -1
View File
@@ -65,7 +65,14 @@ export class CodexCliProvider implements AIProvider {
}
private buildArgs(): string[] {
const baseArgs = ['--json', '--dangerously-bypass-approvals-and-sandbox']
// workspace-write (not read-only) because codex's read-only sandbox
// also blocks network, which breaks `gh pr diff` for reviewers.
const baseArgs = [
'--json',
'--sandbox', 'workspace-write',
'-c', 'approval_policy="never"',
'-c', 'sandbox_workspace_write.network_access=true',
]
if (this.cliModel) {
baseArgs.push('--model', this.cliModel)
}
+47 -2
View File
@@ -7,6 +7,7 @@ import { ClaudeCodeProvider } from './claude-code.js'
import { CodexCliProvider } from './codex-cli.js'
import { GeminiCliProvider } from './gemini-cli.js'
import { GeminiProvider } from './gemini.js'
import { OpencodeCliProvider } from './opencode-cli.js'
import { QwenCodeProvider } from './qwen-code.js'
import { MiniMaxProvider } from './minimax.js'
import { MockProvider } from './mock.js'
@@ -14,9 +15,20 @@ import { checkCliBinary } from './cli-check.js'
// Parse CLI model string: 'gemini-cli:gemini-2.5-pro' → { provider: 'gemini-cli', cliModel: 'gemini-2.5-pro' }
// Plain 'gemini-cli' → { provider: 'gemini-cli', cliModel: undefined }
const CLI_PROVIDERS = ['claude-code', 'codex-cli', 'gemini-cli', 'qwen-code'] as const
const CLI_PROVIDERS = ['claude-code', 'codex-cli', 'gemini-cli', 'opencode-cli', 'qwen-code'] as const
type CliProviderName = typeof CLI_PROVIDERS[number]
const OPENROUTER_PREFIX = 'openrouter/'
const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'
// OpenRouter model IDs look like 'openrouter/<vendor>/<model>',
// e.g. 'openrouter/anthropic/claude-3.5-sonnet'. The prefix routes to
// the OpenAI client (OpenRouter is OpenAI-compatible); the rest is the
// model ID the OpenRouter API expects.
function stripOpenRouterPrefix(model: string): string {
return model.slice(OPENROUTER_PREFIX.length)
}
export function parseCliModel(model: string): { provider: string; cliModel?: string } {
for (const cli of CLI_PROVIDERS) {
if (model === cli) {
@@ -35,7 +47,10 @@ export function isCliModel(model: string): boolean {
return (CLI_PROVIDERS as readonly string[]).includes(provider)
}
export function getProviderForModel(model: string): 'anthropic' | 'openai' | 'google' | 'claude-code' | 'codex-cli' | 'gemini-cli' | 'qwen-code' | 'minimax' | 'mock' {
export function getProviderForModel(model: string): 'anthropic' | 'openai' | 'google' | 'claude-code' | 'codex-cli' | 'gemini-cli' | 'opencode-cli' | 'qwen-code' | 'minimax' | 'mock' | 'openrouter' {
if (model.startsWith(OPENROUTER_PREFIX)) {
return 'openrouter'
}
const { provider } = parseCliModel(model)
if ((CLI_PROVIDERS as readonly string[]).includes(provider)) {
return provider as CliProviderName
@@ -92,11 +107,41 @@ export function createProvider(model: string, config: MagpieConfig): AIProvider
return new QwenCodeProvider({ cliModel })
}
// OpenCode CLI is the one CLI provider that needs upstream API keys —
// it routes to OpenRouter (or another provider) for the actual model call.
// We forward whatever keys magpie already has configured.
if (providerName === 'opencode-cli') {
checkCliBinary('opencode', 'OpenCode')
return new OpencodeCliProvider({ cliModel, config })
}
// Mock provider for debug mode — no API key needed
if (providerName === 'mock') {
return new MockProvider()
}
// OpenRouter is OpenAI-compatible: route through the OpenAI client,
// strip the 'openrouter/' prefix from the model, and point at OpenRouter's API.
if (providerName === 'openrouter') {
const openRouterModel = stripOpenRouterPrefix(model).trim()
if (!openRouterModel) {
throw new Error(`Invalid OpenRouter model "${model}": must include a model ID after "${OPENROUTER_PREFIX}" (e.g. "openrouter/anthropic/claude-3.5-sonnet").`)
}
const providerConfig = config.providers['openrouter']
const apiKey = providerConfig?.api_key || process.env.OPENROUTER_API_KEY || ''
if (!apiKey) {
throw new Error('OpenRouter API key is required. Set OPENROUTER_API_KEY env var or providers.openrouter.api_key in config.')
}
// NOTE: the returned provider's `.name` will be 'openai', not 'openrouter',
// because OpenRouter requests are dispatched through the OpenAI client.
// Logs/UI keyed on provider name will show 'openai' for OpenRouter traffic.
return new OpenAIProvider({
apiKey,
model: openRouterModel,
baseURL: providerConfig?.base_url || DEFAULT_OPENROUTER_BASE_URL,
})
}
// MiniMax uses API key from config or env
if (providerName === 'minimax') {
const providerConfig = config.providers['minimax']
+355
View File
@@ -0,0 +1,355 @@
import { spawn } from 'child_process'
import type { AIProvider, Message, CliProviderOptions, ChatOptions } from './types.js'
import type { MagpieConfig } from '../config/types.js'
import { CliSessionHelper } from './session-helper.js'
import { preparePromptForCli } from '../utils/prompt-file.js'
import { withRetry } from '../utils/retry.js'
// Read-only tool allowlist for opencode reviewers, mirroring claude-code's
// ALLOWED_TOOLS. Injected via the OPENCODE_CONFIG_CONTENT env var so we don't
// touch the user's own opencode.json. With --dangerously-skip-permissions,
// explicit "deny" entries still block — unspecified categories auto-allow,
// which keeps us forward-compatible with new opencode tools.
//
// IMPORTANT — bash rule order: opencode applies the LAST matching pattern,
// not the most specific one. The catch-all `'*': 'deny'` MUST come first,
// followed by the specific allows, or every gh/git/rg call gets denied and
// opencode drops the bash tool from the model's available tool list entirely.
const PERMISSION_CONFIG = JSON.stringify({
$schema: 'https://opencode.ai/config.json',
permission: {
read: 'allow',
grep: 'allow',
glob: 'allow',
list: 'allow',
todowrite: 'allow',
edit: 'deny',
task: 'deny',
webfetch: 'deny',
websearch: 'deny',
// Large prompts (>100KB) are materialized to a file via preparePromptForCli
// and we pass tmpDir: this.cwd so that file lives inside --dir <cwd>.
// That keeps external_directory denied: a prompt injection cannot trick
// the reviewer into reading ~/.ssh, /etc/passwd, or anything else outside
// the repo.
external_directory: 'deny',
bash: {
'*': 'deny',
'gh *': 'allow',
'git *': 'allow',
'rg *': 'allow',
},
},
})
// Magpie provider key → opencode env var. Forwarded so the user only needs
// to configure each key once (in magpie's config) rather than also exporting
// it to opencode's environment.
const API_KEY_FORWARDS: Array<{ env: string; providerKey: 'openrouter' | 'anthropic' | 'openai' | 'google' }> = [
{ env: 'OPENROUTER_API_KEY', providerKey: 'openrouter' },
{ env: 'ANTHROPIC_API_KEY', providerKey: 'anthropic' },
{ env: 'OPENAI_API_KEY', providerKey: 'openai' },
{ env: 'GOOGLE_API_KEY', providerKey: 'google' },
]
export interface OpencodeCliProviderOptions extends CliProviderOptions {
/** MagpieConfig is needed so we can forward API keys to opencode's env. */
config?: MagpieConfig
}
export class OpencodeCliProvider implements AIProvider {
name = 'opencode-cli'
private cwd: string
private timeout: number // ms, 0 = no timeout
private cliModel?: string
private config?: MagpieConfig
private session = new CliSessionHelper()
// Like codex-cli: opencode generates its own session id and returns it in
// the first response's event stream. We never pre-generate one — that
// would risk telling opencode to "continue" a session it has never seen.
private sessionEnabled = false
get sessionId() { return this.session.sessionId }
constructor(options?: OpencodeCliProviderOptions) {
this.cwd = process.cwd()
this.timeout = 15 * 60 * 1000 // 15 minutes
this.cliModel = options?.cliModel
this.config = options?.config
}
setCwd(cwd: string) {
this.cwd = cwd
}
startSession(name?: string): void {
this.sessionEnabled = true
this.session.start(name)
this.session.sessionId = undefined // Captured from the first response, not pre-generated
}
endSession(): void {
this.sessionEnabled = false
this.session.end()
}
async chat(messages: Message[], systemPrompt?: string, _options?: ChatOptions): Promise<string> {
const prompt = this.session.shouldSendFullHistory()
? this.session.buildPrompt(messages, systemPrompt)
: this.session.buildPromptLastOnly(messages)
try {
const result = await withRetry(() => this.runOpencode(prompt))
this.session.markMessageSent()
return result
} catch (err) {
if (this.sessionEnabled) this.startSession(this.session.sessionName)
throw err
}
}
async *chatStream(messages: Message[], systemPrompt?: string): AsyncGenerator<string, void, unknown> {
const prompt = this.session.shouldSendFullHistory()
? this.session.buildPrompt(messages, systemPrompt)
: this.session.buildPromptLastOnly(messages)
try {
yield* this.runOpencodeStream(prompt)
this.session.markMessageSent()
} catch (err) {
if (this.sessionEnabled) this.startSession(this.session.sessionName)
throw err
}
}
private spawnEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, OPENCODE_CONFIG_CONTENT: PERMISSION_CONFIG }
if (this.config) {
for (const { env: envKey, providerKey } of API_KEY_FORWARDS) {
const pc = this.config.providers[providerKey] as { api_key?: string } | undefined
if (pc?.api_key) {
env[envKey] = pc.api_key
}
}
}
return env
}
private buildArgs(): string[] {
// opencode run reads stdin and concatenates with positional args, so we
// can deliver the prompt via stdin like the other CLI providers.
// --dangerously-skip-permissions auto-allows unspecified categories;
// explicit "deny" entries in PERMISSION_CONFIG still block.
const args = [
'run',
'--format', 'json',
'--dir', this.cwd,
'--dangerously-skip-permissions',
]
if (this.cliModel) {
args.push('-m', this.cliModel)
}
// Pass the captured session id on follow-up turns. We never use
// --continue (which resumes opencode's globally-last session and would
// race when multiple magpie reviewers run concurrently), and we never
// pass an unseen id on the first turn (opencode generates the id).
if (this.sessionEnabled && this.session.sessionId && !this.session.isFirstMessage) {
args.push('--session', this.session.sessionId)
}
return args
}
// Event schema (verified against opencode 1.15.11):
// {type:"step_start", sessionID:"ses_...", part:{...}}
// {type:"text", sessionID:"ses_...", part:{type:"text", text:"..."}}
// Each model turn emits one consolidated `text` event — no streaming deltas.
// Tool-use events are ignored for text extraction.
private extractEventText(event: unknown): string {
if (!event || typeof event !== 'object') return ''
const e = event as { type?: unknown; sessionID?: unknown; part?: { type?: unknown; text?: unknown } }
if (this.sessionEnabled && !this.session.sessionId && typeof e.sessionID === 'string') {
this.session.sessionId = e.sessionID
}
if (e.type === 'text' && e.part?.type === 'text' && typeof e.part.text === 'string') {
return e.part.text
}
return ''
}
private parseJsonOutput(output: string): string {
let text = ''
for (const line of output.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
try {
text += this.extractEventText(JSON.parse(trimmed))
} catch {
// not JSON — ignore
}
}
return text
}
private runOpencode(prompt: string): Promise<string> {
// Write the spilled prompt file inside --dir <cwd> so the read tool can
// reach it; external_directory: 'deny' would otherwise block /tmp paths.
const { prompt: stdinPrompt, cleanup } = preparePromptForCli(prompt, { tmpDir: this.cwd })
return new Promise((resolve, reject) => {
const args = this.buildArgs()
const child = spawn('opencode', args, {
cwd: this.cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: this.spawnEnv(),
})
let output = ''
let error = ''
child.stdout.on('data', (data) => {
output += data.toString()
})
child.stderr.on('data', (data) => {
error += data.toString()
})
child.on('close', (code) => {
cleanup()
if (code !== 0) {
reject(new Error(`OpenCode CLI exited with code ${code}: ${error}`))
} else {
resolve(this.parseJsonOutput(output).trim())
}
})
child.on('error', (err) => {
cleanup()
reject(new Error(`Failed to run opencode CLI: ${err.message}`))
})
child.stdin.on('error', () => {})
child.stdin.write(stdinPrompt)
child.stdin.end()
})
}
private async *runOpencodeStream(prompt: string): AsyncGenerator<string, void, unknown> {
// Write the spilled prompt file inside --dir <cwd> so the read tool can
// reach it; external_directory: 'deny' would otherwise block /tmp paths.
const { prompt: stdinPrompt, cleanup } = preparePromptForCli(prompt, { tmpDir: this.cwd })
const args = this.buildArgs()
const child = spawn('opencode', args, {
cwd: this.cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: this.spawnEnv(),
})
const chunks: string[] = []
let resolveNext: ((value: { chunk: string | null }) => void) | null = null
let done = false
let error: Error | null = null
let lastActivity = Date.now()
let lineBuf = ''
let stderrOutput = ''
const timeoutChecker = this.timeout > 0 ? setInterval(() => {
if (Date.now() - lastActivity > this.timeout) {
child.kill('SIGTERM')
const forceKill = setTimeout(() => {
try { child.kill('SIGKILL') } catch {}
}, 5000)
forceKill.unref()
done = true
error = new Error(`OpenCode CLI timed out after ${this.timeout / 1000}s of inactivity`)
if (resolveNext) {
resolveNext({ chunk: null })
}
}
}, 10000) : null
const pushChunk = (chunk: string) => {
if (!chunk) return
if (resolveNext) {
resolveNext({ chunk })
resolveNext = null
} else {
chunks.push(chunk)
}
}
child.stdout.on('data', (data) => {
lastActivity = Date.now()
lineBuf += data.toString()
let idx
while ((idx = lineBuf.indexOf('\n')) !== -1) {
const line = lineBuf.slice(0, idx).trim()
lineBuf = lineBuf.slice(idx + 1)
if (!line) continue
try {
const event = JSON.parse(line) as Record<string, unknown>
const piece = this.extractEventText(event)
if (piece) pushChunk(piece)
} catch {
// Not JSON, ignore
}
}
})
child.stderr.on('data', (data) => {
lastActivity = Date.now()
stderrOutput += data.toString()
})
child.on('close', (code) => {
cleanup()
if (timeoutChecker) clearInterval(timeoutChecker)
if (lineBuf.trim()) {
try {
const event = JSON.parse(lineBuf.trim()) as Record<string, unknown>
const piece = this.extractEventText(event)
if (piece) pushChunk(piece)
} catch {}
}
done = true
if (code !== 0 && !error) {
error = new Error(`OpenCode CLI exited with code ${code}${stderrOutput ? ': ' + stderrOutput.slice(0, 500) : ''}`)
}
if (resolveNext) {
resolveNext({ chunk: null })
}
})
child.on('error', (err) => {
cleanup()
if (timeoutChecker) clearInterval(timeoutChecker)
done = true
error = new Error(`Failed to run opencode CLI: ${err.message}`)
if (resolveNext) {
resolveNext({ chunk: null })
}
})
child.stdin.on('error', () => {})
child.stdin.write(stdinPrompt)
child.stdin.end()
while (!done || chunks.length > 0) {
if (chunks.length > 0) {
yield chunks.shift()!
} else if (!done) {
const result = await new Promise<{ chunk: string | null }>((resolve) => {
resolveNext = resolve
})
if (result.chunk !== null) {
yield result.chunk
}
}
}
if (error) {
throw error
}
}
}
+13 -2
View File
@@ -23,14 +23,25 @@ export interface PreparedPrompt {
cleanup: () => void
}
export function preparePromptForCli(prompt: string): PreparedPrompt {
export interface PreparePromptOptions {
/**
* Directory to materialize the prompt file in when it exceeds the size
* threshold. Defaults to os.tmpdir(). Override when the consuming CLI
* cannot read outside a specific root — e.g. opencode-cli denies reads
* outside its --dir, so the prompt file must live inside the repo.
*/
tmpDir?: string
}
export function preparePromptForCli(prompt: string, options?: PreparePromptOptions): PreparedPrompt {
if (Buffer.byteLength(prompt, 'utf-8') <= PROMPT_SIZE_THRESHOLD) {
return { prompt, cleanup: () => {} }
}
registerExitHandler()
const tmpFile = join(tmpdir(), `magpie_prompt_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`)
const dir = options?.tmpDir ?? tmpdir()
const tmpFile = join(dir, `magpie_prompt_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`)
writeFileSync(tmpFile, prompt, 'utf-8')
activeTempFiles.add(tmpFile)
+49 -1
View File
@@ -1,9 +1,13 @@
// tests/providers/factory.test.ts
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { createProvider, getProviderForModel } from '../../src/providers/factory.js'
import type { MagpieConfig } from '../../src/config/types.js'
describe('Provider Factory', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
const mockConfig: MagpieConfig = {
providers: {
anthropic: { api_key: 'ant-key' },
@@ -39,6 +43,17 @@ describe('Provider Factory', () => {
it('should return codex-cli for codex-cli model', () => {
expect(getProviderForModel('codex-cli')).toBe('codex-cli')
})
it('should return opencode-cli for opencode-cli model (with and without :model suffix)', () => {
expect(getProviderForModel('opencode-cli')).toBe('opencode-cli')
expect(getProviderForModel('opencode-cli:openrouter/anthropic/claude-sonnet-4')).toBe('opencode-cli')
})
it('should return openrouter for openrouter/ prefixed models', () => {
expect(getProviderForModel('openrouter/anthropic/claude-3.5-sonnet')).toBe('openrouter')
expect(getProviderForModel('openrouter/meta-llama/llama-3-70b-instruct')).toBe('openrouter')
expect(getProviderForModel('openrouter/openai/gpt-4o')).toBe('openrouter')
})
})
describe('createProvider', () => {
@@ -76,6 +91,16 @@ describe('Provider Factory', () => {
expect(provider.name).toBe('codex-cli')
})
it('should create opencode-cli provider with no extra config', () => {
const provider = createProvider('opencode-cli', mockConfig)
expect(provider.name).toBe('opencode-cli')
})
it('should create opencode-cli provider with a model suffix', () => {
const provider = createProvider('opencode-cli:openrouter/anthropic/claude-sonnet-4', mockConfig)
expect(provider.name).toBe('opencode-cli')
})
it('should pass base_url through to API providers', () => {
const configWithBaseUrl: MagpieConfig = {
...mockConfig,
@@ -95,5 +120,28 @@ describe('Provider Factory', () => {
const provider = createProvider('claude-sonnet-4-20250514', mockConfig)
expect(provider.name).toBe('anthropic')
})
it('should create openrouter provider (via openai client) with api key from config', () => {
const configWithOpenrouter: MagpieConfig = {
...mockConfig,
providers: { ...mockConfig.providers, openrouter: { api_key: 'or-key' } }
}
const provider = createProvider('openrouter/anthropic/claude-3.5-sonnet', configWithOpenrouter)
// OpenRouter is routed through the OpenAI client, so .name === 'openai'
expect(provider.name).toBe('openai')
})
it('should pick up OPENROUTER_API_KEY env var when config is absent', () => {
vi.stubEnv('OPENROUTER_API_KEY', 'env-or-key')
const provider = createProvider('openrouter/anthropic/claude-3.5-sonnet', mockConfig)
expect(provider.name).toBe('openai')
})
it('should throw when OpenRouter has no api key configured', () => {
vi.stubEnv('OPENROUTER_API_KEY', '')
expect(() =>
createProvider('openrouter/anthropic/claude-3.5-sonnet', mockConfig)
).toThrow(/OpenRouter API key/)
})
})
})
+53 -1
View File
@@ -1,15 +1,21 @@
import { describe, it, expect, vi } from 'vitest'
import { OpenAIProvider } from '../../src/providers/openai'
import { createProvider } from '../../src/providers/factory'
import type { MagpieConfig } from '../../src/config/types'
let lastConstructorOptions: Record<string, unknown> = {}
let lastCreateOptions: Record<string, unknown> = {}
vi.mock('openai', () => ({
default: class MockOpenAI {
chat = {
completions: {
create: vi.fn().mockResolvedValue({
create: vi.fn().mockImplementation((opts: Record<string, unknown>) => {
lastCreateOptions = opts
return Promise.resolve({
choices: [{ message: { content: 'Mock response' } }]
})
})
}
}
constructor(options: Record<string, unknown>) {
@@ -40,3 +46,49 @@ describe('OpenAIProvider', () => {
expect(lastConstructorOptions.baseURL).toBeUndefined()
})
})
describe('OpenRouter via OpenAI client', () => {
const baseConfig: MagpieConfig = {
providers: {},
defaults: { max_rounds: 3, output_format: 'markdown' },
reviewers: {},
summarizer: { model: 'openrouter/anthropic/claude-3.5-sonnet', prompt: '' },
analyzer: { model: 'openrouter/anthropic/claude-3.5-sonnet', prompt: '' }
}
it('strips the openrouter/ prefix from the model and defaults baseURL to OpenRouter', async () => {
const config: MagpieConfig = {
...baseConfig,
providers: { openrouter: { api_key: 'or-key' } }
}
const provider = createProvider('openrouter/anthropic/claude-3.5-sonnet', config)
expect(lastConstructorOptions.apiKey).toBe('or-key')
expect(lastConstructorOptions.baseURL).toBe('https://openrouter.ai/api/v1')
// Invoke chat() so the stripped model reaches chat.completions.create
await provider.chat([{ role: 'user', content: 'hi' }])
expect(lastCreateOptions.model).toBe('anthropic/claude-3.5-sonnet')
})
it('honors a custom base_url from config and forwards the stripped model', async () => {
const config: MagpieConfig = {
...baseConfig,
providers: {
openrouter: { api_key: 'or-key', base_url: 'https://my-openrouter-proxy.example.com/v1' }
}
}
const provider = createProvider('openrouter/meta-llama/llama-3-70b-instruct', config)
expect(lastConstructorOptions.baseURL).toBe('https://my-openrouter-proxy.example.com/v1')
await provider.chat([{ role: 'user', content: 'hi' }])
expect(lastCreateOptions.model).toBe('meta-llama/llama-3-70b-instruct')
})
it('throws when the model is just "openrouter/" with no ID after it', () => {
const config: MagpieConfig = {
...baseConfig,
providers: { openrouter: { api_key: 'or-key' } }
}
expect(() => createProvider('openrouter/', config)).toThrow(/must include a model ID/)
})
})
+109
View File
@@ -0,0 +1,109 @@
// Verifies the JSON event parser against captured opencode 1.15.11 output.
// The schema is internal to opencode; if it changes, these tests fail loudly
// rather than the provider silently returning empty reviewer responses.
import { describe, it, expect } from 'vitest'
import { OpencodeCliProvider } from '../../src/providers/opencode-cli.js'
// Real captures from `opencode run --format json -m openrouter/openai/gpt-4o-mini`.
const STEP_START_EVENT = '{"type":"step_start","timestamp":1780089625130,"sessionID":"ses_abc","part":{"id":"prt_1","messageID":"msg_1","sessionID":"ses_abc","type":"step-start"}}'
const TEXT_EVENT = '{"type":"text","timestamp":1780089625396,"sessionID":"ses_abc","part":{"id":"prt_2","messageID":"msg_1","sessionID":"ses_abc","type":"text","text":"ok","time":{"start":1780089625131,"end":1780089625393}}}'
// Access private parser methods. They're pure logic and worth testing directly;
// extracting them into a separate module just for visibility would be churn.
type ParserHandle = {
extractEventText(event: unknown): string
parseJsonOutput(output: string): string
}
function asParser(p: OpencodeCliProvider): ParserHandle {
return p as unknown as ParserHandle
}
describe('OpencodeCliProvider parser', () => {
describe('extractEventText', () => {
it('returns the text from a text-part event', () => {
const parser = asParser(new OpencodeCliProvider())
expect(parser.extractEventText(JSON.parse(TEXT_EVENT))).toBe('ok')
})
it('returns empty for a step_start event', () => {
const parser = asParser(new OpencodeCliProvider())
expect(parser.extractEventText(JSON.parse(STEP_START_EVENT))).toBe('')
})
it('returns empty for an unknown event type', () => {
const parser = asParser(new OpencodeCliProvider())
expect(parser.extractEventText({ type: 'tool.use', sessionID: 'ses_abc', tool: 'read' })).toBe('')
})
it('returns empty for non-object inputs', () => {
const parser = asParser(new OpencodeCliProvider())
expect(parser.extractEventText(null)).toBe('')
expect(parser.extractEventText('text')).toBe('')
expect(parser.extractEventText(42)).toBe('')
})
})
describe('parseJsonOutput', () => {
it('concatenates text across multiple events, ignoring others', () => {
const provider = new OpencodeCliProvider()
const output = [
STEP_START_EVENT,
'{"type":"text","sessionID":"ses_abc","part":{"type":"text","text":"hello "}}',
'{"type":"tool.use","sessionID":"ses_abc"}',
'{"type":"text","sessionID":"ses_abc","part":{"type":"text","text":"world"}}',
].join('\n')
expect(asParser(provider).parseJsonOutput(output)).toBe('hello world')
})
it('skips blank lines and malformed JSON', () => {
const provider = new OpencodeCliProvider()
const output = [
'',
'not valid json',
TEXT_EVENT,
'{ partial',
'',
].join('\n')
expect(asParser(provider).parseJsonOutput(output)).toBe('ok')
})
it('returns empty when no text events are present', () => {
const provider = new OpencodeCliProvider()
expect(asParser(provider).parseJsonOutput(STEP_START_EVENT)).toBe('')
})
})
describe('session id capture', () => {
it('does not capture sessionID when sessions are disabled', () => {
const provider = new OpencodeCliProvider()
asParser(provider).parseJsonOutput(TEXT_EVENT)
expect(provider.sessionId).toBeUndefined()
})
it('captures sessionID from the first event after startSession', () => {
const provider = new OpencodeCliProvider()
provider.startSession('reviewer-1')
expect(provider.sessionId).toBeUndefined() // not pre-generated
asParser(provider).parseJsonOutput(STEP_START_EVENT)
expect(provider.sessionId).toBe('ses_abc')
})
it('does not overwrite a captured sessionID with a later event', () => {
const provider = new OpencodeCliProvider()
provider.startSession('reviewer-1')
asParser(provider).parseJsonOutput(STEP_START_EVENT)
const laterEvent = '{"type":"text","sessionID":"ses_different","part":{"type":"text","text":"x"}}'
asParser(provider).parseJsonOutput(laterEvent)
expect(provider.sessionId).toBe('ses_abc')
})
it('clears sessionID on endSession', () => {
const provider = new OpencodeCliProvider()
provider.startSession('reviewer-1')
asParser(provider).parseJsonOutput(TEXT_EVENT)
expect(provider.sessionId).toBe('ses_abc')
provider.endSession()
expect(provider.sessionId).toBeUndefined()
})
})
})
+22 -1
View File
@@ -1,5 +1,7 @@
import { describe, it, expect } from 'vitest'
import { existsSync } from 'fs'
import { existsSync, mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join, dirname } from 'path'
import { preparePromptForCli } from '../../src/utils/prompt-file.js'
describe('preparePromptForCli', () => {
@@ -23,4 +25,23 @@ describe('preparePromptForCli', () => {
result.cleanup()
expect(existsSync(tmpPath)).toBe(false)
})
it('writes the spilled prompt into the supplied tmpDir', () => {
const customDir = mkdtempSync(join(tmpdir(), 'magpie-tmpdir-test-'))
try {
const largePrompt = 'y'.repeat(200 * 1024)
const result = preparePromptForCli(largePrompt, { tmpDir: customDir })
const pathMatch = result.prompt.match(/\/.*magpie_prompt_\S+/)
expect(pathMatch).toBeTruthy()
const tmpPath = pathMatch![0]
expect(dirname(tmpPath)).toBe(customDir)
expect(existsSync(tmpPath)).toBe(true)
result.cleanup()
expect(existsSync(tmpPath)).toBe(false)
} finally {
rmSync(customDir, { recursive: true, force: true })
}
})
})