diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 50cfd61fd5..53381ab64a 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -4288,6 +4288,21 @@ const _REGISTRY_EAGER: Record = { ], passthroughModels: true, }, + + chipotle: { + id: "chipotle", + alias: "pepper", + format: "openai", + executor: "chipotle", + baseUrl: "https://amelia.chipotle.com", + baseUrls: ["https://amelia.chipotle.com"], + authType: "none", + authHeader: "none", + models: [ + { id: "pepper-1", name: "Pepper (Chipotle AI 🌯)" }, + ], + passthroughModels: true, + }, }; export const REGISTRY: Record = _REGISTRY_EAGER; diff --git a/open-sse/executors/chipotle.ts b/open-sse/executors/chipotle.ts new file mode 100644 index 0000000000..59391c4add --- /dev/null +++ b/open-sse/executors/chipotle.ts @@ -0,0 +1,442 @@ +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import type { ProviderCredentials } from "./base.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +const BASE_URL = "https://amelia.chipotle.com"; +const DOMAIN_CODE = "chipotle"; +const DOMAIN_ID = "23700760-e1e5-4c3c-931d-8804e29a6775"; + +function randomServerId(): string { + return String(Math.floor(Math.random() * 1000)).padStart(3, "0"); +} + +function randomSessionId(): string { + return Array.from({ length: 8 }, () => + Math.random().toString(36).substring(2, 6), + ).join(""); +} + +interface AmeliaSession { + csrfToken: string; + userId: string; + cookieHeader: string; +} + +class AmeliaClient { + private session: AmeliaSession | null = null; + private ws: import("ws").WebSocket | null = null; + private stompConnected = false; + private messageCallbacks: Map void> = new Map(); + private connectPromise: Promise | null = null; + + async init(): Promise { + const res = await fetch(`${BASE_URL}/Amelia/api/init`, { + headers: { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + Origin: BASE_URL, + Referer: `${BASE_URL}/Amelia/ui/chipotle/chat?embed=iframe`, + }, + redirect: "manual", + }); + + if (!res.ok) throw new Error(`Amelia init failed: ${res.status}`); + + const data = (await res.json()) as { + csrfToken: string; + user: { userId: string; anonymous: boolean }; + }; + + const setCookies = res.headers.getSetCookie?.() ?? []; + const cookieHeader = setCookies.map((c) => c.split(";")[0]).join("; "); + + this.session = { + csrfToken: data.csrfToken, + userId: data.user.userId, + cookieHeader, + }; + } + + async connect(): Promise { + if (!this.session) throw new Error("Call init() first"); + if (this.connectPromise) return this.connectPromise; + + this.connectPromise = this._connect(); + try { + await this.connectPromise; + } finally { + this.connectPromise = null; + } + } + + private async _connect(): Promise { + const { WebSocket } = await import("ws"); + const server = randomServerId(); + const sessionId = randomSessionId(); + const wsUrl = `wss://amelia.chipotle.com/Amelia/api/sock/${server}/${sessionId}/websocket`; + + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("WS connect timeout")), + 15_000, + ); + + const ws = new WebSocket(wsUrl, { + headers: { + Cookie: this.session!.cookieHeader, + Origin: BASE_URL, + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + }, + }); + + ws.on("open", () => {}); + + ws.on("message", (raw: Buffer | string) => { + const data = raw.toString(); + this.handleSockJSFrame(data, resolve, reject, timeout); + }); + + ws.on("error", (err: Error) => { + clearTimeout(timeout); + reject(err); + }); + + ws.on("close", () => { + this.stompConnected = false; + this.ws = null; + }); + + this.ws = ws; + }); + } + + private handleSockJSFrame( + frame: string, + resolveConnect: () => void, + rejectConnect: (e: Error) => void, + timeout: NodeJS.Timeout, + ): void { + if (frame === "o") { + this.sendSockJS(this.buildStompConnect()); + return; + } + if (frame === "h") return; + if (frame.startsWith("a")) { + try { + const arr = JSON.parse(frame.slice(1)) as string[]; + for (const msg of arr) { + this.handleStompFrame(msg, resolveConnect, rejectConnect, timeout); + } + } catch { + // ignore + } + } + } + + private handleStompFrame( + frame: string, + resolveConnect: () => void, + rejectConnect: (e: Error) => void, + timeout: NodeJS.Timeout, + ): void { + const command = frame.split("\n")[0].replace(/\r$/, ""); + + if (command === "CONNECTED") { + this.stompConnected = true; + this.sendSockJS( + this.buildStompSubscribe( + `/queue/session.${this.session!.userId}`, + "sub-0", + ), + ); + this.sendSockJS( + this.buildStompSubscribe("/user/queue/session", "sub-1"), + ); + clearTimeout(timeout); + resolveConnect(); + return; + } + + if (command === "MESSAGE") { + this.handleStompMessage(frame); + return; + } + + if (command === "ERROR") { + clearTimeout(timeout); + rejectConnect(new Error(`STOMP ERROR: ${frame}`)); + } + } + + private handleStompMessage(frame: string): void { + const nullIdx = frame.indexOf("\0"); + let bodyStart = frame.indexOf("\n\n"); + if (bodyStart === -1) bodyStart = frame.indexOf("\r\n\r\n"); + const headerLen = bodyStart !== -1 + ? (frame[bodyStart + 2] === "\r" ? 4 : 2) + : 0; + let body = ""; + if (bodyStart !== -1) { + body = frame + .substring(bodyStart + headerLen, nullIdx !== -1 ? nullIdx : undefined) + .replace(/\0$/, ""); + } + if (!body) return; + + let text = body; + try { + const parsed = JSON.parse(body) as Record; + if (parsed.type === "message" && parsed.body) { + const b = parsed.body as Record; + text = (b.text as string) || JSON.stringify(b); + } else if (parsed.text) { + text = parsed.text as string; + } else if (parsed.message) { + text = parsed.message as string; + } else { + return; + } + } catch { + // plain text + } + + for (const [id, cb] of this.messageCallbacks.entries()) { + cb(text); + this.messageCallbacks.delete(id); + } + } + + async chat(message: string, timeoutMs = 15_000, signal?: AbortSignal | null): Promise { + if (!this.stompConnected) { + await this.init(); + await this.connect(); + } + + if (signal?.aborted) { + throw new DOMException("Aborted", "AbortError"); + } + + const callbackId = crypto.randomUUID(); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.messageCallbacks.delete(callbackId); + reject(new Error("Response timeout")); + }, timeoutMs); + + const onAbort = () => { + clearTimeout(timer); + this.messageCallbacks.delete(callbackId); + reject(new DOMException("Aborted", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + this.messageCallbacks.set(callbackId, (text) => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + resolve(text); + }); + + const payload = JSON.stringify({ + message, + domainCode: DOMAIN_CODE, + conversationId: null, + type: "text", + }); + + this.sendSockJS(this.buildStompSend("/app/send", payload)); + }); + } + + private buildStompConnect(): string { + return `CONNECT\naccept-version:1.1,1.0\nheart-beat:0,0\nX-CSRF-TOKEN:${this.session!.csrfToken}\n\n\0`; + } + + private buildStompSubscribe(destination: string, id: string): string { + return `SUBSCRIBE\ndestination:${destination}\nid:${id}\n\n\0`; + } + + private buildStompSend(destination: string, body: string): string { + return `SEND\ndestination:${destination}\ncontent-type:application/json\ncontent-length:${Buffer.byteLength(body)}\n\n${body}\0`; + } + + private sendSockJS(stompFrame: string): void { + if (!this.ws || this.ws.readyState !== 1) { + throw new Error("WebSocket not open"); + } + this.ws.send(JSON.stringify([stompFrame])); + } + + async close(): Promise { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.stompConnected = false; + this.session = null; + } +} + +// ── Client pool ────────────────────────────────────────────────────────── + +const POOL_MAX = 5; +const pool: AmeliaClient[] = []; + +async function getClient(): Promise { + if (pool.length > 0) return pool.pop()!; + const client = new AmeliaClient(); + await client.init(); + await client.connect(); + return client; +} + +function releaseClient(client: AmeliaClient): void { + if (pool.length < POOL_MAX) { + pool.push(client); + } else { + client.close().catch(() => {}); + } +} + +// ── Executor ───────────────────────────────────────────────────────────── + +export class ChipotleExecutor extends BaseExecutor { + constructor() { + super("chipotle", { format: "openai" }); + } + + buildUrl(_model: string, _stream: boolean): string { + return `${BASE_URL}/Amelia/api/chat`; + } + + buildHeaders(_credentials: ProviderCredentials): Record { + return { "Content-Type": "application/json" }; + } + + transformRequest(model: string, body: unknown, _stream: boolean): unknown { + if (typeof body === "object" && body !== null) { + return { ...(body as Record), model }; + } + return body; + } + + async execute(input: ExecuteInput): Promise<{ + response: Response; + url: string; + headers: Record; + transformedBody: unknown; + }> { + const { model, stream, body, signal, log } = input; + const encoder = new TextEncoder(); + + if (signal?.aborted) { + return { + response: new Response( + encoder.encode( + JSON.stringify({ + error: { + message: "Request aborted", + type: "abort", + code: "ABORTED", + }, + }), + ), + { status: 499, headers: { "Content-Type": "application/json" } }, + ), + url: this.buildUrl(model, stream), + headers: this.buildHeaders(input.credentials), + transformedBody: body, + }; + } + + const messages = (body as { messages?: Array<{ role: string; content: string }> }) + ?.messages ?? []; + const lastUser = [...messages].reverse().find((m) => m.role === "user"); + const prompt = lastUser?.content ?? ""; + + let client: AmeliaClient | null = null; + try { + client = await getClient(); + log?.info?.("CHIPOTLE", `Sending to Pepper (model=${model})`); + + const responseText = await client.chat(prompt, 15_000, signal); + releaseClient(client); + client = null; + + const requestId = `chatcmpl-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + if (stream) { + const sse = [ + `data: ${JSON.stringify({ id: requestId, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant", content: responseText }, finish_reason: null }] })}`, + `data: ${JSON.stringify({ id: requestId, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}`, + "data: [DONE]", + "", + ].join("\n"); + + return { + response: new Response(encoder.encode(sse), { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }, + }), + url: this.buildUrl(model, stream), + headers: this.buildHeaders(input.credentials), + transformedBody: body, + }; + } + + const json = JSON.stringify({ + id: requestId, + object: "chat.completion", + created, + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: responseText }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }); + + return { + response: new Response(encoder.encode(json), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + url: this.buildUrl(model, stream), + headers: this.buildHeaders(input.credentials), + transformedBody: body, + }; + } catch (err) { + if (client) client.close().catch(() => {}); + const msg = err instanceof Error ? err.message : String(err); + log?.error?.("CHIPOTLE", `Error: ${msg}`); + + return { + response: new Response( + encoder.encode( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(msg), + type: "upstream_error", + code: "CHIPOTLE_ERROR", + }, + }), + ), + { status: 502, headers: { "Content-Type": "application/json" } }, + ), + url: this.buildUrl(model, stream), + headers: this.buildHeaders(input.credentials), + transformedBody: body, + }; + } + } +} + +export default ChipotleExecutor; \ No newline at end of file diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index b649ca751b..f6af45bb5e 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -48,6 +48,7 @@ import { DoubaoWebExecutor } from "./doubao-web.ts"; import { QwenWebExecutor } from "./qwen-web.ts"; import { KimiExecutor } from "./kimi.ts" import { TheOldLlmExecutor } from "./theoldllm.ts"; +import { ChipotleExecutor } from "./chipotle.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -137,6 +138,8 @@ const executors = { qw: new QwenWebExecutor(), // Alias theoldllm: new TheOldLlmExecutor(), tllm: new TheOldLlmExecutor(), // Alias + chipotle: new ChipotleExecutor(), + pepper: new ChipotleExecutor(), // Alias }; const defaultCache = new Map(); @@ -194,3 +197,4 @@ export { T3ChatWebExecutor } from "./t3-chat-web.ts"; export { InnerAiExecutor } from "./inner-ai.ts"; export { QwenWebExecutor } from "./qwen-web.ts"; export { TheOldLlmExecutor } from "./theoldllm.ts"; +export { ChipotleExecutor } from "./chipotle.ts"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index da1555abf5..df46a689f6 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -73,6 +73,21 @@ export const NOAUTH_PROVIDERS = { authHint: "No credentials required. The executor auto-generates access tokens via an embedded Playwright browser instance.", }, + chipotle: { + id: "chipotle", + alias: "pepper", + name: "Chipotle Pepper AI (Free)", + icon: "restaurant", + color: "#C41230", + textIcon: "🌯", + website: "https://amelia.chipotle.com", + noAuth: true, + hasFree: true, + freeNote: + "Free — Chipotle's Pepper AI (IPsoft Amelia). Anonymous sessions, no API key. Rate-limited.", + authHint: + "No credentials required. Uses Chipotle's public support chatbot via reverse-engineered SockJS/STOMP protocol.", + }, "veoaifree-web": { id: "veoaifree-web", alias: "veo-free", diff --git a/tests/unit/chipotle-executor.test.ts b/tests/unit/chipotle-executor.test.ts new file mode 100644 index 0000000000..3354cf7b2b --- /dev/null +++ b/tests/unit/chipotle-executor.test.ts @@ -0,0 +1,56 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { ChipotleExecutor } from "../../open-sse/executors/chipotle.ts"; + +const executor = new ChipotleExecutor(); + +describe("ChipotleExecutor", () => { + it("buildHeaders returns static headers", () => { + const headers = (executor as any).buildHeaders({}); + assert.strictEqual(headers["Content-Type"], "application/json"); + }); + + it("buildUrl returns Amelia endpoint", () => { + const url = executor.buildUrl("pepper-1", false); + assert.ok(url.includes("amelia.chipotle.com")); + }); + + it("transformRequest passes model through", () => { + const result = (executor as any).transformRequest( + "pepper-1", + { model: "pepper-1", messages: [{ role: "user", content: "hi" }] }, + false, + ); + assert.strictEqual(result.model, "pepper-1"); + }); + + it("returns 499 on pre-aborted signal", async () => { + const controller = new AbortController(); + controller.abort(new Error("cancelled")); + + const result = await executor.execute({ + model: "pepper-1", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: controller.signal, + credentials: {}, + log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + }); + + assert.strictEqual((result as any).response.status, 499); + }); + + it("is registered in executor index", async () => { + const { getExecutor } = await import("../../open-sse/executors/index.ts"); + const exec = getExecutor("chipotle"); + assert.ok(exec, "chipotle executor should be registered"); + assert.ok(exec instanceof ChipotleExecutor); + }); + + it("pepper alias works", async () => { + const { getExecutor } = await import("../../open-sse/executors/index.ts"); + const exec = getExecutor("pepper"); + assert.ok(exec, "pepper alias should be registered"); + assert.ok(exec instanceof ChipotleExecutor); + }); +});