Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
12
extensions/deepinfra/README.md
Normal file
12
extensions/deepinfra/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# OpenClaw DeepInfra Provider
|
||||
|
||||
Official OpenClaw provider plugin for DeepInfra.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/deepinfra-provider
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
See <https://docs.openclaw.ai/providers/deepinfra> for setup and configuration.
|
||||
9
extensions/deepinfra/api.ts
Normal file
9
extensions/deepinfra/api.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Deepinfra API module exposes the plugin public contract.
|
||||
export { buildDeepInfraProvider, buildStaticDeepInfraProvider } from "./provider-catalog.js";
|
||||
export { applyDeepInfraConfig } from "./onboard.js";
|
||||
export { DEEPINFRA_DEFAULT_MODEL_REF } from "./provider-models.js";
|
||||
export { buildDeepInfraImageGenerationProvider } from "./image-generation-provider.js";
|
||||
export { deepinfraMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
export { deepinfraMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
|
||||
export { buildDeepInfraSpeechProvider } from "./speech-provider.js";
|
||||
export { buildDeepInfraVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
121
extensions/deepinfra/cache-wrapper.test.ts
Normal file
121
extensions/deepinfra/cache-wrapper.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// Deepinfra tests cover cache wrapper plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDeepInfraAnthropicCacheWrapper } from "./cache-wrapper.js";
|
||||
|
||||
type StreamFn = Parameters<typeof createDeepInfraAnthropicCacheWrapper>[0];
|
||||
|
||||
function capturePayload(params: { modelId: string; initialPayload: Record<string, unknown> }): {
|
||||
captured: Record<string, unknown>;
|
||||
baseCalls: number;
|
||||
} {
|
||||
let captured: Record<string, unknown> = {};
|
||||
let baseCalls = 0;
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
baseCalls += 1;
|
||||
const payload = structuredClone(params.initialPayload);
|
||||
options?.onPayload?.(payload, _model);
|
||||
captured = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrapped = createDeepInfraAnthropicCacheWrapper(baseStreamFn);
|
||||
void wrapped(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "deepinfra",
|
||||
id: params.modelId,
|
||||
reasoning: false,
|
||||
} as Parameters<StreamFn>[0],
|
||||
{ messages: [] } as Parameters<StreamFn>[1],
|
||||
{} as never,
|
||||
);
|
||||
|
||||
return { captured, baseCalls };
|
||||
}
|
||||
|
||||
describe("createDeepInfraAnthropicCacheWrapper", () => {
|
||||
it("injects ephemeral cache_control markers on the system message for anthropic/* models", () => {
|
||||
const { captured, baseCalls } = capturePayload({
|
||||
modelId: "anthropic/claude-sonnet-4-6",
|
||||
initialPayload: {
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hi" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(baseCalls).toBe(1);
|
||||
expect(captured.messages).toEqual([
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "You are a helpful assistant.",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "Hi" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("tags the last block of an array-shaped system message", () => {
|
||||
const { captured } = capturePayload({
|
||||
modelId: "anthropic/claude-haiku-4-5",
|
||||
initialPayload: {
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ type: "text", text: "Block one" },
|
||||
{ type: "text", text: "Block two" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "Hi" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const messages = captured.messages as Array<{ role: string; content: unknown }>;
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Block one" },
|
||||
{
|
||||
type: "text",
|
||||
text: "Block two",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches the anthropic/ prefix case-insensitively", () => {
|
||||
const { captured } = capturePayload({
|
||||
modelId: "Anthropic/Claude-Sonnet-4-6",
|
||||
initialPayload: {
|
||||
messages: [{ role: "system", content: "sys" }],
|
||||
},
|
||||
});
|
||||
|
||||
const messages = captured.messages as Array<{ content: unknown }>;
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "sys", cache_control: { type: "ephemeral" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate payloads for non-anthropic model ids", () => {
|
||||
const initialPayload = {
|
||||
messages: [
|
||||
{ role: "system", content: "sys" },
|
||||
{ role: "user", content: "Hi" },
|
||||
],
|
||||
};
|
||||
const { captured, baseCalls } = capturePayload({
|
||||
modelId: "deepseek-ai/DeepSeek-V4-Flash",
|
||||
initialPayload,
|
||||
});
|
||||
|
||||
expect(baseCalls).toBe(1);
|
||||
expect(captured).toEqual(initialPayload);
|
||||
});
|
||||
});
|
||||
26
extensions/deepinfra/cache-wrapper.ts
Normal file
26
extensions/deepinfra/cache-wrapper.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Deepinfra plugin module implements cache wrapper behavior.
|
||||
import {
|
||||
applyAnthropicEphemeralCacheControlMarkers,
|
||||
streamWithPayloadPatch,
|
||||
} from "openclaw/plugin-sdk/provider-stream";
|
||||
|
||||
// StreamFn isn't re-exported via the plugin SDK; derive it from a helper that
|
||||
// accepts it so we stay on the SDK boundary.
|
||||
type StreamFn = Parameters<typeof streamWithPayloadPatch>[0];
|
||||
|
||||
// Inject Anthropic ephemeral cache_control markers for anthropic/* models on
|
||||
// DeepInfra. The OpenRouter equivalent short-circuits on a provider/endpoint
|
||||
// check, so DeepInfra advertises isCacheTtlEligible but the payload patch
|
||||
// never fires. Gating on the model id instead fixes that.
|
||||
export function createDeepInfraAnthropicCacheWrapper(baseStreamFn: StreamFn): StreamFn {
|
||||
return ((model, context, options) => {
|
||||
const modelIdRaw = (model as { id?: unknown }).id;
|
||||
const modelId = typeof modelIdRaw === "string" ? modelIdRaw.toLowerCase() : "";
|
||||
if (!modelId.startsWith("anthropic/")) {
|
||||
return baseStreamFn(model, context, options);
|
||||
}
|
||||
return streamWithPayloadPatch(baseStreamFn, model, context, options, (payload) => {
|
||||
applyAnthropicEphemeralCacheControlMarkers(payload);
|
||||
});
|
||||
}) as StreamFn;
|
||||
}
|
||||
35
extensions/deepinfra/embedding-provider.ts
Normal file
35
extensions/deepinfra/embedding-provider.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Deepinfra provider module implements model/runtime integration.
|
||||
import {
|
||||
createRemoteEmbeddingProvider,
|
||||
resolveRemoteEmbeddingClient,
|
||||
type MemoryEmbeddingProviderCreateOptions,
|
||||
type MemoryEmbeddingProviderCreateResult,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import {
|
||||
DEEPINFRA_BASE_URL,
|
||||
DEEPINFRA_EMBED_FALLBACK_MODELS,
|
||||
normalizeDeepInfraModelRef,
|
||||
} from "./media-models.js";
|
||||
|
||||
export const DEFAULT_DEEPINFRA_EMBEDDING_MODEL = DEEPINFRA_EMBED_FALLBACK_MODELS[0];
|
||||
|
||||
export async function createDeepInfraEmbeddingProvider(
|
||||
options: MemoryEmbeddingProviderCreateOptions & { defaultModel?: string },
|
||||
): Promise<MemoryEmbeddingProviderCreateResult & { client: { model: string } }> {
|
||||
const defaultModel = options.defaultModel ?? DEFAULT_DEEPINFRA_EMBEDDING_MODEL;
|
||||
const client = await resolveRemoteEmbeddingClient({
|
||||
provider: "deepinfra",
|
||||
options: {
|
||||
...options,
|
||||
model: normalizeDeepInfraModelRef(options.model, defaultModel),
|
||||
},
|
||||
defaultBaseUrl: DEEPINFRA_BASE_URL,
|
||||
normalizeModel: (model) => normalizeDeepInfraModelRef(model, defaultModel),
|
||||
});
|
||||
const provider = createRemoteEmbeddingProvider({
|
||||
id: "deepinfra",
|
||||
client,
|
||||
errorPrefix: "DeepInfra embeddings API error",
|
||||
});
|
||||
return { provider, client };
|
||||
}
|
||||
234
extensions/deepinfra/image-generation-provider.test.ts
Normal file
234
extensions/deepinfra/image-generation-provider.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
// Deepinfra tests cover image generation provider plugin behavior.
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildDeepInfraImageGenerationProvider } from "./image-generation-provider.js";
|
||||
|
||||
const {
|
||||
assertOkOrThrowHttpErrorMock,
|
||||
postJsonRequestMock,
|
||||
postMultipartRequestMock,
|
||||
resolveApiKeyForProviderMock,
|
||||
resolveProviderHttpRequestConfigMock,
|
||||
createProviderOperationDeadlineMock,
|
||||
resolveProviderOperationTimeoutMsMock,
|
||||
} = vi.hoisted(() => ({
|
||||
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
|
||||
postJsonRequestMock: vi.fn(),
|
||||
postMultipartRequestMock: vi.fn(),
|
||||
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "deepinfra-key" })),
|
||||
createProviderOperationDeadlineMock: vi.fn((params: Record<string, unknown>) => params),
|
||||
resolveProviderOperationTimeoutMsMock: vi.fn(
|
||||
(params: Record<string, unknown>) => params.defaultTimeoutMs,
|
||||
),
|
||||
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
|
||||
baseUrl: params.baseUrl ?? params.defaultBaseUrl ?? "https://api.deepinfra.com/v1/openai",
|
||||
allowPrivateNetwork: false,
|
||||
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
|
||||
dispatcherPolicy: undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
|
||||
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
|
||||
"openclaw/plugin-sdk/provider-http",
|
||||
);
|
||||
return {
|
||||
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
|
||||
createProviderOperationDeadline: createProviderOperationDeadlineMock,
|
||||
postJsonRequest: postJsonRequestMock,
|
||||
postMultipartRequest: postMultipartRequestMock,
|
||||
readProviderJsonResponse: actual.readProviderJsonResponse,
|
||||
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
|
||||
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
|
||||
sanitizeConfiguredModelProviderRequest: vi.fn((request) => request),
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/provider-auth-runtime");
|
||||
vi.doUnmock("openclaw/plugin-sdk/provider-http");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function requireFirstMockArg(mock: ReturnType<typeof vi.fn>, label: string): unknown {
|
||||
const [call] = mock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return call[0];
|
||||
}
|
||||
|
||||
function requireFirstMockObjectArg(mock: ReturnType<typeof vi.fn>, label: string): object {
|
||||
const value = requireFirstMockArg(mock, label);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("deepinfra image generation provider", () => {
|
||||
afterEach(() => {
|
||||
assertOkOrThrowHttpErrorMock.mockClear();
|
||||
postJsonRequestMock.mockReset();
|
||||
postMultipartRequestMock.mockReset();
|
||||
resolveApiKeyForProviderMock.mockClear();
|
||||
resolveProviderHttpRequestConfigMock.mockClear();
|
||||
});
|
||||
|
||||
it("declares generation and single-reference edit support", () => {
|
||||
const provider = buildDeepInfraImageGenerationProvider();
|
||||
|
||||
expect(provider.id).toBe("deepinfra");
|
||||
expect(provider.defaultModel).toBe("black-forest-labs/FLUX-1-schnell");
|
||||
expect(provider.capabilities.generate.maxCount).toBe(4);
|
||||
expect(provider.capabilities.edit.enabled).toBe(true);
|
||||
expect(provider.capabilities.edit.maxInputImages).toBe(1);
|
||||
});
|
||||
|
||||
it("sends OpenAI-compatible image generation requests and sniffs JPEG output", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: jsonResponse({
|
||||
data: [{ b64_json: jpegBytes.toString("base64"), revised_prompt: "red square" }],
|
||||
}),
|
||||
release,
|
||||
});
|
||||
|
||||
const provider = buildDeepInfraImageGenerationProvider();
|
||||
const result = await provider.generateImage({
|
||||
provider: "deepinfra",
|
||||
model: "deepinfra/black-forest-labs/FLUX-1-schnell",
|
||||
prompt: "red square",
|
||||
count: 2,
|
||||
size: "512x512",
|
||||
timeoutMs: 12_345,
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
deepinfra: {
|
||||
baseUrl: "https://api.deepinfra.com/v1/openai/",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(resolveProviderHttpRequestConfigMock.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
baseUrl: "https://api.deepinfra.com/v1/openai",
|
||||
defaultBaseUrl: "https://api.deepinfra.com/v1/openai",
|
||||
allowPrivateNetwork: false,
|
||||
request: undefined,
|
||||
defaultHeaders: {
|
||||
Authorization: "Bearer deepinfra-key",
|
||||
},
|
||||
provider: "deepinfra",
|
||||
capability: "image",
|
||||
transport: "http",
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(postJsonRequestMock).toHaveBeenCalledOnce();
|
||||
const jsonRequest = requireFirstMockArg(postJsonRequestMock, "DeepInfra JSON image request");
|
||||
const jsonRequestHeaders = Reflect.get(jsonRequest ?? {}, "headers");
|
||||
expect(jsonRequestHeaders).toBeInstanceOf(Headers);
|
||||
expect(Object.fromEntries((jsonRequestHeaders as Headers).entries())).toEqual({
|
||||
authorization: "Bearer deepinfra-key",
|
||||
"content-type": "application/json",
|
||||
});
|
||||
expect(jsonRequest).toEqual({
|
||||
url: "https://api.deepinfra.com/v1/openai/images/generations",
|
||||
headers: jsonRequestHeaders,
|
||||
timeoutMs: 12_345,
|
||||
body: {
|
||||
model: "black-forest-labs/FLUX-1-schnell",
|
||||
prompt: "red square",
|
||||
n: 2,
|
||||
size: "512x512",
|
||||
response_format: "b64_json",
|
||||
},
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork: false,
|
||||
dispatcherPolicy: undefined,
|
||||
});
|
||||
expect(result.images).toHaveLength(1);
|
||||
const [firstImage] = result.images;
|
||||
if (!firstImage) {
|
||||
throw new Error("Expected generated DeepInfra image");
|
||||
}
|
||||
expect(firstImage).toEqual({
|
||||
buffer: jpegBytes,
|
||||
mimeType: "image/jpeg",
|
||||
fileName: "image-1.jpg",
|
||||
revisedPrompt: "red square",
|
||||
});
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("sends image edits as multipart OpenAI-compatible requests", async () => {
|
||||
postMultipartRequestMock.mockResolvedValue({
|
||||
response: jsonResponse({
|
||||
data: [
|
||||
{
|
||||
b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString(
|
||||
"base64",
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildDeepInfraImageGenerationProvider();
|
||||
const result = await provider.generateImage({
|
||||
provider: "deepinfra",
|
||||
model: "black-forest-labs/FLUX-1-schnell",
|
||||
prompt: "make it neon",
|
||||
inputImages: [{ buffer: Buffer.from("source"), mimeType: "image/png" }],
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(postMultipartRequestMock).toHaveBeenCalledOnce();
|
||||
const multipartRequest = requireFirstMockObjectArg(
|
||||
postMultipartRequestMock,
|
||||
"DeepInfra multipart image request",
|
||||
);
|
||||
const multipartHeaders = Reflect.get(multipartRequest, "headers");
|
||||
expect(multipartHeaders).toBeInstanceOf(Headers);
|
||||
expect(Object.fromEntries((multipartHeaders as Headers).entries())).toEqual({
|
||||
authorization: "Bearer deepinfra-key",
|
||||
});
|
||||
const form = Reflect.get(multipartRequest, "body") as FormData;
|
||||
expect(multipartRequest).toEqual({
|
||||
url: "https://api.deepinfra.com/v1/openai/images/edits",
|
||||
headers: multipartHeaders,
|
||||
body: form,
|
||||
timeoutMs: undefined,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork: false,
|
||||
dispatcherPolicy: undefined,
|
||||
});
|
||||
expect(form.get("model")).toBe("black-forest-labs/FLUX-1-schnell");
|
||||
expect(form.get("prompt")).toBe("make it neon");
|
||||
expect(form.get("response_format")).toBe("b64_json");
|
||||
expect(form.get("image")).toBeInstanceOf(File);
|
||||
expect(result.images).toHaveLength(1);
|
||||
const [image] = result.images;
|
||||
if (!image) {
|
||||
throw new Error("Expected edited DeepInfra image");
|
||||
}
|
||||
expect(image.mimeType).toBe("image/png");
|
||||
});
|
||||
});
|
||||
99
extensions/deepinfra/image-generation-provider.ts
Normal file
99
extensions/deepinfra/image-generation-provider.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// Deepinfra provider module implements model/runtime integration.
|
||||
import {
|
||||
createOpenAiCompatibleImageGenerationProvider,
|
||||
imageSourceUploadFileName,
|
||||
type ImageGenerationProvider,
|
||||
} from "openclaw/plugin-sdk/image-generation";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
DEEPINFRA_BASE_URL,
|
||||
DEEPINFRA_IMAGE_FALLBACK_MODELS,
|
||||
DEFAULT_DEEPINFRA_IMAGE_SIZE,
|
||||
normalizeDeepInfraBaseUrl,
|
||||
normalizeDeepInfraModelRef,
|
||||
} from "./media-models.js";
|
||||
import type { DeepInfraSurfaceModel } from "./provider-models.js";
|
||||
|
||||
const DEEPINFRA_IMAGE_SIZES = ["512x512", "1024x1024", "1024x1792", "1792x1024"] as const;
|
||||
const MAX_DEEPINFRA_INPUT_IMAGES = 1;
|
||||
|
||||
// First entry of imageGenModels is the default; rest fill the allowlist.
|
||||
// No catalog supplied -> DEEPINFRA_IMAGE_FALLBACK_MODELS.
|
||||
export function buildDeepInfraImageGenerationProvider(options?: {
|
||||
imageGenModels?: readonly DeepInfraSurfaceModel[];
|
||||
}): ImageGenerationProvider {
|
||||
const ids =
|
||||
options?.imageGenModels && options.imageGenModels.length > 0
|
||||
? options.imageGenModels.map((model) => model.id)
|
||||
: [...DEEPINFRA_IMAGE_FALLBACK_MODELS];
|
||||
const defaultModel = ids[0] ?? DEEPINFRA_IMAGE_FALLBACK_MODELS[0];
|
||||
return createOpenAiCompatibleImageGenerationProvider({
|
||||
id: "deepinfra",
|
||||
label: "DeepInfra",
|
||||
defaultModel,
|
||||
models: ids,
|
||||
capabilities: {
|
||||
generate: {
|
||||
maxCount: 4,
|
||||
supportsSize: true,
|
||||
supportsAspectRatio: false,
|
||||
supportsResolution: false,
|
||||
},
|
||||
edit: {
|
||||
enabled: true,
|
||||
maxCount: 1,
|
||||
maxInputImages: MAX_DEEPINFRA_INPUT_IMAGES,
|
||||
supportsSize: true,
|
||||
supportsAspectRatio: false,
|
||||
supportsResolution: false,
|
||||
},
|
||||
geometry: {
|
||||
sizes: [...DEEPINFRA_IMAGE_SIZES],
|
||||
},
|
||||
},
|
||||
defaultBaseUrl: DEEPINFRA_BASE_URL,
|
||||
normalizeModel: normalizeDeepInfraModelRef,
|
||||
resolveBaseUrl: ({ providerConfig }) =>
|
||||
normalizeDeepInfraBaseUrl(providerConfig?.baseUrl, DEEPINFRA_BASE_URL),
|
||||
resolveAllowPrivateNetwork: () => false,
|
||||
useConfiguredRequest: true,
|
||||
resolveCount: ({ req, mode }) => (mode === "edit" ? 1 : (req.count ?? 1)),
|
||||
buildGenerateRequest: ({ req, model, count }) => ({
|
||||
kind: "json",
|
||||
body: {
|
||||
model,
|
||||
prompt: req.prompt,
|
||||
n: count,
|
||||
size: normalizeOptionalString(req.size) ?? DEFAULT_DEEPINFRA_IMAGE_SIZE,
|
||||
response_format: "b64_json",
|
||||
},
|
||||
}),
|
||||
buildEditRequest: ({ req, inputImages, model, count }) => {
|
||||
const image = inputImages[0];
|
||||
if (!image) {
|
||||
throw new Error("DeepInfra image edit missing reference image.");
|
||||
}
|
||||
const form = new FormData();
|
||||
form.set("model", model);
|
||||
form.set("prompt", req.prompt);
|
||||
form.set("n", String(count));
|
||||
form.set("size", normalizeOptionalString(req.size) ?? DEFAULT_DEEPINFRA_IMAGE_SIZE);
|
||||
form.set("response_format", "b64_json");
|
||||
const mimeType = normalizeOptionalString(image.mimeType) ?? "image/png";
|
||||
form.append(
|
||||
"image",
|
||||
new Blob([new Uint8Array(image.buffer)], { type: mimeType }),
|
||||
imageSourceUploadFileName({ image, index: 0 }),
|
||||
);
|
||||
return { kind: "multipart", form };
|
||||
},
|
||||
response: { defaultMimeType: "image/jpeg", sniffMimeType: true },
|
||||
tooManyInputImagesError: "DeepInfra image editing supports one reference image.",
|
||||
missingApiKeyError: "DeepInfra API key missing",
|
||||
emptyResponseError: "DeepInfra image response did not include generated image data",
|
||||
failureLabels: {
|
||||
generate: "DeepInfra image generation failed",
|
||||
edit: "DeepInfra image edit failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
309
extensions/deepinfra/index.test.ts
Normal file
309
extensions/deepinfra/index.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// Deepinfra tests cover index plugin behavior.
|
||||
import {
|
||||
createCapturedPluginRegistration,
|
||||
registerSingleProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import deepinfraPlugin from "./index.js";
|
||||
import {
|
||||
DEEPINFRA_MODEL_CATALOG,
|
||||
DEEPINFRA_MODELS_URL,
|
||||
resetDeepInfraModelCacheForTest,
|
||||
} from "./provider-models.js";
|
||||
|
||||
function buildSyntheticDeepInfraEntries(count: number) {
|
||||
return Array.from({ length: count }, (_unused, index) => ({
|
||||
provider: "deepinfra",
|
||||
id: `synthetic/model-${index}`,
|
||||
name: `synthetic/model-${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDeepInfraCatalogContext(): ProviderCatalogContext {
|
||||
return {
|
||||
config: {},
|
||||
env: {},
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
resolveProviderApiKey: () => ({ apiKey: "profile-key" }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "profile-key",
|
||||
mode: "api_key",
|
||||
source: "profile",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeAgentModelEntry(id = "profile/live-model") {
|
||||
return {
|
||||
id,
|
||||
object: "model",
|
||||
owned_by: "deepinfra",
|
||||
metadata: {
|
||||
description: id,
|
||||
context_length: 32768,
|
||||
max_tokens: 4096,
|
||||
pricing: { input_tokens: 1, output_tokens: 2 },
|
||||
tags: ["chat"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function withLiveDiscoveryTestEnv(
|
||||
mockFetch: ReturnType<typeof vi.fn>,
|
||||
runAssertions: () => Promise<void>,
|
||||
) {
|
||||
const env = { ...process.env };
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.VITEST;
|
||||
delete process.env.DEEPINFRA_API_KEY;
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
try {
|
||||
await runAssertions();
|
||||
} finally {
|
||||
for (const key of ["NODE_ENV", "VITEST", "DEEPINFRA_API_KEY"]) {
|
||||
if (env[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = env[key];
|
||||
}
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
}
|
||||
|
||||
describe("deepinfra augmentModelCatalog", () => {
|
||||
it("returns the discovered (static under VITEST) catalog when nothing is configured", async () => {
|
||||
resetDeepInfraModelCacheForTest();
|
||||
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
|
||||
|
||||
const entries = (await provider.augmentModelCatalog?.({ entries: [] } as never)) ?? [];
|
||||
|
||||
expect(entries.map((entry) => entry.id)).toEqual(
|
||||
DEEPINFRA_MODEL_CATALOG.map((model) => model.id),
|
||||
);
|
||||
for (const entry of entries) {
|
||||
expect(entry.provider).toBe("deepinfra");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves configured entries and appends discovered entries that are not already configured", async () => {
|
||||
resetDeepInfraModelCacheForTest();
|
||||
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
|
||||
|
||||
const entries =
|
||||
(await provider.augmentModelCatalog?.({
|
||||
entries: [],
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
deepinfra: {
|
||||
models: [
|
||||
{
|
||||
id: "zai-org/GLM-5.1",
|
||||
name: "GLM-5.1 custom",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
contextWindow: 202752,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never)) ?? [];
|
||||
|
||||
const glmEntry = entries.find((entry) => entry.id === "zai-org/GLM-5.1");
|
||||
expect(glmEntry?.name).toBe("GLM-5.1 custom");
|
||||
expect(entries.filter((entry) => entry.id === "zai-org/GLM-5.1")).toHaveLength(1);
|
||||
expect(entries.length).toBe(DEEPINFRA_MODEL_CATALOG.length);
|
||||
});
|
||||
|
||||
it("uses config-backed API keys to enable live model catalog augmentation", async () => {
|
||||
resetDeepInfraModelCacheForTest();
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry("config/live-model")] }));
|
||||
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
|
||||
|
||||
await withLiveDiscoveryTestEnv(mockFetch, async () => {
|
||||
const entries =
|
||||
(await provider.augmentModelCatalog?.({
|
||||
entries: [],
|
||||
env: {},
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
deepinfra: {
|
||||
apiKey: { source: "env", provider: "default", id: "CUSTOM_DEEPINFRA_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never)) ?? [];
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
expect(entries.map((entry) => entry.id)).toContain("config/live-model");
|
||||
});
|
||||
});
|
||||
|
||||
it("still runs live discovery when ctx.entries includes custom DeepInfra rows", async () => {
|
||||
resetDeepInfraModelCacheForTest();
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry("custom/live-model")] }));
|
||||
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
|
||||
|
||||
const seededDeepInfraCount = DEEPINFRA_MODEL_CATALOG.length + 5;
|
||||
await withLiveDiscoveryTestEnv(mockFetch, async () => {
|
||||
const entries =
|
||||
(await provider.augmentModelCatalog?.({
|
||||
entries: [
|
||||
...buildSyntheticDeepInfraEntries(seededDeepInfraCount),
|
||||
{ provider: "openai", id: "noise", name: "noise" },
|
||||
],
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
deepinfra: {
|
||||
apiKey: "sk-test",
|
||||
models: [
|
||||
{
|
||||
id: "zai-org/GLM-5.1",
|
||||
name: "configured override",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
contextWindow: 202752,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never)) ?? [];
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
expect(entries[0]).toEqual({
|
||||
provider: "deepinfra",
|
||||
id: "zai-org/GLM-5.1",
|
||||
name: "configured override",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
contextWindow: 202752,
|
||||
});
|
||||
expect(entries.map((entry) => entry.id)).toContain("custom/live-model");
|
||||
});
|
||||
});
|
||||
|
||||
it("still fetches when ctx.entries has exactly the static catalog length (static-fallback case)", async () => {
|
||||
resetDeepInfraModelCacheForTest();
|
||||
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
|
||||
|
||||
const entries =
|
||||
(await provider.augmentModelCatalog?.({
|
||||
entries: buildSyntheticDeepInfraEntries(DEEPINFRA_MODEL_CATALOG.length),
|
||||
} as never)) ?? [];
|
||||
|
||||
expect(entries.map((entry) => entry.id)).toEqual(
|
||||
DEEPINFRA_MODEL_CATALOG.map((model) => model.id),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deepinfra capability registration", () => {
|
||||
it("registers all DeepInfra-backed OpenClaw provider surfaces", () => {
|
||||
const captured = createCapturedPluginRegistration();
|
||||
deepinfraPlugin.register(captured.api);
|
||||
|
||||
expect(captured.providers.map((provider) => provider.id)).toEqual(["deepinfra"]);
|
||||
expect(captured.imageGenerationProviders.map((provider) => provider.id)).toEqual(["deepinfra"]);
|
||||
expect(captured.mediaUnderstandingProviders.map((provider) => provider.id)).toEqual([
|
||||
"deepinfra",
|
||||
]);
|
||||
expect(captured.memoryEmbeddingProviders.map((provider) => provider.id)).toEqual(["deepinfra"]);
|
||||
expect(captured.speechProviders.map((provider) => provider.id)).toEqual(["deepinfra"]);
|
||||
expect(captured.videoGenerationProviders.map((provider) => provider.id)).toEqual(["deepinfra"]);
|
||||
});
|
||||
|
||||
it("uses profile-resolved API keys for live text catalog discovery", async () => {
|
||||
resetDeepInfraModelCacheForTest();
|
||||
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry()] }));
|
||||
const captured = createCapturedPluginRegistration();
|
||||
deepinfraPlugin.register(captured.api);
|
||||
const provider = captured.providers[0];
|
||||
if (!provider?.catalog) {
|
||||
throw new Error("expected DeepInfra provider registration");
|
||||
}
|
||||
const catalog = provider.catalog;
|
||||
|
||||
await withLiveDiscoveryTestEnv(mockFetch, async () => {
|
||||
const result = await catalog.run(buildDeepInfraCatalogContext());
|
||||
if (!result || !("provider" in result)) {
|
||||
throw new Error("expected single-provider DeepInfra catalog result");
|
||||
}
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(DEEPINFRA_MODELS_URL);
|
||||
expect(result?.provider.apiKey).toBe("profile-key");
|
||||
expect(result.provider.models.map((model) => model.id)).toEqual([
|
||||
"profile/live-model",
|
||||
...DEEPINFRA_MODEL_CATALOG.map((model) => model.id),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deepinfra isCacheTtlEligible", () => {
|
||||
it("returns true for anthropic/* proxied models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
|
||||
expect(
|
||||
provider.isCacheTtlEligible?.({
|
||||
provider: "deepinfra",
|
||||
modelId: "anthropic/claude-4-sonnet",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Locked to case-insensitive to stay consistent with the shared proxy cache
|
||||
// wrapper, which lowercases the modelId before the "anthropic/" prefix check.
|
||||
it("returns true regardless of modelId case", async () => {
|
||||
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
|
||||
expect(
|
||||
provider.isCacheTtlEligible?.({
|
||||
provider: "deepinfra",
|
||||
modelId: "Anthropic/Claude-4-Sonnet",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
provider.isCacheTtlEligible?.({
|
||||
provider: "deepinfra",
|
||||
modelId: "ANTHROPIC/claude-4-sonnet",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-anthropic models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
|
||||
expect(
|
||||
provider.isCacheTtlEligible?.({
|
||||
provider: "deepinfra",
|
||||
modelId: "meta-llama/Llama-4-Scout-17B-16E-Instruct",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
provider.isCacheTtlEligible?.({
|
||||
provider: "deepinfra",
|
||||
modelId: "zai-org/GLM-5.1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
156
extensions/deepinfra/index.ts
Normal file
156
extensions/deepinfra/index.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// Deepinfra plugin entrypoint registers its OpenClaw integration.
|
||||
import {
|
||||
type ProviderCatalogContext,
|
||||
type ConfiguredProviderCatalogEntry,
|
||||
readConfiguredProviderCatalogEntries,
|
||||
} from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { PASSTHROUGH_GEMINI_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
createOpenRouterWrapper,
|
||||
isProxyReasoningUnsupported,
|
||||
} from "openclaw/plugin-sdk/provider-stream";
|
||||
import { createDeepInfraAnthropicCacheWrapper } from "./cache-wrapper.js";
|
||||
import { buildDeepInfraImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import { buildDeepInfraMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import { buildDeepInfraMemoryEmbeddingAdapter } from "./memory-embedding-adapter.js";
|
||||
import { applyDeepInfraConfig } from "./onboard.js";
|
||||
import { buildDeepInfraApiKeyCatalog, buildStaticDeepInfraProvider } from "./provider-catalog.js";
|
||||
import {
|
||||
DEEPINFRA_DEFAULT_MODEL_REF,
|
||||
discoverDeepInfraModels,
|
||||
getDeepInfraSurfaceFallbackCatalog,
|
||||
hasDeepInfraApiKey,
|
||||
} from "./provider-models.js";
|
||||
import { buildDeepInfraSpeechProvider } from "./speech-provider.js";
|
||||
import {
|
||||
listDeepInfraImageGenCatalog,
|
||||
listDeepInfraVideoGenCatalog,
|
||||
} from "./surface-model-catalogs.js";
|
||||
import { buildDeepInfraVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
const PROVIDER_ID = "deepinfra";
|
||||
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "DeepInfra Provider",
|
||||
description: "Bundled DeepInfra provider plugin",
|
||||
provider: {
|
||||
label: "DeepInfra",
|
||||
docsPath: "/providers/deepinfra",
|
||||
auth: [
|
||||
{
|
||||
methodId: "api-key",
|
||||
label: "DeepInfra API key",
|
||||
hint: "Unified API for open source models",
|
||||
optionKey: "deepinfraApiKey",
|
||||
flagName: "--deepinfra-api-key",
|
||||
envVar: "DEEPINFRA_API_KEY",
|
||||
promptMessage: "Enter DeepInfra API key",
|
||||
noteTitle: "DeepInfra",
|
||||
noteMessage: [
|
||||
"DeepInfra provides an OpenAI-compatible API for open source and frontier models.",
|
||||
"Get your API key at: https://deepinfra.com/dash/api_keys",
|
||||
].join("\n"),
|
||||
defaultModel: DEEPINFRA_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyDeepInfraConfig(cfg),
|
||||
wizard: {
|
||||
choiceId: "deepinfra-api-key",
|
||||
choiceLabel: "DeepInfra API key",
|
||||
choiceHint: "Unified API for open source models",
|
||||
groupId: PROVIDER_ID,
|
||||
groupLabel: "DeepInfra",
|
||||
groupHint: "Unified API for open source models",
|
||||
},
|
||||
},
|
||||
],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: (ctx: ProviderCatalogContext) => buildDeepInfraApiKeyCatalog(ctx),
|
||||
staticRun: async () => ({ provider: buildStaticDeepInfraProvider() }),
|
||||
},
|
||||
augmentModelCatalog: async ({ config, env, agentDir }) => {
|
||||
const configured = readConfiguredProviderCatalogEntries({
|
||||
config,
|
||||
providerId: PROVIDER_ID,
|
||||
});
|
||||
// Gate dynamic discovery on the user having configured a DeepInfra API
|
||||
// key (env var, config SecretInput, or auth-profile store).
|
||||
// Pre-auth flows keep the curated manifest fallback so the model picker
|
||||
// stays tight and startup stays offline-friendly.
|
||||
const hasApiKey = hasDeepInfraApiKey({ env, agentDir, config });
|
||||
const seen = new Set(configured.map((entry) => entry.id));
|
||||
const discovered = await discoverDeepInfraModels({ hasApiKey, env, agentDir });
|
||||
const merged: ConfiguredProviderCatalogEntry[] = [...configured];
|
||||
for (const model of discovered) {
|
||||
if (seen.has(model.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(model.id);
|
||||
const input = model.input;
|
||||
merged.push({
|
||||
provider: PROVIDER_ID,
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
...(typeof model.contextWindow === "number" && model.contextWindow > 0
|
||||
? { contextWindow: model.contextWindow }
|
||||
: {}),
|
||||
...(typeof model.reasoning === "boolean" ? { reasoning: model.reasoning } : {}),
|
||||
...(input && input.length > 0 ? { input } : {}),
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
normalizeConfig: ({ providerConfig }) => providerConfig,
|
||||
normalizeTransport: ({ api, baseUrl }) =>
|
||||
baseUrl === "https://api.deepinfra.com/v1/openai" ? { api, baseUrl } : undefined,
|
||||
...PASSTHROUGH_GEMINI_REPLAY_HOOKS,
|
||||
wrapStreamFn: (ctx) => {
|
||||
const thinkingLevel = isProxyReasoningUnsupported(ctx.modelId)
|
||||
? undefined
|
||||
: ctx.thinkingLevel;
|
||||
// OpenRouter wrapper handles reasoning normalization for proxy-style
|
||||
// providers; layer DeepInfra's anthropic cache-marker wrapper on top so
|
||||
// anthropic/* requests carry the ephemeral cache_control markers that
|
||||
// the upstream OpenRouter-only wrapper skips.
|
||||
return createDeepInfraAnthropicCacheWrapper(
|
||||
createOpenRouterWrapper(ctx.streamFn, thinkingLevel),
|
||||
);
|
||||
},
|
||||
isModernModelRef: () => true,
|
||||
isCacheTtlEligible: (ctx) => ctx.modelId.toLowerCase().startsWith("anthropic/"),
|
||||
},
|
||||
register(api) {
|
||||
// Single source for media defaults at register time; image-gen and
|
||||
// video-gen also get a live registerModelCatalogProvider that refreshes
|
||||
// from the agent endpoint when a key is configured (OpenRouter pattern).
|
||||
// TTS/STT/VLM/embed stay static until UnifiedModelCatalogKind covers them.
|
||||
const catalog = getDeepInfraSurfaceFallbackCatalog();
|
||||
api.registerImageGenerationProvider(
|
||||
buildDeepInfraImageGenerationProvider({ imageGenModels: catalog.imageGen }),
|
||||
);
|
||||
api.registerModelCatalogProvider({
|
||||
provider: PROVIDER_ID,
|
||||
kinds: ["image_generation"],
|
||||
liveCatalog: listDeepInfraImageGenCatalog,
|
||||
});
|
||||
api.registerMediaUnderstandingProvider(
|
||||
buildDeepInfraMediaUnderstandingProvider({
|
||||
vlmModels: catalog.vlm,
|
||||
sttModels: catalog.stt,
|
||||
}),
|
||||
);
|
||||
api.registerMemoryEmbeddingProvider(
|
||||
buildDeepInfraMemoryEmbeddingAdapter({ embedModels: catalog.embed }),
|
||||
);
|
||||
api.registerSpeechProvider(buildDeepInfraSpeechProvider({ ttsModels: catalog.tts }));
|
||||
api.registerVideoGenerationProvider(
|
||||
buildDeepInfraVideoGenerationProvider({ videoGenModels: catalog.videoGen }),
|
||||
);
|
||||
api.registerModelCatalogProvider({
|
||||
provider: PROVIDER_ID,
|
||||
kinds: ["video_generation"],
|
||||
liveCatalog: listDeepInfraVideoGenCatalog,
|
||||
});
|
||||
},
|
||||
});
|
||||
55
extensions/deepinfra/media-models.ts
Normal file
55
extensions/deepinfra/media-models.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Deepinfra plugin module implements media models behavior.
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { DEEPINFRA_BASE_URL } from "./provider-models.js";
|
||||
|
||||
export { DEEPINFRA_BASE_URL };
|
||||
|
||||
export const DEEPINFRA_NATIVE_BASE_URL = "https://api.deepinfra.com/v1/inference";
|
||||
|
||||
// Structural capability shapes — not model IDs.
|
||||
export const DEFAULT_DEEPINFRA_IMAGE_SIZE = "1024x1024";
|
||||
export const DEFAULT_DEEPINFRA_TTS_VOICE = "af_bella";
|
||||
export const DEEPINFRA_VIDEO_ASPECT_RATIOS = ["16:9", "4:3", "1:1", "3:4", "9:16"] as const;
|
||||
export const DEEPINFRA_VIDEO_DURATIONS = [5, 8] as const;
|
||||
|
||||
// Per-surface fallback lists — used when no discovered/static catalog is
|
||||
// supplied. First entry is the default. Prefer discoverDeepInfraSurfaces().
|
||||
export const DEEPINFRA_IMAGE_FALLBACK_MODELS = [
|
||||
"black-forest-labs/FLUX-1-schnell",
|
||||
"run-diffusion/Juggernaut-Lightning-Flux",
|
||||
"black-forest-labs/FLUX-1-dev",
|
||||
"Qwen/Qwen-Image-Max",
|
||||
"stabilityai/sdxl-turbo",
|
||||
] as const;
|
||||
|
||||
export const DEEPINFRA_TTS_FALLBACK_MODELS = [
|
||||
"hexgrad/Kokoro-82M",
|
||||
"Qwen/Qwen3-TTS",
|
||||
"ResembleAI/chatterbox-turbo",
|
||||
"sesame/csm-1b",
|
||||
] as const;
|
||||
|
||||
export const DEEPINFRA_VIDEO_FALLBACK_MODELS = [
|
||||
"Pixverse/Pixverse-T2V",
|
||||
"Pixverse/Pixverse-T2V-HD",
|
||||
"Wan-AI/Wan2.6-T2V",
|
||||
"google/veo-3.1-fast",
|
||||
] as const;
|
||||
|
||||
export const DEEPINFRA_STT_FALLBACK_MODELS = [
|
||||
"openai/whisper-large-v3-turbo",
|
||||
"openai/whisper-large-v3",
|
||||
] as const;
|
||||
|
||||
export const DEEPINFRA_EMBED_FALLBACK_MODELS = ["BAAI/bge-m3"] as const;
|
||||
|
||||
export const DEEPINFRA_VLM_FALLBACK_MODELS = ["moonshotai/Kimi-K2.5"] as const;
|
||||
|
||||
export function normalizeDeepInfraModelRef(model: string | undefined, fallback: string): string {
|
||||
const value = normalizeOptionalString(model) ?? fallback;
|
||||
return value.startsWith("deepinfra/") ? value.slice("deepinfra/".length) : value;
|
||||
}
|
||||
|
||||
export function normalizeDeepInfraBaseUrl(value: unknown, fallback = DEEPINFRA_BASE_URL): string {
|
||||
return (normalizeOptionalString(value) ?? fallback).replace(/\/+$/u, "");
|
||||
}
|
||||
74
extensions/deepinfra/media-understanding-provider.test.ts
Normal file
74
extensions/deepinfra/media-understanding-provider.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// Deepinfra tests cover media understanding provider plugin behavior.
|
||||
import {
|
||||
describeImagesWithModel,
|
||||
describeImageWithModel,
|
||||
} from "openclaw/plugin-sdk/media-understanding";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
deepinfraMediaUnderstandingProvider,
|
||||
transcribeDeepInfraAudio,
|
||||
} from "./media-understanding-provider.js";
|
||||
|
||||
const { transcribeOpenAiCompatibleAudioMock } = vi.hoisted(() => ({
|
||||
transcribeOpenAiCompatibleAudioMock: vi.fn(async () => ({ text: "hello", model: "whisper" })),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-understanding", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/media-understanding")>(
|
||||
"openclaw/plugin-sdk/media-understanding",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
transcribeOpenAiCompatibleAudio: transcribeOpenAiCompatibleAudioMock,
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/media-understanding");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("deepinfra media understanding provider", () => {
|
||||
it("declares image and audio defaults", () => {
|
||||
expect(deepinfraMediaUnderstandingProvider).toEqual({
|
||||
id: "deepinfra",
|
||||
capabilities: ["image", "audio"],
|
||||
defaultModels: {
|
||||
image: "moonshotai/Kimi-K2.5",
|
||||
audio: "openai/whisper-large-v3-turbo",
|
||||
},
|
||||
autoPriority: {
|
||||
image: 45,
|
||||
audio: 45,
|
||||
},
|
||||
transcribeAudio: transcribeDeepInfraAudio,
|
||||
describeImage: describeImageWithModel,
|
||||
describeImages: describeImagesWithModel,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes audio transcription through the OpenAI-compatible DeepInfra endpoint", async () => {
|
||||
const buffer = Buffer.from("audio");
|
||||
const result = await transcribeDeepInfraAudio({
|
||||
buffer,
|
||||
fileName: "clip.mp3",
|
||||
apiKey: "deepinfra-key",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ text: "hello", model: "whisper" });
|
||||
expect(transcribeOpenAiCompatibleAudioMock.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
buffer,
|
||||
fileName: "clip.mp3",
|
||||
apiKey: "deepinfra-key",
|
||||
timeoutMs: 30_000,
|
||||
provider: "deepinfra",
|
||||
defaultBaseUrl: "https://api.deepinfra.com/v1/openai",
|
||||
defaultModel: "openai/whisper-large-v3-turbo",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
57
extensions/deepinfra/media-understanding-provider.ts
Normal file
57
extensions/deepinfra/media-understanding-provider.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Deepinfra provider module implements model/runtime integration.
|
||||
import {
|
||||
describeImageWithModel,
|
||||
describeImagesWithModel,
|
||||
transcribeOpenAiCompatibleAudio,
|
||||
type AudioTranscriptionRequest,
|
||||
type MediaUnderstandingProvider,
|
||||
} from "openclaw/plugin-sdk/media-understanding";
|
||||
import {
|
||||
DEEPINFRA_BASE_URL,
|
||||
DEEPINFRA_STT_FALLBACK_MODELS,
|
||||
DEEPINFRA_VLM_FALLBACK_MODELS,
|
||||
} from "./media-models.js";
|
||||
import type { DeepInfraSurfaceModel } from "./provider-models.js";
|
||||
|
||||
function resolveDefault(
|
||||
surfaceModels: readonly DeepInfraSurfaceModel[] | undefined,
|
||||
fallback: readonly string[],
|
||||
): string {
|
||||
const first = surfaceModels?.[0]?.id;
|
||||
return first ?? fallback[0] ?? "";
|
||||
}
|
||||
|
||||
export async function transcribeDeepInfraAudio(params: AudioTranscriptionRequest) {
|
||||
return await transcribeOpenAiCompatibleAudio({
|
||||
...params,
|
||||
provider: "deepinfra",
|
||||
defaultBaseUrl: DEEPINFRA_BASE_URL,
|
||||
defaultModel: resolveDefault(undefined, DEEPINFRA_STT_FALLBACK_MODELS),
|
||||
});
|
||||
}
|
||||
|
||||
// First entries of vlmModels / sttModels become the image / audio defaults.
|
||||
export function buildDeepInfraMediaUnderstandingProvider(options?: {
|
||||
vlmModels?: readonly DeepInfraSurfaceModel[];
|
||||
sttModels?: readonly DeepInfraSurfaceModel[];
|
||||
}): MediaUnderstandingProvider {
|
||||
return {
|
||||
id: "deepinfra",
|
||||
capabilities: ["image", "audio"],
|
||||
defaultModels: {
|
||||
image: resolveDefault(options?.vlmModels, DEEPINFRA_VLM_FALLBACK_MODELS),
|
||||
audio: resolveDefault(options?.sttModels, DEEPINFRA_STT_FALLBACK_MODELS),
|
||||
},
|
||||
autoPriority: {
|
||||
image: 45,
|
||||
audio: 45,
|
||||
},
|
||||
transcribeAudio: transcribeDeepInfraAudio,
|
||||
describeImage: describeImageWithModel,
|
||||
describeImages: describeImagesWithModel,
|
||||
};
|
||||
}
|
||||
|
||||
// Back-compat const for callers not yet on the builder. Static fallback only.
|
||||
export const deepinfraMediaUnderstandingProvider: MediaUnderstandingProvider =
|
||||
buildDeepInfraMediaUnderstandingProvider();
|
||||
32
extensions/deepinfra/memory-embedding-adapter.test.ts
Normal file
32
extensions/deepinfra/memory-embedding-adapter.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Deepinfra tests cover memory embedding adapter plugin behavior.
|
||||
import { isMissingEmbeddingApiKeyError } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_DEEPINFRA_EMBEDDING_MODEL } from "./embedding-provider.js";
|
||||
import { deepinfraMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
|
||||
|
||||
describe("deepinfra memory embedding adapter", () => {
|
||||
it("declares a remote auth-backed embedding provider", () => {
|
||||
expect(Object.keys(deepinfraMemoryEmbeddingProviderAdapter)).toEqual([
|
||||
"id",
|
||||
"defaultModel",
|
||||
"transport",
|
||||
"authProviderId",
|
||||
"autoSelectPriority",
|
||||
"allowExplicitWhenConfiguredAuto",
|
||||
"shouldContinueAutoSelection",
|
||||
"create",
|
||||
]);
|
||||
expect(deepinfraMemoryEmbeddingProviderAdapter.id).toBe("deepinfra");
|
||||
expect(deepinfraMemoryEmbeddingProviderAdapter.defaultModel).toBe(
|
||||
DEFAULT_DEEPINFRA_EMBEDDING_MODEL,
|
||||
);
|
||||
expect(deepinfraMemoryEmbeddingProviderAdapter.transport).toBe("remote");
|
||||
expect(deepinfraMemoryEmbeddingProviderAdapter.authProviderId).toBe("deepinfra");
|
||||
expect(deepinfraMemoryEmbeddingProviderAdapter.autoSelectPriority).toBe(55);
|
||||
expect(deepinfraMemoryEmbeddingProviderAdapter.allowExplicitWhenConfiguredAuto).toBe(true);
|
||||
expect(deepinfraMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection).toBe(
|
||||
isMissingEmbeddingApiKeyError,
|
||||
);
|
||||
expect(deepinfraMemoryEmbeddingProviderAdapter.create).toBeTypeOf("function");
|
||||
});
|
||||
});
|
||||
48
extensions/deepinfra/memory-embedding-adapter.ts
Normal file
48
extensions/deepinfra/memory-embedding-adapter.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Deepinfra plugin module implements memory embedding adapter behavior.
|
||||
import {
|
||||
isMissingEmbeddingApiKeyError,
|
||||
type MemoryEmbeddingProviderAdapter,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import {
|
||||
createDeepInfraEmbeddingProvider,
|
||||
DEFAULT_DEEPINFRA_EMBEDDING_MODEL,
|
||||
} from "./embedding-provider.js";
|
||||
import type { DeepInfraSurfaceModel } from "./provider-models.js";
|
||||
|
||||
// First entry of embedModels becomes the default embedding model.
|
||||
export function buildDeepInfraMemoryEmbeddingAdapter(options?: {
|
||||
embedModels?: readonly DeepInfraSurfaceModel[];
|
||||
}): MemoryEmbeddingProviderAdapter {
|
||||
const defaultModel = options?.embedModels?.[0]?.id ?? DEFAULT_DEEPINFRA_EMBEDDING_MODEL;
|
||||
return {
|
||||
id: "deepinfra",
|
||||
defaultModel,
|
||||
transport: "remote",
|
||||
authProviderId: "deepinfra",
|
||||
autoSelectPriority: 55,
|
||||
allowExplicitWhenConfiguredAuto: true,
|
||||
shouldContinueAutoSelection: isMissingEmbeddingApiKeyError,
|
||||
create: async (createOptions) => {
|
||||
const { provider, client } = await createDeepInfraEmbeddingProvider({
|
||||
...createOptions,
|
||||
provider: "deepinfra",
|
||||
fallback: "none",
|
||||
defaultModel,
|
||||
});
|
||||
return {
|
||||
provider,
|
||||
runtime: {
|
||||
id: "deepinfra",
|
||||
cacheKeyData: {
|
||||
provider: "deepinfra",
|
||||
model: client.model,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Back-compat const for callers not yet on the builder.
|
||||
export const deepinfraMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter =
|
||||
buildDeepInfraMemoryEmbeddingAdapter();
|
||||
12
extensions/deepinfra/npm-shrinkwrap.json
generated
Normal file
12
extensions/deepinfra/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/deepinfra-provider",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/deepinfra-provider",
|
||||
"version": "2026.6.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
141
extensions/deepinfra/onboard.test.ts
Normal file
141
extensions/deepinfra/onboard.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// Deepinfra tests cover onboard plugin behavior.
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
type OpenClawConfig,
|
||||
resolveAgentModelPrimaryValue,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { captureEnv } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyDeepInfraConfig,
|
||||
DEEPINFRA_BASE_URL,
|
||||
DEEPINFRA_DEFAULT_MODEL_REF,
|
||||
} from "./onboard.js";
|
||||
import { DEEPINFRA_DEFAULT_MODEL_ID } from "./provider-models.js";
|
||||
|
||||
const { resolveEnvApiKey } = providerAuth;
|
||||
|
||||
const emptyCfg: OpenClawConfig = {};
|
||||
|
||||
describe("DeepInfra provider config", () => {
|
||||
describe("constants", () => {
|
||||
it("DEEPINFRA_BASE_URL points to deepinfra openai endpoint", () => {
|
||||
expect(DEEPINFRA_BASE_URL).toBe("https://api.deepinfra.com/v1/openai");
|
||||
});
|
||||
|
||||
it("DEEPINFRA_DEFAULT_MODEL_REF includes provider prefix", () => {
|
||||
expect(DEEPINFRA_DEFAULT_MODEL_REF).toBe("deepinfra/deepseek-ai/DeepSeek-V4-Flash");
|
||||
});
|
||||
|
||||
it("DEEPINFRA_DEFAULT_MODEL_ID is deepseek-ai/DeepSeek-V4-Flash", () => {
|
||||
expect(DEEPINFRA_DEFAULT_MODEL_ID).toBe("deepseek-ai/DeepSeek-V4-Flash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDeepInfraConfig", () => {
|
||||
it("sets the provided model ref as the primary default", () => {
|
||||
const result = applyDeepInfraConfig(emptyCfg, DEEPINFRA_DEFAULT_MODEL_REF);
|
||||
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe(
|
||||
DEEPINFRA_DEFAULT_MODEL_REF,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets the DeepInfra alias on the provided ref", () => {
|
||||
const result = applyDeepInfraConfig(emptyCfg, DEEPINFRA_DEFAULT_MODEL_REF);
|
||||
const agentModel = result.agents?.defaults?.models?.[DEEPINFRA_DEFAULT_MODEL_REF];
|
||||
expect(agentModel?.alias).toBe("DeepInfra");
|
||||
});
|
||||
|
||||
it("honors a fallback ref when discovery picked a non-default model", () => {
|
||||
const fallbackRef = "deepinfra/other/awesome-model";
|
||||
const result = applyDeepInfraConfig(emptyCfg, fallbackRef);
|
||||
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe(fallbackRef);
|
||||
expect(result.agents?.defaults?.models?.[fallbackRef]?.alias).toBe("DeepInfra");
|
||||
});
|
||||
|
||||
it("preserves an existing alias on the selected model", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[DEEPINFRA_DEFAULT_MODEL_REF]: { alias: "My Custom Alias" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = applyDeepInfraConfig(cfg, DEEPINFRA_DEFAULT_MODEL_REF);
|
||||
expect(result.agents?.defaults?.models?.[DEEPINFRA_DEFAULT_MODEL_REF]?.alias).toBe(
|
||||
"My Custom Alias",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("env var resolution", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("resolves DEEPINFRA_API_KEY from env", () => {
|
||||
const envSnapshot = captureEnv(["DEEPINFRA_API_KEY"]);
|
||||
process.env.DEEPINFRA_API_KEY = "test-deepinfra-key";
|
||||
|
||||
try {
|
||||
const result = resolveEnvApiKey("deepinfra");
|
||||
expect(result?.apiKey).toBe("test-deepinfra-key");
|
||||
expect(result?.source.endsWith("DEEPINFRA_API_KEY")).toBe(true);
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when DEEPINFRA_API_KEY is not set", () => {
|
||||
const envSnapshot = captureEnv(["DEEPINFRA_API_KEY"]);
|
||||
delete process.env.DEEPINFRA_API_KEY;
|
||||
|
||||
try {
|
||||
const result = resolveEnvApiKey("deepinfra");
|
||||
expect(result).toBeNull();
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the deepinfra api key via resolveApiKeyForProvider", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const envSnapshot = captureEnv(["DEEPINFRA_API_KEY"]);
|
||||
process.env.DEEPINFRA_API_KEY = "deepinfra-provider-test-key";
|
||||
|
||||
const spy = vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "deepinfra-provider-test-key",
|
||||
source: "env: DEEPINFRA_API_KEY",
|
||||
mode: "api-key",
|
||||
});
|
||||
|
||||
try {
|
||||
const auth = await providerAuth.resolveApiKeyForProvider({
|
||||
provider: "deepinfra",
|
||||
agentDir,
|
||||
});
|
||||
|
||||
expect(spy.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
provider: "deepinfra",
|
||||
agentDir,
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(auth).toEqual({
|
||||
apiKey: "deepinfra-provider-test-key",
|
||||
source: "env: DEEPINFRA_API_KEY",
|
||||
mode: "api-key",
|
||||
});
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
30
extensions/deepinfra/onboard.ts
Normal file
30
extensions/deepinfra/onboard.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Deepinfra setup module handles plugin onboarding behavior.
|
||||
import {
|
||||
applyAgentDefaultModelPrimary,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { DEEPINFRA_BASE_URL, DEEPINFRA_DEFAULT_MODEL_REF } from "./provider-models.js";
|
||||
|
||||
export { DEEPINFRA_BASE_URL, DEEPINFRA_DEFAULT_MODEL_REF };
|
||||
|
||||
export function applyDeepInfraConfig(
|
||||
cfg: OpenClawConfig,
|
||||
modelRef: string = DEEPINFRA_DEFAULT_MODEL_REF,
|
||||
): OpenClawConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[modelRef] = {
|
||||
...models[modelRef],
|
||||
alias: models[modelRef]?.alias ?? "DeepInfra",
|
||||
};
|
||||
|
||||
return applyAgentDefaultModelPrimary({
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
}, modelRef);
|
||||
}
|
||||
224
extensions/deepinfra/openclaw.plugin.json
Normal file
224
extensions/deepinfra/openclaw.plugin.json
Normal file
@@ -0,0 +1,224 @@
|
||||
{
|
||||
"id": "deepinfra",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["deepinfra"],
|
||||
"providerCatalogEntry": "./provider-discovery.ts",
|
||||
"providerAuthEnvVars": {
|
||||
"deepinfra": ["DEEPINFRA_API_KEY"]
|
||||
},
|
||||
"providerEndpoints": [
|
||||
{
|
||||
"endpointClass": "deepinfra-native",
|
||||
"hosts": ["api.deepinfra.com"]
|
||||
}
|
||||
],
|
||||
"providerRequest": {
|
||||
"providers": {
|
||||
"deepinfra": {
|
||||
"family": "deepinfra"
|
||||
}
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "deepinfra",
|
||||
"authMethods": ["api-key"],
|
||||
"envVars": ["DEEPINFRA_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"modelCatalog": {
|
||||
"providers": {
|
||||
"deepinfra": {
|
||||
"baseUrl": "https://api.deepinfra.com/v1/openai",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "deepseek-ai/DeepSeek-V4-Flash",
|
||||
"name": "DeepSeek V4 Flash",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 1048576,
|
||||
"maxTokens": 1048576,
|
||||
"cost": {
|
||||
"input": 0.1,
|
||||
"output": 0.2,
|
||||
"cacheRead": 0.02,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"compat": {
|
||||
"supportsUsageInStreaming": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "deepseek-ai/DeepSeek-V3.2",
|
||||
"name": "DeepSeek V3.2",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"contextWindow": 163840,
|
||||
"maxTokens": 163840,
|
||||
"cost": {
|
||||
"input": 0.26,
|
||||
"output": 0.38,
|
||||
"cacheRead": 0.13,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"compat": {
|
||||
"supportsUsageInStreaming": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "zai-org/GLM-5.1",
|
||||
"name": "GLM-5.1",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 202752,
|
||||
"maxTokens": 202752,
|
||||
"cost": {
|
||||
"input": 1.05,
|
||||
"output": 3.5,
|
||||
"cacheRead": 0.205000005,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"compat": {
|
||||
"supportsUsageInStreaming": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "stepfun-ai/Step-3.5-Flash",
|
||||
"name": "Step 3.5 Flash",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 262144,
|
||||
"cost": {
|
||||
"input": 0.1,
|
||||
"output": 0.3,
|
||||
"cacheRead": 0.02,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"compat": {
|
||||
"supportsUsageInStreaming": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "MiniMaxAI/MiniMax-M2.5",
|
||||
"name": "MiniMax M2.5",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 196608,
|
||||
"maxTokens": 196608,
|
||||
"cost": {
|
||||
"input": 0.15,
|
||||
"output": 1.15,
|
||||
"cacheRead": 0.03,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"compat": {
|
||||
"supportsUsageInStreaming": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "moonshotai/Kimi-K2.5",
|
||||
"name": "Kimi K2.5",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 262144,
|
||||
"cost": {
|
||||
"input": 0.45,
|
||||
"output": 2.25,
|
||||
"cacheRead": 0.070000002,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"compat": {
|
||||
"supportsUsageInStreaming": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B",
|
||||
"name": "NVIDIA Nemotron 3 Super 120B A12B",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 262144,
|
||||
"cost": {
|
||||
"input": 0.1,
|
||||
"output": 0.5,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"compat": {
|
||||
"supportsUsageInStreaming": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"name": "Llama 3.3 70B Instruct Turbo",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"contextWindow": 131072,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 0.1,
|
||||
"output": 0.32,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"compat": {
|
||||
"supportsUsageInStreaming": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"deepinfra": "refreshable"
|
||||
}
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "deepinfra",
|
||||
"method": "api-key",
|
||||
"choiceId": "deepinfra-api-key",
|
||||
"choiceLabel": "DeepInfra API key",
|
||||
"choiceHint": "Unified API for open source models",
|
||||
"groupId": "deepinfra",
|
||||
"groupLabel": "DeepInfra",
|
||||
"groupHint": "Unified API for open source models",
|
||||
"optionKey": "deepinfraApiKey",
|
||||
"cliFlag": "--deepinfra-api-key",
|
||||
"cliOption": "--deepinfra-api-key <key>",
|
||||
"cliDescription": "DeepInfra API key"
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["deepinfra"],
|
||||
"memoryEmbeddingProviders": ["deepinfra"],
|
||||
"imageGenerationProviders": ["deepinfra"],
|
||||
"speechProviders": ["deepinfra"],
|
||||
"videoGenerationProviders": ["deepinfra"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"deepinfra": {
|
||||
"capabilities": ["image", "audio"],
|
||||
"defaultModels": {
|
||||
"image": "moonshotai/Kimi-K2.5",
|
||||
"audio": "openai/whisper-large-v3-turbo"
|
||||
},
|
||||
"autoPriority": {
|
||||
"image": 45,
|
||||
"audio": 45
|
||||
}
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
35
extensions/deepinfra/package.json
Normal file
35
extensions/deepinfra/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@openclaw/deepinfra-provider",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw DeepInfra provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/deepinfra-provider",
|
||||
"npmSpec": "@openclaw/deepinfra-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
51
extensions/deepinfra/provider-catalog.ts
Normal file
51
extensions/deepinfra/provider-catalog.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Deepinfra provider module implements model/runtime integration.
|
||||
import {
|
||||
buildSingleProviderApiKeyCatalog,
|
||||
type ProviderCatalogContext,
|
||||
type ProviderCatalogResult,
|
||||
} from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
DEEPINFRA_BASE_URL,
|
||||
DEEPINFRA_MODEL_CATALOG,
|
||||
buildDeepInfraModelDefinition,
|
||||
discoverDeepInfraModels,
|
||||
} from "./provider-models.js";
|
||||
|
||||
export function buildStaticDeepInfraProvider(): ModelProviderConfig {
|
||||
return {
|
||||
baseUrl: DEEPINFRA_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: DEEPINFRA_MODEL_CATALOG.map(buildDeepInfraModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildDeepInfraProvider(options?: {
|
||||
hasApiKey?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
agentDir?: string;
|
||||
}): Promise<ModelProviderConfig> {
|
||||
const models = await discoverDeepInfraModels(options);
|
||||
return {
|
||||
baseUrl: DEEPINFRA_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDeepInfraApiKeyCatalog(
|
||||
ctx: ProviderCatalogContext,
|
||||
): Promise<ProviderCatalogResult> {
|
||||
return buildSingleProviderApiKeyCatalog({
|
||||
ctx,
|
||||
providerId: "deepinfra",
|
||||
// The shared API-key helper already resolved env/profile credentials.
|
||||
// Pass that fact into discovery so profile-only setups get the live catalog.
|
||||
buildProvider: () =>
|
||||
buildDeepInfraProvider({
|
||||
hasApiKey: true,
|
||||
env: ctx.env,
|
||||
agentDir: ctx.agentDir,
|
||||
}),
|
||||
});
|
||||
}
|
||||
25
extensions/deepinfra/provider-discovery.ts
Normal file
25
extensions/deepinfra/provider-discovery.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Deepinfra provider module implements model/runtime integration.
|
||||
import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { buildDeepInfraApiKeyCatalog, buildStaticDeepInfraProvider } from "./provider-catalog.js";
|
||||
|
||||
const PROVIDER_ID = "deepinfra";
|
||||
|
||||
const deepinfraProviderDiscovery: ProviderPlugin = {
|
||||
id: PROVIDER_ID,
|
||||
label: "DeepInfra",
|
||||
docsPath: "/providers/deepinfra",
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: (ctx: ProviderCatalogContext) => buildDeepInfraApiKeyCatalog(ctx),
|
||||
},
|
||||
staticCatalog: {
|
||||
order: "simple",
|
||||
run: async () => ({
|
||||
provider: buildStaticDeepInfraProvider(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default deepinfraProviderDiscovery;
|
||||
522
extensions/deepinfra/provider-models.test.ts
Normal file
522
extensions/deepinfra/provider-models.test.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
// Deepinfra tests cover provider models plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isProviderApiKeyConfiguredMock = vi.hoisted(() => vi.fn<(p: unknown) => boolean>());
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-auth")>(
|
||||
"openclaw/plugin-sdk/provider-auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
isProviderApiKeyConfigured: isProviderApiKeyConfiguredMock,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
DEEPINFRA_MODELS_URL,
|
||||
DEEPINFRA_DEFAULT_MODEL_REF,
|
||||
DEEPINFRA_MODEL_CATALOG,
|
||||
discoverDeepInfraModels,
|
||||
discoverDeepInfraSurfaces,
|
||||
hasDeepInfraApiKey,
|
||||
resetDeepInfraModelCacheForTest,
|
||||
} from "./provider-models.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetDeepInfraModelCacheForTest();
|
||||
isProviderApiKeyConfiguredMock.mockReset();
|
||||
isProviderApiKeyConfiguredMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
function makeAgentModelEntry(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "openai/gpt-oss-120b",
|
||||
object: "model",
|
||||
owned_by: "deepinfra",
|
||||
metadata: {
|
||||
description: "gpt-oss-120b",
|
||||
context_length: 131072,
|
||||
max_tokens: 65536,
|
||||
pricing: {
|
||||
input_tokens: 3,
|
||||
output_tokens: 15,
|
||||
cache_read_tokens: 0.3,
|
||||
},
|
||||
tags: ["chat", "vlm", "vision", "reasoning_effort", "prompt_cache", "reasoning"],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function expectedStaticChatCatalog() {
|
||||
return DEEPINFRA_MODEL_CATALOG.map((model) => {
|
||||
const compat = Object.assign({}, model.compat, {
|
||||
supportsUsageInStreaming: model.compat?.supportsUsageInStreaming ?? true,
|
||||
});
|
||||
return Object.assign({}, model, { compat });
|
||||
});
|
||||
}
|
||||
|
||||
function expectedLiveChatCatalog(liveModels: ReturnType<typeof expectedStaticChatCatalog>) {
|
||||
const liveIds = new Set(liveModels.map((model) => model.id));
|
||||
return [...liveModels, ...expectedStaticChatCatalog().filter((model) => !liveIds.has(model.id))];
|
||||
}
|
||||
|
||||
async function withFetchPathTest(
|
||||
mockFetch: ReturnType<typeof vi.fn>,
|
||||
envOverrides: Record<string, string | undefined>,
|
||||
runAssertions: () => Promise<void>,
|
||||
) {
|
||||
const env = { ...process.env };
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.VITEST;
|
||||
for (const [key, value] of Object.entries(envOverrides)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
Reflect.set(process.env, key, value);
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
try {
|
||||
await runAssertions();
|
||||
} finally {
|
||||
for (const key of Object.keys(envOverrides)) {
|
||||
if (env[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
Reflect.set(process.env, key, env[key]);
|
||||
}
|
||||
}
|
||||
if (env.NODE_ENV !== undefined) {
|
||||
process.env.NODE_ENV = env.NODE_ENV;
|
||||
}
|
||||
if (env.VITEST !== undefined) {
|
||||
process.env.VITEST = env.VITEST;
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
}
|
||||
|
||||
function requireFirstFetchCall(mockFetch: ReturnType<typeof vi.fn>): [unknown, unknown] {
|
||||
const [call] = mockFetch.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected DeepInfra models fetch call");
|
||||
}
|
||||
return call as [unknown, unknown];
|
||||
}
|
||||
|
||||
describe("DEEPINFRA_MODELS_URL", () => {
|
||||
it("points at /v1/openai/models with the openclaw sort + filter=with_meta gate", () => {
|
||||
expect(DEEPINFRA_MODELS_URL).toBe(
|
||||
"https://api.deepinfra.com/v1/openai/models?sort_by=openclaw&filter=with_meta",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDeepInfraApiKey", () => {
|
||||
it("returns true via env var, false on missing / blank", () => {
|
||||
expect(hasDeepInfraApiKey({ env: { DEEPINFRA_API_KEY: "sk-x" } })).toBe(true);
|
||||
expect(hasDeepInfraApiKey({ env: { DEEPINFRA_API_KEY: "" } })).toBe(false);
|
||||
expect(hasDeepInfraApiKey({ env: { DEEPINFRA_API_KEY: " " } })).toBe(false);
|
||||
expect(hasDeepInfraApiKey({ env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the auth-profile store when no env var is set", () => {
|
||||
isProviderApiKeyConfiguredMock.mockReturnValue(true);
|
||||
|
||||
expect(hasDeepInfraApiKey({ env: {}, agentDir: "/tmp/openclaw-agent" })).toBe(true);
|
||||
|
||||
expect(isProviderApiKeyConfiguredMock).toHaveBeenCalledTimes(1);
|
||||
expect(isProviderApiKeyConfiguredMock).toHaveBeenCalledWith({
|
||||
provider: "deepinfra",
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts config-backed provider API keys before probing the profile store", () => {
|
||||
expect(
|
||||
hasDeepInfraApiKey({
|
||||
env: {},
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
deepinfra: {
|
||||
apiKey: { source: "env", provider: "default", id: "CUSTOM_DEEPINFRA_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(isProviderApiKeyConfiguredMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits on env var and skips the profile-store probe", () => {
|
||||
isProviderApiKeyConfiguredMock.mockReturnValue(true);
|
||||
|
||||
expect(
|
||||
hasDeepInfraApiKey({
|
||||
env: { DEEPINFRA_API_KEY: "sk-x" },
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(isProviderApiKeyConfiguredMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when env is empty and the auth-profile store has no deepinfra profile", () => {
|
||||
isProviderApiKeyConfiguredMock.mockReturnValue(false);
|
||||
|
||||
expect(hasDeepInfraApiKey({ env: {}, agentDir: "/tmp/openclaw-agent" })).toBe(false);
|
||||
|
||||
expect(isProviderApiKeyConfiguredMock).toHaveBeenCalledWith({
|
||||
provider: "deepinfra",
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverDeepInfraModels (chat-only shim)", () => {
|
||||
it("returns static catalog in test environment", async () => {
|
||||
const models = await discoverDeepInfraModels();
|
||||
const modelIds = models.map((m) => m.id);
|
||||
const streamingUsageIncompatibleModelIds = models
|
||||
.filter((m) => !m.compat?.supportsUsageInStreaming)
|
||||
.map((m) => m.id);
|
||||
|
||||
expect(DEEPINFRA_DEFAULT_MODEL_REF).toBe("deepinfra/deepseek-ai/DeepSeek-V4-Flash");
|
||||
expect(models).toStrictEqual(expectedStaticChatCatalog());
|
||||
expect(modelIds).toStrictEqual(expectedStaticChatCatalog().map((model) => model.id));
|
||||
expect(streamingUsageIncompatibleModelIds).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("fetches the openclaw-projection endpoint and parses chat-surface entries when an API key is configured", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry()] }));
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const models = await discoverDeepInfraModels();
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const [fetchUrl, fetchInit] = requireFirstFetchCall(mockFetch);
|
||||
const fetchSignal = Reflect.get(fetchInit ?? {}, "signal");
|
||||
const fetchHeaders = Reflect.get(fetchInit ?? {}, "headers");
|
||||
expect(fetchUrl).toBe(DEEPINFRA_MODELS_URL);
|
||||
expect(fetchSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(fetchHeaders).toBeInstanceOf(Headers);
|
||||
expect((fetchHeaders as Headers).get("Accept")).toBe("application/json");
|
||||
expect(models).toEqual(
|
||||
expectedLiveChatCatalog([
|
||||
{
|
||||
id: "openai/gpt-oss-120b",
|
||||
name: "openai/gpt-oss-120b",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 65536,
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 },
|
||||
compat: { supportsUsageInStreaming: true },
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips entries with no metadata or no surface tag, and deduplicates ids", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
{ id: "BAAI/bge-m3", object: "model", metadata: null },
|
||||
makeAgentModelEntry({
|
||||
id: "untagged/model",
|
||||
metadata: { context_length: 1, max_tokens: 1, pricing: {}, tags: [] },
|
||||
}),
|
||||
makeAgentModelEntry(),
|
||||
makeAgentModelEntry(),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const models = await discoverDeepInfraModels();
|
||||
expect(models.map((m) => m.id)).toEqual(
|
||||
expectedLiveChatCatalog([
|
||||
{
|
||||
id: "openai/gpt-oss-120b",
|
||||
name: "openai/gpt-oss-120b",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 65536,
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 },
|
||||
compat: { supportsUsageInStreaming: true },
|
||||
},
|
||||
]).map((model) => model.id),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the static catalog when no API key is configured (skips network entirely)", async () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: undefined }, async () => {
|
||||
const models = await discoverDeepInfraModels();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(models.map((m) => m.id)).toEqual(expectedStaticChatCatalog().map((model) => model.id));
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the static catalog on network errors", async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error("network error"));
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const models = await discoverDeepInfraModels();
|
||||
expect(models.map((m) => m.id)).toEqual(expectedStaticChatCatalog().map((model) => model.id));
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the static catalog on non-2xx HTTP responses", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const models = await discoverDeepInfraModels();
|
||||
expect(models.map((m) => m.id)).toEqual(expectedStaticChatCatalog().map((model) => model.id));
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back without caching malformed successful model list payloads", async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ data: {} }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
|
||||
);
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(
|
||||
expectedStaticChatCatalog().map((model) => model.id),
|
||||
);
|
||||
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(
|
||||
expectedLiveChatCatalog([
|
||||
{
|
||||
id: "recovered/model",
|
||||
name: "recovered/model",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 65536,
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 },
|
||||
compat: { supportsUsageInStreaming: true },
|
||||
},
|
||||
]).map((model) => model.id),
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("caches successful discovery responses only", async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ data: [makeAgentModelEntry({ id: "first/model" })] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: [makeAgentModelEntry({ id: "second/model" })] }));
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const expectedIds = expectedLiveChatCatalog([
|
||||
{
|
||||
id: "first/model",
|
||||
name: "first/model",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 65536,
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 },
|
||||
compat: { supportsUsageInStreaming: true },
|
||||
},
|
||||
]).map((model) => model.id);
|
||||
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(expectedIds);
|
||||
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(expectedIds);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not cache successful responses that produce no live catalog rows", async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ data: [] }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
|
||||
);
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(
|
||||
expectedStaticChatCatalog().map((model) => model.id),
|
||||
);
|
||||
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(
|
||||
expectedLiveChatCatalog([
|
||||
{
|
||||
id: "recovered/model",
|
||||
name: "recovered/model",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 65536,
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 },
|
||||
compat: { supportsUsageInStreaming: true },
|
||||
},
|
||||
]).map((model) => model.id),
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverDeepInfraSurfaces (per-surface bucketing)", () => {
|
||||
it("buckets dynamic entries by short-alias surface tag", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
makeAgentModelEntry({
|
||||
id: "anthropic/claude-sonnet-4-6",
|
||||
metadata: {
|
||||
description: "claude sonnet 4.6",
|
||||
context_length: 200000,
|
||||
max_tokens: 8192,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
tags: ["chat", "vlm", "vision", "prompt_cache"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "BAAI/bge-m3",
|
||||
metadata: {
|
||||
description: "bge-m3",
|
||||
pricing: { input_tokens: 0.01 },
|
||||
tags: ["embed"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "black-forest-labs/FLUX-1-schnell",
|
||||
metadata: {
|
||||
description: "FLUX schnell",
|
||||
pricing: { per_image_unit: 0.003 },
|
||||
tags: ["image-gen"],
|
||||
default_width: 1024,
|
||||
default_height: 1024,
|
||||
default_iterations: 4,
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "Wan-AI/Wan2.6-T2V",
|
||||
metadata: {
|
||||
description: "Wan T2V",
|
||||
pricing: { output_seconds: 0.05 },
|
||||
tags: ["video-gen"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "Qwen/Qwen3-TTS",
|
||||
metadata: {
|
||||
description: "Qwen3 TTS",
|
||||
pricing: { input_characters: 0.65 },
|
||||
tags: ["tts"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "openai/whisper-large-v3-turbo",
|
||||
metadata: {
|
||||
description: "whisper",
|
||||
pricing: { input_seconds: 0.00004 },
|
||||
tags: ["stt"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const catalog = await discoverDeepInfraSurfaces();
|
||||
expect(catalog.live).toBe(true);
|
||||
expect(catalog.chat.map((m) => m.id)).toEqual(["anthropic/claude-sonnet-4-6"]);
|
||||
expect(catalog.vlm.map((m) => m.id)).toEqual(["anthropic/claude-sonnet-4-6"]);
|
||||
expect(catalog.embed.map((m) => m.id)).toEqual(["BAAI/bge-m3"]);
|
||||
expect(catalog.imageGen.map((m) => m.id)).toEqual(["black-forest-labs/FLUX-1-schnell"]);
|
||||
expect(catalog.imageGen[0]?.defaultWidth).toBe(1024);
|
||||
expect(catalog.imageGen[0]?.pricing.per_image_unit).toBe(0.003);
|
||||
expect(catalog.videoGen.map((m) => m.id)).toEqual(["Wan-AI/Wan2.6-T2V"]);
|
||||
expect(catalog.tts.map((m) => m.id)).toEqual(["Qwen/Qwen3-TTS"]);
|
||||
expect(catalog.stt.map((m) => m.id)).toEqual(["openai/whisper-large-v3-turbo"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("drops malformed live numeric metadata", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
makeAgentModelEntry({
|
||||
id: "bad/chat",
|
||||
metadata: {
|
||||
description: "bad chat",
|
||||
context_length: -1,
|
||||
max_tokens: 1.5,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
tags: ["chat"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "bad/image",
|
||||
metadata: {
|
||||
description: "bad image",
|
||||
pricing: { per_image_unit: 0.003 },
|
||||
tags: ["image-gen"],
|
||||
default_width: Number.POSITIVE_INFINITY,
|
||||
default_height: 1024.5,
|
||||
default_iterations: 0,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const catalog = await discoverDeepInfraSurfaces();
|
||||
|
||||
expect(catalog.chat[0]).toMatchObject({ id: "bad/chat" });
|
||||
expect(catalog.chat[0]?.contextWindow).toBeUndefined();
|
||||
expect(catalog.chat[0]?.maxTokens).toBeUndefined();
|
||||
expect(catalog.imageGen[0]).toMatchObject({ id: "bad/image" });
|
||||
expect(catalog.imageGen[0]?.defaultWidth).toBeUndefined();
|
||||
expect(catalog.imageGen[0]?.defaultHeight).toBeUndefined();
|
||||
expect(catalog.imageGen[0]?.defaultIterations).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the manifest static fallback (live=false) when no API key is configured", async () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: undefined }, async () => {
|
||||
const catalog = await discoverDeepInfraSurfaces();
|
||||
expect(catalog.live).toBe(false);
|
||||
expect(catalog.chat.length).toBeGreaterThan(0);
|
||||
// Non-chat surfaces in the static fallback live in TS constants because
|
||||
// the manifest schema only validates chat-shaped rows.
|
||||
expect(catalog.imageGen.map((m) => m.id)).toContain("black-forest-labs/FLUX-1-schnell");
|
||||
expect(catalog.tts.map((m) => m.id)).toContain("Qwen/Qwen3-TTS");
|
||||
expect(catalog.stt.map((m) => m.id)).toContain("openai/whisper-large-v3-turbo");
|
||||
// No static video-gen fallback — live discovery picks up text-to-video
|
||||
// models when the backend tags them. The live-discovery test above
|
||||
// covers the video-gen bucketing path.
|
||||
expect(catalog.videoGen).toEqual([]);
|
||||
expect(catalog.embed.map((m) => m.id)).toContain("BAAI/bge-m3");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
475
extensions/deepinfra/provider-models.ts
Normal file
475
extensions/deepinfra/provider-models.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
// Deepinfra provider module implements model/runtime integration.
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
clearLiveCatalogCacheForTests,
|
||||
getCachedLiveProviderModelRows,
|
||||
LiveModelCatalogHttpError,
|
||||
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
|
||||
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import { asPositiveSafeInteger } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
const log = createSubsystemLogger("deepinfra-models");
|
||||
|
||||
const DEEPINFRA_MANIFEST_PROVIDER = buildManifestModelProviderConfig({
|
||||
providerId: "deepinfra",
|
||||
catalog: manifest.modelCatalog.providers.deepinfra,
|
||||
});
|
||||
|
||||
export const DEEPINFRA_BASE_URL = DEEPINFRA_MANIFEST_PROVIDER.baseUrl;
|
||||
export const DEEPINFRA_MODELS_URL = `${DEEPINFRA_BASE_URL}/models?sort_by=openclaw&filter=with_meta`;
|
||||
|
||||
export const DEEPINFRA_DEFAULT_MODEL_ID = "deepseek-ai/DeepSeek-V4-Flash";
|
||||
export const DEEPINFRA_DEFAULT_MODEL_REF = `deepinfra/${DEEPINFRA_DEFAULT_MODEL_ID}`;
|
||||
|
||||
const DEEPINFRA_DEFAULT_CONTEXT_WINDOW = 128000;
|
||||
const DEEPINFRA_DEFAULT_MAX_TOKENS = 8192;
|
||||
|
||||
export const DEEPINFRA_MODEL_CATALOG: ModelDefinitionConfig[] = DEEPINFRA_MANIFEST_PROVIDER.models;
|
||||
|
||||
const DISCOVERY_TIMEOUT_MS = 5000;
|
||||
const DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
type DeepInfraAuthConfig = {
|
||||
secrets?: { defaults?: { env?: string; file?: string; exec?: string } };
|
||||
models?: { providers?: Record<string, { apiKey?: unknown } | undefined> };
|
||||
};
|
||||
|
||||
// Wire format — mirrors deepapi/agent_models_api.AgentOpenAIModelsOut.
|
||||
interface DeepInfraAgentModelPricing {
|
||||
// chat / vlm / embed
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cache_read_tokens?: number;
|
||||
// image-gen
|
||||
per_image_unit?: number;
|
||||
// video-gen
|
||||
output_seconds?: number;
|
||||
// tts
|
||||
input_characters?: number;
|
||||
// stt
|
||||
input_seconds?: number;
|
||||
}
|
||||
|
||||
interface DeepInfraAgentModelMetadata {
|
||||
description?: string;
|
||||
context_length?: number | null;
|
||||
max_tokens?: number | null;
|
||||
pricing?: DeepInfraAgentModelPricing;
|
||||
tags?: string[];
|
||||
default_width?: number | null;
|
||||
default_height?: number | null;
|
||||
default_iterations?: number | null;
|
||||
}
|
||||
|
||||
interface DeepInfraAgentModelEntry {
|
||||
id: string;
|
||||
metadata: DeepInfraAgentModelMetadata | null;
|
||||
}
|
||||
|
||||
export type DeepInfraSurface = "chat" | "vlm" | "embed" | "image-gen" | "video-gen" | "tts" | "stt";
|
||||
|
||||
export interface DeepInfraSurfaceModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
pricing: DeepInfraAgentModelPricing;
|
||||
defaultWidth?: number;
|
||||
defaultHeight?: number;
|
||||
defaultIterations?: number;
|
||||
}
|
||||
|
||||
export interface DeepInfraDiscoveredCatalog {
|
||||
chat: DeepInfraSurfaceModel[];
|
||||
vlm: DeepInfraSurfaceModel[];
|
||||
embed: DeepInfraSurfaceModel[];
|
||||
imageGen: DeepInfraSurfaceModel[];
|
||||
videoGen: DeepInfraSurfaceModel[];
|
||||
tts: DeepInfraSurfaceModel[];
|
||||
stt: DeepInfraSurfaceModel[];
|
||||
/** True iff served from a successful live fetch; false for the static fallback. */
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
export function resetDeepInfraModelCacheForTest(): void {
|
||||
clearLiveCatalogCacheForTests();
|
||||
}
|
||||
|
||||
const SURFACE_FOR_TAG: Record<string, DeepInfraSurface> = {
|
||||
chat: "chat",
|
||||
vlm: "vlm",
|
||||
embed: "embed",
|
||||
"image-gen": "image-gen",
|
||||
"video-gen": "video-gen",
|
||||
tts: "tts",
|
||||
stt: "stt",
|
||||
};
|
||||
|
||||
function entryToSurfaceModel(entry: DeepInfraAgentModelEntry): DeepInfraSurfaceModel | null {
|
||||
const id = typeof entry?.id === "string" ? entry.id.trim() : "";
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const metadata = entry.metadata;
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
const tags = Array.isArray(metadata.tags)
|
||||
? metadata.tags.filter((t): t is string => typeof t === "string")
|
||||
: [];
|
||||
const pricing: DeepInfraAgentModelPricing = metadata.pricing ?? {};
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: metadata.description ?? undefined,
|
||||
tags,
|
||||
contextWindow: asPositiveSafeInteger(metadata.context_length),
|
||||
maxTokens: asPositiveSafeInteger(metadata.max_tokens),
|
||||
pricing,
|
||||
defaultWidth: asPositiveSafeInteger(metadata.default_width),
|
||||
defaultHeight: asPositiveSafeInteger(metadata.default_height),
|
||||
defaultIterations: asPositiveSafeInteger(metadata.default_iterations),
|
||||
};
|
||||
}
|
||||
|
||||
function bucketBySurface(models: DeepInfraSurfaceModel[]): DeepInfraDiscoveredCatalog {
|
||||
const catalog: DeepInfraDiscoveredCatalog = {
|
||||
chat: [],
|
||||
vlm: [],
|
||||
embed: [],
|
||||
imageGen: [],
|
||||
videoGen: [],
|
||||
tts: [],
|
||||
stt: [],
|
||||
live: true,
|
||||
};
|
||||
const buckets: Record<DeepInfraSurface, DeepInfraSurfaceModel[]> = {
|
||||
chat: catalog.chat,
|
||||
vlm: catalog.vlm,
|
||||
embed: catalog.embed,
|
||||
"image-gen": catalog.imageGen,
|
||||
"video-gen": catalog.videoGen,
|
||||
tts: catalog.tts,
|
||||
stt: catalog.stt,
|
||||
};
|
||||
for (const model of models) {
|
||||
const seen = new Set<DeepInfraSurface>();
|
||||
for (const tag of model.tags) {
|
||||
const surface = SURFACE_FOR_TAG[tag];
|
||||
if (surface && !seen.has(surface)) {
|
||||
seen.add(surface);
|
||||
buckets[surface].push(model);
|
||||
}
|
||||
}
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
function hasDeepInfraSurfaceModelRows(rows: readonly unknown[]): boolean {
|
||||
return rows.some((entry) => entryToSurfaceModel(entry as DeepInfraAgentModelEntry) !== null);
|
||||
}
|
||||
|
||||
// Static fallback. Chat rows live in openclaw.plugin.json (manifest-validated);
|
||||
// non-chat surfaces live below because the manifest validator only accepts
|
||||
// chat-shaped rows. These are used pre-auth / offline; live discovery
|
||||
// overrides once a key is configured.
|
||||
interface ManifestChatModelEntry {
|
||||
id: string;
|
||||
name?: string;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
reasoning?: boolean;
|
||||
input?: Array<"text" | "image">;
|
||||
cost?: { input?: number; output?: number; cacheRead?: number };
|
||||
}
|
||||
|
||||
function manifestChatEntryToSurfaceModel(entry: ManifestChatModelEntry): DeepInfraSurfaceModel {
|
||||
const cost = entry.cost ?? {};
|
||||
const pricing: DeepInfraAgentModelPricing = {};
|
||||
if (typeof cost.input === "number") {
|
||||
pricing.input_tokens = cost.input;
|
||||
}
|
||||
if (typeof cost.output === "number") {
|
||||
pricing.output_tokens = cost.output;
|
||||
}
|
||||
if (typeof cost.cacheRead === "number" && cost.cacheRead > 0) {
|
||||
pricing.cache_read_tokens = cost.cacheRead;
|
||||
}
|
||||
const tags: string[] = ["chat"];
|
||||
if (entry.input?.includes("image")) {
|
||||
tags.push("vlm");
|
||||
}
|
||||
if (entry.reasoning) {
|
||||
tags.push("reasoning");
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name ?? entry.id,
|
||||
tags,
|
||||
contextWindow: entry.contextWindow,
|
||||
maxTokens: entry.maxTokens,
|
||||
pricing,
|
||||
};
|
||||
}
|
||||
|
||||
// Per-surface static fallback used only when no API key is configured or
|
||||
// live discovery fails. Kept deliberately minimal: the dynamic
|
||||
// `/v1/openai/models?sort_by=openclaw&filter=with_meta` projection is the
|
||||
// real source of truth (140 tagged rows today), so every retired model
|
||||
// removed from the DeepInfra catalog disappears here automatically the
|
||||
// next time discovery runs. Newer entries — additional image-gen models,
|
||||
// video-gen models, additional TTS voices — arrive through discovery
|
||||
// without a code change.
|
||||
//
|
||||
// Every entry below is verified against the live catalog at the time of
|
||||
// addition; entries are not pinned to historical shipped models if the
|
||||
// upstream provider has retired them (e.g. `run-diffusion/Juggernaut-
|
||||
// Lightning-Flux` was removed from DeepInfra and is therefore not listed
|
||||
// even though earlier main releases shipped it as a fallback).
|
||||
const STATIC_NON_CHAT_FALLBACK: DeepInfraSurfaceModel[] = [
|
||||
// image-gen — representative subset of currently-served models.
|
||||
{
|
||||
id: "black-forest-labs/FLUX-1-schnell",
|
||||
name: "black-forest-labs/FLUX-1-schnell",
|
||||
tags: ["image-gen"],
|
||||
pricing: { per_image_unit: 0.003 },
|
||||
defaultWidth: 1024,
|
||||
defaultHeight: 1024,
|
||||
defaultIterations: 4,
|
||||
},
|
||||
{
|
||||
id: "black-forest-labs/FLUX-1-dev",
|
||||
name: "black-forest-labs/FLUX-1-dev",
|
||||
tags: ["image-gen"],
|
||||
pricing: { per_image_unit: 0.025 },
|
||||
defaultWidth: 1024,
|
||||
defaultHeight: 1024,
|
||||
defaultIterations: 28,
|
||||
},
|
||||
{
|
||||
id: "Qwen/Qwen-Image-Max",
|
||||
name: "Qwen/Qwen-Image-Max",
|
||||
tags: ["image-gen"],
|
||||
pricing: { per_image_unit: 0.075 },
|
||||
defaultWidth: 1024,
|
||||
defaultHeight: 1024,
|
||||
defaultIterations: 28,
|
||||
},
|
||||
{
|
||||
id: "stabilityai/sdxl-turbo",
|
||||
name: "stabilityai/sdxl-turbo",
|
||||
tags: ["image-gen"],
|
||||
pricing: { per_image_unit: 0.0002 },
|
||||
defaultWidth: 1024,
|
||||
defaultHeight: 1024,
|
||||
defaultIterations: 4,
|
||||
},
|
||||
// video-gen — DeepInfra has no live video-gen catalog rows today;
|
||||
// intentionally empty here. Live discovery picks up text-to-video
|
||||
// models as soon as the backend tags them, no static row required.
|
||||
// tts — Kokoro first so the shipped default voice (af_bella) pairs with
|
||||
// the chosen default model; the rest are alternative TTS providers
|
||||
// currently served by DeepInfra. Qwen3-TTS / chatterbox-turbo / csm-1b
|
||||
// each require their own voice; they ship as discoverable alternatives,
|
||||
// not the implicit default.
|
||||
{
|
||||
id: "hexgrad/Kokoro-82M",
|
||||
name: "hexgrad/Kokoro-82M",
|
||||
tags: ["tts"],
|
||||
pricing: { input_characters: 0.65 },
|
||||
},
|
||||
{
|
||||
id: "Qwen/Qwen3-TTS",
|
||||
name: "Qwen/Qwen3-TTS",
|
||||
tags: ["tts"],
|
||||
pricing: { input_characters: 0.65 },
|
||||
},
|
||||
{
|
||||
id: "ResembleAI/chatterbox-turbo",
|
||||
name: "ResembleAI/chatterbox-turbo",
|
||||
tags: ["tts"],
|
||||
pricing: { input_characters: 1 },
|
||||
},
|
||||
{
|
||||
id: "sesame/csm-1b",
|
||||
name: "sesame/csm-1b",
|
||||
tags: ["tts"],
|
||||
pricing: { input_characters: 7 },
|
||||
},
|
||||
// stt
|
||||
{
|
||||
id: "openai/whisper-large-v3-turbo",
|
||||
name: "openai/whisper-large-v3-turbo",
|
||||
tags: ["stt"],
|
||||
pricing: { input_seconds: 0.00004 },
|
||||
},
|
||||
// embed
|
||||
{
|
||||
id: "BAAI/bge-m3",
|
||||
name: "BAAI/bge-m3",
|
||||
tags: ["embed"],
|
||||
pricing: { input_tokens: 0.01 },
|
||||
maxTokens: 8192,
|
||||
contextWindow: 8192,
|
||||
},
|
||||
];
|
||||
|
||||
function manifestFallbackCatalog(): DeepInfraDiscoveredCatalog {
|
||||
const rawChat = (manifest.modelCatalog.providers.deepinfra.models ??
|
||||
[]) as ManifestChatModelEntry[];
|
||||
const chatModels = rawChat.map(manifestChatEntryToSurfaceModel);
|
||||
const catalog = bucketBySurface([...chatModels, ...STATIC_NON_CHAT_FALLBACK]);
|
||||
catalog.live = false;
|
||||
return catalog;
|
||||
}
|
||||
|
||||
// Sync per-surface fallback for the (sync) register callback. Media providers
|
||||
// register with these defaults; live discovery feeds the chat surface via
|
||||
// augmentModelCatalog and the catalog seams for image/video-gen.
|
||||
export function getDeepInfraSurfaceFallbackCatalog(): DeepInfraDiscoveredCatalog {
|
||||
return manifestFallbackCatalog();
|
||||
}
|
||||
|
||||
export function buildDeepInfraModelDefinition(model: ModelDefinitionConfig): ModelDefinitionConfig {
|
||||
return {
|
||||
...model,
|
||||
compat: {
|
||||
...model.compat,
|
||||
supportsUsageInStreaming: model.compat?.supportsUsageInStreaming ?? true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function chatSurfaceModelToModelDefinition(model: DeepInfraSurfaceModel): ModelDefinitionConfig {
|
||||
const input: Array<"text" | "image"> = model.tags.includes("vlm") ? ["text", "image"] : ["text"];
|
||||
const reasoning = model.tags.includes("reasoning") || model.tags.includes("reasoning_effort");
|
||||
return buildDeepInfraModelDefinition({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
reasoning,
|
||||
input,
|
||||
contextWindow: model.contextWindow ?? DEEPINFRA_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: model.maxTokens ?? DEEPINFRA_DEFAULT_MAX_TOKENS,
|
||||
cost: {
|
||||
input: model.pricing.input_tokens ?? 0,
|
||||
output: model.pricing.output_tokens ?? 0,
|
||||
cacheRead: model.pricing.cache_read_tokens ?? 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Gate dynamic discovery on key presence: pre-auth keeps the picker tight and
|
||||
// avoids a useless network call. The endpoint itself is unauthenticated.
|
||||
// Accepts env-var keys and auth-profile-store keys via the shared
|
||||
// `isProviderApiKeyConfigured` helper (covers SecretRef / `OPENCLAW_LIVE_*`
|
||||
// indirection too).
|
||||
export function hasDeepInfraApiKey(options?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
agentDir?: string;
|
||||
config?: DeepInfraAuthConfig;
|
||||
}): boolean {
|
||||
const env = options?.env ?? process.env;
|
||||
const fromEnv = env.DEEPINFRA_API_KEY;
|
||||
if (typeof fromEnv === "string" && fromEnv.trim() !== "") {
|
||||
return true;
|
||||
}
|
||||
const providers = options?.config?.models?.providers;
|
||||
for (const [providerId, provider] of Object.entries(providers ?? {})) {
|
||||
if (
|
||||
providerId.trim().toLowerCase() === "deepinfra" &&
|
||||
hasConfiguredSecretInput(provider?.apiKey, options?.config?.secrets?.defaults)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return isProviderApiKeyConfigured({ provider: "deepinfra", agentDir: options?.agentDir });
|
||||
}
|
||||
|
||||
// Discover the per-surface catalog. Falls back to the static manifest when
|
||||
// no key, fetch fails, or running under Vitest. 5-minute cache on success.
|
||||
export async function discoverDeepInfraSurfaces(options?: {
|
||||
hasApiKey?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
agentDir?: string;
|
||||
}): Promise<DeepInfraDiscoveredCatalog> {
|
||||
if (process.env.NODE_ENV === "test" || process.env.VITEST) {
|
||||
return manifestFallbackCatalog();
|
||||
}
|
||||
|
||||
const env = options?.env ?? process.env;
|
||||
const hasKey = options?.hasApiKey ?? hasDeepInfraApiKey({ env, agentDir: options?.agentDir });
|
||||
if (!hasKey) {
|
||||
return manifestFallbackCatalog();
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getCachedLiveProviderModelRows({
|
||||
providerId: "deepinfra",
|
||||
endpoint: DEEPINFRA_MODELS_URL,
|
||||
timeoutMs: DISCOVERY_TIMEOUT_MS,
|
||||
ttlMs: DISCOVERY_CACHE_TTL_MS,
|
||||
buildRequestHeaders: () => ({ Accept: "application/json" }),
|
||||
auditContext: "deepinfra-model-discovery",
|
||||
shouldCacheRows: hasDeepInfraSurfaceModelRows,
|
||||
});
|
||||
if (data.length === 0) {
|
||||
log.warn("No models found from DeepInfra agent-projection endpoint, using static catalog");
|
||||
return manifestFallbackCatalog();
|
||||
}
|
||||
const seenIds = new Set<string>();
|
||||
const surfaceModels: DeepInfraSurfaceModel[] = [];
|
||||
for (const entry of data) {
|
||||
const model = entryToSurfaceModel(entry as DeepInfraAgentModelEntry);
|
||||
if (!model || seenIds.has(model.id)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(model.id);
|
||||
surfaceModels.push(model);
|
||||
}
|
||||
if (surfaceModels.length === 0) {
|
||||
return manifestFallbackCatalog();
|
||||
}
|
||||
return bucketBySurface(surfaceModels);
|
||||
} catch (error) {
|
||||
if (error instanceof LiveModelCatalogHttpError) {
|
||||
log.warn(`Failed to discover models: HTTP ${error.status}, using static catalog`);
|
||||
return manifestFallbackCatalog();
|
||||
}
|
||||
log.warn(`Discovery failed: ${String(error)}, using static catalog`);
|
||||
return manifestFallbackCatalog();
|
||||
}
|
||||
}
|
||||
|
||||
// Chat-only shim for callers that haven't migrated to the per-surface catalog
|
||||
// (provider-catalog.ts, augmentModelCatalog).
|
||||
export async function discoverDeepInfraModels(options?: {
|
||||
hasApiKey?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
agentDir?: string;
|
||||
}): Promise<ModelDefinitionConfig[]> {
|
||||
const catalog = await discoverDeepInfraSurfaces(options);
|
||||
const chatModels = catalog.chat.length > 0 ? catalog.chat : [...catalog.chat, ...catalog.vlm];
|
||||
if (chatModels.length === 0) {
|
||||
// True empty (no manifest entries either) — keep behavior stable.
|
||||
return DEEPINFRA_MODEL_CATALOG.map(buildDeepInfraModelDefinition);
|
||||
}
|
||||
const liveModels = chatModels.map(chatSurfaceModelToModelDefinition);
|
||||
const seen = new Set(liveModels.map((model) => model.id));
|
||||
const manifestModels = DEEPINFRA_MODEL_CATALOG.map(buildDeepInfraModelDefinition).filter(
|
||||
(model) => {
|
||||
if (seen.has(model.id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(model.id);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
return [...liveModels, ...manifestModels];
|
||||
}
|
||||
42
extensions/deepinfra/provider-policy-api.test.ts
Normal file
42
extensions/deepinfra/provider-policy-api.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Deepinfra tests cover provider policy api plugin behavior.
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeConfig } from "./provider-policy-api.js";
|
||||
|
||||
function createModel(id: string): ModelDefinitionConfig {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deepinfra provider policy public artifact", () => {
|
||||
it("preserves the DeepInfra mid-path /v1 baseUrl without appending another /v1", () => {
|
||||
const providerConfig: ModelProviderConfig = {
|
||||
baseUrl: "https://api.deepinfra.com/v1/openai",
|
||||
api: "openai-completions",
|
||||
models: [createModel("zai-org/GLM-5")],
|
||||
};
|
||||
|
||||
const normalized = normalizeConfig({ provider: "deepinfra", providerConfig });
|
||||
|
||||
expect(normalized.baseUrl).toBe("https://api.deepinfra.com/v1/openai");
|
||||
expect(normalized.baseUrl).not.toMatch(/\/v1\/openai\/v1$/);
|
||||
});
|
||||
|
||||
it("returns the providerConfig unchanged (referentially equal)", () => {
|
||||
const providerConfig = {
|
||||
baseUrl: "https://api.deepinfra.com/v1/openai",
|
||||
models: [],
|
||||
};
|
||||
expect(normalizeConfig({ provider: "deepinfra", providerConfig })).toBe(providerConfig);
|
||||
});
|
||||
});
|
||||
22
extensions/deepinfra/provider-policy-api.ts
Normal file
22
extensions/deepinfra/provider-policy-api.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Deepinfra API module exposes the plugin public contract.
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types";
|
||||
|
||||
/**
|
||||
* Passthrough normalization for DeepInfra provider config.
|
||||
*
|
||||
* DeepInfra's OpenAI-compatible base URL is `https://api.deepinfra.com/v1/openai`
|
||||
* with the `/v1` segment mid-path, not at the end. The generic
|
||||
* openai-completions config normalizer strips a trailing `/v1` and re-appends
|
||||
* one, which is idempotent for providers like OpenRouter (`.../api/v1`) but
|
||||
* doubles to `.../v1/openai/v1` here and breaks inference (404).
|
||||
*
|
||||
* Shipping this bundled policy surface short-circuits the fallback normalizer
|
||||
* chain (see `src/plugins/provider-runtime.ts:normalizeProviderConfigWithPlugin`)
|
||||
* and preserves the DeepInfra-declared baseUrl as-is.
|
||||
*/
|
||||
export function normalizeConfig(params: {
|
||||
provider: string;
|
||||
providerConfig: ModelProviderConfig;
|
||||
}): ModelProviderConfig {
|
||||
return params.providerConfig;
|
||||
}
|
||||
4
extensions/deepinfra/provider.contract.test.ts
Normal file
4
extensions/deepinfra/provider.contract.test.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Deepinfra tests cover provider.contract plugin behavior.
|
||||
import { describeProviderContracts } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
|
||||
describeProviderContracts("deepinfra");
|
||||
170
extensions/deepinfra/speech-provider.test.ts
Normal file
170
extensions/deepinfra/speech-provider.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// Deepinfra tests cover speech provider plugin behavior.
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildDeepInfraSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
const {
|
||||
assertOkOrThrowHttpErrorMock,
|
||||
postJsonRequestMock,
|
||||
readProviderBinaryResponseMock,
|
||||
resolveProviderHttpRequestConfigMock,
|
||||
} = vi.hoisted(() => ({
|
||||
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
|
||||
postJsonRequestMock: vi.fn(),
|
||||
readProviderBinaryResponseMock: vi.fn(async (response: Response) => {
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}),
|
||||
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
|
||||
baseUrl: params.baseUrl ?? params.defaultBaseUrl ?? "https://api.deepinfra.com/v1/openai",
|
||||
allowPrivateNetwork: false,
|
||||
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
|
||||
dispatcherPolicy: undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
|
||||
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
|
||||
postJsonRequest: postJsonRequestMock,
|
||||
readProviderBinaryResponse: readProviderBinaryResponseMock,
|
||||
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
|
||||
}));
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/provider-http");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function requireFirstPostJsonRequest(): unknown {
|
||||
const [call] = postJsonRequestMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected DeepInfra speech request");
|
||||
}
|
||||
return call[0];
|
||||
}
|
||||
|
||||
describe("deepinfra speech provider", () => {
|
||||
afterEach(() => {
|
||||
assertOkOrThrowHttpErrorMock.mockClear();
|
||||
postJsonRequestMock.mockReset();
|
||||
readProviderBinaryResponseMock.mockClear();
|
||||
resolveProviderHttpRequestConfigMock.mockClear();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("normalizes provider-owned speech config", () => {
|
||||
const provider = buildDeepInfraSpeechProvider();
|
||||
const resolved = provider.resolveConfig?.({
|
||||
cfg: {} as never,
|
||||
timeoutMs: 30_000,
|
||||
rawConfig: {
|
||||
providers: {
|
||||
deepinfra: {
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.deepinfra.com/v1/openai/",
|
||||
modelId: "deepinfra/hexgrad/Kokoro-82M",
|
||||
voiceId: "af_bella",
|
||||
speed: 1.1,
|
||||
responseFormat: " MP3 ",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.deepinfra.com/v1/openai",
|
||||
model: "hexgrad/Kokoro-82M",
|
||||
voice: "af_bella",
|
||||
speed: 1.1,
|
||||
responseFormat: "mp3",
|
||||
extraBody: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("synthesizes OpenAI-compatible speech through DeepInfra", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
|
||||
release,
|
||||
});
|
||||
|
||||
const provider = buildDeepInfraSpeechProvider();
|
||||
const result = await provider.synthesize({
|
||||
text: "hello",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
deepinfra: {
|
||||
apiKey: "sk-deepinfra",
|
||||
baseUrl: "https://api.deepinfra.com/v1/openai/",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
providerConfig: {
|
||||
model: "hexgrad/Kokoro-82M",
|
||||
voice: "af_bella",
|
||||
speed: 1.2,
|
||||
},
|
||||
target: "voice-note",
|
||||
timeoutMs: 12_345,
|
||||
});
|
||||
|
||||
expect(resolveProviderHttpRequestConfigMock.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
baseUrl: "https://api.deepinfra.com/v1/openai",
|
||||
defaultBaseUrl: "https://api.deepinfra.com/v1/openai",
|
||||
allowPrivateNetwork: false,
|
||||
defaultHeaders: {
|
||||
Authorization: "Bearer sk-deepinfra",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
provider: "deepinfra",
|
||||
capability: "audio",
|
||||
transport: "http",
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(postJsonRequestMock).toHaveBeenCalledOnce();
|
||||
const postRequest = requireFirstPostJsonRequest();
|
||||
const postRequestHeaders = Reflect.get(postRequest ?? {}, "headers");
|
||||
expect(postRequestHeaders).toBeInstanceOf(Headers);
|
||||
expect(Object.fromEntries((postRequestHeaders as Headers).entries())).toEqual({
|
||||
authorization: "Bearer sk-deepinfra",
|
||||
"content-type": "application/json",
|
||||
});
|
||||
expect(postRequest).toEqual({
|
||||
url: "https://api.deepinfra.com/v1/openai/audio/speech",
|
||||
headers: postRequestHeaders,
|
||||
timeoutMs: 12_345,
|
||||
body: {
|
||||
model: "hexgrad/Kokoro-82M",
|
||||
input: "hello",
|
||||
voice: "af_bella",
|
||||
response_format: "mp3",
|
||||
speed: 1.2,
|
||||
},
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork: false,
|
||||
dispatcherPolicy: undefined,
|
||||
});
|
||||
expect(result.audioBuffer).toEqual(Buffer.from([1, 2, 3]));
|
||||
expect(result.outputFormat).toBe("mp3");
|
||||
expect(result.fileExtension).toBe(".mp3");
|
||||
expect(result.voiceCompatible).toBe(true);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses DEEPINFRA_API_KEY when provider config omits apiKey", () => {
|
||||
vi.stubEnv("DEEPINFRA_API_KEY", "sk-env");
|
||||
const provider = buildDeepInfraSpeechProvider();
|
||||
|
||||
expect(
|
||||
provider.isConfigured({
|
||||
cfg: {} as never,
|
||||
providerConfig: {},
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
50
extensions/deepinfra/speech-provider.ts
Normal file
50
extensions/deepinfra/speech-provider.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Deepinfra provider module implements model/runtime integration.
|
||||
import {
|
||||
asObject,
|
||||
createOpenAiCompatibleSpeechProvider,
|
||||
type SpeechProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/speech";
|
||||
import {
|
||||
DEEPINFRA_BASE_URL,
|
||||
DEEPINFRA_TTS_FALLBACK_MODELS,
|
||||
DEFAULT_DEEPINFRA_TTS_VOICE,
|
||||
normalizeDeepInfraModelRef,
|
||||
} from "./media-models.js";
|
||||
import type { DeepInfraSurfaceModel } from "./provider-models.js";
|
||||
|
||||
const DEEPINFRA_TTS_RESPONSE_FORMATS = ["mp3", "opus", "flac", "wav", "pcm"] as const;
|
||||
|
||||
type DeepInfraTtsExtraConfig = {
|
||||
extraBody?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// First entry of ttsModels is the default; rest fill the allowlist.
|
||||
export function buildDeepInfraSpeechProvider(options?: {
|
||||
ttsModels?: readonly DeepInfraSurfaceModel[];
|
||||
}): SpeechProviderPlugin {
|
||||
const ids =
|
||||
options?.ttsModels && options.ttsModels.length > 0
|
||||
? options.ttsModels.map((model) => model.id)
|
||||
: [...DEEPINFRA_TTS_FALLBACK_MODELS];
|
||||
const defaultModel = ids[0] ?? DEEPINFRA_TTS_FALLBACK_MODELS[0];
|
||||
return createOpenAiCompatibleSpeechProvider<DeepInfraTtsExtraConfig>({
|
||||
id: "deepinfra",
|
||||
label: "DeepInfra",
|
||||
autoSelectOrder: 45,
|
||||
models: ids,
|
||||
voices: [DEFAULT_DEEPINFRA_TTS_VOICE],
|
||||
defaultModel,
|
||||
defaultVoice: DEFAULT_DEEPINFRA_TTS_VOICE,
|
||||
defaultBaseUrl: DEEPINFRA_BASE_URL,
|
||||
envKey: "DEEPINFRA_API_KEY",
|
||||
responseFormats: DEEPINFRA_TTS_RESPONSE_FORMATS,
|
||||
defaultResponseFormat: "mp3",
|
||||
voiceCompatibleResponseFormats: ["mp3", "opus"],
|
||||
baseUrlPolicy: { kind: "trim-trailing-slash" },
|
||||
normalizeModel: normalizeDeepInfraModelRef,
|
||||
apiErrorLabel: "DeepInfra TTS API error",
|
||||
missingApiKeyError: "DeepInfra API key missing",
|
||||
readExtraConfig: (raw) => ({ extraBody: asObject(raw?.extraBody) }),
|
||||
extraJsonBodyFields: [{ configKey: "extraBody", requestKey: "extra_body" }],
|
||||
});
|
||||
}
|
||||
273
extensions/deepinfra/surface-model-catalogs.test.ts
Normal file
273
extensions/deepinfra/surface-model-catalogs.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
// Deepinfra tests cover surface model catalogs plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetDeepInfraModelCacheForTest } from "./provider-models.js";
|
||||
import {
|
||||
listDeepInfraImageGenCatalog,
|
||||
listDeepInfraVideoGenCatalog,
|
||||
resolveDeepInfraVideoModelCapabilities,
|
||||
} from "./surface-model-catalogs.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetDeepInfraModelCacheForTest();
|
||||
});
|
||||
|
||||
function makeCtx(overrides: Partial<Parameters<typeof listDeepInfraImageGenCatalog>[0]> = {}) {
|
||||
return {
|
||||
config: {},
|
||||
env: { ...process.env },
|
||||
resolveProviderApiKey: (_id?: string) => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
}),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
mode: "none" as const,
|
||||
source: "none" as const,
|
||||
}),
|
||||
...overrides,
|
||||
} as Parameters<typeof listDeepInfraImageGenCatalog>[0];
|
||||
}
|
||||
|
||||
function withKeyCtx(): Parameters<typeof listDeepInfraImageGenCatalog>[0] {
|
||||
return makeCtx({
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "sk-test",
|
||||
discoveryApiKey: "sk-test",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const surfaceEntry = (id: string, surfaceTag: string, extra: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
object: "model" as const,
|
||||
owned_by: "deepinfra",
|
||||
metadata: {
|
||||
description: id,
|
||||
tags: [surfaceTag],
|
||||
pricing: {},
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function withLiveFetch(mockFetch: ReturnType<typeof vi.fn>, run: () => Promise<void>) {
|
||||
const env = { ...process.env };
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.VITEST;
|
||||
process.env.DEEPINFRA_API_KEY = "sk-test";
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (env.NODE_ENV !== undefined) {
|
||||
process.env.NODE_ENV = env.NODE_ENV;
|
||||
} else {
|
||||
delete process.env.NODE_ENV;
|
||||
}
|
||||
if (env.VITEST !== undefined) {
|
||||
process.env.VITEST = env.VITEST;
|
||||
} else {
|
||||
delete process.env.VITEST;
|
||||
}
|
||||
if (env.DEEPINFRA_API_KEY !== undefined) {
|
||||
process.env.DEEPINFRA_API_KEY = env.DEEPINFRA_API_KEY;
|
||||
} else {
|
||||
delete process.env.DEEPINFRA_API_KEY;
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
}
|
||||
|
||||
describe("DeepInfra generation catalogs", () => {
|
||||
it("return null when no discoveryApiKey is configured", async () => {
|
||||
await expect(listDeepInfraImageGenCatalog(makeCtx())).resolves.toBeNull();
|
||||
await expect(listDeepInfraVideoGenCatalog(makeCtx())).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listDeepInfraImageGenCatalog", () => {
|
||||
it("returns null when live discovery succeeds but the response has zero image-gen entries", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
|
||||
context_length: 200000,
|
||||
max_tokens: 8192,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const result = await listDeepInfraImageGenCatalog(withKeyCtx());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null under VITEST even with a key (static fallback owns offline)", async () => {
|
||||
// The default VITEST env path makes discoverDeepInfraSurfaces emit the
|
||||
// manifest fallback (live=false), and the catalog provider rejects
|
||||
// non-live results so it cannot serve stale offline data as "live".
|
||||
const result = await listDeepInfraImageGenCatalog(withKeyCtx());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("projects discovered image-gen entries when a key is configured and discovery is live", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
|
||||
pricing: { per_image_unit: 0.08 },
|
||||
default_width: 1024,
|
||||
default_height: 1024,
|
||||
default_iterations: 28,
|
||||
}),
|
||||
surfaceEntry("ByteDance/Seedream-4", "image-gen", {
|
||||
pricing: { per_image_unit: 0.03 },
|
||||
}),
|
||||
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
|
||||
context_length: 200000,
|
||||
max_tokens: 8192,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const result = await listDeepInfraImageGenCatalog(withKeyCtx());
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.map((e) => e.model)).toEqual([
|
||||
"black-forest-labs/FLUX-2-pro",
|
||||
"ByteDance/Seedream-4",
|
||||
]);
|
||||
for (const entry of result ?? []) {
|
||||
expect(entry.kind).toBe("image_generation");
|
||||
expect(entry.provider).toBe("deepinfra");
|
||||
expect(entry.source).toBe("live");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("listDeepInfraVideoGenCatalog", () => {
|
||||
it("returns null when live discovery succeeds but the response has zero video-gen entries", async () => {
|
||||
// Current production state: TTS/STT/T2V models lack the OPENAI tag the
|
||||
// backend filter requires, so a key-authenticated discovery still
|
||||
// produces zero video-gen entries. We must return null so the registered
|
||||
// provider's static fallback list is consulted instead of an empty
|
||||
// "live" answer.
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
|
||||
context_length: 200000,
|
||||
max_tokens: 8192,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
}),
|
||||
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
|
||||
pricing: { per_image_unit: 0.08 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const result = await listDeepInfraVideoGenCatalog(withKeyCtx());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("projects discovered video-gen entries with capability shape", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
|
||||
pricing: { output_seconds: 0.05 },
|
||||
}),
|
||||
surfaceEntry("ByteDance/Seedance-2.0", "video-gen", {
|
||||
pricing: { output_seconds: 0.08 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const result = await listDeepInfraVideoGenCatalog(withKeyCtx());
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.map((e) => e.model)).toEqual(["Wan-AI/Wan2.6-T2V", "ByteDance/Seedance-2.0"]);
|
||||
const first = result?.[0];
|
||||
expect(first?.kind).toBe("video_generation");
|
||||
expect(first?.capabilities?.generate?.supportsAspectRatio).toBe(true);
|
||||
expect(first?.capabilities?.generate?.supportedDurationSeconds).toEqual([5, 8]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDeepInfraVideoModelCapabilities", () => {
|
||||
it("returns capabilities for a discovered video-gen model", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
|
||||
pricing: { output_seconds: 0.05 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const caps = await resolveDeepInfraVideoModelCapabilities({
|
||||
model: "Wan-AI/Wan2.6-T2V",
|
||||
} as Parameters<typeof resolveDeepInfraVideoModelCapabilities>[0]);
|
||||
expect(caps).toBeDefined();
|
||||
expect(caps?.generate?.supportsAspectRatio).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("strips the deepinfra/ prefix when matching", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
|
||||
pricing: { output_seconds: 0.05 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const caps = await resolveDeepInfraVideoModelCapabilities({
|
||||
model: "deepinfra/Wan-AI/Wan2.6-T2V",
|
||||
} as Parameters<typeof resolveDeepInfraVideoModelCapabilities>[0]);
|
||||
expect(caps).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown model", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
|
||||
pricing: { output_seconds: 0.05 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const caps = await resolveDeepInfraVideoModelCapabilities({
|
||||
model: "ByteDance/Seedance-2.0",
|
||||
} as Parameters<typeof resolveDeepInfraVideoModelCapabilities>[0]);
|
||||
expect(caps).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
115
extensions/deepinfra/surface-model-catalogs.ts
Normal file
115
extensions/deepinfra/surface-model-catalogs.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Deepinfra plugin module implements surface model catalogs behavior.
|
||||
import type {
|
||||
UnifiedModelCatalogEntry,
|
||||
UnifiedModelCatalogProviderContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type {
|
||||
VideoGenerationModelCapabilitiesContext,
|
||||
VideoGenerationProviderCapabilities,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
import { DEEPINFRA_VIDEO_ASPECT_RATIOS, DEEPINFRA_VIDEO_DURATIONS } from "./media-models.js";
|
||||
import { discoverDeepInfraSurfaces, type DeepInfraSurfaceModel } from "./provider-models.js";
|
||||
|
||||
const PROVIDER_ID = "deepinfra";
|
||||
|
||||
// Live catalog providers (registered via api.registerModelCatalogProvider).
|
||||
// Mirrors extensions/openrouter/video-model-catalog.ts: auth-gated (returns
|
||||
// null without a key so the static fallback wins), and reuses the cached
|
||||
// discoverDeepInfraSurfaces call so chat/image-gen/video-gen share one fetch.
|
||||
|
||||
function surfaceModelToImageGenEntry(model: DeepInfraSurfaceModel): UnifiedModelCatalogEntry {
|
||||
return {
|
||||
kind: "image_generation",
|
||||
provider: PROVIDER_ID,
|
||||
model: model.id,
|
||||
source: "live",
|
||||
...(model.name ? { label: model.name } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function surfaceModelToVideoGenEntry(
|
||||
model: DeepInfraSurfaceModel,
|
||||
): UnifiedModelCatalogEntry<VideoGenerationProviderCapabilities> {
|
||||
return {
|
||||
kind: "video_generation",
|
||||
provider: PROVIDER_ID,
|
||||
model: model.id,
|
||||
source: "live",
|
||||
...(model.name ? { label: model.name } : {}),
|
||||
capabilities: buildDeepInfraVideoModelCapabilities(),
|
||||
};
|
||||
}
|
||||
|
||||
// Canonical DeepInfra-wide video-gen shape. Wire per-model hints
|
||||
// (metadata.supported_durations etc.) in here once the backend emits them.
|
||||
function buildDeepInfraVideoModelCapabilities(): VideoGenerationProviderCapabilities {
|
||||
return {
|
||||
providerOptions: {
|
||||
seed: "number",
|
||||
negative_prompt: "string",
|
||||
negativePrompt: "string",
|
||||
style: "string",
|
||||
guidance_scale: "number",
|
||||
guidanceScale: "number",
|
||||
},
|
||||
generate: {
|
||||
maxVideos: 1,
|
||||
maxDurationSeconds: 8,
|
||||
supportedDurationSeconds: [...DEEPINFRA_VIDEO_DURATIONS],
|
||||
supportsAspectRatio: true,
|
||||
aspectRatios: [...DEEPINFRA_VIDEO_ASPECT_RATIOS],
|
||||
},
|
||||
imageToVideo: { enabled: false },
|
||||
videoToVideo: { enabled: false },
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDeepInfraImageGenCatalog(
|
||||
ctx: UnifiedModelCatalogProviderContext,
|
||||
): Promise<readonly UnifiedModelCatalogEntry[] | null> {
|
||||
const { discoveryApiKey } = ctx.resolveProviderApiKey(PROVIDER_ID);
|
||||
if (!discoveryApiKey) {
|
||||
return null;
|
||||
}
|
||||
const catalog = await discoverDeepInfraSurfaces({ hasApiKey: true, env: ctx.env });
|
||||
// Bail on non-live (static fallback owns offline) and on empty surface
|
||||
// (returning [] would starve the unified catalog instead of falling back).
|
||||
if (!catalog.live || catalog.imageGen.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return catalog.imageGen.map(surfaceModelToImageGenEntry);
|
||||
}
|
||||
|
||||
export async function listDeepInfraVideoGenCatalog(
|
||||
ctx: UnifiedModelCatalogProviderContext,
|
||||
): Promise<readonly UnifiedModelCatalogEntry<VideoGenerationProviderCapabilities>[] | null> {
|
||||
const { discoveryApiKey } = ctx.resolveProviderApiKey(PROVIDER_ID);
|
||||
if (!discoveryApiKey) {
|
||||
return null;
|
||||
}
|
||||
const catalog = await discoverDeepInfraSurfaces({ hasApiKey: true, env: ctx.env });
|
||||
if (!catalog.live || catalog.videoGen.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return catalog.videoGen.map(surfaceModelToVideoGenEntry);
|
||||
}
|
||||
|
||||
// VideoGenerationProvider.resolveModelCapabilities hook. Returns the
|
||||
// capability shape per-request when the model is live; provider static caps
|
||||
// are the fallback.
|
||||
export async function resolveDeepInfraVideoModelCapabilities(
|
||||
ctx: VideoGenerationModelCapabilitiesContext,
|
||||
): Promise<VideoGenerationProviderCapabilities | undefined> {
|
||||
// Model id may arrive bare or `deepinfra/`-prefixed.
|
||||
const rawId = typeof ctx.model === "string" ? ctx.model : "";
|
||||
const normalized = rawId.startsWith(`${PROVIDER_ID}/`)
|
||||
? rawId.slice(PROVIDER_ID.length + 1)
|
||||
: rawId;
|
||||
const catalog = await discoverDeepInfraSurfaces({
|
||||
env: process.env,
|
||||
});
|
||||
const entry =
|
||||
catalog.videoGen.find((m) => m.id === normalized) ??
|
||||
catalog.videoGen.find((m) => m.id === rawId);
|
||||
return entry ? buildDeepInfraVideoModelCapabilities() : undefined;
|
||||
}
|
||||
16
extensions/deepinfra/tsconfig.json
Normal file
16
extensions/deepinfra/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
296
extensions/deepinfra/video-generation-provider.test.ts
Normal file
296
extensions/deepinfra/video-generation-provider.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
// Deepinfra tests cover video generation provider plugin behavior.
|
||||
import {
|
||||
getProviderHttpMocks,
|
||||
installProviderHttpMockCleanup,
|
||||
} from "openclaw/plugin-sdk/provider-http-test-mocks";
|
||||
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { postJsonRequestMock, resolveProviderHttpRequestConfigMock } = getProviderHttpMocks();
|
||||
|
||||
let buildDeepInfraVideoGenerationProvider: typeof import("./video-generation-provider.js").buildDeepInfraVideoGenerationProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ buildDeepInfraVideoGenerationProvider } = await import("./video-generation-provider.js"));
|
||||
});
|
||||
|
||||
installProviderHttpMockCleanup();
|
||||
|
||||
function requireFirstPostJsonRequest(): unknown {
|
||||
const [call] = postJsonRequestMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected DeepInfra video request");
|
||||
}
|
||||
return call[0];
|
||||
}
|
||||
|
||||
describe("deepinfra video generation provider", () => {
|
||||
it("declares explicit mode capabilities", () => {
|
||||
expectExplicitVideoGenerationCapabilities(buildDeepInfraVideoGenerationProvider());
|
||||
});
|
||||
|
||||
it("uses the current DeepInfra text-to-video fallback model first", () => {
|
||||
const provider = buildDeepInfraVideoGenerationProvider();
|
||||
|
||||
expect(provider.defaultModel).toBe("Pixverse/Pixverse-T2V");
|
||||
expect(provider.models?.slice(0, 3)).toEqual([
|
||||
"Pixverse/Pixverse-T2V",
|
||||
"Pixverse/Pixverse-T2V-HD",
|
||||
"Wan-AI/Wan2.6-T2V",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates native text-to-video requests and returns the hosted output URL", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
video_url: "/generated/video.mp4",
|
||||
request_id: "req_123",
|
||||
seed: 42,
|
||||
inference_status: { status: "succeeded" },
|
||||
}),
|
||||
},
|
||||
release,
|
||||
});
|
||||
|
||||
const provider = buildDeepInfraVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "deepinfra",
|
||||
model: "deepinfra/Pixverse/Pixverse-T2V",
|
||||
prompt: "A bicycle weaving through a rainy neon street",
|
||||
cfg: {},
|
||||
aspectRatio: "16:9",
|
||||
durationSeconds: 8,
|
||||
providerOptions: {
|
||||
seed: 42,
|
||||
negative_prompt: "blur",
|
||||
style: "anime",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveProviderHttpRequestConfigMock.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
baseUrl: "https://api.deepinfra.com/v1/inference",
|
||||
defaultBaseUrl: "https://api.deepinfra.com/v1/inference",
|
||||
allowPrivateNetwork: false,
|
||||
defaultHeaders: {
|
||||
Authorization: "Bearer provider-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
provider: "deepinfra",
|
||||
capability: "video",
|
||||
transport: "http",
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(postJsonRequestMock).toHaveBeenCalledOnce();
|
||||
const postRequest = requireFirstPostJsonRequest();
|
||||
const postRequestHeaders = Reflect.get(postRequest ?? {}, "headers");
|
||||
expect(postRequestHeaders).toBeInstanceOf(Headers);
|
||||
expect(Object.fromEntries((postRequestHeaders as Headers).entries())).toEqual({
|
||||
authorization: "Bearer provider-key",
|
||||
"content-type": "application/json",
|
||||
});
|
||||
expect(postRequest).toEqual({
|
||||
url: "https://api.deepinfra.com/v1/inference/Pixverse/Pixverse-T2V",
|
||||
headers: postRequestHeaders,
|
||||
body: {
|
||||
prompt: "A bicycle weaving through a rainy neon street",
|
||||
aspect_ratio: "16:9",
|
||||
duration: 8,
|
||||
seed: 42,
|
||||
negative_prompt: "blur",
|
||||
style: "anime",
|
||||
},
|
||||
timeoutMs: undefined,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork: false,
|
||||
dispatcherPolicy: undefined,
|
||||
});
|
||||
expect(result.videos).toEqual([
|
||||
{
|
||||
url: "https://api.deepinfra.com/generated/video.mp4",
|
||||
mimeType: "video/mp4",
|
||||
fileName: "video-1.mp4",
|
||||
},
|
||||
]);
|
||||
expect(result.metadata).toEqual({
|
||||
requestId: "req_123",
|
||||
seed: 42,
|
||||
status: "succeeded",
|
||||
});
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not forward malformed video seed values", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
video_url: "/generated/video.mp4",
|
||||
request_id: "req_seed",
|
||||
inference_status: { status: "succeeded" },
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildDeepInfraVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "deepinfra",
|
||||
model: "deepinfra/Pixverse/Pixverse-T2V",
|
||||
prompt: "A bicycle weaving through a rainy neon street",
|
||||
cfg: {},
|
||||
providerOptions: {
|
||||
seed: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledOnce();
|
||||
const postRequest = requireFirstPostJsonRequest();
|
||||
expect(Reflect.get(Reflect.get(postRequest ?? {}, "body") ?? {}, "seed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops malformed response seed metadata", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
video_url: "/generated/video.mp4",
|
||||
request_id: "req_bad_seed",
|
||||
seed: 1.5,
|
||||
inference_status: { status: "succeeded" },
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildDeepInfraVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "deepinfra",
|
||||
model: "deepinfra/Pixverse/Pixverse-T2V",
|
||||
prompt: "A bicycle weaving through a rainy neon street",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(result.metadata).toEqual({
|
||||
requestId: "req_bad_seed",
|
||||
seed: undefined,
|
||||
status: "succeeded",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports malformed native video JSON as a provider error", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token");
|
||||
},
|
||||
},
|
||||
release,
|
||||
});
|
||||
|
||||
const provider = buildDeepInfraVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "deepinfra",
|
||||
model: "deepinfra/Pixverse/Pixverse-T2V",
|
||||
prompt: "A bicycle weaving through a rainy neon street",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("DeepInfra video generation failed: malformed JSON response");
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("names base64 WebM data URL outputs from the MIME type", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
video_url: `data:video/webm;base64,${Buffer.from("webm-data").toString("base64")}`,
|
||||
request_id: "req_webm",
|
||||
inference_status: { status: "succeeded" },
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildDeepInfraVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "deepinfra",
|
||||
model: "deepinfra/Pixverse/Pixverse-T2V",
|
||||
prompt: "A WebM data URL",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(result.videos).toHaveLength(1);
|
||||
const [video] = result.videos;
|
||||
if (!video) {
|
||||
throw new Error("Expected generated DeepInfra video");
|
||||
}
|
||||
expect(video).toEqual({
|
||||
buffer: Buffer.from("webm-data"),
|
||||
mimeType: "video/webm",
|
||||
fileName: "video-1.webm",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts DeepInfra video array responses", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
videos: [{ url: "/generated/video-array.mp4" }],
|
||||
request_id: "req_array",
|
||||
status: "succeeded",
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildDeepInfraVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "deepinfra",
|
||||
model: "deepinfra/google/veo-3.1-fast",
|
||||
prompt: "A videos array response",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(result.videos).toEqual([
|
||||
{
|
||||
url: "https://api.deepinfra.com/generated/video-array.mp4",
|
||||
mimeType: "video/mp4",
|
||||
fileName: "video-1.mp4",
|
||||
},
|
||||
]);
|
||||
expect(result.metadata).toEqual({
|
||||
requestId: "req_array",
|
||||
seed: undefined,
|
||||
status: "succeeded",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed base64 data URL video outputs", async () => {
|
||||
const release = vi.fn(async () => undefined);
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
video_url: "data:video/webm;base64,not-base64!",
|
||||
request_id: "req_bad_base64",
|
||||
inference_status: { status: "succeeded" },
|
||||
}),
|
||||
},
|
||||
release,
|
||||
});
|
||||
|
||||
const provider = buildDeepInfraVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "deepinfra",
|
||||
model: "deepinfra/Pixverse/Pixverse-T2V",
|
||||
prompt: "A malformed WebM data URL",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("DeepInfra video response returned malformed data URL base64");
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
297
extensions/deepinfra/video-generation-provider.ts
Normal file
297
extensions/deepinfra/video-generation-provider.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
// Deepinfra provider module implements model/runtime integration.
|
||||
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
asFiniteNumber,
|
||||
asSafeIntegerInRange,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type {
|
||||
GeneratedVideoAsset,
|
||||
VideoGenerationProvider,
|
||||
VideoGenerationRequest,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
import {
|
||||
DEEPINFRA_NATIVE_BASE_URL,
|
||||
DEEPINFRA_VIDEO_ASPECT_RATIOS,
|
||||
DEEPINFRA_VIDEO_DURATIONS,
|
||||
DEEPINFRA_VIDEO_FALLBACK_MODELS,
|
||||
normalizeDeepInfraBaseUrl,
|
||||
normalizeDeepInfraModelRef,
|
||||
} from "./media-models.js";
|
||||
import type { DeepInfraSurfaceModel } from "./provider-models.js";
|
||||
import { resolveDeepInfraVideoModelCapabilities } from "./surface-model-catalogs.js";
|
||||
|
||||
type DeepInfraVideoStatus = {
|
||||
status?: string;
|
||||
runtime_ms?: number;
|
||||
};
|
||||
|
||||
type DeepInfraVideoResponse = {
|
||||
video_url?: string;
|
||||
video?: string;
|
||||
videos?: Array<string | { url?: string; video_url?: string }>;
|
||||
status?: string;
|
||||
seed?: number;
|
||||
request_id?: string;
|
||||
inference_status?: DeepInfraVideoStatus;
|
||||
};
|
||||
|
||||
function encodeDeepInfraModelPath(model: string): string {
|
||||
return model.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
function resolveDeepInfraNativeBaseUrl(req: VideoGenerationRequest): string {
|
||||
const providerConfig = req.cfg?.models?.providers?.deepinfra as
|
||||
| (Record<string, unknown> & { baseUrl?: unknown })
|
||||
| undefined;
|
||||
const nativeBaseUrl = normalizeOptionalString(providerConfig?.nativeBaseUrl);
|
||||
if (nativeBaseUrl) {
|
||||
return normalizeDeepInfraBaseUrl(nativeBaseUrl, DEEPINFRA_NATIVE_BASE_URL);
|
||||
}
|
||||
const configuredBaseUrl = normalizeOptionalString(providerConfig?.baseUrl);
|
||||
if (configuredBaseUrl?.includes("/v1/inference")) {
|
||||
return normalizeDeepInfraBaseUrl(configuredBaseUrl, DEEPINFRA_NATIVE_BASE_URL);
|
||||
}
|
||||
return DEEPINFRA_NATIVE_BASE_URL;
|
||||
}
|
||||
|
||||
function normalizeDeepInfraVideoUrl(url: string): string {
|
||||
if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("data:")) {
|
||||
return url;
|
||||
}
|
||||
return new URL(url, "https://api.deepinfra.com").href;
|
||||
}
|
||||
|
||||
function parseVideoDataUrl(url: string): GeneratedVideoAsset | undefined {
|
||||
const match = /^data:([^;,]+);base64,(.+)$/u.exec(url);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const mimeType = match[1] ?? "video/mp4";
|
||||
const ext = extensionForMime(mimeType)?.slice(1) ?? "mp4";
|
||||
const canonicalBase64 = canonicalizeBase64(match[2] ?? "");
|
||||
if (!canonicalBase64) {
|
||||
throw new Error("DeepInfra video response returned malformed data URL base64");
|
||||
}
|
||||
return {
|
||||
buffer: Buffer.from(canonicalBase64, "base64"),
|
||||
mimeType,
|
||||
fileName: `video-1.${ext}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDurationSeconds(value: number | undefined): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value <= 6.5 ? 5 : 8;
|
||||
}
|
||||
|
||||
function resolveSeed(value: unknown): number | undefined {
|
||||
return asSafeIntegerInRange(value, { min: 0, max: 4_294_967_295 });
|
||||
}
|
||||
|
||||
function buildDeepInfraVideoBody(
|
||||
req: VideoGenerationRequest,
|
||||
model: string,
|
||||
): Record<string, unknown> {
|
||||
const options = req.providerOptions ?? {};
|
||||
const body: Record<string, unknown> = {
|
||||
prompt: req.prompt,
|
||||
};
|
||||
const aspectRatio = normalizeOptionalString(req.aspectRatio);
|
||||
if (aspectRatio) {
|
||||
body.aspect_ratio = aspectRatio;
|
||||
}
|
||||
const duration = resolveDurationSeconds(req.durationSeconds);
|
||||
if (duration) {
|
||||
body.duration = duration;
|
||||
}
|
||||
const seed = resolveSeed(options.seed);
|
||||
if (seed != null) {
|
||||
body.seed = seed;
|
||||
}
|
||||
const negativePrompt =
|
||||
normalizeOptionalString(options.negative_prompt) ??
|
||||
normalizeOptionalString(options.negativePrompt);
|
||||
if (negativePrompt) {
|
||||
body.negative_prompt = negativePrompt;
|
||||
}
|
||||
const style = normalizeOptionalString(options.style);
|
||||
if (style) {
|
||||
body.style = style;
|
||||
}
|
||||
const guidanceScale =
|
||||
asFiniteNumber(options.guidance_scale) ?? asFiniteNumber(options.guidanceScale);
|
||||
if (guidanceScale != null && model.startsWith("Wan-AI/")) {
|
||||
body.guidance_scale = guidanceScale;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function firstDeepInfraVideoUrl(payload: DeepInfraVideoResponse): string | undefined {
|
||||
const direct =
|
||||
normalizeOptionalString(payload.video_url) ?? normalizeOptionalString(payload.video);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
for (const entry of payload.videos ?? []) {
|
||||
const videoUrl =
|
||||
typeof entry === "string"
|
||||
? normalizeOptionalString(entry)
|
||||
: (normalizeOptionalString(entry.url) ?? normalizeOptionalString(entry.video_url));
|
||||
if (videoUrl) {
|
||||
return videoUrl;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractDeepInfraVideoAsset(payload: DeepInfraVideoResponse): GeneratedVideoAsset {
|
||||
const videoUrl = firstDeepInfraVideoUrl(payload);
|
||||
if (!videoUrl) {
|
||||
throw new Error("DeepInfra video response missing video URL");
|
||||
}
|
||||
const normalizedUrl = normalizeDeepInfraVideoUrl(videoUrl);
|
||||
const dataAsset = parseVideoDataUrl(normalizedUrl);
|
||||
if (dataAsset) {
|
||||
return dataAsset;
|
||||
}
|
||||
return {
|
||||
url: normalizedUrl,
|
||||
mimeType: "video/mp4",
|
||||
fileName: "video-1.mp4",
|
||||
};
|
||||
}
|
||||
|
||||
function failureMessage(payload: DeepInfraVideoResponse): string | undefined {
|
||||
const status = (
|
||||
normalizeOptionalString(payload.inference_status?.status) ??
|
||||
normalizeOptionalString(payload.status)
|
||||
)?.toLowerCase();
|
||||
if (status === "failed" || status === "error") {
|
||||
return "DeepInfra video generation failed";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// First entry of videoGenModels is the default; rest fill the allowlist.
|
||||
export function buildDeepInfraVideoGenerationProvider(options?: {
|
||||
videoGenModels?: readonly DeepInfraSurfaceModel[];
|
||||
}): VideoGenerationProvider {
|
||||
const ids =
|
||||
options?.videoGenModels && options.videoGenModels.length > 0
|
||||
? options.videoGenModels.map((model) => model.id)
|
||||
: [...DEEPINFRA_VIDEO_FALLBACK_MODELS];
|
||||
const defaultModel = ids[0] ?? DEEPINFRA_VIDEO_FALLBACK_MODELS[0];
|
||||
return {
|
||||
id: "deepinfra",
|
||||
label: "DeepInfra",
|
||||
defaultModel,
|
||||
models: ids,
|
||||
resolveModelCapabilities: resolveDeepInfraVideoModelCapabilities,
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({
|
||||
provider: "deepinfra",
|
||||
agentDir,
|
||||
}),
|
||||
capabilities: {
|
||||
generate: {
|
||||
maxVideos: 1,
|
||||
maxDurationSeconds: 8,
|
||||
supportedDurationSeconds: [...DEEPINFRA_VIDEO_DURATIONS],
|
||||
supportsAspectRatio: true,
|
||||
aspectRatios: [...DEEPINFRA_VIDEO_ASPECT_RATIOS],
|
||||
providerOptions: {
|
||||
seed: "number",
|
||||
negative_prompt: "string",
|
||||
negativePrompt: "string",
|
||||
style: "string",
|
||||
guidance_scale: "number",
|
||||
guidanceScale: "number",
|
||||
},
|
||||
},
|
||||
imageToVideo: {
|
||||
enabled: false,
|
||||
},
|
||||
videoToVideo: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
async generateVideo(req) {
|
||||
if ((req.inputImages?.length ?? 0) > 0) {
|
||||
throw new Error("DeepInfra video generation currently supports text-to-video only.");
|
||||
}
|
||||
if ((req.inputVideos?.length ?? 0) > 0) {
|
||||
throw new Error("DeepInfra video generation does not support video reference inputs.");
|
||||
}
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: "deepinfra",
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("DeepInfra API key missing");
|
||||
}
|
||||
|
||||
const model = normalizeDeepInfraModelRef(req.model, defaultModel);
|
||||
const resolvedBaseUrl = resolveDeepInfraNativeBaseUrl(req);
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: resolvedBaseUrl,
|
||||
defaultBaseUrl: DEEPINFRA_NATIVE_BASE_URL,
|
||||
allowPrivateNetwork: false,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
provider: "deepinfra",
|
||||
capability: "video",
|
||||
transport: "http",
|
||||
});
|
||||
|
||||
const { response, release } = await postJsonRequest({
|
||||
url: `${baseUrl}/${encodeDeepInfraModelPath(model)}`,
|
||||
headers,
|
||||
body: buildDeepInfraVideoBody(req, model),
|
||||
timeoutMs: req.timeoutMs,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "DeepInfra video generation failed");
|
||||
const payload = await readProviderJsonResponse<DeepInfraVideoResponse>(
|
||||
response,
|
||||
"DeepInfra video generation failed",
|
||||
);
|
||||
const failed = failureMessage(payload);
|
||||
if (failed) {
|
||||
throw new Error(failed);
|
||||
}
|
||||
const video = extractDeepInfraVideoAsset(payload);
|
||||
return {
|
||||
videos: [video],
|
||||
model,
|
||||
metadata: {
|
||||
requestId: normalizeOptionalString(payload.request_id),
|
||||
seed: resolveSeed(payload.seed),
|
||||
status: payload.inference_status?.status ?? payload.status,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user