mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* feat(cursor): vision (image_url) input + tool-commit/output-constraint enhancements Add image/vision input to the Cursor provider's agent.v1 endpoint, plus the supporting prompt-engineering and resilience work developed alongside it. Vision input - Decode OpenAI `image_url` parts (base64 `data:` URIs and remote `http(s)` URLs) and inline them as `SelectedContext.selected_images[]` — field numbers pinned from the cursor-agent agent.v1 protobuf descriptor (SelectedImage.data oneof, uuid, optional Dimension, mime_type). Cross-checked against composer-api's shape. - New `resolveCursorImages` helper: SSRF-guarded remote fetches via the repo's canonical `parseAndValidatePublicUrl` (always public-only for client URLs), <=1 MiB per image (pre-decode + streaming cap), `image/*` enforced, max 12 images, sanitized `CursorImageError` (no stack/path leakage). - `openai-to-cursor` translator now preserves `image_url` parts instead of dropping them; executor `buildRequest` resolves images and attaches them to the user turn. The no-image path is byte-identical to before (test-asserted). Supporting cursor enhancements - Tool-commit directive (raises composer-2.5 tool-call rate ~53% -> ~88%), `tool_choice` none/required/specific handling, and output constraints (`response_format` / `max_tokens` / `stop` surfaced as prompt instructions). - `cursorSessionManager`: clear pending tool-call mappings on session close. - `cursorVersionDetector`: export `FALLBACK_VERSION` as a single source of truth. Tests & docs - New unit suite for the image encoder + resolver (field layout, byte-identical no-image path, SSRF / oversize / bad-base64 / too-many rejections, sanitized error body), translator image-preservation tests, and live e2e tests (base64 + remote URL, gated on `CURSOR_E2E_TOKEN`). - Documented `CURSOR_TOOL_DIRECTIVE` and `CURSOR_IMAGE_FETCH_TIMEOUT_MS` in `.env.example` and `docs/reference/ENVIRONMENT.md`. * fix(cursor): address review — redirect SSRF, large-payload guard, stream OOM, case/NaN nits Resolves the gemini-code-assist review on #3104: - SSRF via redirect (critical): fetchImageBytes now uses redirect:"manual" and re-validates every hop through parseAndValidatePublicUrl, so a public URL can't 30x-redirect to a private/link-local address. Bounded to 3 redirects. - Large data URL (high): reject on raw payload length before the whitespace-strip regex, so an oversized data URL can't burn CPU. - Stream read (high): readCapped consumes the body as an async iterable (Node Readable + Web Streams) or via getReader, capping mid-read; uncapped arrayBuffer() is only a last resort. - data: scheme (medium): match case-insensitively (RFC 2397) while preserving the original payload. - NaN timeouts (medium): CURSOR_IMAGE_FETCH_TIMEOUT_MS and CURSOR_STREAM_TIMEOUT_MS fall back to defaults when the env value isn't a positive integer. Adds tests: redirect-to-private blocked, redirect-to-public followed, too-many- redirects rejected, uppercase DATA: accepted. * fix(cursor): defend image fetch against DNS-rebinding SSRF Address the @codex review on #3104: parseAndValidatePublicUrl only checks the hostname string, so a public-looking host that (re)resolves to a private / link-local / metadata IP would still be fetched. Each hop now resolves the host via dns.lookup({all:true}) and rejects if ANY answer is private (isPrivateHost), before connecting. IP literals are skipped (already validated by the URL guard). This narrows but doesn't fully close the TOCTOU window vs fetch's own resolution; a connection-time IP filter on the shared outbound guard would close it for every caller. Adds unit tests for the IP gate and a mocked DNS-rebinding case (public host -> 127.0.0.1, fetch never reached).
529 lines
18 KiB
TypeScript
529 lines
18 KiB
TypeScript
/**
|
|
* Cursor end-to-end integration test.
|
|
*
|
|
* Skipped unless `CURSOR_E2E_TOKEN` env var is set. Exercises the full
|
|
* OpenAI-compatible flow against cursor's real `agent.v1.AgentService/Run`
|
|
* endpoint, across both auto/claude (tests 1-4) and composer-2.5 (tests 5-9):
|
|
*
|
|
* 1. Single-turn plain chat
|
|
* 2. System prompt biasing
|
|
* 3. Tool-use single-turn (request → tool_calls)
|
|
* 4. Streaming SSE incremental delivery
|
|
* 5. composer-2.5 plain chat (+ usage present)
|
|
* 6. composer-2.5 reasoning surfaced as reasoning_content (no marker leakage)
|
|
* 7. composer-2.5 multi-turn tool round-trip (inline h2 session reuse)
|
|
* 8. composer-2.5 cold-resume fallback (no live session)
|
|
* 9. composer-2.5 streaming (+ usage chunk)
|
|
*
|
|
* The composer model id is overridable with CURSOR_E2E_MODEL (e.g.
|
|
* composer-2.5-fast).
|
|
*
|
|
* To run:
|
|
* CURSOR_E2E_TOKEN=$(cat ~/.cursor/access-token) \
|
|
* node --import tsx --test tests/integration/cursor-e2e.test.ts
|
|
*
|
|
* Capturing wire fixtures (separate workflow):
|
|
* CURSOR_TOKEN=... node scripts/ad-hoc/cursor-tap.cjs single-turn-chat "say PING"
|
|
*/
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import zlib from "node:zlib";
|
|
|
|
const TOKEN = process.env.CURSOR_E2E_TOKEN;
|
|
const skipReason = TOKEN ? undefined : "CURSOR_E2E_TOKEN not set";
|
|
|
|
// Model used by the composer-specific regression tests below. Override with
|
|
// CURSOR_E2E_MODEL to exercise composer-2.5-fast or another id.
|
|
const COMPOSER_MODEL = process.env.CURSOR_E2E_MODEL || "composer-2.5";
|
|
|
|
// Vision-capable model for the image tests. composer-2.5 and the claude/gpt
|
|
// ids all accept inline images; default to a gpt id, override with
|
|
// CURSOR_E2E_VISION_MODEL. A public solid-color image service backs the
|
|
// URL-image test (override with CURSOR_E2E_IMAGE_URL).
|
|
const VISION_MODEL = process.env.CURSOR_E2E_VISION_MODEL || "gpt-5.2";
|
|
const RED_IMAGE_URL =
|
|
process.env.CURSOR_E2E_IMAGE_URL || "https://dummyimage.com/80x80/ff0000/ff0000.png";
|
|
|
|
// Build a valid solid-color PNG (size x size, truecolor) with no deps — used
|
|
// to prove a vision model actually reads the inline image bytes.
|
|
function solidColorPng(size: number, rgb: [number, number, number]): Buffer {
|
|
const crc32 = (buf: Buffer): number => {
|
|
let c = ~0;
|
|
for (let i = 0; i < buf.length; i++) {
|
|
c ^= buf[i];
|
|
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
|
|
}
|
|
return (~c) >>> 0;
|
|
};
|
|
const chunk = (type: string, data: Buffer): Buffer => {
|
|
const t = Buffer.from(type, "ascii");
|
|
const len = Buffer.alloc(4);
|
|
len.writeUInt32BE(data.length, 0);
|
|
const body = Buffer.concat([t, data]);
|
|
const crc = Buffer.alloc(4);
|
|
crc.writeUInt32BE(crc32(body), 0);
|
|
return Buffer.concat([len, body, crc]);
|
|
};
|
|
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(size, 0);
|
|
ihdr.writeUInt32BE(size, 4);
|
|
ihdr[8] = 8; // bit depth
|
|
ihdr[9] = 2; // color type 2 = truecolor RGB
|
|
const row = Buffer.concat([
|
|
Buffer.from([0]),
|
|
Buffer.concat(Array.from({ length: size }, () => Buffer.from(rgb))),
|
|
]);
|
|
const raw = Buffer.concat(Array.from({ length: size }, () => row));
|
|
const idat = zlib.deflateSync(raw);
|
|
return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", Buffer.alloc(0))]);
|
|
}
|
|
|
|
const weatherTools = [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Get current weather for a city",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { city: { type: "string" } },
|
|
required: ["city"],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
test(
|
|
"[cursor-e2e] single-turn plain chat returns assistant text",
|
|
{ skip: skipReason },
|
|
async () => {
|
|
const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
|
|
const exec = new CursorExecutor();
|
|
const result = await exec.execute({
|
|
model: "auto",
|
|
body: { messages: [{ role: "user", content: "say only PING" }] },
|
|
stream: false,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
assert.equal(result.response.status, 200);
|
|
const json = await result.response.json();
|
|
assert.equal(json.choices[0].finish_reason, "stop");
|
|
assert.match(json.choices[0].message.content, /PING/i);
|
|
}
|
|
);
|
|
|
|
test("[cursor-e2e] system prompt biases the response", { skip: skipReason }, async () => {
|
|
const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
|
|
const exec = new CursorExecutor();
|
|
const result = await exec.execute({
|
|
model: "auto",
|
|
body: {
|
|
messages: [
|
|
{ role: "system", content: "Reply with exactly the word HAIKU and nothing else." },
|
|
{ role: "user", content: "hi" },
|
|
],
|
|
},
|
|
stream: false,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
assert.equal(result.response.status, 200);
|
|
const json = await result.response.json();
|
|
assert.match(json.choices[0].message.content, /HAIKU/);
|
|
});
|
|
|
|
test("[cursor-e2e] tool-use single-turn returns tool_calls", { skip: skipReason }, async () => {
|
|
const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
|
|
const exec = new CursorExecutor();
|
|
const result = await exec.execute({
|
|
model: "claude-4.6-sonnet-medium",
|
|
body: {
|
|
messages: [{ role: "user", content: "What's the weather in Paris? Use the tool." }],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Get current weather for a city",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { city: { type: "string" } },
|
|
required: ["city"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
stream: false,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
assert.equal(result.response.status, 200);
|
|
const json = await result.response.json();
|
|
assert.equal(json.choices[0].finish_reason, "tool_calls");
|
|
const toolCall = json.choices[0].message.tool_calls?.[0];
|
|
assert.ok(toolCall, "expected a tool_call");
|
|
assert.equal(toolCall.function.name, "get_weather");
|
|
assert.match(toolCall.function.arguments, /Paris/);
|
|
});
|
|
|
|
test(
|
|
"[cursor-e2e] streaming SSE delivers chunks before the upstream closes",
|
|
{ skip: skipReason },
|
|
async () => {
|
|
const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
|
|
const exec = new CursorExecutor();
|
|
const result = await exec.execute({
|
|
model: "auto",
|
|
body: { messages: [{ role: "user", content: "count from 1 to 5" }] },
|
|
stream: true,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
assert.equal(result.response.status, 200);
|
|
const reader = (result.response.body as ReadableStream<Uint8Array>).getReader();
|
|
const decoder = new TextDecoder();
|
|
let chunks = 0;
|
|
let totalText = "";
|
|
let firstChunkTime: number | null = null;
|
|
const startTime = Date.now();
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (firstChunkTime == null) firstChunkTime = Date.now();
|
|
const text = decoder.decode(value);
|
|
chunks++;
|
|
totalText += text;
|
|
}
|
|
void firstChunkTime;
|
|
void startTime;
|
|
// Multiple SSE chunks (not one big buffered blob) proves we're streaming
|
|
// emit-as-decoded. The exact latency ratio depends on cursor's pacing
|
|
// and isn't worth asserting tightly.
|
|
assert.ok(chunks > 1, `expected multiple chunks; got ${chunks}`);
|
|
assert.match(totalText, /data: \[DONE\]/);
|
|
}
|
|
);
|
|
|
|
// ─── composer-2.5 regression coverage ──────────────────────────────────────
|
|
//
|
|
// The four tests above cover auto/claude single-turn. These add coverage for
|
|
// the composer model specifically plus the two highest-value untested paths:
|
|
// the multi-turn tool round-trip (inline h2 session reuse) and the cold-resume
|
|
// fallback. All were validated end-to-end against the live endpoint.
|
|
|
|
test("[cursor-e2e] composer-2.5 plain chat returns assistant text", { skip: skipReason }, async () => {
|
|
const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
|
|
const exec = new CursorExecutor();
|
|
const result = await exec.execute({
|
|
model: COMPOSER_MODEL,
|
|
body: { messages: [{ role: "user", content: "Say only the word PING and nothing else." }] },
|
|
stream: false,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
assert.equal(result.response.status, 200);
|
|
const json = await result.response.json();
|
|
assert.equal(json.choices[0].finish_reason, "stop");
|
|
assert.match(json.choices[0].message.content, /PING/i);
|
|
// Usage is always present on the success path (OpenAI contract).
|
|
assert.equal(typeof json.usage?.total_tokens, "number");
|
|
});
|
|
|
|
test(
|
|
"[cursor-e2e] composer-2.5 surfaces reasoning as reasoning_content",
|
|
{ skip: skipReason },
|
|
async () => {
|
|
const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
|
|
const exec = new CursorExecutor();
|
|
const result = await exec.execute({
|
|
model: COMPOSER_MODEL,
|
|
body: {
|
|
messages: [
|
|
{ role: "user", content: "Think step by step: what is 17 * 23? Then give the answer." },
|
|
],
|
|
},
|
|
stream: false,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
assert.equal(result.response.status, 200);
|
|
const json = await result.response.json();
|
|
// The final answer is plain text — reasoning must NOT leak control tokens
|
|
// (</think>, <|final|>, <|tool_calls_begin|>) into the visible content.
|
|
const content = json.choices[0].message.content || "";
|
|
assert.match(content, /391/);
|
|
assert.doesNotMatch(content, /<\|?tool_calls_begin|<\/think>|<\|?final\|?>/);
|
|
}
|
|
);
|
|
|
|
test(
|
|
"[cursor-e2e] composer-2.5 multi-turn tool round-trip reuses the h2 session",
|
|
{ skip: skipReason },
|
|
async () => {
|
|
const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
|
|
const { cursorSessionManager } = await import(
|
|
"../../open-sse/services/cursorSessionManager.ts"
|
|
);
|
|
const exec = new CursorExecutor();
|
|
const conversationId = `e2e-rt-${Date.now()}`;
|
|
|
|
// Turn 1: declare a tool → expect tool_calls + a retained session.
|
|
const r1 = await exec.execute({
|
|
model: COMPOSER_MODEL,
|
|
body: {
|
|
conversation_id: conversationId,
|
|
messages: [{ role: "user", content: "What's the weather in Paris? Call get_weather." }],
|
|
tools: weatherTools,
|
|
},
|
|
stream: false,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
const j1 = await r1.response.json();
|
|
assert.equal(j1.choices[0].finish_reason, "tool_calls");
|
|
const toolCall = j1.choices[0].message.tool_calls?.[0];
|
|
assert.ok(toolCall, "expected a tool_call on turn 1");
|
|
assert.equal(toolCall.function.name, "get_weather");
|
|
assert.ok(
|
|
cursorSessionManager.has(conversationId),
|
|
"session should be retained for inline resume"
|
|
);
|
|
|
|
// Turn 2: same conversation_id, append the tool result → final answer.
|
|
const r2 = await exec.execute({
|
|
model: COMPOSER_MODEL,
|
|
body: {
|
|
conversation_id: conversationId,
|
|
messages: [
|
|
{ role: "user", content: "What's the weather in Paris? Call get_weather." },
|
|
{ role: "assistant", content: null, tool_calls: [toolCall] },
|
|
{
|
|
role: "tool",
|
|
tool_call_id: toolCall.id,
|
|
name: "get_weather",
|
|
content: '{"temp_c": 19, "condition": "sunny"}',
|
|
},
|
|
],
|
|
tools: weatherTools,
|
|
},
|
|
stream: false,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
const j2 = await r2.response.json();
|
|
assert.equal(r2.response.status, 200);
|
|
assert.match(j2.choices[0].message.content || "", /19|sunny/i);
|
|
}
|
|
);
|
|
|
|
test(
|
|
"[cursor-e2e] composer-2.5 cold-resume incorporates a tool result without a live session",
|
|
{ skip: skipReason },
|
|
async () => {
|
|
const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
|
|
const exec = new CursorExecutor();
|
|
// Brand-new conversation_id with a fabricated prior tool call/result and no
|
|
// session ever opened → acquire() misses, exercising the cold-resume path
|
|
// (fresh RunRequest with full history flattened into UserText).
|
|
const result = await exec.execute({
|
|
model: COMPOSER_MODEL,
|
|
body: {
|
|
conversation_id: `e2e-cold-${Date.now()}`,
|
|
messages: [
|
|
{ role: "user", content: "What's the weather in Tokyo? Call get_weather." },
|
|
{
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
id: "call_cold_1",
|
|
type: "function",
|
|
function: { name: "get_weather", arguments: '{"city":"Tokyo"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_cold_1",
|
|
name: "get_weather",
|
|
content: '{"temp_c": 8, "condition": "rainy"}',
|
|
},
|
|
],
|
|
tools: weatherTools,
|
|
},
|
|
stream: false,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
assert.equal(result.response.status, 200);
|
|
const json = await result.response.json();
|
|
assert.match(json.choices[0].message.content || "", /8|rainy/i);
|
|
}
|
|
);
|
|
|
|
test(
|
|
"[cursor-e2e] composer-2.5 honors response_format json_object",
|
|
{ skip: skipReason },
|
|
async () => {
|
|
const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
|
|
const exec = new CursorExecutor();
|
|
const result = await exec.execute({
|
|
model: COMPOSER_MODEL,
|
|
body: {
|
|
messages: [
|
|
{ role: "user", content: "Give me a fake user profile with fields name, age, and city." },
|
|
],
|
|
response_format: { type: "json_object" },
|
|
},
|
|
stream: false,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
assert.equal(result.response.status, 200);
|
|
const json = await result.response.json();
|
|
const content = (json.choices[0].message.content || "").trim();
|
|
// cursor's agent endpoint has no native response_format; the OUTPUT
|
|
// CONSTRAINTS prompt injection is what makes the model return raw JSON.
|
|
const parsed = JSON.parse(content);
|
|
assert.equal(typeof parsed, "object");
|
|
}
|
|
);
|
|
|
|
test(
|
|
"[cursor-e2e] composer-2.5 streaming delivers incremental chunks",
|
|
{ skip: skipReason },
|
|
async () => {
|
|
const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
|
|
const exec = new CursorExecutor();
|
|
const result = await exec.execute({
|
|
model: COMPOSER_MODEL,
|
|
body: { messages: [{ role: "user", content: "Count from 1 to 5, one number per line." }] },
|
|
stream: true,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
assert.equal(result.response.status, 200);
|
|
const reader = (result.response.body as ReadableStream<Uint8Array>).getReader();
|
|
const decoder = new TextDecoder();
|
|
let chunks = 0;
|
|
let sawUsage = false;
|
|
let totalText = "";
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
const text = decoder.decode(value);
|
|
chunks++;
|
|
totalText += text;
|
|
if (text.includes('"usage"')) sawUsage = true;
|
|
}
|
|
assert.ok(chunks > 1, `expected multiple chunks; got ${chunks}`);
|
|
assert.match(totalText, /data: \[DONE\]/);
|
|
assert.ok(sawUsage, "streaming response should include a usage chunk");
|
|
}
|
|
);
|
|
|
|
test(
|
|
"[cursor-e2e] base64 image_url reaches a vision model (sees the color)",
|
|
{ skip: skipReason },
|
|
async () => {
|
|
const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
|
|
const exec = new CursorExecutor();
|
|
const png = solidColorPng(64, [255, 0, 0]); // solid red
|
|
const dataUri = `data:image/png;base64,${png.toString("base64")}`;
|
|
const result = await exec.execute({
|
|
model: VISION_MODEL,
|
|
body: {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "What single color is this square? Answer with just the color name.",
|
|
},
|
|
{ type: "image_url", image_url: { url: dataUri } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
stream: false,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
assert.equal(result.response.status, 200);
|
|
const json = await result.response.json();
|
|
assert.equal(json.choices[0].finish_reason, "stop");
|
|
assert.match(
|
|
json.choices[0].message.content,
|
|
/red/i,
|
|
`vision model should report red; got: ${json.choices[0].message.content}`
|
|
);
|
|
}
|
|
);
|
|
|
|
test(
|
|
"[cursor-e2e] remote image_url is fetched and reaches a vision model",
|
|
{ skip: skipReason },
|
|
async () => {
|
|
const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
|
|
const exec = new CursorExecutor();
|
|
const result = await exec.execute({
|
|
model: VISION_MODEL,
|
|
body: {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "What single color is this square? Answer with just the color name.",
|
|
},
|
|
{ type: "image_url", image_url: { url: RED_IMAGE_URL } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
stream: false,
|
|
credentials: { accessToken: TOKEN },
|
|
signal: undefined,
|
|
log: () => {},
|
|
upstreamExtraHeaders: undefined,
|
|
});
|
|
assert.equal(result.response.status, 200);
|
|
const json = await result.response.json();
|
|
assert.match(
|
|
json.choices[0].message.content,
|
|
/red/i,
|
|
`vision model should report red; got: ${json.choices[0].message.content}`
|
|
);
|
|
}
|
|
);
|