mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
* 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>
103 lines
4.1 KiB
TypeScript
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();
|
|
});
|
|
});
|