Skip to content

Commit 7715e14

Browse files
committed
feat: add Commerce Gateway adapter package and Apache license alignment
Introduce @loop-engine/adapter-commerce-gateway with typed client, AI actor factories, and evidence formatters, and align adapter-openclaw package licensing to Apache-2.0.
1 parent c80a568 commit 7715e14

13 files changed

Lines changed: 657 additions & 1 deletion

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# @loop-engine/adapter-commerce-gateway
2+
3+
`@loop-engine/adapter-commerce-gateway` provides a typed Commerce Gateway client and actor factories that turn live commerce data into governed Loop Engine evidence.
4+
5+
## Install
6+
7+
```bash
8+
npm install @loop-engine/adapter-commerce-gateway
9+
```
10+
11+
Install only the LLM SDK you use:
12+
13+
```bash
14+
npm install @anthropic-ai/sdk
15+
# or
16+
npm install openai
17+
```
18+
19+
## Environment
20+
21+
- `COMMERCE_GATEWAY_URL`
22+
- `COMMERCE_GATEWAY_API_KEY`
23+
24+
## Read/write boundary
25+
26+
- AI actors call **read** endpoints only (`getInventory`, `getDemandForecast`, `getSuppliers`, pricing reads).
27+
- Write operations (`createPurchaseOrder`) run in automation flows after human approval has passed loop guards.
28+
29+
## Quick start
30+
31+
```ts
32+
import { CommerceGatewayClient, buildProcurementActor } from "@loop-engine/adapter-commerce-gateway";
33+
34+
const client = new CommerceGatewayClient({
35+
baseUrl: process.env.COMMERCE_GATEWAY_URL!,
36+
apiKey: process.env.COMMERCE_GATEWAY_API_KEY!
37+
});
38+
39+
const actor = buildProcurementActor({
40+
gatewayClient: client,
41+
llmProvider: "claude",
42+
apiKey: process.env.ANTHROPIC_API_KEY!,
43+
confidenceThreshold: 0.8
44+
});
45+
```
46+
47+
## API surface
48+
49+
- `CommerceGatewayClient`
50+
- `getInventory(sku)`
51+
- `getInventoryBatch(skus)`
52+
- `getDemandForecast(sku, horizon?)`
53+
- `getSuppliers(sku)`
54+
- `getCurrentPrice(sku)`
55+
- `getPriceHistory(sku, days)`
56+
- `createPurchaseOrder(order)`
57+
- `recordLoopOutcome(outcome)` (optional endpoint support by deployment)
58+
- `buildProcurementActor(options)`
59+
- `buildPricingActor(options)` (throws: coming in v0.2)
60+
- `buildProcurementEvidence(cgData, llmRecommendation, requestIds)`
61+
- `toLoopOutcomeEvent(input)`
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"name": "@loop-engine/adapter-commerce-gateway",
3+
"version": "0.1.0",
4+
"description": "LLM Commerce Gateway adapter for Loop Engine — AI procurement and pricing actors",
5+
"license": "Apache-2.0",
6+
"type": "module",
7+
"main": "./dist/index.cjs",
8+
"module": "./dist/index.js",
9+
"types": "./dist/index.d.ts",
10+
"exports": {
11+
".": {
12+
"import": "./dist/index.js",
13+
"require": "./dist/index.cjs",
14+
"types": "./dist/index.d.ts"
15+
}
16+
},
17+
"scripts": {
18+
"build": "tsup",
19+
"lint": "tsc -p tsconfig.json --noEmit",
20+
"typecheck": "tsc -p tsconfig.json --noEmit"
21+
},
22+
"dependencies": {
23+
"@loop-engine/core": "workspace:*",
24+
"@loop-engine/sdk": "workspace:*"
25+
},
26+
"peerDependencies": {
27+
"@anthropic-ai/sdk": ">=0.39.0",
28+
"openai": ">=4.0.0"
29+
},
30+
"peerDependenciesMeta": {
31+
"@anthropic-ai/sdk": {
32+
"optional": true
33+
},
34+
"openai": {
35+
"optional": true
36+
}
37+
},
38+
"devDependencies": {
39+
"@anthropic-ai/sdk": "^0.39.0",
40+
"openai": "^4.0.0",
41+
"tsup": "^8.0.0",
42+
"typescript": "^5.0.0"
43+
},
44+
"files": [
45+
"dist/",
46+
"README.md",
47+
"LICENSE"
48+
]
49+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { Evidence } from "@loop-engine/core";
2+
3+
export interface BuildPricingActorOptions {
4+
llmProvider: "claude" | "openai";
5+
apiKey: string;
6+
}
7+
8+
export function buildPricingActor(_options: BuildPricingActorOptions) {
9+
return async (): Promise<Evidence> => {
10+
throw new Error("buildPricingActor is coming in v0.2");
11+
};
12+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import Anthropic from "@anthropic-ai/sdk";
2+
import OpenAI from "openai";
3+
import type { Evidence } from "@loop-engine/core";
4+
import { CommerceGatewayClient } from "../client/commerce-gateway-client";
5+
import type { DemandForecast, InventoryRecord, Supplier } from "../client/types";
6+
import { buildProcurementEvidence, type LlmRecommendation } from "../formatters/evidence";
7+
8+
export interface ActorContext {
9+
instance: {
10+
data?: Record<string, unknown>;
11+
};
12+
}
13+
14+
export interface BuildProcurementActorOptions {
15+
gatewayClient: CommerceGatewayClient;
16+
llmProvider: "claude" | "openai";
17+
apiKey: string;
18+
confidenceThreshold?: number;
19+
}
20+
21+
export function buildProcurementActor(options: BuildProcurementActorOptions) {
22+
return async (context: ActorContext): Promise<Evidence> => {
23+
const sku = String(context.instance.data?.sku ?? "");
24+
if (!sku) throw new Error("ActorContext.instance.data.sku is required");
25+
26+
// Read-only Gateway access in AI actor: inventory, forecast, suppliers.
27+
const [inventory, forecast, suppliers] = await Promise.all([
28+
options.gatewayClient.getInventory(sku),
29+
options.gatewayClient.getDemandForecast(sku),
30+
options.gatewayClient.getSuppliers(sku)
31+
]);
32+
33+
const recommendation = await generateRecommendation(
34+
options.llmProvider,
35+
options.apiKey,
36+
{ inventory, forecast, suppliers },
37+
options.confidenceThreshold ?? 0.8
38+
);
39+
const requestIds = [inventory.requestId, forecast.requestId, ...suppliers.map((supplier) => supplier.requestId)].filter(
40+
(value): value is string => Boolean(value)
41+
);
42+
return buildProcurementEvidence({ inventory, forecast, suppliers }, recommendation, requestIds);
43+
};
44+
}
45+
46+
async function generateRecommendation(
47+
provider: "claude" | "openai",
48+
apiKey: string,
49+
payload: {
50+
inventory: InventoryRecord;
51+
forecast: DemandForecast;
52+
suppliers: Supplier[];
53+
},
54+
confidenceThreshold: number
55+
): Promise<LlmRecommendation> {
56+
if (provider === "claude") {
57+
const client = new Anthropic({ apiKey });
58+
const response = await client.messages.create({
59+
model: "claude-sonnet-4-20250514",
60+
max_tokens: 500,
61+
system:
62+
"You are a procurement analyst. Produce strict JSON with keys recommendedQty, supplierId, confidence, rationale.",
63+
messages: [{ role: "user", content: JSON.stringify(payload) }]
64+
});
65+
const textBlock = response.content.find((block) => block.type === "text");
66+
if (!textBlock || textBlock.type !== "text") throw new Error("Claude response missing text block");
67+
return parseRecommendation(textBlock.text, "claude-sonnet-4-20250514", confidenceThreshold);
68+
}
69+
70+
const client = new OpenAI({ apiKey });
71+
const response = await client.chat.completions.create({
72+
model: "gpt-4o",
73+
response_format: { type: "json_object" },
74+
messages: [
75+
{
76+
role: "system",
77+
content: "You are a procurement analyst. Return strict JSON with recommendedQty, supplierId, confidence, rationale."
78+
},
79+
{ role: "user", content: JSON.stringify(payload) }
80+
]
81+
});
82+
const text = response.choices[0]?.message.content;
83+
if (!text) throw new Error("OpenAI response missing content");
84+
return parseRecommendation(text, "gpt-4o", confidenceThreshold);
85+
}
86+
87+
function parseRecommendation(text: string, model: string, confidenceThreshold: number): LlmRecommendation {
88+
const parsed = JSON.parse(text) as {
89+
recommendedQty: number;
90+
supplierId: string;
91+
confidence: number;
92+
rationale: string;
93+
};
94+
if (
95+
typeof parsed.recommendedQty !== "number" ||
96+
typeof parsed.supplierId !== "string" ||
97+
typeof parsed.confidence !== "number" ||
98+
typeof parsed.rationale !== "string"
99+
) {
100+
throw new Error("Recommendation schema invalid");
101+
}
102+
return {
103+
recommendedQty: parsed.recommendedQty,
104+
supplierId: parsed.supplierId,
105+
// Guard handles pass/fail; actor only reports model confidence.
106+
confidence: Math.max(0, Math.min(parsed.confidence, 1)),
107+
rationale: `${parsed.rationale} (threshold: ${confidenceThreshold.toFixed(2)})`,
108+
model
109+
};
110+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type {
2+
CommerceGatewayClientOptions,
3+
CreatePORequest,
4+
DemandForecast,
5+
InventoryRecord,
6+
LoopOutcomeEvent,
7+
PORecord,
8+
PriceRecord,
9+
Supplier
10+
} from "./types";
11+
12+
export class CommerceGatewayClient {
13+
constructor(private readonly options: CommerceGatewayClientOptions) {}
14+
15+
async getInventory(sku: string): Promise<InventoryRecord> {
16+
return this.request<InventoryRecord>(`/inventory/${encodeURIComponent(sku)}`);
17+
}
18+
19+
async getInventoryBatch(skus: string[]): Promise<InventoryRecord[]> {
20+
return this.request<InventoryRecord[]>("/inventory/batch", {
21+
method: "POST",
22+
body: JSON.stringify({ skus })
23+
});
24+
}
25+
26+
async getDemandForecast(sku: string, horizon?: number): Promise<DemandForecast> {
27+
const params = new URLSearchParams();
28+
if (horizon !== undefined) params.set("horizon", String(horizon));
29+
const query = params.size > 0 ? `?${params}` : "";
30+
return this.request<DemandForecast>(`/demand-forecast/${encodeURIComponent(sku)}${query}`);
31+
}
32+
33+
async getSuppliers(sku: string): Promise<Supplier[]> {
34+
return this.request<Supplier[]>(`/suppliers/${encodeURIComponent(sku)}`);
35+
}
36+
37+
async getCurrentPrice(sku: string): Promise<PriceRecord> {
38+
return this.request<PriceRecord>(`/pricing/${encodeURIComponent(sku)}`);
39+
}
40+
41+
async getPriceHistory(sku: string, days: number): Promise<PriceRecord[]> {
42+
return this.request<PriceRecord[]>(`/pricing/${encodeURIComponent(sku)}/history?days=${days}`);
43+
}
44+
45+
async createPurchaseOrder(order: CreatePORequest): Promise<PORecord> {
46+
return this.request<PORecord>("/purchase-orders", {
47+
method: "POST",
48+
body: JSON.stringify(order)
49+
});
50+
}
51+
52+
async recordLoopOutcome(outcome: LoopOutcomeEvent): Promise<void> {
53+
// Endpoint availability differs across Commerce Gateway deployments.
54+
// If unavailable, caller can ignore this optional integration.
55+
try {
56+
await this.request<unknown>("/loop-outcomes", {
57+
method: "POST",
58+
body: JSON.stringify(outcome)
59+
});
60+
} catch (error) {
61+
const message = error instanceof Error ? error.message : String(error);
62+
throw new Error(`recordLoopOutcome is unavailable on this Commerce Gateway instance: ${message}`);
63+
}
64+
}
65+
66+
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
67+
const baseUrl = this.options.baseUrl.replace(/\/$/, "");
68+
const response = await fetch(`${baseUrl}${path}`, {
69+
...init,
70+
headers: {
71+
"Content-Type": "application/json",
72+
Authorization: `Bearer ${this.options.apiKey}`,
73+
...(init.headers as Record<string, string> | undefined)
74+
}
75+
});
76+
if (!response.ok) {
77+
throw new Error(`Commerce Gateway request failed: ${response.status} ${response.statusText}`);
78+
}
79+
if (response.status === 204) return undefined as T;
80+
const payload = (await response.json()) as T;
81+
const requestId = response.headers.get("x-request-id");
82+
if (requestId && payload && typeof payload === "object") {
83+
(payload as Record<string, unknown>).requestId = requestId;
84+
}
85+
return payload;
86+
}
87+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
export interface InventoryRecord {
2+
sku: string;
3+
currentStock: number;
4+
reorderPoint: number;
5+
leadTimeDays?: number;
6+
requestId?: string;
7+
}
8+
9+
export interface DemandForecast {
10+
sku: string;
11+
forecastedDemand: number;
12+
confidence: number;
13+
horizonDays?: number;
14+
requestId?: string;
15+
}
16+
17+
export interface Supplier {
18+
id: string;
19+
sku: string;
20+
unitCost: number;
21+
leadTimeDays: number;
22+
requestId?: string;
23+
}
24+
25+
export interface PriceRecord {
26+
sku: string;
27+
currency: string;
28+
value: number;
29+
effectiveAt: string;
30+
requestId?: string;
31+
}
32+
33+
export interface CreatePORequest {
34+
sku: string;
35+
qty: number;
36+
supplierId: string;
37+
expectedUnitCost?: number;
38+
}
39+
40+
export interface PORecord {
41+
id: string;
42+
sku: string;
43+
qty: number;
44+
supplierId: string;
45+
status: string;
46+
createdAt?: string;
47+
requestId?: string;
48+
}
49+
50+
export interface LoopOutcomeEvent {
51+
loopId: string;
52+
aggregateId: string;
53+
outcomeId: string;
54+
occurredAt: string;
55+
metadata?: Record<string, unknown>;
56+
}
57+
58+
export interface CommerceGatewayClientOptions {
59+
baseUrl: string;
60+
apiKey: string;
61+
}

0 commit comments

Comments
 (0)