feat(pi): add Cursor models through the OMP gateway
This commit is contained in:
parent
b9ab5a392e
commit
73e9b17305
4 changed files with 573 additions and 0 deletions
138
pi/extensions/omp-gateway/catalog.ts
Normal file
138
pi/extensions/omp-gateway/catalog.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||
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<string, Record<string, unknown>>();
|
||||
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<string, Model<"openai-completions">>();
|
||||
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<ModelThinkingLevel>();
|
||||
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<string, unknown> {
|
||||
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(/\/$/, "");
|
||||
}
|
||||
146
pi/extensions/omp-gateway/index.ts
Normal file
146
pi/extensions/omp-gateway/index.ts
Normal file
|
|
@ -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<string | undefined> {
|
||||
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<unknown> {
|
||||
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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
77
pi/extensions/omp-gateway/tests/catalog.test.ts
Normal file
77
pi/extensions/omp-gateway/tests/catalog.test.ts
Normal file
|
|
@ -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/);
|
||||
}
|
||||
});
|
||||
212
pi/extensions/omp-gateway/tests/runtime.test.cjs
Normal file
212
pi/extensions/omp-gateway/tests/runtime.test.cjs
Normal file
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue