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:
Kacper
2025-12-13 03:45:41 +01:00
parent 55603cb5c7
commit a65b16cbae
15 changed files with 2404 additions and 89 deletions

View 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;
}
}