@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 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
- # Temporary Holding Version
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,11 @@
1
+ {
2
+ "mcpServers": {
3
+ "cheapbuddy-media": {
4
+ "command": "npx",
5
+ "args": ["--yes", "@cheapbuddy/media-mcp"],
6
+ "env": {
7
+ "CHEAPBUDDY_API_KEY": "${CHEAPBUDDY_API_KEY}"
8
+ }
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,6 @@
1
+ [mcp_servers.cheapbuddy_media]
2
+ command = "npx"
3
+ args = ["--yes", "@cheapbuddy/media-mcp"]
4
+
5
+ [mcp_servers.cheapbuddy_media.env]
6
+ CHEAPBUDDY_API_KEY = "${CHEAPBUDDY_API_KEY}"
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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
+
@@ -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
+