feat: add ai providers

This commit is contained in:
hyrious
2026-09-01 19:21:41 +08:00
parent 4e22a3fb44
commit fc5eb6a6b9
28 changed files with 2408 additions and 173 deletions
-1
View File
@@ -1,4 +1,3 @@
.private-docs/
.tmp/
.wrangler/
wrangler.local.jsonc
+85
View File
@@ -0,0 +1,85 @@
import type { ProviderActionDefinition } from "../../core/provider-definition.ts";
import type { JsonSchema } from "../../core/types.ts";
import { s } from "../../core/json-schema.ts";
import { defineProviderAction } from "../../core/provider-definition.ts";
const service = "doubao_seedream";
export const defaultSeedreamModel = "doubao-seedream-5-0-260128";
const sizeSchema: JsonSchema = s.oneOf(
[
s.stringEnum("A standard output resolution.", ["2K", "3K", "4K"]),
s.object(
"Custom output dimensions in pixels.",
{
width: s.integer("The output width in pixels.", { minimum: 1 }),
height: s.integer("The output height in pixels.", { minimum: 1 }),
},
{ required: ["width", "height"] },
),
],
{ description: "The standard resolution or custom pixel dimensions for generated images." },
);
export const doubaoSeedreamActions: ProviderActionDefinition[] = [
defineProviderAction(service, {
name: "generate_image",
description: "Generate or edit one or more images with Doubao Seedream.",
inputSchema: s.actionInput(
{
model: {
...s.withDefault(
s.nonEmptyString("The Doubao Seedream Model ID or Endpoint ID used for image generation."),
defaultSeedreamModel,
),
examples: [defaultSeedreamModel],
},
prompt: s.string("The image generation or editing prompt.", { minLength: 1, maxLength: 1000 }),
images: s.array(
"Public image URLs or data URLs used as references for image editing or fusion.",
s.nonEmptyString("A public image URL or data URL."),
{ maxItems: 10 },
),
size: s.withDefault(sizeSchema, "2K"),
maxImages: s.withDefault(
s.integer("The maximum number of images to generate. Use 1 for a single image.", {
minimum: 1,
maximum: 15,
}),
1,
),
watermark: s.withDefault(s.boolean("Whether generated images include a watermark."), true),
},
["prompt"],
"A synchronous Doubao Seedream image generation request.",
),
outputSchema: s.actionOutput(
{
model: s.nonEmptyString("The model reported by Doubao Seedream."),
createdAt: s.integer("The response creation Unix timestamp in seconds."),
images: s.array(
"The generated images.",
s.object(
"A generated image.",
{
url: s.nonEmptyString("The temporary generated image URL."),
size: s.nonEmptyString("The generated image dimensions as WIDTHxHEIGHT."),
},
{ required: ["url", "size"] },
),
),
usage: s.object(
"Image generation usage reported by Doubao Seedream.",
{
generatedImages: s.nonNegativeInteger("The number of generated images."),
outputTokens: s.nonNegativeInteger("The output token count."),
totalTokens: s.nonNegativeInteger("The total token count."),
},
{ required: ["generatedImages", "outputTokens", "totalTokens"] },
),
},
"The generated Doubao Seedream images and usage.",
),
}),
];
@@ -0,0 +1,23 @@
import type { ProviderDefinition } from "../../core/types.ts";
import { doubaoSeedreamActions } from "./actions.ts";
const service = "doubao_seedream";
export const provider: ProviderDefinition = {
service,
displayName: "Doubao Seedream",
categories: ["AI", "Design & Media"],
authTypes: ["api_key"],
auth: [
{
type: "api_key",
label: "Ark API Key",
placeholder: "Your Doubao Seedream API key",
description:
"Doubao Seedream API key sent as a Bearer token. Create an API key in the Ark console: https://console.volcengine.com/ark/region:ark+cn-beijing/apikey.",
},
],
homepageUrl: "https://www.volcengine.com/product/ark",
actions: doubaoSeedreamActions,
};
@@ -0,0 +1,16 @@
import type { CredentialValidators, ProviderExecutors } from "../../core/types.ts";
import { defineApiKeyProviderExecutors } from "../provider-runtime.ts";
import { validateDoubaoSeedreamCredential, doubaoSeedreamActionHandlers } from "./runtime.ts";
const service = "doubao_seedream";
export const executors: ProviderExecutors = defineApiKeyProviderExecutors(service, doubaoSeedreamActionHandlers, {
skipDnsValidation: true,
});
export const credentialValidators: CredentialValidators = {
apiKey(input, { fetcher, signal }) {
return validateDoubaoSeedreamCredential(input.apiKey, fetcher, signal);
},
};
+148
View File
@@ -0,0 +1,148 @@
import type { CredentialValidationResult } from "../../core/types.ts";
import type { ApiKeyProviderContext, ProviderActionHandlers, ProviderRuntimeHandler } from "../provider-runtime.ts";
import {
compactObject,
objectArray,
optionalBoolean,
optionalInteger,
optionalRecord,
optionalScalarString,
optionalString,
optionalStringArray,
requiredString,
} from "../../core/cast.ts";
import {
ProviderRequestError,
providerResponseError,
providerUserAgent,
readProviderJsonBody,
requiredInputString,
requiredResponseRecord,
runProviderRequest,
} from "../provider-runtime.ts";
import { defaultSeedreamModel } from "./actions.ts";
export const doubaoSeedreamApiBaseUrl = "https://ark.cn-beijing.volces.com/api/v3";
const imagesPath = "images/generations";
export const doubaoSeedreamActionHandlers: ProviderActionHandlers<
"doubao_seedream",
ProviderRuntimeHandler<ApiKeyProviderContext>
> = {
async generate_image(input, context): Promise<unknown> {
const payload = await requestArkJson(context, buildSeedreamBody(input));
return normalizeSeedreamResponse(requiredResponseRecord(payload, "image generation response"));
},
};
export function buildSeedreamBody(input: Record<string, unknown>): Record<string, unknown> {
const images = optionalStringArray(input.images);
const maxImages = Math.min(optionalInteger(input.maxImages) ?? 1, 15 - (images?.length ?? 0));
const model = optionalString(input.model) ?? defaultSeedreamModel;
const supportsSequentialGeneration = model !== "doubao-seedream-5-0-pro-260628";
if (!supportsSequentialGeneration && maxImages > 1)
throw new ProviderRequestError(400, "The selected model does not support multiple image generation");
return compactObject({
model,
prompt: requiredInputString(input.prompt, "prompt"),
image: images,
size: readSize(input.size),
sequential_image_generation: supportsSequentialGeneration ? (maxImages > 1 ? "auto" : "disabled") : undefined,
sequential_image_generation_options:
supportsSequentialGeneration && maxImages > 1 ? { max_images: maxImages } : undefined,
stream: supportsSequentialGeneration ? false : undefined,
response_format: "url",
watermark: optionalBoolean(input.watermark) ?? true,
});
}
function readSize(value: unknown): string {
if (value === undefined) return "2K";
const preset = optionalScalarString(value);
if (preset) return preset;
const size = optionalRecord(value);
if (!size) throw new ProviderRequestError(400, "size must be a preset or dimensions object");
const width = optionalInteger(size.width);
const height = optionalInteger(size.height);
if (width === undefined || width <= 0 || height === undefined || height <= 0) {
throw new ProviderRequestError(400, "size width and height must be positive integers");
}
return `${width}x${height}`;
}
export function normalizeSeedreamResponse(payload: Record<string, unknown>): Record<string, unknown> {
const usage = requiredResponseRecord(payload.usage, "response usage");
return {
model: requiredString(payload.model, "model", providerResponseError),
createdAt: requireInteger(payload.created, "created"),
images: objectArray(payload.data, "data", providerResponseError).map((image) => ({
url: requiredString(image.url, "data.url", providerResponseError),
size: requiredString(image.size, "data.size", providerResponseError),
})),
usage: {
generatedImages: requireInteger(usage.generated_images, "usage.generated_images"),
outputTokens: requireInteger(usage.output_tokens, "usage.output_tokens"),
totalTokens: requireInteger(usage.total_tokens, "usage.total_tokens"),
},
};
}
export async function validateDoubaoSeedreamCredential(
apiKey: string,
fetcher: typeof fetch,
signal?: AbortSignal,
): Promise<CredentialValidationResult> {
await requestArkJson({ apiKey, fetcher, signal }, {});
return {
profile: { accountId: "doubao_seedream:api_key", displayName: "Doubao Seedream API Key" },
grantedScopes: [],
metadata: { apiBaseUrl: doubaoSeedreamApiBaseUrl },
};
}
async function requestArkJson(context: ApiKeyProviderContext, body: Record<string, unknown>): Promise<unknown> {
context.signal?.throwIfAborted();
return runProviderRequest({ label: "Doubao Seedream", signal: context.signal }, async (signal) => {
const response = await context.fetcher(new URL(imagesPath, `${doubaoSeedreamApiBaseUrl}/`), {
method: "POST",
headers: {
accept: "application/json",
authorization: `Bearer ${context.apiKey}`,
"content-type": "application/json",
"user-agent": providerUserAgent,
},
body: JSON.stringify(body),
signal,
});
const payload = await readProviderJsonBody(response, {
emptyBody: {},
invalidJsonMessage: "Doubao Seedream returned an invalid JSON response",
});
if (!response.ok) handleArkError(response, payload);
return payload;
});
}
function handleArkError(response: Response, payload: unknown): never {
const record = optionalRecord(payload);
const nestedError = optionalRecord(record?.error);
const code = optionalString(record?.code) ?? optionalString(nestedError?.code);
const message =
optionalString(record?.message) ??
optionalString(record?.error) ??
optionalString(nestedError?.message) ??
`Doubao Seedream request failed with HTTP ${response.status}`;
const details = compactObject({ code, status: response.status });
if (response.status === 400 || response.status === 404 || response.status === 422) {
throw new ProviderRequestError(response.status === 404 ? 404 : 400, message, details, "invalid_input");
}
if (response.status === 429) throw new ProviderRequestError(429, message, details);
throw new ProviderRequestError(response.status >= 500 ? 502 : response.status, message, details);
}
function requireInteger(value: unknown, field: string): number {
const integer = optionalInteger(value);
if (integer === undefined) throw providerResponseError(`Doubao Seedream ${field} must be an integer`);
return integer;
}
+131
View File
@@ -0,0 +1,131 @@
import type { ProviderActionDefinition } from "../../core/provider-definition.ts";
import type { JsonSchema } from "../../core/types.ts";
import { s } from "../../core/json-schema.ts";
import { defineProviderAction } from "../../core/provider-definition.ts";
const service = "doubao_speech";
const taskIdSchema = s.nonEmptyString("The opaque Doubao Speech task identifier returned by the selected connection.");
const ttsLifecycle = {
startActionId: "doubao_speech.submit_tts",
statusActionId: "doubao_speech.get_tts",
};
const sttLifecycle = {
startActionId: "doubao_speech.submit_stt",
statusActionId: "doubao_speech.get_stt",
};
const ttsTaskSchema: JsonSchema = s.oneOf(
[
s.object(
"A queued or running speech synthesis task.",
{
taskId: taskIdSchema,
state: s.literal("processing", { description: "The task is processing." }),
progress: s.number("The task progress percentage when available.", { minimum: 0, maximum: 100 }),
},
{ required: ["taskId", "state"], optional: ["progress"] },
),
s.object(
"A completed speech synthesis task.",
{
taskId: taskIdSchema,
state: s.literal("succeeded", { description: "The task succeeded." }),
audioUrl: s.nonEmptyString("The temporary synthesized audio URL."),
urlExpiresAt: s.integer("The Unix timestamp when the audio URL expires."),
requestedTextLength: s.nonNegativeInteger("The requested text length reported by Doubao Speech."),
synthesizedTextLength: s.nonNegativeInteger("The synthesized text length reported by Doubao Speech."),
},
{
required: ["taskId", "state", "audioUrl"],
optional: ["urlExpiresAt", "requestedTextLength", "synthesizedTextLength"],
},
),
],
{ description: "The normalized Doubao speech synthesis task state." },
);
const utteranceSchema = s.object(
"A recognized speech segment.",
{
text: s.string("The recognized segment text."),
startTime: s.nonNegativeInteger("The segment start time in milliseconds."),
endTime: s.nonNegativeInteger("The segment end time in milliseconds."),
},
{ required: ["text", "startTime", "endTime"] },
);
const sttTaskSchema: JsonSchema = s.oneOf(
[
s.object(
"A queued or running speech recognition task.",
{
taskId: taskIdSchema,
state: s.literal("processing", { description: "The task is queued or processing." }),
progress: s.number("The task progress percentage when available.", { minimum: 0, maximum: 100 }),
},
{ required: ["taskId", "state"], optional: ["progress"] },
),
s.object(
"A completed speech recognition task.",
{
taskId: taskIdSchema,
state: s.literal("succeeded", { description: "The task succeeded." }),
text: s.string("The complete recognized text."),
utterances: s.array("Recognized speech segments.", utteranceSchema),
duration: s.nonNegativeInteger("The source audio duration in milliseconds."),
},
{ required: ["taskId", "state", "text"], optional: ["utterances", "duration"] },
),
],
{ description: "The normalized Doubao speech recognition task state." },
);
export const doubaoSpeechActions: ProviderActionDefinition[] = [
defineProviderAction(service, {
name: "submit_tts",
description: "Submit an asynchronous Doubao Speech 2.0 text-to-speech task.",
followUpActions: [ttsLifecycle.statusActionId],
asyncLifecycle: ttsLifecycle,
inputSchema: s.actionInput(
{
text: s.string("The text to synthesize.", { minLength: 1, maxLength: 100000 }),
voice: s.nonEmptyString("The Doubao Speech speaker ID."),
format: s.withDefault(s.stringEnum("The synthesized audio format.", ["mp3", "wav", "pcm", "ogg_opus"]), "mp3"),
},
["text", "voice"],
"A Doubao Speech 2.0 synthesis request.",
),
outputSchema: s.actionOutput({ taskId: taskIdSchema }, "The submitted speech synthesis task handle."),
}),
defineProviderAction(service, {
name: "get_tts",
description: "Retrieve a Doubao Speech 2.0 text-to-speech task state and output.",
asyncLifecycle: ttsLifecycle,
inputSchema: s.actionInput({ taskId: taskIdSchema }, ["taskId"], "A speech synthesis task lookup."),
outputSchema: ttsTaskSchema,
}),
defineProviderAction(service, {
name: "submit_stt",
description: "Submit an asynchronous Doubao recording-file speech recognition task.",
followUpActions: [sttLifecycle.statusActionId],
asyncLifecycle: sttLifecycle,
inputSchema: s.actionInput(
{
audioUrl: s.nonEmptyString("A public URL for the audio file to recognize."),
format: s.stringEnum("The audio container format.", ["raw", "wav", "mp3", "ogg"]),
language: s.string("The recognition language code, such as zh-CN or en-US."),
enableSpeakerInfo: s.withDefault(s.boolean("Whether to return speaker clustering information."), false),
},
["audioUrl", "format"],
"A Doubao recording-file speech recognition request.",
),
outputSchema: s.actionOutput({ taskId: taskIdSchema }, "The submitted speech recognition task handle."),
}),
defineProviderAction(service, {
name: "get_stt",
description: "Retrieve a Doubao recording-file speech recognition task state and output.",
asyncLifecycle: sttLifecycle,
inputSchema: s.actionInput({ taskId: taskIdSchema }, ["taskId"], "A speech recognition task lookup."),
outputSchema: sttTaskSchema,
}),
];
+23
View File
@@ -0,0 +1,23 @@
import type { ProviderDefinition } from "../../core/types.ts";
import { doubaoSpeechActions } from "./actions.ts";
const service = "doubao_speech";
export const provider: ProviderDefinition = {
service,
displayName: "Doubao Speech",
categories: ["AI", "Communication"],
authTypes: ["api_key"],
auth: [
{
type: "api_key",
label: "Doubao Speech APP Key",
placeholder: "Your Doubao Speech APP Key",
description:
"APP Key from the Doubao Speech console, sent only in the X-Api-Key header. Keys from the Ark console are not supported.",
},
],
homepageUrl: "https://www.volcengine.com/product/doubao-voice",
actions: doubaoSpeechActions,
};
@@ -1,16 +1,16 @@
import type { CredentialValidators, ProviderExecutors } from "../../core/types.ts";
import { defineApiKeyProviderExecutors } from "../provider-runtime.ts";
import { validateVolcengineArkCredential, volcengineArkActionHandlers } from "./runtime.ts";
import { doubaoSpeechActionHandlers, validateDoubaoSpeechCredential } from "./runtime.ts";
const service = "volcengine_ark";
const service = "doubao_speech";
export const executors: ProviderExecutors = defineApiKeyProviderExecutors(service, volcengineArkActionHandlers, {
export const executors: ProviderExecutors = defineApiKeyProviderExecutors(service, doubaoSpeechActionHandlers, {
skipDnsValidation: true,
});
export const credentialValidators: CredentialValidators = {
apiKey(input, { fetcher, signal }) {
return validateVolcengineArkCredential(input.apiKey, fetcher, signal);
return validateDoubaoSpeechCredential(input.apiKey, fetcher, signal);
},
};
+247
View File
@@ -0,0 +1,247 @@
import type { CredentialValidationResult } from "../../core/types.ts";
import type { ApiKeyProviderContext, ProviderActionHandlers, ProviderRuntimeHandler } from "../provider-runtime.ts";
import { compactObject, optionalInteger, optionalRecord, optionalString, requiredString } from "../../core/cast.ts";
import { randomUUIDv7 } from "../../core/uuid-v7.ts";
import {
ProviderRequestError,
providerResponseError,
providerUserAgent,
readProviderJsonBody,
requiredInputString,
runProviderRequest,
} from "../provider-runtime.ts";
export const doubaoSpeechApiBaseUrl = "https://openspeech.bytedance.com/api/v3";
const ttsResourceId = "seed-tts-2.0";
const sttResourceId = "volc.seedasr.auc";
const successCode = 20000000;
export const doubaoSpeechActionHandlers: ProviderActionHandlers<
"doubao_speech",
ProviderRuntimeHandler<ApiKeyProviderContext>
> = {
async submit_tts(input, context): Promise<unknown> {
const requestId = randomUUIDv7();
const payload = await requestJson(
context,
"tts/submit",
{
req_params: {
text: requiredInputString(input.text, "text"),
speaker: requiredInputString(input.voice, "voice"),
audio_params: { format: optionalString(input.format) ?? "mp3" },
},
},
{ requestId, resourceId: ttsResourceId },
);
const response = requireRecord(payload, "TTS submit response");
handleTtsCode(response);
const data = requireRecord(response.data, "TTS submit data");
return { taskId: requiredString(data.task_id, "data.task_id", providerResponseError) };
},
async get_tts(input, context): Promise<unknown> {
const taskId = requiredInputString(input.taskId, "taskId");
const payload = await requestJson(
context,
"tts/query",
{ task_id: taskId },
{ requestId: randomUUIDv7(), resourceId: ttsResourceId },
);
const response = requireRecord(payload, "TTS query response");
handleTtsCode(response);
const data = requireRecord(response.data, "TTS query data");
if (optionalInteger(data.task_status) !== 2) return { taskId, state: "processing" };
return compactObject({
taskId,
state: "succeeded",
audioUrl: requiredString(data.audio_url, "data.audio_url", providerResponseError),
urlExpiresAt: optionalInteger(data.url_expire_time),
requestedTextLength: optionalInteger(data.req_text_length),
synthesizedTextLength: optionalInteger(data.synthesize_text_length),
});
},
async submit_stt(input, context): Promise<unknown> {
const taskId = randomUUIDv7();
await requestStt(
context,
"auc/bigmodel/submit",
{
user: { uid: "oomol-connector" },
audio: {
url: requiredInputString(input.audioUrl, "audioUrl"),
format: requiredInputString(input.format, "format"),
},
request: compactObject({
model_name: "bigmodel",
enable_itn: true,
enable_punc: true,
enable_ddc: true,
show_utterances: true,
enable_speaker_info: input.enableSpeakerInfo === true,
language: optionalString(input.language),
}),
},
{ requestId: taskId, resourceId: sttResourceId, sequence: "-1" },
);
return { taskId };
},
async get_stt(input, context): Promise<unknown> {
const taskId = requiredInputString(input.taskId, "taskId");
const response = await requestStt(
context,
"auc/bigmodel/query",
{},
{ requestId: taskId, resourceId: sttResourceId },
);
if (response.statusCode === 20000001 || response.statusCode === 20000002) return { taskId, state: "processing" };
const body = requireRecord(response.payload, "STT query response");
const result = requireRecord(body.result, "STT query result");
const utterances = Array.isArray(result.utterances)
? result.utterances.map((item) => {
const utterance = requireRecord(item, "STT utterance");
return {
text: requiredString(utterance.text, "utterance.text", providerResponseError),
startTime: optionalInteger(utterance.start_time) ?? 0,
endTime: optionalInteger(utterance.end_time) ?? 0,
};
})
: undefined;
const audioInfo = optionalRecord(body.audio_info);
return compactObject({
taskId,
state: "succeeded",
text: requiredString(result.text, "result.text", providerResponseError),
utterances,
duration: optionalInteger(audioInfo?.duration),
});
},
};
export async function validateDoubaoSpeechCredential(
apiKey: string,
fetcher: typeof fetch,
signal?: AbortSignal,
): Promise<CredentialValidationResult> {
const context = { apiKey, fetcher, signal };
await requestStt(
context,
"auc/bigmodel/query",
{},
{
requestId: randomUUIDv7(),
resourceId: sttResourceId,
},
).catch((error: unknown) => {
if (!(error instanceof ProviderRequestError) || error.code !== "invalid_input") throw error;
});
return {
profile: { accountId: "doubao_speech:app_key", displayName: "Doubao Speech APP Key" },
grantedScopes: [],
metadata: { apiBaseUrl: doubaoSpeechApiBaseUrl },
};
}
interface RequestHeaders {
requestId: string;
resourceId: string;
sequence?: string;
}
async function requestJson(
context: ApiKeyProviderContext,
path: string,
body: Record<string, unknown>,
headers: RequestHeaders,
): Promise<unknown> {
const { response, payload } = await requestAndReadJson(context, path, body, headers);
if (!response.ok) throw httpError(response.status, payload);
return payload;
}
async function requestStt(
context: ApiKeyProviderContext,
path: string,
body: Record<string, unknown>,
headers: RequestHeaders,
): Promise<{ payload: unknown; statusCode: number }> {
const { response, payload } = await requestAndReadJson(context, path, body, headers);
if (!response.ok) throw httpError(response.status, payload);
const statusCode = Number(response.headers.get("x-api-status-code"));
if (!Number.isInteger(statusCode)) throw providerResponseError("Doubao Speech returned no X-Api-Status-Code header");
if (statusCode < successCode || statusCode > 20000002)
throw speechCodeError(statusCode, response.headers.get("x-api-message") ?? "Doubao Speech request failed");
return { payload, statusCode };
}
async function requestAndReadJson(
context: ApiKeyProviderContext,
path: string,
body: Record<string, unknown>,
headers: RequestHeaders,
): Promise<{ response: Response; payload: unknown }> {
context.signal?.throwIfAborted();
return runProviderRequest({ label: "Doubao Speech", signal: context.signal }, async (signal) => {
const response = await context.fetcher(new URL(path, `${doubaoSpeechApiBaseUrl}/`), {
method: "POST",
headers: compactHeaders({
accept: "application/json",
"content-type": "application/json",
"user-agent": providerUserAgent,
"x-api-key": context.apiKey,
"x-api-resource-id": headers.resourceId,
"x-api-request-id": headers.requestId,
"x-api-sequence": headers.sequence,
}),
body: JSON.stringify(body),
signal,
});
const payload = await readProviderJsonBody(response, {
emptyBody: {},
invalidJsonMessage: "Doubao Speech returned an invalid JSON response",
});
return { response, payload };
});
}
function handleTtsCode(response: Record<string, unknown>): void {
const code = optionalInteger(response.code);
if (code === successCode) return;
throw speechCodeError(code, optionalString(response.message) ?? "Doubao Speech TTS request failed");
}
function speechCodeError(code: number | undefined, message: string): ProviderRequestError {
const details = compactObject({ code });
if (
code === 40000000 ||
code === 40000001 ||
code === 40000002 ||
code === 45000001 ||
code === 45000002 ||
code === 45000151
)
return new ProviderRequestError(400, message, details, "invalid_input");
if (code === 55000031) return new ProviderRequestError(429, message, details);
return new ProviderRequestError(502, message, details);
}
function httpError(status: number, payload: unknown): ProviderRequestError {
const record = optionalRecord(payload);
const message = optionalString(record?.message) ?? `Doubao Speech request failed with HTTP ${status}`;
if (status === 400 || status === 404 || status === 422)
return new ProviderRequestError(status === 404 ? 404 : 400, message, { status }, "invalid_input");
if (status === 429) return new ProviderRequestError(429, message, { status });
return new ProviderRequestError(status >= 500 ? 502 : status, message, { status });
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
const record = optionalRecord(value);
if (!record) throw providerResponseError(`${label} must be an object`);
return record;
}
function compactHeaders(input: Record<string, string | undefined>): Headers {
const headers = new Headers();
for (const [key, value] of Object.entries(input)) if (value !== undefined) headers.set(key, value);
return headers;
}
+4 -3
View File
@@ -2530,7 +2530,7 @@ export const fusionApiOperations: FusionApiOperation[] = [
kind: "string",
required: false,
description: "Video generation model",
enumValues: ["doubao-seedance-2-0-260128", "doubao-seedance-2-0-fast-260128"],
enumValues: ["doubao-seedance-2-0-260128", "doubao-seedance-2-0-fast-260128", "doubao-seedance-2-5-260628"],
defaultValue: "doubao-seedance-2-0-260128",
},
{
@@ -2609,9 +2609,10 @@ export const fusionApiOperations: FusionApiOperation[] = [
},
{
name: "duration",
kind: "unknown",
kind: "integer",
required: false,
description: "Video duration in seconds. Use -1 to let the model choose automatically.",
description:
"Video duration in seconds. Supported values depend on the selected model; use -1 to let the model choose automatically. Unsupported values are rejected by the upstream API.",
defaultValue: 5,
},
{
@@ -1,4 +1,4 @@
import { randomUUIDv7 } from "./uuid-v7.ts";
import { randomUUIDv7 } from "../../core/uuid-v7.ts";
type JsonPrimitive = boolean | number | string | null;
type JsonValue = JsonPrimitive | JsonValue[] | JsonObject;
+1 -1
View File
@@ -4,6 +4,7 @@ import type { ConnectionActionPermission, ConnectionPermissionGroupsState } from
import type { OomolConsoleEndpoints } from "./request.ts";
import { optionalNumber, optionalRawString } from "../../core/cast.ts";
import { randomUUIDv7 } from "../../core/uuid-v7.ts";
import { ProviderRequestError } from "../provider-runtime.ts";
import {
parseConnectionPermissionGroups,
@@ -12,7 +13,6 @@ import {
toPermissionGroupsView,
} from "./permission-groups.ts";
import { requestOomolConsole, requestOomolConsoleWithResponse } from "./request.ts";
import { randomUUIDv7 } from "./uuid-v7.ts";
export interface OomolConsoleContext {
apiKey: string;
+590
View File
@@ -0,0 +1,590 @@
import type { ProviderActionDefinition } from "../../core/provider-definition.ts";
import { s } from "../../core/json-schema.ts";
import { defineProviderAction } from "../../core/provider-definition.ts";
const service = "qwen";
export const defaultQwenImageModel = "qwen-image-3.0";
const translationTaskIdSchema = s.nonEmptyString(
"The opaque Qwen image translation task identifier returned by the selected connection.",
);
const speechTaskIdSchema = s.nonEmptyString(
"The opaque Qwen speech recognition task identifier returned by the selected connection.",
);
const speechWordSchema = s.object(
"A recognized word with timing information.",
{
text: s.string("The recognized word text."),
beginTime: s.nonNegativeInteger("The word start time in milliseconds."),
endTime: s.nonNegativeInteger("The word end time in milliseconds."),
punctuation: s.string("The punctuation following the word."),
},
{ required: ["text", "beginTime", "endTime", "punctuation"] },
);
const speechSentenceSchema = s.object(
"A recognized sentence.",
{
text: s.string("The recognized sentence text."),
beginTime: s.nonNegativeInteger("The sentence start time in milliseconds."),
endTime: s.nonNegativeInteger("The sentence end time in milliseconds."),
sentenceId: s.nonNegativeInteger("The sentence index."),
speakerId: s.nonNegativeInteger("The detected speaker index when diarization is enabled."),
language: s.string("The detected language code."),
emotion: s.string("The detected emotion."),
words: s.array("Recognized words in the sentence.", speechWordSchema),
},
{
required: ["text", "beginTime", "endTime", "sentenceId"],
optional: ["speakerId", "language", "emotion", "words"],
},
);
const speechTranscriptSchema = s.object(
"The transcription for one audio channel.",
{
channelId: s.nonNegativeInteger("The zero-based audio channel index."),
text: s.string("The complete recognized text for the channel."),
contentDuration: s.nonNegativeInteger("The detected speech duration in milliseconds."),
sentences: s.array("Recognized sentences for the channel.", speechSentenceSchema),
},
{ required: ["channelId", "text", "contentDuration", "sentences"] },
);
const qwenAudioTtsModelSchema = s.stringEnum("The Qwen-Audio 3.0 TTS model bound to the voice.", [
"qwen-audio-3.0-tts-flash",
"qwen-audio-3.0-tts-plus",
]);
const customVoiceSchema = s.object(
"A Qwen-Audio custom voice.",
{
voiceId: s.nonEmptyString("The custom voice identifier accepted by generate_speech."),
targetModel: qwenAudioTtsModelSchema,
status: s.string("The upstream voice status."),
createdAt: s.string("The upstream voice creation time."),
modifiedAt: s.string("The upstream voice modification time."),
voicePrompt: s.string("The voice description used for a designed voice."),
previewText: s.string("The preview text used for a designed voice."),
},
{
required: ["voiceId"],
optional: ["targetModel", "status", "createdAt", "modifiedAt", "voicePrompt", "previewText"],
},
);
const translationLifecycle = {
startActionId: "qwen.submit_image_translation",
statusActionId: "qwen.get_image_translation",
};
export const qwenActions: ProviderActionDefinition[] = [
defineProviderAction(service, {
name: "generate_image",
description: "Generate or edit images with the Qwen Image 3.0 family.",
inputSchema: s.actionInput(
{
model: s.withDefault(
s.stringEnum("The Qwen Image 3.0 model.", ["qwen-image-3.0", "qwen-image-3.0-pro"]),
defaultQwenImageModel,
),
prompt: s.string("The image generation or editing prompt.", { minLength: 1 }),
images: s.array(
"Reference image URLs or data URLs. Omit for text-to-image generation.",
s.nonEmptyString("A reference image URL or data URL."),
{ maxItems: 3 },
),
size: s.nonEmptyString("The optional output dimensions as WIDTH*HEIGHT. Omit for model-selected dimensions."),
imageCount: s.withDefault(s.integer("The number of images to generate.", { minimum: 1, maximum: 6 }), 1),
negativePrompt: s.string("Content that should not appear in the generated images."),
promptExtend: s.withDefault(s.boolean("Whether Qwen should enhance the prompt."), true),
promptExtendMode: s.withDefault(s.stringEnum("The prompt enhancement mode.", ["direct", "agent"]), "direct"),
enableThinking: s.withDefault(s.boolean("Whether to use reasoning during image generation."), true),
watermark: s.withDefault(s.boolean("Whether generated images include a watermark."), false),
seed: s.integer("The random seed.", { minimum: 0, maximum: 2147483647 }),
},
["prompt"],
"A unified Qwen Image 3.0 generation or editing request.",
),
outputSchema: s.actionOutput(
{
images: s.array("The generated temporary image URLs.", s.nonEmptyString("A generated image URL.")),
imageCount: s.nonNegativeInteger("The number of generated images."),
width: s.nonNegativeInteger("The output image width in pixels."),
height: s.nonNegativeInteger("The output image height in pixels."),
inputImageCount: s.nonNegativeInteger("The number of input images billed by Qwen."),
},
"The generated Qwen images and usage.",
["images", "imageCount", "inputImageCount"],
),
}),
defineProviderAction(service, {
name: "generate_speech",
description: "Generate speech with Qwen-Audio 3.0 TTS.",
inputSchema: s.actionInput(
{
model: s.withDefault(
s.stringEnum("The Qwen-Audio 3.0 TTS model.", ["qwen-audio-3.0-tts-flash", "qwen-audio-3.0-tts-plus"]),
"qwen-audio-3.0-tts-flash",
),
text: s.string("The text to synthesize.", { minLength: 1 }),
voice: s.withDefault(
s.nonEmptyString("A system, cloned, or designed Qwen-Audio voice name."),
"longanhuan_v3.6",
),
format: s.withDefault(s.stringEnum("The generated audio encoding.", ["mp3", "pcm", "wav", "opus"]), "mp3"),
sampleRate: s.withDefault(
{
type: "integer",
enum: [8000, 16000, 22050, 24000, 44100, 48000],
description: "The audio sample rate in hertz.",
},
22050,
),
volume: s.integer("The output volume from 0 to 100.", { minimum: 0, maximum: 100 }),
rate: s.number("The speech rate from 0.5 to 2.0.", { minimum: 0.5, maximum: 2 }),
pitch: s.number("The pitch multiplier from 0.5 to 2.0.", { minimum: 0.5, maximum: 2 }),
instruction: s.string("A natural-language instruction for dialect, emotion, pace, or role.", {
minLength: 1,
}),
languageHints: s.array(
"The target language hint; the API currently uses only the first value.",
s.stringEnum("A supported target language code.", [
"zh",
"en",
"fr",
"de",
"ja",
"ko",
"ru",
"pt",
"th",
"id",
"vi",
"es",
"it",
"ms",
"fil",
"ar",
]),
{ minItems: 1, maxItems: 1 },
),
enableSsml: s.boolean("Whether text contains supported SSML markup."),
seed: s.integer("The deterministic synthesis seed.", { minimum: 0, maximum: 65535 }),
bitRate: s.integer("The Opus bit rate in kbps; only valid when format is opus.", {
minimum: 6,
maximum: 510,
}),
enableAigcTag: s.boolean("Whether to embed an AIGC provenance tag in the generated audio."),
},
["text"],
"A Qwen-Audio 3.0 speech synthesis request.",
),
outputSchema: s.actionOutput(
{
audioUrl: s.nonEmptyString("The temporary generated audio URL."),
expiresAt: s.nonNegativeInteger("The Unix timestamp when the audio URL expires."),
},
"The generated speech audio.",
["audioUrl"],
),
}),
defineProviderAction(service, {
name: "analyze_document",
description: "Analyze documents or text with Qwen-Doc-Turbo.",
inputSchema: s.actionInput(
{
instruction: s.string("The extraction, classification, review, or summarization instruction.", {
minLength: 1,
}),
documentUrls: s.array("Public URLs for up to ten documents.", s.nonEmptyString("A public document URL."), {
minItems: 1,
maxItems: 10,
}),
text: s.string("Plain text to analyze instead of documents.", { minLength: 1 }),
systemPrompt: s.withDefault(
s.nonEmptyString("The system instruction defining the model role and behavior."),
"You are a helpful assistant.",
),
fileParsingStrategy: s.withDefault(
s.stringEnum("How Qwen parses document URLs.", ["auto", "text_only", "text_and_images"]),
"auto",
),
},
["instruction"],
"A Qwen-Doc-Turbo analysis request using either documentUrls or text.",
),
outputSchema: s.actionOutput(
{
content: s.string("The generated analysis content."),
model: s.nonEmptyString("The model reported by Qwen."),
inputTokens: s.nonNegativeInteger("The number of input tokens billed by Qwen."),
outputTokens: s.nonNegativeInteger("The number of output tokens billed by Qwen."),
},
"The Qwen document analysis result and usage.",
),
}),
defineProviderAction(service, {
name: "create_voice_clone",
description: "Create a Qwen-Audio custom voice from a public audio sample.",
inputSchema: s.actionInput(
{
targetModel: s.withDefault(qwenAudioTtsModelSchema, "qwen-audio-3.0-tts-flash"),
prefix: s.string("An alphanumeric voice-name prefix of at most ten characters.", {
minLength: 1,
maxLength: 10,
pattern: "^[A-Za-z0-9]+$",
}),
audioUrl: s.nonEmptyString("A publicly accessible reference audio URL."),
languageHints: s.array(
"The sample audio language hint; the API currently uses only the first value.",
s.stringEnum("A supported sample language code.", [
"zh",
"en",
"fr",
"de",
"ja",
"ko",
"ru",
"pt",
"th",
"id",
"vi",
"it",
"es",
"ms",
"fil",
"ar",
]),
{ minItems: 1, maxItems: 1 },
),
maxPromptAudioLength: s.number("The maximum reference audio duration used, in seconds.", {
minimum: 3,
maximum: 30,
}),
enablePreprocess: s.boolean("Whether to denoise and enhance the reference audio."),
enableVolumeNormalization: s.boolean("Whether to normalize reference audio volume."),
},
["prefix", "audioUrl"],
"A Qwen-Audio voice cloning request.",
),
outputSchema: s.actionOutput(
{ voiceId: s.nonEmptyString("The created voice identifier accepted by generate_speech.") },
"The created cloned voice.",
),
}),
defineProviderAction(service, {
name: "create_designed_voice",
description: "Create a Qwen-Audio custom voice from a text description.",
inputSchema: s.actionInput(
{
targetModel: s.withDefault(qwenAudioTtsModelSchema, "qwen-audio-3.0-tts-flash"),
prefix: s.string("An alphanumeric voice-name prefix of at most ten characters.", {
minLength: 1,
maxLength: 10,
pattern: "^[A-Za-z0-9]+$",
}),
voicePrompt: s.string("A Chinese or English description of the desired voice.", {
minLength: 1,
maxLength: 500,
}),
previewText: s.string("Chinese or English text spoken by the preview audio.", {
minLength: 1,
maxLength: 200,
}),
languageHints: s.array(
"The preview language hint; the API currently uses only the first value.",
s.stringEnum("A supported preview language code.", ["zh", "en"]),
{ minItems: 1, maxItems: 1 },
),
sampleRate: {
type: "integer",
enum: [16000, 24000, 48000],
default: 24000,
description: "The preview audio sample rate in hertz.",
},
responseFormat: s.withDefault(s.stringEnum("The preview audio encoding.", ["pcm", "wav", "mp3"]), "wav"),
},
["prefix", "voicePrompt", "previewText"],
"A Qwen-Audio voice design request.",
),
outputSchema: s.actionOutput(
{
voiceId: s.nonEmptyString("The created voice identifier accepted by generate_speech."),
previewAudio: s.string("The Base64-encoded preview audio."),
sampleRate: s.positiveInteger("The preview audio sample rate in hertz."),
responseFormat: s.nonEmptyString("The preview audio encoding."),
},
"The created designed voice and preview audio.",
),
}),
defineProviderAction(service, {
name: "list_custom_voices",
description: "List Qwen-Audio cloned and designed voices together.",
inputSchema: s.actionInput(
{
prefix: s.string("An optional voice-name prefix filter.", { minLength: 1, maxLength: 10 }),
pageIndex: s.nonNegativeInteger("The zero-based page index.", { default: 0 }),
pageSize: s.positiveInteger("The number of voices requested per page.", { default: 10 }),
},
[],
"A custom voice list request.",
),
outputSchema: s.actionOutput(
{
voices: s.array("The custom voices on this page.", customVoiceSchema),
pageIndex: s.nonNegativeInteger("The zero-based page index."),
pageSize: s.positiveInteger("The requested page size."),
totalCount: s.nonNegativeInteger("The total number of matching voices."),
},
"A page of Qwen-Audio custom voices.",
),
}),
defineProviderAction(service, {
name: "get_custom_voice",
description: "Get one Qwen-Audio cloned or designed voice.",
inputSchema: s.actionInput(
{ voiceId: s.nonEmptyString("The custom voice identifier.") },
["voiceId"],
"A custom voice lookup.",
),
outputSchema: s.object(
"The Qwen-Audio custom voice details.",
{
voiceId: s.nonEmptyString("The custom voice identifier accepted by generate_speech."),
targetModel: qwenAudioTtsModelSchema,
status: s.string("The upstream voice status."),
createdAt: s.string("The upstream voice creation time."),
modifiedAt: s.string("The upstream voice modification time."),
voicePrompt: s.string("The voice description used for a designed voice."),
previewText: s.string("The preview text used for a designed voice."),
resourceUrl: s.nonEmptyString("The upstream reference or preview audio URL."),
},
{
required: ["voiceId"],
optional: ["targetModel", "status", "createdAt", "modifiedAt", "voicePrompt", "previewText", "resourceUrl"],
},
),
}),
defineProviderAction(service, {
name: "delete_custom_voice",
description: "Delete one Qwen-Audio cloned or designed voice.",
inputSchema: s.actionInput(
{ voiceId: s.nonEmptyString("The custom voice identifier to delete.") },
["voiceId"],
"A custom voice deletion request.",
),
outputSchema: s.actionOutput(
{ voiceId: s.nonEmptyString("The deleted custom voice identifier.") },
"The deleted custom voice.",
),
}),
defineProviderAction(service, {
name: "submit_speech_recognition",
description: "Submit a Qwen-Audio 3.0 asynchronous audio or video transcription task.",
followUpActions: ["qwen.get_speech_recognition"],
asyncLifecycle: {
startActionId: "qwen.submit_speech_recognition",
statusActionId: "qwen.get_speech_recognition",
},
inputSchema: s.actionInput(
{
fileUrl: s.nonEmptyString("A public HTTP or HTTPS URL for the audio or video file."),
languageHints: s.array(
"Possible language codes. Omit to let Qwen detect the language.",
s.nonEmptyString("A BCP-47-style language code supported by Qwen."),
{ maxItems: 4 },
),
channelIds: s.withDefault(
s.array(
"Zero-based audio channel indexes to transcribe. Each channel is billed separately.",
s.nonNegativeInteger("A zero-based audio channel index."),
{ minItems: 1 },
),
[0],
),
vocabularyId: s.nonEmptyString("A precompiled Qwen vocabulary identifier."),
vocabulary: s.array(
"Request-specific hotwords and their recognition weights.",
s.object(
"A request-specific hotword.",
{
text: s.nonEmptyString("The hotword text."),
weight: s.integer("The hotword weight from 1 to 5, or 50 for a super hotword."),
},
{ required: ["text", "weight"] },
),
{ maxItems: 2000 },
),
context: s.array(
"Prior conversation turns or domain context used to improve recognition.",
s.object(
"One prior conversation turn.",
{
userText: s.nonEmptyString("Prior user speech text or domain vocabulary."),
assistantText: s.nonEmptyString("The corresponding prior assistant response."),
},
{ required: ["userText", "assistantText"] },
),
{ maxItems: 5 },
),
specialWordFilter: s.nonEmptyString("The Qwen special-word filtering configuration string."),
diarizationEnabled: s.withDefault(s.boolean("Whether to separate speakers in single-channel audio."), false),
speakerCount: s.integer("The expected speaker count when diarization is enabled.", {
minimum: 2,
maximum: 100,
}),
},
["fileUrl"],
"A Qwen-Audio 3.0 file transcription request.",
),
outputSchema: s.actionOutput({ taskId: speechTaskIdSchema }, "The submitted speech recognition task handle."),
}),
defineProviderAction(service, {
name: "get_speech_recognition",
description: "Retrieve a Qwen-Audio 3.0 transcription task and its normalized result.",
asyncLifecycle: {
startActionId: "qwen.submit_speech_recognition",
statusActionId: "qwen.get_speech_recognition",
},
inputSchema: s.actionInput({ taskId: speechTaskIdSchema }, ["taskId"], "A speech recognition task lookup."),
outputSchema: s.oneOf(
[
s.object(
"A queued or running speech recognition task.",
{
taskId: speechTaskIdSchema,
state: s.literal("processing", { description: "The task is queued or running." }),
progress: s.number("The task progress percentage when available.", { minimum: 0, maximum: 100 }),
},
{ required: ["taskId", "state"], optional: ["progress"] },
),
s.object(
"A succeeded speech recognition task.",
{
taskId: speechTaskIdSchema,
state: s.literal("succeeded", { description: "The task succeeded." }),
transcriptionUrl: s.nonEmptyString("The temporary URL of the original transcription JSON."),
fileUrl: s.nonEmptyString("The transcribed file URL."),
duration: s.nonNegativeInteger("The billable speech duration in seconds."),
transcripts: s.array("Normalized per-channel transcriptions.", speechTranscriptSchema),
},
{ required: ["taskId", "state", "transcriptionUrl", "fileUrl", "duration", "transcripts"] },
),
s.object(
"A terminal speech recognition task that did not succeed.",
{
taskId: speechTaskIdSchema,
state: s.stringEnum("The terminal task state.", ["failed", "cancelled", "expired"]),
error: s.object(
"The upstream terminal error.",
{ code: s.string("The upstream error code."), message: s.string("The upstream error message.") },
{ optional: ["code", "message"] },
),
},
{ required: ["taskId", "state"], optional: ["error"] },
),
],
{ description: "The normalized Qwen speech recognition task state." },
),
}),
defineProviderAction(service, {
name: "submit_image_translation",
description: "Submit an asynchronous Qwen image translation task.",
followUpActions: [translationLifecycle.statusActionId],
asyncLifecycle: translationLifecycle,
inputSchema: s.actionInput(
{
imageUrl: s.nonEmptyString("A public URL for the image to translate."),
sourceLanguage: s.withDefault(
s.stringEnum("The source language, or auto for detection.", [
"auto",
"zh",
"en",
"ko",
"ja",
"ru",
"es",
"fr",
"pt",
"it",
"de",
"vi",
]),
"auto",
),
targetLanguage: s.stringEnum("The target language.", [
"zh",
"en",
"ko",
"ja",
"ru",
"es",
"fr",
"pt",
"it",
"vi",
"ms",
"th",
"id",
"ar",
]),
domainHint: s.string("An English domain hint describing the desired translation style.", { maxLength: 2000 }),
sensitivities: s.array("Exact text values that should not be translated.", s.string("An exact text value."), {
maxItems: 50,
}),
terminologies: s.array(
"Required terminology translations.",
s.object(
"A terminology mapping.",
{ source: s.string("The source term."), target: s.string("The required target term.") },
{ required: ["source", "target"] },
),
{ maxItems: 50 },
),
skipImageSegmentation: s.boolean("Whether to translate all text without foreground segmentation."),
},
["imageUrl", "targetLanguage"],
"A Qwen image translation request.",
),
outputSchema: s.actionOutput({ taskId: translationTaskIdSchema }, "The submitted image translation task handle."),
}),
defineProviderAction(service, {
name: "get_image_translation",
description: "Retrieve a Qwen image translation task state and output.",
asyncLifecycle: translationLifecycle,
inputSchema: s.actionInput({ taskId: translationTaskIdSchema }, ["taskId"], "An image translation task lookup."),
outputSchema: s.oneOf(
[
s.object(
"A queued or running image translation task.",
{
taskId: translationTaskIdSchema,
state: s.literal("processing", { description: "The task is queued or running." }),
progress: s.number("The task progress percentage when available.", { minimum: 0, maximum: 100 }),
},
{ required: ["taskId", "state"], optional: ["progress"] },
),
s.object(
"A succeeded image translation task.",
{
taskId: translationTaskIdSchema,
state: s.literal("succeeded", { description: "The task succeeded." }),
imageUrl: s.nonEmptyString("The temporary translated image URL."),
},
{ required: ["taskId", "state", "imageUrl"] },
),
s.object(
"A terminal image translation task that did not succeed.",
{
taskId: translationTaskIdSchema,
state: s.stringEnum("The terminal task state.", ["failed", "cancelled", "expired"]),
error: s.object(
"The upstream terminal error.",
{ code: s.string("The upstream error code."), message: s.string("The upstream error message.") },
{ optional: ["code", "message"] },
),
},
{ required: ["taskId", "state"], optional: ["error"] },
),
],
{ description: "The normalized Qwen image translation task state." },
),
}),
];
+23
View File
@@ -0,0 +1,23 @@
import type { ProviderDefinition } from "../../core/types.ts";
import { qwenActions } from "./actions.ts";
const service = "qwen";
export const provider: ProviderDefinition = {
service,
displayName: "Qwen",
categories: ["AI", "Design & Media"],
authTypes: ["api_key"],
auth: [
{
type: "api_key",
label: "Model Studio API Key",
placeholder: "Your Beijing Model Studio API key",
description:
"Beijing Model Studio API key sent as a Bearer token. Create one at https://bailian.console.aliyun.com/?apiKey=1#/api-key.",
},
],
homepageUrl: "https://qwen.ai/",
actions: qwenActions,
};
+16
View File
@@ -0,0 +1,16 @@
import type { CredentialValidators, ProviderExecutors } from "../../core/types.ts";
import { defineApiKeyProviderExecutors } from "../provider-runtime.ts";
import { qwenActionHandlers, validateQwenCredential } from "./runtime.ts";
const service = "qwen";
export const executors: ProviderExecutors = defineApiKeyProviderExecutors(service, qwenActionHandlers, {
skipDnsValidation: true,
});
export const credentialValidators: CredentialValidators = {
apiKey(input, { fetcher, signal }) {
return validateQwenCredential(input.apiKey, fetcher, signal);
},
};
+620
View File
@@ -0,0 +1,620 @@
import type { CredentialValidationResult } from "../../core/types.ts";
import type { ApiKeyProviderContext, ProviderActionHandlers, ProviderRuntimeHandler } from "../provider-runtime.ts";
import {
compactObject,
objectArray,
optionalBoolean,
optionalInteger,
optionalNumber,
optionalRecord,
optionalString,
optionalStringArray,
requiredString,
} from "../../core/cast.ts";
import {
ProviderRequestError,
providerResponseError,
providerUserAgent,
readProviderJsonBody,
requiredInputString,
requiredResponseRecord,
runProviderRequest,
} from "../provider-runtime.ts";
import { defaultQwenImageModel } from "./actions.ts";
export const qwenApiBaseUrl = "https://dashscope.aliyuncs.com/api/v1";
const imagePath = "services/aigc/multimodal-generation/generation";
const voiceCustomizationPath = "services/audio/tts/customization";
export const qwenActionHandlers: ProviderActionHandlers<"qwen", ProviderRuntimeHandler<ApiKeyProviderContext>> = {
async create_voice_clone(input, context): Promise<unknown> {
const payload = await requestJson(context, "POST", voiceCustomizationPath, buildCreateVoiceCloneBody(input));
return normalizeCreatedVoice(requiredResponseRecord(payload, "voice clone response"));
},
async create_designed_voice(input, context): Promise<unknown> {
const payload = await requestJson(context, "POST", voiceCustomizationPath, buildCreateDesignedVoiceBody(input));
return normalizeDesignedVoice(requiredResponseRecord(payload, "voice design response"));
},
async list_custom_voices(input, context): Promise<unknown> {
const payload = await requestJson(
context,
"POST",
voiceCustomizationPath,
buildCustomVoiceManagementBody("list_voice", input),
);
return normalizeCustomVoiceList(requiredResponseRecord(payload, "voice list response"), input);
},
async get_custom_voice(input, context): Promise<unknown> {
const voiceId = requiredInputString(input.voiceId, "voiceId");
const payload = await requestJson(
context,
"POST",
voiceCustomizationPath,
buildCustomVoiceManagementBody("query_voice", input),
);
return normalizeCustomVoice(requiredResponseRecord(payload, "voice query response"), voiceId);
},
async delete_custom_voice(input, context): Promise<unknown> {
const voiceId = requiredInputString(input.voiceId, "voiceId");
await requestJson(context, "POST", voiceCustomizationPath, buildCustomVoiceManagementBody("delete_voice", input));
return { voiceId };
},
async generate_image(input, context): Promise<unknown> {
const payload = await requestJson(context, "POST", imagePath, buildQwenImageBody(input));
return normalizeQwenImageResponse(requiredResponseRecord(payload, "image response"));
},
async generate_speech(input, context): Promise<unknown> {
const payload = await requestJson(
context,
"POST",
"services/audio/tts/SpeechSynthesizer",
buildQwenSpeechBody(input),
);
return normalizeQwenSpeechResponse(requiredResponseRecord(payload, "speech response"));
},
async analyze_document(input, context): Promise<unknown> {
const payload = await requestJson(
context,
"POST",
"services/aigc/text-generation/generation",
buildQwenDocumentBody(input),
);
return normalizeQwenDocumentResponse(requiredResponseRecord(payload, "document response"));
},
async submit_speech_recognition(input, context): Promise<unknown> {
const payload = await requestJson(
context,
"POST",
"services/audio/asr/transcription",
buildQwenSpeechRecognitionBody(input),
true,
);
const output = requiredResponseRecord(
requiredResponseRecord(payload, "speech recognition submit response").output,
"output",
);
return { taskId: requiredString(output.task_id, "output.task_id", providerResponseError) };
},
async get_speech_recognition(input, context): Promise<unknown> {
const taskId = requiredInputString(input.taskId, "taskId");
const payload = await requestJson(context, "GET", `tasks/${encodeURIComponent(taskId)}`);
return normalizeQwenSpeechRecognitionTask(payload, taskId, context.fetcher, context.signal);
},
async submit_image_translation(input, context): Promise<unknown> {
const payload = await requestJson(
context,
"POST",
"services/aigc/image2image/image-synthesis",
buildImageTranslationBody(input),
true,
);
const output = requiredResponseRecord(
requiredResponseRecord(payload, "translation submit response").output,
"output",
);
return { taskId: requiredString(output.task_id, "output.task_id", providerResponseError) };
},
async get_image_translation(input, context): Promise<unknown> {
const taskId = requiredInputString(input.taskId, "taskId");
const payload = await requestJson(context, "GET", `tasks/${encodeURIComponent(taskId)}`);
return normalizeImageTranslationTask(payload, taskId);
},
};
export function buildCreateVoiceCloneBody(input: Record<string, unknown>): Record<string, unknown> {
return {
model: "voice-enrollment",
input: compactObject({
action: "create_voice",
target_model: optionalString(input.targetModel) ?? "qwen-audio-3.0-tts-flash",
prefix: requiredInputString(input.prefix, "prefix"),
url: requiredInputString(input.audioUrl, "audioUrl"),
language_hints: optionalStringArray(input.languageHints),
max_prompt_audio_length: optionalNumber(input.maxPromptAudioLength),
enable_preprocess: optionalBoolean(input.enablePreprocess),
enable_volume_normalization:
typeof input.enableVolumeNormalization === "boolean" ? String(input.enableVolumeNormalization) : undefined,
}),
};
}
export function buildCreateDesignedVoiceBody(input: Record<string, unknown>): Record<string, unknown> {
return {
model: "voice-enrollment",
input: compactObject({
action: "create_voice",
target_model: optionalString(input.targetModel) ?? "qwen-audio-3.0-tts-flash",
prefix: requiredInputString(input.prefix, "prefix"),
voice_prompt: requiredInputString(input.voicePrompt, "voicePrompt"),
preview_text: requiredInputString(input.previewText, "previewText"),
language_hints: optionalStringArray(input.languageHints),
}),
parameters: {
sample_rate: optionalInteger(input.sampleRate) ?? 24000,
response_format: optionalString(input.responseFormat) ?? "wav",
},
};
}
export function buildCustomVoiceManagementBody(
action: "list_voice" | "query_voice" | "delete_voice",
input: Record<string, unknown>,
): Record<string, unknown> {
return {
model: "voice-enrollment",
input: compactObject({
action,
prefix: action === "list_voice" ? optionalString(input.prefix) : undefined,
page_index: action === "list_voice" ? (optionalInteger(input.pageIndex) ?? 0) : undefined,
page_size: action === "list_voice" ? (optionalInteger(input.pageSize) ?? 10) : undefined,
voice_id: action === "list_voice" ? undefined : requiredInputString(input.voiceId, "voiceId"),
}),
};
}
export function normalizeCreatedVoice(payload: Record<string, unknown>): Record<string, unknown> {
const output = requiredResponseRecord(payload.output, "voice output");
return { voiceId: requiredString(output.voice_id, "output.voice_id", providerResponseError) };
}
export function normalizeDesignedVoice(payload: Record<string, unknown>): Record<string, unknown> {
const output = requiredResponseRecord(payload.output, "voice design output");
const previewAudio = requiredResponseRecord(output.preview_audio, "output.preview_audio");
return {
voiceId: requiredString(output.voice_id, "output.voice_id", providerResponseError),
previewAudio: requiredString(previewAudio.data, "output.preview_audio.data", providerResponseError),
sampleRate: optionalInteger(previewAudio.sample_rate) ?? 0,
responseFormat: requiredString(
previewAudio.response_format,
"output.preview_audio.response_format",
providerResponseError,
),
};
}
export function normalizeCustomVoiceList(
payload: Record<string, unknown>,
input: Record<string, unknown>,
): Record<string, unknown> {
const output = requiredResponseRecord(payload.output, "voice list output");
const voices = objectArray(output.voice_list, "output.voice_list", providerResponseError).map((voice) =>
normalizeCustomVoiceFields(
voice,
requiredString(voice.voice_id, "output.voice_list.voice_id", providerResponseError),
),
);
return {
voices,
pageIndex: optionalInteger(output.page_index) ?? optionalInteger(input.pageIndex) ?? 0,
pageSize: optionalInteger(output.page_size) ?? optionalInteger(input.pageSize) ?? 10,
totalCount: optionalInteger(output.total_count) ?? voices.length,
};
}
export function normalizeCustomVoice(payload: Record<string, unknown>, voiceId: string): Record<string, unknown> {
return normalizeCustomVoiceFields(requiredResponseRecord(payload.output, "voice query output"), voiceId);
}
function normalizeCustomVoiceFields(voice: Record<string, unknown>, voiceId: string): Record<string, unknown> {
return compactObject({
voiceId,
targetModel: optionalString(voice.target_model),
status: optionalString(voice.status),
createdAt: optionalString(voice.gmt_create),
modifiedAt: optionalString(voice.gmt_modified),
voicePrompt: optionalString(voice.voice_prompt),
previewText: optionalString(voice.preview_text),
resourceUrl: optionalString(voice.resource_link),
});
}
export function buildQwenDocumentBody(input: Record<string, unknown>): Record<string, unknown> {
const documentUrls = optionalStringArray(input.documentUrls);
const text = optionalString(input.text);
if ((documentUrls?.length ? 1 : 0) + (text ? 1 : 0) !== 1)
throw inputError("exactly one of documentUrls or text is required");
const instruction = requiredInputString(input.instruction, "instruction");
const userContent = documentUrls
? [
{ type: "text", text: instruction },
{
type: "doc_url",
doc_url: documentUrls,
file_parsing_strategy: optionalString(input.fileParsingStrategy) ?? "auto",
},
]
: `${instruction}\n\n${text}`;
return {
model: "qwen-doc-turbo",
input: {
messages: [
{
role: "system",
content: optionalString(input.systemPrompt) ?? "You are a helpful assistant.",
},
{ role: "user", content: userContent },
],
},
};
}
export function normalizeQwenDocumentResponse(payload: Record<string, unknown>): Record<string, unknown> {
const output = requiredResponseRecord(payload.output, "output");
const choices = objectArray(output.choices, "output.choices", providerResponseError);
const message = requiredResponseRecord(choices[0]?.message, "output.choices.message");
const usage = optionalRecord(payload.usage);
return {
content: requiredString(message.content, "output.choices.message.content", providerResponseError),
model: optionalString(payload.model) ?? "qwen-doc-turbo",
inputTokens: optionalInteger(usage?.input_tokens) ?? 0,
outputTokens: optionalInteger(usage?.output_tokens) ?? 0,
};
}
export function buildQwenSpeechRecognitionBody(input: Record<string, unknown>): Record<string, unknown> {
const vocabulary = optionalInputObjectArray(input.vocabulary, "vocabulary");
const vocabularyMap = vocabulary
? Object.fromEntries(
vocabulary.map((item) => [
requiredInputString(item.text, "vocabulary.text"),
requiredHotwordWeight(item.weight),
]),
)
: undefined;
const context = optionalInputObjectArray(input.context, "context")?.flatMap((turn) => [
{
role: "user",
content: [{ type: "input_text", text: requiredInputString(turn.userText, "context.userText") }],
},
{
role: "assistant",
content: [{ type: "text", text: requiredInputString(turn.assistantText, "context.assistantText") }],
},
]);
const speakerCount = optionalInteger(input.speakerCount);
if (speakerCount !== undefined && optionalBoolean(input.diarizationEnabled) !== true)
throw inputError("speakerCount requires diarizationEnabled");
return {
model: "qwen-audio-3.0-asr-flash-filetrans",
input: compactObject({ file_urls: [requiredInputString(input.fileUrl, "fileUrl")], context }),
parameters: compactObject({
language_hints: optionalStringArray(input.languageHints),
channel_id: optionalIntegerArray(input.channelIds, "channelIds") ?? [0],
vocabulary_id: optionalString(input.vocabularyId),
vocabulary: vocabularyMap,
special_word_filter: optionalString(input.specialWordFilter),
diarization_enabled: optionalBoolean(input.diarizationEnabled) ?? false,
speaker_count: speakerCount,
}),
};
}
export async function normalizeQwenSpeechRecognitionTask(
payload: unknown,
fallbackTaskId: string,
fetcher: typeof fetch,
signal?: AbortSignal,
): Promise<Record<string, unknown>> {
const response = requiredResponseRecord(payload, "speech recognition task response");
const output = requiredResponseRecord(response.output, "output");
const taskId = optionalString(output.task_id) ?? fallbackTaskId;
const status = requiredString(output.task_status, "output.task_status", providerResponseError);
if (status === "PENDING" || status === "RUNNING") return { taskId, state: "processing" };
if (status === "FAILED" || status === "CANCELED" || status === "UNKNOWN") {
return compactObject({
taskId,
state: status === "FAILED" ? "failed" : status === "CANCELED" ? "cancelled" : "expired",
error: compactObject({ code: optionalString(output.code), message: optionalString(output.message) }),
});
}
if (status !== "SUCCEEDED") throw providerResponseError(`Qwen returned an unknown task status: ${status}`);
const results = objectArray(output.results, "output.results", providerResponseError);
const succeeded = results.find((result) => optionalString(result.subtask_status) === "SUCCEEDED");
if (!succeeded) {
return compactObject({
taskId,
state: "failed",
error: compactObject({ code: optionalString(results[0]?.code), message: optionalString(results[0]?.message) }),
});
}
const transcriptionUrl = requiredString(
succeeded.transcription_url,
"output.results.transcription_url",
providerResponseError,
);
const transcription = await runProviderRequest(
{ label: "Qwen transcription download", signal, timeoutMs: 180_000 },
async (requestSignal) => {
const response = await fetcher(transcriptionUrl, {
headers: { accept: "application/json" },
signal: requestSignal,
});
const body = await readProviderJsonBody(response, {
emptyBody: {},
invalidJsonMessage: "Qwen returned an invalid transcription JSON response",
});
if (!response.ok)
throw new ProviderRequestError(502, `Qwen transcription download failed with HTTP ${response.status}`);
return body;
},
);
const record = requiredResponseRecord(transcription, "transcription");
const usage = optionalRecord(response.usage);
return {
taskId,
state: "succeeded",
transcriptionUrl,
fileUrl: requiredString(record.file_url, "transcription.file_url", providerResponseError),
duration: optionalInteger(usage?.duration) ?? 0,
transcripts: normalizeSpeechTranscripts(record.transcripts),
};
}
function normalizeSpeechTranscripts(value: unknown): Record<string, unknown>[] {
return objectArray(value, "transcription.transcripts", providerResponseError).map((transcript) => ({
channelId: optionalInteger(transcript.channel_id) ?? 0,
text: optionalString(transcript.text) ?? "",
contentDuration: optionalInteger(transcript.content_duration_in_milliseconds) ?? 0,
sentences: objectArray(transcript.sentences, "transcription.transcripts.sentences", providerResponseError).map(
(sentence) =>
compactObject({
text: optionalString(sentence.text) ?? "",
beginTime: optionalInteger(sentence.begin_time) ?? 0,
endTime: optionalInteger(sentence.end_time) ?? 0,
sentenceId: optionalInteger(sentence.sentence_id) ?? 0,
speakerId: optionalInteger(sentence.speaker_id),
language: optionalString(sentence.language),
emotion: optionalString(sentence.emotion),
words: Array.isArray(sentence.words)
? objectArray(sentence.words, "transcription.transcripts.sentences.words", providerResponseError).map(
(word) => ({
text: optionalString(word.text) ?? "",
beginTime: optionalInteger(word.begin_time) ?? 0,
endTime: optionalInteger(word.end_time) ?? 0,
punctuation: optionalString(word.punctuation) ?? "",
}),
)
: undefined,
}),
),
}));
}
export function buildQwenSpeechBody(input: Record<string, unknown>): Record<string, unknown> {
const format = optionalString(input.format) ?? "mp3";
if (input.bitRate !== undefined && format !== "opus") throw inputError("bitRate requires format opus");
return {
model: optionalString(input.model) ?? "qwen-audio-3.0-tts-flash",
input: compactObject({
text: requiredInputString(input.text, "text"),
voice: optionalString(input.voice) ?? "longanhuan_v3.6",
format,
sample_rate: optionalInteger(input.sampleRate) ?? 22050,
volume: optionalInteger(input.volume),
rate: optionalNumber(input.rate),
pitch: optionalNumber(input.pitch),
instruction: optionalString(input.instruction),
language_hints: optionalStringArray(input.languageHints),
enable_ssml: optionalBoolean(input.enableSsml),
seed: optionalInteger(input.seed),
bit_rate: optionalInteger(input.bitRate),
enable_aigc_tag: optionalBoolean(input.enableAigcTag),
}),
};
}
export function normalizeQwenSpeechResponse(payload: Record<string, unknown>): Record<string, unknown> {
const output = requiredResponseRecord(payload.output, "speech output");
const audio = requiredResponseRecord(output.audio, "speech output.audio");
return compactObject({
audioUrl: requiredString(audio.url, "output.audio.url", providerResponseError),
expiresAt: optionalInteger(audio.expires_at),
});
}
export function buildQwenImageBody(input: Record<string, unknown>): Record<string, unknown> {
const images = optionalStringArray(input.images) ?? [];
if (images.length > 0 && optionalString(input.promptExtendMode) === "agent")
throw inputError("promptExtendMode agent is only supported without reference images");
return {
model: optionalString(input.model) ?? defaultQwenImageModel,
input: {
messages: [
{
role: "user",
content: [...images.map((image) => ({ image })), { text: requiredInputString(input.prompt, "prompt") }],
},
],
},
parameters: compactObject({
size: optionalString(input.size),
n: optionalInteger(input.imageCount) ?? 1,
negative_prompt: optionalString(input.negativePrompt),
prompt_extend: optionalBoolean(input.promptExtend) ?? true,
prompt_extend_mode: optionalString(input.promptExtendMode) ?? "direct",
enable_thinking: optionalBoolean(input.enableThinking) ?? true,
watermark: optionalBoolean(input.watermark) ?? false,
seed: optionalInteger(input.seed),
}),
};
}
export function normalizeQwenImageResponse(payload: Record<string, unknown>): Record<string, unknown> {
const output = requiredResponseRecord(payload.output, "output");
const usage = requiredResponseRecord(payload.usage, "usage");
const images = objectArray(output.choices, "output.choices", providerResponseError).flatMap((choice) => {
const message = optionalRecord(choice.message);
return message
? objectArray(message.content, "output.choices.message.content", providerResponseError).flatMap((item) => {
const image = optionalString(item.image);
return image ? [image] : [];
})
: [];
});
if (images.length === 0) throw providerResponseError("Qwen returned no generated images");
return compactObject({
images,
imageCount: optionalInteger(usage.output_image_count) ?? images.length,
width: optionalInteger(usage.output_width),
height: optionalInteger(usage.output_height),
inputImageCount: optionalInteger(usage.input_image_count) ?? 0,
});
}
export function buildImageTranslationBody(input: Record<string, unknown>): Record<string, unknown> {
const terminologies =
input.terminologies === undefined
? undefined
: objectArray(input.terminologies, "terminologies", inputError).map((item) => ({
src: requiredInputString(item.source, "terminologies.source"),
tgt: requiredInputString(item.target, "terminologies.target"),
}));
const ext = compactObject({
domainHint: optionalString(input.domainHint),
sensitivities: optionalStringArray(input.sensitivities),
terminologies,
config:
input.skipImageSegmentation === undefined
? undefined
: { skipImgSegment: optionalBoolean(input.skipImageSegmentation) },
});
return {
model: "qwen-mt-image",
input: compactObject({
image_url: requiredInputString(input.imageUrl, "imageUrl"),
source_lang: optionalString(input.sourceLanguage) ?? "auto",
target_lang: requiredInputString(input.targetLanguage, "targetLanguage"),
ext: Object.keys(ext).length ? ext : undefined,
}),
};
}
export function normalizeImageTranslationTask(payload: unknown, fallbackTaskId: string): Record<string, unknown> {
const response = requiredResponseRecord(payload, "translation task response");
const output = requiredResponseRecord(response.output, "output");
const taskId = optionalString(output.task_id) ?? fallbackTaskId;
const status = requiredString(output.task_status, "output.task_status", providerResponseError);
if (status === "PENDING" || status === "RUNNING") return { taskId, state: "processing" };
if (status === "SUCCEEDED") {
return {
taskId,
state: "succeeded",
imageUrl: requiredString(output.image_url, "output.image_url", providerResponseError),
};
}
if (status === "FAILED" || status === "CANCELED" || status === "UNKNOWN") {
return compactObject({
taskId,
state: status === "FAILED" ? "failed" : status === "CANCELED" ? "cancelled" : "expired",
error: compactObject({ code: optionalString(output.code), message: optionalString(output.message) }),
});
}
throw providerResponseError(`Qwen returned an unknown image translation task status: ${status}`);
}
export async function validateQwenCredential(
apiKey: string,
fetcher: typeof fetch,
signal?: AbortSignal,
): Promise<CredentialValidationResult> {
try {
await requestJson({ apiKey, fetcher, signal }, "GET", "tasks/connector-credential-validation");
} catch (error) {
if (!(error instanceof ProviderRequestError) || error.status !== 404) throw error;
}
return {
profile: { accountId: "qwen:api_key", displayName: "Qwen Model Studio API Key" },
grantedScopes: [],
metadata: { apiBaseUrl: qwenApiBaseUrl },
};
}
async function requestJson(
context: ApiKeyProviderContext,
method: "GET" | "POST",
path: string,
body?: Record<string, unknown>,
asynchronous = false,
): Promise<unknown> {
context.signal?.throwIfAborted();
return runProviderRequest({ label: "Qwen", signal: context.signal, timeoutMs: 180_000 }, async (signal) => {
const response = await context.fetcher(new URL(path, `${qwenApiBaseUrl}/`), {
method,
headers: {
accept: "application/json",
authorization: `Bearer ${context.apiKey}`,
...(body ? { "content-type": "application/json" } : {}),
"user-agent": providerUserAgent,
...(asynchronous ? { "x-dashscope-async": "enable" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal,
});
const payload = await readProviderJsonBody(response, {
emptyBody: {},
invalidJsonMessage: "Qwen returned an invalid JSON response",
});
if (!response.ok) handleError(response, payload);
return payload;
});
}
function handleError(response: Response, payload: unknown): never {
const record = optionalRecord(payload);
const message = optionalString(record?.message) ?? `Qwen request failed with HTTP ${response.status}`;
if (response.status === 400 || response.status === 404 || response.status === 422)
throw new ProviderRequestError(
response.status === 404 ? 404 : 400,
message,
{ status: response.status },
"invalid_input",
);
if (response.status === 429) throw new ProviderRequestError(429, message, { status: response.status });
throw new ProviderRequestError(response.status >= 500 ? 502 : response.status, message, { status: response.status });
}
function inputError(message: string): ProviderRequestError {
return new ProviderRequestError(400, message, undefined, "invalid_input");
}
function optionalInputObjectArray(value: unknown, field: string): Array<Record<string, unknown>> | undefined {
return value === undefined ? undefined : objectArray(value, field, inputError);
}
function optionalIntegerArray(value: unknown, field: string): number[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value)) throw inputError(`${field} must be an array`);
return value.map((item) => requiredInputInteger(item, field));
}
function requiredInputInteger(value: unknown, field: string): number {
if (!Number.isInteger(value)) throw inputError(`${field} must be an integer`);
return value as number;
}
function requiredHotwordWeight(value: unknown): number {
const weight = requiredInputInteger(value, "vocabulary.weight");
if ((1 <= weight && weight <= 5) || weight === 50) return weight;
throw inputError("vocabulary.weight must be from 1 to 5, or 50");
}
@@ -4,7 +4,7 @@ import type { JsonSchema } from "../../core/types.ts";
import { s } from "../../core/json-schema.ts";
import { defineProviderAction } from "../../core/provider-definition.ts";
const service = "volcengine_ark";
const service = "seedance";
export const defaultSeedanceModel = "doubao-seedance-2-0-260128";
export const fastSeedanceModel = "doubao-seedance-2-0-fast-260128";
@@ -12,7 +12,7 @@ export const fastSeedanceModel = "doubao-seedance-2-0-fast-260128";
const taskIdSchema = s.nonEmptyString("The opaque Seedance task identifier returned by the selected connection.");
const modelSchema: JsonSchema = {
...s.withDefault(
s.nonEmptyString("The Volcengine Ark Model ID or Endpoint ID used for Seedance video generation."),
s.nonEmptyString("The Seedance Model ID or Endpoint ID used for Seedance video generation."),
defaultSeedanceModel,
),
examples: [defaultSeedanceModel],
@@ -57,7 +57,7 @@ const audioSchema = s.object(
{ required: ["url"] },
);
const errorSchema = s.object(
"The terminal error reported by Volcengine Ark when the task did not succeed.",
"The terminal error reported by Seedance when the task did not succeed.",
{
code: s.string("The upstream error code when provided."),
message: s.string("The upstream error message when provided."),
@@ -201,29 +201,29 @@ const submitInputSchema = s.requireAnyProperty(
);
const lifecycle = {
startActionId: "volcengine_ark.submit_seedance_video_generation",
statusActionId: "volcengine_ark.get_seedance_video_generation",
startActionId: "seedance.submit_video_generation",
statusActionId: "seedance.get_video_generation",
};
export const volcengineArkActions: ProviderActionDefinition[] = [
export const seedanceActions: ProviderActionDefinition[] = [
defineProviderAction(service, {
name: "submit_seedance_video_generation",
description: "Submit an asynchronous Seedance video generation task through Volcengine Ark.",
name: "submit_video_generation",
description: "Submit an asynchronous Seedance video generation task through Seedance.",
followUpActions: [lifecycle.statusActionId],
asyncLifecycle: lifecycle,
inputSchema: submitInputSchema,
outputSchema: s.actionOutput({ taskId: taskIdSchema }, "The submitted Seedance task handle."),
}),
defineProviderAction(service, {
name: "get_seedance_video_generation",
name: "get_video_generation",
description: "Retrieve a Seedance task state and its generated video when available.",
asyncLifecycle: lifecycle,
inputSchema: s.actionInput({ taskId: taskIdSchema }, ["taskId"], "A Seedance task lookup."),
outputSchema: seedanceTaskSchema,
}),
defineProviderAction(service, {
name: "list_seedance_video_generations",
description: "List Seedance video generation tasks visible to the configured Volcengine Ark API key.",
name: "list_video_generations",
description: "List Seedance video generation tasks visible to the configured Seedance API key.",
inputSchema: s.actionInput(
{
pageNumber: s.integer("The result page number.", { minimum: 1, maximum: 500 }),
@@ -236,7 +236,7 @@ export const volcengineArkActions: ProviderActionDefinition[] = [
"failed",
]),
taskIds: s.array("Filter by exact Seedance task identifiers.", taskIdSchema),
model: s.nonEmptyString("Filter by an exact Volcengine Ark Endpoint ID."),
model: s.nonEmptyString("Filter by an exact Seedance Endpoint ID."),
serviceTier: s.stringEnum("Filter by the processing service tier.", ["default", "flex"]),
},
[],
@@ -251,13 +251,13 @@ export const volcengineArkActions: ProviderActionDefinition[] = [
),
}),
defineProviderAction(service, {
name: "delete_seedance_video_generation",
description: "Cancel a queued Seedance task or delete a task according to Volcengine Ark task-state semantics.",
name: "delete_video_generation",
description: "Cancel a queued Seedance task or delete a task according to Seedance task-state semantics.",
inputSchema: s.actionInput({ taskId: taskIdSchema }, ["taskId"], "A Seedance task cancellation or deletion."),
outputSchema: s.actionOutput(
{
taskId: taskIdSchema,
accepted: s.boolean("Whether Volcengine Ark accepted the cancellation or deletion."),
accepted: s.boolean("Whether Seedance accepted the cancellation or deletion."),
},
"The Seedance cancellation or deletion result.",
),
+23
View File
@@ -0,0 +1,23 @@
import type { ProviderDefinition } from "../../core/types.ts";
import { seedanceActions } from "./actions.ts";
const service = "seedance";
export const provider: ProviderDefinition = {
service,
displayName: "Seedance",
categories: ["AI", "Design & Media"],
authTypes: ["api_key"],
auth: [
{
type: "api_key",
label: "Ark API Key",
placeholder: "Your Seedance API key",
description:
"Seedance API key sent as a Bearer token. Create an API key in the Ark console: https://console.volcengine.com/ark/region:ark+cn-beijing/apikey.",
},
],
homepageUrl: "https://www.volcengine.com/product/ark",
actions: seedanceActions,
};
+16
View File
@@ -0,0 +1,16 @@
import type { CredentialValidators, ProviderExecutors } from "../../core/types.ts";
import { defineApiKeyProviderExecutors } from "../provider-runtime.ts";
import { validateSeedanceCredential, seedanceActionHandlers } from "./runtime.ts";
const service = "seedance";
export const executors: ProviderExecutors = defineApiKeyProviderExecutors(service, seedanceActionHandlers, {
skipDnsValidation: true,
});
export const credentialValidators: CredentialValidators = {
apiKey(input, { fetcher, signal }) {
return validateSeedanceCredential(input.apiKey, fetcher, signal);
},
};
@@ -26,7 +26,7 @@ import {
} from "../provider-runtime.ts";
import { defaultSeedanceModel, fastSeedanceModel } from "./actions.ts";
export const volcengineArkApiBaseUrl = "https://ark.cn-beijing.volces.com/api/v3";
export const seedanceApiBaseUrl = "https://ark.cn-beijing.volces.com/api/v3";
const seedanceTasksPath = "contents/generations/tasks";
interface SeedanceImageInput {
@@ -72,14 +72,14 @@ interface SeedanceRequestInput {
path?: string;
query?: URLSearchParams;
body?: Record<string, unknown>;
phase?: "validate" | "execute";
resourcePath?: string;
}
export const volcengineArkActionHandlers: ProviderActionHandlers<
"volcengine_ark",
export const seedanceActionHandlers: ProviderActionHandlers<
"seedance",
ProviderRuntimeHandler<ApiKeyProviderContext>
> = {
async submit_seedance_video_generation(input, context): Promise<unknown> {
async submit_video_generation(input, context): Promise<unknown> {
const normalized = readSeedanceSubmitInput(input);
const payload = await requestArkJson({
context,
@@ -89,7 +89,7 @@ export const volcengineArkActionHandlers: ProviderActionHandlers<
const record = requiredResponseRecord(payload, "submit response");
return { taskId: requiredString(record.id, "id", providerResponseError) };
},
async get_seedance_video_generation(input, context): Promise<unknown> {
async get_video_generation(input, context): Promise<unknown> {
const taskId = requiredInputString(input.taskId, "taskId");
const payload = await requestArkJson({
context,
@@ -98,7 +98,7 @@ export const volcengineArkActionHandlers: ProviderActionHandlers<
});
return normalizeSeedanceTask(payload, taskId);
},
async list_seedance_video_generations(input, context): Promise<unknown> {
async list_video_generations(input, context): Promise<unknown> {
const query = new URLSearchParams();
setIntegerQuery(query, "page_num", input.pageNumber);
setIntegerQuery(query, "page_size", input.pageSize);
@@ -118,7 +118,7 @@ export const volcengineArkActionHandlers: ProviderActionHandlers<
total: requireArkInteger(record.total, "total"),
};
},
async delete_seedance_video_generation(input, context): Promise<unknown> {
async delete_video_generation(input, context): Promise<unknown> {
const taskId = requiredInputString(input.taskId, "taskId");
await requestArkJson({
context,
@@ -129,7 +129,7 @@ export const volcengineArkActionHandlers: ProviderActionHandlers<
},
};
export async function validateVolcengineArkCredential(
export async function validateSeedanceCredential(
apiKey: string,
fetcher: typeof fetch,
signal?: AbortSignal,
@@ -139,15 +139,14 @@ export async function validateVolcengineArkCredential(
context: { apiKey, fetcher, signal },
method: "GET",
query,
phase: "validate",
});
return {
profile: {
accountId: "volcengine_ark:api_key",
displayName: "Volcengine Ark API Key",
accountId: "seedance:api_key",
displayName: "Seedance API Key",
},
grantedScopes: [],
metadata: { apiBaseUrl: volcengineArkApiBaseUrl },
metadata: { apiBaseUrl: seedanceApiBaseUrl },
};
}
@@ -254,14 +253,14 @@ export function normalizeSeedanceTask(payload: unknown, fallbackTaskId: string):
if (status === "failed" || status === "cancelled" || status === "expired") {
return { ...common, state: status, error: normalizeError(record.error) };
}
throw new ProviderRequestError(502, `Volcengine Ark returned an unknown Seedance task status: ${status}`);
throw new ProviderRequestError(502, `Seedance returned an unknown Seedance task status: ${status}`);
}
async function requestArkJson(input: SeedanceRequestInput): Promise<unknown> {
input.context.signal?.throwIfAborted();
const url = new URL(`${seedanceTasksPath}${input.path ?? ""}`, `${volcengineArkApiBaseUrl}/`);
const url = new URL(`${input.resourcePath ?? seedanceTasksPath}${input.path ?? ""}`, `${seedanceApiBaseUrl}/`);
if (input.query) url.search = input.query.toString();
return runProviderRequest({ label: "Volcengine Ark", signal: input.context.signal }, async (signal) => {
return runProviderRequest({ label: "Seedance", signal: input.context.signal }, async (signal) => {
const response = await input.context.fetcher(url, {
method: input.method,
headers: {
@@ -275,14 +274,14 @@ async function requestArkJson(input: SeedanceRequestInput): Promise<unknown> {
});
const payload = await readProviderJsonBody(response, {
emptyBody: {},
invalidJsonMessage: "Volcengine Ark returned an invalid JSON response",
invalidJsonMessage: "Seedance returned an invalid JSON response",
});
if (!response.ok) handleArkError(response, payload, input.phase ?? "execute");
if (!response.ok) handleArkError(response, payload);
return payload;
});
}
function handleArkError(response: Response, payload: unknown, phase: "validate" | "execute"): never {
function handleArkError(response: Response, payload: unknown): never {
const record = optionalRecord(payload);
const nestedError = optionalRecord(record?.error);
const code = optionalString(record?.code) ?? optionalString(nestedError?.code);
@@ -290,11 +289,10 @@ function handleArkError(response: Response, payload: unknown, phase: "validate"
optionalString(record?.message) ??
optionalString(record?.error) ??
optionalString(nestedError?.message) ??
`Volcengine Ark request failed with HTTP ${response.status}`;
`Seedance request failed with HTTP ${response.status}`;
const details = compactObject({ code, status: response.status });
if (response.status === 401 || response.status === 403) {
throw new ProviderRequestError(phase === "validate" ? 400 : response.status, message, details);
}
if (response.status === 401 || response.status === 403)
throw new ProviderRequestError(response.status, message, details);
if (response.status === 400 || response.status === 404 || response.status === 422) {
throw new ProviderRequestError(response.status === 404 ? 404 : 400, message, details, "invalid_input");
}
@@ -384,7 +382,7 @@ function normalizeError(value: unknown): Record<string, unknown> | undefined {
function requireArkInteger(value: unknown, field: string): number {
const integer = optionalInteger(value);
if (integer === undefined) throw providerResponseError(`Volcengine Ark ${field} must be an integer`);
if (integer === undefined) throw providerResponseError(`Seedance ${field} must be an integer`);
return integer;
}
@@ -1,23 +0,0 @@
import type { ProviderDefinition } from "../../core/types.ts";
import { volcengineArkActions } from "./actions.ts";
const service = "volcengine_ark";
export const provider: ProviderDefinition = {
service,
displayName: "Volcengine Ark",
categories: ["AI", "Design & Media"],
authTypes: ["api_key"],
auth: [
{
type: "api_key",
label: "Ark API Key",
placeholder: "Your Volcengine Ark API key",
description:
"Volcengine Ark API key sent as a Bearer token. Create an API key in the Ark console: https://console.volcengine.com/ark/region:ark+cn-beijing/apikey.",
},
],
homepageUrl: "https://www.volcengine.com/product/ark",
actions: volcengineArkActions,
};
@@ -1,100 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildSeedanceSubmitBody,
normalizeSeedanceTask,
readSeedanceSubmitInput,
volcengineArkActionHandlers,
} from "./runtime.ts";
describe("Volcengine Ark Seedance runtime", () => {
it("builds the official structured content request with defaults", () => {
const input = readSeedanceSubmitInput({
prompt: "A cat watches the sunrise.",
images: [{ url: "https://example.com/first.png", role: "first_frame" }],
});
expect(buildSeedanceSubmitBody(input)).toEqual({
model: "doubao-seedance-2-0-260128",
content: [
{ type: "text", text: "A cat watches the sunrise." },
{
type: "image_url",
image_url: { url: "https://example.com/first.png" },
role: "first_frame",
},
],
return_last_frame: false,
generate_audio: true,
resolution: "720p",
ratio: "adaptive",
duration: 5,
watermark: false,
});
});
it("rejects incompatible frame and reference inputs", () => {
expect(() =>
readSeedanceSubmitInput({
images: [{ url: "https://example.com/first.png", role: "first_frame" }],
videos: [{ url: "https://example.com/reference.mp4", role: "reference_video" }],
}),
).toThrow("frame images cannot be mixed");
});
it("allows custom Ark Endpoint IDs without model-name inference", () => {
expect(
readSeedanceSubmitInput({
model: "ep-20260831-example",
prompt: "A landscape shot.",
resolution: "1080p",
}).model,
).toBe("ep-20260831-example");
});
it("normalizes succeeded and terminal task states", () => {
expect(
normalizeSeedanceTask(
{
id: "cgt-1",
model: "doubao-seedance-2-0-260128",
status: "succeeded",
content: { video_url: "https://example.com/video.mp4" },
duration: "5",
usage: { total_tokens: 100 },
},
"fallback",
),
).toMatchObject({
taskId: "cgt-1",
state: "succeeded",
videoUrl: "https://example.com/video.mp4",
duration: 5,
usage: { totalTokens: 100 },
});
expect(
normalizeSeedanceTask(
{ id: "cgt-2", status: "expired", error: { code: "Expired", message: "Task expired" } },
"fallback",
),
).toMatchObject({ taskId: "cgt-2", state: "expired", error: { code: "Expired" } });
});
it("maps upstream not-found responses to invalid_input with HTTP 404 details", async () => {
const fetcher = async (): Promise<Response> =>
new Response(JSON.stringify({ error: { code: "NotFound", message: "Task not found" } }), {
status: 404,
headers: { "content-type": "application/json" },
});
await expect(
volcengineArkActionHandlers.get_seedance_video_generation(
{ taskId: "cgt-missing" },
{ apiKey: "secret", fetcher: fetcher as typeof fetch },
),
).rejects.toMatchObject({
status: 404,
code: "invalid_input",
message: "Task not found",
});
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { randomUUID as randomUUIDv7 } from "node:crypto";
import { optionalNumber as asOptionalNumber, optionalString as asOptionalString } from "../../core/cast.ts";
import { randomUUIDv7 } from "../../core/uuid-v7.ts";
import { ProviderRequestError, providerUserAgent as connectorUserAgent } from "../provider-runtime.ts";
export const walmartMarketplaceApiBaseUrl = "https://marketplace.walmartapis.com";
+150
View File
@@ -0,0 +1,150 @@
import type { ProviderActionDefinition } from "../../core/provider-definition.ts";
import type { JsonSchema } from "../../core/types.ts";
import { s } from "../../core/json-schema.ts";
import { defineProviderAction } from "../../core/provider-definition.ts";
const service = "wanx";
export const defaultImageModel = "wan2.7-image";
export const defaultVideoModel = "wan3.0-video";
const taskIdSchema = s.nonEmptyString("The opaque Wanx task identifier returned by the selected connection.");
const mediaSchema = s.object(
"A media input for Wan 3.0 video generation.",
{
type: s.stringEnum("How Wan 3.0 uses this media.", [
"first_frame",
"last_frame",
"reference_image",
"reference_video",
"reference_audio",
"file",
"link",
]),
url: s.nonEmptyString("A public URL, OSS temporary URL, or supported image data URL."),
},
{ required: ["type", "url"] },
);
const videoTaskSchema: JsonSchema = s.oneOf(
[
s.object(
"A queued or running Wan 3.0 task.",
{
taskId: taskIdSchema,
state: s.literal("processing", { description: "The task is queued or running." }),
progress: s.number("The task progress percentage when available.", { minimum: 0, maximum: 100 }),
},
{ required: ["taskId", "state"], optional: ["progress"] },
),
s.object(
"A succeeded Wan 3.0 task.",
{
taskId: taskIdSchema,
state: s.literal("succeeded", { description: "The task succeeded." }),
videoUrl: s.nonEmptyString("The temporary generated video URL."),
duration: s.number("The billed generated video duration in seconds."),
resolution: s.string("The generated video resolution."),
ratio: s.string("The generated video aspect ratio."),
},
{ required: ["taskId", "state", "videoUrl"], optional: ["duration", "resolution", "ratio"] },
),
s.object(
"A terminal Wan 3.0 task that did not succeed.",
{
taskId: taskIdSchema,
state: s.stringEnum("The terminal task state.", ["failed", "cancelled", "expired"]),
error: s.object(
"The upstream terminal error.",
{ code: s.string("The upstream error code."), message: s.string("The upstream error message.") },
{ optional: ["code", "message"] },
),
},
{ required: ["taskId", "state"], optional: ["error"] },
),
],
{ description: "The normalized Wan 3.0 video task state." },
);
const videoLifecycle = {
startActionId: "wanx.submit_video_generation",
statusActionId: "wanx.get_video_generation",
};
export const wanxActions: ProviderActionDefinition[] = [
defineProviderAction(service, {
name: "generate_image",
description: "Generate or edit images with the Wan Image 2.7 family.",
inputSchema: s.actionInput(
{
model: s.withDefault(
s.stringEnum("The Wan Image 2.7 model.", ["wan2.7-image", "wan2.7-image-pro"]),
defaultImageModel,
),
prompt: s.string("The image generation or editing prompt.", { minLength: 1, maxLength: 5000 }),
images: s.array(
"Reference image URLs or data URLs. Omit for text-to-image generation.",
s.nonEmptyString("A reference image URL or data URL."),
{ maxItems: 9 },
),
size: s.withDefault(s.nonEmptyString("The output size preset or WIDTH*HEIGHT value."), "2K"),
imageCount: s.withDefault(s.integer("The number of images to generate.", { minimum: 1, maximum: 4 }), 1),
negativePrompt: s.string("Content that should not appear in the generated images.", { maxLength: 500 }),
promptExtend: s.withDefault(s.boolean("Whether Wanx should enhance the prompt."), true),
watermark: s.withDefault(s.boolean("Whether generated images include a watermark."), false),
seed: s.integer("The random seed.", { minimum: 0, maximum: 2147483647 }),
},
["prompt"],
"A unified Wan Image 2.7 generation or editing request.",
),
outputSchema: s.actionOutput(
{
images: s.array("The generated temporary image URLs.", s.nonEmptyString("A generated image URL.")),
imageCount: s.nonNegativeInteger("The number of generated images."),
size: s.string("The generated image dimensions."),
},
"The generated Wanx images and usage.",
),
}),
defineProviderAction(service, {
name: "submit_video_generation",
description: "Submit a unified asynchronous Wan 3.0 video generation task.",
followUpActions: [videoLifecycle.statusActionId],
asyncLifecycle: videoLifecycle,
inputSchema: s.requireAnyProperty(
s.actionInput(
{
model: s.withDefault(
s.stringEnum("The Wan 3.0 video model.", ["wan3.0-video", "wan3.0-video-prime"]),
defaultVideoModel,
),
prompt: s.string("The video generation or editing prompt.", { minLength: 1, maxLength: 20000 }),
media: s.array("Media inputs for Wan 3.0.", mediaSchema, { maxItems: 20 }),
resolution: s.withDefault(
s.stringEnum("The generated video resolution.", ["480P", "720P", "1080P"]),
"1080P",
),
ratio: s.withDefault(
s.stringEnum("The generated video aspect ratio.", ["adaptive", "16:9", "4:3", "1:1", "3:4", "9:16"]),
"adaptive",
),
duration: s.withDefault(s.integer("The video duration in seconds, or -1 for automatic selection."), 5),
audio: s.withDefault(s.boolean("Whether the generated video contains audio."), true),
promptExtend: s.withDefault(s.boolean("Whether Wanx should enhance the prompt."), true),
watermark: s.withDefault(s.boolean("Whether the generated video includes a watermark."), false),
seed: s.integer("The random seed.", { minimum: 0, maximum: 2147483647 }),
},
[],
"A unified Wan 3.0 video generation request. Provide a prompt or media.",
),
["prompt", "media"],
),
outputSchema: s.actionOutput({ taskId: taskIdSchema }, "The submitted Wan 3.0 task handle."),
}),
defineProviderAction(service, {
name: "get_video_generation",
description: "Retrieve a Wan 3.0 video task state and output.",
asyncLifecycle: videoLifecycle,
inputSchema: s.actionInput({ taskId: taskIdSchema }, ["taskId"], "A Wan 3.0 task lookup."),
outputSchema: videoTaskSchema,
}),
];
+23
View File
@@ -0,0 +1,23 @@
import type { ProviderDefinition } from "../../core/types.ts";
import { wanxActions } from "./actions.ts";
const service = "wanx";
export const provider: ProviderDefinition = {
service,
displayName: "Wanx",
categories: ["AI", "Design & Media"],
authTypes: ["api_key"],
auth: [
{
type: "api_key",
label: "Model Studio API Key",
placeholder: "Your Beijing Model Studio API key",
description:
"Beijing Model Studio API key sent as a Bearer token. Create one at https://bailian.console.aliyun.com/?apiKey=1#/api-key.",
},
],
homepageUrl: "https://tongyi.aliyun.com/wanxiang/",
actions: wanxActions,
};
+16
View File
@@ -0,0 +1,16 @@
import type { CredentialValidators, ProviderExecutors } from "../../core/types.ts";
import { defineApiKeyProviderExecutors } from "../provider-runtime.ts";
import { validateWanxCredential, wanxActionHandlers } from "./runtime.ts";
const service = "wanx";
export const executors: ProviderExecutors = defineApiKeyProviderExecutors(service, wanxActionHandlers, {
skipDnsValidation: true,
});
export const credentialValidators: CredentialValidators = {
apiKey(input, { fetcher, signal }) {
return validateWanxCredential(input.apiKey, fetcher, signal);
},
};
+210
View File
@@ -0,0 +1,210 @@
import type { CredentialValidationResult } from "../../core/types.ts";
import type { ApiKeyProviderContext, ProviderActionHandlers, ProviderRuntimeHandler } from "../provider-runtime.ts";
import {
compactObject,
objectArray,
optionalBoolean,
optionalInteger,
optionalNumber,
optionalRecord,
optionalScalarString,
optionalString,
optionalStringArray,
requiredString,
} from "../../core/cast.ts";
import {
ProviderRequestError,
providerResponseError,
providerUserAgent,
readProviderJsonBody,
requiredInputString,
requiredResponseRecord,
runProviderRequest,
} from "../provider-runtime.ts";
import { defaultImageModel, defaultVideoModel } from "./actions.ts";
export const wanxApiBaseUrl = "https://dashscope.aliyuncs.com/api/v1";
const imagePath = "services/aigc/multimodal-generation/generation";
const videoPath = "services/aigc/video-generation/video-synthesis";
export const wanxActionHandlers: ProviderActionHandlers<"wanx", ProviderRuntimeHandler<ApiKeyProviderContext>> = {
async generate_image(input, context): Promise<unknown> {
const payload = await requestJson(context, "POST", imagePath, buildImageBody(input));
return normalizeImageResponse(requiredResponseRecord(payload, "image response"));
},
async submit_video_generation(input, context): Promise<unknown> {
const payload = await requestJson(context, "POST", videoPath, buildVideoBody(input), true);
const output = requiredResponseRecord(requiredResponseRecord(payload, "submit response").output, "output");
return { taskId: requiredString(output.task_id, "output.task_id", providerResponseError) };
},
async get_video_generation(input, context): Promise<unknown> {
const taskId = requiredInputString(input.taskId, "taskId");
const payload = await requestJson(context, "GET", `tasks/${encodeURIComponent(taskId)}`);
return normalizeVideoTask(payload, taskId);
},
};
export function buildImageBody(input: Record<string, unknown>): Record<string, unknown> {
const images = optionalStringArray(input.images) ?? [];
return {
model: optionalString(input.model) ?? defaultImageModel,
input: {
messages: [
{
role: "user",
content: [{ text: requiredInputString(input.prompt, "prompt") }, ...images.map((image) => ({ image }))],
},
],
},
parameters: compactObject({
size: optionalString(input.size) ?? "2K",
n: optionalInteger(input.imageCount) ?? 1,
negative_prompt: optionalString(input.negativePrompt),
prompt_extend: optionalBoolean(input.promptExtend) ?? true,
watermark: optionalBoolean(input.watermark) ?? false,
seed: optionalInteger(input.seed),
}),
};
}
export function buildVideoBody(input: Record<string, unknown>): Record<string, unknown> {
const media =
input.media === undefined
? undefined
: objectArray(input.media, "media", inputError).map((item) => ({
type: requiredString(item.type, "media.type", inputError),
url: requiredString(item.url, "media.url", inputError),
}));
return {
model: optionalString(input.model) ?? defaultVideoModel,
input: compactObject({ prompt: optionalString(input.prompt), media }),
parameters: compactObject({
resolution: optionalString(input.resolution) ?? "1080P",
ratio: optionalString(input.ratio) ?? "adaptive",
duration: optionalInteger(input.duration) ?? 5,
audio: optionalBoolean(input.audio) ?? true,
prompt_extend: optionalBoolean(input.promptExtend) ?? true,
watermark: optionalBoolean(input.watermark) ?? false,
seed: optionalInteger(input.seed),
}),
};
}
export function normalizeImageResponse(payload: Record<string, unknown>): Record<string, unknown> {
const output = requiredResponseRecord(payload.output, "output");
const usage = requiredResponseRecord(payload.usage, "usage");
const images = objectArray(output.choices, "output.choices", providerResponseError).flatMap((choice) => {
const message = optionalRecord(choice.message);
if (!message) return [];
return objectArray(message.content, "output.choices.message.content", providerResponseError).flatMap((item) => {
const image = optionalString(item.image);
return image ? [image] : [];
});
});
if (images.length === 0) throw providerResponseError("Wanx returned no generated images");
return {
images,
imageCount: optionalInteger(usage.image_count) ?? images.length,
size: optionalString(usage.size),
};
}
export function normalizeVideoTask(payload: unknown, fallbackTaskId: string): Record<string, unknown> {
const response = requiredResponseRecord(payload, "task response");
const output = requiredResponseRecord(response.output, "output");
const taskId = optionalString(output.task_id) ?? fallbackTaskId;
const status = requiredString(output.task_status, "output.task_status", providerResponseError);
if (status === "PENDING" || status === "RUNNING") return { taskId, state: "processing" };
if (status === "SUCCEEDED") {
const usage = optionalRecord(response.usage);
return compactObject({
taskId,
state: "succeeded",
videoUrl: requiredString(output.video_url, "output.video_url", providerResponseError),
duration: optionalNumber(usage?.duration) ?? optionalNumber(usage?.output_video_duration),
resolution: optionalScalarString(usage?.SR),
ratio: optionalString(usage?.ratio),
});
}
if (status === "FAILED" || status === "CANCELED" || status === "UNKNOWN") {
return compactObject({
taskId,
state: status === "FAILED" ? "failed" : status === "CANCELED" ? "cancelled" : "expired",
error: compactObject({ code: optionalString(output.code), message: optionalString(output.message) }),
});
}
throw providerResponseError(`Wanx returned an unknown task status: ${status}`);
}
export async function validateWanxCredential(
apiKey: string,
fetcher: typeof fetch,
signal?: AbortSignal,
): Promise<CredentialValidationResult> {
try {
await requestJson({ apiKey, fetcher, signal }, "GET", "tasks/connector-credential-validation");
} catch (error) {
if (!(error instanceof ProviderRequestError) || error.status !== 404) throw error;
}
return {
profile: { accountId: "wanx:api_key", displayName: "Wanx Model Studio API Key" },
grantedScopes: [],
metadata: { apiBaseUrl: wanxApiBaseUrl },
};
}
async function requestJson(
context: ApiKeyProviderContext,
method: "GET" | "POST",
path: string,
body?: Record<string, unknown>,
asynchronous = false,
): Promise<unknown> {
context.signal?.throwIfAborted();
return runProviderRequest({ label: "Wanx", signal: context.signal }, async (signal) => {
const response = await context.fetcher(new URL(path, `${wanxApiBaseUrl}/`), {
method,
headers: compactHeaders({
accept: "application/json",
authorization: `Bearer ${context.apiKey}`,
"content-type": body ? "application/json" : undefined,
"user-agent": providerUserAgent,
"x-dashscope-async": asynchronous ? "enable" : undefined,
}),
body: body ? JSON.stringify(body) : undefined,
signal,
});
const payload = await readProviderJsonBody(response, {
emptyBody: {},
invalidJsonMessage: "Wanx returned an invalid JSON response",
});
if (!response.ok) handleError(response, payload);
return payload;
});
}
function handleError(response: Response, payload: unknown): never {
const record = optionalRecord(payload);
const output = optionalRecord(record?.output);
const code = optionalString(record?.code) ?? optionalString(output?.code);
const message =
optionalString(record?.message) ??
optionalString(output?.message) ??
`Wanx request failed with HTTP ${response.status}`;
const details = compactObject({ code, status: response.status });
if (response.status === 400 || response.status === 404 || response.status === 422)
throw new ProviderRequestError(response.status === 404 ? 404 : 400, message, details, "invalid_input");
if (response.status === 429) throw new ProviderRequestError(429, message, details);
throw new ProviderRequestError(response.status >= 500 ? 502 : response.status, message, details);
}
function inputError(message: string): ProviderRequestError {
return new ProviderRequestError(400, message, undefined, "invalid_input");
}
function compactHeaders(input: Record<string, string | undefined>): Headers {
const headers = new Headers();
for (const [key, value] of Object.entries(input)) if (value !== undefined) headers.set(key, value);
return headers;
}