mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
feat: implement modular provider architecture with Codex CLI support
Implements a flexible provider pattern that supports both Claude Agent SDK and OpenAI Codex CLI, enabling future expansion to other AI providers (Cursor, OpenCode, etc.) with minimal changes. ## Architecture Changes ### New Provider System - Created provider abstraction layer with BaseProvider interface - Model-based routing: model prefix determines provider - `gpt-*`, `o*` → CodexProvider (subprocess CLI) - `claude-*`, `opus/sonnet/haiku` → ClaudeProvider (SDK) - Providers implement common ExecuteOptions interface ### New Files Created - `providers/types.ts` - Shared interfaces (ExecuteOptions, ProviderMessage, etc.) - `providers/base-provider.ts` - Abstract base class - `providers/claude-provider.ts` - Claude Agent SDK wrapper - `providers/codex-provider.ts` - Codex CLI subprocess executor - `providers/codex-cli-detector.ts` - Installation & auth detection - `providers/codex-config-manager.ts` - TOML config management - `providers/provider-factory.ts` - Model-based provider routing - `lib/subprocess-manager.ts` - Reusable subprocess utilities ## Features Implemented ### Codex CLI Integration - Spawns Codex CLI as subprocess with JSONL output - Converts Codex events to Claude SDK-compatible format - Supports both `codex login` and OPENAI_API_KEY auth methods - Handles: reasoning, messages, commands, todos, file changes - Extracts text from content blocks for non-vision CLI ### Conversation History - Added conversationHistory support to ExecuteOptions - ClaudeProvider: yields previous messages to SDK - CodexProvider: prepends history as text context - Follow-up prompts maintain full conversation context ### Image Upload Support - Images embedded as base64 for vision models - Image paths appended to prompt text for Read tool access - Auto-mode: copies images to feature folder - Follow-up: combines original + new images - Updates feature.json with image metadata ### Session Model Persistence - Added `model` field to Session and SessionMetadata - Sessions remember model preference across interactions - API endpoints accept model parameter - Auto-mode respects feature's model setting ## Modified Files ### Services - `agent-service.ts`: - Added conversation history building - Uses ProviderFactory instead of direct SDK calls - Appends image paths to prompts - Added model parameter and persistence - `auto-mode-service.ts`: - Removed OpenAI model block restriction - Uses ProviderFactory for all models - Added image support in buildFeaturePrompt - Follow-up: loads context, copies images, updates feature.json - Returns to waiting_approval after follow-up ### Routes - `agent.ts`: Added model parameter to /send endpoint - `sessions.ts`: Added model field to create/update - `models.ts`: Added Codex models (gpt-5.2, gpt-5.1-codex*) ### Configuration - `.env.example`: Added OPENAI_API_KEY and CODEX_CLI_PATH - `.gitignore`: Added provider-specific ignores ## Bug Fixes - Fixed image path resolution (relative → absolute) - Fixed Codex empty prompt when images attached - Fixed follow-up status management (in_progress → waiting_approval) - Fixed follow-up images not appearing in prompt text - Removed OpenAI model restrictions in auto-mode ## Testing Notes - Codex CLI authentication verified with both methods - Image uploads work for both Claude (vision) and Codex (Read tool) - Follow-up prompts maintain full context - Conversation history persists across turns - Model switching works per-session 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,3 +12,5 @@ node_modules
|
|||||||
.automaker/
|
.automaker/
|
||||||
/.automaker/*
|
/.automaker/*
|
||||||
/.automaker/
|
/.automaker/
|
||||||
|
|
||||||
|
/old
|
||||||
@@ -38,8 +38,12 @@ DATA_DIR=./data
|
|||||||
# OPTIONAL - Additional AI Providers
|
# OPTIONAL - Additional AI Providers
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# OpenAI API key (for Codex CLI support)
|
# OpenAI API key for GPT/Codex models (gpt-5.2, gpt-5.1-codex, etc.)
|
||||||
|
# Codex CLI must be installed: npm install -g @openai/codex@latest
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# Optional: Override Codex CLI path (auto-detected by default)
|
||||||
|
# CODEX_CLI_PATH=/usr/local/bin/codex
|
||||||
|
|
||||||
# Google API key (for future Gemini support)
|
# Google API key (for future Gemini support)
|
||||||
GOOGLE_API_KEY=
|
GOOGLE_API_KEY=
|
||||||
|
|||||||
202
apps/server/src/lib/subprocess-manager.ts
Normal file
202
apps/server/src/lib/subprocess-manager.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Subprocess management utilities for CLI providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, type ChildProcess } from "child_process";
|
||||||
|
import readline from "readline";
|
||||||
|
|
||||||
|
export interface SubprocessOptions {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
cwd: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
abortController?: AbortController;
|
||||||
|
timeout?: number; // Milliseconds of no output before timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubprocessResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns a subprocess and streams JSONL output line-by-line
|
||||||
|
*/
|
||||||
|
export async function* spawnJSONLProcess(
|
||||||
|
options: SubprocessOptions
|
||||||
|
): AsyncGenerator<unknown> {
|
||||||
|
const { command, args, cwd, env, abortController, timeout = 30000 } = options;
|
||||||
|
|
||||||
|
const processEnv = {
|
||||||
|
...process.env,
|
||||||
|
...env,
|
||||||
|
};
|
||||||
|
|
||||||
|
const childProcess: ChildProcess = spawn(command, args, {
|
||||||
|
cwd,
|
||||||
|
env: processEnv,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderrOutput = "";
|
||||||
|
let lastOutputTime = Date.now();
|
||||||
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// Collect stderr for error reporting
|
||||||
|
if (childProcess.stderr) {
|
||||||
|
childProcess.stderr.on("data", (data: Buffer) => {
|
||||||
|
const text = data.toString();
|
||||||
|
stderrOutput += text;
|
||||||
|
console.error(`[SubprocessManager] stderr: ${text}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup timeout detection
|
||||||
|
const resetTimeout = () => {
|
||||||
|
lastOutputTime = Date.now();
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
const elapsed = Date.now() - lastOutputTime;
|
||||||
|
if (elapsed >= timeout) {
|
||||||
|
console.error(
|
||||||
|
`[SubprocessManager] Process timeout: no output for ${timeout}ms`
|
||||||
|
);
|
||||||
|
childProcess.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
resetTimeout();
|
||||||
|
|
||||||
|
// Setup abort handling
|
||||||
|
if (abortController) {
|
||||||
|
abortController.signal.addEventListener("abort", () => {
|
||||||
|
console.log("[SubprocessManager] Abort signal received, killing process");
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
childProcess.kill("SIGTERM");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse stdout as JSONL (one JSON object per line)
|
||||||
|
if (childProcess.stdout) {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: childProcess.stdout,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const line of rl) {
|
||||||
|
resetTimeout();
|
||||||
|
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
yield parsed;
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error(
|
||||||
|
`[SubprocessManager] Failed to parse JSONL line: ${line}`,
|
||||||
|
parseError
|
||||||
|
);
|
||||||
|
// Yield error but continue processing
|
||||||
|
yield {
|
||||||
|
type: "error",
|
||||||
|
error: `Failed to parse output: ${line}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SubprocessManager] Error reading stdout:", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for process to exit
|
||||||
|
const exitCode = await new Promise<number | null>((resolve) => {
|
||||||
|
childProcess.on("exit", (code) => {
|
||||||
|
resolve(code);
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.on("error", (error) => {
|
||||||
|
console.error("[SubprocessManager] Process error:", error);
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle non-zero exit codes
|
||||||
|
if (exitCode !== 0 && exitCode !== null) {
|
||||||
|
const errorMessage = stderrOutput || `Process exited with code ${exitCode}`;
|
||||||
|
console.error(`[SubprocessManager] Process failed: ${errorMessage}`);
|
||||||
|
yield {
|
||||||
|
type: "error",
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process completed successfully
|
||||||
|
if (exitCode === 0 && !stderrOutput) {
|
||||||
|
// Success - no logging needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns a subprocess and collects all output
|
||||||
|
*/
|
||||||
|
export async function spawnProcess(
|
||||||
|
options: SubprocessOptions
|
||||||
|
): Promise<SubprocessResult> {
|
||||||
|
const { command, args, cwd, env, abortController } = options;
|
||||||
|
|
||||||
|
const processEnv = {
|
||||||
|
...process.env,
|
||||||
|
...env,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const childProcess = spawn(command, args, {
|
||||||
|
cwd,
|
||||||
|
env: processEnv,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
if (childProcess.stdout) {
|
||||||
|
childProcess.stdout.on("data", (data: Buffer) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childProcess.stderr) {
|
||||||
|
childProcess.stderr.on("data", (data: Buffer) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup abort handling
|
||||||
|
if (abortController) {
|
||||||
|
abortController.signal.addEventListener("abort", () => {
|
||||||
|
childProcess.kill("SIGTERM");
|
||||||
|
reject(new Error("Process aborted"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
childProcess.on("exit", (code) => {
|
||||||
|
resolve({ stdout, stderr, exitCode: code });
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.on("error", (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
96
apps/server/src/providers/base-provider.ts
Normal file
96
apps/server/src/providers/base-provider.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Abstract base class for AI model providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProviderConfig,
|
||||||
|
ExecuteOptions,
|
||||||
|
ProviderMessage,
|
||||||
|
InstallationStatus,
|
||||||
|
ValidationResult,
|
||||||
|
ModelDefinition,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base provider class that all provider implementations must extend
|
||||||
|
*/
|
||||||
|
export abstract class BaseProvider {
|
||||||
|
protected config: ProviderConfig;
|
||||||
|
protected name: string;
|
||||||
|
|
||||||
|
constructor(config: ProviderConfig = {}) {
|
||||||
|
this.config = config;
|
||||||
|
this.name = this.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the provider name (e.g., "claude", "codex", "cursor")
|
||||||
|
*/
|
||||||
|
abstract getName(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query and stream responses
|
||||||
|
* @param options Execution options
|
||||||
|
* @returns AsyncGenerator yielding provider messages
|
||||||
|
*/
|
||||||
|
abstract executeQuery(
|
||||||
|
options: ExecuteOptions
|
||||||
|
): AsyncGenerator<ProviderMessage>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if the provider is installed and configured
|
||||||
|
* @returns Installation status
|
||||||
|
*/
|
||||||
|
abstract detectInstallation(): Promise<InstallationStatus>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available models for this provider
|
||||||
|
* @returns Array of model definitions
|
||||||
|
*/
|
||||||
|
abstract getAvailableModels(): ModelDefinition[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the provider configuration
|
||||||
|
* @returns Validation result
|
||||||
|
*/
|
||||||
|
validateConfig(): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Base validation (can be overridden)
|
||||||
|
if (!this.config) {
|
||||||
|
errors.push("Provider config is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the provider supports a specific feature
|
||||||
|
* @param feature Feature name (e.g., "vision", "tools", "mcp")
|
||||||
|
* @returns Whether the feature is supported
|
||||||
|
*/
|
||||||
|
supportsFeature(feature: string): boolean {
|
||||||
|
// Default implementation - override in subclasses
|
||||||
|
const commonFeatures = ["tools", "text"];
|
||||||
|
return commonFeatures.includes(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider configuration
|
||||||
|
*/
|
||||||
|
getConfig(): ProviderConfig {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update provider configuration
|
||||||
|
*/
|
||||||
|
setConfig(config: Partial<ProviderConfig>): void {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
}
|
||||||
|
}
|
||||||
202
apps/server/src/providers/claude-provider.ts
Normal file
202
apps/server/src/providers/claude-provider.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Claude Provider - Executes queries using Claude Agent SDK
|
||||||
|
*
|
||||||
|
* Wraps the @anthropic-ai/claude-agent-sdk for seamless integration
|
||||||
|
* with the provider architecture.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
import { BaseProvider } from "./base-provider.js";
|
||||||
|
import type {
|
||||||
|
ExecuteOptions,
|
||||||
|
ProviderMessage,
|
||||||
|
InstallationStatus,
|
||||||
|
ModelDefinition,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export class ClaudeProvider extends BaseProvider {
|
||||||
|
getName(): string {
|
||||||
|
return "claude";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query using Claude Agent SDK
|
||||||
|
*/
|
||||||
|
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||||
|
const {
|
||||||
|
prompt,
|
||||||
|
model,
|
||||||
|
cwd,
|
||||||
|
systemPrompt,
|
||||||
|
maxTurns = 20,
|
||||||
|
allowedTools,
|
||||||
|
abortController,
|
||||||
|
conversationHistory,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Build Claude SDK options
|
||||||
|
const sdkOptions: Options = {
|
||||||
|
model,
|
||||||
|
systemPrompt,
|
||||||
|
maxTurns,
|
||||||
|
cwd,
|
||||||
|
allowedTools: allowedTools || [
|
||||||
|
"Read",
|
||||||
|
"Write",
|
||||||
|
"Edit",
|
||||||
|
"Glob",
|
||||||
|
"Grep",
|
||||||
|
"Bash",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch",
|
||||||
|
],
|
||||||
|
permissionMode: "acceptEdits",
|
||||||
|
sandbox: {
|
||||||
|
enabled: true,
|
||||||
|
autoAllowBashIfSandboxed: true,
|
||||||
|
},
|
||||||
|
abortController,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build prompt payload with conversation history
|
||||||
|
let promptPayload: string | AsyncGenerator<any, void, unknown>;
|
||||||
|
|
||||||
|
if (conversationHistory && conversationHistory.length > 0) {
|
||||||
|
// Multi-turn conversation with history
|
||||||
|
promptPayload = (async function* () {
|
||||||
|
// Yield all previous messages first
|
||||||
|
for (const historyMsg of conversationHistory) {
|
||||||
|
yield {
|
||||||
|
type: historyMsg.role,
|
||||||
|
session_id: "",
|
||||||
|
message: {
|
||||||
|
role: historyMsg.role,
|
||||||
|
content: Array.isArray(historyMsg.content)
|
||||||
|
? historyMsg.content
|
||||||
|
: [{ type: "text", text: historyMsg.content }],
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield current prompt
|
||||||
|
yield {
|
||||||
|
type: "user" as const,
|
||||||
|
session_id: "",
|
||||||
|
message: {
|
||||||
|
role: "user" as const,
|
||||||
|
content: Array.isArray(prompt)
|
||||||
|
? prompt
|
||||||
|
: [{ type: "text", text: prompt }],
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
} else if (Array.isArray(prompt)) {
|
||||||
|
// Multi-part prompt (with images) - no history
|
||||||
|
promptPayload = (async function* () {
|
||||||
|
yield {
|
||||||
|
type: "user" as const,
|
||||||
|
session_id: "",
|
||||||
|
message: {
|
||||||
|
role: "user" as const,
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
|
// Simple text prompt - no history
|
||||||
|
promptPayload = prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute via Claude Agent SDK
|
||||||
|
const stream = query({ prompt: promptPayload, options: sdkOptions });
|
||||||
|
|
||||||
|
// Stream messages directly - they're already in the correct format
|
||||||
|
for await (const msg of stream) {
|
||||||
|
yield msg as ProviderMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect Claude SDK installation (always available via npm)
|
||||||
|
*/
|
||||||
|
async detectInstallation(): Promise<InstallationStatus> {
|
||||||
|
// Claude SDK is always available since it's a dependency
|
||||||
|
const hasApiKey =
|
||||||
|
!!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
method: "sdk",
|
||||||
|
hasApiKey,
|
||||||
|
authenticated: hasApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available Claude models
|
||||||
|
*/
|
||||||
|
getAvailableModels(): ModelDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "claude-opus-4-5-20251101",
|
||||||
|
name: "Claude Opus 4.5",
|
||||||
|
modelString: "claude-opus-4-5-20251101",
|
||||||
|
provider: "anthropic",
|
||||||
|
description: "Most capable Claude model",
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxOutputTokens: 16000,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "premium",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4-20250514",
|
||||||
|
name: "Claude Sonnet 4",
|
||||||
|
modelString: "claude-sonnet-4-20250514",
|
||||||
|
provider: "anthropic",
|
||||||
|
description: "Balanced performance and cost",
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxOutputTokens: 16000,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "standard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-3-5-sonnet-20241022",
|
||||||
|
name: "Claude 3.5 Sonnet",
|
||||||
|
modelString: "claude-3-5-sonnet-20241022",
|
||||||
|
provider: "anthropic",
|
||||||
|
description: "Fast and capable",
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxOutputTokens: 8000,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "standard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-3-5-haiku-20241022",
|
||||||
|
name: "Claude 3.5 Haiku",
|
||||||
|
modelString: "claude-3-5-haiku-20241022",
|
||||||
|
provider: "anthropic",
|
||||||
|
description: "Fastest Claude model",
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxOutputTokens: 8000,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "basic",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the provider supports a specific feature
|
||||||
|
*/
|
||||||
|
supportsFeature(feature: string): boolean {
|
||||||
|
const supportedFeatures = ["tools", "text", "vision", "thinking"];
|
||||||
|
return supportedFeatures.includes(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
408
apps/server/src/providers/codex-cli-detector.ts
Normal file
408
apps/server/src/providers/codex-cli-detector.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* Codex CLI Detector - Checks if OpenAI Codex CLI is installed
|
||||||
|
*
|
||||||
|
* Codex CLI is OpenAI's agent CLI tool that allows users to use
|
||||||
|
* GPT-5.1/5.2 Codex models for code generation and agentic tasks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import type { InstallationStatus } from "./types.js";
|
||||||
|
|
||||||
|
export class CodexCliDetector {
|
||||||
|
/**
|
||||||
|
* Get the path to Codex config directory
|
||||||
|
*/
|
||||||
|
static getConfigDir(): string {
|
||||||
|
return path.join(os.homedir(), ".codex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to Codex auth file
|
||||||
|
*/
|
||||||
|
static getAuthPath(): string {
|
||||||
|
return path.join(this.getConfigDir(), "auth.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Codex authentication status
|
||||||
|
*/
|
||||||
|
static checkAuth(): {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: string;
|
||||||
|
hasAuthFile?: boolean;
|
||||||
|
hasEnvKey?: boolean;
|
||||||
|
authPath?: string;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
try {
|
||||||
|
const authPath = this.getAuthPath();
|
||||||
|
const envApiKey = process.env.OPENAI_API_KEY;
|
||||||
|
|
||||||
|
// Try to verify authentication using codex CLI command if available
|
||||||
|
try {
|
||||||
|
const detection = this.detectCodexInstallation();
|
||||||
|
if (detection.installed && detection.path) {
|
||||||
|
try {
|
||||||
|
// Use 2>&1 to capture both stdout and stderr
|
||||||
|
const statusOutput = execSync(
|
||||||
|
`"${detection.path}" login status 2>&1`,
|
||||||
|
{
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 5000,
|
||||||
|
}
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
// Check if the output indicates logged in status
|
||||||
|
if (
|
||||||
|
statusOutput &&
|
||||||
|
(statusOutput.includes("Logged in") || statusOutput.includes("Authenticated"))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: "cli_verified",
|
||||||
|
hasAuthFile: fs.existsSync(authPath),
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (statusError) {
|
||||||
|
// status command failed, continue with file-based check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (verifyError) {
|
||||||
|
// CLI verification failed, continue with file-based check
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if auth file exists
|
||||||
|
if (fs.existsSync(authPath)) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(authPath, "utf-8");
|
||||||
|
const auth: any = JSON.parse(content);
|
||||||
|
|
||||||
|
// Check for token object structure
|
||||||
|
if (auth.token && typeof auth.token === "object") {
|
||||||
|
const token = auth.token;
|
||||||
|
if (
|
||||||
|
token.Id_token ||
|
||||||
|
token.access_token ||
|
||||||
|
token.refresh_token ||
|
||||||
|
token.id_token
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: "cli_tokens",
|
||||||
|
hasAuthFile: true,
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for tokens at root level
|
||||||
|
if (
|
||||||
|
auth.access_token ||
|
||||||
|
auth.refresh_token ||
|
||||||
|
auth.Id_token ||
|
||||||
|
auth.id_token
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: "cli_tokens",
|
||||||
|
hasAuthFile: true,
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for API key fields
|
||||||
|
if (auth.api_key || auth.openai_api_key || auth.apiKey) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: "auth_file",
|
||||||
|
hasAuthFile: true,
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
method: "none",
|
||||||
|
hasAuthFile: false,
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment variable override
|
||||||
|
if (envApiKey) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: "env",
|
||||||
|
hasAuthFile: fs.existsSync(authPath),
|
||||||
|
hasEnvKey: true,
|
||||||
|
authPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
method: "none",
|
||||||
|
hasAuthFile: fs.existsSync(authPath),
|
||||||
|
hasEnvKey: false,
|
||||||
|
authPath,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
method: "none",
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Codex CLI is installed and accessible
|
||||||
|
*/
|
||||||
|
static detectCodexInstallation(): InstallationStatus & {
|
||||||
|
hasApiKey?: boolean;
|
||||||
|
} {
|
||||||
|
try {
|
||||||
|
// Method 1: Check if 'codex' command is in PATH
|
||||||
|
try {
|
||||||
|
const codexPath = execSync("which codex 2>/dev/null", {
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
if (codexPath) {
|
||||||
|
const version = this.getCodexVersion(codexPath);
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
path: codexPath,
|
||||||
|
version: version || undefined,
|
||||||
|
method: "cli",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// CLI not in PATH, continue checking other methods
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Check for npm global installation
|
||||||
|
try {
|
||||||
|
const npmListOutput = execSync(
|
||||||
|
"npm list -g @openai/codex --depth=0 2>/dev/null",
|
||||||
|
{ encoding: "utf-8" }
|
||||||
|
);
|
||||||
|
if (npmListOutput && npmListOutput.includes("@openai/codex")) {
|
||||||
|
// Get the path from npm bin
|
||||||
|
const npmBinPath = execSync("npm bin -g", {
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
const codexPath = path.join(npmBinPath, "codex");
|
||||||
|
const version = this.getCodexVersion(codexPath);
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
path: codexPath,
|
||||||
|
version: version || undefined,
|
||||||
|
method: "npm",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// npm global not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Check for Homebrew installation on macOS
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
try {
|
||||||
|
const brewList = execSync("brew list --formula 2>/dev/null", {
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
if (brewList.includes("codex")) {
|
||||||
|
const brewPrefixOutput = execSync("brew --prefix codex 2>/dev/null", {
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
const codexPath = path.join(brewPrefixOutput, "bin", "codex");
|
||||||
|
const version = this.getCodexVersion(codexPath);
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
path: codexPath,
|
||||||
|
version: version || undefined,
|
||||||
|
method: "brew",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Homebrew not found or codex not installed via brew
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 4: Check Windows path
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
try {
|
||||||
|
const codexPath = execSync("where codex 2>nul", {
|
||||||
|
encoding: "utf-8",
|
||||||
|
})
|
||||||
|
.trim()
|
||||||
|
.split("\n")[0];
|
||||||
|
if (codexPath) {
|
||||||
|
const version = this.getCodexVersion(codexPath);
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
path: codexPath,
|
||||||
|
version: version || undefined,
|
||||||
|
method: "cli",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Not found on Windows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 5: Check common installation paths
|
||||||
|
const commonPaths = [
|
||||||
|
path.join(os.homedir(), ".local", "bin", "codex"),
|
||||||
|
path.join(os.homedir(), ".npm-global", "bin", "codex"),
|
||||||
|
"/usr/local/bin/codex",
|
||||||
|
"/opt/homebrew/bin/codex",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const checkPath of commonPaths) {
|
||||||
|
if (fs.existsSync(checkPath)) {
|
||||||
|
const version = this.getCodexVersion(checkPath);
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
path: checkPath,
|
||||||
|
version: version || undefined,
|
||||||
|
method: "cli",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 6: Check if OPENAI_API_KEY is set (can use Codex API directly)
|
||||||
|
if (process.env.OPENAI_API_KEY) {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
hasApiKey: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Codex CLI version from executable path
|
||||||
|
*/
|
||||||
|
static getCodexVersion(codexPath: string): string | null {
|
||||||
|
try {
|
||||||
|
const version = execSync(`"${codexPath}" --version 2>/dev/null`, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
return version || null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get installation info and recommendations
|
||||||
|
*/
|
||||||
|
static getInstallationInfo(): {
|
||||||
|
status: string;
|
||||||
|
method?: string;
|
||||||
|
version?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
recommendation: string;
|
||||||
|
installCommands?: Record<string, string>;
|
||||||
|
} {
|
||||||
|
const detection = this.detectCodexInstallation();
|
||||||
|
|
||||||
|
if (detection.installed) {
|
||||||
|
return {
|
||||||
|
status: "installed",
|
||||||
|
method: detection.method,
|
||||||
|
version: detection.version,
|
||||||
|
path: detection.path,
|
||||||
|
recommendation:
|
||||||
|
detection.method === "cli"
|
||||||
|
? "Using Codex CLI - ready for GPT-5.1/5.2 Codex models"
|
||||||
|
: `Using Codex CLI via ${detection.method} - ready for GPT-5.1/5.2 Codex models`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not installed but has API key
|
||||||
|
if (detection.hasApiKey) {
|
||||||
|
return {
|
||||||
|
status: "api_key_only",
|
||||||
|
method: "api-key-only",
|
||||||
|
recommendation:
|
||||||
|
"OPENAI_API_KEY detected but Codex CLI not installed. Install Codex CLI for full agentic capabilities.",
|
||||||
|
installCommands: this.getInstallCommands(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "not_installed",
|
||||||
|
recommendation:
|
||||||
|
"Install OpenAI Codex CLI to use GPT-5.1/5.2 Codex models for agentic tasks",
|
||||||
|
installCommands: this.getInstallCommands(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get installation commands for different platforms
|
||||||
|
*/
|
||||||
|
static getInstallCommands(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
npm: "npm install -g @openai/codex@latest",
|
||||||
|
macos: "brew install codex",
|
||||||
|
linux: "npm install -g @openai/codex@latest",
|
||||||
|
windows: "npm install -g @openai/codex@latest",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Codex CLI supports a specific model
|
||||||
|
*/
|
||||||
|
static isModelSupported(model: string): boolean {
|
||||||
|
const supportedModels = [
|
||||||
|
"gpt-5.1-codex-max",
|
||||||
|
"gpt-5.1-codex",
|
||||||
|
"gpt-5.1-codex-mini",
|
||||||
|
"gpt-5.1",
|
||||||
|
"gpt-5.2",
|
||||||
|
];
|
||||||
|
return supportedModels.includes(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default model for Codex CLI
|
||||||
|
*/
|
||||||
|
static getDefaultModel(): string {
|
||||||
|
return "gpt-5.2";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comprehensive installation info including auth status
|
||||||
|
*/
|
||||||
|
static getFullStatus() {
|
||||||
|
const installation = this.detectCodexInstallation();
|
||||||
|
const auth = this.checkAuth();
|
||||||
|
const info = this.getInstallationInfo();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...info,
|
||||||
|
auth,
|
||||||
|
installation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
355
apps/server/src/providers/codex-config-manager.ts
Normal file
355
apps/server/src/providers/codex-config-manager.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
/**
|
||||||
|
* Codex TOML Configuration Manager
|
||||||
|
*
|
||||||
|
* Manages Codex CLI's TOML configuration file to add/update MCP server settings.
|
||||||
|
* Codex CLI looks for config at:
|
||||||
|
* - ~/.codex/config.toml (user-level)
|
||||||
|
* - .codex/config.toml (project-level, takes precedence)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
|
||||||
|
interface McpServerConfig {
|
||||||
|
command: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
startup_timeout_sec?: number;
|
||||||
|
tool_timeout_sec?: number;
|
||||||
|
enabled_tools?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexConfig {
|
||||||
|
experimental_use_rmcp_client?: boolean;
|
||||||
|
mcp_servers?: Record<string, McpServerConfig>;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CodexConfigManager {
|
||||||
|
private userConfigPath: string;
|
||||||
|
private projectConfigPath: string | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.userConfigPath = path.join(os.homedir(), ".codex", "config.toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the project path for project-level config
|
||||||
|
*/
|
||||||
|
setProjectPath(projectPath: string): void {
|
||||||
|
this.projectConfigPath = path.join(projectPath, ".codex", "config.toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective config path (project-level if exists, otherwise user-level)
|
||||||
|
*/
|
||||||
|
async getConfigPath(): Promise<string> {
|
||||||
|
if (this.projectConfigPath) {
|
||||||
|
try {
|
||||||
|
await fs.access(this.projectConfigPath);
|
||||||
|
return this.projectConfigPath;
|
||||||
|
} catch (e) {
|
||||||
|
// Project config doesn't exist, fall back to user config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure user config directory exists
|
||||||
|
const userConfigDir = path.dirname(this.userConfigPath);
|
||||||
|
try {
|
||||||
|
await fs.mkdir(userConfigDir, { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
// Directory might already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.userConfigPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read existing TOML config (simple parser for our needs)
|
||||||
|
*/
|
||||||
|
async readConfig(configPath: string): Promise<CodexConfig> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(configPath, "utf-8");
|
||||||
|
return this.parseToml(content);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.code === "ENOENT") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple TOML parser for our specific use case
|
||||||
|
* This is a minimal parser that handles the MCP server config structure
|
||||||
|
*/
|
||||||
|
parseToml(content: string): CodexConfig {
|
||||||
|
const config: CodexConfig = {};
|
||||||
|
let currentSection: string | null = null;
|
||||||
|
let currentSubsection: string | null = null;
|
||||||
|
|
||||||
|
const lines = content.split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
// Skip comments and empty lines
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section header: [section]
|
||||||
|
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
|
||||||
|
if (sectionMatch) {
|
||||||
|
const sectionName = sectionMatch[1];
|
||||||
|
const parts = sectionName.split(".");
|
||||||
|
|
||||||
|
if (parts.length === 1) {
|
||||||
|
currentSection = parts[0];
|
||||||
|
currentSubsection = null;
|
||||||
|
if (!config[currentSection]) {
|
||||||
|
config[currentSection] = {};
|
||||||
|
}
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
currentSection = parts[0];
|
||||||
|
currentSubsection = parts[1];
|
||||||
|
if (!config[currentSection]) {
|
||||||
|
config[currentSection] = {};
|
||||||
|
}
|
||||||
|
if (!config[currentSection][currentSubsection]) {
|
||||||
|
config[currentSection][currentSubsection] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key-value pair: key = value
|
||||||
|
const kvMatch = trimmed.match(/^([^=]+)=(.+)$/);
|
||||||
|
if (kvMatch) {
|
||||||
|
const key = kvMatch[1].trim();
|
||||||
|
let value: any = kvMatch[2].trim();
|
||||||
|
|
||||||
|
// Remove quotes if present
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse boolean
|
||||||
|
if (value === "true") value = true;
|
||||||
|
else if (value === "false") value = false;
|
||||||
|
// Parse number
|
||||||
|
else if (/^-?\d+$/.test(value)) value = parseInt(value, 10);
|
||||||
|
else if (/^-?\d+\.\d+$/.test(value)) value = parseFloat(value);
|
||||||
|
|
||||||
|
if (currentSubsection && currentSection) {
|
||||||
|
if (!config[currentSection][currentSubsection]) {
|
||||||
|
config[currentSection][currentSubsection] = {};
|
||||||
|
}
|
||||||
|
config[currentSection][currentSubsection][key] = value;
|
||||||
|
} else if (currentSection) {
|
||||||
|
if (!config[currentSection]) {
|
||||||
|
config[currentSection] = {};
|
||||||
|
}
|
||||||
|
config[currentSection][key] = value;
|
||||||
|
} else {
|
||||||
|
config[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the automaker-tools MCP server
|
||||||
|
*/
|
||||||
|
async configureMcpServer(
|
||||||
|
projectPath: string,
|
||||||
|
mcpServerScriptPath: string
|
||||||
|
): Promise<string> {
|
||||||
|
this.setProjectPath(projectPath);
|
||||||
|
const configPath = await this.getConfigPath();
|
||||||
|
|
||||||
|
// Read existing config
|
||||||
|
const config = await this.readConfig(configPath);
|
||||||
|
|
||||||
|
// Ensure mcp_servers section exists
|
||||||
|
if (!config.mcp_servers) {
|
||||||
|
config.mcp_servers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure automaker-tools server
|
||||||
|
config.mcp_servers["automaker-tools"] = {
|
||||||
|
command: "node",
|
||||||
|
args: [mcpServerScriptPath],
|
||||||
|
env: {
|
||||||
|
AUTOMAKER_PROJECT_PATH: projectPath,
|
||||||
|
},
|
||||||
|
startup_timeout_sec: 10,
|
||||||
|
tool_timeout_sec: 60,
|
||||||
|
enabled_tools: ["UpdateFeatureStatus"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure experimental_use_rmcp_client is enabled (if needed)
|
||||||
|
if (!config.experimental_use_rmcp_client) {
|
||||||
|
config.experimental_use_rmcp_client = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write config back
|
||||||
|
await this.writeConfig(configPath, config);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[CodexConfigManager] Configured automaker-tools MCP server in ${configPath}`
|
||||||
|
);
|
||||||
|
return configPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write config to TOML file
|
||||||
|
*/
|
||||||
|
async writeConfig(configPath: string, config: CodexConfig): Promise<void> {
|
||||||
|
let content = "";
|
||||||
|
|
||||||
|
// Write top-level keys first (preserve existing non-MCP config)
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (key === "mcp_servers" || key === "experimental_use_rmcp_client") {
|
||||||
|
continue; // Handle these separately
|
||||||
|
}
|
||||||
|
if (typeof value !== "object") {
|
||||||
|
content += `${key} = ${this.formatValue(value)}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write experimental flag if enabled
|
||||||
|
if (config.experimental_use_rmcp_client) {
|
||||||
|
if (content && !content.endsWith("\n\n")) {
|
||||||
|
content += "\n";
|
||||||
|
}
|
||||||
|
content += `experimental_use_rmcp_client = true\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write mcp_servers section
|
||||||
|
if (config.mcp_servers && Object.keys(config.mcp_servers).length > 0) {
|
||||||
|
if (content && !content.endsWith("\n\n")) {
|
||||||
|
content += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [serverName, serverConfig] of Object.entries(
|
||||||
|
config.mcp_servers
|
||||||
|
)) {
|
||||||
|
content += `\n[mcp_servers.${serverName}]\n`;
|
||||||
|
|
||||||
|
// Write command first
|
||||||
|
if (serverConfig.command) {
|
||||||
|
content += `command = "${this.escapeTomlString(serverConfig.command)}"\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write args
|
||||||
|
if (serverConfig.args && Array.isArray(serverConfig.args)) {
|
||||||
|
const argsStr = serverConfig.args
|
||||||
|
.map((a) => `"${this.escapeTomlString(a)}"`)
|
||||||
|
.join(", ");
|
||||||
|
content += `args = [${argsStr}]\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write timeouts (must be before env subsection)
|
||||||
|
if (serverConfig.startup_timeout_sec !== undefined) {
|
||||||
|
content += `startup_timeout_sec = ${serverConfig.startup_timeout_sec}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverConfig.tool_timeout_sec !== undefined) {
|
||||||
|
content += `tool_timeout_sec = ${serverConfig.tool_timeout_sec}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write enabled_tools (must be before env subsection - at server level, not env level)
|
||||||
|
if (serverConfig.enabled_tools && Array.isArray(serverConfig.enabled_tools)) {
|
||||||
|
const toolsStr = serverConfig.enabled_tools
|
||||||
|
.map((t) => `"${this.escapeTomlString(t)}"`)
|
||||||
|
.join(", ");
|
||||||
|
content += `enabled_tools = [${toolsStr}]\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write env section last (as a separate subsection)
|
||||||
|
if (
|
||||||
|
serverConfig.env &&
|
||||||
|
typeof serverConfig.env === "object" &&
|
||||||
|
Object.keys(serverConfig.env).length > 0
|
||||||
|
) {
|
||||||
|
content += `\n[mcp_servers.${serverName}.env]\n`;
|
||||||
|
for (const [envKey, envValue] of Object.entries(serverConfig.env)) {
|
||||||
|
content += `${envKey} = "${this.escapeTomlString(String(envValue))}"\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
const configDir = path.dirname(configPath);
|
||||||
|
await fs.mkdir(configDir, { recursive: true });
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
await fs.writeFile(configPath, content, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape special characters in TOML strings
|
||||||
|
*/
|
||||||
|
escapeTomlString(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.replace(/\r/g, "\\r")
|
||||||
|
.replace(/\t/g, "\\t");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a value for TOML output
|
||||||
|
*/
|
||||||
|
formatValue(value: any): string {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||||
|
return `"${escaped}"`;
|
||||||
|
} else if (typeof value === "boolean") {
|
||||||
|
return value.toString();
|
||||||
|
} else if (typeof value === "number") {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
return `"${String(value)}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove automaker-tools MCP server configuration
|
||||||
|
*/
|
||||||
|
async removeMcpServer(projectPath: string): Promise<void> {
|
||||||
|
this.setProjectPath(projectPath);
|
||||||
|
const configPath = await this.getConfigPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await this.readConfig(configPath);
|
||||||
|
|
||||||
|
if (config.mcp_servers && config.mcp_servers["automaker-tools"]) {
|
||||||
|
delete config.mcp_servers["automaker-tools"];
|
||||||
|
|
||||||
|
// If no more MCP servers, remove the section
|
||||||
|
if (Object.keys(config.mcp_servers).length === 0) {
|
||||||
|
delete config.mcp_servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.writeConfig(configPath, config);
|
||||||
|
console.log(
|
||||||
|
`[CodexConfigManager] Removed automaker-tools MCP server from ${configPath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[CodexConfigManager] Error removing MCP server config:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const codexConfigManager = new CodexConfigManager();
|
||||||
550
apps/server/src/providers/codex-provider.ts
Normal file
550
apps/server/src/providers/codex-provider.ts
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
/**
|
||||||
|
* Codex Provider - Executes queries using OpenAI Codex CLI
|
||||||
|
*
|
||||||
|
* Spawns Codex CLI as a subprocess and converts JSONL output to
|
||||||
|
* Claude SDK-compatible message format for seamless integration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseProvider } from "./base-provider.js";
|
||||||
|
import { CodexCliDetector } from "./codex-cli-detector.js";
|
||||||
|
import { codexConfigManager } from "./codex-config-manager.js";
|
||||||
|
import { spawnJSONLProcess } from "../lib/subprocess-manager.js";
|
||||||
|
import type {
|
||||||
|
ExecuteOptions,
|
||||||
|
ProviderMessage,
|
||||||
|
InstallationStatus,
|
||||||
|
ModelDefinition,
|
||||||
|
ContentBlock,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
// Codex event types
|
||||||
|
const CODEX_EVENT_TYPES = {
|
||||||
|
THREAD_STARTED: "thread.started",
|
||||||
|
THREAD_COMPLETED: "thread.completed",
|
||||||
|
ITEM_STARTED: "item.started",
|
||||||
|
ITEM_COMPLETED: "item.completed",
|
||||||
|
TURN_STARTED: "turn.started",
|
||||||
|
ERROR: "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CodexEvent {
|
||||||
|
type: string;
|
||||||
|
data?: any;
|
||||||
|
item?: any;
|
||||||
|
thread_id?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CodexProvider extends BaseProvider {
|
||||||
|
getName(): string {
|
||||||
|
return "codex";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query using Codex CLI
|
||||||
|
*/
|
||||||
|
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||||
|
const {
|
||||||
|
prompt,
|
||||||
|
model = "gpt-5.2",
|
||||||
|
cwd,
|
||||||
|
systemPrompt,
|
||||||
|
mcpServers,
|
||||||
|
abortController,
|
||||||
|
conversationHistory,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Find Codex CLI path
|
||||||
|
const codexPath = this.findCodexPath();
|
||||||
|
if (!codexPath) {
|
||||||
|
yield {
|
||||||
|
type: "error",
|
||||||
|
error:
|
||||||
|
"Codex CLI not found. Please install it with: npm install -g @openai/codex@latest",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure MCP server if provided
|
||||||
|
if (mcpServers && mcpServers["automaker-tools"]) {
|
||||||
|
try {
|
||||||
|
const mcpServerScriptPath = await this.getMcpServerPath();
|
||||||
|
if (mcpServerScriptPath) {
|
||||||
|
await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CodexProvider] Failed to configure MCP server:", error);
|
||||||
|
// Continue execution even if MCP config fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build combined prompt with conversation history
|
||||||
|
// Codex CLI doesn't support native conversation history or images, so we extract text
|
||||||
|
let combinedPrompt = "";
|
||||||
|
|
||||||
|
if (typeof prompt === "string") {
|
||||||
|
combinedPrompt = prompt;
|
||||||
|
} else if (Array.isArray(prompt)) {
|
||||||
|
// Extract text from content blocks (ignore images - Codex CLI doesn't support vision)
|
||||||
|
combinedPrompt = prompt
|
||||||
|
.filter(block => block.type === "text")
|
||||||
|
.map(block => block.text || "")
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add system prompt first
|
||||||
|
if (systemPrompt) {
|
||||||
|
combinedPrompt = `${systemPrompt}\n\n---\n\n${combinedPrompt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add conversation history
|
||||||
|
if (conversationHistory && conversationHistory.length > 0) {
|
||||||
|
let historyText = "Previous conversation:\n\n";
|
||||||
|
for (const msg of conversationHistory) {
|
||||||
|
const contentText = typeof msg.content === "string"
|
||||||
|
? msg.content
|
||||||
|
: msg.content.map(c => c.text || "").join("");
|
||||||
|
historyText += `${msg.role === "user" ? "User" : "Assistant"}: ${contentText}\n\n`;
|
||||||
|
}
|
||||||
|
combinedPrompt = `${historyText}---\n\nCurrent request:\n${combinedPrompt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build command arguments
|
||||||
|
const args = this.buildArgs({ prompt: combinedPrompt, model });
|
||||||
|
|
||||||
|
// Check authentication - either API key or CLI login
|
||||||
|
const auth = CodexCliDetector.checkAuth();
|
||||||
|
const hasApiKey = this.config.apiKey || process.env.OPENAI_API_KEY;
|
||||||
|
|
||||||
|
if (!auth.authenticated && !hasApiKey) {
|
||||||
|
yield {
|
||||||
|
type: "error",
|
||||||
|
error:
|
||||||
|
"Codex CLI is not authenticated. Please run 'codex login' or set OPENAI_API_KEY environment variable.",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare environment variables (API key is optional if using CLI auth)
|
||||||
|
const env = {
|
||||||
|
...this.config.env,
|
||||||
|
...(hasApiKey && { OPENAI_API_KEY: hasApiKey }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn the Codex process and stream JSONL output
|
||||||
|
try {
|
||||||
|
const stream = spawnJSONLProcess({
|
||||||
|
command: codexPath,
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
abortController,
|
||||||
|
timeout: 30000, // 30s timeout for no output
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
const converted = this.convertToProviderFormat(event as CodexEvent);
|
||||||
|
if (converted) {
|
||||||
|
yield converted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield completion event
|
||||||
|
yield {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
result: "",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CodexProvider] Execution error:", error);
|
||||||
|
yield {
|
||||||
|
type: "error",
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Codex JSONL event to Provider message format (Claude SDK compatible)
|
||||||
|
*/
|
||||||
|
private convertToProviderFormat(event: CodexEvent): ProviderMessage | null {
|
||||||
|
const { type, data, item, thread_id } = event;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case CODEX_EVENT_TYPES.THREAD_STARTED:
|
||||||
|
case "thread.started":
|
||||||
|
// Session initialization - not needed for provider format
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case CODEX_EVENT_TYPES.ITEM_COMPLETED:
|
||||||
|
case "item.completed":
|
||||||
|
return this.convertItemCompleted(item || data);
|
||||||
|
|
||||||
|
case CODEX_EVENT_TYPES.ITEM_STARTED:
|
||||||
|
case "item.started":
|
||||||
|
// Item started events can show tool usage
|
||||||
|
const startedItem = item || data;
|
||||||
|
if (
|
||||||
|
startedItem?.type === "command_execution" &&
|
||||||
|
startedItem?.command
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
name: "bash",
|
||||||
|
input: { command: startedItem.command },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Handle todo_list started
|
||||||
|
if (startedItem?.type === "todo_list" && startedItem?.items) {
|
||||||
|
const todos = startedItem.items || [];
|
||||||
|
const todoText = todos
|
||||||
|
.map((t: any, i: number) => `${i + 1}. ${t.text || t}`)
|
||||||
|
.join("\n");
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `**Todo List:**\n${todoText}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case "item.updated":
|
||||||
|
// Handle updated items (like todo list updates)
|
||||||
|
const updatedItem = item || data;
|
||||||
|
if (updatedItem?.type === "todo_list" && updatedItem?.items) {
|
||||||
|
const todos = updatedItem.items || [];
|
||||||
|
const todoText = todos
|
||||||
|
.map((t: any, i: number) => {
|
||||||
|
const status = t.status === "completed" ? "✓" : " ";
|
||||||
|
return `${i + 1}. [${status}] ${t.text || t}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `**Updated Todo List:**\n${todoText}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case CODEX_EVENT_TYPES.THREAD_COMPLETED:
|
||||||
|
case "thread.completed":
|
||||||
|
return {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
result: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
case CODEX_EVENT_TYPES.ERROR:
|
||||||
|
case "error":
|
||||||
|
return {
|
||||||
|
type: "error",
|
||||||
|
error:
|
||||||
|
data?.message ||
|
||||||
|
item?.message ||
|
||||||
|
event.message ||
|
||||||
|
"Unknown error from Codex CLI",
|
||||||
|
};
|
||||||
|
|
||||||
|
case "turn.started":
|
||||||
|
case "turn.completed":
|
||||||
|
// Turn markers - not needed for provider format
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert item.completed event to Provider format
|
||||||
|
*/
|
||||||
|
private convertItemCompleted(item: any): ProviderMessage | null {
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemType = item.type || item.item_type;
|
||||||
|
|
||||||
|
switch (itemType) {
|
||||||
|
case "reasoning":
|
||||||
|
// Thinking/reasoning output
|
||||||
|
const reasoningText = item.text || item.content || "";
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "thinking",
|
||||||
|
thinking: reasoningText,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "agent_message":
|
||||||
|
case "message":
|
||||||
|
// Assistant text message
|
||||||
|
const messageText = item.content || item.text || "";
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: messageText,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "command_execution":
|
||||||
|
// Command execution - show both the command and its output
|
||||||
|
const command = item.command || "";
|
||||||
|
const output = item.aggregated_output || item.output || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `\`\`\`bash\n${command}\n\`\`\`\n\n${output}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "tool_use":
|
||||||
|
// Tool use
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
name: item.tool || item.command || "unknown",
|
||||||
|
input: item.input || item.args || {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "tool_result":
|
||||||
|
// Tool result
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: item.tool_use_id,
|
||||||
|
content: item.output || item.result,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "todo_list":
|
||||||
|
// Todo list - convert to text format
|
||||||
|
const todos = item.items || [];
|
||||||
|
const todoText = todos
|
||||||
|
.map((t: any, i: number) => `${i + 1}. ${t.text || t}`)
|
||||||
|
.join("\n");
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `**Todo List:**\n${todoText}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "file_change":
|
||||||
|
// File changes - show what files were modified
|
||||||
|
const changes = item.changes || [];
|
||||||
|
const changeText = changes
|
||||||
|
.map((c: any) => `- Modified: ${c.path}`)
|
||||||
|
.join("\n");
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `**File Changes:**\n${changeText}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Generic text output
|
||||||
|
const text = item.text || item.content || item.aggregated_output;
|
||||||
|
if (text) {
|
||||||
|
return {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: String(text),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build command arguments for Codex CLI
|
||||||
|
*/
|
||||||
|
private buildArgs(options: {
|
||||||
|
prompt: string;
|
||||||
|
model: string;
|
||||||
|
}): string[] {
|
||||||
|
const { prompt, model } = options;
|
||||||
|
|
||||||
|
return [
|
||||||
|
"exec",
|
||||||
|
"--model",
|
||||||
|
model,
|
||||||
|
"--json", // JSONL output format
|
||||||
|
"--full-auto", // Non-interactive mode
|
||||||
|
prompt, // Prompt as the last argument
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find Codex CLI executable path
|
||||||
|
*/
|
||||||
|
private findCodexPath(): string | null {
|
||||||
|
// Check config override
|
||||||
|
if (this.config.cliPath) {
|
||||||
|
return this.config.cliPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check environment variable override
|
||||||
|
if (process.env.CODEX_CLI_PATH) {
|
||||||
|
return process.env.CODEX_CLI_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect
|
||||||
|
const detection = CodexCliDetector.detectCodexInstallation();
|
||||||
|
return detection.path || "codex";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MCP server script path
|
||||||
|
*/
|
||||||
|
private async getMcpServerPath(): Promise<string | null> {
|
||||||
|
// TODO: Implement MCP server path resolution
|
||||||
|
// For now, return null - MCP support is optional
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect Codex CLI installation
|
||||||
|
*/
|
||||||
|
async detectInstallation(): Promise<InstallationStatus> {
|
||||||
|
const detection = CodexCliDetector.detectCodexInstallation();
|
||||||
|
const auth = CodexCliDetector.checkAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: detection.installed,
|
||||||
|
path: detection.path,
|
||||||
|
version: detection.version,
|
||||||
|
method: detection.method,
|
||||||
|
hasApiKey: auth.hasEnvKey || auth.authenticated,
|
||||||
|
authenticated: auth.authenticated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available Codex models
|
||||||
|
*/
|
||||||
|
getAvailableModels(): ModelDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "gpt-5.2",
|
||||||
|
name: "GPT-5.2 (Codex)",
|
||||||
|
modelString: "gpt-5.2",
|
||||||
|
provider: "openai-codex",
|
||||||
|
description: "Latest Codex model for agentic code generation",
|
||||||
|
contextWindow: 256000,
|
||||||
|
maxOutputTokens: 32768,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "premium",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.1-codex-max",
|
||||||
|
name: "GPT-5.1 Codex Max",
|
||||||
|
modelString: "gpt-5.1-codex-max",
|
||||||
|
provider: "openai-codex",
|
||||||
|
description: "Maximum capability Codex model",
|
||||||
|
contextWindow: 256000,
|
||||||
|
maxOutputTokens: 32768,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "premium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.1-codex",
|
||||||
|
name: "GPT-5.1 Codex",
|
||||||
|
modelString: "gpt-5.1-codex",
|
||||||
|
provider: "openai-codex",
|
||||||
|
description: "Standard Codex model",
|
||||||
|
contextWindow: 256000,
|
||||||
|
maxOutputTokens: 32768,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: "standard",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the provider supports a specific feature
|
||||||
|
*/
|
||||||
|
supportsFeature(feature: string): boolean {
|
||||||
|
const supportedFeatures = ["tools", "text", "vision", "mcp", "cli"];
|
||||||
|
return supportedFeatures.includes(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
apps/server/src/providers/provider-factory.ts
Normal file
126
apps/server/src/providers/provider-factory.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Provider Factory - Routes model IDs to the appropriate provider
|
||||||
|
*
|
||||||
|
* This factory implements model-based routing to automatically select
|
||||||
|
* the correct provider based on the model string. This makes adding
|
||||||
|
* new providers (Cursor, OpenCode, etc.) trivial - just add one line.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseProvider } from "./base-provider.js";
|
||||||
|
import { ClaudeProvider } from "./claude-provider.js";
|
||||||
|
import { CodexProvider } from "./codex-provider.js";
|
||||||
|
import type { InstallationStatus } from "./types.js";
|
||||||
|
|
||||||
|
export class ProviderFactory {
|
||||||
|
/**
|
||||||
|
* Get the appropriate provider for a given model ID
|
||||||
|
*
|
||||||
|
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "gpt-5.2", "cursor-fast")
|
||||||
|
* @returns Provider instance for the model
|
||||||
|
*/
|
||||||
|
static getProviderForModel(modelId: string): BaseProvider {
|
||||||
|
const lowerModel = modelId.toLowerCase();
|
||||||
|
|
||||||
|
// OpenAI/Codex models (gpt-*, o1, o3, etc.)
|
||||||
|
if (lowerModel.startsWith("gpt-") || lowerModel.startsWith("o")) {
|
||||||
|
return new CodexProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude models (claude-*, opus, sonnet, haiku)
|
||||||
|
if (
|
||||||
|
lowerModel.startsWith("claude-") ||
|
||||||
|
["haiku", "sonnet", "opus"].includes(lowerModel)
|
||||||
|
) {
|
||||||
|
return new ClaudeProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future providers:
|
||||||
|
// if (lowerModel.startsWith("cursor-")) {
|
||||||
|
// return new CursorProvider();
|
||||||
|
// }
|
||||||
|
// if (lowerModel.startsWith("opencode-")) {
|
||||||
|
// return new OpenCodeProvider();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Default to Claude for unknown models
|
||||||
|
console.warn(
|
||||||
|
`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`
|
||||||
|
);
|
||||||
|
return new ClaudeProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available providers
|
||||||
|
*/
|
||||||
|
static getAllProviders(): BaseProvider[] {
|
||||||
|
return [
|
||||||
|
new ClaudeProvider(),
|
||||||
|
new CodexProvider(),
|
||||||
|
// Future providers...
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check installation status for all providers
|
||||||
|
*
|
||||||
|
* @returns Map of provider name to installation status
|
||||||
|
*/
|
||||||
|
static async checkAllProviders(): Promise<
|
||||||
|
Record<string, InstallationStatus>
|
||||||
|
> {
|
||||||
|
const providers = this.getAllProviders();
|
||||||
|
const statuses: Record<string, InstallationStatus> = {};
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const name = provider.getName();
|
||||||
|
const status = await provider.detectInstallation();
|
||||||
|
statuses[name] = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider by name (for direct access if needed)
|
||||||
|
*
|
||||||
|
* @param name Provider name (e.g., "claude", "codex", "cursor")
|
||||||
|
* @returns Provider instance or null if not found
|
||||||
|
*/
|
||||||
|
static getProviderByName(name: string): BaseProvider | null {
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
|
||||||
|
switch (lowerName) {
|
||||||
|
case "claude":
|
||||||
|
case "anthropic":
|
||||||
|
return new ClaudeProvider();
|
||||||
|
|
||||||
|
case "codex":
|
||||||
|
case "openai":
|
||||||
|
return new CodexProvider();
|
||||||
|
|
||||||
|
// Future providers:
|
||||||
|
// case "cursor":
|
||||||
|
// return new CursorProvider();
|
||||||
|
// case "opencode":
|
||||||
|
// return new OpenCodeProvider();
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available models from all providers
|
||||||
|
*/
|
||||||
|
static getAllAvailableModels() {
|
||||||
|
const providers = this.getAllProviders();
|
||||||
|
const allModels = [];
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const models = provider.getAvailableModels();
|
||||||
|
allModels.push(...models);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allModels;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
apps/server/src/providers/types.ts
Normal file
103
apps/server/src/providers/types.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for AI model providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a provider instance
|
||||||
|
*/
|
||||||
|
export interface ProviderConfig {
|
||||||
|
apiKey?: string;
|
||||||
|
cliPath?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message in conversation history
|
||||||
|
*/
|
||||||
|
export interface ConversationMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string | Array<{ type: string; text?: string; source?: object }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for executing a query via a provider
|
||||||
|
*/
|
||||||
|
export interface ExecuteOptions {
|
||||||
|
prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
||||||
|
model: string;
|
||||||
|
cwd: string;
|
||||||
|
systemPrompt?: string;
|
||||||
|
maxTurns?: number;
|
||||||
|
allowedTools?: string[];
|
||||||
|
mcpServers?: Record<string, unknown>;
|
||||||
|
abortController?: AbortController;
|
||||||
|
conversationHistory?: ConversationMessage[]; // Previous messages for context
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content block in a provider message (matches Claude SDK format)
|
||||||
|
*/
|
||||||
|
export interface ContentBlock {
|
||||||
|
type: "text" | "tool_use" | "thinking" | "tool_result";
|
||||||
|
text?: string;
|
||||||
|
thinking?: string;
|
||||||
|
name?: string;
|
||||||
|
input?: unknown;
|
||||||
|
tool_use_id?: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message returned by a provider (matches Claude SDK streaming format)
|
||||||
|
*/
|
||||||
|
export interface ProviderMessage {
|
||||||
|
type: "assistant" | "user" | "error" | "result";
|
||||||
|
subtype?: "success" | "error";
|
||||||
|
session_id?: string;
|
||||||
|
message?: {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: ContentBlock[];
|
||||||
|
};
|
||||||
|
result?: string;
|
||||||
|
error?: string;
|
||||||
|
parent_tool_use_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installation status for a provider
|
||||||
|
*/
|
||||||
|
export interface InstallationStatus {
|
||||||
|
installed: boolean;
|
||||||
|
path?: string;
|
||||||
|
version?: string;
|
||||||
|
method?: "cli" | "npm" | "brew" | "sdk";
|
||||||
|
hasApiKey?: boolean;
|
||||||
|
authenticated?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result
|
||||||
|
*/
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model definition
|
||||||
|
*/
|
||||||
|
export interface ModelDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
modelString: string;
|
||||||
|
provider: string;
|
||||||
|
description: string;
|
||||||
|
contextWindow?: number;
|
||||||
|
maxOutputTokens?: number;
|
||||||
|
supportsVision?: boolean;
|
||||||
|
supportsTools?: boolean;
|
||||||
|
tier?: "basic" | "standard" | "premium";
|
||||||
|
default?: boolean;
|
||||||
|
}
|
||||||
@@ -40,11 +40,12 @@ export function createAgentRoutes(
|
|||||||
// Send a message
|
// Send a message
|
||||||
router.post("/send", async (req: Request, res: Response) => {
|
router.post("/send", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { sessionId, message, workingDirectory, imagePaths } = req.body as {
|
const { sessionId, message, workingDirectory, imagePaths, model } = req.body as {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
message: string;
|
message: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
imagePaths?: string[];
|
imagePaths?: string[];
|
||||||
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!sessionId || !message) {
|
if (!sessionId || !message) {
|
||||||
@@ -61,6 +62,7 @@ export function createAgentRoutes(
|
|||||||
message,
|
message,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
imagePaths,
|
imagePaths,
|
||||||
|
model,
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[Agent Route] Error sending message:", error);
|
console.error("[Agent Route] Error sending message:", error);
|
||||||
@@ -128,5 +130,26 @@ export function createAgentRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set session model
|
||||||
|
router.post("/model", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { sessionId, model } = req.body as {
|
||||||
|
sessionId: string;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sessionId || !model) {
|
||||||
|
res.status(400).json({ success: false, error: "sessionId and model are required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await agentService.setSessionModel(sessionId, model);
|
||||||
|
res.json({ success: result });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, type Request, type Response } from "express";
|
import { Router, type Request, type Response } from "express";
|
||||||
|
import { ProviderFactory } from "../providers/provider-factory.js";
|
||||||
|
|
||||||
interface ModelDefinition {
|
interface ModelDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -93,7 +94,25 @@ export function createModelsRoutes(): Router {
|
|||||||
{
|
{
|
||||||
id: "gpt-5.2",
|
id: "gpt-5.2",
|
||||||
name: "GPT-5.2 (Codex)",
|
name: "GPT-5.2 (Codex)",
|
||||||
provider: "openai",
|
provider: "openai-codex",
|
||||||
|
contextWindow: 256000,
|
||||||
|
maxOutputTokens: 32768,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.1-codex-max",
|
||||||
|
name: "GPT-5.1 Codex Max",
|
||||||
|
provider: "openai-codex",
|
||||||
|
contextWindow: 256000,
|
||||||
|
maxOutputTokens: 32768,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.1-codex",
|
||||||
|
name: "GPT-5.1 Codex",
|
||||||
|
provider: "openai-codex",
|
||||||
contextWindow: 256000,
|
contextWindow: 256000,
|
||||||
maxOutputTokens: 32768,
|
maxOutputTokens: 32768,
|
||||||
supportsVision: true,
|
supportsVision: true,
|
||||||
@@ -111,15 +130,25 @@ export function createModelsRoutes(): Router {
|
|||||||
// Check provider status
|
// Check provider status
|
||||||
router.get("/providers", async (_req: Request, res: Response) => {
|
router.get("/providers", async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const providers: Record<string, ProviderStatus> = {
|
// Get installation status from all providers
|
||||||
|
const statuses = await ProviderFactory.checkAllProviders();
|
||||||
|
|
||||||
|
const providers: Record<string, any> = {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
available: !!process.env.ANTHROPIC_API_KEY,
|
available: statuses.claude?.installed || false,
|
||||||
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
|
hasApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||||
},
|
},
|
||||||
openai: {
|
openai: {
|
||||||
available: !!process.env.OPENAI_API_KEY,
|
available: !!process.env.OPENAI_API_KEY,
|
||||||
hasApiKey: !!process.env.OPENAI_API_KEY,
|
hasApiKey: !!process.env.OPENAI_API_KEY,
|
||||||
},
|
},
|
||||||
|
"openai-codex": {
|
||||||
|
available: statuses.codex?.installed || false,
|
||||||
|
hasApiKey: !!process.env.OPENAI_API_KEY,
|
||||||
|
cliInstalled: statuses.codex?.installed,
|
||||||
|
cliVersion: statuses.codex?.version,
|
||||||
|
cliPath: statuses.codex?.path,
|
||||||
|
},
|
||||||
google: {
|
google: {
|
||||||
available: !!process.env.GOOGLE_API_KEY,
|
available: !!process.env.GOOGLE_API_KEY,
|
||||||
hasApiKey: !!process.env.GOOGLE_API_KEY,
|
hasApiKey: !!process.env.GOOGLE_API_KEY,
|
||||||
|
|||||||
@@ -46,10 +46,11 @@ export function createSessionsRoutes(agentService: AgentService): Router {
|
|||||||
// Create a new session
|
// Create a new session
|
||||||
router.post("/", async (req: Request, res: Response) => {
|
router.post("/", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { name, projectPath, workingDirectory } = req.body as {
|
const { name, projectPath, workingDirectory, model } = req.body as {
|
||||||
name: string;
|
name: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -60,7 +61,8 @@ export function createSessionsRoutes(agentService: AgentService): Router {
|
|||||||
const session = await agentService.createSession(
|
const session = await agentService.createSession(
|
||||||
name,
|
name,
|
||||||
projectPath,
|
projectPath,
|
||||||
workingDirectory
|
workingDirectory,
|
||||||
|
model
|
||||||
);
|
);
|
||||||
res.json({ success: true, session });
|
res.json({ success: true, session });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -73,12 +75,13 @@ export function createSessionsRoutes(agentService: AgentService): Router {
|
|||||||
router.put("/:sessionId", async (req: Request, res: Response) => {
|
router.put("/:sessionId", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
const { name, tags } = req.body as {
|
const { name, tags, model } = req.body as {
|
||||||
name?: string;
|
name?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const session = await agentService.updateSession(sessionId, { name, tags });
|
const session = await agentService.updateSession(sessionId, { name, tags, model });
|
||||||
if (!session) {
|
if (!session) {
|
||||||
res.status(404).json({ success: false, error: "Session not found" });
|
res.status(404).json({ success: false, error: "Session not found" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Agent Service - Runs Claude agents via the Claude Agent SDK
|
* Agent Service - Runs AI agents via provider architecture
|
||||||
* Manages conversation sessions and streams responses via WebSocket
|
* Manages conversation sessions and streams responses via WebSocket
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk";
|
import { AbortError } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import type { EventEmitter } from "../lib/events.js";
|
import type { EventEmitter } from "../lib/events.js";
|
||||||
|
import { ProviderFactory } from "../providers/provider-factory.js";
|
||||||
|
import type { ExecuteOptions } from "../providers/types.js";
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,6 +28,7 @@ interface Session {
|
|||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
abortController: AbortController | null;
|
abortController: AbortController | null;
|
||||||
workingDirectory: string;
|
workingDirectory: string;
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionMetadata {
|
interface SessionMetadata {
|
||||||
@@ -37,6 +40,7 @@ interface SessionMetadata {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AgentService {
|
export class AgentService {
|
||||||
@@ -91,11 +95,13 @@ export class AgentService {
|
|||||||
message,
|
message,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
imagePaths,
|
imagePaths,
|
||||||
|
model,
|
||||||
}: {
|
}: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
message: string;
|
message: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
imagePaths?: string[];
|
imagePaths?: string[];
|
||||||
|
model?: string;
|
||||||
}) {
|
}) {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -106,6 +112,12 @@ export class AgentService {
|
|||||||
throw new Error("Agent is already processing a message");
|
throw new Error("Agent is already processing a message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update session model if provided
|
||||||
|
if (model) {
|
||||||
|
session.model = model;
|
||||||
|
await this.updateSession(sessionId, { model });
|
||||||
|
}
|
||||||
|
|
||||||
// Read images and convert to base64
|
// Read images and convert to base64
|
||||||
const images: Message["images"] = [];
|
const images: Message["images"] = [];
|
||||||
if (imagePaths && imagePaths.length > 0) {
|
if (imagePaths && imagePaths.length > 0) {
|
||||||
@@ -143,6 +155,12 @@ export class AgentService {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build conversation history from existing messages BEFORE adding current message
|
||||||
|
const conversationHistory = session.messages.map((msg) => ({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
}));
|
||||||
|
|
||||||
session.messages.push(userMessage);
|
session.messages.push(userMessage);
|
||||||
session.isRunning = true;
|
session.isRunning = true;
|
||||||
session.abortController = new AbortController();
|
session.abortController = new AbortController();
|
||||||
@@ -156,11 +174,23 @@ export class AgentService {
|
|||||||
await this.saveSession(sessionId, session.messages);
|
await this.saveSession(sessionId, session.messages);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const options: Options = {
|
// Use session model, parameter model, or default
|
||||||
model: "claude-opus-4-5-20251101",
|
const effectiveModel = model || session.model || "claude-opus-4-5-20251101";
|
||||||
|
|
||||||
|
// Get provider for this model
|
||||||
|
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build options for provider
|
||||||
|
const options: ExecuteOptions = {
|
||||||
|
prompt: "", // Will be set below based on images
|
||||||
|
model: effectiveModel,
|
||||||
|
cwd: workingDirectory || session.workingDirectory,
|
||||||
systemPrompt: this.getSystemPrompt(),
|
systemPrompt: this.getSystemPrompt(),
|
||||||
maxTurns: 20,
|
maxTurns: 20,
|
||||||
cwd: workingDirectory || session.workingDirectory,
|
|
||||||
allowedTools: [
|
allowedTools: [
|
||||||
"Read",
|
"Read",
|
||||||
"Write",
|
"Write",
|
||||||
@@ -171,23 +201,28 @@ export class AgentService {
|
|||||||
"WebSearch",
|
"WebSearch",
|
||||||
"WebFetch",
|
"WebFetch",
|
||||||
],
|
],
|
||||||
permissionMode: "acceptEdits",
|
|
||||||
sandbox: {
|
|
||||||
enabled: true,
|
|
||||||
autoAllowBashIfSandboxed: true,
|
|
||||||
},
|
|
||||||
abortController: session.abortController!,
|
abortController: session.abortController!,
|
||||||
|
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build prompt content
|
// Build prompt content
|
||||||
let promptContent: string | Array<{ type: string; text?: string; source?: object }> =
|
let promptContent: string | Array<{ type: string; text?: string; source?: object }> =
|
||||||
message;
|
message;
|
||||||
|
|
||||||
|
// Append image paths to prompt text (like old implementation)
|
||||||
if (imagePaths && imagePaths.length > 0) {
|
if (imagePaths && imagePaths.length > 0) {
|
||||||
|
let enhancedMessage = message;
|
||||||
|
|
||||||
|
// Append image file paths to the message text
|
||||||
|
enhancedMessage += "\n\nAttached images:\n";
|
||||||
|
for (const imagePath of imagePaths) {
|
||||||
|
enhancedMessage += `- ${imagePath}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
const contentBlocks: Array<{ type: string; text?: string; source?: object }> = [];
|
const contentBlocks: Array<{ type: string; text?: string; source?: object }> = [];
|
||||||
|
|
||||||
if (message && message.trim()) {
|
if (enhancedMessage && enhancedMessage.trim()) {
|
||||||
contentBlocks.push({ type: "text", text: message });
|
contentBlocks.push({ type: "text", text: enhancedMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const imagePath of imagePaths) {
|
for (const imagePath of imagePaths) {
|
||||||
@@ -219,25 +254,16 @@ export class AgentService {
|
|||||||
|
|
||||||
if (contentBlocks.length > 1 || contentBlocks[0]?.type === "image") {
|
if (contentBlocks.length > 1 || contentBlocks[0]?.type === "image") {
|
||||||
promptContent = contentBlocks;
|
promptContent = contentBlocks;
|
||||||
|
} else {
|
||||||
|
promptContent = enhancedMessage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build payload
|
// Set the prompt in options
|
||||||
const promptPayload = Array.isArray(promptContent)
|
options.prompt = promptContent;
|
||||||
? (async function* () {
|
|
||||||
yield {
|
|
||||||
type: "user" as const,
|
|
||||||
session_id: "",
|
|
||||||
message: {
|
|
||||||
role: "user" as const,
|
|
||||||
content: promptContent,
|
|
||||||
},
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
};
|
|
||||||
})()
|
|
||||||
: promptContent;
|
|
||||||
|
|
||||||
const stream = query({ prompt: promptPayload, options });
|
// Execute via provider
|
||||||
|
const stream = provider.executeQuery(options);
|
||||||
|
|
||||||
let currentAssistantMessage: Message | null = null;
|
let currentAssistantMessage: Message | null = null;
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
@@ -245,7 +271,7 @@ export class AgentService {
|
|||||||
|
|
||||||
for await (const msg of stream) {
|
for await (const msg of stream) {
|
||||||
if (msg.type === "assistant") {
|
if (msg.type === "assistant") {
|
||||||
if (msg.message.content) {
|
if (msg.message?.content) {
|
||||||
for (const block of msg.message.content) {
|
for (const block of msg.message.content) {
|
||||||
if (block.type === "text") {
|
if (block.type === "text") {
|
||||||
responseText += block.text;
|
responseText += block.text;
|
||||||
@@ -270,7 +296,7 @@ export class AgentService {
|
|||||||
});
|
});
|
||||||
} else if (block.type === "tool_use") {
|
} else if (block.type === "tool_use") {
|
||||||
const toolUse = {
|
const toolUse = {
|
||||||
name: block.name,
|
name: block.name || "unknown",
|
||||||
input: block.input,
|
input: block.input,
|
||||||
};
|
};
|
||||||
toolUses.push(toolUse);
|
toolUses.push(toolUse);
|
||||||
@@ -450,7 +476,8 @@ export class AgentService {
|
|||||||
async createSession(
|
async createSession(
|
||||||
name: string,
|
name: string,
|
||||||
projectPath?: string,
|
projectPath?: string,
|
||||||
workingDirectory?: string
|
workingDirectory?: string,
|
||||||
|
model?: string
|
||||||
): Promise<SessionMetadata> {
|
): Promise<SessionMetadata> {
|
||||||
const sessionId = this.generateId();
|
const sessionId = this.generateId();
|
||||||
const metadata = await this.loadMetadata();
|
const metadata = await this.loadMetadata();
|
||||||
@@ -462,6 +489,7 @@ export class AgentService {
|
|||||||
workingDirectory: workingDirectory || projectPath || process.cwd(),
|
workingDirectory: workingDirectory || projectPath || process.cwd(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
model,
|
||||||
};
|
};
|
||||||
|
|
||||||
metadata[sessionId] = session;
|
metadata[sessionId] = session;
|
||||||
@@ -470,6 +498,16 @@ export class AgentService {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setSessionModel(sessionId: string, model: string): Promise<boolean> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.model = model;
|
||||||
|
await this.updateSession(sessionId, { model });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async updateSession(
|
async updateSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
updates: Partial<SessionMetadata>
|
updates: Partial<SessionMetadata>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
* - Verification and merge workflows
|
* - Verification and merge workflows
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk";
|
import { AbortError } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
import { ProviderFactory } from "../providers/provider-factory.js";
|
||||||
|
import type { ExecuteOptions } from "../providers/types.js";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -33,6 +35,7 @@ interface Feature {
|
|||||||
priority?: number;
|
priority?: number;
|
||||||
spec?: string;
|
spec?: string;
|
||||||
model?: string; // Model to use for this feature
|
model?: string; // Model to use for this feature
|
||||||
|
imagePaths?: Array<string | { path: string; filename?: string; mimeType?: string; [key: string]: unknown }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,12 +240,17 @@ export class AutoModeService {
|
|||||||
// Build the prompt
|
// Build the prompt
|
||||||
const prompt = this.buildFeaturePrompt(feature);
|
const prompt = this.buildFeaturePrompt(feature);
|
||||||
|
|
||||||
|
// Extract image paths from feature
|
||||||
|
const imagePaths = feature.imagePaths?.map((img) =>
|
||||||
|
typeof img === "string" ? img : img.path
|
||||||
|
);
|
||||||
|
|
||||||
// Get model from feature
|
// Get model from feature
|
||||||
const model = getModelString(feature);
|
const model = getModelString(feature);
|
||||||
console.log(`[AutoMode] Executing feature ${featureId} with model: ${model}`);
|
console.log(`[AutoMode] Executing feature ${featureId} with model: ${model}`);
|
||||||
|
|
||||||
// Run the agent with the feature's model
|
// Run the agent with the feature's model and images
|
||||||
await this.runAgent(workDir, featureId, prompt, abortController, undefined, model);
|
await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model);
|
||||||
|
|
||||||
// Mark as waiting_approval for user review
|
// Mark as waiting_approval for user review
|
||||||
await this.updateFeatureStatus(projectPath, featureId, "waiting_approval");
|
await this.updateFeatureStatus(projectPath, featureId, "waiting_approval");
|
||||||
@@ -377,7 +385,126 @@ export class AutoModeService {
|
|||||||
const model = feature ? getModelString(feature) : MODEL_MAP.opus;
|
const model = feature ? getModelString(feature) : MODEL_MAP.opus;
|
||||||
console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`);
|
console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`);
|
||||||
|
|
||||||
await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model);
|
// Update feature status to in_progress
|
||||||
|
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
|
||||||
|
|
||||||
|
// Copy follow-up images to feature folder
|
||||||
|
const copiedImagePaths: string[] = [];
|
||||||
|
if (imagePaths && imagePaths.length > 0) {
|
||||||
|
const featureImagesDir = path.join(
|
||||||
|
projectPath,
|
||||||
|
".automaker",
|
||||||
|
"features",
|
||||||
|
featureId,
|
||||||
|
"images"
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.mkdir(featureImagesDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const imagePath of imagePaths) {
|
||||||
|
try {
|
||||||
|
// Get the filename from the path
|
||||||
|
const filename = path.basename(imagePath);
|
||||||
|
const destPath = path.join(featureImagesDir, filename);
|
||||||
|
|
||||||
|
// Copy the image
|
||||||
|
await fs.copyFile(imagePath, destPath);
|
||||||
|
|
||||||
|
// Store the relative path (like FeatureLoader does)
|
||||||
|
const relativePath = path.join(
|
||||||
|
".automaker",
|
||||||
|
"features",
|
||||||
|
featureId,
|
||||||
|
"images",
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
copiedImagePaths.push(relativePath);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[AutoMode] Failed to copy follow-up image ${imagePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update feature object with new follow-up images BEFORE building prompt
|
||||||
|
if (copiedImagePaths.length > 0 && feature) {
|
||||||
|
const currentImagePaths = feature.imagePaths || [];
|
||||||
|
const newImagePaths = copiedImagePaths.map((p) => ({
|
||||||
|
path: p,
|
||||||
|
filename: path.basename(p),
|
||||||
|
mimeType: "image/png", // Default, could be improved
|
||||||
|
}));
|
||||||
|
|
||||||
|
feature.imagePaths = [...currentImagePaths, ...newImagePaths];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load previous agent output for context
|
||||||
|
const outputPath = path.join(
|
||||||
|
workDir,
|
||||||
|
".automaker",
|
||||||
|
"features",
|
||||||
|
featureId,
|
||||||
|
"agent-output.md"
|
||||||
|
);
|
||||||
|
let previousContext = "";
|
||||||
|
try {
|
||||||
|
previousContext = await fs.readFile(outputPath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
// No previous context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build follow-up prompt with context (feature now includes new images)
|
||||||
|
let followUpPrompt = prompt;
|
||||||
|
if (previousContext) {
|
||||||
|
followUpPrompt = `## Follow-up Request
|
||||||
|
|
||||||
|
${this.buildFeaturePrompt(feature!)}
|
||||||
|
|
||||||
|
## Previous Work
|
||||||
|
The following is the output from the previous implementation:
|
||||||
|
|
||||||
|
${previousContext}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Instructions
|
||||||
|
${prompt}
|
||||||
|
|
||||||
|
Please continue from where you left off and address the new instructions above.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine original feature images with new follow-up images
|
||||||
|
const allImagePaths: string[] = [];
|
||||||
|
|
||||||
|
// Add all images from feature (now includes both original and new)
|
||||||
|
if (feature?.imagePaths) {
|
||||||
|
const allPaths = feature.imagePaths.map((img) =>
|
||||||
|
typeof img === "string" ? img : img.path
|
||||||
|
);
|
||||||
|
allImagePaths.push(...allPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated feature.json with new images
|
||||||
|
if (copiedImagePaths.length > 0 && feature) {
|
||||||
|
const featurePath = path.join(
|
||||||
|
projectPath,
|
||||||
|
".automaker",
|
||||||
|
"features",
|
||||||
|
featureId,
|
||||||
|
"feature.json"
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[AutoMode] Failed to save feature.json:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.runAgent(workDir, featureId, followUpPrompt, abortController, allImagePaths.length > 0 ? allImagePaths : undefined, model);
|
||||||
|
|
||||||
|
// Mark as waiting_approval for user review
|
||||||
|
await this.updateFeatureStatus(projectPath, featureId, "waiting_approval");
|
||||||
|
|
||||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||||
featureId,
|
featureId,
|
||||||
@@ -544,23 +671,25 @@ export class AutoModeService {
|
|||||||
Format your response as a structured markdown document.`;
|
Format your response as a structured markdown document.`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const options: Options = {
|
const provider = ProviderFactory.getProviderForModel("claude-sonnet-4-20250514");
|
||||||
|
|
||||||
|
const options: ExecuteOptions = {
|
||||||
|
prompt,
|
||||||
model: "claude-sonnet-4-20250514",
|
model: "claude-sonnet-4-20250514",
|
||||||
maxTurns: 5,
|
maxTurns: 5,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
allowedTools: ["Read", "Glob", "Grep"],
|
allowedTools: ["Read", "Glob", "Grep"],
|
||||||
permissionMode: "acceptEdits",
|
|
||||||
abortController,
|
abortController,
|
||||||
};
|
};
|
||||||
|
|
||||||
const stream = query({ prompt, options });
|
const stream = provider.executeQuery(options);
|
||||||
let analysisResult = "";
|
let analysisResult = "";
|
||||||
|
|
||||||
for await (const msg of stream) {
|
for await (const msg of stream) {
|
||||||
if (msg.type === "assistant" && msg.message.content) {
|
if (msg.type === "assistant" && msg.message?.content) {
|
||||||
for (const block of msg.message.content) {
|
for (const block of msg.message.content) {
|
||||||
if (block.type === "text") {
|
if (block.type === "text") {
|
||||||
analysisResult = block.text;
|
analysisResult = block.text || "";
|
||||||
this.emitAutoModeEvent("auto_mode_progress", {
|
this.emitAutoModeEvent("auto_mode_progress", {
|
||||||
featureId: analysisFeatureId,
|
featureId: analysisFeatureId,
|
||||||
content: block.text,
|
content: block.text,
|
||||||
@@ -736,6 +865,27 @@ ${feature.spec}
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add images note (like old implementation)
|
||||||
|
if (feature.imagePaths && feature.imagePaths.length > 0) {
|
||||||
|
const imagesList = feature.imagePaths
|
||||||
|
.map((img, idx) => {
|
||||||
|
const path = typeof img === "string" ? img : img.path;
|
||||||
|
const filename = typeof img === "string" ? path.split("/").pop() : img.filename || path.split("/").pop();
|
||||||
|
const mimeType = typeof img === "string" ? "image/*" : img.mimeType || "image/*";
|
||||||
|
return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${path}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
prompt += `
|
||||||
|
**📎 Context Images Attached:**
|
||||||
|
The user has attached ${feature.imagePaths.length} image(s) for context. These images are provided both visually (in the initial message) and as files you can read:
|
||||||
|
|
||||||
|
${imagesList}
|
||||||
|
|
||||||
|
You can use the Read tool to view these images at any time during implementation. Review them carefully before implementing.
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
prompt += `
|
prompt += `
|
||||||
## Instructions
|
## Instructions
|
||||||
|
|
||||||
@@ -761,17 +911,62 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const finalModel = model || MODEL_MAP.opus;
|
const finalModel = model || MODEL_MAP.opus;
|
||||||
console.log(`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}`);
|
console.log(`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}`);
|
||||||
|
|
||||||
// Check if this is an OpenAI/Codex model - Claude Agent SDK doesn't support these
|
// Get provider for this model
|
||||||
if (finalModel.startsWith("gpt-") || finalModel.startsWith("o")) {
|
const provider = ProviderFactory.getProviderForModel(finalModel);
|
||||||
const errorMessage = `OpenAI/Codex models (like "${finalModel}") are not yet supported in server mode. ` +
|
|
||||||
`Please use a Claude model (opus, sonnet, or haiku) instead. ` +
|
console.log(
|
||||||
`OpenAI/Codex models are only supported in the Electron app.`;
|
`[AutoMode] Using provider "${provider.getName()}" for model "${finalModel}"`
|
||||||
console.error(`[AutoMode] ${errorMessage}`);
|
);
|
||||||
throw new Error(errorMessage);
|
|
||||||
|
// Build prompt content with images (like AgentService)
|
||||||
|
let promptContent: string | Array<{ type: string; text?: string; source?: object }> = prompt;
|
||||||
|
|
||||||
|
if (imagePaths && imagePaths.length > 0) {
|
||||||
|
const contentBlocks: Array<{ type: string; text?: string; source?: object }> = [];
|
||||||
|
|
||||||
|
// Add text block first
|
||||||
|
contentBlocks.push({ type: "text", text: prompt });
|
||||||
|
|
||||||
|
// Add image blocks (for vision models)
|
||||||
|
for (const imagePath of imagePaths) {
|
||||||
|
try {
|
||||||
|
// Make path absolute by prepending workDir if it's relative
|
||||||
|
const absolutePath = path.isAbsolute(imagePath)
|
||||||
|
? imagePath
|
||||||
|
: path.join(workDir, imagePath);
|
||||||
|
|
||||||
|
const imageBuffer = await fs.readFile(absolutePath);
|
||||||
|
const base64Data = imageBuffer.toString("base64");
|
||||||
|
const ext = path.extname(imagePath).toLowerCase();
|
||||||
|
const mimeTypeMap: Record<string, string> = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
};
|
||||||
|
const mediaType = mimeTypeMap[ext] || "image/png";
|
||||||
|
|
||||||
|
contentBlocks.push({
|
||||||
|
type: "image",
|
||||||
|
source: {
|
||||||
|
type: "base64",
|
||||||
|
media_type: mediaType,
|
||||||
|
data: base64Data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[AutoMode] Failed to load image ${imagePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
promptContent = contentBlocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options: Options = {
|
const options: ExecuteOptions = {
|
||||||
|
prompt: promptContent,
|
||||||
model: finalModel,
|
model: finalModel,
|
||||||
maxTurns: 50,
|
maxTurns: 50,
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
@@ -783,35 +978,24 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
"Grep",
|
"Grep",
|
||||||
"Bash",
|
"Bash",
|
||||||
],
|
],
|
||||||
permissionMode: "acceptEdits",
|
|
||||||
sandbox: {
|
|
||||||
enabled: true,
|
|
||||||
autoAllowBashIfSandboxed: true,
|
|
||||||
},
|
|
||||||
abortController,
|
abortController,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build prompt - include image paths for the agent to read
|
// Execute via provider
|
||||||
let finalPrompt = prompt;
|
const stream = provider.executeQuery(options);
|
||||||
|
|
||||||
if (imagePaths && imagePaths.length > 0) {
|
|
||||||
finalPrompt = `${prompt}\n\n## Reference Images\nThe following images are available for reference. Use the Read tool to view them:\n${imagePaths.map((p) => `- ${p}`).join("\n")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = query({ prompt: finalPrompt, options });
|
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
const outputPath = path.join(workDir, ".automaker", "features", featureId, "agent-output.md");
|
const outputPath = path.join(workDir, ".automaker", "features", featureId, "agent-output.md");
|
||||||
|
|
||||||
for await (const msg of stream) {
|
for await (const msg of stream) {
|
||||||
if (msg.type === "assistant" && msg.message.content) {
|
if (msg.type === "assistant" && msg.message?.content) {
|
||||||
for (const block of msg.message.content) {
|
for (const block of msg.message.content) {
|
||||||
if (block.type === "text") {
|
if (block.type === "text") {
|
||||||
responseText = block.text;
|
responseText = block.text || "";
|
||||||
|
|
||||||
// Check for authentication errors in the response
|
// Check for authentication errors in the response
|
||||||
if (block.text.includes("Invalid API key") ||
|
if (block.text && (block.text.includes("Invalid API key") ||
|
||||||
block.text.includes("authentication_failed") ||
|
block.text.includes("authentication_failed") ||
|
||||||
block.text.includes("Fix external API key")) {
|
block.text.includes("Fix external API key"))) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Authentication failed: Invalid or expired API key. " +
|
"Authentication failed: Invalid or expired API key. " +
|
||||||
"Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate."
|
"Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate."
|
||||||
@@ -830,20 +1014,10 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.type === "assistant" && (msg as { error?: string }).error === "authentication_failed") {
|
} else if (msg.type === "error") {
|
||||||
// Handle authentication error from the SDK
|
// Handle error messages
|
||||||
throw new Error(
|
throw new Error(msg.error || "Unknown error");
|
||||||
"Authentication failed: Invalid or expired API key. " +
|
|
||||||
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
|
|
||||||
);
|
|
||||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||||
// Check if result indicates an error
|
|
||||||
if (msg.is_error && msg.result?.includes("Invalid API key")) {
|
|
||||||
throw new Error(
|
|
||||||
"Authentication failed: Invalid or expired API key. " +
|
|
||||||
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
responseText = msg.result || responseText;
|
responseText = msg.result || responseText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user