Compare commits
8 Commits
2163ea45d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bcd105694 | |||
| d1d25cebd2 | |||
| b53f731024 | |||
| 6dc85680ef | |||
| 41a4a8576c | |||
| a216750027 | |||
| 7433e5740d | |||
| fda54bad78 |
@@ -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) |
|
| `claude-code` | CLI | Claude Code CLI (uses your subscription, no API key) |
|
||||||
| `codex-cli` | CLI | OpenAI Codex 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) |
|
| `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) |
|
| `qwen-code` | CLI | Alibaba Qwen Code CLI (uses OAuth login, no API key) |
|
||||||
| `claude-*` | API | Anthropic API (requires ANTHROPIC_API_KEY) |
|
| `claude-*` | API | Anthropic API (requires ANTHROPIC_API_KEY) |
|
||||||
| `gpt-*` | API | OpenAI API (requires OPENAI_API_KEY) |
|
| `gpt-*` | API | OpenAI API (requires OPENAI_API_KEY) |
|
||||||
| `gemini-*` | API | Google Gemini API (requires GOOGLE_API_KEY) |
|
| `gemini-*` | API | Google Gemini API (requires GOOGLE_API_KEY) |
|
||||||
| `minimax` | API | MiniMax API (requires MINIMAX_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)) |
|
| `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.
|
**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
|
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
|
## Installation
|
||||||
|
|
||||||
```bash
|
```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
|
⠋ 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)
|
### 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:
|
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:
|
||||||
|
|||||||
+14
-12
@@ -231,6 +231,7 @@ async function runDiscussion(
|
|||||||
const isSoloDiscussion = reviewers.length === 1
|
const isSoloDiscussion = reviewers.length === 1
|
||||||
const maxRounds = isSoloDiscussion ? 1 : parseInt(options.rounds, 10)
|
const maxRounds = isSoloDiscussion ? 1 : parseInt(options.rounds, 10)
|
||||||
const checkConvergence = !isSoloDiscussion && options.converge !== false && (config.defaults.check_convergence !== false)
|
const checkConvergence = !isSoloDiscussion && options.converge !== false && (config.defaults.check_convergence !== false)
|
||||||
|
const showJokes = config.defaults.show_jokes !== false
|
||||||
|
|
||||||
const summarizer: Reviewer = {
|
const summarizer: Reviewer = {
|
||||||
id: 'summarizer',
|
id: 'summarizer',
|
||||||
@@ -322,29 +323,30 @@ async function runDiscussion(
|
|||||||
`${reviewerId} is thinking`
|
`${reviewerId} is thinking`
|
||||||
|
|
||||||
const updateSpinner = () => {
|
const updateSpinner = () => {
|
||||||
const joke = getRandomJoke()
|
if (!spinnerRef.spinner) return
|
||||||
if (spinnerRef.spinner) {
|
const jokeSuffix = showJokes ? ` ${chalk.dim(`| ${getRandomJoke()}`)}` : ''
|
||||||
if (spinnerRef.parallelStatuses && isParallelRound) {
|
if (spinnerRef.parallelStatuses && isParallelRound) {
|
||||||
const round = parseInt(reviewerId.split('-')[1])
|
const round = parseInt(reviewerId.split('-')[1])
|
||||||
const statusLine = formatParallelStatus(round, spinnerRef.parallelStatuses)
|
const statusLine = formatParallelStatus(round, spinnerRef.parallelStatuses)
|
||||||
spinnerRef.spinner.text = `${statusLine} ${chalk.dim(`| ${joke}`)}`
|
spinnerRef.spinner.text = `${statusLine}${jokeSuffix}`
|
||||||
} else {
|
} else {
|
||||||
spinnerRef.spinner.text = `${baseLabel}... ${chalk.dim(`| ${joke}`)}`
|
spinnerRef.spinner.text = `${baseLabel}...${jokeSuffix}`
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
spinnerRef.parallelStatuses = null
|
spinnerRef.parallelStatuses = null
|
||||||
spinnerRef.spinner = ora({ text: `${baseLabel}...`, discardStdin: false }).start()
|
spinnerRef.spinner = ora({ text: `${baseLabel}...`, discardStdin: false }).start()
|
||||||
updateSpinner()
|
updateSpinner()
|
||||||
spinnerRef.interval = setInterval(updateSpinner, 8000)
|
if (showJokes) {
|
||||||
|
spinnerRef.interval = setInterval(updateSpinner, 8000)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onParallelStatus: (round, statuses) => {
|
onParallelStatus: (round, statuses) => {
|
||||||
spinnerRef.parallelStatuses = statuses
|
spinnerRef.parallelStatuses = statuses
|
||||||
if (spinnerRef.spinner) {
|
if (spinnerRef.spinner) {
|
||||||
const joke = getRandomJoke()
|
const jokeSuffix = showJokes ? ` ${chalk.dim(`| ${getRandomJoke()}`)}` : ''
|
||||||
const statusLine = formatParallelStatus(round, statuses)
|
const statusLine = formatParallelStatus(round, statuses)
|
||||||
spinnerRef.spinner.text = `${statusLine} ${chalk.dim(`| ${joke}`)}`
|
spinnerRef.spinner.text = `${statusLine}${jokeSuffix}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMessage: (reviewerId, chunk) => {
|
onMessage: (reviewerId, chunk) => {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const initCommand = new Command('init')
|
|||||||
if (r.provider === 'anthropic') envVars.add('ANTHROPIC_API_KEY')
|
if (r.provider === 'anthropic') envVars.add('ANTHROPIC_API_KEY')
|
||||||
if (r.provider === 'openai') envVars.add('OPENAI_API_KEY')
|
if (r.provider === 'openai') envVars.add('OPENAI_API_KEY')
|
||||||
if (r.provider === 'google') envVars.add('GOOGLE_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}`))
|
envVars.forEach(v => console.log(` - ${v}`))
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-13
@@ -413,6 +413,7 @@ export const reviewCommand = new Command('review')
|
|||||||
const maxRounds = isSoloReview ? 1 : parseInt(options.rounds, 10)
|
const maxRounds = isSoloReview ? 1 : parseInt(options.rounds, 10)
|
||||||
// Convergence: disable for solo review; otherwise default from config, CLI can override with --no-converge
|
// 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 checkConvergence = !isSoloReview && options.converge !== false && (config.defaults.check_convergence !== false)
|
||||||
|
const showJokes = config.defaults.show_jokes !== false
|
||||||
|
|
||||||
console.log()
|
console.log()
|
||||||
console.log(chalk.bgBlue.white.bold(` ${target.label} Review `))
|
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)
|
// Show spinner with a joke (and parallel status if available)
|
||||||
const updateSpinner = () => {
|
const updateSpinner = () => {
|
||||||
const joke = getRandomJoke()
|
if (!spinnerRef.spinner) return
|
||||||
if (spinnerRef.spinner) {
|
const jokeSuffix = showJokes ? ` ${chalk.dim(`| ${getRandomJoke()}`)}` : ''
|
||||||
if (spinnerRef.parallelStatuses && isParallelRound) {
|
if (spinnerRef.parallelStatuses && isParallelRound) {
|
||||||
const round = parseInt(reviewerId.split('-')[1])
|
const round = parseInt(reviewerId.split('-')[1])
|
||||||
const statusLine = formatParallelStatus(round, spinnerRef.parallelStatuses)
|
const statusLine = formatParallelStatus(round, spinnerRef.parallelStatuses)
|
||||||
spinnerRef.spinner.text = `${statusLine} ${chalk.dim(`| ${joke}`)}`
|
spinnerRef.spinner.text = `${statusLine}${jokeSuffix}`
|
||||||
} else {
|
} else {
|
||||||
spinnerRef.spinner.text = `${baseLabel}... ${chalk.dim(`| ${joke}`)}`
|
spinnerRef.spinner.text = `${baseLabel}...${jokeSuffix}`
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
spinnerRef.parallelStatuses = null // Reset for new waiting phase
|
spinnerRef.parallelStatuses = null // Reset for new waiting phase
|
||||||
spinnerRef.spinner = ora({ text: `${baseLabel}...`, discardStdin: false }).start()
|
spinnerRef.spinner = ora({ text: `${baseLabel}...`, discardStdin: false }).start()
|
||||||
updateSpinner()
|
updateSpinner()
|
||||||
// Update joke every 15 seconds
|
if (showJokes) {
|
||||||
spinnerRef.interval = setInterval(updateSpinner, 15000)
|
spinnerRef.interval = setInterval(updateSpinner, 15000)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onParallelStatus: (round, statuses) => {
|
onParallelStatus: (round, statuses) => {
|
||||||
spinnerRef.parallelStatuses = statuses
|
spinnerRef.parallelStatuses = statuses
|
||||||
// Immediately update spinner to show new status
|
// Immediately update spinner to show new status
|
||||||
if (spinnerRef.spinner) {
|
if (spinnerRef.spinner) {
|
||||||
const joke = getRandomJoke()
|
const jokeSuffix = showJokes ? ` ${chalk.dim(`| ${getRandomJoke()}`)}` : ''
|
||||||
const statusLine = formatParallelStatus(round, statuses)
|
const statusLine = formatParallelStatus(round, statuses)
|
||||||
spinnerRef.spinner.text = `${statusLine} ${chalk.dim(`| ${joke}`)}`
|
spinnerRef.spinner.text = `${statusLine}${jokeSuffix}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMessage: (reviewerId, chunk) => {
|
onMessage: (reviewerId, chunk) => {
|
||||||
|
|||||||
+33
-2
@@ -9,7 +9,7 @@ export interface ReviewerOption {
|
|||||||
model: string
|
model: string
|
||||||
description: string
|
description: string
|
||||||
needsApiKey: boolean
|
needsApiKey: boolean
|
||||||
provider?: 'anthropic' | 'openai' | 'google'
|
provider?: 'anthropic' | 'openai' | 'google' | 'openrouter'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AVAILABLE_REVIEWERS: ReviewerOption[] = [
|
export const AVAILABLE_REVIEWERS: ReviewerOption[] = [
|
||||||
@@ -34,6 +34,21 @@ export const AVAILABLE_REVIEWERS: ReviewerOption[] = [
|
|||||||
description: 'Uses your Gemini CLI (Google account, no API key needed)',
|
description: 'Uses your Gemini CLI (Google account, no API key needed)',
|
||||||
needsApiKey: false
|
needsApiKey: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'antigravity',
|
||||||
|
name: 'Antigravity CLI',
|
||||||
|
model: 'antigravity',
|
||||||
|
description: 'Uses your Antigravity CLI (agy) — 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',
|
id: 'claude-api',
|
||||||
name: 'Claude Sonnet 4.5',
|
name: 'Claude Sonnet 4.5',
|
||||||
@@ -57,6 +72,14 @@ export const AVAILABLE_REVIEWERS: ReviewerOption[] = [
|
|||||||
description: 'Uses Google AI API (requires GOOGLE_API_KEY)',
|
description: 'Uses Google AI API (requires GOOGLE_API_KEY)',
|
||||||
needsApiKey: true,
|
needsApiKey: true,
|
||||||
provider: 'google'
|
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 +121,7 @@ export function generateConfig(selectedReviewerIds: string[]): string {
|
|||||||
const needsAnthropic = selectedReviewers.some(r => r.provider === 'anthropic')
|
const needsAnthropic = selectedReviewers.some(r => r.provider === 'anthropic')
|
||||||
const needsOpenai = selectedReviewers.some(r => r.provider === 'openai')
|
const needsOpenai = selectedReviewers.some(r => r.provider === 'openai')
|
||||||
const needsGoogle = selectedReviewers.some(r => r.provider === 'google')
|
const needsGoogle = selectedReviewers.some(r => r.provider === 'google')
|
||||||
|
const needsOpenrouter = selectedReviewers.some(r => r.provider === 'openrouter')
|
||||||
|
|
||||||
// Build providers section
|
// Build providers section
|
||||||
let providersSection = '# AI Provider API Keys (use environment variables)\nproviders:'
|
let providersSection = '# AI Provider API Keys (use environment variables)\nproviders:'
|
||||||
@@ -116,7 +140,13 @@ export function generateConfig(selectedReviewerIds: string[]): string {
|
|||||||
google:
|
google:
|
||||||
api_key: \${GOOGLE_API_KEY}`
|
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
|
providersSection += ' {}' // Empty providers if only CLI tools are used
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +172,7 @@ defaults:
|
|||||||
max_rounds: 5
|
max_rounds: 5
|
||||||
output_format: markdown
|
output_format: markdown
|
||||||
check_convergence: true # Stop early when reviewers reach consensus
|
check_convergence: true # Stop early when reviewers reach consensus
|
||||||
|
show_jokes: true # Show rotating programmer jokes in the spinner while waiting
|
||||||
|
|
||||||
${reviewersSection}
|
${reviewersSection}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface DefaultsConfig {
|
|||||||
check_convergence: boolean
|
check_convergence: boolean
|
||||||
language?: string // Output language (e.g., 'zh', 'en', 'ja')
|
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*')
|
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 {
|
export interface ContextGathererConfigOptions {
|
||||||
@@ -41,8 +42,10 @@ export interface MagpieConfig {
|
|||||||
google?: ProviderConfig
|
google?: ProviderConfig
|
||||||
'claude-code'?: { enabled: boolean }
|
'claude-code'?: { enabled: boolean }
|
||||||
'codex-cli'?: { enabled: boolean }
|
'codex-cli'?: { enabled: boolean }
|
||||||
|
'opencode-cli'?: { enabled: boolean }
|
||||||
'qwen-code'?: { enabled: boolean }
|
'qwen-code'?: { enabled: boolean }
|
||||||
minimax?: ProviderConfig
|
minimax?: ProviderConfig
|
||||||
|
openrouter?: ProviderConfig
|
||||||
}
|
}
|
||||||
mock?: boolean
|
mock?: boolean
|
||||||
defaults: DefaultsConfig
|
defaults: DefaultsConfig
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { spawn } from 'child_process'
|
||||||
|
import type { AIProvider, Message, CliProviderOptions } from './types.js'
|
||||||
|
import { CliSessionHelper } from './session-helper.js'
|
||||||
|
import { preparePromptForCli } from '../utils/prompt-file.js'
|
||||||
|
import { withRetry } from '../utils/retry.js'
|
||||||
|
|
||||||
|
// Antigravity (`agy`) is Google's rebrand of the Gemini CLI. Compared to `gemini`:
|
||||||
|
// - auto-approve is `--dangerously-skip-permissions` (was `-y`)
|
||||||
|
// - print mode emits plain text on stdout (no `-o json` / `-o stream-json`)
|
||||||
|
// - there is no `--model` flag; agy pins its own model, so cliModel is ignored
|
||||||
|
//
|
||||||
|
// Unlike gemini-cli, this provider is stateless: agy's `--conversation` resume
|
||||||
|
// re-prints the *entire* assistant transcript on every turn, which would duplicate
|
||||||
|
// prior responses. Instead each call runs a fresh conversation with the full
|
||||||
|
// message history in the prompt (the same approach the API providers use), so each
|
||||||
|
// run returns exactly one response. We therefore expose no session id.
|
||||||
|
export class AntigravityCliProvider implements AIProvider {
|
||||||
|
name = 'antigravity'
|
||||||
|
private cwd: string
|
||||||
|
private timeout: number // ms, 0 = no timeout
|
||||||
|
private formatter = new CliSessionHelper()
|
||||||
|
|
||||||
|
constructor(_options?: CliProviderOptions) {
|
||||||
|
// No API key needed (uses Google account). agy has no --model flag, so cliModel is ignored.
|
||||||
|
this.cwd = process.cwd()
|
||||||
|
this.timeout = 15 * 60 * 1000 // 15 minutes default
|
||||||
|
}
|
||||||
|
|
||||||
|
setCwd(cwd: string) {
|
||||||
|
this.cwd = cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(messages: Message[], systemPrompt?: string): Promise<string> {
|
||||||
|
const prompt = this.formatter.buildPrompt(messages, systemPrompt)
|
||||||
|
return withRetry(() => this.runAgy(prompt))
|
||||||
|
}
|
||||||
|
|
||||||
|
async *chatStream(messages: Message[], systemPrompt?: string): AsyncGenerator<string, void, unknown> {
|
||||||
|
const prompt = this.formatter.buildPrompt(messages, systemPrompt)
|
||||||
|
yield* this.runAgyStream(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildArgs(): string[] {
|
||||||
|
const args = ['--dangerously-skip-permissions']
|
||||||
|
if (this.timeout > 0) {
|
||||||
|
args.push('--print-timeout', `${Math.max(1, Math.ceil(this.timeout / 60000))}m`)
|
||||||
|
}
|
||||||
|
// Prompt is read from stdin via the `-` sentinel
|
||||||
|
args.push('--print', '-')
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
private runAgy(prompt: string): Promise<string> {
|
||||||
|
const { prompt: stdinPrompt, cleanup } = preparePromptForCli(prompt)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('agy', this.buildArgs(), {
|
||||||
|
cwd: this.cwd,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
|
||||||
|
// Suppress EPIPE: if child exits early, close handler reports the real error
|
||||||
|
child.stdin.on('error', () => {})
|
||||||
|
child.stdin.write(stdinPrompt)
|
||||||
|
child.stdin.end()
|
||||||
|
|
||||||
|
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(`Antigravity CLI exited with code ${code}: ${error}`))
|
||||||
|
} else {
|
||||||
|
resolve(output.trim())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error(`Failed to run agy CLI: ${err.message}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *runAgyStream(prompt: string): AsyncGenerator<string, void, unknown> {
|
||||||
|
const { prompt: stdinPrompt, cleanup } = preparePromptForCli(prompt)
|
||||||
|
|
||||||
|
const child = spawn('agy', this.buildArgs(), {
|
||||||
|
cwd: this.cwd,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
|
||||||
|
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 stderrBuf = ''
|
||||||
|
|
||||||
|
// Timeout checker - kill if no activity for too long
|
||||||
|
const timeoutChecker = this.timeout > 0 ? setInterval(() => {
|
||||||
|
if (Date.now() - lastActivity > this.timeout) {
|
||||||
|
child.kill('SIGTERM')
|
||||||
|
// Force kill if SIGTERM is ignored
|
||||||
|
const forceKill = setTimeout(() => {
|
||||||
|
try { child.kill('SIGKILL') } catch {}
|
||||||
|
}, 5000)
|
||||||
|
forceKill.unref()
|
||||||
|
done = true
|
||||||
|
const stderr = stderrBuf.trim()
|
||||||
|
error = new Error(`Antigravity CLI timed out after ${this.timeout / 1000}s of inactivity${stderr ? ': ' + stderr.slice(-500) : ''}`)
|
||||||
|
if (resolveNext) {
|
||||||
|
resolveNext({ chunk: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 10000) : null // Check every 10s
|
||||||
|
|
||||||
|
const pushChunk = (chunk: string) => {
|
||||||
|
if (resolveNext) {
|
||||||
|
resolveNext({ chunk })
|
||||||
|
resolveNext = null
|
||||||
|
} else {
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print mode streams the plain-text response on stdout — yield chunks as-is
|
||||||
|
child.stdout.on('data', (data) => {
|
||||||
|
lastActivity = Date.now()
|
||||||
|
pushChunk(data.toString())
|
||||||
|
})
|
||||||
|
|
||||||
|
child.stderr.on('data', (data) => {
|
||||||
|
lastActivity = Date.now() // Activity on stderr also counts
|
||||||
|
stderrBuf += data.toString()
|
||||||
|
if (stderrBuf.length > 10000) stderrBuf = stderrBuf.slice(-10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
cleanup()
|
||||||
|
if (timeoutChecker) clearInterval(timeoutChecker)
|
||||||
|
done = true
|
||||||
|
if (code !== 0 && !error) {
|
||||||
|
const stderr = stderrBuf.trim()
|
||||||
|
error = new Error(`Antigravity CLI exited with code ${code}${stderr ? ': ' + stderr.slice(-500) : ''}`)
|
||||||
|
}
|
||||||
|
if (resolveNext) {
|
||||||
|
resolveNext({ chunk: null })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
cleanup()
|
||||||
|
if (timeoutChecker) clearInterval(timeoutChecker)
|
||||||
|
done = true
|
||||||
|
error = new Error(`Failed to run agy CLI: ${err.message}`)
|
||||||
|
if (resolveNext) {
|
||||||
|
resolveNext({ chunk: null })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Write prompt to stdin and close
|
||||||
|
// Suppress EPIPE: if child exits early, close handler reports the real error
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ import { CliSessionHelper } from './session-helper.js'
|
|||||||
import { preparePromptForCli } from '../utils/prompt-file.js'
|
import { preparePromptForCli } from '../utils/prompt-file.js'
|
||||||
import { withRetry } from '../utils/retry.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 {
|
export class ClaudeCodeProvider implements AIProvider {
|
||||||
name = 'claude-code'
|
name = 'claude-code'
|
||||||
private cwd: string
|
private cwd: string
|
||||||
@@ -74,8 +80,7 @@ export class ClaudeCodeProvider implements AIProvider {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Build args based on session state
|
// Build args based on session state
|
||||||
// Use --dangerously-skip-permissions to allow network access (e.g., gh commands)
|
const args = ['-p', '-', '--effort', 'xhigh', '--allowed-tools', ALLOWED_TOOLS]
|
||||||
const args = ['-p', '-', '--dangerously-skip-permissions', '--effort', 'max']
|
|
||||||
if (this.cliModel) {
|
if (this.cliModel) {
|
||||||
args.push('--model', 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> {
|
private async *runClaudeStream(prompt: string, systemPrompt?: string): AsyncGenerator<string, void, unknown> {
|
||||||
const { prompt: stdinPrompt, cleanup } = preparePromptForCli(prompt)
|
const { prompt: stdinPrompt, cleanup } = preparePromptForCli(prompt)
|
||||||
|
|
||||||
// Build args based on session state
|
// Build args based on session state.
|
||||||
// Use --dangerously-skip-permissions to allow network access (e.g., gh commands)
|
|
||||||
// Use --output-format stream-json --verbose so that tool activity (Read, Bash, etc.)
|
// Use --output-format stream-json --verbose so that tool activity (Read, Bash, etc.)
|
||||||
// produces stdout events, preventing the inactivity timeout from killing Claude
|
// produces stdout events, preventing the inactivity timeout from killing Claude
|
||||||
// while it's actively investigating code.
|
// 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) {
|
if (this.cliModel) {
|
||||||
args.push('--model', this.cliModel)
|
args.push('--model', this.cliModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,14 @@ export class CodexCliProvider implements AIProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildArgs(): string[] {
|
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) {
|
if (this.cliModel) {
|
||||||
baseArgs.push('--model', this.cliModel)
|
baseArgs.push('--model', this.cliModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { OpenAIProvider } from './openai.js'
|
|||||||
import { ClaudeCodeProvider } from './claude-code.js'
|
import { ClaudeCodeProvider } from './claude-code.js'
|
||||||
import { CodexCliProvider } from './codex-cli.js'
|
import { CodexCliProvider } from './codex-cli.js'
|
||||||
import { GeminiCliProvider } from './gemini-cli.js'
|
import { GeminiCliProvider } from './gemini-cli.js'
|
||||||
|
import { AntigravityCliProvider } from './antigravity.js'
|
||||||
import { GeminiProvider } from './gemini.js'
|
import { GeminiProvider } from './gemini.js'
|
||||||
|
import { OpencodeCliProvider } from './opencode-cli.js'
|
||||||
import { QwenCodeProvider } from './qwen-code.js'
|
import { QwenCodeProvider } from './qwen-code.js'
|
||||||
import { MiniMaxProvider } from './minimax.js'
|
import { MiniMaxProvider } from './minimax.js'
|
||||||
import { MockProvider } from './mock.js'
|
import { MockProvider } from './mock.js'
|
||||||
@@ -14,9 +16,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' }
|
// 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 }
|
// 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', 'antigravity', 'opencode-cli', 'qwen-code'] as const
|
||||||
type CliProviderName = typeof CLI_PROVIDERS[number]
|
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 } {
|
export function parseCliModel(model: string): { provider: string; cliModel?: string } {
|
||||||
for (const cli of CLI_PROVIDERS) {
|
for (const cli of CLI_PROVIDERS) {
|
||||||
if (model === cli) {
|
if (model === cli) {
|
||||||
@@ -35,7 +48,10 @@ export function isCliModel(model: string): boolean {
|
|||||||
return (CLI_PROVIDERS as readonly string[]).includes(provider)
|
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' | 'antigravity' | 'opencode-cli' | 'qwen-code' | 'minimax' | 'mock' | 'openrouter' {
|
||||||
|
if (model.startsWith(OPENROUTER_PREFIX)) {
|
||||||
|
return 'openrouter'
|
||||||
|
}
|
||||||
const { provider } = parseCliModel(model)
|
const { provider } = parseCliModel(model)
|
||||||
if ((CLI_PROVIDERS as readonly string[]).includes(provider)) {
|
if ((CLI_PROVIDERS as readonly string[]).includes(provider)) {
|
||||||
return provider as CliProviderName
|
return provider as CliProviderName
|
||||||
@@ -86,17 +102,53 @@ export function createProvider(model: string, config: MagpieConfig): AIProvider
|
|||||||
return new GeminiCliProvider({ cliModel })
|
return new GeminiCliProvider({ cliModel })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Antigravity CLI (`agy`) doesn't need API key config (uses Google account)
|
||||||
|
if (providerName === 'antigravity') {
|
||||||
|
checkCliBinary('agy', 'Antigravity')
|
||||||
|
return new AntigravityCliProvider({ cliModel })
|
||||||
|
}
|
||||||
|
|
||||||
// Qwen Code CLI doesn't need API key config (uses OAuth)
|
// Qwen Code CLI doesn't need API key config (uses OAuth)
|
||||||
if (providerName === 'qwen-code') {
|
if (providerName === 'qwen-code') {
|
||||||
checkCliBinary('qwen', 'Qwen Code')
|
checkCliBinary('qwen', 'Qwen Code')
|
||||||
return new QwenCodeProvider({ cliModel })
|
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
|
// Mock provider for debug mode — no API key needed
|
||||||
if (providerName === 'mock') {
|
if (providerName === 'mock') {
|
||||||
return new MockProvider()
|
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
|
// MiniMax uses API key from config or env
|
||||||
if (providerName === 'minimax') {
|
if (providerName === 'minimax') {
|
||||||
const providerConfig = config.providers['minimax']
|
const providerConfig = config.providers['minimax']
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { homedir } from 'os'
|
|||||||
const PROVIDER_CONTEXT_MAP: Record<string, string[]> = {
|
const PROVIDER_CONTEXT_MAP: Record<string, string[]> = {
|
||||||
'claude-code': ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md'],
|
'claude-code': ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md'],
|
||||||
'gemini-cli': ['GEMINI.md', 'AGENTS.md', 'CLAUDE.md'],
|
'gemini-cli': ['GEMINI.md', 'AGENTS.md', 'CLAUDE.md'],
|
||||||
|
'antigravity': ['GEMINI.md', 'AGENTS.md', 'CLAUDE.md'],
|
||||||
'codex-cli': ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'],
|
'codex-cli': ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'],
|
||||||
}
|
}
|
||||||
const DEFAULT_CONTEXT_FILES = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md']
|
const DEFAULT_CONTEXT_FILES = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md']
|
||||||
|
|||||||
@@ -23,14 +23,25 @@ export interface PreparedPrompt {
|
|||||||
cleanup: () => void
|
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) {
|
if (Buffer.byteLength(prompt, 'utf-8') <= PROMPT_SIZE_THRESHOLD) {
|
||||||
return { prompt, cleanup: () => {} }
|
return { prompt, cleanup: () => {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
registerExitHandler()
|
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')
|
writeFileSync(tmpFile, prompt, 'utf-8')
|
||||||
activeTempFiles.add(tmpFile)
|
activeTempFiles.add(tmpFile)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
// tests/providers/factory.test.ts
|
// 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 { createProvider, getProviderForModel } from '../../src/providers/factory.js'
|
||||||
import type { MagpieConfig } from '../../src/config/types.js'
|
import type { MagpieConfig } from '../../src/config/types.js'
|
||||||
|
|
||||||
describe('Provider Factory', () => {
|
describe('Provider Factory', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
const mockConfig: MagpieConfig = {
|
const mockConfig: MagpieConfig = {
|
||||||
providers: {
|
providers: {
|
||||||
anthropic: { api_key: 'ant-key' },
|
anthropic: { api_key: 'ant-key' },
|
||||||
@@ -39,6 +43,21 @@ describe('Provider Factory', () => {
|
|||||||
it('should return codex-cli for codex-cli model', () => {
|
it('should return codex-cli for codex-cli model', () => {
|
||||||
expect(getProviderForModel('codex-cli')).toBe('codex-cli')
|
expect(getProviderForModel('codex-cli')).toBe('codex-cli')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should return antigravity for antigravity model', () => {
|
||||||
|
expect(getProviderForModel('antigravity')).toBe('antigravity')
|
||||||
|
})
|
||||||
|
|
||||||
|
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', () => {
|
describe('createProvider', () => {
|
||||||
@@ -76,6 +95,21 @@ describe('Provider Factory', () => {
|
|||||||
expect(provider.name).toBe('codex-cli')
|
expect(provider.name).toBe('codex-cli')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should create antigravity provider', () => {
|
||||||
|
const provider = createProvider('antigravity', mockConfig)
|
||||||
|
expect(provider.name).toBe('antigravity')
|
||||||
|
})
|
||||||
|
|
||||||
|
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', () => {
|
it('should pass base_url through to API providers', () => {
|
||||||
const configWithBaseUrl: MagpieConfig = {
|
const configWithBaseUrl: MagpieConfig = {
|
||||||
...mockConfig,
|
...mockConfig,
|
||||||
@@ -95,5 +129,28 @@ describe('Provider Factory', () => {
|
|||||||
const provider = createProvider('claude-sonnet-4-20250514', mockConfig)
|
const provider = createProvider('claude-sonnet-4-20250514', mockConfig)
|
||||||
expect(provider.name).toBe('anthropic')
|
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/)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { OpenAIProvider } from '../../src/providers/openai'
|
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 lastConstructorOptions: Record<string, unknown> = {}
|
||||||
|
let lastCreateOptions: Record<string, unknown> = {}
|
||||||
|
|
||||||
vi.mock('openai', () => ({
|
vi.mock('openai', () => ({
|
||||||
default: class MockOpenAI {
|
default: class MockOpenAI {
|
||||||
chat = {
|
chat = {
|
||||||
completions: {
|
completions: {
|
||||||
create: vi.fn().mockResolvedValue({
|
create: vi.fn().mockImplementation((opts: Record<string, unknown>) => {
|
||||||
choices: [{ message: { content: 'Mock response' } }]
|
lastCreateOptions = opts
|
||||||
|
return Promise.resolve({
|
||||||
|
choices: [{ message: { content: 'Mock response' } }]
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,3 +46,49 @@ describe('OpenAIProvider', () => {
|
|||||||
expect(lastConstructorOptions.baseURL).toBeUndefined()
|
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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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'
|
import { preparePromptForCli } from '../../src/utils/prompt-file.js'
|
||||||
|
|
||||||
describe('preparePromptForCli', () => {
|
describe('preparePromptForCli', () => {
|
||||||
@@ -23,4 +25,23 @@ describe('preparePromptForCli', () => {
|
|||||||
result.cleanup()
|
result.cleanup()
|
||||||
expect(existsSync(tmpPath)).toBe(false)
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user