Initial WallMuse project

This commit is contained in:
fenglee
2026-05-09 09:12:41 +00:00
commit 3ea7d29827
91 changed files with 13136 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
{
"name": "@wallmuse/provider-adapters",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.json",
"build": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"@wallmuse/shared": "workspace:*"
},
"devDependencies": {
"vitest": "^3.1.3"
}
}

View File

@@ -0,0 +1,178 @@
import { describe, expect, it, vi } from "vitest";
import {
createDefaultProviderRegistry,
MockImageProviderAdapter,
normalizeImageAssets,
normalizeProviderError,
OpenAICompatibleImageProviderAdapter,
SiliconFlowImageProviderAdapter
} from "../index";
describe("provider adapter registry", () => {
it("creates implemented and reserved adapters", () => {
const registry = createDefaultProviderRegistry();
expect(registry.mock.provider).toBe("mock");
expect(registry["openai-compatible"].provider).toBe("openai-compatible");
expect(registry.siliconflow.provider).toBe("siliconflow");
expect(registry.dashscope.provider).toBe("dashscope");
expect(registry.volcengine.provider).toBe("volcengine");
expect(registry.zhipu.provider).toBe("zhipu");
expect(registry["custom-template"].provider).toBe("custom-template");
});
});
describe("mock provider", () => {
it("returns deterministic URL assets with mapped dimensions", async () => {
const adapter = new MockImageProviderAdapter();
const result = await adapter.generateTextToImage(
{
model: "mock-wallpaper",
prompt: "misty mountain wallpaper",
size: { aspectRatio: "16:9", resolution: "2k" },
seed: 42
},
{}
);
expect(result.assets[0]).toMatchObject({
kind: "url",
width: 2560,
height: 1440,
seed: 42
});
expect(result.usage?.imageCount).toBe(1);
});
it("can return base64 assets for local tests", async () => {
const adapter = new MockImageProviderAdapter();
const result = await adapter.generateTextToImage(
{
model: "mock-wallpaper",
prompt: "phone wallpaper",
responseFormat: "base64",
size: { aspectRatio: "9:16", resolution: "1k" }
},
{}
);
expect(result.assets[0]?.kind).toBe("base64");
expect(result.assets[0]?.mimeType).toBe("image/png");
expect(result.assets[0]?.width).toBe(720);
expect(result.assets[0]?.height).toBe(1280);
});
});
describe("OpenAI compatible provider", () => {
it("posts to configurable image endpoint and normalizes base64 response", async () => {
const fetch = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => {
expect(_url).toBe("https://new-api.example.com/v1/images/generations");
expect(JSON.parse(String(init?.body))).toMatchObject({
model: "gpt-image-1",
prompt: "desktop wallpaper",
n: 1,
size: "1536x1024",
response_format: "b64_json"
});
return jsonResponse({
data: [{ b64_json: "data:image/png;base64,abc123" }],
usage: { input_tokens: 12, output_tokens: 0 }
});
});
const adapter = new OpenAICompatibleImageProviderAdapter();
const result = await adapter.generateTextToImage(
{
baseUrl: "https://new-api.example.com",
model: "gpt-image-1",
prompt: "desktop wallpaper",
responseFormat: "base64",
size: { aspectRatio: "16:9", resolution: "4k" }
},
{ auth: { apiKey: "test-key" }, fetch }
);
expect(result.assets[0]).toMatchObject({
kind: "base64",
value: "abc123",
mimeType: "image/png",
width: 1536,
height: 1024
});
expect(result.usage?.inputTokens).toBe(12);
});
});
describe("SiliconFlow provider", () => {
it("uses image_size and normalizes URL response", async () => {
const fetch = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => {
expect(_url).toBe("https://api.siliconflow.cn/v1/images/generations");
expect(JSON.parse(String(init?.body))).toMatchObject({
model: "black-forest-labs/FLUX.2-pro",
prompt: "portrait wallpaper",
image_size: "720x1280",
batch_size: 1,
seed: 7
});
return jsonResponse({ data: [{ url: "https://cdn.example.com/result.png" }] });
});
const adapter = new SiliconFlowImageProviderAdapter();
const result = await adapter.generateTextToImage(
{
model: "black-forest-labs/FLUX.2-pro",
prompt: "portrait wallpaper",
size: { aspectRatio: "9:16", resolution: "1k" },
seed: 7
},
{ auth: { apiKey: "sf-key" }, fetch }
);
expect(result.assets[0]).toMatchObject({
kind: "url",
value: "https://cdn.example.com/result.png",
width: 720,
height: 1280
});
});
});
describe("normalization helpers", () => {
it("normalizes common provider errors", () => {
expect(normalizeProviderError("siliconflow", { status: 429, message: "rate limit exceeded" })).toMatchObject({
category: "rate_limit",
retryable: true,
statusCode: 429
});
expect(normalizeProviderError("openai-compatible", { status: 401, message: "invalid api key" })).toMatchObject({
category: "authentication",
retryable: false
});
expect(normalizeProviderError("openai-compatible", { status: 504, message: "gateway timeout" })).toMatchObject({
category: "timeout",
retryable: true
});
});
it("normalizes URL and base64 image payloads", () => {
expect(normalizeImageAssets({ data: [{ url: "https://example.com/a.png" }] })[0]).toMatchObject({
kind: "url",
value: "https://example.com/a.png"
});
expect(normalizeImageAssets({ images: [{ image_base64: "data:image/jpeg;base64,zzz" }] })[0]).toMatchObject({
kind: "base64",
value: "zzz",
mimeType: "image/jpeg"
});
});
});
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" }
});
}

View File

@@ -0,0 +1,165 @@
import { normalizeImageAssets, normalizeUsage } from "../asset-normalizer";
import { normalizeProviderError, providerHttpError } from "../errors";
import { resolveModelCapability, resolveSize } from "../capabilities/presets";
import type {
ImageGenerationResult,
ImageProviderAdapter,
ProviderCallContext,
ProviderCode,
ProviderEndpointConfig,
ProviderError,
TestConnectionInput,
TestConnectionResult,
TextToImageInput
} from "../types";
import { joinUrl } from "../url";
export abstract class BaseHttpImageAdapter implements ImageProviderAdapter {
abstract readonly provider: ProviderCode;
abstract readonly capabilities: ImageProviderAdapter["capabilities"];
protected abstract readonly defaultBaseUrl: string;
protected abstract readonly defaultEndpointPath: string;
async testConnection(input: TestConnectionInput): Promise<TestConnectionResult> {
const startedAt = Date.now();
const fetchImpl = input.fetch ?? globalThis.fetch;
try {
if (!fetchImpl) {
throw new Error("No fetch implementation is available");
}
const response = await fetchImpl(this.resolveUrl(input), {
method: "POST",
headers: this.buildHeaders(input.auth ?? {}, input.defaultHeaders),
body: JSON.stringify({
model: input.model ?? Object.keys(this.capabilities)[0] ?? "default",
prompt: "WallMuse provider connection test",
n: 1,
...this.testPayloadExtras()
}),
...withSignal(abortSignal(input.timeoutMs))
});
if (!response.ok) {
return {
ok: false,
provider: this.provider,
latencyMs: Date.now() - startedAt,
...withModel(input.model),
error: providerHttpError(this.provider, response.status, await safeJson(response))
};
}
return {
ok: true,
provider: this.provider,
latencyMs: Date.now() - startedAt,
...withModel(input.model),
rawMetadata: await safeJson(response)
};
} catch (error) {
return {
ok: false,
provider: this.provider,
latencyMs: Date.now() - startedAt,
...withModel(input.model),
error: this.normalizeError(error)
};
}
}
async generateTextToImage(input: TextToImageInput, context: ProviderCallContext): Promise<ImageGenerationResult> {
const fetchImpl = context.fetch ?? globalThis.fetch;
if (!fetchImpl) {
throw new Error("No fetch implementation is available");
}
const capability = resolveModelCapability(this.capabilities, input.model, input.capability);
const size = resolveSize(capability, input.size);
const response = await fetchImpl(this.resolveUrl(input), {
method: "POST",
headers: this.buildHeaders(context.auth ?? {}, input.defaultHeaders),
body: JSON.stringify(this.toRequestBody(input, size.providerSizeValue ?? `${size.width}x${size.height}`)),
...withSignal(context.signal ?? abortSignal(context.timeoutMs))
});
const body = await safeJson(response);
if (!response.ok) {
throw providerHttpError(this.provider, response.status, body);
}
const assets = normalizeImageAssets(body, { width: input.width ?? size.width, height: input.height ?? size.height });
return {
provider: this.provider,
model: input.model,
assets,
usage: normalizeUsage(body, assets.length),
rawMetadata: body
};
}
normalizeError(error: unknown): ProviderError {
return normalizeProviderError(this.provider, error);
}
protected resolveUrl(config: ProviderEndpointConfig): string {
return joinUrl(config.baseUrl ?? this.defaultBaseUrl, config.endpointPath ?? this.defaultEndpointPath);
}
protected buildHeaders(auth: { apiKey?: string; bearerToken?: string; headers?: Record<string, string> }, defaultHeaders?: Record<string, string>): Record<string, string> {
const token = auth.bearerToken ?? auth.apiKey;
return {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
...defaultHeaders,
...auth.headers
};
}
protected testPayloadExtras(): Record<string, unknown> {
return {};
}
protected toRequestBody(input: TextToImageInput, providerSizeValue: string): Record<string, unknown> {
const body: Record<string, unknown> = {
model: input.model,
prompt: input.prompt,
n: input.count ?? 1,
size: providerSizeValue,
...input.params
};
if (input.negativePrompt) body.negative_prompt = input.negativePrompt;
if (input.seed !== undefined) body.seed = input.seed;
if (input.responseFormat) {
body.response_format = input.responseFormat === "base64" ? "b64_json" : input.responseFormat;
}
return body;
}
}
export async function safeJson(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) return undefined;
try {
return JSON.parse(text);
} catch {
return { message: text };
}
}
function abortSignal(timeoutMs?: number): AbortSignal | undefined {
if (!timeoutMs) return undefined;
return AbortSignal.timeout(timeoutMs);
}
function withSignal(signal: AbortSignal | undefined): Pick<RequestInit, "signal"> {
return signal ? { signal } : {};
}
function withModel(model: string | undefined): Pick<TestConnectionResult, "model"> {
return model ? { model } : {};
}

View File

@@ -0,0 +1,104 @@
import type { GeneratedImageAsset, ProviderUsage } from "./types";
type ProviderImageRecord = Record<string, unknown>;
export function normalizeImageAssets(payload: unknown, fallbackSize?: { width?: number; height?: number }): GeneratedImageAsset[] {
const data = getImageRecords(payload);
return data.map((item) => {
const url = pickString(item, ["url", "image_url", "imageUrl", "uri"]);
const b64 = pickString(item, ["b64_json", "base64", "image_base64", "image"]);
const width = pickNumber(item, ["width"]) ?? fallbackSize?.width;
const height = pickNumber(item, ["height"]) ?? fallbackSize?.height;
const seed = pickNumber(item, ["seed"]);
const mimeType = pickString(item, ["mime_type", "mimeType"]);
if (url) {
return {
kind: "url",
value: url,
...withOptionalAssetFields({ width, height, seed, mimeType })
};
}
if (b64) {
return {
kind: "base64",
value: stripDataUrlPrefix(b64),
...withOptionalAssetFields({ width, height, seed, mimeType: mimeType ?? mimeFromDataUrl(b64) })
};
}
throw new Error("Provider response did not include a URL or base64 image asset");
});
}
export function normalizeUsage(payload: unknown, imageCount: number): ProviderUsage {
if (!payload || typeof payload !== "object") return { imageCount };
const record = payload as Record<string, unknown>;
const usage = record.usage && typeof record.usage === "object" ? (record.usage as Record<string, unknown>) : record;
return {
imageCount,
...withOptionalUsageFields({
inputTokens: pickNumber(usage, ["input_tokens", "prompt_tokens", "inputTokens"]),
outputTokens: pickNumber(usage, ["output_tokens", "completion_tokens", "outputTokens"]),
providerCost: pickNumber(usage, ["cost", "providerCost"])
})
};
}
function getImageRecords(payload: unknown): ProviderImageRecord[] {
if (!payload || typeof payload !== "object") return [];
const record = payload as Record<string, unknown>;
const data = record.data ?? record.images ?? record.output;
if (Array.isArray(data)) return data.filter((item): item is ProviderImageRecord => !!item && typeof item === "object");
if (record.url || record.b64_json || record.base64 || record.image) return [record];
return [];
}
function pickString(record: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.length > 0) return value;
}
return undefined;
}
function pickNumber(record: Record<string, unknown>, keys: string[]): number | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return undefined;
}
function stripDataUrlPrefix(value: string): string {
const match = value.match(/^data:[^;]+;base64,(.*)$/);
return match?.[1] ?? value;
}
function mimeFromDataUrl(value: string): string | undefined {
const match = value.match(/^data:([^;]+);base64,/);
return match?.[1];
}
type OptionalAssetFields = {
width?: number | undefined;
height?: number | undefined;
seed?: number | undefined;
mimeType?: string | undefined;
};
function withOptionalAssetFields(fields: OptionalAssetFields): Omit<GeneratedImageAsset, "kind" | "value"> {
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) as Omit<GeneratedImageAsset, "kind" | "value">;
}
type OptionalUsageFields = {
inputTokens?: number | undefined;
outputTokens?: number | undefined;
providerCost?: number | undefined;
};
function withOptionalUsageFields(fields: OptionalUsageFields): Omit<ProviderUsage, "imageCount"> {
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) as Omit<ProviderUsage, "imageCount">;
}

View File

@@ -0,0 +1,183 @@
import type { AspectRatio, ImageSizePreset, ModelCapability, ResolutionTier } from "@wallmuse/shared";
import type { ProviderSizeRequest } from "../types";
const WALLPAPER_PRESETS: ImageSizePreset[] = [
{ aspectRatio: "16:9", resolution: "1k", width: 1280, height: 720, providerSizeValue: "1280x720", native: true, requiresUpscale: false },
{ aspectRatio: "9:16", resolution: "1k", width: 720, height: 1280, providerSizeValue: "720x1280", native: true, requiresUpscale: false },
{ aspectRatio: "16:9", resolution: "2k", width: 2560, height: 1440, providerSizeValue: "2560x1440", native: true, requiresUpscale: false },
{ aspectRatio: "9:16", resolution: "2k", width: 1440, height: 2560, providerSizeValue: "1440x2560", native: true, requiresUpscale: false },
{ aspectRatio: "16:9", resolution: "4k", width: 3840, height: 2160, providerSizeValue: "3840x2160", native: true, requiresUpscale: false },
{ aspectRatio: "9:16", resolution: "4k", width: 2160, height: 3840, providerSizeValue: "2160x3840", native: true, requiresUpscale: false }
];
const OPENAI_IMAGE_PRESETS: ImageSizePreset[] = [
{ aspectRatio: "1:1", resolution: "1k", width: 1024, height: 1024, providerSizeValue: "1024x1024", native: true, requiresUpscale: false },
{ aspectRatio: "9:16", resolution: "1k", width: 1024, height: 1536, providerSizeValue: "1024x1536", native: true, requiresUpscale: false },
{ aspectRatio: "16:9", resolution: "1k", width: 1536, height: 1024, providerSizeValue: "1536x1024", native: true, requiresUpscale: false },
{ aspectRatio: "16:9", resolution: "2k", width: 1536, height: 1024, providerSizeValue: "1536x1024", native: false, requiresUpscale: true },
{ aspectRatio: "9:16", resolution: "2k", width: 1024, height: 1536, providerSizeValue: "1024x1536", native: false, requiresUpscale: true },
{ aspectRatio: "16:9", resolution: "4k", width: 1536, height: 1024, providerSizeValue: "1536x1024", native: false, requiresUpscale: true },
{ aspectRatio: "9:16", resolution: "4k", width: 1024, height: 1536, providerSizeValue: "1024x1536", native: false, requiresUpscale: true }
];
const SILICONFLOW_PRESETS: ImageSizePreset[] = [
...WALLPAPER_PRESETS.filter((preset) => preset.resolution !== "4k"),
{ aspectRatio: "16:9", resolution: "4k", width: 2560, height: 1440, providerSizeValue: "2560x1440", native: false, requiresUpscale: true },
{ aspectRatio: "9:16", resolution: "4k", width: 1440, height: 2560, providerSizeValue: "1440x2560", native: false, requiresUpscale: true },
{ aspectRatio: "1:1", resolution: "1k", width: 1024, height: 1024, providerSizeValue: "1024x1024", native: true, requiresUpscale: false }
];
export const mockModelCapabilities: Record<string, ModelCapability> = {
"mock-wallpaper": capability({
supportsTextToImage: true,
supportsImageToImage: true,
supportsEdit: true,
supportsNegativePrompt: true,
supportsSeed: true,
supportsBatch: true,
supportsBase64Result: true,
supportsUrlResult: true,
supportsNative4k: true,
maxBatchSize: 8,
maxInputImages: 3,
maxPixels: 8294400,
sizePresets: [...WALLPAPER_PRESETS, { aspectRatio: "1:1", resolution: "1k", width: 1024, height: 1024, providerSizeValue: "1024x1024", native: true, requiresUpscale: false }]
})
};
export const openAiCompatibleModelCapabilities: Record<string, ModelCapability> = {
"gpt-image-1": capability({
supportsTextToImage: true,
supportsImageToImage: true,
supportsEdit: true,
supportsNegativePrompt: false,
supportsSeed: false,
supportsBatch: true,
supportsBase64Result: true,
supportsUrlResult: true,
supportsNative4k: false,
maxBatchSize: 4,
maxInputImages: 3,
maxPixels: 1572864,
sizePresets: OPENAI_IMAGE_PRESETS
}),
default: capability({
supportsTextToImage: true,
supportsImageToImage: false,
supportsEdit: false,
supportsNegativePrompt: true,
supportsSeed: true,
supportsBatch: true,
supportsBase64Result: true,
supportsUrlResult: true,
supportsNative4k: false,
maxBatchSize: 4,
maxPixels: 2097152,
sizePresets: SILICONFLOW_PRESETS
})
};
export const siliconFlowModelCapabilities: Record<string, ModelCapability> = {
"black-forest-labs/FLUX.2-pro": capability({
supportsTextToImage: true,
supportsImageToImage: false,
supportsEdit: false,
supportsNegativePrompt: true,
supportsSeed: true,
supportsBatch: false,
supportsBase64Result: false,
supportsUrlResult: true,
supportsNative4k: false,
maxBatchSize: 1,
maxPixels: 3686400,
sizePresets: SILICONFLOW_PRESETS
}),
"Qwen/Qwen-Image": capability({
supportsTextToImage: true,
supportsImageToImage: false,
supportsEdit: false,
supportsNegativePrompt: true,
supportsSeed: true,
supportsBatch: false,
supportsBase64Result: false,
supportsUrlResult: true,
supportsNative4k: false,
maxBatchSize: 1,
maxPixels: 4194304,
sizePresets: SILICONFLOW_PRESETS
})
};
export const placeholderCapabilities: Record<string, ModelCapability> = {
default: capability({
supportsTextToImage: false,
supportsImageToImage: false,
supportsEdit: false,
supportsNegativePrompt: false,
supportsSeed: false,
supportsBatch: false,
supportsBase64Result: false,
supportsUrlResult: false,
supportsNative4k: false,
maxBatchSize: 1,
maxPixels: 1048576,
sizePresets: [{ aspectRatio: "1:1", resolution: "1k", width: 1024, height: 1024, native: false, requiresUpscale: false }]
})
};
export function resolveModelCapability(
capabilities: Record<string, ModelCapability>,
model: string,
override?: ModelCapability
): ModelCapability {
return override ?? capabilities[model] ?? capabilities.default ?? Object.values(capabilities)[0] ?? placeholderCapabilities.default!;
}
export function resolveSize(
capability: ModelCapability,
request?: ProviderSizeRequest
): ImageSizePreset {
const aspectRatio = request?.aspectRatio ?? "1:1";
const resolution = request?.resolution ?? "1k";
const exact = capability.sizePresets.find((preset) => matchesPreset(preset, aspectRatio, resolution));
if (exact) return exact;
const sameAspect = capability.sizePresets.find((preset) => preset.aspectRatio === aspectRatio);
if (sameAspect) return sameAspect;
return capability.sizePresets[0] ?? placeholderCapabilities.default!.sizePresets[0]!;
}
type CapabilityInput = Omit<
ModelCapability,
| "supportedAspectRatios"
| "supportedResolutions"
| "defaultParams"
| "maxPromptLength"
| "maxNegativePromptLength"
| "supportsStreaming"
| "maxInputImages"
> &
Partial<Pick<ModelCapability, "maxPromptLength" | "maxNegativePromptLength" | "defaultParams" | "supportsStreaming" | "maxInputImages">>;
function capability(input: CapabilityInput): ModelCapability {
const sizePresets = input.sizePresets;
return {
...input,
supportsStreaming: input.supportsStreaming ?? false,
maxInputImages: input.maxInputImages ?? 0,
maxPromptLength: input.maxPromptLength ?? 4000,
maxNegativePromptLength: input.maxNegativePromptLength ?? 2000,
supportedAspectRatios: unique(sizePresets.map((preset) => preset.aspectRatio)),
supportedResolutions: unique(sizePresets.map((preset) => preset.resolution)),
defaultParams: input.defaultParams ?? {}
};
}
function matchesPreset(preset: ImageSizePreset, aspectRatio: AspectRatio, resolution: ResolutionTier): boolean {
return preset.aspectRatio === aspectRatio && preset.resolution === resolution;
}
function unique<T extends string>(values: T[]): T[] {
return Array.from(new Set(values));
}

View File

@@ -0,0 +1,137 @@
import type { ProviderCode, ProviderError, ProviderErrorCategory } from "./types";
type ErrorLike = {
name?: string;
message?: string;
status?: number;
statusCode?: number;
code?: string;
response?: {
status?: number;
data?: unknown;
};
cause?: unknown;
};
export class NormalizedProviderError extends Error {
readonly providerError: ProviderError;
constructor(providerError: ProviderError) {
super(providerError.message);
this.name = "NormalizedProviderError";
this.providerError = providerError;
}
}
export function normalizeProviderError(provider: ProviderCode | string, error: unknown): ProviderError {
if (error instanceof NormalizedProviderError) {
return error.providerError;
}
const statusCode = getStatusCode(error);
const providerCode = getProviderErrorCode(error, statusCode);
const message = getProviderErrorMessage(error);
const category = categorizeProviderError(statusCode, providerCode, message);
return {
provider,
category,
code: providerCode,
message,
retryable: isRetryable(category, statusCode),
...(statusCode !== undefined ? { statusCode } : {}),
raw: error
};
}
export function providerHttpError(provider: ProviderCode | string, statusCode: number, body: unknown): ProviderError {
const code = getBodyErrorCode(body) ?? `HTTP_${statusCode}`;
const message = getBodyErrorMessage(body) ?? `Provider request failed with HTTP ${statusCode}`;
const category = categorizeProviderError(statusCode, code, message);
return {
provider,
category,
code,
message,
statusCode,
retryable: isRetryable(category, statusCode),
raw: body
};
}
function getStatusCode(error: unknown): number | undefined {
const value = error as ErrorLike;
return value?.statusCode ?? value?.status ?? value?.response?.status;
}
function getProviderErrorCode(error: unknown, statusCode?: number): string {
const value = error as ErrorLike;
const fromBody = getBodyErrorCode(value?.response?.data) ?? getBodyErrorCode(error);
return fromBody ?? value?.code ?? (statusCode ? `HTTP_${statusCode}` : "PROVIDER_ERROR");
}
function getProviderErrorMessage(error: unknown): string {
const value = error as ErrorLike;
return getBodyErrorMessage(value?.response?.data) ?? getBodyErrorMessage(error) ?? value?.message ?? "Provider request failed";
}
function getBodyErrorCode(body: unknown): string | undefined {
if (!body || typeof body !== "object") return undefined;
const record = body as Record<string, unknown>;
const nested = record.error && typeof record.error === "object" ? (record.error as Record<string, unknown>) : undefined;
const code = nested?.code ?? nested?.type ?? record.code ?? record.error_code;
return typeof code === "string" ? code : undefined;
}
function getBodyErrorMessage(body: unknown): string | undefined {
if (!body || typeof body !== "object") return undefined;
const record = body as Record<string, unknown>;
const nested = record.error && typeof record.error === "object" ? (record.error as Record<string, unknown>) : undefined;
const message = nested?.message ?? record.message ?? record.error_msg ?? record.detail;
return typeof message === "string" ? message : undefined;
}
function categorizeProviderError(statusCode: number | undefined, code: string, message: string): ProviderErrorCategory {
const normalized = `${code} ${message}`.toLowerCase();
if (statusCode === 401 || normalized.includes("invalid api key") || normalized.includes("unauthorized")) {
return "authentication";
}
if (statusCode === 403 || normalized.includes("permission") || normalized.includes("forbidden")) {
return normalized.includes("policy") || normalized.includes("safety") ? "content_policy" : "permission";
}
if (statusCode === 408 || normalized.includes("timeout") || normalized.includes("aborted")) {
return "timeout";
}
if (statusCode === 429 || normalized.includes("rate limit") || normalized.includes("too many request")) {
return "rate_limit";
}
if (normalized.includes("quota") || normalized.includes("insufficient") || normalized.includes("balance")) {
return "quota";
}
if (normalized.includes("content policy") || normalized.includes("safety") || normalized.includes("sensitive")) {
return "content_policy";
}
if (statusCode === 400 || statusCode === 422) {
return "bad_request";
}
if (statusCode === 404) {
return "not_found";
}
if (statusCode && statusCode >= 500) {
return "provider_unavailable";
}
if (normalized.includes("fetch failed") || normalized.includes("econn") || normalized.includes("network")) {
return "network";
}
return statusCode ? "provider_error" : "unknown";
}
function isRetryable(category: ProviderErrorCategory, statusCode?: number): boolean {
if (category === "rate_limit" || category === "timeout" || category === "network" || category === "provider_unavailable") {
return true;
}
return typeof statusCode === "number" && statusCode >= 500;
}

View File

@@ -0,0 +1,16 @@
export * from "./types.js";
export * from "./errors.js";
export * from "./asset-normalizer.js";
export * from "./capabilities/presets.js";
export * from "./providers/mock.js";
export * from "./providers/openai-compatible.js";
export * from "./providers/siliconflow.js";
export * from "./providers/placeholders.js";
export * from "./registry.js";
import { createImageProviderAdapter } from "./registry.js";
import type { ImageProviderAdapter, ProviderCode } from "./types.js";
export function getImageProviderAdapter(provider: string): ImageProviderAdapter {
return createImageProviderAdapter(provider as ProviderCode);
}

View File

@@ -0,0 +1,70 @@
import { mockModelCapabilities, resolveModelCapability, resolveSize } from "../capabilities/presets";
import type {
ImageGenerationResult,
ImageProviderAdapter,
ImageToImageInput,
ProviderCallContext,
ProviderError,
TestConnectionInput,
TestConnectionResult,
TextToImageInput
} from "../types";
import { normalizeProviderError } from "../errors";
const ONE_PIXEL_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
export class MockImageProviderAdapter implements ImageProviderAdapter {
readonly provider = "mock" as const;
readonly capabilities = mockModelCapabilities;
async testConnection(input: TestConnectionInput): Promise<TestConnectionResult> {
return {
ok: true,
provider: this.provider,
model: input.model ?? "mock-wallpaper",
latencyMs: 0,
rawMetadata: { mode: "mock" }
};
}
async generateTextToImage(input: TextToImageInput, _context: ProviderCallContext): Promise<ImageGenerationResult> {
return this.generate(input);
}
async generateImageToImage(input: ImageToImageInput, _context: ProviderCallContext): Promise<ImageGenerationResult> {
return this.generate(input);
}
normalizeError(error: unknown): ProviderError {
return normalizeProviderError(this.provider, error);
}
private generate(input: TextToImageInput): ImageGenerationResult {
const capability = resolveModelCapability(this.capabilities, input.model, input.capability);
const size = resolveSize(capability, input.size);
const count = input.count ?? 1;
const responseAsBase64 = input.responseFormat === "base64" || input.responseFormat === "b64_json";
return {
provider: this.provider,
model: input.model,
assets: Array.from({ length: count }, (_, index) =>
({
kind: responseAsBase64 ? "base64" : "url",
value: responseAsBase64 ? ONE_PIXEL_PNG : `mock://wallmuse/${input.model}/${size.width}x${size.height}/${input.seed ?? "auto"}-${index}.png`,
mimeType: "image/png",
width: input.width ?? size.width,
height: input.height ?? size.height,
...(input.seed !== undefined ? { seed: input.seed } : {})
})
),
usage: { imageCount: count },
rawMetadata: {
prompt: input.prompt,
negativePrompt: input.negativePrompt,
size
}
};
}
}

View File

@@ -0,0 +1,23 @@
import { BaseHttpImageAdapter } from "../adapters/base-http-adapter";
import { openAiCompatibleModelCapabilities } from "../capabilities/presets";
import type { TextToImageInput } from "../types";
export class OpenAICompatibleImageProviderAdapter extends BaseHttpImageAdapter {
readonly provider = "openai-compatible" as const;
readonly capabilities = openAiCompatibleModelCapabilities;
protected readonly defaultBaseUrl = "https://api.openai.com";
protected readonly defaultEndpointPath = "/v1/images/generations";
protected override toRequestBody(input: TextToImageInput, providerSizeValue: string): Record<string, unknown> {
const body = super.toRequestBody(input, providerSizeValue);
if (input.quality) {
body.quality = input.quality === "ultra" ? "hd" : input.quality;
}
if (input.width && input.height) {
body.size = `${input.width}x${input.height}`;
}
return body;
}
}

View File

@@ -0,0 +1,50 @@
import { placeholderCapabilities } from "../capabilities/presets";
import { normalizeProviderError } from "../errors";
import type {
ImageGenerationResult,
ImageProviderAdapter,
ProviderCallContext,
ProviderCode,
ProviderError,
TestConnectionInput,
TestConnectionResult,
TextToImageInput
} from "../types";
export class ReservedImageProviderAdapter implements ImageProviderAdapter {
readonly capabilities = placeholderCapabilities;
constructor(readonly provider: Extract<ProviderCode, "dashscope" | "volcengine" | "zhipu" | "custom-template">) {}
async testConnection(input: TestConnectionInput): Promise<TestConnectionResult> {
return {
ok: false,
provider: this.provider,
...(input.model ? { model: input.model } : {}),
error: {
provider: this.provider,
category: "provider_error",
code: "ADAPTER_RESERVED",
message: `${this.provider} adapter is reserved but not implemented yet`,
retryable: false
}
};
}
async generateTextToImage(_input: TextToImageInput, _context: ProviderCallContext): Promise<ImageGenerationResult> {
throw this.normalizeError(new Error(`${this.provider} adapter is reserved but not implemented yet`));
}
normalizeError(error: unknown): ProviderError {
const normalized = normalizeProviderError(this.provider, error);
if (normalized.code === "PROVIDER_ERROR") {
return {
...normalized,
category: "provider_error",
code: "ADAPTER_RESERVED",
retryable: false
};
}
return normalized;
}
}

View File

@@ -0,0 +1,29 @@
import { BaseHttpImageAdapter } from "../adapters/base-http-adapter";
import { siliconFlowModelCapabilities } from "../capabilities/presets";
import type { TextToImageInput } from "../types";
export class SiliconFlowImageProviderAdapter extends BaseHttpImageAdapter {
readonly provider = "siliconflow" as const;
readonly capabilities = siliconFlowModelCapabilities;
protected readonly defaultBaseUrl = "https://api.siliconflow.cn";
protected readonly defaultEndpointPath = "/v1/images/generations";
protected override testPayloadExtras(): Record<string, unknown> {
return { image_size: "1024x1024" };
}
protected override toRequestBody(input: TextToImageInput, providerSizeValue: string): Record<string, unknown> {
const body: Record<string, unknown> = {
model: input.model,
prompt: input.prompt,
image_size: input.width && input.height ? `${input.width}x${input.height}` : providerSizeValue,
batch_size: input.count ?? 1,
...input.params
};
if (input.negativePrompt) body.negative_prompt = input.negativePrompt;
if (input.seed !== undefined) body.seed = input.seed;
return body;
}
}

View File

@@ -0,0 +1,33 @@
import { MockImageProviderAdapter } from "./providers/mock";
import { OpenAICompatibleImageProviderAdapter } from "./providers/openai-compatible";
import { ReservedImageProviderAdapter } from "./providers/placeholders";
import { SiliconFlowImageProviderAdapter } from "./providers/siliconflow";
import type { ImageProviderAdapter, ProviderCode } from "./types";
export function createImageProviderAdapter(provider: ProviderCode): ImageProviderAdapter {
switch (provider) {
case "mock":
return new MockImageProviderAdapter();
case "openai-compatible":
return new OpenAICompatibleImageProviderAdapter();
case "siliconflow":
return new SiliconFlowImageProviderAdapter();
case "dashscope":
case "volcengine":
case "zhipu":
case "custom-template":
return new ReservedImageProviderAdapter(provider);
}
}
export function createDefaultProviderRegistry(): Record<ProviderCode, ImageProviderAdapter> {
return {
mock: createImageProviderAdapter("mock"),
"openai-compatible": createImageProviderAdapter("openai-compatible"),
siliconflow: createImageProviderAdapter("siliconflow"),
dashscope: createImageProviderAdapter("dashscope"),
volcengine: createImageProviderAdapter("volcengine"),
zhipu: createImageProviderAdapter("zhipu"),
"custom-template": createImageProviderAdapter("custom-template")
};
}

View File

@@ -0,0 +1,144 @@
import type { AspectRatio, ModelCapability, ResolutionTier } from "@wallmuse/shared";
export type ProviderCode =
| "mock"
| "openai-compatible"
| "siliconflow"
| "dashscope"
| "volcengine"
| "zhipu"
| "custom-template";
export type ProviderAuth = {
apiKey?: string;
bearerToken?: string;
headers?: Record<string, string>;
};
export type ProviderCallContext = {
requestId?: string;
timeoutMs?: number;
auth?: ProviderAuth;
signal?: AbortSignal;
fetch?: typeof fetch;
metadata?: Record<string, unknown>;
};
export type ProviderEndpointConfig = {
baseUrl?: string;
endpointPath?: string;
defaultHeaders?: Record<string, string>;
};
export type TestConnectionInput = ProviderEndpointConfig & {
model?: string;
auth?: ProviderAuth;
timeoutMs?: number;
fetch?: typeof fetch;
};
export type TestConnectionResult = {
ok: boolean;
provider: ProviderCode;
latencyMs?: number;
model?: string;
error?: ProviderError;
rawMetadata?: unknown;
};
export type ImageReference = {
kind: "url" | "base64";
value: string;
mimeType?: string;
};
export type ProviderSizeRequest = {
aspectRatio: AspectRatio;
resolution: ResolutionTier;
width?: number;
height?: number;
};
export type TextToImageInput = ProviderEndpointConfig & {
model: string;
prompt: string;
negativePrompt?: string;
size?: ProviderSizeRequest;
width?: number;
height?: number;
count?: number;
seed?: number;
quality?: "standard" | "hd" | "ultra";
responseFormat?: "url" | "base64" | "b64_json";
params?: Record<string, unknown>;
capability?: ModelCapability;
};
export type ImageToImageInput = TextToImageInput & {
images: ImageReference[];
strength?: number;
};
export type GeneratedImageAsset = {
kind: "url" | "base64";
value: string;
mimeType?: string;
width?: number;
height?: number;
seed?: number;
};
export type ProviderUsage = {
inputTokens?: number;
outputTokens?: number;
imageCount?: number;
providerCost?: number;
};
export type ImageGenerationResult = {
provider: ProviderCode | string;
model: string;
assets: GeneratedImageAsset[];
usage?: ProviderUsage;
rawMetadata?: unknown;
};
export type ProviderErrorCategory =
| "authentication"
| "permission"
| "rate_limit"
| "quota"
| "timeout"
| "bad_request"
| "not_found"
| "provider_unavailable"
| "provider_error"
| "content_policy"
| "network"
| "unknown";
export type ProviderError = {
provider: ProviderCode | string;
category: ProviderErrorCategory;
code: string;
message: string;
statusCode?: number;
retryable: boolean;
raw?: unknown;
};
export interface ImageProviderAdapter {
readonly provider: ProviderCode;
readonly capabilities: Record<string, ModelCapability>;
testConnection(input: TestConnectionInput): Promise<TestConnectionResult>;
generateTextToImage(input: TextToImageInput, context: ProviderCallContext): Promise<ImageGenerationResult>;
generateImageToImage?(
input: ImageToImageInput,
context: ProviderCallContext
): Promise<ImageGenerationResult>;
normalizeError(error: unknown): ProviderError;
}

View File

@@ -0,0 +1,14 @@
export function joinUrl(baseUrl: string, endpointPath: string): string {
const base = baseUrl.replace(/\/+$/, "");
const path = endpointPath.replace(/^\/+/, "");
return `${base}/${path}`;
}
export function withDefaultImageEndpoint(baseUrl: string | undefined, endpointPath = "/v1/images/generations"): string {
return joinUrl(baseUrl ?? "https://api.openai.com", endpointPath);
}
export function ensureV1BaseUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../..",
"outDir": "dist",
"declaration": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts"]
}