Skip to content

Commit 719ec4f

Browse files
committed
refactor: share OpenAI-compatible image provider
1 parent 358579b commit 719ec4f

13 files changed

Lines changed: 741 additions & 378 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
343a555f212dd5ebf26dccbefff1cb4b56a08e4dcc2c801ac7ab5fb98973192a plugin-sdk-api-baseline.json
2-
02aaccbe13f261de2d41fcb4270fc9ae70b931966089e56d21a4ebc8e80c8821 plugin-sdk-api-baseline.jsonl
1+
5a77a53e9d48f4b683838a3993e583db8037edf836e8e13a209cc0ad1c89d809 plugin-sdk-api-baseline.json
2+
d31c5887a379c48a714e91d00a7d7fde7dff353319aee38a98babfdb753f49fb plugin-sdk-api-baseline.jsonl

docs/plugins/sdk-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ releases.
486486
| `plugin-sdk/speech-core` | Shared speech core | Speech provider types, registry, directives, normalization |
487487
| `plugin-sdk/realtime-transcription` | Realtime transcription helpers | Provider types, registry helpers, and shared WebSocket session helper |
488488
| `plugin-sdk/realtime-voice` | Realtime voice helpers | Provider types, registry/resolution helpers, and bridge session helpers |
489-
| `plugin-sdk/image-generation` | Image-generation helpers | Image generation provider types plus image asset/data URL helpers |
489+
| `plugin-sdk/image-generation` | Image-generation helpers | Image generation provider types plus image asset/data URL helpers and the OpenAI-compatible image provider builder |
490490
| `plugin-sdk/image-generation-core` | Shared image-generation core | Image-generation types, failover, auth, and registry helpers |
491491
| `plugin-sdk/music-generation` | Music-generation helpers | Music-generation provider/request/result types |
492492
| `plugin-sdk/music-generation-core` | Shared music-generation core | Music-generation types, failover helpers, provider lookup, and model-ref parsing |

docs/plugins/sdk-subpaths.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
262262
| `plugin-sdk/speech-core` | Shared speech provider types, registry, directive, normalization, and speech helper exports |
263263
| `plugin-sdk/realtime-transcription` | Realtime transcription provider types, registry helpers, and shared WebSocket session helper |
264264
| `plugin-sdk/realtime-voice` | Realtime voice provider types and registry helpers |
265-
| `plugin-sdk/image-generation` | Image generation provider types plus image asset/data URL helpers |
265+
| `plugin-sdk/image-generation` | Image generation provider types plus image asset/data URL helpers and the OpenAI-compatible image provider builder |
266266
| `plugin-sdk/image-generation-core` | Shared image-generation types, failover, auth, and registry helpers |
267267
| `plugin-sdk/music-generation` | Music generation provider/request/result types |
268268
| `plugin-sdk/music-generation-core` | Shared music-generation types, failover helpers, provider lookup, and model-ref parsing |

extensions/deepinfra/image-generation-provider.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@ const {
77
postMultipartRequestMock,
88
resolveApiKeyForProviderMock,
99
resolveProviderHttpRequestConfigMock,
10+
createProviderOperationDeadlineMock,
11+
resolveProviderOperationTimeoutMsMock,
1012
} = vi.hoisted(() => ({
1113
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
1214
postJsonRequestMock: vi.fn(),
1315
postMultipartRequestMock: vi.fn(),
1416
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "deepinfra-key" })),
17+
createProviderOperationDeadlineMock: vi.fn((params: Record<string, unknown>) => params),
18+
resolveProviderOperationTimeoutMsMock: vi.fn(
19+
(params: Record<string, unknown>) => params.defaultTimeoutMs,
20+
),
1521
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
1622
baseUrl: params.baseUrl ?? params.defaultBaseUrl ?? "https://api.deepinfra.com/v1/openai",
1723
allowPrivateNetwork: false,
@@ -26,9 +32,11 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
2632

2733
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
2834
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
35+
createProviderOperationDeadline: createProviderOperationDeadlineMock,
2936
postJsonRequest: postJsonRequestMock,
3037
postMultipartRequest: postMultipartRequestMock,
3138
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
39+
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
3240
sanitizeConfiguredModelProviderRequest: vi.fn((request) => request),
3341
}));
3442

Lines changed: 45 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,8 @@
1-
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
2-
import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
31
import {
2+
createOpenAiCompatibleImageGenerationProvider,
43
imageSourceUploadFileName,
5-
parseOpenAiCompatibleImageResponse,
4+
type ImageGenerationProvider,
65
} from "openclaw/plugin-sdk/image-generation";
7-
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
8-
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
9-
import {
10-
assertOkOrThrowHttpError,
11-
postJsonRequest,
12-
postMultipartRequest,
13-
resolveProviderHttpRequestConfig,
14-
sanitizeConfiguredModelProviderRequest,
15-
} from "openclaw/plugin-sdk/provider-http";
166
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
177
import {
188
DEEPINFRA_BASE_URL,
@@ -26,35 +16,12 @@ import {
2616
const DEEPINFRA_IMAGE_SIZES = ["512x512", "1024x1024", "1024x1792", "1792x1024"] as const;
2717
const MAX_DEEPINFRA_INPUT_IMAGES = 1;
2818

29-
type DeepInfraProviderConfig = NonNullable<
30-
NonNullable<OpenClawConfig["models"]>["providers"]
31-
>[string];
32-
33-
type DeepInfraImageApiResponse = {
34-
data?: Array<{
35-
b64_json?: string;
36-
revised_prompt?: string;
37-
url?: string;
38-
}>;
39-
};
40-
41-
function resolveDeepInfraProviderConfig(
42-
cfg: OpenClawConfig | undefined,
43-
): DeepInfraProviderConfig | undefined {
44-
return cfg?.models?.providers?.deepinfra;
45-
}
46-
4719
export function buildDeepInfraImageGenerationProvider(): ImageGenerationProvider {
48-
return {
20+
return createOpenAiCompatibleImageGenerationProvider({
4921
id: "deepinfra",
5022
label: "DeepInfra",
5123
defaultModel: DEFAULT_DEEPINFRA_IMAGE_MODEL,
5224
models: [...DEEPINFRA_IMAGE_MODELS],
53-
isConfigured: ({ agentDir }) =>
54-
isProviderApiKeyConfigured({
55-
provider: "deepinfra",
56-
agentDir,
57-
}),
5825
capabilities: {
5926
generate: {
6027
maxCount: 4,
@@ -74,111 +41,49 @@ export function buildDeepInfraImageGenerationProvider(): ImageGenerationProvider
7441
sizes: [...DEEPINFRA_IMAGE_SIZES],
7542
},
7643
},
77-
async generateImage(req) {
78-
const inputImages = req.inputImages ?? [];
79-
const isEdit = inputImages.length > 0;
80-
if (inputImages.length > MAX_DEEPINFRA_INPUT_IMAGES) {
81-
throw new Error("DeepInfra image editing supports one reference image.");
82-
}
83-
const auth = await resolveApiKeyForProvider({
84-
provider: "deepinfra",
85-
cfg: req.cfg,
86-
agentDir: req.agentDir,
87-
store: req.authStore,
88-
});
89-
if (!auth.apiKey) {
90-
throw new Error("DeepInfra API key missing");
44+
defaultBaseUrl: DEEPINFRA_BASE_URL,
45+
normalizeModel: normalizeDeepInfraModelRef,
46+
resolveBaseUrl: ({ providerConfig }) =>
47+
normalizeDeepInfraBaseUrl(providerConfig?.baseUrl, DEEPINFRA_BASE_URL),
48+
resolveAllowPrivateNetwork: () => false,
49+
useConfiguredRequest: true,
50+
resolveCount: ({ req, mode }) => (mode === "edit" ? 1 : (req.count ?? 1)),
51+
buildGenerateRequest: ({ req, model, count }) => ({
52+
kind: "json",
53+
body: {
54+
model,
55+
prompt: req.prompt,
56+
n: count,
57+
size: normalizeOptionalString(req.size) ?? DEFAULT_DEEPINFRA_IMAGE_SIZE,
58+
response_format: "b64_json",
59+
},
60+
}),
61+
buildEditRequest: ({ req, inputImages, model, count }) => {
62+
const image = inputImages[0];
63+
if (!image) {
64+
throw new Error("DeepInfra image edit missing reference image.");
9165
}
92-
93-
const providerConfig = resolveDeepInfraProviderConfig(req.cfg);
94-
const resolvedBaseUrl = normalizeDeepInfraBaseUrl(
95-
providerConfig?.baseUrl,
96-
DEEPINFRA_BASE_URL,
66+
const form = new FormData();
67+
form.set("model", model);
68+
form.set("prompt", req.prompt);
69+
form.set("n", String(count));
70+
form.set("size", normalizeOptionalString(req.size) ?? DEFAULT_DEEPINFRA_IMAGE_SIZE);
71+
form.set("response_format", "b64_json");
72+
const mimeType = normalizeOptionalString(image.mimeType) ?? "image/png";
73+
form.append(
74+
"image",
75+
new Blob([new Uint8Array(image.buffer)], { type: mimeType }),
76+
imageSourceUploadFileName({ image, index: 0 }),
9777
);
98-
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
99-
resolveProviderHttpRequestConfig({
100-
baseUrl: resolvedBaseUrl,
101-
defaultBaseUrl: DEEPINFRA_BASE_URL,
102-
allowPrivateNetwork: false,
103-
request: sanitizeConfiguredModelProviderRequest(providerConfig?.request),
104-
defaultHeaders: {
105-
Authorization: `Bearer ${auth.apiKey}`,
106-
},
107-
provider: "deepinfra",
108-
capability: "image",
109-
transport: "http",
110-
});
111-
112-
const model = normalizeDeepInfraModelRef(req.model, DEFAULT_DEEPINFRA_IMAGE_MODEL);
113-
const count = isEdit ? 1 : (req.count ?? 1);
114-
const size = normalizeOptionalString(req.size) ?? DEFAULT_DEEPINFRA_IMAGE_SIZE;
115-
const endpoint = isEdit ? "images/edits" : "images/generations";
116-
const request = isEdit
117-
? (() => {
118-
const form = new FormData();
119-
form.set("model", model);
120-
form.set("prompt", req.prompt);
121-
form.set("n", String(count));
122-
form.set("size", size);
123-
form.set("response_format", "b64_json");
124-
const image = inputImages[0];
125-
if (!image) {
126-
throw new Error("DeepInfra image edit missing reference image.");
127-
}
128-
const mimeType = normalizeOptionalString(image.mimeType) ?? "image/png";
129-
form.append(
130-
"image",
131-
new Blob([new Uint8Array(image.buffer)], { type: mimeType }),
132-
imageSourceUploadFileName({ image, index: 0 }),
133-
);
134-
const multipartHeaders = new Headers(headers);
135-
multipartHeaders.delete("Content-Type");
136-
return postMultipartRequest({
137-
url: `${baseUrl}/${endpoint}`,
138-
headers: multipartHeaders,
139-
body: form,
140-
timeoutMs: req.timeoutMs,
141-
fetchFn: fetch,
142-
allowPrivateNetwork,
143-
dispatcherPolicy,
144-
});
145-
})()
146-
: postJsonRequest({
147-
url: `${baseUrl}/${endpoint}`,
148-
headers: new Headers({
149-
...Object.fromEntries(headers.entries()),
150-
"Content-Type": "application/json",
151-
}),
152-
body: {
153-
model,
154-
prompt: req.prompt,
155-
n: count,
156-
size,
157-
response_format: "b64_json",
158-
},
159-
timeoutMs: req.timeoutMs,
160-
fetchFn: fetch,
161-
allowPrivateNetwork,
162-
dispatcherPolicy,
163-
});
164-
165-
const { response, release } = await request;
166-
try {
167-
await assertOkOrThrowHttpError(
168-
response,
169-
isEdit ? "DeepInfra image edit failed" : "DeepInfra image generation failed",
170-
);
171-
const images = parseOpenAiCompatibleImageResponse(
172-
(await response.json()) as DeepInfraImageApiResponse,
173-
{ defaultMimeType: "image/jpeg", sniffMimeType: true },
174-
);
175-
if (images.length === 0) {
176-
throw new Error("DeepInfra image response did not include generated image data");
177-
}
178-
return { images, model };
179-
} finally {
180-
await release();
181-
}
78+
return { kind: "multipart", form };
79+
},
80+
response: { defaultMimeType: "image/jpeg", sniffMimeType: true },
81+
tooManyInputImagesError: "DeepInfra image editing supports one reference image.",
82+
missingApiKeyError: "DeepInfra API key missing",
83+
emptyResponseError: "DeepInfra image response did not include generated image data",
84+
failureLabels: {
85+
generate: "DeepInfra image generation failed",
86+
edit: "DeepInfra image edit failed",
18287
},
183-
};
88+
});
18489
}

extensions/litellm/image-generation-provider.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,27 @@ import { buildLitellmImageGenerationProvider } from "./image-generation-provider
44
const {
55
resolveApiKeyForProviderMock,
66
postJsonRequestMock,
7+
postMultipartRequestMock,
78
assertOkOrThrowHttpErrorMock,
9+
createProviderOperationDeadlineMock,
810
resolveProviderHttpRequestConfigMock,
11+
resolveProviderOperationTimeoutMsMock,
912
sanitizeConfiguredModelProviderRequestMock,
1013
} = vi.hoisted(() => ({
1114
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "litellm-key" })),
1215
postJsonRequestMock: vi.fn(),
16+
postMultipartRequestMock: vi.fn(),
1317
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
18+
createProviderOperationDeadlineMock: vi.fn((params: Record<string, unknown>) => params),
1419
resolveProviderHttpRequestConfigMock: vi.fn((params) => ({
1520
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
1621
allowPrivateNetwork: Boolean(params.allowPrivateNetwork ?? params.request?.allowPrivateNetwork),
1722
headers: new Headers(params.defaultHeaders),
1823
dispatcherPolicy: undefined as unknown,
1924
})),
25+
resolveProviderOperationTimeoutMsMock: vi.fn(
26+
(params: Record<string, unknown>) => params.defaultTimeoutMs,
27+
),
2028
sanitizeConfiguredModelProviderRequestMock: vi.fn((request) => request),
2129
}));
2230

@@ -26,8 +34,11 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
2634

2735
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
2836
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
37+
createProviderOperationDeadline: createProviderOperationDeadlineMock,
2938
postJsonRequest: postJsonRequestMock,
39+
postMultipartRequest: postMultipartRequestMock,
3040
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
41+
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
3142
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
3243
}));
3344

0 commit comments

Comments
 (0)