From 73e9b17305051275267673d0a3dd1a49dcb45007 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Tue, 13 Oct 2026 16:02:22 +0700 Subject: [PATCH] feat(pi): add Cursor models through the OMP gateway --- pi/extensions/omp-gateway/catalog.ts | 138 ++++++++++++ pi/extensions/omp-gateway/index.ts | 146 ++++++++++++ .../omp-gateway/tests/catalog.test.ts | 77 +++++++ .../omp-gateway/tests/runtime.test.cjs | 212 ++++++++++++++++++ 4 files changed, 573 insertions(+) create mode 100644 pi/extensions/omp-gateway/catalog.ts create mode 100644 pi/extensions/omp-gateway/index.ts create mode 100644 pi/extensions/omp-gateway/tests/catalog.test.ts create mode 100644 pi/extensions/omp-gateway/tests/runtime.test.cjs diff --git a/pi/extensions/omp-gateway/catalog.ts b/pi/extensions/omp-gateway/catalog.ts new file mode 100644 index 0000000..c1b8682 --- /dev/null +++ b/pi/extensions/omp-gateway/catalog.ts @@ -0,0 +1,138 @@ +import type { Model, ModelThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai"; + +export const PROVIDER_ID = "cursor"; +export const THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function positiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +function rate(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function modelId(value: unknown): value is string { + return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(value); +} + +function modalities(value: unknown): ("text" | "image")[] { + if (!Array.isArray(value)) return []; + const result: ("text" | "image")[] = []; + for (const item of value) { + if ((item === "text" || item === "image") && !result.includes(item)) result.push(item); + } + return result; +} + +function thinkingLevel(value: unknown): ModelThinkingLevel | undefined { + switch (value) { + case "off": case "minimal": case "low": case "medium": case "high": case "xhigh": case "max": return value; + default: return undefined; + } +} + +type GatewayCursorRow = { + id: string; + name: string | undefined; + input: Model<"openai-completions">["input"]; + contextWindow: number | undefined; + maxTokens: number | undefined; +}; + +/** Reject accidental connections to a gateway backed by a shared credential vault. */ +export function assertCursorOnlyCatalog(payload: unknown): void { + if (!record(payload) || !Array.isArray(payload.data)) throw new Error("OMP gateway returned an invalid model catalog."); + if (payload.data.some(row => !record(row) || row.owned_by !== PROVIDER_ID || + typeof row.id !== "string" || !row.id.startsWith(`${PROVIDER_ID}/`))) { + throw new Error("OMP gateway exposes non-Cursor models. Use the dedicated .omp-cursor configuration."); + } +} + +export function gatewayCursorRows(payload: unknown): GatewayCursorRow[] { + if (!record(payload) || !Array.isArray(payload.data)) throw new Error("OMP gateway returned an invalid model catalog."); + const rows: GatewayCursorRow[] = []; + for (const row of payload.data) { + if (!record(row) || row.owned_by !== PROVIDER_ID || typeof row.id !== "string" || + !row.id.startsWith(`${PROVIDER_ID}/`) || (row.kind !== undefined && row.kind !== "chat") || + row.supports_tools === false) continue; + const id = row.id.slice(PROVIDER_ID.length + 1); + if (!modelId(id)) continue; + rows.push({ + id, + name: typeof row.display_name === "string" ? row.display_name : undefined, + input: modalities(row.input_modalities), + contextWindow: positiveInteger(row.context_length) ? row.context_length : undefined, + maxTokens: positiveInteger(row.max_output_tokens) ? row.max_output_tokens : undefined, + }); + } + return rows; +} + +/** Join the gateway's routable models with OMP's pricing and thinking metadata. */ +export function discoverCursorModels(gateway: unknown, local: unknown, baseUrl: string): Model<"openai-completions">[] { + const rows = gatewayCursorRows(gateway); + if (!record(local) || !Array.isArray(local.models)) throw new Error("OMP CLI returned an invalid model catalog."); + const metadata = new Map>(); + for (const row of local.models) { + if (record(row) && row.provider === PROVIDER_ID && modelId(row.id) && (row.kind === undefined || row.kind === "chat")) { + metadata.set(row.id, row); + } + } + const models = new Map>(); + for (const row of rows) { + const id = row.id; + const meta = metadata.get(id); + if (!meta) continue; + const cost = meta.cost; + if (!record(cost) || !rate(cost.input) || !rate(cost.output) || !rate(cost.cacheRead) || !rate(cost.cacheWrite)) { + throw new Error(`OMP model ${id} has invalid pricing metadata.`); + } + if (!positiveInteger(meta.contextWindow) || !positiveInteger(meta.maxTokens) || typeof meta.reasoning !== "boolean") { + throw new Error(`OMP model ${id} has invalid capability metadata.`); + } + const localInput = modalities(meta.input); + const input = row.input.filter(item => localInput.includes(item)); + if (!input.includes("text")) throw new Error(`OMP model ${id} does not declare text input.`); + const thinkingLevelMap: ThinkingLevelMap = {}; + const supported = new Set(); + if (Array.isArray(meta.thinking)) { + for (const item of meta.thinking) { + const level = thinkingLevel(item); + if (level) supported.add(level); + } + } + if (meta.reasoning && supported.size === 0) throw new Error(`OMP model ${id} has no thinking-level metadata.`); + for (const level of THINKING_LEVELS) thinkingLevelMap[level] = supported.has(level) ? level : null; + const name = row.name ?? (typeof meta.name === "string" ? meta.name : id); + models.set(id, { + id, provider: PROVIDER_ID, name: name.replace(/[\u0000-\u001f\u007f-\u009f]/g, ""), + api: "openai-completions", baseUrl, input, + reasoning: meta.reasoning, thinkingLevelMap, + contextWindow: row.contextWindow === undefined ? meta.contextWindow : Math.min(row.contextWindow, meta.contextWindow), + maxTokens: row.maxTokens === undefined ? meta.maxTokens : Math.min(row.maxTokens, meta.maxTokens), + cost: { input: cost.input, output: cost.output, cacheRead: cost.cacheRead, cacheWrite: cost.cacheWrite }, + compat: { supportsReasoningEffort: true, supportsDeveloperRole: true, supportsStore: false, maxTokensField: "max_tokens" }, + }); + } + if (rows.length > 0 && models.size === 0) throw new Error("OMP gateway models do not match the local OMP catalog."); + return [...models.values()]; +} + +/** Preserve Pi's public model id while routing unambiguously through Cursor. */ +export function gatewayPayload(payload: unknown, id: string, sessionId?: string): Record { + if (!record(payload) || !modelId(id)) throw new Error("Invalid OMP gateway request payload."); + return { ...payload, model: `${PROVIDER_ID}/${id}`, ...(sessionId ? { prompt_cache_key: sessionId } : {}) }; +} + +export function loopbackBaseUrl(value: string): string { + const url = new URL(value); + if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || !['http:', 'https:'].includes(url.protocol) || + url.username || url.password || url.search || url.hash || url.pathname.replace(/\/$/, "") !== "/v1") { + throw new Error("OMP_GATEWAY_URL must be an authenticated loopback URL ending in /v1."); + } + return url.toString().replace(/\/$/, ""); +} diff --git a/pi/extensions/omp-gateway/index.ts b/pi/extensions/omp-gateway/index.ts new file mode 100644 index 0000000..def16a1 --- /dev/null +++ b/pi/extensions/omp-gateway/index.ts @@ -0,0 +1,146 @@ +import { execFile } from "node:child_process"; +import { readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { createProvider, type ProviderStreams, type StreamOptions } from "@earendil-works/pi-ai"; +import { openAICompletionsApi } from "@earendil-works/pi-ai/compat"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { assertCursorOnlyCatalog, discoverCursorModels, gatewayCursorRows, gatewayPayload, loopbackBaseUrl, PROVIDER_ID } from "./catalog.ts"; + +const execute = promisify(execFile); +const TIMEOUT_MS = 10_000; + +function timeoutSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +export default function ompGateway(pi: ExtensionAPI): void { + const baseUrl = loopbackBaseUrl(process.env.OMP_GATEWAY_URL ?? "http://127.0.0.1:4000/v1"); + const tokenFile = process.env.OMP_GATEWAY_TOKEN_FILE ?? join(homedir(), ".omp-cursor", "auth-gateway.token"); + const omp = process.env.OMP_GATEWAY_COMMAND ?? "omp"; + // Pi exports its own PI_CODING_AGENT_DIR. Never let OMP create its vault there. + const metadataEnv: NodeJS.ProcessEnv = { + ...process.env, + PI_CONFIG_DIR: ".omp-cursor", + PI_CODING_AGENT_DIR: process.env.OMP_GATEWAY_AGENT_DIR ?? join(homedir(), ".omp-cursor", "agent"), + OMP_PROFILE: "", + PI_PROFILE: "", + PI_CONFIG_FILES: "", + }; + delete metadataEnv.OMP_AUTH_BROKER_URL; + delete metadataEnv.OMP_AUTH_BROKER_TOKEN; + + async function token(): Promise { + try { + const info = await stat(tokenFile); + if (!info.isFile() || (info.mode & 0o077) !== 0) throw new Error("OMP gateway token file must have owner-only permissions."); + const key = (await readFile(tokenFile, "utf8")).trim(); + if (!key || /\s/.test(key)) throw new Error("OMP gateway token file is empty or invalid."); + return key; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined; + throw error; + } + } + + async function catalog(signal?: AbortSignal): Promise { + const key = await token(); + if (!key) throw new Error("OMP gateway token is missing. Start the managed OMP services first."); + let response: Response; + try { + response = await fetch(`${baseUrl}/models`, { + headers: { Authorization: `Bearer ${key}` }, signal: timeoutSignal(signal), redirect: "error", + }); + } catch { + signal?.throwIfAborted(); + throw new Error("OMP gateway is unavailable. Check the local omp-auth-broker and omp-auth-gateway services."); + } + if (!response.ok) throw new Error(`OMP gateway model discovery failed with HTTP ${response.status}.`); + let payload: unknown; + try { payload = await response.json(); } + catch { throw new Error("OMP gateway returned invalid model JSON."); } + assertCursorOnlyCatalog(payload); + return payload; + } + + // Keep Pi's streaming, tools, abort handling, and usage accounting. Only qualify + // the wire model id and carry the Pi session id into OMP's conversation cache. + const supportedApi = openAICompletionsApi(); + function optionsForGateway(options?: StreamOptions): StreamOptions { + return { + ...options, + onPayload: async (payload, model) => { + const routed = gatewayPayload(payload, model.id, options?.sessionId); + const replacement = await options?.onPayload?.(routed, model); + return gatewayPayload(replacement ?? routed, model.id, options?.sessionId); + }, + }; + } + const api: ProviderStreams = { + stream: (model, context, options) => supportedApi.stream(model, context, optionsForGateway(options)), + streamSimple: (model, context, options) => supportedApi.streamSimple(model, context, optionsForGateway(options)), + }; + const provider = createProvider({ + id: PROVIDER_ID, + name: "Cursor through OMP", + baseUrl, + models: [], + api, + auth: { + apiKey: { + name: "OMP gateway token file", + check: async () => (await token()) ? { type: "api_key", source: "OMP gateway token file" } : undefined, + resolve: async ({ signal }) => { + signal.throwIfAborted(); + const key = await token(); + return key ? { auth: { apiKey: key, baseUrl }, source: "OMP gateway token file" } : undefined; + }, + }, + }, + fetchModels: async context => { + const signal = timeoutSignal(context.signal); + const gateway = await catalog(signal); + let stdout: string; + try { + ({ stdout } = await execute(omp, ["models", "cursor", "--json", "--no-extensions"], { + signal, timeout: TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, env: metadataEnv, + })); + } catch { + context.signal.throwIfAborted(); + // execFile errors include child stderr. Never forward those bytes to Pi. + throw new Error("Cannot read OMP model metadata. Check OMP_GATEWAY_COMMAND and the dedicated Cursor configuration."); + } + let metadata: unknown; + try { metadata = JSON.parse(stdout); } + catch { throw new Error("OMP CLI returned invalid model JSON."); } + return discoverCursorModels(gateway, metadata, baseUrl); + }, + }); + pi.registerProvider(provider); + + pi.registerCommand("omp-gateway", { + description: "Check the OMP gateway or refresh Cursor models: /omp-gateway status|refresh", + handler: async (args, ctx) => { + try { + const action = args.trim() || "status"; + if (action === "refresh") { + const result = await ctx.modelRegistry.refresh({ providers: [PROVIDER_ID], allowNetwork: true, force: true }); + const error = result.errors.get(PROVIDER_ID); + if (error) throw error; + if (result.aborted) throw new Error("OMP gateway model refresh was cancelled."); + const count = ctx.modelRegistry.getAll().filter(model => model.provider === PROVIDER_ID).length; + ctx.ui.notify(`OMP gateway refreshed ${count} Cursor models. Select one with /model.`, "info"); + } else if (action === "status") { + const count = gatewayCursorRows(await catalog()).length; + ctx.ui.notify(`OMP gateway connected at ${baseUrl}. ${count} Cursor chat models are routable.`, "info"); + } else { + ctx.ui.notify("Usage: /omp-gateway status|refresh", "warning"); + } + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : "OMP gateway check failed.", "error"); + } + }, + }); +} diff --git a/pi/extensions/omp-gateway/tests/catalog.test.ts b/pi/extensions/omp-gateway/tests/catalog.test.ts new file mode 100644 index 0000000..615cf2c --- /dev/null +++ b/pi/extensions/omp-gateway/tests/catalog.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { assertCursorOnlyCatalog, discoverCursorModels, gatewayCursorRows, gatewayPayload, loopbackBaseUrl } from "../catalog.ts"; + +function catalogs() { + return { + gateway: { data: [{ id: "cursor/grok-4.7", owned_by: "cursor", display_name: "Grok 4.7", context_length: 256000, + max_output_tokens: 64000, input_modalities: ["text", "image"] }] }, + local: { models: [{ provider: "cursor", kind: "chat", id: "grok-4.7", name: "Grok 4.7", input: ["text", "image"], + reasoning: true, thinking: ["low", "medium", "high", "xhigh"], contextWindow: 500000, maxTokens: 64000, + cost: { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 } }] }, + }; +} + +test("joins routable models with OMP pricing, capabilities, and thinking levels", () => { + const { gateway, local } = catalogs(); + const models = discoverCursorModels(gateway, local, "http://127.0.0.1:4000/v1"); + assert.equal(models.length, 1); + const [model] = models; + assert.equal(model.provider, "cursor"); + assert.equal(model.id, "grok-4.7"); + assert.equal(model.contextWindow, 256000, "Use the conservative intersection, not a guessed 500k limit"); + assert.deepEqual(model.cost, { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }); + assert.equal(model.thinkingLevelMap?.max, null); + assert.equal(model.thinkingLevelMap?.xhigh, "xhigh"); + assert.deepEqual(model.input, ["text", "image"]); +}); + +test("excludes other providers, unsupported tools, non-chat models, and unsafe ids", () => { + const { gateway } = catalogs(); + const valid = gateway.data[0]; + const payload = { data: [valid, { ...valid, owned_by: "xai" }, { ...valid, supports_tools: false }, + { ...valid, kind: "image" }, { ...valid, id: "cursor/../../key" }, null] }; + assert.equal(gatewayCursorRows(payload).length, 1); + assert.throws(() => gatewayCursorRows({ error: "not a catalog" }), /invalid model catalog/); +}); + +test("rejects a shared-provider gateway before model discovery", () => { + assertCursorOnlyCatalog(catalogs().gateway); + assertCursorOnlyCatalog({ data: [] }); + for (const row of [{ id: "xai/grok-4.7", owned_by: "xai" }, + { id: "xai/grok-4.7", owned_by: "cursor" }, null]) { + assert.throws(() => assertCursorOnlyCatalog({ data: [row] }), /non-Cursor/); + } +}); + +test("never substitutes zero prices or guessed capabilities for malformed metadata", () => { + const { gateway, local } = catalogs(); + local.models[0].cost.input = Number.NaN; + assert.throws(() => discoverCursorModels(gateway, local, "http://localhost:4000/v1"), /invalid pricing/); + const fresh = catalogs(); + fresh.local.models[0].thinking = []; + assert.throws(() => discoverCursorModels(fresh.gateway, fresh.local, "http://localhost:4000/v1"), /thinking-level/); + assert.throws(() => discoverCursorModels(fresh.gateway, { models: [] }, "http://localhost:4000/v1"), /do not match/); +}); + +test("accepts an authoritative empty catalog and deduplicates model ids", () => { + const { gateway, local } = catalogs(); + gateway.data.push(gateway.data[0]); + assert.equal(discoverCursorModels(gateway, local, "http://localhost:4000/v1").length, 1); + assert.deepEqual(discoverCursorModels({ data: [] }, local, "http://localhost:4000/v1"), []); +}); + +test("qualifies wire ids and keeps session identity and request fields", () => { + assert.deepEqual(gatewayPayload({ model: "grok-4.7", messages: [], temperature: 0.5 }, "grok-4.7", "session-one"), + { model: "cursor/grok-4.7", messages: [], temperature: 0.5, prompt_cache_key: "session-one" }); + assert.throws(() => gatewayPayload(null, "grok-4.7"), /Invalid/); + assert.throws(() => gatewayPayload({}, "../bad"), /Invalid/); +}); + +test("rejects remote endpoints, embedded credentials, and query strings", () => { + assert.equal(loopbackBaseUrl("http://127.0.0.1:4000/v1/"), "http://127.0.0.1:4000/v1"); + assert.equal(loopbackBaseUrl("http://[::1]:4000/v1"), "http://[::1]:4000/v1"); + for (const url of ["https://remote.example/v1", "http://token@localhost:4000/v1", "http://localhost/v1?token=x", "http://localhost/"]) { + assert.throws(() => loopbackBaseUrl(url), /loopback/); + } +}); diff --git a/pi/extensions/omp-gateway/tests/runtime.test.cjs b/pi/extensions/omp-gateway/tests/runtime.test.cjs new file mode 100644 index 0000000..1eb9f1d --- /dev/null +++ b/pi/extensions/omp-gateway/tests/runtime.test.cjs @@ -0,0 +1,212 @@ +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +// Runs the installed Pi provider runtime against a real local HTTP server. +// Only OMP's external CLI and upstream inference are fixtures; no paid requests. +test('native provider discovery, streaming, tools, usage, token rotation, and cache restoration', { timeout: 60000 }, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-omp-gateway-')); + const tokenFile = path.join(dir, 'gateway.token'); + const metadataFile = path.join(dir, 'catalog.json'); + const fixture = path.join(dir, 'fixture.txt'); + const omp = path.join(dir, 'omp'); + const helper = path.join(dir, 'helper.ts'); + const defaultModel = { id: 'idle', name: 'Idle', reasoning: false, input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 32000, maxTokens: 1024 }; + fs.writeFileSync(tokenFile, 'test-token', { mode: 0o600 }); + fs.writeFileSync(fixture, 'LOCAL_FIXTURE_OK'); + const meta = { provider: 'cursor', kind: 'chat', id: 'grok-4.7', name: 'Grok 4.7', input: ['text', 'image'], + reasoning: true, thinking: ['low', 'medium', 'high', 'xhigh'], contextWindow: 256000, maxTokens: 64000, + cost: { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 } }; + fs.writeFileSync(metadataFile, JSON.stringify({ models: [meta] })); + const mockCli = path.join(dir, 'omp.cjs'); + fs.writeFileSync(mockCli, `const fs=require('node:fs'); + if (process.argv.slice(2).join(' ') !== 'models cursor --json --no-extensions') process.exit(2); + if (process.env.PI_CONFIG_DIR !== '.omp-cursor' || process.env.PI_CODING_AGENT_DIR !== ${JSON.stringify(path.join(dir, 'cursor-agent'))} || + process.env.OMP_PROFILE !== '' || process.env.PI_PROFILE !== '' || process.env.PI_CONFIG_FILES !== '' || + process.env.OMP_AUTH_BROKER_URL || process.env.OMP_AUTH_BROKER_TOKEN) process.exit(3); + process.stdout.write(fs.readFileSync(${JSON.stringify(metadataFile)},'utf8'));`); + fs.writeFileSync(omp, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockCli)} "$@"\n`, { mode: 0o700 }); + let expectedToken = 'test-token'; + let catalogStatus = 200; + let rows = [{ id: 'cursor/grok-4.7', owned_by: 'cursor', display_name: 'Grok 4.7', input_modalities: ['text', 'image'], + context_length: 256000, max_output_tokens: 64000 }]; + const requests = []; + const server = http.createServer(async (req, res) => { + if (req.headers.authorization !== `Bearer ${expectedToken}`) { + res.writeHead(401); res.end('DO_NOT_LOG_THIS_SECRET'); return; + } + if (req.url === '/v1/models') { + res.writeHead(catalogStatus, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(catalogStatus === 200 ? { object: 'list', data: rows } : { error: 'DO_NOT_LOG_THIS_SECRET' })); + return; + } + if (req.url !== '/v1/chat/completions') { res.writeHead(404); res.end(); return; } + let raw = ''; + for await (const chunk of req) raw += chunk; + const body = JSON.parse(raw); + requests.push({ body, authorization: req.headers.authorization }); + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + const send = (delta, finish = null) => res.write(`data: ${JSON.stringify({ id: 'mock', object: 'chat.completion.chunk', + model: body.model, choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`); + send({ role: 'assistant' }); + const toolResult = body.messages.findLast(message => message.role === 'tool'); + const user = body.messages.findLast(message => message.role === 'user'); + const prompt = typeof user?.content === 'string' ? user.content : + Array.isArray(user?.content) ? user.content.filter(block => block.type === 'text').map(block => block.text).join('') : ''; + if (prompt === 'USE_TOOL' && !toolResult) { + send({ tool_calls: [{ index: 0, id: 'call_read', type: 'function', function: { + name: 'read', arguments: JSON.stringify({ path: fixture }) } }] }); + send({}, 'tool_calls'); + } else { + if (prompt !== 'EMPTY') send({ content: toolResult ? 'TOOL_OK' : 'Gateway ✓ Unicode 你好' }); + send({}, 'stop'); + } + res.write(`data: ${JSON.stringify({ id: 'mock', choices: [], usage: { + prompt_tokens: 100, completion_tokens: 4, total_tokens: 104, prompt_tokens_details: { cached_tokens: 10 } } })}\n\n`); + res.end('data: [DONE]\n\n'); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}/v1`; + fs.writeFileSync(helper, `export default function(pi) { pi.registerProvider('gateway-test', { + api: 'openai-completions', apiKey: 'test-token', baseUrl: ${JSON.stringify(baseUrl)}, models: [${JSON.stringify(defaultModel)}] + }); }`); + const children = []; + const allStderr = []; + function start() { + const child = spawn('pi', ['--offline', '--no-extensions', '--no-mcp', '--no-skills', '--no-prompt-templates', + '--no-context-files', '--no-session', '-e', path.resolve(__dirname, '../index.ts'), '-e', helper, + '--model', 'gateway-test/idle', '--mode', 'rpc', '--tools', 'read'], { + env: { ...process.env, PI_CODING_AGENT_DIR: dir, OMP_GATEWAY_URL: baseUrl, + OMP_GATEWAY_TOKEN_FILE: tokenFile, OMP_GATEWAY_COMMAND: omp, OMP_GATEWAY_AGENT_DIR: path.join(dir, "cursor-agent"), + OMP_PROFILE: "do-not-use", PI_PROFILE: "do-not-use", PI_CONFIG_FILES: "/do-not-use.yml", + OMP_AUTH_BROKER_URL: "http://do-not-use", OMP_AUTH_BROKER_TOKEN: "do-not-use" }, + }); + children.push(child); + let buffer = '', stderr = '', seq = 0; + const events = [], waiters = new Set(); + child.stderr.on('data', chunk => { stderr += chunk; allStderr.push(String(chunk)); }); + child.stdout.on('data', chunk => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); + let record; + try { record = JSON.parse(line); } catch { continue; } + events.push(record); + for (const waiter of [...waiters]) if (waiter.predicate(record)) { + waiters.delete(waiter); clearTimeout(waiter.timer); waiter.resolve(record); + } + } + }); + function wait(predicate) { + return new Promise((resolve, reject) => { + const waiter = { predicate, resolve, timer: setTimeout(() => { + waiters.delete(waiter); reject(new Error(`RPC timeout: ${stderr}`)); + }, 15000) }; + waiters.add(waiter); + }); + } + async function command(type, fields = {}) { + const id = String(++seq); + const response = wait(record => record.type === 'response' && record.id === id); + child.stdin.write(JSON.stringify({ id, type, ...fields }) + '\n'); + const record = await response; + assert.equal(record.success, true, JSON.stringify(record)); + return record; + } + async function prompt(message) { + const from = events.length; + const done = wait(record => record.type === 'agent_settled'); + await command('prompt', { message }); await done; + return events.slice(from).filter(record => record.type === 'message_end' && record.message?.role === 'assistant').at(-1).message; + } + async function close() { + for (const waiter of waiters) clearTimeout(waiter.timer); + if (child.exitCode !== null) return; + const done = new Promise(resolve => child.once('close', resolve)); + child.stdin.end(); + const timer = setTimeout(() => child.kill('SIGKILL'), 3000); + await done; clearTimeout(timer); + } + return { child, command, prompt, close, events }; + } + try { + const runtime = start(); + await runtime.command('set_auto_retry', { enabled: false }); + const before = await runtime.command('get_available_models'); + assert.equal(before.data.models.filter(model => model.provider === 'cursor').length, 0, 'No network or CLI startup work in the factory'); + await runtime.prompt('BOOT'); + requests.length = 0; + await runtime.command('prompt', { message: '/omp-gateway refresh' }); + const available = await runtime.command('get_available_models'); + const model = available.data.models.find(model => model.provider === 'cursor' && model.id === 'grok-4.7'); + assert.ok(model, JSON.stringify(runtime.events)); + assert.equal(model.thinkingLevelMap.max, null); + await runtime.command('set_model', { provider: 'cursor', modelId: 'grok-4.7' }); + await runtime.command('set_thinking_level', { level: 'high' }); + const selected = await runtime.command('get_state'); + assert.equal(selected.data.model.provider, 'cursor', JSON.stringify(selected)); + const text = await runtime.prompt('TEXT'); + assert.equal(text.stopReason, 'stop', JSON.stringify(text)); + assert.equal(text.content.find(block => block.type === 'text').text, 'Gateway ✓ Unicode 你好'); + assert.equal(text.provider, 'cursor'); + assert.equal(text.model, 'grok-4.7'); + assert.equal(text.usage.input, 90); + assert.equal(text.usage.cacheRead, 10); + assert.equal(text.usage.output, 4); + assert.ok(text.usage.cost.total > 0); + assert.equal(requests[0].body.model, 'cursor/grok-4.7'); + assert.equal(requests[0].body.reasoning_effort, 'high'); + assert.ok(requests[0].body.prompt_cache_key); + await runtime.command('new_session'); + await runtime.prompt('BOOT'); + await runtime.command('set_model', { provider: 'cursor', modelId: 'grok-4.7' }); + await runtime.command('set_thinking_level', { level: 'high' }); + const tool = await runtime.prompt('USE_TOOL'); + assert.equal(tool.stopReason, 'stop', JSON.stringify(tool)); + assert.equal(tool.content.find(block => block.type === 'text').text, 'TOOL_OK'); + assert.ok(requests.at(-1).body.messages.some(message => message.role === 'tool' && JSON.stringify(message.content).includes('LOCAL_FIXTURE_OK'))); + assert.equal(requests.at(-1).body.prompt_cache_key, requests.at(-2).body.prompt_cache_key, 'Keep the tool handoff in the same gateway conversation'); + assert.notEqual(requests[0].body.prompt_cache_key, requests.at(-1).body.prompt_cache_key, 'New sessions must not share Cursor conversations'); + expectedToken = 'rotated-token'; + fs.writeFileSync(tokenFile, expectedToken); + await runtime.prompt('AFTER_ROTATION'); + assert.equal(requests.at(-1).authorization, 'Bearer rotated-token'); + const empty = await runtime.prompt('EMPTY'); + assert.equal(empty.stopReason, 'stop'); + assert.deepEqual(empty.content, []); + catalogStatus = 401; + await runtime.command('prompt', { message: '/omp-gateway refresh' }); + assert.ok(runtime.events.some(record => record.type === 'extension_ui_request' && /HTTP 401/.test(record.message || ''))); + const retained = await runtime.command('get_available_models'); + assert.ok(retained.data.models.some(model => model.provider === 'cursor'), 'Failed refresh must preserve the last catalog'); + assert.ok(!JSON.stringify(runtime.events).includes('DO_NOT_LOG_THIS_SECRET')); + catalogStatus = 200; + await runtime.close(); + const restored = start(); + const cached = await restored.command('get_available_models'); + assert.ok(cached.data.models.some(model => model.provider === 'cursor'), 'Pi restores persisted catalog offline'); + rows = [{ id: "xai/grok-4.7", owned_by: "xai" }]; + await restored.command("prompt", { message: "/omp-gateway refresh" }); + assert.ok(restored.events.some(record => record.type === "extension_ui_request" && /non-Cursor/.test(record.message || ""))); + const scoped = await restored.command("get_available_models"); + assert.ok(scoped.data.models.some(model => model.provider === "cursor"), "Reject mixed catalogs without losing cached Cursor models"); + assert.ok(!scoped.data.models.some(model => model.provider === "xai")); + rows = []; + await restored.command('prompt', { message: '/omp-gateway refresh' }); + const removed = await restored.command('get_available_models'); + assert.equal(removed.data.models.filter(model => model.provider === 'cursor').length, 0, 'Authoritative empty catalogs remove models'); + await restored.close(); + assert.ok(!allStderr.join('').includes('DO_NOT_LOG_THIS_SECRET')); + } finally { + for (const child of children) if (child.exitCode === null) child.kill('SIGKILL'); + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + fs.rmSync(dir, { recursive: true, force: true }); + } +});