Files
automaker/docs/server/providers.md
Kacper 7cbdb3db73 refactor: eliminate code duplication with shared utilities
Created 5 new utility modules in apps/server/src/lib/ to eliminate ~320 lines of duplicated code:
- image-handler.ts: Centralized image processing (MIME types, base64, content blocks)
- prompt-builder.ts: Standardized prompt building with image attachments
- model-resolver.ts: Model alias resolution and provider routing
- conversation-utils.ts: Conversation history processing for providers
- error-handler.ts: Error classification and user-friendly messages

Updated services and providers to use shared utilities:
- agent-service.ts: -51 lines (removed duplicate image handling, model logic)
- auto-mode-service.ts: -75 lines (removed MODEL_MAP, duplicate utilities)
- claude-provider.ts: -10 lines (uses conversation-utils)
- codex-provider.ts: -5 lines (uses conversation-utils)

Added comprehensive documentation:
- docs/server/utilities.md: Complete reference for all 9 lib utilities
- docs/server/providers.md: Provider architecture guide with examples

Benefits:
- Single source of truth for critical business logic
- Improved maintainability and testability
- Consistent behavior across services and providers
- Better documentation for future development

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 04:26:58 +01:00

18 KiB

Provider Architecture Reference

This document describes the modular provider architecture in apps/server/src/providers/ that enables support for multiple AI model providers (Claude SDK, OpenAI Codex CLI, and future providers like Cursor, OpenCode, etc.).


Table of Contents

  1. Architecture Overview
  2. Provider Interface
  3. Available Providers
  4. Provider Factory
  5. Adding New Providers
  6. Provider Types
  7. Best Practices

Architecture Overview

The provider architecture separates AI model execution logic from business logic, enabling clean abstraction and easy extensibility.

Architecture Diagram

┌─────────────────────────────────────────┐
│         AgentService / AutoModeService   │
│              (No provider logic)         │
└──────────────────┬──────────────────────┘
                   │
         ┌─────────▼──────────┐
         │  ProviderFactory   │  Model-based routing
         │  (Routes by model) │  "gpt-*" → Codex
         └─────────┬──────────┘  "claude-*" → Claude
                   │
      ┌────────────┴────────────┐
      │                         │
┌─────▼──────┐          ┌──────▼──────┐
│   Claude   │          │    Codex    │
│  Provider  │          │   Provider  │
│ (Agent SDK)│          │ (CLI Spawn) │
└────────────┘          └─────────────┘

Key Benefits

  • Adding new providers: Only 1 new file + 1 line in factory
  • Services remain clean: No provider-specific logic
  • All providers implement same interface: Consistent behavior
  • Model prefix determines provider: Automatic routing
  • Easy to test: Each provider can be tested independently

Provider Interface

Location: apps/server/src/providers/base-provider.ts

All providers must extend BaseProvider and implement the required methods.

BaseProvider Abstract Class

export abstract class BaseProvider {
  protected config: ProviderConfig;

  constructor(config: ProviderConfig = {}) {
    this.config = config;
  }

  /**
   * Get provider name (e.g., "claude", "codex")
   */
  abstract getName(): string;

  /**
   * Execute a query and stream responses
   */
  abstract executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage>;

  /**
   * Detect provider installation status
   */
  abstract detectInstallation(): Promise<InstallationStatus>;

  /**
   * Get available models for this provider
   */
  abstract getAvailableModels(): ModelDefinition[];

  /**
   * Check if provider supports a specific feature (optional)
   */
  supportsFeature(feature: string): boolean {
    return false;
  }
}

Shared Types

Location: apps/server/src/providers/types.ts

ExecuteOptions

Input configuration for executing queries:

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[];
}

ProviderMessage

Output messages streamed from providers:

export interface ProviderMessage {
  type: "assistant" | "user" | "error" | "result";
  subtype?: "success" | "error";
  message?: {
    role: "user" | "assistant";
    content: ContentBlock[];
  };
  result?: string;
  error?: string;
}

ContentBlock

Individual content blocks in messages:

export interface ContentBlock {
  type: "text" | "tool_use" | "thinking" | "tool_result";
  text?: string;
  thinking?: string;
  name?: string;
  input?: unknown;
  tool_use_id?: string;
  content?: string;
}

Available Providers

1. Claude Provider (SDK-based)

Location: apps/server/src/providers/claude-provider.ts

Uses @anthropic-ai/claude-agent-sdk for direct SDK integration.

Features

  • Native multi-turn conversation support
  • Vision support (images)
  • Tool use (Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch)
  • Thinking blocks (extended thinking)
  • Streaming responses
  • No CLI installation required (npm dependency)

Model Detection

Routes models that:

  • Start with "claude-" (e.g., "claude-opus-4-5-20251101")
  • Are Claude aliases: "opus", "sonnet", "haiku"

Authentication

Requires one of:

  • ANTHROPIC_API_KEY environment variable
  • CLAUDE_CODE_OAUTH_TOKEN environment variable

Example Usage

const provider = new ClaudeProvider();

const stream = provider.executeQuery({
  prompt: "What is 2+2?",
  model: "claude-opus-4-5-20251101",
  cwd: "/project/path",
  systemPrompt: "You are a helpful assistant.",
  maxTurns: 20,
  allowedTools: ["Read", "Write", "Bash"],
  abortController: new AbortController(),
  conversationHistory: [
    { role: "user", content: "Hello" },
    { role: "assistant", content: "Hi! How can I help?" }
  ]
});

for await (const msg of stream) {
  if (msg.type === "assistant") {
    console.log(msg.message?.content);
  }
}

Conversation History Handling

Uses convertHistoryToMessages() utility to convert history to SDK format:

const historyMessages = convertHistoryToMessages(conversationHistory);
for (const msg of historyMessages) {
  yield msg;  // Yield to SDK
}

2. Codex Provider (CLI-based)

Location: apps/server/src/providers/codex-provider.ts

Spawns OpenAI Codex CLI as a subprocess and converts JSONL output to provider format.

Features

  • Subprocess execution (codex exec --model <model> --json --full-auto)
  • JSONL stream parsing
  • Supports GPT-5.1/5.2 Codex models
  • Vision support (GPT-5.1, GPT-5.2)
  • Tool use via MCP servers
  • Timeout detection (30s no output)
  • Abort signal handling

Model Detection

Routes models that:

  • Start with "gpt-" (e.g., "gpt-5.2", "gpt-5.1-codex-max")
  • Start with "o" (e.g., "o1", "o1-mini")

Available Models

Model Description Context Max Output Vision
gpt-5.2 Latest Codex model 256K 32K Yes
gpt-5.1-codex-max Maximum capability 256K 32K Yes
gpt-5.1-codex Standard Codex 256K 32K Yes
gpt-5.1-codex-mini Lightweight 256K 16K No
gpt-5.1 General-purpose 256K 32K Yes

Authentication

Supports two methods:

  1. CLI login: codex login (OAuth tokens stored in ~/.codex/auth.json)
  2. API key: OPENAI_API_KEY environment variable

Installation Detection

Uses CodexCliDetector to check:

  • PATH for codex command
  • npm global: npm list -g @openai/codex
  • Homebrew (macOS): /opt/homebrew/bin/codex
  • Common paths: ~/.local/bin/codex, /usr/local/bin/codex

Example Usage

const provider = new CodexProvider();

const stream = provider.executeQuery({
  prompt: "Fix the bug in main.ts",
  model: "gpt-5.2",
  cwd: "/project/path",
  systemPrompt: "You are an expert TypeScript developer.",
  abortController: new AbortController()
});

for await (const msg of stream) {
  if (msg.type === "assistant") {
    console.log(msg.message?.content);
  } else if (msg.type === "error") {
    console.error(msg.error);
  }
}

JSONL Event Conversion

Codex CLI outputs JSONL events that get converted to ProviderMessage format:

Codex Event Provider Message
item.completed (reasoning) { type: "assistant", content: [{ type: "thinking" }] }
item.completed (agent_message) { type: "assistant", content: [{ type: "text" }] }
item.completed (command_execution) { type: "assistant", content: [{ type: "text", text: "```bash\n...\n```" }] }
item.started (command_execution) { type: "assistant", content: [{ type: "tool_use" }] }
item.updated (todo_list) { type: "assistant", content: [{ type: "text", text: "**Updated Todo List:**..." }] }
thread.completed { type: "result", subtype: "success" }
error { type: "error", error: "..." }

Conversation History Handling

Uses formatHistoryAsText() utility to prepend history as text context (CLI doesn't support native multi-turn):

const historyText = formatHistoryAsText(conversationHistory);
combinedPrompt = `${historyText}Current request:\n${combinedPrompt}`;

MCP Server Configuration

Location: apps/server/src/providers/codex-config-manager.ts

Manages TOML configuration for MCP servers:

await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath);

Generates .codex/config.toml:

[mcp_servers.automaker-tools]
command = "node"
args = ["/path/to/mcp-server.js"]
enabled_tools = ["UpdateFeatureStatus"]

Provider Factory

Location: apps/server/src/providers/provider-factory.ts

Routes requests to the appropriate provider based on model string.

Model-Based Routing

export class ProviderFactory {
  /**
   * Get provider for a specific model
   */
  static getProviderForModel(modelId: string): BaseProvider {
    const lowerModel = modelId.toLowerCase();

    // OpenAI/Codex models
    if (lowerModel.startsWith("gpt-") || lowerModel.startsWith("o")) {
      return new CodexProvider();
    }

    // Claude models
    if (lowerModel.startsWith("claude-") ||
        ["haiku", "sonnet", "opus"].includes(lowerModel)) {
      return new ClaudeProvider();
    }

    // Default to Claude
    return new ClaudeProvider();
  }

  /**
   * Check installation status of all providers
   */
  static async checkAllProviders(): Promise<Record<string, InstallationStatus>> {
    const claude = new ClaudeProvider();
    const codex = new CodexProvider();

    return {
      claude: await claude.detectInstallation(),
      codex: await codex.detectInstallation(),
    };
  }
}

Usage in Services

import { ProviderFactory } from "../providers/provider-factory.js";

// In AgentService or AutoModeService
const provider = ProviderFactory.getProviderForModel(model);
const stream = provider.executeQuery(options);

for await (const msg of stream) {
  // Handle messages (format is consistent across all providers)
}

Adding New Providers

Step 1: Create Provider File

Create apps/server/src/providers/[name]-provider.ts:

import { BaseProvider } from "./base-provider.js";
import type {
  ExecuteOptions,
  ProviderMessage,
  InstallationStatus,
  ModelDefinition,
} from "./types.js";

export class CursorProvider extends BaseProvider {
  getName(): string {
    return "cursor";
  }

  async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
    // Implementation here
    // 1. Spawn cursor CLI or use SDK
    // 2. Convert output to ProviderMessage format
    // 3. Yield messages
  }

  async detectInstallation(): Promise<InstallationStatus> {
    // Check if cursor is installed
    // Return { installed: boolean, path?: string, version?: string }
  }

  getAvailableModels(): ModelDefinition[] {
    return [
      {
        id: "cursor-premium",
        name: "Cursor Premium",
        modelString: "cursor-premium",
        provider: "cursor",
        description: "Cursor's premium model",
        contextWindow: 200000,
        maxOutputTokens: 8192,
        supportsVision: true,
        supportsTools: true,
        tier: "premium",
        default: true,
      }
    ];
  }

  supportsFeature(feature: string): boolean {
    const supportedFeatures = ["tools", "text", "vision"];
    return supportedFeatures.includes(feature);
  }
}

Step 2: Add Routing in Factory

Update apps/server/src/providers/provider-factory.ts:

import { CursorProvider } from "./cursor-provider.js";

static getProviderForModel(modelId: string): BaseProvider {
  const lowerModel = modelId.toLowerCase();

  // Cursor models
  if (lowerModel.startsWith("cursor-")) {
    return new CursorProvider();
  }

  // ... existing routing
}

static async checkAllProviders() {
  const cursor = new CursorProvider();

  return {
    claude: await claude.detectInstallation(),
    codex: await codex.detectInstallation(),
    cursor: await cursor.detectInstallation(), // NEW
  };
}

Step 3: Update Models List

Update apps/server/src/routes/models.ts:

{
  id: "cursor-premium",
  name: "Cursor Premium",
  provider: "cursor",
  contextWindow: 200000,
  maxOutputTokens: 8192,
  supportsVision: true,
  supportsTools: true,
}

Step 4: Done!

No changes needed in:

  • AgentService
  • AutoModeService
  • Any business logic

The provider architecture handles everything automatically.


Provider Types

SDK-Based Providers (like Claude)

Characteristics:

  • Direct SDK/library integration
  • No subprocess spawning
  • Native multi-turn support
  • Streaming via async generators

Example: ClaudeProvider using @anthropic-ai/claude-agent-sdk

Advantages:

  • Lower latency
  • More control over options
  • Easier error handling
  • No CLI installation required

CLI-Based Providers (like Codex)

Characteristics:

  • Subprocess spawning
  • JSONL stream parsing
  • Text-based conversation history
  • Requires CLI installation

Example: CodexProvider using codex exec --json

Advantages:

  • Access to CLI-only features
  • No SDK dependency
  • Can use any CLI tool

Implementation Pattern:

  1. Use spawnJSONLProcess() from subprocess-manager.ts
  2. Convert JSONL events to ProviderMessage format
  3. Handle authentication (CLI login or API key)
  4. Implement timeout detection

Best Practices

1. Message Format Consistency

All providers MUST output the same ProviderMessage format so services can handle them uniformly:

// ✅ Correct - Consistent format
yield {
  type: "assistant",
  message: {
    role: "assistant",
    content: [{ type: "text", text: "Response" }]
  }
};

// ❌ Incorrect - Provider-specific format
yield {
  customType: "response",
  data: "Response"
};

2. Error Handling

Always yield error messages, never throw:

// ✅ Correct
try {
  // ...
} catch (error) {
  yield {
    type: "error",
    error: (error as Error).message
  };
  return;
}

// ❌ Incorrect
throw new Error("Provider failed");

3. Abort Signal Support

Respect the abort controller:

if (abortController?.signal.aborted) {
  yield { type: "error", error: "Operation cancelled" };
  return;
}

4. Conversation History

  • SDK providers: Use convertHistoryToMessages() and yield messages
  • CLI providers: Use formatHistoryAsText() and prepend to prompt

5. Image Handling

  • Vision models: Pass images as content blocks
  • Non-vision models: Extract text only using utilities

6. Logging

Use consistent logging prefixes:

console.log(`[${this.getName()}Provider] Operation started`);
console.error(`[${this.getName()}Provider] Error:`, error);

7. Installation Detection

Implement thorough detection:

  • Check multiple installation methods
  • Verify authentication
  • Return detailed status

8. Model Definitions

Provide accurate model metadata:

{
  id: "model-id",
  name: "Human-readable name",
  modelString: "exact-model-string",
  provider: "provider-name",
  description: "What this model is good for",
  contextWindow: 200000,
  maxOutputTokens: 8192,
  supportsVision: true,
  supportsTools: true,
  tier: "premium" | "standard" | "basic",
  default: false
}

Testing Providers

Unit Tests

Test each provider method independently:

describe("ClaudeProvider", () => {
  it("should detect installation", async () => {
    const provider = new ClaudeProvider();
    const status = await provider.detectInstallation();

    expect(status.installed).toBe(true);
    expect(status.method).toBe("sdk");
  });

  it("should stream messages correctly", async () => {
    const provider = new ClaudeProvider();
    const messages = [];

    for await (const msg of provider.executeQuery(options)) {
      messages.push(msg);
    }

    expect(messages.length).toBeGreaterThan(0);
    expect(messages[0].type).toBe("assistant");
  });
});

Integration Tests

Test provider interaction with services:

describe("Provider Integration", () => {
  it("should work with AgentService", async () => {
    const provider = ProviderFactory.getProviderForModel("claude-opus-4-5-20251101");

    // Test full workflow
  });
});

Environment Variables

Claude Provider

# Required (one of):
ANTHROPIC_API_KEY=sk-ant-...
CLAUDE_CODE_OAUTH_TOKEN=...

Codex Provider

# Required (one of):
OPENAI_API_KEY=sk-...
# OR run: codex login

# Optional:
CODEX_CLI_PATH=/custom/path/to/codex

Troubleshooting

Provider Not Found

Problem: ProviderFactory.getProviderForModel() returns wrong provider

Solution: Check model string prefix in factory routing

Authentication Errors

Problem: Provider fails with auth error

Solution:

  1. Check environment variables
  2. For CLI providers, verify CLI login status
  3. Check detectInstallation() output

JSONL Parsing Errors (CLI providers)

Problem: Failed to parse JSONL line

Solution:

  1. Check CLI output format
  2. Verify JSON is valid
  3. Add error handling for malformed lines

Timeout Issues (CLI providers)

Problem: Subprocess hangs

Solution:

  1. Increase timeout in spawnJSONLProcess options
  2. Check CLI process for hangs
  3. Verify abort signal handling

Future Provider Ideas

Potential providers to add:

  1. Cursor Provider (cursor-*)

    • CLI-based
    • Code completion specialist
  2. OpenCode Provider (opencode-*)

    • SDK or CLI-based
    • Open-source alternative
  3. Gemini Provider (gemini-*)

    • Google's AI models
    • SDK-based via @google/generative-ai
  4. Ollama Provider (ollama-*)

    • Local model hosting
    • CLI or HTTP API

Each would follow the same pattern:

  1. Create [name]-provider.ts implementing BaseProvider
  2. Add routing in provider-factory.ts
  3. Update models list
  4. Done!