ADR-044: Component Extraction Strategy

Status

Proposed

Date

2025-12-10

Deciders

Context and Problem Statement

Following ADR-043, we need a detailed strategy for extracting ~131 source files from com.cloudempiere.ai (Java 11, OSGi) and integrating them into idempiere-cli (Java 17+, Quarkus).

Key challenges:

  1. Different dependency injection - OSGi vs Quarkus CDI
  2. Different database access - iDempiere PO vs Quarkus/Panache
  3. Different logging - iDempiere CLogger vs Quarkus/JBoss logging
  4. Different configuration - OSGi config vs Quarkus application.properties
  5. iDempiere-specific code - X_* models, MRole, Env context

Decision Drivers

Decision Outcome

Use a layered extraction strategy with clear transformation rules for each layer.

Extraction Layers

Layer 1: Pure DTOs (No Dependencies)

Extraction: Direct copy with minor cleanup

Source: com.cloudempiere.ai/provider/dto/
Target: org.idempiere.cli/satellite/dto/

Files:
├── AIRequest.java          → SatelliteRequest.java (rename for clarity)
├── AIResponse.java         → SatelliteResponse.java
├── AIMessage.java          → ChatMessage.java
├── AITokenUsage.java       → TokenUsage.java
├── AIStreamCallback.java   → StreamCallback.java
├── AIFunction.java         → ToolFunction.java
├── AIFunctionCall.java     → ToolFunctionCall.java
├── AIHealthStatus.java     → HealthStatus.java
├── AIRateLimitStatus.java  → RateLimitStatus.java
└── AIModelCapabilities.java → ModelCapabilities.java

Transformation Rules:

Example Transformation:

// BEFORE (Java 11, OSGi)
public class AITokenUsage {
    private int promptTokens;
    private int completionTokens;
    private int totalTokens;

    // getters, setters, builder...
}

// AFTER (Java 17+, Quarkus)
@RegisterForReflection
public record TokenUsage(
    int promptTokens,
    int completionTokens,
    int totalTokens
) {
    public static TokenUsage of(int prompt, int completion) {
        return new TokenUsage(prompt, completion, prompt + completion);
    }
}

Layer 2: Business Logic (Minimal Dependencies)

Extraction: Copy with dependency injection adaptation

Source: com.cloudempiere.ai/guardrails/
Target: org.idempiere.cli/satellite/guardrails/

Files:
├── InputGuard.java         → InputGuard.java (adapt logging)
├── OutputGuard.java        → OutputGuard.java
├── ExecutionGuard.java     → ExecutionGuard.java
└── GuardResult.java        → GuardResult.java (direct copy)

Transformation Rules:

OSGi Pattern Quarkus Pattern
private static CLogger log = CLogger.getCLogger(X.class) @Inject Logger log or private static final Logger log = Logger.getLogger(X.class)
Singleton via getInstance() @ApplicationScoped
Manual instantiation @Inject
Env.getCtx() context Pass context as parameter

Example Transformation:

// BEFORE (Java 11, OSGi)
public class InputGuard {
    private static CLogger log = CLogger.getCLogger(InputGuard.class);
    private static InputGuard instance;

    public static InputGuard getInstance() {
        if (instance == null) instance = new InputGuard();
        return instance;
    }

    public GuardResult validate(String input, Properties ctx) {
        int adClientId = Env.getAD_Client_ID(ctx);
        // validation logic...
    }
}

// AFTER (Java 17+, Quarkus)
@ApplicationScoped
public class InputGuard {

    private static final Logger log = Logger.getLogger(InputGuard.class);

    public GuardResult validate(String input, ChatContext ctx) {
        int adClientId = ctx.adClientId();
        // same validation logic...
    }
}

Layer 3: Observability (Metrics & Audit)

Extraction: Adapt to Quarkus observability stack

Source: com.cloudempiere.ai/observability/
Target: org.idempiere.cli/satellite/observability/

Files:
├── AIMetricsListener.java  → SatelliteMetricsListener.java
├── CostGuard.java          → CostGuard.java
└── UsageMetrics.java       → UsageMetrics.java (DTO)

Key Adaptations:

OSGi/iDempiere Quarkus
MAIUsageMetrics.record() Micrometer Counter/Gauge
MAIBudget table lookup Configuration or external service
CCache Quarkus Cache extension
Custom metrics format Prometheus format

Example: Metrics Listener Transformation

// BEFORE (Java 11)
public class AIMetricsListener implements ChatModelListener {
    @Override
    public void onResponse(ChatModelResponseContext ctx) {
        TokenUsage usage = ctx.response().tokenUsage();

        // Record to iDempiere database
        MAIUsageMetrics.record(
            Env.getCtx(),
            usage.inputTokenCount(),
            usage.outputTokenCount(),
            calculateCost(ctx.request().model(), usage)
        );
    }
}

// AFTER (Java 17+, Quarkus)
@ApplicationScoped
public class SatelliteMetricsListener implements ChatModelListener {

    @Inject
    MeterRegistry registry;

    @Inject
    UsageMetricsService metricsService;  // New service

    @Override
    public void onResponse(ChatModelResponseContext ctx) {
        TokenUsage usage = ctx.response().tokenUsage();
        String model = ctx.request().model();

        // Prometheus metrics
        registry.counter("satellite.tokens.input", "model", model)
            .increment(usage.inputTokenCount());
        registry.counter("satellite.tokens.output", "model", model)
            .increment(usage.outputTokenCount());
        registry.counter("satellite.cost.usd", "model", model)
            .increment(calculateCost(model, usage));

        // Persist to database (optional, async)
        metricsService.recordAsync(ctx, usage);
    }
}

Layer 4: Database Access

Extraction: Replace iDempiere PO with Quarkus patterns

Source: com.cloudempiere.ai/model/ (M* classes only, not X_*)
Target: org.idempiere.cli/satellite/model/

Approach: Create new Quarkus entities, migrate business logic

iDempiere Model → Quarkus Entity Mapping:

iDempiere (X_/M*) Quarkus (Panache/JPA)
X_AIG_Provider AigProvider entity
MAIProvider AigProviderRepository + service
X_AIG_UsageMetrics AigUsageMetrics entity
MAIUsageMetrics AigUsageMetricsRepository

Example Entity Transformation:

// BEFORE: iDempiere X_ model (generated)
public class X_AIG_Provider extends PO {
    public static final String COLUMNNAME_AIG_Provider_ID = "AIG_Provider_ID";
    public static final String COLUMNNAME_AIGProviderType = "AIGProviderType";
    // ... 50+ columns
}

// BEFORE: iDempiere M* model (business logic)
public class MAIProvider extends X_AIG_Provider {
    private static CCache<Integer, MAIProvider> s_cache =
        new CCache<>("AIG_Provider", 10, 60);

    public static MAIProvider get(Properties ctx, int id) {
        MAIProvider provider = s_cache.get(id);
        if (provider == null) {
            provider = new MAIProvider(ctx, id, null);
            s_cache.put(id, provider);
        }
        return provider;
    }
}

// AFTER: Quarkus entity
@Entity
@Table(name = "aig_provider")
@Cacheable
public class AigProvider extends PanacheEntity {

    @Column(name = "aig_provider_id")
    public Long aigProviderId;

    @Column(name = "aigprovidertype")
    public String providerType;

    @Column(name = "name")
    public String name;

    @Column(name = "endpoint")
    public String endpoint;

    @Column(name = "apikey")
    public String apiKey;  // Note: Consider encryption

    @Column(name = "modelname")
    public String modelName;

    @Column(name = "isactive")
    public boolean active;

    @Column(name = "isdefault")
    public boolean isDefault;

    @Column(name = "ad_client_id")
    public int adClientId;
}

// AFTER: Quarkus repository
@ApplicationScoped
public class AigProviderRepository implements PanacheRepository<AigProvider> {

    @CacheResult(cacheName = "providers")
    public AigProvider findById(Long id) {
        return find("aigProviderId", id).firstResult();
    }

    public AigProvider findDefault(int adClientId) {
        return find("adClientId = ?1 and isDefault = true and active = true",
                    adClientId).firstResult();
    }

    public List<AigProvider> findByType(int adClientId, String type) {
        return find("adClientId = ?1 and providerType = ?2 and active = true",
                    adClientId, type).list();
    }
}

Layer 5: Context Management

Extraction: Create new iDempiere-agnostic context system

Source: com.cloudempiere.ai/routing/, context/
Target: org.idempiere.cli/satellite/context/

New Components:
├── ChatContext.java       → Request context (from headers)
├── ConversationContext.java    → Chat memory context
├── ContextManager.java         → TTL-based context cache
└── ContextExtractor.java       → Extract from HTTP headers

ChatContext (New):

@RegisterForReflection
public record ChatContext(
    // Security context (from X-iDempiere-* headers)
    int adClientId,
    int adOrgId,
    int adUserId,
    int adRoleId,
    String sessionId,

    // Optional UI context
    Integer adWindowId,
    Integer adTabId,
    Integer recordId,
    String tableName,

    // Locale
    String language,

    // Conversation
    String conversationId
) {
    public static ChatContext fromHeaders(HttpHeaders headers) {
        return new ChatContext(
            parseIntHeader(headers, "X-iDempiere-Client-ID", 0),
            parseIntHeader(headers, "X-iDempiere-Org-ID", 0),
            parseIntHeader(headers, "X-iDempiere-User-ID", 0),
            parseIntHeader(headers, "X-iDempiere-Role-ID", 0),
            headers.getHeaderString("X-iDempiere-Session-ID"),
            parseOptionalInt(headers, "X-iDempiere-Window-ID"),
            parseOptionalInt(headers, "X-iDempiere-Tab-ID"),
            parseOptionalInt(headers, "X-iDempiere-Record-ID"),
            headers.getHeaderString("X-iDempiere-Table-Name"),
            headers.getHeaderString("X-iDempiere-Language"),
            headers.getHeaderString("X-Conversation-ID")
        );
    }
}

Layer 6: Tool Framework

Extraction: Adapt to Quarkus CDI and LangChain4j @Tool

Source: com.cloudempiere.ai/tool/
Target: org.idempiere.cli/satellite/tool/

Approach:
- Keep ITool interface for custom tools
- Add @Tool annotation support for LangChain4j integration
- Use CDI for registry

Dual Registration Pattern:

// Custom tool interface (for complex tools)
public interface ISatelliteTool {
    String getName();
    String getDescription();
    ToolResult execute(Map<String, Object> params, ChatContext ctx);
    Set<ToolPermission> getRequiredPermissions();
}

// LangChain4j tool (for simple tools)
@ApplicationScoped
public class DatabaseTools {

    @Inject
    SecureQueryExecutor queryExecutor;

    @Tool("Execute a read-only SQL query against iDempiere database")
    public String executeQuery(
            @P("SQL SELECT query") String sql,
            @P("Maximum rows to return") int limit) {
        return queryExecutor.execute(sql, limit).toJson();
    }
}

// Registry manages both
@ApplicationScoped
public class ToolRegistry {

    @Inject
    Instance<ISatelliteTool> customTools;

    @Inject
    Instance<Object> langchainTools;  // Classes with @Tool methods

    public List<ToolSpecification> getAllTools() {
        // Merge both registrations
    }
}

Layer 7: REST API (New Development)

New components for OpenAI-compatible API:

Target: org.idempiere.cli/satellite/api/

New Files:
├── SatelliteApiResource.java       → Main REST resource
├── dto/
│   ├── ChatCompletionRequest.java  → OpenAI-compatible request
│   ├── ChatCompletionResponse.java → OpenAI-compatible response
│   ├── ChatCompletionChoice.java   → Response choice
│   ├── ChatCompletionUsage.java    → Token usage
│   ├── EmbeddingRequest.java       → Embedding request
│   ├── EmbeddingResponse.java      → Embedding response
│   └── ErrorResponse.java          → Error format
├── ModelRouter.java                → Route model → provider
└── StreamingResponseWriter.java    → SSE streaming

Dependency Transformation Reference

Logging

Before (iDempiere) After (Quarkus)
CLogger.getCLogger(X.class) Logger.getLogger(X.class)
log.info(msg) log.info(msg) (same)
log.warning(msg) log.warn(msg)
log.severe(msg) log.error(msg)
log.fine(msg) log.debug(msg)
log.finer(msg) log.trace(msg)

Caching

Before (iDempiere) After (Quarkus)
CCache<K,V> @CacheResult, @CacheInvalidate
cache.put(k, v) Automatic via annotation
cache.get(k) Automatic via annotation
Manual TTL in constructor quarkus.cache.caffeine.X.expire-after-write

Configuration

Before (iDempiere) After (Quarkus)
MSysConfig.getValue() @ConfigProperty
Env.getCtx().getProperty() @ConfigProperty or Context param
Hardcoded values application.properties

Database

Before (iDempiere) After (Quarkus)
new Query(ctx, table, where, trx) Panache find() methods
DB.executeUpdate() Panache persist(), update()
ResultSet processing Panache entity mapping
PO.get_ID() entity.id

Threading

Before (iDempiere) After (Quarkus)
CompletableFuture.supplyAsync() @Blocking / @NonBlocking
Manual thread pools Quarkus managed executors
synchronized blocks CDI @Lock or Mutiny

Files NOT to Extract

These files remain in the OSGi plugin:

File/Package Reason
X_*.java Generated from iDempiere schema
Activator.java OSGi lifecycle
component/AIChatWidget.java ZK UI specific
component/AIChatStreamingMessage.java ZK UI specific
OSGI-INF/*.xml OSGi service declarations
*.zul files ZK templates

Testing Strategy

Unit Tests (Extracted Code)

@QuarkusTest
class InputGuardTest {

    @Inject
    InputGuard guard;

    @Test
    void shouldDetectPII() {
        var result = guard.validate("My SSN is 123-45-6789", testContext());
        assertThat(result.status()).isEqualTo(GuardStatus.MASK);
    }

    @Test
    void shouldPassCleanInput() {
        var result = guard.validate("Show me open orders", testContext());
        assertThat(result.status()).isEqualTo(GuardStatus.PASS);
    }
}

Integration Tests (API Layer)

@QuarkusTest
class SatelliteApiResourceTest {

    @Test
    void shouldHandleChatCompletion() {
        given()
            .contentType(ContentType.JSON)
            .header("Authorization", "Bearer test-token")
            .header("X-iDempiere-Client-ID", "1000000")
            .body("""
                {
                    "model": "claude-sonnet-4",
                    "messages": [{"role": "user", "content": "Hello"}]
                }
                """)
        .when()
            .post("/v1/chat/completions")
        .then()
            .statusCode(200)
            .body("choices[0].message.content", notNullValue());
    }
}

Migration Checklist

Per-File Checklist

Per-Phase Checklist



ADR-044 | Version 1.0 | 2025-12-10 Status: Proposed

Path: /docs/developers/architecture/idempiere-hub/044-component-extraction-strategy