Initial WallMuse project
This commit is contained in:
25
packages/provider-adapters/package.json
Normal file
25
packages/provider-adapters/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
});
|
||||
}
|
||||
165
packages/provider-adapters/src/adapters/base-http-adapter.ts
Normal file
165
packages/provider-adapters/src/adapters/base-http-adapter.ts
Normal 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 } : {};
|
||||
}
|
||||
104
packages/provider-adapters/src/asset-normalizer.ts
Normal file
104
packages/provider-adapters/src/asset-normalizer.ts
Normal 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">;
|
||||
}
|
||||
183
packages/provider-adapters/src/capabilities/presets.ts
Normal file
183
packages/provider-adapters/src/capabilities/presets.ts
Normal 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));
|
||||
}
|
||||
137
packages/provider-adapters/src/errors.ts
Normal file
137
packages/provider-adapters/src/errors.ts
Normal 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;
|
||||
}
|
||||
16
packages/provider-adapters/src/index.ts
Normal file
16
packages/provider-adapters/src/index.ts
Normal 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);
|
||||
}
|
||||
70
packages/provider-adapters/src/providers/mock.ts
Normal file
70
packages/provider-adapters/src/providers/mock.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
50
packages/provider-adapters/src/providers/placeholders.ts
Normal file
50
packages/provider-adapters/src/providers/placeholders.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
29
packages/provider-adapters/src/providers/siliconflow.ts
Normal file
29
packages/provider-adapters/src/providers/siliconflow.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
33
packages/provider-adapters/src/registry.ts
Normal file
33
packages/provider-adapters/src/registry.ts
Normal 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")
|
||||
};
|
||||
}
|
||||
144
packages/provider-adapters/src/types.ts
Normal file
144
packages/provider-adapters/src/types.ts
Normal 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;
|
||||
}
|
||||
14
packages/provider-adapters/src/url.ts
Normal file
14
packages/provider-adapters/src/url.ts
Normal 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`;
|
||||
}
|
||||
10
packages/provider-adapters/tsconfig.json
Normal file
10
packages/provider-adapters/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../..",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user