@cheapbuddy/media-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +120 -2
- package/RELEASE-CHECKLIST.md +50 -0
- package/examples/claude-code.mcp.json +11 -0
- package/examples/codex.toml +6 -0
- package/examples/opencode.json +13 -0
- package/package.json +35 -4
- package/src/backend.mjs +172 -0
- package/src/catalog.mjs +113 -0
- package/src/cli.mjs +18 -0
- package/src/errors.mjs +98 -0
- package/src/http.mjs +168 -0
- package/src/job-id.mjs +34 -0
- package/src/media-input.mjs +181 -0
- package/src/normalize.mjs +214 -0
- package/src/server.mjs +28 -0
- package/src/tools.mjs +404 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CheapBuddy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,121 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@cheapbuddy/media-mcp`
|
|
2
|
+
|
|
3
|
+
CheapBuddy Media MCP is a local, stdio-only Model Context Protocol server for
|
|
4
|
+
the authenticated CheapBuddy image and MiniMax-H3 video HTTP APIs. It is
|
|
5
|
+
intended for Claude Code, Codex, and OpenCode.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Node.js 20 or newer is required. Configure the MCP client with an environment
|
|
10
|
+
variable rather than putting a key in arguments or a project file:
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{
|
|
14
|
+
"mcpServers": {
|
|
15
|
+
"cheapbuddy-media": {
|
|
16
|
+
"command": "npx",
|
|
17
|
+
"args": ["--yes", "@cheapbuddy/media-mcp"],
|
|
18
|
+
"env": {
|
|
19
|
+
"CHEAPBUDDY_API_KEY": "${CHEAPBUDDY_API_KEY}"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`CHEAPBUDDY_BASE_URL` is optional and is useful for a compatible self-hosted or
|
|
27
|
+
local Relay. The package does not implement login, key persistence, OAuth, or
|
|
28
|
+
CLI key arguments. Do not commit credentials.
|
|
29
|
+
|
|
30
|
+
Ready-to-adapt client snippets are in `examples/claude-code.mcp.json`,
|
|
31
|
+
`examples/codex.toml`, and `examples/opencode.json`. Keep the environment
|
|
32
|
+
variable expansion syntax appropriate to the installed client version.
|
|
33
|
+
|
|
34
|
+
Claude Code has two independent credential paths: the Claude model gateway
|
|
35
|
+
uses its own Anthropic-compatible provider credentials, while this local MCP
|
|
36
|
+
server uses `CHEAPBUDDY_API_KEY`. The verified local smoke path used
|
|
37
|
+
`tokfan/claude-opus-5-5` for Claude Code and injected the CheapBuddy key only
|
|
38
|
+
into the `cheapbuddy-media` MCP subprocess. A CheapBuddy key that is allowed
|
|
39
|
+
to call OpenAI-compatible `/v1/chat/completions` is not automatically allowed
|
|
40
|
+
to dispatch Anthropic `/v1/messages`; that model-gateway restriction does not
|
|
41
|
+
affect the MCP's own Relay calls.
|
|
42
|
+
|
|
43
|
+
## Tools
|
|
44
|
+
|
|
45
|
+
The server exposes exactly six tools:
|
|
46
|
+
|
|
47
|
+
- `cheapbuddy_list_media_models` — account-scoped image/video model discovery.
|
|
48
|
+
- `cheapbuddy_quote_image` and `cheapbuddy_quote_video` — optional read-only
|
|
49
|
+
rate cards or non-exact estimates. They do not reserve balance.
|
|
50
|
+
- `cheapbuddy_generate_image` — `generate`, `edit`, and `variation`, including
|
|
51
|
+
masks and model-permitted multiple images.
|
|
52
|
+
- `cheapbuddy_submit_video` — non-blocking MiniMax-H3 submission with text,
|
|
53
|
+
first/last frames, reference images, video, and audio.
|
|
54
|
+
- `cheapbuddy_get_media_job` — stateless image/video task lookup.
|
|
55
|
+
|
|
56
|
+
Write tools consume CheapBuddy account balance directly. There is intentionally
|
|
57
|
+
no budget system, forced quote/confirmation step, batch tool, or blocking video
|
|
58
|
+
wait tool. This keeps repeated and automated generation possible while Relay
|
|
59
|
+
continues to enforce account balance, rate limits, idempotency, reservation,
|
|
60
|
+
settlement, and task ownership.
|
|
61
|
+
|
|
62
|
+
## Media inputs and outputs
|
|
63
|
+
|
|
64
|
+
Inputs are either an absolute regular local file path or an HTTPS URL. Symlinks,
|
|
65
|
+
directories, special files, HTTP URLs, local/private/metadata addresses,
|
|
66
|
+
embedded URL credentials, unsupported MIME types, and oversized files are
|
|
67
|
+
rejected. Signed HTTPS query parameters are passed through unchanged. Local
|
|
68
|
+
files are sent directly as multipart data; they are not first copied to the
|
|
69
|
+
temporary media store. When an image edit mixes local and remote references,
|
|
70
|
+
local bytes are represented as request-scoped data URLs so the remote URL can
|
|
71
|
+
remain untouched; the request remains bounded by the Relay body limit.
|
|
72
|
+
|
|
73
|
+
Image results may be returned as a provider URL or `b64_json`. Base64 images
|
|
74
|
+
are validated and uploaded through authenticated `/v1/media` to obtain a
|
|
75
|
+
short-lived Relay URL. Video results are returned as Relay/provider artifact
|
|
76
|
+
URLs and never as base64. Use `expires_at` when present and download temporary
|
|
77
|
+
artifacts promptly.
|
|
78
|
+
|
|
79
|
+
Image generation may poll a processing task for up to 300 seconds, then returns
|
|
80
|
+
an `img_task_...` ID and `poll_after_ms`. Video submission always returns
|
|
81
|
+
immediately with a `vid_task_...` ID (or an explicit accepted-unknown result);
|
|
82
|
+
call `cheapbuddy_get_media_job` to query it. IDs encode only the underlying
|
|
83
|
+
Relay task ID and remain valid after this process restarts; no local task
|
|
84
|
+
database is used.
|
|
85
|
+
|
|
86
|
+
## Architecture and security boundary
|
|
87
|
+
|
|
88
|
+
The package calls existing Relay routes (`/v1/models`, `/v1/pricing`, image
|
|
89
|
+
routes, `/v1/videos`, task routes, and `/v1/media`) using the CheapBuddy Bearer
|
|
90
|
+
key. It does **not** require or add a NewAPI MCP Server, `/mcp` route, generic
|
|
91
|
+
Relay MCP forwarding, direct provider access, provider credentials, billing
|
|
92
|
+
formulas, or H3 polling logic. NewAPI remains responsible for provider
|
|
93
|
+
adaptation, task persistence, artifacts, and upstream credentials; Relay
|
|
94
|
+
remains responsible for public authentication and billing.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```powershell
|
|
99
|
+
npm install
|
|
100
|
+
npm test
|
|
101
|
+
npm run test:coverage
|
|
102
|
+
npm run lint
|
|
103
|
+
npm run check:secrets
|
|
104
|
+
npm run check:licenses
|
|
105
|
+
npm run audit
|
|
106
|
+
npm run pack:check
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
To publish after the release gates pass, authenticate against the official
|
|
110
|
+
registry and publish the pinned public package:
|
|
111
|
+
|
|
112
|
+
```powershell
|
|
113
|
+
npm login --registry=https://registry.npmjs.org
|
|
114
|
+
npm publish --access public
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Before publishing, run MCP Inspector and the Relay mock contract suite, then
|
|
118
|
+
perform explicit Claude Code, Codex, and OpenCode smoke tests. Real image and
|
|
119
|
+
H3 billing/artifact reconciliation are release gates and are not performed by
|
|
120
|
+
the automated unit test suite.
|
|
2
121
|
|
|
3
|
-
This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Media MCP release checklist
|
|
2
|
+
|
|
3
|
+
Automated implementation gates are covered by `npm test`, `npm run test:coverage`,
|
|
4
|
+
`npm run lint`, `npm run check:secrets`, `npm run check:licenses`,
|
|
5
|
+
`npm run audit`, and `npm run pack:check`. The checks
|
|
6
|
+
below require an operator with a valid CheapBuddy key and must not be run in a
|
|
7
|
+
CI job that can spend account balance unintentionally.
|
|
8
|
+
|
|
9
|
+
## Protocol and client smoke tests
|
|
10
|
+
|
|
11
|
+
1. Run the MCP Inspector against the packaged command and confirm `initialize`,
|
|
12
|
+
`tools/list`, and one read-only model-list call. Confirm stdout contains only
|
|
13
|
+
JSON-RPC frames and diagnostics stay on stderr.
|
|
14
|
+
2. Configure and launch the snippets in `examples/claude-code.mcp.json`,
|
|
15
|
+
`examples/codex.toml`, and `examples/opencode.json` in Claude Code, Codex,
|
|
16
|
+
and OpenCode respectively. Confirm each client discovers all six tools.
|
|
17
|
+
3. Use a read-only model-list or pricing call before considering any paid
|
|
18
|
+
write-tool probe.
|
|
19
|
+
|
|
20
|
+
## Opt-in paid probes
|
|
21
|
+
|
|
22
|
+
Run only against a deliberately funded test key and record the Relay request
|
|
23
|
+
ID, returned MCP job ID, billing status, and artifact expiry. Do not retry a
|
|
24
|
+
timed-out create with a new key.
|
|
25
|
+
|
|
26
|
+
- Image: one small generation, then reconcile the final Relay/NewAPI usage and
|
|
27
|
+
temporary artifact URL.
|
|
28
|
+
- MiniMax-H3: one six-second 768P text request with a valid ratio, then query
|
|
29
|
+
the `vid_task_...` ID until terminal and reconcile reservation, settlement,
|
|
30
|
+
failure/success usage, and the Relay content URL.
|
|
31
|
+
|
|
32
|
+
The package intentionally has no budget or forced-confirmation layer. The
|
|
33
|
+
account balance and Relay reservation/settlement path are the spending
|
|
34
|
+
boundary.
|
|
35
|
+
|
|
36
|
+
## Verification record: 2026-10-11
|
|
37
|
+
|
|
38
|
+
- MCP Inspector `tools/list`: passed; exactly six tools returned.
|
|
39
|
+
- Codex live smoke: passed; discovered `grok-imagine-image-2.0` and `MiniMax-H3`.
|
|
40
|
+
- OpenCode live smoke: passed with the locally configured `tokfan/claude-opus-5-5`; discovered `grok-imagine-image-2.0` and `MiniMax-H3`.
|
|
41
|
+
- Claude Code live smoke: passed with the locally configured `tokfan/claude-opus-5-5`; it invoked `cheapbuddy_list_media_models` and returned `grok-imagine-image-2.0` and `MiniMax-H3`. Claude model credentials and `CHEAPBUDDY_API_KEY` were injected separately.
|
|
42
|
+
- Image paid probe: passed; one JPEG artifact fetched from Relay, same-day media usage USD 0.000076.
|
|
43
|
+
- H3 paid probe: passed; one 6-second 768P 16:9 MP4 artifact fetched from Relay, same-day media usage USD 0.092400.
|
|
44
|
+
- npm publication: not executed.
|
|
45
|
+
|
|
46
|
+
The client and paid-task release gates are complete. npm publication still
|
|
47
|
+
requires an authenticated official npm registry session and an explicit
|
|
48
|
+
publication action. `package.json` pins publication to
|
|
49
|
+
`https://registry.npmjs.org/` so a local mirror does not receive the package.
|
|
50
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://opencode.ai/config.json",
|
|
3
|
+
"mcp": {
|
|
4
|
+
"cheapbuddy-media": {
|
|
5
|
+
"type": "local",
|
|
6
|
+
"command": ["npx", "--yes", "@cheapbuddy/media-mcp"],
|
|
7
|
+
"enabled": true,
|
|
8
|
+
"environment": {
|
|
9
|
+
"CHEAPBUDDY_API_KEY": "{env:CHEAPBUDDY_API_KEY}"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cheapbuddy/media-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local stdio MCP server for CheapBuddy image and MiniMax-H3 media APIs",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20.0.0"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"cheapbuddy-media-mcp": "src/cli.mjs"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public",
|
|
15
|
+
"registry": "https://registry.npmjs.org/"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"examples",
|
|
20
|
+
"README.md",
|
|
21
|
+
"RELEASE-CHECKLIST.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"start": "node src/cli.mjs",
|
|
26
|
+
"test": "node --test test/*.test.mjs",
|
|
27
|
+
"test:coverage": "node --test --experimental-test-coverage test/*.test.mjs",
|
|
28
|
+
"lint": "node --check src/backend.mjs && node --check src/catalog.mjs && node --check src/cli.mjs && node --check src/errors.mjs && node --check src/http.mjs && node --check src/job-id.mjs && node --check src/media-input.mjs && node --check src/normalize.mjs && node --check src/server.mjs && node --check src/tools.mjs && node --check scripts/check-licenses.mjs && node --check scripts/check-secrets.mjs && node --check test/config.test.mjs && node --check test/media-mcp.test.mjs && node --check test/stdio.test.mjs",
|
|
29
|
+
"check:secrets": "node scripts/check-secrets.mjs",
|
|
30
|
+
"check:licenses": "node scripts/check-licenses.mjs",
|
|
31
|
+
"audit": "npm audit --registry=https://registry.npmjs.org --omit=dev --audit-level=high",
|
|
32
|
+
"pack:check": "npm pack --dry-run --ignore-scripts"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.32.1"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/backend.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CheapBuddyHttpClient } from "./http.mjs";
|
|
3
|
+
import { inputValue, loadLocalMedia, validateAggregate } from "./media-input.mjs";
|
|
4
|
+
import { validationError } from "./errors.mjs";
|
|
5
|
+
import { mediaModelRows } from "./catalog.mjs";
|
|
6
|
+
|
|
7
|
+
const IMAGE_STANDARD_FIELDS = new Set(["model", "prompt", "response_format"]);
|
|
8
|
+
|
|
9
|
+
export class CheapBuddyRelayBackend {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.http = options.http || new CheapBuddyHttpClient(options);
|
|
12
|
+
this.now = options.now || (() => Date.now());
|
|
13
|
+
this.catalogCache = null;
|
|
14
|
+
this.catalogCachedAt = 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async listMediaModels({ refresh = false } = {}) {
|
|
18
|
+
const fresh = this.catalogCache && this.now() - this.catalogCachedAt < 60000;
|
|
19
|
+
if (!refresh && fresh) return structuredClone(this.catalogCache);
|
|
20
|
+
const payload = await this.http.readJson("GET", "/v1/models");
|
|
21
|
+
const rows = mediaModelRows(payload);
|
|
22
|
+
this.catalogCache = rows;
|
|
23
|
+
this.catalogCachedAt = this.now();
|
|
24
|
+
return structuredClone(rows);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
invalidateCatalog() {
|
|
28
|
+
this.catalogCache = null;
|
|
29
|
+
this.catalogCachedAt = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async quote(model, query = {}) {
|
|
33
|
+
const params = new URLSearchParams({ model });
|
|
34
|
+
for (const [key, value] of Object.entries(query)) {
|
|
35
|
+
if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
|
|
36
|
+
}
|
|
37
|
+
return this.http.readJson("GET", `/v1/pricing?${params.toString()}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async createImage({ model, operation, prompt, images = [], mask, parameters = {}, idempotencyKey }) {
|
|
41
|
+
const preparedImages = await Promise.all(images.map((value) => loadLocalMedia(value, "image")));
|
|
42
|
+
const preparedMask = mask ? await loadLocalMedia(mask, "image") : null;
|
|
43
|
+
validateAggregate(preparedMask ? [...preparedImages, preparedMask] : preparedImages, "image");
|
|
44
|
+
const fields = { model, ...(prompt ? { prompt } : {}), ...parameters };
|
|
45
|
+
const path = operation === "generate" ? "/v1/images/generations" : operation === "edit" ? "/v1/images/edits" : "/v1/images/variations";
|
|
46
|
+
const hasFile = preparedImages.some((item) => item.kind === "file") || preparedMask?.kind === "file";
|
|
47
|
+
const hasRemote = preparedImages.some((item) => item.kind === "url") || preparedMask?.kind === "url";
|
|
48
|
+
const key = idempotencyKey || `cheapbuddy-media-${randomUUID()}`;
|
|
49
|
+
if (!hasFile || (hasFile && hasRemote)) {
|
|
50
|
+
const values = preparedImages.map(imageJsonValue);
|
|
51
|
+
const body = { ...fields };
|
|
52
|
+
if (values.length) {
|
|
53
|
+
if (values.length === 1) body.image = values[0];
|
|
54
|
+
else body.images = values;
|
|
55
|
+
}
|
|
56
|
+
if (preparedMask) body.mask = imageJsonValue(preparedMask);
|
|
57
|
+
if (Buffer.byteLength(JSON.stringify(body), "utf8") > 60 * 1024 * 1024) throw validationError("Image request exceeds the Relay aggregate request limit.");
|
|
58
|
+
return this.http.request("POST", path, { body, json: true, idempotencyKey: key });
|
|
59
|
+
}
|
|
60
|
+
const form = new FormData();
|
|
61
|
+
appendScalarFields(form, fields);
|
|
62
|
+
for (const image of preparedImages) appendMediaPart(form, "image[]", image);
|
|
63
|
+
if (preparedMask) appendMediaPart(form, "mask", preparedMask);
|
|
64
|
+
return this.http.request("POST", path, { body: form, idempotencyKey: key });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getImageTask(taskId) {
|
|
68
|
+
return this.http.readJson("GET", `/v1/images/tasks/${encodeURIComponent(taskId)}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async createVideo({ model, prompt, seconds, ratio, resolution, first_frame, last_frame, reference_image, reference_video, reference_audio, images = [], context_ir_enabled, aigc_watermark, idempotencyKey }) {
|
|
72
|
+
const normalized = await normalizeVideoInputs({ first_frame, last_frame, reference_image, reference_video, reference_audio, images });
|
|
73
|
+
const values = {
|
|
74
|
+
model,
|
|
75
|
+
prompt,
|
|
76
|
+
...(seconds === undefined ? {} : { seconds }),
|
|
77
|
+
...(ratio === undefined ? {} : { ratio }),
|
|
78
|
+
...(resolution === undefined ? {} : { resolution }),
|
|
79
|
+
...(context_ir_enabled === undefined ? {} : { context_ir_enabled }),
|
|
80
|
+
...(aigc_watermark === undefined ? {} : { aigc_watermark }),
|
|
81
|
+
};
|
|
82
|
+
const hasFile = Object.values(normalized).some((item) => Array.isArray(item) ? item.some((entry) => entry?.kind === "file") : item?.kind === "file");
|
|
83
|
+
const key = idempotencyKey || `cheapbuddy-media-${randomUUID()}`;
|
|
84
|
+
if (!hasFile) {
|
|
85
|
+
const body = { ...values };
|
|
86
|
+
for (const [field, input] of Object.entries(normalized)) {
|
|
87
|
+
if (Array.isArray(input)) {
|
|
88
|
+
if (input.length) body.images = input.map(inputValue);
|
|
89
|
+
} else if (input) {
|
|
90
|
+
body[field] = inputValue(input);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return this.http.request("POST", "/v1/videos", { body, json: true, idempotencyKey: key });
|
|
94
|
+
}
|
|
95
|
+
const form = new FormData();
|
|
96
|
+
appendScalarFields(form, values);
|
|
97
|
+
for (const [field, input] of Object.entries(normalized)) {
|
|
98
|
+
if (Array.isArray(input)) {
|
|
99
|
+
const fields = field === "images" ? ["first_frame", "last_frame", "reference_image"] : [];
|
|
100
|
+
input.forEach((item, index) => appendMediaPart(form, fields[index] || "reference_image", item));
|
|
101
|
+
} else if (input) {
|
|
102
|
+
appendMediaPart(form, field, input);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return this.http.request("POST", "/v1/videos", { body: form, idempotencyKey: key });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async getVideoTask(taskId) {
|
|
109
|
+
return this.http.readJson("GET", `/v1/videos/${encodeURIComponent(taskId)}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
videoContentUrl(taskId) {
|
|
113
|
+
return this.http.buildUrl(`/v1/videos/${encodeURIComponent(taskId)}/content`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
isRelayUrl(value) {
|
|
117
|
+
try {
|
|
118
|
+
return new URL(value).origin === new URL(this.http.baseUrl).origin;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async uploadResult(bytes, mimeType) {
|
|
125
|
+
return this.http.uploadMedia(bytes, mimeType);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function normalizeVideoInputs(values) {
|
|
130
|
+
const result = {};
|
|
131
|
+
const single = ["first_frame", "last_frame", "reference_image", "reference_video", "reference_audio"];
|
|
132
|
+
for (const field of single) {
|
|
133
|
+
if (!values[field]) continue;
|
|
134
|
+
const kind = field.includes("video") ? "video" : field.includes("audio") ? "audio" : "image";
|
|
135
|
+
result[field] = await loadLocalMedia(values[field], kind);
|
|
136
|
+
}
|
|
137
|
+
const images = Array.isArray(values.images) ? values.images : [];
|
|
138
|
+
if (images.length > 3) throw validationError("images accepts at most three H3 image references.");
|
|
139
|
+
if (images.length) {
|
|
140
|
+
result.images = await Promise.all(images.map((value) => loadLocalMedia(value, "image")));
|
|
141
|
+
validateAggregate(result.images, "image");
|
|
142
|
+
}
|
|
143
|
+
const all = Object.values(result).flatMap((item) => Array.isArray(item) ? item : [item]).filter(Boolean);
|
|
144
|
+
for (const item of all) if (item.kind === "file") validateAggregate([item], item.mediaKind);
|
|
145
|
+
const totalBytes = all.filter((item) => item.kind === "file").reduce((sum, item) => sum + item.bytes.length, 0);
|
|
146
|
+
if (totalBytes > 60 * 1024 * 1024) throw validationError("H3 media inputs exceed the Relay aggregate request limit.");
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function appendScalarFields(form, fields) {
|
|
151
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
152
|
+
if (value === undefined || value === null) continue;
|
|
153
|
+
if (key === "prompt" && !String(value).trim()) continue;
|
|
154
|
+
form.append(key, typeof value === "boolean" ? String(value) : String(value));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function appendMediaPart(form, field, input) {
|
|
159
|
+
if (input.kind === "url") {
|
|
160
|
+
form.append(field, input.url);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
form.append(field, new Blob([input.bytes], { type: input.mimeType }), input.filename);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function imageJsonValue(input) {
|
|
167
|
+
if (input.kind === "url") return input.url;
|
|
168
|
+
return `data:${input.mimeType};base64,${input.bytes.toString("base64")}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { normalizeVideoInputs, appendScalarFields, appendMediaPart, IMAGE_STANDARD_FIELDS };
|
|
172
|
+
|
package/src/catalog.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { validationError } from "./errors.mjs";
|
|
2
|
+
|
|
3
|
+
const IMAGE_CAPABILITIES = new Set(["text_to_image", "image_edit", "variation", "image_generation", "image_editing"]);
|
|
4
|
+
const VIDEO_CAPABILITIES = new Set(["text_to_video", "image_to_video", "reference_to_video", "video_generation", "openai-video"]);
|
|
5
|
+
|
|
6
|
+
export function mediaModelRows(payload) {
|
|
7
|
+
const rows = Array.isArray(payload?.data) ? payload.data : (payload?.id ? [payload] : []);
|
|
8
|
+
return rows
|
|
9
|
+
.filter((row) => row && typeof row === "object" && typeof row.id === "string")
|
|
10
|
+
.map(normalizeModel)
|
|
11
|
+
.filter(Boolean);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeModel(row) {
|
|
15
|
+
const id = String(row.id).trim();
|
|
16
|
+
const type = String(row.type || "").toLowerCase();
|
|
17
|
+
const capabilities = toStringArray(row.capabilities);
|
|
18
|
+
const endpoints = toStringArray(row.supported_endpoint_types);
|
|
19
|
+
const isH3 = id === "MiniMax-H3";
|
|
20
|
+
const isImage = type.includes("image") || capabilities.some((item) => IMAGE_CAPABILITIES.has(item)) || endpoints.includes("image-generation");
|
|
21
|
+
const isVideo = type.includes("video") || capabilities.some((item) => VIDEO_CAPABILITIES.has(item)) || endpoints.includes("openai-video") || isH3;
|
|
22
|
+
if (!isImage && !isVideo) return null;
|
|
23
|
+
const parameterSchema = row.parameter_schema && typeof row.parameter_schema === "object" && !Array.isArray(row.parameter_schema)
|
|
24
|
+
? structuredClone(row.parameter_schema) : {};
|
|
25
|
+
if (isH3) applyH3Schema(parameterSchema);
|
|
26
|
+
if (id === "grok-imagine-image-2.0" && !parameterSchema.images) {
|
|
27
|
+
parameterSchema.images = { type: "array", minItems: 1, maxItems: 4, optional: true, description: "One or more source images for edit/variation." };
|
|
28
|
+
}
|
|
29
|
+
const operations = isImage ? imageOperations(capabilities, parameterSchema) : [];
|
|
30
|
+
const maxImages = maxImageCount(parameterSchema);
|
|
31
|
+
return {
|
|
32
|
+
id,
|
|
33
|
+
object: row.object || "model",
|
|
34
|
+
owned_by: row.owned_by || "cheapbuddy",
|
|
35
|
+
media_type: isVideo ? "video" : "image",
|
|
36
|
+
type: isVideo ? "video" : "image",
|
|
37
|
+
capabilities: capabilities.length ? capabilities : (isH3 ? ["text_to_video", "image_to_video", "reference_to_video"] : ["text_to_image"]),
|
|
38
|
+
operations,
|
|
39
|
+
parameter_schema: parameterSchema,
|
|
40
|
+
...(maxImages ? { max_images: maxImages } : {}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function findMediaModel(rows, modelId) {
|
|
45
|
+
const model = rows.find((item) => item.id === modelId);
|
|
46
|
+
if (!model) throw validationError(`Media model is not visible for this API key: ${modelId}.`, { model: modelId });
|
|
47
|
+
return model;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function imageOperations(capabilities, schema = {}) {
|
|
51
|
+
const result = [];
|
|
52
|
+
if (capabilities.includes("text_to_image") || capabilities.includes("image_generation") || !capabilities.length) result.push("generate");
|
|
53
|
+
if (capabilities.includes("image_edit") || capabilities.includes("image_editing")) result.push("edit");
|
|
54
|
+
if (capabilities.includes("variation")) result.push("variation");
|
|
55
|
+
if (!result.length && Object.keys(schema).some((key) => ["image", "images", "mask"].includes(key))) result.push("edit");
|
|
56
|
+
return [...new Set(result)];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function modelAllowsOperation(model, operation) {
|
|
60
|
+
return model.media_type === "image" && model.operations.includes(operation);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function allowedParameterNames(model) {
|
|
64
|
+
return new Set(Object.keys(model.parameter_schema || {}).filter((key) => !MEDIA_PARAMETER_NAMES.has(key)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function schemaForTool(model) {
|
|
68
|
+
const schema = {};
|
|
69
|
+
for (const [name, rule] of Object.entries(model.parameter_schema || {})) {
|
|
70
|
+
if (MEDIA_PARAMETER_NAMES.has(name)) continue;
|
|
71
|
+
schema[name] = structuredClone(rule);
|
|
72
|
+
}
|
|
73
|
+
return schema;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function maxImageCount(schema) {
|
|
77
|
+
for (const name of ["images", "image"]) {
|
|
78
|
+
const rule = schema?.[name];
|
|
79
|
+
const maximum = Number(rule?.maximum ?? rule?.maxItems);
|
|
80
|
+
if (Number.isInteger(maximum) && maximum > 0 && maximum <= 128) return maximum;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isReferenceVideoRequest(request) {
|
|
86
|
+
return Boolean(request.first_frame || request.last_frame || request.reference_image || request.reference_video || request.reference_audio || request.images?.length);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function applyH3Schema(schema) {
|
|
90
|
+
const defaults = {
|
|
91
|
+
seconds: { type: "integer", minimum: 4, maximum: 15, default: 6, optional: true },
|
|
92
|
+
duration: { type: "integer", minimum: 4, maximum: 15, default: 6, optional: true },
|
|
93
|
+
ratio: { type: "string", options: ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "adaptive"], optional: true },
|
|
94
|
+
resolution: { type: "string", options: ["768P"], default: "768P", optional: true },
|
|
95
|
+
first_frame: { type: "image", optional: true },
|
|
96
|
+
last_frame: { type: "image", optional: true },
|
|
97
|
+
reference_image: { type: "image", optional: true },
|
|
98
|
+
reference_video: { type: "video", optional: true },
|
|
99
|
+
reference_audio: { type: "audio", optional: true },
|
|
100
|
+
};
|
|
101
|
+
for (const [key, value] of Object.entries(defaults)) if (!schema[key]) schema[key] = value;
|
|
102
|
+
return schema;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function toStringArray(value) {
|
|
106
|
+
return Array.isArray(value) ? value.map((item) => String(item).trim().toLowerCase()).filter(Boolean) : [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const MEDIA_PARAMETER_NAMES = new Set([
|
|
110
|
+
"model", "prompt", "image", "images", "mask", "first_frame", "last_frame", "reference_image", "reference_video", "reference_audio",
|
|
111
|
+
"seconds", "duration", "ratio", "resolution", "size", "content", "input", "generation_type", "idempotency_key",
|
|
112
|
+
]);
|
|
113
|
+
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { publicError } from "./errors.mjs";
|
|
2
|
+
import { startStdioServer } from "./server.mjs";
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
if (!String(process.env.CHEAPBUDDY_API_KEY || "").trim()) {
|
|
6
|
+
const error = publicError(new Error("CHEAPBUDDY_API_KEY is required."));
|
|
7
|
+
error.code = "configuration_error";
|
|
8
|
+
error.message = "CHEAPBUDDY_API_KEY is required.";
|
|
9
|
+
process.stderr.write(`${JSON.stringify({ error })}\n`);
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
} else {
|
|
12
|
+
await startStdioServer();
|
|
13
|
+
}
|
|
14
|
+
} catch (error) {
|
|
15
|
+
process.stderr.write(`${JSON.stringify({ error: publicError(error) })}\n`);
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
}
|
|
18
|
+
|