mirror of
https://gh-proxy.org/https://github.com/klinsmaya/open-connector.git
synced 2026-11-08 01:16:52 +08:00
feat: add ai providers
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
.private-docs/
|
||||
.tmp/
|
||||
.wrangler/
|
||||
wrangler.local.jsonc
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
+4
-4
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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." },
|
||||
),
|
||||
}),
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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.",
|
||||
),
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user