|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Smoke test for the Gemini Robotics ER 2 pointing endpoint. |
| 4 | + * |
| 5 | + * Uploads a test image and asks the model to point to objects, asserting the |
| 6 | + * response is a JSON array where every item has: |
| 7 | + * - point: integer [y, x] pair normalized to 0-1000 |
| 8 | + * - label: non-empty string |
| 9 | + * |
| 10 | + * Prerequisites (see README): |
| 11 | + * - GEMINI_API_KEY set and restricted in AI Studio (unrestricted keys |
| 12 | + * return 403 Forbidden for Robotics models). |
| 13 | + * - Optional GEMINI_API_BASE for a custom endpoint/proxy. |
| 14 | + * |
| 15 | + * Usage: |
| 16 | + * GEMINI_API_KEY=... node scripts/smoke_robotics.mjs |
| 17 | + * GEMINI_API_KEY=... GEMINI_API_BASE=... node scripts/smoke_robotics.mjs [image-path] |
| 18 | + * |
| 19 | + * Exit code: 0 = all checks passed, 1 = any check failed, 2 = usage/API error. |
| 20 | + */ |
| 21 | +import { GoogleGenAI } from '@google/genai'; |
| 22 | +import fs from 'node:fs'; |
| 23 | +import path from 'node:path'; |
| 24 | + |
| 25 | +const MODEL = process.env.ROBOTICS_MODEL ?? 'gemini-robotics-er-2-preview'; |
| 26 | +const DEFAULT_IMAGE = new URL('../public/app-logo.png', import.meta.url).pathname; |
| 27 | + |
| 28 | +const PROMPT = ` |
| 29 | +Point to no more than 10 items in the image. The label returned |
| 30 | +should be an identifying name for the object detected. |
| 31 | +The answer should follow the json format: [{"point": [y, x], "label": <label>}, ...]. |
| 32 | +The points are in [y, x] format normalized to 0-1000. |
| 33 | +`; |
| 34 | + |
| 35 | +// JSON schema for structured output: array of { point: [y, x], label: string }. |
| 36 | +const responseSchema = { |
| 37 | + type: 'array', |
| 38 | + items: { |
| 39 | + type: 'object', |
| 40 | + properties: { |
| 41 | + point: { |
| 42 | + type: 'array', |
| 43 | + items: { type: 'integer' }, |
| 44 | + minItems: 2, |
| 45 | + maxItems: 2, |
| 46 | + }, |
| 47 | + label: { type: 'string' }, |
| 48 | + }, |
| 49 | + required: ['point', 'label'], |
| 50 | + additionalProperties: false, |
| 51 | + }, |
| 52 | +}; |
| 53 | + |
| 54 | +function fail(message) { |
| 55 | + console.error(`✗ FAIL: ${message}`); |
| 56 | + process.exitCode = 1; |
| 57 | +} |
| 58 | + |
| 59 | +function parsePoints(rawText) { |
| 60 | + const match = rawText.match(/\[[\s\S]*\]/); |
| 61 | + if (!match) return { ok: false, error: `no JSON array found in response: ${rawText.slice(0, 200)}` }; |
| 62 | + try { |
| 63 | + return { ok: true, items: JSON.parse(match[0]) }; |
| 64 | + } catch (err) { |
| 65 | + return { ok: false, error: `JSON parse error: ${err.message}` }; |
| 66 | + } |
| 67 | +} |
| 68 | + |
| 69 | +async function main() { |
| 70 | + const imagePath = process.argv[2] ?? DEFAULT_IMAGE; |
| 71 | + if (!fs.existsSync(imagePath)) { |
| 72 | + fail(`image not found: ${imagePath}`); |
| 73 | + return; |
| 74 | + } |
| 75 | + |
| 76 | + const apiKey = process.env.GEMINI_API_KEY; |
| 77 | + if (!apiKey) { |
| 78 | + console.error('GEMINI_API_KEY is not set (required; must be restricted in AI Studio for Robotics models).'); |
| 79 | + process.exit(2); |
| 80 | + } |
| 81 | + |
| 82 | + const client = new GoogleGenAI({ apiKey, baseUrl: process.env.GEMINI_API_BASE }); |
| 83 | + |
| 84 | + console.log(`Model: ${MODEL}`); |
| 85 | + console.log(`Image: ${imagePath}`); |
| 86 | + |
| 87 | + let uploaded; |
| 88 | + try { |
| 89 | + uploaded = await client.files.upload({ file: imagePath }); |
| 90 | + console.log(`Uploaded: ${uploaded.uri}`); |
| 91 | + } catch (err) { |
| 92 | + fail(`file upload failed: ${err.message}`); |
| 93 | + return; |
| 94 | + } |
| 95 | + |
| 96 | + let response; |
| 97 | + try { |
| 98 | + response = await client.interactions.create({ |
| 99 | + model: MODEL, |
| 100 | + input: [ |
| 101 | + { type: 'image', uri: uploaded.uri, mime_type: uploaded.mime_type }, |
| 102 | + { type: 'text', text: PROMPT }, |
| 103 | + ], |
| 104 | + generation_config: { |
| 105 | + thinking_config: { thinking_level: 'medium' }, |
| 106 | + response_schema: responseSchema, |
| 107 | + response_mime_type: 'application/json', |
| 108 | + }, |
| 109 | + }); |
| 110 | + } catch (err) { |
| 111 | + fail(`interactions.create failed: ${err.message}`); |
| 112 | + return; |
| 113 | + } |
| 114 | + |
| 115 | + const text = response.output_text ?? ''; |
| 116 | + const { ok, items } = parsePoints(text); |
| 117 | + if (!ok) { |
| 118 | + fail(items.error); |
| 119 | + return; |
| 120 | + } |
| 121 | + if (!Array.isArray(items)) { |
| 122 | + fail(`response is not an array: ${JSON.stringify(items).slice(0, 200)}`); |
| 123 | + return; |
| 124 | + } |
| 125 | + |
| 126 | + let pass = 0; |
| 127 | + const output = []; |
| 128 | + for (const item of items) { |
| 129 | + const problems = []; |
| 130 | + if (!Array.isArray(item?.point) || item.point.length !== 2) { |
| 131 | + problems.push('point is not a [y, x] pair'); |
| 132 | + } else { |
| 133 | + const [y, x] = item.point; |
| 134 | + if (!Number.isInteger(y) || !Number.isInteger(x)) problems.push(`point [${y}, ${x}] is not integer`); |
| 135 | + if (y < 0 || y > 1000 || x < 0 || x > 1000) problems.push(`point [${y}, ${x}] out of 0-1000 range`); |
| 136 | + } |
| 137 | + if (typeof item?.label !== 'string' || item.label.trim() === '') { |
| 138 | + problems.push('label is empty'); |
| 139 | + } |
| 140 | + const itemPass = problems.length === 0; |
| 141 | + if (itemPass) pass += 1; |
| 142 | + output.push( |
| 143 | + `${itemPass ? '✓' : '✗'} point=[${item?.point?.join(', ') ?? 'n/a'}] label="${item?.label ?? ''}"${ |
| 144 | + problems.length ? ` (${problems.join('; ')})` : '' |
| 145 | + }`, |
| 146 | + ); |
| 147 | + } |
| 148 | + |
| 149 | + console.log('\n--- items ---'); |
| 150 | + console.log(output.join('\n')); |
| 151 | + console.log(`\npassed ${pass}/${items.length} items, ${items.length} total`); |
| 152 | + |
| 153 | + if (items.length === 0) { |
| 154 | + fail('response contains no items'); |
| 155 | + } |
| 156 | + if (pass !== items.length) { |
| 157 | + fail(`${items.length - pass} item(s) failed validation`); |
| 158 | + } |
| 159 | +} |
| 160 | + |
| 161 | +main().catch((err) => { |
| 162 | + console.error(`✗ unhandled error: ${err.stack ?? err}`); |
| 163 | + process.exit(2); |
| 164 | +}); |
0 commit comments