From 532a9d999f6109141c77664f302dff996c2ccc3b Mon Sep 17 00:00:00 2001 From: F0RGIS Date: Sun, 20 Sep 2026 12:47:10 -0300 Subject: [PATCH 1/4] Add OpenRouter as a generation provider (#1) Adds @hypit/provider-openrouter so Hypit can fulfill installed generation models through an OpenRouter API key. Image capabilities call POST /api/v1/images immediately. Video capabilities submit POST /api/v1/videos, poll the job, then download the result. --- docs/guide/providers.md | 3 +- docs/guide/service-partners.md | 10 + docs/zh/guide/providers.md | 2 +- docs/zh/guide/service-partners.md | 7 + package.json | 1 + packages/provider-openrouter/README.md | 87 ++++++ packages/provider-openrouter/package.json | 24 ++ .../provider-openrouter/src/activation.ts | 62 ++++ packages/provider-openrouter/src/errors.ts | 49 +++ packages/provider-openrouter/src/index.ts | 2 + packages/provider-openrouter/src/mapping.ts | 110 +++++++ packages/provider-openrouter/src/provider.ts | 288 ++++++++++++++++++ packages/provider-openrouter/src/routes.ts | 231 ++++++++++++++ .../provider-openrouter/test/routes.test.ts | 149 +++++++++ packages/video-cli/package.json | 1 + pnpm-lock.yaml | 43 +++ 16 files changed, 1067 insertions(+), 2 deletions(-) create mode 100644 packages/provider-openrouter/README.md create mode 100644 packages/provider-openrouter/package.json create mode 100644 packages/provider-openrouter/src/activation.ts create mode 100644 packages/provider-openrouter/src/errors.ts create mode 100644 packages/provider-openrouter/src/index.ts create mode 100644 packages/provider-openrouter/src/mapping.ts create mode 100644 packages/provider-openrouter/src/provider.ts create mode 100644 packages/provider-openrouter/src/routes.ts create mode 100644 packages/provider-openrouter/test/routes.test.ts diff --git a/docs/guide/providers.md b/docs/guide/providers.md index 80a487709..212e84f88 100644 --- a/docs/guide/providers.md +++ b/docs/guide/providers.md @@ -9,7 +9,8 @@ configured instance of that Provider, with its service address, credential refer The Runtime Profile binds the requested capability to an Endpoint. Hypit's official Distribution includes local Providers, the HypiHub Provider, and API-key Providers -for TokenDance, HiAPI, Pollo and Monid that serve the installed models each service offers. Other services +for TokenDance, HiAPI, Pollo, BeatAPI, Monid and OpenRouter that serve the installed models each +service offers. Other services connect through packages owned by the production or their authors. The Agent can implement a new service through the public SDK, just as it can create a visual component for a video. [Model and deployment services](./service-partners.md) introduces independent partners through that diff --git a/docs/guide/service-partners.md b/docs/guide/service-partners.md index ff980ab97..4bd288da5 100644 --- a/docs/guide/service-partners.md +++ b/docs/guide/service-partners.md @@ -64,6 +64,16 @@ reference media through Monid's workspace file system. For another Monid tool, i [HTTP API documentation](https://monid.ai/docs/api/overview) supplies the connection details, and the Agent can implement the required request and result mapping in a project package. +### OpenRouter + +[OpenRouter](https://openrouter.ai) offers image and video models through one Images API and one +asynchronous Videos API. +[`@hypit/provider-openrouter`](https://github.com/hypit-ai/hypit/blob/main/packages/provider-openrouter/README.md) +serves the Seedance 2.0 and 2.5 models, Seedream 5.0 lite, MiniMax H3, GPT Image 2, Nano Banana and +Grok Imagine models the Distribution describes. Its +[image](https://openrouter.ai/api/v1/images/models) and +[video](https://openrouter.ai/api/v1/videos/models) model indexes list the rest. + ## Your own model deployment A deployment platform supplies somewhere to run a model. The resulting inference service connects diff --git a/docs/zh/guide/providers.md b/docs/zh/guide/providers.md index 18a7c165a..67b5d2c63 100644 --- a/docs/zh/guide/providers.md +++ b/docs/zh/guide/providers.md @@ -5,7 +5,7 @@ description: 选择账户、连接服务或添加模型,沿用同一套视频 **Model** 定义要生成什么:输入、支持的参数和输出类型。**Provider** 知道如何通过某个服务完成这个请求。**Endpoint** 是配置好的 Provider 实例,包含服务地址、凭据引用和容量。Runtime Profile 将所需能力绑定到 Endpoint。 -官方发行包含本地 Provider、HypiHub Provider,以及 TokenDance、HiAPI、Pollo、Monid 四个使用 API Key 的 Provider,各自覆盖该服务提供的已安装模型。其他服务通过项目或作者自己的包接入;Agent 可以使用公开 SDK 编写所需接入,就像为视频创建视觉组件。[模型与部署服务](./service-partners.md) 集中介绍独立合作服务,它们沿用同一套扩展方式。选择执行服务,与[选择 Agent 工作环境](./agents.md)是两件事。 +官方发行包含本地 Provider、HypiHub Provider,以及 TokenDance、HiAPI、Pollo、BeatAPI、Monid、OpenRouter 这些使用 API Key 的 Provider,各自覆盖该服务提供的已安装模型。其他服务通过项目或作者自己的包接入;Agent 可以使用公开 SDK 编写所需接入,就像为视频创建视觉组件。[模型与部署服务](./service-partners.md) 集中介绍独立合作服务,它们沿用同一套扩展方式。选择执行服务,与[选择 Agent 工作环境](./agents.md)是两件事。 HypiHub 是我们推荐的集成托管服务。**BYOK** 指使用自己账户的 API Key:Key 通过兼容的 Provider 连接到签发它的服务。告诉 Agent 你已有哪个服务及其 API 文档,具体接线和项目包可以由 Agent 完成。Key 负责授权请求,本身不会实现 API 接口。一个项目可以为不同能力使用不同服务,分别使用各自的账户并按各自规则计费。 diff --git a/docs/zh/guide/service-partners.md b/docs/zh/guide/service-partners.md index 0dc67119a..14f8b280e 100644 --- a/docs/zh/guide/service-partners.md +++ b/docs/zh/guide/service-partners.md @@ -54,6 +54,13 @@ Pollo 只接受公网 URL 形式的参考素材,带参考素材的请求需要 使用 Monid 的其他工具时,[HTTP API 文档](https://monid.ai/docs/api/overview)提供接入依据, Agent 可以在项目包中实现这次所需的请求与结果映射。 +### OpenRouter + +[OpenRouter](https://openrouter.ai) 通过统一的图片 API 和异步视频 API 提供多家模型。 +[`@hypit/provider-openrouter`](https://github.com/hypit-ai/hypit/blob/main/packages/provider-openrouter/README.md) +提供发行包已描述的 Seedance 2.0、2.5 系列、Seedream 5.0 lite、MiniMax H3、GPT Image 2、Nano Banana 和 Grok Imagine。 +其余模型见它的[图片](https://openrouter.ai/api/v1/images/models)与[视频](https://openrouter.ai/api/v1/videos/models)模型目录。 + ## 自己部署模型 部署平台提供运行模型的地方,部署得到的推理服务沿用 Model–Provider–Endpoint 的关系接入。 diff --git a/package.json b/package.json index 1b118f635..76cf4a59d 100644 --- a/package.json +++ b/package.json @@ -243,6 +243,7 @@ "@hypit/provider-image-opencv-local": "workspace:*", "@hypit/provider-media-local": "workspace:*", "@hypit/provider-monid": "workspace:*", + "@hypit/provider-openrouter": "workspace:*", "@hypit/provider-pollo": "workspace:*", "@hypit/provider-tokendance": "workspace:*", "@hypit/provider-whisperx-local": "workspace:*", diff --git a/packages/provider-openrouter/README.md b/packages/provider-openrouter/README.md new file mode 100644 index 000000000..9e3170969 --- /dev/null +++ b/packages/provider-openrouter/README.md @@ -0,0 +1,87 @@ +# `@hypit/provider-openrouter` + +Hypit Runtime Provider for an [OpenRouter](https://openrouter.ai) account. Image capabilities call +`POST /api/v1/images` and store the returned files immediately. Video capabilities submit +`POST /api/v1/videos`, poll `GET /api/v1/videos/{jobId}` until the job is terminal, then download +`unsigned_urls` (or `GET /api/v1/videos/{jobId}/content`) into the current Build. + +| Capability | OpenRouter model | +| --- | --- | +| `@hypit/seedance@1#seedance-2` | `bytedance/seedance-2.0` | +| `@hypit/seedance@1#seedance-2-fast` | `bytedance/seedance-2.0-fast` | +| `@hypit/seedance@1#seedance-2-mini` | `bytedance/seedance-2.0-mini` | +| `@hypit/seedance@1#seedance-2.5` | `bytedance/seedance-2.5` | +| `@hypit/seedream@1#seedream-5-lite` | `bytedance-seed/seedream-5-0-lite` | +| `@hypit/minimax-h3@1#minimax-h3` | `minimax/hailuo-3` | +| `@hypit/gpt-image@1#gpt-image-2` | `openai/gpt-image-2` | +| `@hypit/nano-banana@1#nano-banana-2` | `google/gemini-3.1-flash-image` | +| `@hypit/nano-banana@1#nano-banana-pro` | `google/gemini-3-pro-image` | +| `@hypit/grok-imagine@1#grok-imagine-video` | `x-ai/grok-imagine-video` | +| `@hypit/grok-imagine@1#grok-imagine-video-1.5-preview` | `x-ai/grok-imagine-video-1.5` | + +OpenRouter's [image](https://openrouter.ai/api/v1/images/models) and +[video](https://openrouter.ai/api/v1/videos/models) indexes list further models; this Provider maps +only the models the Distribution already describes. + +Video requests send `prompt`, `duration`, `resolution`, `aspect_ratio` and `generate_audio` where +the model declares them. First and last frames become `frame_images` with `frame_type`; reference +images, videos and audio become `input_references`. Grok Imagine with one image uses that image as +`first_frame`; several images travel as `input_references`. Seedance visual references require +`person-reference`; the Provider accepts the declaration and transmits nothing for it, since +OpenRouter has no field for it. `web-search="true"` is unsupported: the Videos API has no such field. + +Image requests send `prompt`, `aspect_ratio`, `resolution` where the model takes it, GPT Image +`background`, and `input_references` for reference pictures. Seedream 5.0 lite maps `quality` +`basic`→`2K` and `ultra`→`4K`; `output-format` and `nsfw-check` have no OpenRouter field and are +not sent. Nano Banana `output-format` is likewise omitted. + +Service limits this Provider reports as unsupported before submitting: + +- Seedance aspect-ratio `adaptive` is unsupported. Seedance 2.5 renders 480p or 720p, not 1080p, and + does not take duration `-1`. Seedance 2 Fast/Mini render 480p or 720p. +- Seedream 5.0 lite renders 2K (`quality="basic"`) or 4K (`quality="ultra"`); `high` (3K) is + unsupported. +- `minimax/hailuo-3` renders 2K only, duration from 5 seconds, and accepts up to five reference + images. +- GPT Image 2 has no resolution field on OpenRouter: only `1K` is accepted. `background` is `auto` + or `opaque`; `transparent` is unsupported. Aspect ratios outside OpenRouter's GPT Image 2 list + are refused. +- Nano Banana `aspect-ratio="auto"` is unsupported. +- Grok Imagine Video renders 480p or 720p for at most 15 seconds. Grok Imagine Video 1.5 also + renders 1080p and animates at most one first-frame image. Aspect-ratio `auto` is unsupported. + +Reference images and audio travel inline as `data:` URLs, within 30 MB per image and 15 MB per +audio file. A reference video must be a public HTTPS URL; configure `publicAssetUrl` when embedding +the Provider, otherwise such a request fails before submission. + +Runtime Profile example: + +```json +{ + "format": "hypit.runtime-local@1", + "dataRoot": ".hypit/runtimes/local", + "credentials": { + "platform": { + "use": "@hypit/credential-store-platform" + } + }, + "endpoints": { + "openrouter.default": { + "use": "@hypit/provider-openrouter", + "pool": "openrouter.default", + "config": { + "apiKey": { "store": "platform", "key": "openrouter.api-key" }, + "defaultConcurrency": 3, + "pollIntervalMs": 10000 + } + } + }, + "bindings": {} +} +``` + +`baseUrl` defaults to `https://openrouter.ai`. Store the API key with +`hypit auth login openrouter.default --runtime hypit.runtime.json`. Optional `requestTimeoutMs`, +`operationTimeoutMs` and `actionLimits` bound single HTTP calls, the whole remote video job and +action concurrency. HTTP failures keep OpenRouter's `error.code` and message; failed jobs keep the +job `error`, with any signed URL in the message redacted. diff --git a/packages/provider-openrouter/package.json b/packages/provider-openrouter/package.json new file mode 100644 index 000000000..04fe2997d --- /dev/null +++ b/packages/provider-openrouter/package.json @@ -0,0 +1,24 @@ +{ + "name": "@hypit/provider-openrouter", + "version": "0.0.0-dev", + "license": "SEE LICENSE IN LICENSE", + "private": true, + "type": "module", + "exports": { ".": "./src/index.ts" }, + "hypit": { "activation": "./src/activation.ts" }, + "dependencies": { + "@hypit/endpoint-kit": "workspace:*", + "@hypit/generation": "workspace:*", + "@hypit/protocol": "workspace:*", + "@hypit/runtime": "workspace:*", + "@hypit/runtime-kit": "workspace:*" + }, + "devDependencies": { + "@hypit/gpt-image": "workspace:*", + "@hypit/grok-imagine": "workspace:*", + "@hypit/minimax-h3": "workspace:*", + "@hypit/nano-banana": "workspace:*", + "@hypit/seedance": "workspace:*", + "@hypit/seedream": "workspace:*" + } +} diff --git a/packages/provider-openrouter/src/activation.ts b/packages/provider-openrouter/src/activation.ts new file mode 100644 index 000000000..80d57ec9b --- /dev/null +++ b/packages/provider-openrouter/src/activation.ts @@ -0,0 +1,62 @@ +import { + createRuntimeEndpointAdapterFacet, + runtimeConfigCredentialRef, + runtimeConfigActionLimits, + runtimeConfigExact, + runtimeConfigObject, + runtimeConfigPositiveInteger, + runtimeConfigString, +} from "@hypit/runtime-kit"; + +import { createOpenRouterProvider } from "./provider.js"; + +const adapter = createRuntimeEndpointAdapterFacet({ + use: "@hypit/provider-openrouter", + activate(context) { + if (context.pool === undefined) throw new Error("OpenRouter Provider Pool is required"); + const config = runtimeConfigObject(context.config, "OpenRouter"); + runtimeConfigExact(config, [ + "baseUrl", + "apiKey", + "defaultConcurrency", + "actionLimits", + "pollIntervalMs", + "requestTimeoutMs", + "operationTimeoutMs", + ], "OpenRouter"); + const baseUrl = runtimeConfigString(config.baseUrl, "OpenRouter baseUrl"); + if (baseUrl !== undefined) { + const url = new URL(baseUrl); + if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") { + throw new Error("OpenRouter baseUrl must use HTTPS or loopback"); + } + } + const apiKey = runtimeConfigCredentialRef(config.apiKey, "OpenRouter apiKey"); + if (apiKey === undefined) throw new Error("OpenRouter apiKey CredentialRef is required"); + const actionLimits = runtimeConfigActionLimits(config.actionLimits); + const defaultConcurrency = runtimeConfigPositiveInteger(config.defaultConcurrency, "OpenRouter defaultConcurrency"); + const pollIntervalMs = runtimeConfigPositiveInteger(config.pollIntervalMs, "OpenRouter pollIntervalMs"); + const requestTimeoutMs = runtimeConfigPositiveInteger(config.requestTimeoutMs, "OpenRouter requestTimeoutMs"); + const operationTimeoutMs = runtimeConfigPositiveInteger(config.operationTimeoutMs, "OpenRouter operationTimeoutMs"); + return { + endpoint: createOpenRouterProvider({ + instance: context.instance, + pool: context.pool, + ...(baseUrl === undefined ? {} : { baseUrl }), + apiKey, + ...(defaultConcurrency === undefined ? {} : { defaultConcurrency }), + ...(actionLimits === undefined ? {} : { actionLimits }), + ...(pollIntervalMs === undefined ? {} : { pollIntervalMs }), + ...(requestTimeoutMs === undefined ? {} : { requestTimeoutMs }), + ...(operationTimeoutMs === undefined ? {} : { operationTimeoutMs }), + }), + }; + }, +}); + +export const hypitPackage = { + format: "hypit.node-package@1" as const, + hostFacets: [adapter], +}; + +export default hypitPackage; diff --git a/packages/provider-openrouter/src/errors.ts b/packages/provider-openrouter/src/errors.ts new file mode 100644 index 000000000..567d7b518 --- /dev/null +++ b/packages/provider-openrouter/src/errors.ts @@ -0,0 +1,49 @@ +/** OpenRouter's `{ error: { code, message } }` envelope and video-job `error` field, kept at the service boundary. */ +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : undefined; +} +function text(value: unknown): string | undefined { + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return undefined; +} + +// Responses may mention a signed asset URL. Keep the reason, not its access capability. +export function safeOpenRouterReason(value: string): string { + return value.replace(/https?:\/\/\S+/giu, "[redacted-url]"); +} + +export class OpenRouterServiceError extends Error { + constructor(readonly code: string, message: string) { super(message); } +} + +export class OpenRouterHttpError extends OpenRouterServiceError { + constructor(readonly status: number, response: { readonly headers: Headers }, bodyText: string, + request: { readonly method: string; readonly path: string; readonly model?: string }) { + let body: Record | undefined; + try { body = record(JSON.parse(bodyText)); } catch { /* Non-JSON gateway failures still have HTTP evidence. */ } + const error = record(body?.error); + const code = text(error?.code) ?? text(error?.type) ?? text(body?.code) ?? "OPENROUTER_HTTP_ERROR"; + const reason = text(error?.message) ?? text(body?.error) ?? text(body?.message) + ?? (body === undefined ? text(bodyText.slice(0, 2000)) : undefined); + const requestId = text(response.headers.get("x-request-id")) ?? text(response.headers.get("x-openrouter-id")); + const facts = [ + `OpenRouter HTTP ${status}`, code, `${request.method} ${request.path}`, + ...(request.model === undefined ? [] : [`model=${request.model}`]), + ...(requestId === undefined ? [] : [`request=${requestId}`]), + ]; + super(code, `${facts.join("; ")}${reason === undefined ? "" : `: ${safeOpenRouterReason(reason)}`}`); + } +} + +/** A terminal video job; `undefined` otherwise. */ +export function openRouterTaskFailure(task: Record, id: string): OpenRouterServiceError | undefined { + const status = String(task.status); + if (!["failed", "cancelled", "canceled", "expired"].includes(status)) return undefined; + const error = record(task.error); + const code = text(error?.code) ?? text(error?.type) ?? "OPENROUTER_TASK_FAILED"; + const reason = text(error?.message) ?? text(task.error); + return new OpenRouterServiceError(code, + `OpenRouter job ${id} ${status}; ${code}${reason === undefined ? "" : `: ${safeOpenRouterReason(reason)}`}`); +} diff --git a/packages/provider-openrouter/src/index.ts b/packages/provider-openrouter/src/index.ts new file mode 100644 index 000000000..b6f6e7ac0 --- /dev/null +++ b/packages/provider-openrouter/src/index.ts @@ -0,0 +1,2 @@ +export { createOpenRouterProvider, openRouterProviderModuleRef } from "./provider.js"; +export type { CreateOpenRouterProviderOptions } from "./provider.js"; diff --git a/packages/provider-openrouter/src/mapping.ts b/packages/provider-openrouter/src/mapping.ts new file mode 100644 index 000000000..b8d514946 --- /dev/null +++ b/packages/provider-openrouter/src/mapping.ts @@ -0,0 +1,110 @@ +import type { ModuleRef } from "@hypit/protocol"; +import type { GenerationWireMapping } from "@hypit/generation"; + +const SEEDANCE: ModuleRef = { name: "@hypit/seedance", version: "1" }; +const SEEDREAM: ModuleRef = { name: "@hypit/seedream", version: "1" }; +const MINIMAX: ModuleRef = { name: "@hypit/minimax-h3", version: "1" }; +const GPT_IMAGE: ModuleRef = { name: "@hypit/gpt-image", version: "1" }; +const NANO_BANANA: ModuleRef = { name: "@hypit/nano-banana", version: "1" }; +const GROK: ModuleRef = { name: "@hypit/grok-imagine", version: "1" }; + +/** + * OpenRouter model IDs and the request fields they accept. Media fields keep intermediate names; + * routes.ts folds them into `frame_images` / `input_references`. `personReference` is accepted on + * Seedance visual references and not transmitted: OpenRouter has no field for it. + */ +const seedanceFields = { + prompt: { as: "value", field: "prompt" }, + aspectRatio: { as: "value", field: "aspect_ratio" }, + duration: { as: "value", field: "duration" }, + resolution: { as: "value", field: "resolution" }, + firstFrame: { as: "url", field: "first_frame", resourceFields: ["personReference"] }, + lastFrame: { as: "url", field: "last_frame", resourceFields: ["personReference"] }, + referenceImage: { as: "urlArray", field: "reference_images", resourceFields: ["personReference"] }, + referenceVideo: { as: "urlArray", field: "reference_videos", resourceFields: ["personReference"] }, + referenceAudio: { as: "urlArray", field: "reference_audios" }, + generateAudio: { as: "value", field: "generate_audio" }, + webSearch: { as: "value", field: "web_search" }, +} as const satisfies GenerationWireMapping["fields"]; + +const seedance = (name: string, model: string): GenerationWireMapping => ({ + capability: { module: SEEDANCE, name }, result: "video", routes: [{ model }], fields: seedanceFields, +}); + +export const openRouterMappings: readonly GenerationWireMapping[] = [ + seedance("seedance-2", "bytedance/seedance-2.0"), + seedance("seedance-2-fast", "bytedance/seedance-2.0-fast"), + seedance("seedance-2-mini", "bytedance/seedance-2.0-mini"), + seedance("seedance-2.5", "bytedance/seedance-2.5"), + { + capability: { module: SEEDREAM, name: "seedream-5-lite" }, result: "image", + routes: [{ model: "bytedance-seed/seedream-5-0-lite" }], + fields: { + prompt: { as: "value", field: "prompt" }, + aspectRatio: { as: "value", field: "aspect_ratio" }, + quality: { as: "value", field: "quality" }, + outputFormat: { as: "value", field: "output_format" }, + nsfwCheck: { as: "value", field: "nsfw_check" }, + images: { as: "urlArray", field: "images" }, + }, + }, + { + capability: { module: MINIMAX, name: "minimax-h3" }, result: "video", + routes: [{ model: "minimax/hailuo-3" }], + fields: { + prompt: { as: "value", field: "prompt" }, + duration: { as: "value", field: "duration" }, + resolution: { as: "value", field: "resolution" }, + aspectRatio: { as: "value", field: "aspect_ratio" }, + firstFrame: { as: "url", field: "first_frame" }, + lastFrame: { as: "url", field: "last_frame" }, + referenceImage: { as: "urlArray", field: "reference_images" }, + referenceVideo: { as: "urlArray", field: "reference_videos" }, + referenceAudio: { as: "urlArray", field: "reference_audios" }, + }, + }, + { + capability: { module: GPT_IMAGE, name: "gpt-image-2" }, result: "image", + routes: [{ model: "openai/gpt-image-2" }], + fields: { + prompt: { as: "value", field: "prompt" }, + aspectRatio: { as: "value", field: "aspect_ratio" }, + resolution: { as: "value", field: "resolution" }, + background: { as: "value", field: "background" }, + images: { as: "urlArray", field: "images" }, + }, + }, + ...([["nano-banana-2", "google/gemini-3.1-flash-image"], ["nano-banana-pro", "google/gemini-3-pro-image"]] as const) + .map(([name, model]): GenerationWireMapping => ({ + capability: { module: NANO_BANANA, name }, result: "image", routes: [{ model }], + fields: { + prompt: { as: "value", field: "prompt" }, + images: { as: "urlArray", field: "images" }, + aspectRatio: { as: "value", field: "aspect_ratio" }, + resolution: { as: "value", field: "resolution" }, + outputFormat: { as: "value", field: "output_format" }, + }, + })), + { + capability: { module: GROK, name: "grok-imagine-video" }, result: "video", + routes: [{ model: "x-ai/grok-imagine-video" }], + fields: { + prompt: { as: "value", field: "prompt" }, + aspectRatio: { as: "value", field: "aspect_ratio" }, + resolution: { as: "value", field: "resolution" }, + duration: { as: "value", field: "duration" }, + images: { as: "urlArray", field: "images" }, + }, + }, + { + capability: { module: GROK, name: "grok-imagine-video-1.5-preview" }, result: "video", + routes: [{ model: "x-ai/grok-imagine-video-1.5" }], + fields: { + prompt: { as: "value", field: "prompt" }, + aspectRatio: { as: "value", field: "aspect_ratio" }, + resolution: { as: "value", field: "resolution" }, + duration: { as: "value", field: "duration" }, + images: { as: "urlArray", field: "images" }, + }, + }, +]; diff --git a/packages/provider-openrouter/src/provider.ts b/packages/provider-openrouter/src/provider.ts new file mode 100644 index 000000000..ef36cbd3d --- /dev/null +++ b/packages/provider-openrouter/src/provider.ts @@ -0,0 +1,288 @@ +import { requestDeadline } from "@hypit/runtime-kit"; +import type { AsyncEndpoint, EndpointCredential, EndpointInvocationContext, EndpointOutcome, ImmediateEndpointHandler } from "@hypit/endpoint-kit"; +import { defineEndpointPackage, wakeAfter } from "@hypit/endpoint-kit"; +import type { GenerationArtifactUrlResolver } from "@hypit/generation"; +import { canonicalize } from "@hypit/protocol"; +import type { BlobRef } from "@hypit/protocol"; +import { credentialRef } from "@hypit/runtime"; +import type { CredentialRef, ResourceStore } from "@hypit/runtime"; +import { openRouterRouteForCapability, openRouterRoutes } from "./routes.js"; +import type { OpenRouterMediaLimits, OpenRouterRoute } from "./routes.js"; +import { OpenRouterHttpError, OpenRouterServiceError, openRouterTaskFailure } from "./errors.js"; + +export const openRouterProviderModuleRef = { name: "@hypit/provider-openrouter", version: "1" } as const; + +export type CreateOpenRouterProviderOptions = { + readonly instance?: string; + readonly pool?: string; + readonly baseUrl?: string; + readonly apiKey?: CredentialRef; + readonly defaultConcurrency?: number; + readonly actionLimits?: import("@hypit/endpoint-kit").EndpointActionLimits; + readonly pollIntervalMs?: number; + readonly requestTimeoutMs?: number; + readonly operationTimeoutMs?: number; + readonly fetch?: typeof globalThis.fetch; + /** Publish a referenced Resource at a URL the service can fetch; replaces inline data URLs. */ + readonly publicAssetUrl?: (artifact: BlobRef, artifacts: ResourceStore, fields?: Readonly>) => Promise; +}; + +type Handle = { + readonly contract: "hypit.openrouter-operation@1"; + readonly taskId: string; + readonly route: string; + readonly startedAt: number; + readonly urls?: readonly string[]; +}; + +const OPENROUTER_HEADERS = { + "http-referer": "https://hypit.ai", + "x-title": "Hypit", +} as const; + +function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } +function object(value: unknown, subject: string): Record { + assert(value !== null && typeof value === "object" && !Array.isArray(value), `${subject} must be an object`); + return value as Record; +} +function apiBaseUrl(value: string): string { + let trimmed = value.trim(); + while (trimmed.endsWith("/")) trimmed = trimmed.slice(0, -1); + assert(trimmed.length > 0, "OpenRouter base URL is empty"); + return trimmed; +} +function apiKey(credentials: Readonly>): string { + const value = credentials.apiKey?.secret; + assert(typeof value === "string" && value.length > 0, "OpenRouter apiKey credential is unavailable; store an OpenRouter API key for this Endpoint"); + return value; +} +function failureMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} +function failure(error: unknown): EndpointOutcome { + return { status: "failed", failure: { code: error instanceof OpenRouterServiceError ? error.code : "OPENROUTER_ERROR", message: failureMessage(error) } }; +} + +class OpenRouterClient { + constructor(readonly baseUrl: string, readonly timeout: number, readonly fetcher: typeof globalThis.fetch) {} + private headers(key: string, extra?: HeadersInit): HeadersInit { + return { authorization: `Bearer ${key}`, ...OPENROUTER_HEADERS, ...(extra ?? {}) }; + } + async json(path: string, key: string, init: RequestInit = {}): Promise> { + const deadline = requestDeadline(this.timeout); + try { + const response = await deadline.wait(this.fetcher(`${this.baseUrl}${path}`, { + ...init, signal: deadline.signal, headers: this.headers(key, init.headers), + })); + const text = await deadline.wait(response.text()); + if (!response.ok) { + const input = typeof init.body === "string" ? JSON.parse(init.body) as Record : undefined; + throw new OpenRouterHttpError(response.status, response, text, { + method: init.method ?? "GET", path, ...(typeof input?.model === "string" ? { model: input.model } : {}), + }); + } + let body: unknown; + try { body = text.length === 0 ? {} : JSON.parse(text); } catch { throw new Error(`OpenRouter returned invalid JSON (${response.status})`); } + return object(body, "OpenRouter response"); + } finally { deadline.finish(); } + } + async download(url: string, key?: string): Promise<{ readonly bytes: Uint8Array; readonly mediaType: string }> { + const deadline = requestDeadline(this.timeout); + try { + const headers = key === undefined ? undefined : this.headers(key); + const response = await deadline.wait(this.fetcher(url, { signal: deadline.signal, ...(headers === undefined ? {} : { headers }) })); + if (!response.ok) throw new Error(`OpenRouter asset returned HTTP ${response.status}`); + return { bytes: new Uint8Array(await deadline.wait(response.arrayBuffer())), mediaType: response.headers.get("content-type")?.split(";", 1)[0] ?? "application/octet-stream" }; + } finally { deadline.finish(); } + } +} + +function mediaKind(mediaType: string): "image" | "video" | "audio" | undefined { + const kind = mediaType.split("/", 1)[0]; + return kind === "image" || kind === "video" || kind === "audio" ? kind : undefined; +} + +function resolverFor(limits: OpenRouterMediaLimits, context: EndpointInvocationContext, publicAssetUrl: CreateOpenRouterProviderOptions["publicAssetUrl"]): GenerationArtifactUrlResolver { + const resolved = new Map>(); + return (artifact, fields) => { + const existing = resolved.get(artifact.resource); + if (existing !== undefined) return existing; + const promise = (async () => { + if (publicAssetUrl !== undefined) return await publicAssetUrl(artifact, context.resources, fields); + const kind = mediaKind(artifact.mediaType); + const limit = kind === undefined ? undefined : limits[kind as keyof OpenRouterMediaLimits]; + assert(limit !== undefined, + `OpenRouter accepts ${artifact.mediaType} references only by public URL; configure publicAssetUrl for this Endpoint`); + assert(artifact.size <= limit, + `OpenRouter accepts ${kind} references up to ${limit / 1_000_000} MB for this model; ${artifact.resource} is ${artifact.size} bytes`); + const bytes = await context.resources.get(artifact.resource); + assert(bytes !== undefined && bytes.byteLength === artifact.size, `Reference Resource ${artifact.resource} is unavailable or has changed`); + return `data:${artifact.mediaType};base64,${Buffer.from(bytes).toString("base64")}`; + })(); + resolved.set(artifact.resource, promise); + return promise; + }; +} + +async function prepare(context: EndpointInvocationContext, publicAssetUrl: CreateOpenRouterProviderOptions["publicAssetUrl"]) { + const route = openRouterRouteForCapability(context.need.capability); + assert(route !== undefined, "OpenRouter does not implement this exact capability"); + const request = route.prepare(context.need.constraints); + await context.reportProgress?.({ phase: `Preparing OpenRouter request: ${request.model}` }); + let body: Record; + try { + body = await request.compile(resolverFor(request.mediaLimits, context, publicAssetUrl)); + } catch (error) { + throw new OpenRouterServiceError(error instanceof OpenRouterServiceError ? error.code : "OPENROUTER_ERROR", + `OpenRouter request preparation failed; model=${request.model}; generation not submitted: ${failureMessage(error)}`); + } + return { route, model: request.model, body }; +} + +function decodeDataUrl(value: string): { readonly bytes: Uint8Array; readonly mediaType: string } | undefined { + const match = /^data:([^;,]+);base64,([A-Za-z0-9+/]+=*)$/u.exec(value); + if (match === null) return undefined; + return { mediaType: match[1]!, bytes: Buffer.from(match[2]!, "base64") }; +} + +async function storeImages( + client: OpenRouterClient, + response: Record, + resources: ResourceStore, + key: string, +): Promise { + assert(Array.isArray(response.data) && response.data.length > 0, "OpenRouter image response has no data"); + const blobs: BlobRef[] = []; + for (const [index, item] of response.data.entries()) { + const image = object(item, `OpenRouter image ${index + 1}`); + const encoded = typeof image.b64_json === "string" ? image.b64_json : undefined; + if (encoded !== undefined) { + blobs.push(await resources.put(Buffer.from(encoded, "base64"), "image/png")); + continue; + } + const url = typeof image.url === "string" ? image.url + : typeof image.image_url === "string" ? image.image_url + : typeof object(image.image_url ?? {}, `OpenRouter image ${index + 1} image_url`).url === "string" + ? object(image.image_url, `OpenRouter image ${index + 1} image_url`).url as string + : undefined; + assert(typeof url === "string" && url.length > 0, `OpenRouter image ${index + 1} has no image data`); + const inline = decodeDataUrl(url); + if (inline !== undefined) { + blobs.push(await resources.put(inline.bytes, inline.mediaType)); + continue; + } + const downloaded = await client.download(url, key); + blobs.push(await resources.put(downloaded.bytes, downloaded.mediaType.startsWith("image/") ? downloaded.mediaType : "image/png")); + } + return blobs; +} + +function videoUrls(client: OpenRouterClient, task: Record, taskId: string): readonly string[] { + if (Array.isArray(task.unsigned_urls) && task.unsigned_urls.length > 0) { + return task.unsigned_urls.map((item, index) => { + assert(typeof item === "string" && item.length > 0, `OpenRouter output ${index + 1} has no URL`); + return item; + }); + } + return [`${client.baseUrl}/api/v1/videos/${encodeURIComponent(taskId)}/content`]; +} + +function endpoint(client: OpenRouterClient, pollIntervalMs: number, maxOperationMs: number, publicAssetUrl: CreateOpenRouterProviderOptions["publicAssetUrl"]): AsyncEndpoint { + return { + async start(context) { + try { + const { route, model, body } = await prepare(context, publicAssetUrl); + assert(route.protocol === "video", "OpenRouter image capabilities use an immediate endpoint"); + await context.reportProgress?.({ phase: `Submitting OpenRouter request: ${model}` }); + const response = await client.json("/api/v1/videos", apiKey(context.credentials), { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }); + const taskId = response.id; + assert(typeof taskId === "string" && taskId.length > 0, "OpenRouter response has no job id"); + const handle: Handle = { contract: "hypit.openrouter-operation@1", taskId, route: route.key, startedAt: Date.now() }; + const receipt = { id: handle.taskId }; + await context.checkpoint?.({ handle: canonicalize(handle), receipt }); + return { ...wakeAfter(canonicalize(handle), pollIntervalMs, Date.now(), { phase: "submitted" }), receipt }; + } catch (error) { + return failure(error); + } + }, + async poll(context) { + try { + const handle = object(context.handle, "OpenRouter handle") as unknown as Handle; + const route = openRouterRouteForCapability(context.need.capability); + assert(route !== undefined && handle.contract === "hypit.openrouter-operation@1" && handle.route === route.key && route.protocol === "video", "OpenRouter handle is invalid"); + const receipt = { id: handle.taskId }; + if (Date.now() - handle.startedAt > maxOperationMs) { + return { status: "failed", receipt, failure: { code: "OPENROUTER_OPERATION_TIMEOUT", message: `OpenRouter job ${handle.taskId} exceeded this Provider's operationTimeoutMs (${maxOperationMs}); remote outcome is unknown` } }; + } + const task = await client.json(`/api/v1/videos/${encodeURIComponent(handle.taskId)}`, apiKey(context.credentials)); + const status = String(task.status); + if (status === "pending" || status === "in_progress" || status === "queued" || status === "processing") { + return { ...wakeAfter(canonicalize(handle), pollIntervalMs, Date.now(), { phase: status }), receipt }; + } + const rejected = openRouterTaskFailure(task, handle.taskId); + if (rejected !== undefined) return { ...failure(rejected), receipt }; + assert(status === "completed" || status === "succeeded", `OpenRouter returned unknown job status ${status}`); + const urls = videoUrls(client, task, handle.taskId); + return { status: "ready", handle: canonicalize({ ...handle, urls }), receipt }; + } catch (error) { + return failure(error); + } + }, + async collect(context) { + try { + const handle = object(context.handle, "OpenRouter handle") as unknown as Handle; + const route = openRouterRouteForCapability(context.need.capability); + assert(route !== undefined && handle.route === route.key && Array.isArray(handle.urls), "OpenRouter collection route differs"); + await context.reportProgress?.({ phase: "Receiving generated files" }); + const key = apiKey(context.credentials); + const blobs: BlobRef[] = []; + for (const url of handle.urls) { + const downloaded = await client.download(url, key); + blobs.push(await resourcesPutVideo(context.resources, downloaded)); + } + return { status: "completed", result: { value: route.packageResult(blobs) }, receipt: { id: handle.taskId } }; + } catch (error) { + return failure(error); + } + }, + }; +} + +async function resourcesPutVideo(resources: ResourceStore, downloaded: { readonly bytes: Uint8Array; readonly mediaType: string }): Promise { + const mediaType = downloaded.mediaType.startsWith("video/") ? downloaded.mediaType : "video/mp4"; + return await resources.put(downloaded.bytes, mediaType); +} + +export function createOpenRouterProvider(options: CreateOpenRouterProviderOptions = {}) { + const requestTimeoutMs = options.requestTimeoutMs ?? 300_000; + const operationTimeoutMs = options.operationTimeoutMs ?? 30 * 60_000; + for (const [name, value] of Object.entries({ requestTimeoutMs, operationTimeoutMs })) { + assert(Number.isSafeInteger(value) && value > 0, `OpenRouter ${name} must be a positive integer`); + } + const client = new OpenRouterClient(apiBaseUrl(options.baseUrl ?? "https://openrouter.ai"), requestTimeoutMs, options.fetch ?? globalThis.fetch); + const asyncEndpoint = endpoint(client, options.pollIntervalMs ?? 10_000, operationTimeoutMs, options.publicAssetUrl); + const imageEndpoint: ImmediateEndpointHandler = async (context) => { + const { route, model, body } = await prepare(context, options.publicAssetUrl); + assert(route.protocol === "image", "OpenRouter video capabilities use an asynchronous endpoint"); + await context.reportProgress?.({ phase: `Submitting OpenRouter request: ${model}` }); + const key = apiKey(context.credentials); + const response = await client.json("/api/v1/images", key, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }); + await context.reportProgress?.({ phase: "Receiving generated images" }); + return { value: route.packageResult(await storeImages(client, response, context.resources, key)) }; + }; + return defineEndpointPackage({ + module: openRouterProviderModuleRef, facet: "gateway", instance: options.instance ?? "openrouter.default", pool: options.pool ?? options.instance ?? "openrouter.default", + pricing: { kind: "page", url: "https://openrouter.ai/models" }, + credentials: { apiKey: options.apiKey ?? credentialRef("os", "openrouter.api-key") }, + credentialInputs: { apiKey: { label: "OpenRouter API key" } }, + defaultConcurrency: options.defaultConcurrency ?? 4, + ...(options.actionLimits === undefined ? {} : { actionLimits: options.actionLimits }), + capabilities: openRouterRoutes.map((route: OpenRouterRoute) => route.protocol === "image" + ? { capability: route.capability, returns: route.returns, lifecycle: "immediate" as const, handler: imageEndpoint, capacity: route.capability.name, supports: route.supports } + : { capability: route.capability, returns: route.returns, lifecycle: "asynchronous" as const, endpoint: asyncEndpoint, capacity: route.capability.name, supports: route.supports }), + }); +} diff --git a/packages/provider-openrouter/src/routes.ts b/packages/provider-openrouter/src/routes.ts new file mode 100644 index 000000000..42808a4cd --- /dev/null +++ b/packages/provider-openrouter/src/routes.ts @@ -0,0 +1,231 @@ +import { + compileWireRequest, + selectWireModelForRequest, + generationTypes, + sealGeneratedImageSet, + sealGeneratedVideoSet, +} from "@hypit/generation"; +import type { GenerationArtifactUrlResolver, GenerationRequest, GenerationWireMapping } from "@hypit/generation"; +import { canonicalize } from "@hypit/protocol"; +import type { BlobRef, CapabilityRef, CanonicalValue, StoredValue, TypeRef } from "@hypit/protocol"; +import type { EndpointRequest, EndpointSupport } from "@hypit/endpoint-kit"; +import { openRouterMappings } from "./mapping.js"; + +/** Which OpenRouter generation API a capability submits through. */ +export type OpenRouterProtocol = "image" | "video"; + +/** Documented byte limits for inline media; an absent kind is not accepted as a data URL. */ +export type OpenRouterMediaLimits = { + readonly image?: number; + readonly audio?: number; +}; + +const MB = 1_000_000; + +export const openRouterMediaLimits: Readonly> = { + image: { image: 30 * MB }, + video: { image: 30 * MB, audio: 15 * MB }, +}; + +export type OpenRouterPreparedRequest = { + readonly model: string; + readonly protocol: OpenRouterProtocol; + readonly mediaLimits: OpenRouterMediaLimits; + readonly compile: (resolve: GenerationArtifactUrlResolver) => Promise>; +}; + +export type OpenRouterRoute = GenerationWireMapping & { + readonly key: string; + readonly returns: TypeRef; + readonly protocol: OpenRouterProtocol; + readonly mediaLimits: OpenRouterMediaLimits; + readonly supports: (request: EndpointRequest) => EndpointSupport; + readonly prepare: (constraints: CanonicalValue) => OpenRouterPreparedRequest; + readonly packageResult: (artifacts: readonly BlobRef[]) => StoredValue; +}; + +function scalar(request: GenerationRequest, port: string): string | number | boolean | undefined { + const value = request.ports[port]?.[0]; + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : undefined; +} +function count(request: GenerationRequest, port: string): number { + return request.ports[port]?.length ?? 0; +} +function strings(value: unknown): readonly string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.length > 0) : []; +} +function listed(value: unknown, allowed: readonly string[]): boolean { + return typeof value === "string" && allowed.includes(value); +} + +const SEEDREAM_RESOLUTION: Readonly> = { basic: "2K", ultra: "4K" }; +const SEEDANCE_20_RATIOS = ["1:1", "3:4", "9:16", "4:3", "16:9", "21:9", "9:21"] as const; +const SEEDANCE_25_RATIOS = ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"] as const; +const GPT_IMAGE_RATIOS = ["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16", "21:9", "auto"] as const; +const NANO_BANANA_2_RATIOS = ["1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"] as const; +const NANO_BANANA_PRO_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"] as const; +const GROK_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3"] as const; + +/** OpenRouter's documented input ranges for each mapped model, checked before any reference is resolved. */ +function rejection(mapping: GenerationWireMapping, request: GenerationRequest): string | undefined { + const { name } = mapping.capability; + const ratio = scalar(request, "aspectRatio"); + const resolution = scalar(request, "resolution"); + const duration = scalar(request, "duration"); + let model: string; + try { model = selectWireModelForRequest(mapping, request); } catch (error) { return error instanceof Error ? error.message : String(error); } + + if (mapping.capability.module.name === "@hypit/seedance") { + if (scalar(request, "webSearch") === true) { + return `OpenRouter ${model} has no web_search field`; + } + if (name === "seedance-2.5") { + if (duration === -1) return "OpenRouter bytedance/seedance-2.5 does not accept duration -1"; + if (resolution === "1080p") return "OpenRouter bytedance/seedance-2.5 renders 480p or 720p, not 1080p"; + if (!listed(ratio, SEEDANCE_25_RATIOS)) return `OpenRouter bytedance/seedance-2.5 does not render aspect-ratio ${String(ratio)}`; + } else { + if (name !== "seedance-2" && (resolution === "1080p" || resolution === "4k")) { + return `OpenRouter ${model} renders 480p or 720p, not ${String(resolution)}`; + } + if (!listed(ratio, SEEDANCE_20_RATIOS)) return `OpenRouter ${model} does not render aspect-ratio ${String(ratio)}`; + } + } + + if (name === "seedream-5-lite" && SEEDREAM_RESOLUTION[String(scalar(request, "quality"))] === undefined) { + return `OpenRouter Seedream 5.0 lite renders 2K (basic) or 4K (ultra), not quality ${String(scalar(request, "quality"))}`; + } + + if (name === "minimax-h3") { + if (resolution !== undefined && resolution !== "2K") return `OpenRouter minimax/hailuo-3 renders 2K only, not ${String(resolution)}`; + if (typeof duration === "number" && duration < 5) return `OpenRouter minimax/hailuo-3 duration starts at 5 seconds, not ${String(duration)}`; + if (count(request, "referenceImage") > 5) return "OpenRouter minimax/hailuo-3 accepts up to five reference images"; + } + + if (name === "gpt-image-2") { + if (resolution !== "1K") return `OpenRouter openai/gpt-image-2 has no resolution field; it does not take ${String(resolution)}`; + if (scalar(request, "background") === "transparent") return "OpenRouter openai/gpt-image-2 accepts background auto or opaque, not transparent"; + if (!listed(ratio, GPT_IMAGE_RATIOS)) return `OpenRouter openai/gpt-image-2 does not render aspect-ratio ${String(ratio)}`; + } + + if (name === "nano-banana-2" && !listed(ratio, NANO_BANANA_2_RATIOS)) { + return `OpenRouter google/gemini-3.1-flash-image does not render aspect-ratio ${String(ratio)}`; + } + if (name === "nano-banana-pro" && !listed(ratio, NANO_BANANA_PRO_RATIOS)) { + return `OpenRouter google/gemini-3-pro-image does not render aspect-ratio ${String(ratio)}`; + } + + if (mapping.capability.module.name === "@hypit/grok-imagine") { + if (!listed(ratio, GROK_RATIOS)) return `OpenRouter ${model} does not render aspect-ratio ${String(ratio)}`; + if (name === "grok-imagine-video") { + if (resolution === "1080p") return "OpenRouter x-ai/grok-imagine-video renders 480p or 720p, not 1080p"; + if (typeof duration === "number" && duration > 15) return `OpenRouter x-ai/grok-imagine-video duration is at most 15 seconds, not ${String(duration)}`; + } + if (name === "grok-imagine-video-1.5-preview" && count(request, "images") > 1) { + return "OpenRouter x-ai/grok-imagine-video-1.5 animates at most one first-frame image"; + } + } + return undefined; +} + +function frameImage(url: string, frameType: "first_frame" | "last_frame") { + return { type: "image_url", image_url: { url }, frame_type: frameType }; +} +function videoRef(kind: "image" | "video" | "audio", url: string) { + const type = `${kind}_url` as const; + return { type, [type]: { url } }; +} +function imageRef(url: string) { + return { type: "image_url", image_url: url }; +} + +function videoBody(model: string, input: Record, name: string): Record { + const frames = [ + ...(typeof input.first_frame === "string" ? [frameImage(input.first_frame, "first_frame")] : []), + ...(typeof input.last_frame === "string" ? [frameImage(input.last_frame, "last_frame")] : []), + ]; + const images = strings(input.images); + const refs = [ + ...strings(input.reference_images).map((url) => videoRef("image", url)), + ...strings(input.reference_videos).map((url) => videoRef("video", url)), + ...strings(input.reference_audios).map((url) => videoRef("audio", url)), + ]; + if (name.startsWith("grok-imagine")) { + if (images.length === 1) frames.push(frameImage(images[0]!, "first_frame")); + else refs.push(...images.map((url) => videoRef("image", url))); + } + const resolution = input.resolution === "4k" ? "4K" : input.resolution; + return { + model, + prompt: input.prompt, + ...(typeof input.duration === "number" ? { duration: input.duration } : {}), + ...(typeof resolution === "string" ? { resolution } : {}), + ...(typeof input.aspect_ratio === "string" ? { aspect_ratio: input.aspect_ratio } : {}), + ...(typeof input.generate_audio === "boolean" ? { generate_audio: input.generate_audio } : {}), + ...(frames.length > 0 ? { frame_images: frames } : {}), + ...(refs.length > 0 ? { input_references: refs } : {}), + }; +} + +function imageBody(model: string, input: Record, name: string): Record { + const images = strings(input.images); + const resolution = name === "seedream-5-lite" + ? SEEDREAM_RESOLUTION[String(input.quality)] + : name === "gpt-image-2" ? undefined : input.resolution; + return { + model, + prompt: input.prompt, + ...(typeof input.aspect_ratio === "string" ? { aspect_ratio: input.aspect_ratio } : {}), + ...(typeof resolution === "string" ? { resolution } : {}), + ...(name === "gpt-image-2" && typeof input.background === "string" ? { background: input.background } : {}), + ...(images.length > 0 ? { input_references: images.map(imageRef) } : {}), + }; +} + +function capabilityKey(capability: CapabilityRef): string { + return `${capability.module.name}@${capability.module.version}#${capability.name}`; +} + +export const openRouterRoutes: readonly OpenRouterRoute[] = openRouterMappings.map((mapping) => { + const protocol: OpenRouterProtocol = mapping.result === "image" ? "image" : "video"; + const mediaLimits = openRouterMediaLimits[protocol]; + return { + ...mapping, + key: capabilityKey(mapping.capability), + returns: mapping.result === "image" ? generationTypes.imageSet : generationTypes.videoSet, + protocol, + mediaLimits, + supports: (request) => { + const reason = rejection(mapping, request.constraints as unknown as GenerationRequest); + return reason === undefined ? { status: "supported" } : { status: "unsupported", reason }; + }, + prepare: (constraints) => { + const request = constraints as unknown as GenerationRequest; + const reason = rejection(mapping, request); + if (reason !== undefined) throw new Error(reason); + const model = selectWireModelForRequest(mapping, request); + return { + model, + protocol, + mediaLimits, + compile: async (resolve) => { + const input = (await compileWireRequest(mapping, request, resolve)).input as Record; + return protocol === "image" + ? imageBody(model, input, mapping.capability.name) + : videoBody(model, input, mapping.capability.name); + }, + }; + }, + packageResult: (artifacts) => ({ + kind: "inline", + value: canonicalize(mapping.result === "image" + ? sealGeneratedImageSet({ images: artifacts }) + : sealGeneratedVideoSet({ videos: artifacts })), + }), + }; +}); + +const byCapability = new Map(openRouterRoutes.map((route) => [route.key, route])); + +export function openRouterRouteForCapability(capability: CapabilityRef): OpenRouterRoute | undefined { + return byCapability.get(capabilityKey(capability)); +} diff --git a/packages/provider-openrouter/test/routes.test.ts b/packages/provider-openrouter/test/routes.test.ts new file mode 100644 index 000000000..69cf8d87c --- /dev/null +++ b/packages/provider-openrouter/test/routes.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assertMappingCoversPorts } from "@hypit/generation"; +import type { BlobRef, CanonicalValue } from "@hypit/protocol"; +import { gptImage2Ports, sealGptImage2Request } from "@hypit/gpt-image"; +import { grokImaginePorts, sealGrokImagineRequest } from "@hypit/grok-imagine"; +import { minimaxH3Ports, sealMinimaxH3Request } from "@hypit/minimax-h3"; +import { nanoBananaPorts } from "@hypit/nano-banana"; +import { seedancePorts, sealSeedanceRequest } from "@hypit/seedance"; +import { seedream5LitePorts, sealSeedreamRequest } from "@hypit/seedream"; + +import { openRouterMappings } from "../src/mapping.js"; +import { openRouterRouteForCapability } from "../src/routes.js"; + +const image: BlobRef = { kind: "blob", resource: "res_openrouter_1", size: 3, mediaType: "image/png" }; +const video: BlobRef = { kind: "blob", resource: "res_openrouter_2", size: 3, mediaType: "video/mp4" }; +const resolve = async () => "https://example.test/reference"; +const route = (module: string, name: string) => openRouterRouteForCapability({ module: { name: module, version: "1" }, name })!; +const constraints = (request: unknown) => request as CanonicalValue; + +test("every OpenRouter mapping covers its model's ports", () => { + const tables = { + ...seedancePorts, ...grokImaginePorts, ...nanoBananaPorts, + "seedream-5-lite": seedream5LitePorts, "minimax-h3": minimaxH3Ports, "gpt-image-2": gptImage2Ports, + }; + for (const mapping of openRouterMappings) { + assertMappingCoversPorts(tables[mapping.capability.name as keyof typeof tables], mapping); + } +}); + +test("Seedance frames and references fold into OpenRouter video fields", async () => { + const seedance = route("@hypit/seedance", "seedance-2.5"); + const framed = seedance.prepare(constraints(sealSeedanceRequest("seedance-2.5", { + prompt: ["go"], resolution: ["720p"], duration: [5], generateAudio: [true], webSearch: [false], + aspectRatio: ["16:9"], + firstFrame: [{ role: "image", artifact: image, fields: { personReference: true } }], + }))); + assert.equal(framed.model, "bytedance/seedance-2.5"); + assert.deepEqual(await framed.compile(resolve), { + model: "bytedance/seedance-2.5", + prompt: "go", + duration: 5, + resolution: "720p", + aspect_ratio: "16:9", + generate_audio: true, + frame_images: [{ type: "image_url", image_url: { url: "https://example.test/reference" }, frame_type: "first_frame" }], + }); + + const referenced = seedance.prepare(constraints(sealSeedanceRequest("seedance-2.5", { + prompt: ["go"], resolution: ["720p"], duration: [8], generateAudio: [false], webSearch: [false], + aspectRatio: ["9:16"], + referenceVideo: [{ role: "video", artifact: video, fields: { personReference: false } }], + }))); + assert.deepEqual(await referenced.compile(resolve), { + model: "bytedance/seedance-2.5", + prompt: "go", + duration: 8, + resolution: "720p", + aspect_ratio: "9:16", + generate_audio: false, + input_references: [{ type: "video_url", video_url: { url: "https://example.test/reference" } }], + }); +}); + +test("Seedance adaptive aspect ratio and web search are unsupported", () => { + const mini = route("@hypit/seedance", "seedance-2-mini"); + const ports = { + prompt: ["A handheld tracking shot through a crowded neon night market."], + duration: [5], resolution: ["720p"], aspectRatio: ["adaptive"], + generateAudio: [false], webSearch: [false], + }; + assert.equal(mini.supports({ + capability: mini.capability, returns: mini.returns, + constraints: constraints(sealSeedanceRequest("seedance-2-mini", ports)), + }).status, "unsupported"); + + const search = sealSeedanceRequest("seedance-2", { + prompt: ["go"], duration: [5], resolution: ["720p"], aspectRatio: ["16:9"], + generateAudio: [true], webSearch: [true], + }); + assert.equal(route("@hypit/seedance", "seedance-2").supports({ + capability: route("@hypit/seedance", "seedance-2").capability, + returns: route("@hypit/seedance", "seedance-2").returns, + constraints: constraints(search), + }).status, "unsupported"); +}); + +test("Seedream quality becomes the OpenRouter resolution tier and 3K is unsupported", async () => { + const seedream = route("@hypit/seedream", "seedream-5-lite"); + const request = sealSeedreamRequest({ + prompt: ["a cup"], aspectRatio: ["9:16"], quality: ["ultra"], outputFormat: ["png"], nsfwCheck: [true], + images: [{ role: "image", artifact: image }], + }); + assert.deepEqual(await seedream.prepare(constraints(request)).compile(resolve), { + model: "bytedance-seed/seedream-5-0-lite", + prompt: "a cup", + aspect_ratio: "9:16", + resolution: "4K", + input_references: [{ type: "image_url", image_url: "https://example.test/reference" }], + }); + const high = sealSeedreamRequest({ ...request.ports, quality: ["high"] }); + assert.equal(seedream.supports({ + capability: seedream.capability, returns: seedream.returns, constraints: constraints(high), + }).status, "unsupported"); +}); + +test("GPT Image 2 resolution outside 1K is unsupported before references resolve", () => { + const gpt = route("@hypit/gpt-image", "gpt-image-2"); + const request = sealGptImage2Request({ prompt: ["cut"], aspectRatio: ["1:1"], resolution: ["2K"] }); + assert.equal(gpt.supports({ + capability: gpt.capability, returns: gpt.returns, constraints: constraints(request), + }).status, "unsupported"); +}); + +test("Grok Imagine 1.5 maps one image to first_frame and refuses a second", async () => { + const grok = route("@hypit/grok-imagine", "grok-imagine-video-1.5-preview"); + const one = sealGrokImagineRequest("grok-imagine-video-1.5-preview", { + prompt: ["turn"], aspectRatio: ["9:16"], resolution: ["720p"], duration: [6], + images: [{ role: "image", artifact: image }], + }); + assert.deepEqual(await grok.prepare(constraints(one)).compile(resolve), { + model: "x-ai/grok-imagine-video-1.5", + prompt: "turn", + duration: 6, + resolution: "720p", + aspect_ratio: "9:16", + frame_images: [{ type: "image_url", image_url: { url: "https://example.test/reference" }, frame_type: "first_frame" }], + }); + const two = sealGrokImagineRequest("grok-imagine-video-1.5-preview", { + prompt: ["turn"], aspectRatio: ["9:16"], resolution: ["720p"], duration: [6], + images: [ + { role: "image", artifact: image }, + { role: "image", artifact: { ...image, resource: "res_openrouter_3" } }, + ], + }); + assert.equal(grok.supports({ + capability: grok.capability, returns: grok.returns, constraints: constraints(two), + }).status, "unsupported"); +}); + +test("MiniMax H3 768P is unsupported", () => { + const minimax = route("@hypit/minimax-h3", "minimax-h3"); + const request = sealMinimaxH3Request({ + prompt: ["a kite"], duration: [6], resolution: ["768P"], aspectRatio: ["16:9"], + }); + assert.equal(minimax.supports({ + capability: minimax.capability, returns: minimax.returns, constraints: constraints(request), + }).status, "unsupported"); +}); diff --git a/packages/video-cli/package.json b/packages/video-cli/package.json index 530471bfd..5d045df1e 100644 --- a/packages/video-cli/package.json +++ b/packages/video-cli/package.json @@ -27,6 +27,7 @@ "@hypit/provider-hiapi": "workspace:*", "@hypit/provider-hypihub": "workspace:*", "@hypit/provider-monid": "workspace:*", + "@hypit/provider-openrouter": "workspace:*", "@hypit/provider-pollo": "workspace:*", "@hypit/provider-tokendance": "workspace:*", "@hypit/render-hyperframes": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a93d843a..fc960da0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: '@hypit/provider-monid': specifier: workspace:* version: link:packages/provider-monid + '@hypit/provider-openrouter': + specifier: workspace:* + version: link:packages/provider-openrouter '@hypit/provider-pollo': specifier: workspace:* version: link:packages/provider-pollo @@ -2519,6 +2522,43 @@ importers: specifier: workspace:* version: link:../seedance + packages/provider-openrouter: + dependencies: + '@hypit/endpoint-kit': + specifier: workspace:* + version: link:../endpoint-kit + '@hypit/generation': + specifier: workspace:* + version: link:../generation + '@hypit/protocol': + specifier: workspace:* + version: link:../protocol + '@hypit/runtime': + specifier: workspace:* + version: link:../runtime + '@hypit/runtime-kit': + specifier: workspace:* + version: link:../runtime-kit + devDependencies: + '@hypit/gpt-image': + specifier: workspace:* + version: link:../gpt-image + '@hypit/grok-imagine': + specifier: workspace:* + version: link:../grok-imagine + '@hypit/minimax-h3': + specifier: workspace:* + version: link:../minimax-h3 + '@hypit/nano-banana': + specifier: workspace:* + version: link:../nano-banana + '@hypit/seedance': + specifier: workspace:* + version: link:../seedance + '@hypit/seedream': + specifier: workspace:* + version: link:../seedream + packages/provider-pollo: dependencies: '@hypit/endpoint-kit': @@ -3648,6 +3688,9 @@ importers: '@hypit/provider-monid': specifier: workspace:* version: link:../provider-monid + '@hypit/provider-openrouter': + specifier: workspace:* + version: link:../provider-openrouter '@hypit/provider-pollo': specifier: workspace:* version: link:../provider-pollo From d40eaaddbb10a2f1cf70b8f0fdce4b128434e9c5 Mon Sep 17 00:00:00 2001 From: F0RGIS <324665117+F0RGIS@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:10:26 +0000 Subject: [PATCH 2/4] docs: add Abliteration.ai to Launch Partners Add the Abliteration.ai wordmark and a short description to the English and Chinese Launch Partners tables. --- README.md | 11 +++++++++++ README.zh-CN.md | 11 +++++++++++ docs/public/abliteration-dark.png | Bin 0 -> 17813 bytes docs/public/abliteration-light.png | Bin 0 -> 5570 bytes 4 files changed, 22 insertions(+) create mode 100644 docs/public/abliteration-dark.png create mode 100644 docs/public/abliteration-light.png diff --git a/README.md b/README.md index 15f31267a..51185de3c 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,17 @@ commands and the repository layout. BeatAPI is the professional capability layer for any agent. We provide AI models, real-world data, task-ready capabilities, and professional workspaces that help agents complete real-world tasks. + + + + + + Abliteration.ai + + + + Abliteration.ai is an OpenAI- and Anthropic-compatible API for unrestricted models, with an optional Policy Gateway so teams write their own rules. It hosts abliterated models for security research, red teaming, and agent testing, with zero retention by default. + ## ⭐ Star History diff --git a/README.zh-CN.md b/README.zh-CN.md index cf5ca6680..b8537f5e4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -276,6 +276,17 @@ Agent 会检查环境,索要这条视频需要的凭据,生成素材并构 BeatAPI 是面向各类 Agent 的专业能力层,提供 AI 模型、真实世界数据、可直接调用的任务能力和专业工作空间,帮助 Agent 完成真实世界的任务。 + + + + + + Abliteration.ai + + + + Abliteration.ai 提供兼容 OpenAI 与 Anthropic 的无限制模型 API,并可选 Policy Gateway,由团队自行制定规则。它托管经过 abliteration 处理的模型,面向安全研究、红队测试与 Agent 测试,默认不保留输入与输出。 + ## ⭐ Star History diff --git a/docs/public/abliteration-dark.png b/docs/public/abliteration-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..081c0ac0361b1d55cb0d85e9403ab614fed58a38 GIT binary patch literal 17813 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

s0BvMgP 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 E1E8(SVE_OC literal 0 HcmV?d00001 From 57e446779be752aa872651958d449abedb68bbaf Mon Sep 17 00:00:00 2001 From: F0RGIS <324665117+F0RGIS@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:22:17 +0000 Subject: [PATCH 3/4] docs: replace Abliteration.ai with OpenRouter in Launch Partners Remove the Abliteration.ai row and logos. Add the official OpenRouter lockup and a short description to the English and Chinese tables. --- README.md | 8 ++++---- README.zh-CN.md | 8 ++++---- docs/public/abliteration-dark.png | Bin 17813 -> 0 bytes docs/public/abliteration-light.png | Bin 5570 -> 0 bytes docs/public/openrouter-dark.svg | 13 +++++++++++++ docs/public/openrouter-light.svg | 13 +++++++++++++ 6 files changed, 34 insertions(+), 8 deletions(-) delete mode 100644 docs/public/abliteration-dark.png delete mode 100644 docs/public/abliteration-light.png create mode 100644 docs/public/openrouter-dark.svg create mode 100644 docs/public/openrouter-light.svg diff --git a/README.md b/README.md index 51185de3c..0a770fd32 100644 --- a/README.md +++ b/README.md @@ -282,14 +282,14 @@ commands and the repository layout. - + - - Abliteration.ai + + OpenRouter - Abliteration.ai is an OpenAI- and Anthropic-compatible API for unrestricted models, with an optional Policy Gateway so teams write their own rules. It hosts abliterated models for security research, red teaming, and agent testing, with zero retention by default. + OpenRouter is a unified API for hundreds of models from many providers. Hypit can fulfill Seedance, Seedream, MiniMax H3, GPT Image, Nano Banana, and Grok Imagine image and video requests through one OpenRouter key. diff --git a/README.zh-CN.md b/README.zh-CN.md index b8537f5e4..49f141d2a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -278,14 +278,14 @@ Agent 会检查环境,索要这条视频需要的凭据,生成素材并构 - + - - Abliteration.ai + + OpenRouter - Abliteration.ai 提供兼容 OpenAI 与 Anthropic 的无限制模型 API,并可选 Policy Gateway,由团队自行制定规则。它托管经过 abliteration 处理的模型,面向安全研究、红队测试与 Agent 测试,默认不保留输入与输出。 + OpenRouter 是接入多家服务商、数百种模型的统一 API。Hypit 可以用一把 OpenRouter 密钥完成 Seedance、Seedream、MiniMax H3、GPT Image、Nano Banana 和 Grok Imagine 的图像与视频请求。 diff --git a/docs/public/abliteration-dark.png b/docs/public/abliteration-dark.png deleted file mode 100644 index 081c0ac0361b1d55cb0d85e9403ab614fed58a38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17813 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

s0BvMgP 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 E1E8(SVE_OC diff --git a/docs/public/openrouter-dark.svg b/docs/public/openrouter-dark.svg new file mode 100644 index 000000000..adac9996a --- /dev/null +++ b/docs/public/openrouter-dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/docs/public/openrouter-light.svg b/docs/public/openrouter-light.svg new file mode 100644 index 000000000..fa2470212 --- /dev/null +++ b/docs/public/openrouter-light.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + From 7fe8d8aa1871fc6e79460500d4514fa8ea479109 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:41:57 +0000 Subject: [PATCH 4/4] docs: point README install and repo links to F0RGIS/hihi-pinis Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RHBSLAv1VVNwTRQzhDVvrt --- README.md | 20 ++++++++++---------- README.zh-CN.md | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 0a770fd32..7f6934457 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@

- Stars + Stars Node 22.15+ pnpm 10.33 TypeScript 5.9 @@ -59,7 +59,7 @@ Hypit gives AI agents (Claude Code, Codex...) a language and system to create vi ## Install once ```bash -npx skills add hypit-ai/hypit -g +npx skills add F0RGIS/hihi-pinis -g ``` This installs the Skill. On first use, your agent checks for the Hypit executable and helps prepare @@ -202,11 +202,11 @@ commands and the repository layout. - + - + @@ -295,18 +295,18 @@ commands and the repository layout. ## ⭐ Star History - + - - - Star History Chart + + + Star History Chart ## Contributors - - Contributors + + Contributors ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 49f141d2a..09eaf8ac4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,7 +28,7 @@

- Stars + Stars Node 22.15+ pnpm 10.33 TypeScript 5.9 @@ -59,7 +59,7 @@ Hypit 为 AI Agent(Claude Code、Codex 等)提供了一套制作视频的语 ## 只安装一次 ```bash -npx skills add hypit-ai/hypit -g +npx skills add F0RGIS/hihi-pinis -g ``` 这条命令安装 Skill。首次使用时,Agent 会检查 Hypit 可执行程序,并按需协助安装。 @@ -194,11 +194,11 @@ Agent 会检查环境,索要这条视频需要的凭据,生成素材并构

Bug reportsOpen an issueOpen an issue
Feature requestsOpen an issueOpen an issue
Questions
- + - + @@ -291,18 +291,18 @@ Agent 会检查环境,索要这条视频需要的凭据,生成素材并构 ## ⭐ Star History - + - - - Star History Chart + + + Star History Chart ## 贡献者 - - Contributors + + Contributors ## 许可证
缺陷报告提交 issue提交 issue
功能请求提交 issue提交 issue
问题咨询