Files
hyperframes/packages/cli/src/capture/contentExtractor.test.ts
T
James RussoandClaude Opus 4.8 646ffff927 feat(cli): support OpenRouter as an alternative vision provider for capture captioning (#1478)
* feat(cli): support OpenRouter as an alternative vision provider for capture captioning

`hyperframes capture` could only enrich asset descriptions with Gemini vision,
which requires a Google API key. Add OpenRouter as an alternative so users
without Google access can caption via any vision-capable model through one
unified key.

Provider is selected by which key is present: OPENROUTER_API_KEY → OpenRouter
(OpenAI-style /chat/completions with an image_url data URI), else
GEMINI_API_KEY/GOOGLE_API_KEY → Gemini (unchanged), else DOM-only as before.
OpenRouter wins if both are set. Default model is google/gemini-3.1-flash-lite
(the OpenRouter analog of the Gemini path's existing 3.1-flash-lite tier),
overridable via HYPERFRAMES_OPENROUTER_MODEL.

Both vision call sites — the image loop and the rasterized-SVG loop — route
through a single `captionOne` dispatcher, so the new provider works for SVGs too
(the original PR #840 only patched the image loop, which would have left
OpenRouter-only users with crashing SVG captioning). The OpenRouter path checks
res.ok and surfaces the status/body on failure.

Reimplements #840 (which was unmergeable: saved with a UTF-8 BOM + CRLF so
GitHub rendered it as a binary diff, used `any`, reused the Gemini model env
var, and had a hallucinated default model id).

- Adds unit tests for the OpenRouter path (happy path, graceful degradation on
  non-OK status, no-key skip).
- Documents OPENROUTER_API_KEY in the website-to-video guide and the CLI capture
  reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): fix typecheck in OpenRouter caption test — capture request without `as`

The test cast `fetchMock.mock.calls[0]` to a tuple (TS2352: `[] | undefined`
doesn't overlap `[string, RequestInit]`), which failed the Typecheck CI job.
Capture the url/init inside the typed mock and assert via `new Headers()` +
`typeof` narrowing instead — no `as` assertions (which the repo bans anyway).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:14:45 -07:00

103 lines
4.1 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { captionImagesWithGemini } from "./contentExtractor.js";
// These tests exercise the OpenRouter provider path only — it makes a plain
// `fetch` call we can stub, with no native (`sharp`) or `@google/genai`
// dependency. OpenRouter wins over Gemini when OPENROUTER_API_KEY is set, so we
// don't need to clear the Gemini keys for the OpenRouter cases.
function makeProjectWithImage(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-caption-"));
mkdirSync(join(dir, "assets"), { recursive: true });
// Contents are irrelevant to the OpenRouter path (it just base64-encodes the
// bytes); only the .png extension matters for the image filter.
writeFileSync(join(dir, "assets", "hero.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
return dir;
}
describe("captionImagesWithGemini — OpenRouter provider", () => {
const dirs: string[] = [];
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
for (const d of dirs) rmSync(d, { recursive: true, force: true });
dirs.length = 0;
});
it("captions via OpenRouter when OPENROUTER_API_KEY is set", async () => {
const dir = makeProjectWithImage();
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "or-test-key");
vi.stubEnv("HYPERFRAMES_OPENROUTER_MODEL", "google/gemini-3.1-flash-lite");
// Capture the request inside the mock, where the args are well-typed —
// avoids casting `mock.calls` (and the repo's ban on `as` assertions).
let capturedUrl: string | undefined;
let capturedInit: RequestInit | undefined;
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
capturedUrl = url;
capturedInit = init;
return new Response(
JSON.stringify({ choices: [{ message: { content: "A dark hero with blue accents." } }] }),
{ status: 200, headers: { "content-type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchMock);
const warnings: string[] = [];
const captions = await captionImagesWithGemini(dir, () => {}, warnings);
expect(captions).toEqual({ "hero.png": "A dark hero with blue accents." });
expect(warnings).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(capturedUrl).toBe("https://openrouter.ai/api/v1/chat/completions");
expect(new Headers(capturedInit?.headers).get("authorization")).toBe("Bearer or-test-key");
const body = JSON.parse(typeof capturedInit?.body === "string" ? capturedInit.body : "{}");
expect(body.model).toBe("google/gemini-3.1-flash-lite");
const image = body.messages[0].content.find((p: { type: string }) => p.type === "image_url");
expect(image?.image_url?.url).toMatch(/^data:image\/png;base64,/);
});
it("degrades gracefully (no throw, no captions) when OpenRouter returns a non-OK status", async () => {
const dir = makeProjectWithImage();
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "or-bad-key");
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response("invalid api key", { status: 401, statusText: "Unauthorized" }),
),
);
const warnings: string[] = [];
// captionOne throws on !res.ok, but the throw is per-image inside
// Promise.allSettled, so it's filtered out as a rejected result rather than
// bubbling up — same silent degradation as the existing Gemini path.
const captions = await captionImagesWithGemini(dir, () => {}, warnings);
expect(captions).toEqual({});
});
it("skips captioning entirely when no provider key is present", async () => {
const dir = makeProjectWithImage();
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "");
vi.stubEnv("GEMINI_API_KEY", "");
vi.stubEnv("GOOGLE_API_KEY", "");
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const warnings: string[] = [];
const captions = await captionImagesWithGemini(dir, () => {}, warnings);
expect(captions).toEqual({});
expect(fetchMock).not.toHaveBeenCalled();
});
});