Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.3.0] - 2026-08-11

### Added
- `imageUrl` on `EditParams`, `AiVectorizeParams` and `RemoveBackgroundParams`, and `generationId` on edit and remove-background, so an image can be supplied without reading a local file
- `uploadId` on the same three, accepting an id from the temporary upload endpoint
- `client.upload.prepare({ filename })`, which returns a short-lived multipart upload URL and its expiry
- Exactly one image source must be set. Existing callers passing `image`/`file` validate unchanged

### Fixed
- `EditClient`, `AIVectorizeClient` and `GenerateClient` sent a hardcoded `x-api-key` header on their stream paths, which returned a guaranteed 401 for OAuth callers. They now use `buildAuthHeaders()`
- A URL no longer reaches `addFileToForm`, which resolves every string against the local filesystem and threw `File not found: https://...`
- Fix AI-enhanced release notes failing silently due to missing `models: read` permission in release job
- Add test workflow for verifying GitHub Models API access

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@genwave/svgmaker-sdk",
"version": "1.2.0",
"version": "1.3.0",
"description": "Official Node.js SDK for SVGMaker API",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
Expand Down
33 changes: 33 additions & 0 deletions src/clients/BaseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,39 @@ export abstract class BaseClient {
}
}

/**
* Append the single image source the params carry. addFileToForm resolves
* every string against the local filesystem, so a URL or an id must never
* reach it — the schemas guarantee exactly one source is set.
* @param formData Form data
* @param params Validated request params
* @param fileField Field name the endpoint expects the raw file under
*/
protected async appendImageSource(
formData: FormData,
params: {
imageUrl?: string;
generationId?: string;
uploadId?: string;
[key: string]: unknown;
},
fileField: 'image' | 'file'
): Promise<void> {
if (params.imageUrl) {
formData.append('imageUrl', params.imageUrl);
} else if (params.generationId) {
formData.append('generationId', params.generationId);
} else if (params.uploadId) {
formData.append('uploadId', params.uploadId);
} else {
await this.addFileToForm(
formData,
fileField,
params[fileField] as string | Buffer | Readable
);
}
}

/**
* Get MIME type from filename
* @param filename File name
Expand Down
28 changes: 21 additions & 7 deletions src/clients/EditClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import { decodeSvgContent, decodeBase64Png } from '../utils/base64';
*/
const editParamsSchema = z
.object({
image: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]),
image: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]).optional(),
imageUrl: z.string().optional(),
generationId: z.string().optional(),
uploadId: z.string().optional(),
prompt: z.string().optional(),
styleParams: z
.object({
Expand Down Expand Up @@ -44,6 +47,18 @@ const editParamsSchema = z
model: z.string().optional(),
raster: z.boolean().optional(),
})
.refine(
data =>
(data.image ? 1 : 0) +
(data.imageUrl ? 1 : 0) +
(data.generationId ? 1 : 0) +
(data.uploadId ? 1 : 0) ===
1,
{
message:
"Provide exactly one image source: 'image', 'imageUrl', 'generationId' or 'uploadId'",
}
)
.refine(data => data.prompt || data.styleParams, {
message: 'Either prompt or styleParams must be provided',
})
Expand Down Expand Up @@ -89,8 +104,7 @@ export class EditClient extends BaseClient {
// Prepare form data
const formData = new FormData();

// Add image file
await this.addFileToForm(formData, 'image', this.params.image!);
await this.appendImageSource(formData, this.params, 'image');

// Add styleParams if present (requires JSON.stringify)
if (this.params.styleParams) {
Expand Down Expand Up @@ -205,8 +219,7 @@ export class EditClient extends BaseClient {
// Prepare form data
const formData = new FormData();

// Add image file
await this.addFileToForm(formData, 'image', client.params.image!);
await this.appendImageSource(formData, client.params, 'image');

// Add prompt if present
if (client.params.prompt) {
Expand Down Expand Up @@ -253,12 +266,13 @@ export class EditClient extends BaseClient {
formData.append('raster', String(client.params.raster));
}

// Make request to the streaming endpoint using native fetch
// Make request to the streaming endpoint using native fetch. Auth headers
// prefer the OAuth Bearer token when present, else the x-api-key.
const response = await fetch(`${this.config.baseUrl}/v1/edit`, {
method: 'POST',
headers: {
Accept: 'text/event-stream',
'x-api-key': this.config.apiKey,
...this.buildAuthHeaders(),
},
body: formData,
});
Expand Down
5 changes: 3 additions & 2 deletions src/clients/GenerateClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,14 @@ export class GenerateClient extends BaseClient {
// Execute the request and handle streaming
(async () => {
try {
// Make request to the streaming endpoint using native fetch
// Make request to the streaming endpoint using native fetch. Auth headers
// prefer the OAuth Bearer token when present, else the x-api-key.
const response = await fetch(`${this.config.baseUrl}/v1/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'x-api-key': this.config.apiKey,
...this.buildAuthHeaders(),
},
body: JSON.stringify(client.params),
});
Expand Down
33 changes: 23 additions & 10 deletions src/clients/RemoveBackgroundClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,27 @@ import { decodeSvgContent } from '../utils/base64';
/**
* Schema for validating remove background parameters
*/
const removeBackgroundParamsSchema = z.object({
file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]),
stream: z.boolean().optional(),
svgText: z.boolean().optional(),
storage: z.boolean().optional(),
});
const removeBackgroundParamsSchema = z
.object({
file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]).optional(),
imageUrl: z.string().optional(),
generationId: z.string().optional(),
uploadId: z.string().optional(),
stream: z.boolean().optional(),
svgText: z.boolean().optional(),
storage: z.boolean().optional(),
})
.refine(
data =>
(data.file ? 1 : 0) +
(data.imageUrl ? 1 : 0) +
(data.generationId ? 1 : 0) +
(data.uploadId ? 1 : 0) ===
1,
{
message: "Provide exactly one image source: 'file', 'imageUrl', 'generationId' or 'uploadId'",
}
);

/**
* Client for the Remove Background API
Expand Down Expand Up @@ -52,8 +67,7 @@ export class RemoveBackgroundClient extends BaseClient {
// Prepare form data
const formData = new FormData();

// Add file
await this.addFileToForm(formData, 'file', this.params.file!);
await this.appendImageSource(formData, this.params, 'file');

// Add optional parameters
this.appendOptionalParams(formData, this.params as Record<string, any>, [
Expand Down Expand Up @@ -127,8 +141,7 @@ export class RemoveBackgroundClient extends BaseClient {
// Prepare form data
const formData = new FormData();

// Add file
await this.addFileToForm(formData, 'file', client.params.file!);
await this.appendImageSource(formData, client.params, 'file');

// Add storage option if present
if (client.params.storage !== undefined) {
Expand Down
90 changes: 90 additions & 0 deletions src/clients/UploadClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { BaseClient } from './BaseClient';
import { UploadPrepareParams, UploadPrepareResponse } from '../types/api';
import { SVGMakerClient } from '../core/SVGMakerClient';
import { APIError } from '../errors/CustomErrors';
import { z } from 'zod';

/**
* Schema for validating upload preparation parameters
*/
const uploadPrepareParamsSchema = z.object({
filename: z.string().min(1),
});

/**
* Client for preparing temporary uploads through the SVGMaker API.
*/
export class UploadClient extends BaseClient {
/**
* Create a new Upload client
* @param client Parent SVGMaker client
*/
constructor(client: SVGMakerClient) {
super(client);
}

/**
* Prepare a temporary upload endpoint.
* @param params Original file metadata
* @returns Short-lived multipart upload URL
*/
public async prepare(params: UploadPrepareParams): Promise<UploadPrepareResponse> {
this.logger.debug('Preparing temporary upload', {
filename: params.filename,
});

this.validateRequest(params, uploadPrepareParamsSchema);

// Raw fetch bypasses the retry/timeout wrapper applied to httpClient.request.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.config.timeout);

let response: globalThis.Response;
try {
response = await fetch(`${this.config.baseUrl}/v1/upload/prepare`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.buildAuthHeaders(),
},
body: JSON.stringify({ filename: params.filename }),
signal: controller.signal,
});
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new APIError(
`Upload preparation request timed out after ${this.config.timeout}ms`,
408
);
}
throw error;
} finally {
clearTimeout(timer);
}

if (!response.ok) {
await this.handleFetchErrorResponse(response);
}

const rawResult = await response.json();
const { data, metadata: responseMetadata } = this.unwrapEnvelope<any>(rawResult);

this.logger.debug('Temporary upload prepared');

return {
uploadUrl: data.upload_url,
expiresIn: data.expires_in,
metadata: responseMetadata,
};
}

/**
* Create a clone of this client
* @returns New client instance
*/
protected clone(): UploadClient {
const client = new UploadClient(this.client);
this.copyTo(client);
return client;
}
}
29 changes: 17 additions & 12 deletions src/clients/convert/AIVectorizeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ import { decodeSvgContent } from '../../utils/base64';
/**
* Schema for validating AI vectorize parameters
*/
const aiVectorizeParamsSchema = z.object({
file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]),
storage: z.boolean().optional(),
stream: z.boolean().optional(),
svgText: z.boolean().optional(),
});
const aiVectorizeParamsSchema = z
.object({
file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]).optional(),
imageUrl: z.string().optional(),
uploadId: z.string().optional(),
storage: z.boolean().optional(),
stream: z.boolean().optional(),
svgText: z.boolean().optional(),
})
.refine(data => (data.file ? 1 : 0) + (data.imageUrl ? 1 : 0) + (data.uploadId ? 1 : 0) === 1, {
message: "Provide exactly one image source: 'file', 'imageUrl' or 'uploadId'",
});

/**
* Client for the AI Vectorize (Convert Image to SVG) API
Expand Down Expand Up @@ -45,8 +51,7 @@ export class AIVectorizeClient extends BaseClient {
// Prepare form data
const formData = new FormData();

// Add file
await this.addFileToForm(formData, 'file', this.params.file!);
await this.appendImageSource(formData, this.params, 'file');

// Add optional parameters
this.appendOptionalParams(formData, this.params as Record<string, any>, [
Expand Down Expand Up @@ -120,8 +125,7 @@ export class AIVectorizeClient extends BaseClient {
// Prepare form data
const formData = new FormData();

// Add file
await this.addFileToForm(formData, 'file', client.params.file!);
await this.appendImageSource(formData, client.params, 'file');

// Add storage option if present
if (client.params.storage !== undefined) {
Expand All @@ -135,12 +139,13 @@ export class AIVectorizeClient extends BaseClient {
formData.append('svgText', String(client.params.svgText));
}

// Make request to the streaming endpoint using native fetch
// Make request to the streaming endpoint using native fetch. Auth headers
// prefer the OAuth Bearer token when present, else the x-api-key.
const response = await fetch(`${this.config.baseUrl}/v1/convert/ai-vectorize`, {
method: 'POST',
headers: {
Accept: 'text/event-stream',
'x-api-key': this.config.apiKey,
...this.buildAuthHeaders(),
},
body: formData,
});
Expand Down
7 changes: 7 additions & 0 deletions src/core/SVGMakerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { GalleryClient } from '../clients/GalleryClient';
import { AccountClient } from '../clients/AccountClient';
import { OptimizeSvgClient } from '../clients/OptimizeSvgClient';
import { RemoveBackgroundClient } from '../clients/RemoveBackgroundClient';
import { UploadClient } from '../clients/UploadClient';
import { createRetryWrapper } from '../utils/retry';
import { createRateLimiter } from '../utils/rateLimit';
import { Logger, createLogger } from '../utils/logger';
Expand Down Expand Up @@ -113,6 +114,11 @@ export class SVGMakerClient {
*/
public readonly enhancePrompt: EnhancePromptClient;

/**
* Upload client for minting signed upload tickets
*/
public readonly upload: UploadClient;

/**
* Create a new SVGMaker client
* @param apiKey API key for authentication
Expand Down Expand Up @@ -163,6 +169,7 @@ export class SVGMakerClient {
this.gallery = new GalleryClient(this);
this.account = new AccountClient(this);
this.optimizeSvg = new OptimizeSvgClient(this);
this.upload = new UploadClient(this);

this.logger.info('SVGMaker SDK initialized');
}
Expand Down
Loading
Loading