kestra-io / kestra-io/plugin-ai
Add Huawei Cloud MaaS (ModelArts Studio) provider tasks for AI Plugin
- Dominant language
- Java
- Stars
- 9
- Forks
- 24
- Avg merge
- 1d 23h
- Merged PRs (30d)
- 15
Description
## Summary
Huawei Cloud **MaaS (ModelArts Studio)** serves DeepSeek, Qwen3 and GLM models through an OpenAI-compatible inference API. Adding it as a `ModelProvider` in `plugin-ai` lets users run chat completion, embeddings, RAG and agentic flows against models hosted in Huawei Cloud regions — including the CN and APAC regions where OpenAI and Anthropic endpoints are unreachable — without leaving Kestra.
Because MaaS speaks the OpenAI wire protocol, the provider is a thin subclass of the existing `OpenAICompliantProvider`: no new SDK, no new dependency, and every existing AI task (`ChatCompletion`, `Classification`, `JSONStructuredExtraction`, RAG ingestion/retrieval, agents) works with it immediately.
## Motivation
- **Today** users targeting Huawei-hosted models have to fall back on `io.kestra.plugin.core.http.Request` or a Python task wrapping the OpenAI SDK, hand-rolling message construction, retries and token accounting — and they lose every AI-plugin capability built on top of `ModelProvider` (tool calling, memory, RAG, structured output).
- **Who benefits**: teams already running on Huawei Cloud (this repo's sibling `plugin-huawei` covers OBS, DIS, DLI, DataArts, FunctionGraph…), and any team in APAC/China regions where Huawei MaaS is the practical LLM option.
- **Ecosystem fit**: completes the set of regional OpenAI-compatible providers already shipped — `DashScope` (Alibaba), `ZhiPuAI`, `DeepSeek`, `OciGenAI`, `WatsonxAI`. Huawei is the notable gap.
## Context
`plugin-ai` already ships the abstraction this needs: `io.kestra.plugin.ai.provider.OpenAICompliantProvider` handles chat, image and embedding model construction, JSON/structured response formats, PEM-based custom HTTP clients and listeners. A concrete provider only declares its default `baseUrl`.
**Reference implementation**: [`io.kestra.plugin.ai.provider.DeepSeek`](https://github.com/kestra-io/plugin-ai/blob/main/src/main/java/io/kestra/plugin/ai/provider/DeepSeek.java) — 20 lines, sets one constant. `HuaweiMaaS` should look almost identical.
Related: the Huawei Cloud service plugin lives at [`kestra-io/plugin-huawei`](https://github.com/kestra-io/plugin-huawei). MaaS deliberately does **not** go there — it is an LLM provider, not a Huawei service task, and belongs with its peers in `plugin-ai`.
## API Reference
- **Official docs**: https://support.huaweicloud.com/intl/en-us/maas/index.html
- [Calling a model service in MaaS](https://support.huaweicloud.com/intl/en-us/usermanual-maas-modelarts/maas-modelarts-0011.html)
- [OpenAI-compatible APIs](https://support.huaweicloud.com/intl/en-us/usermanual-maas-modelarts/maas-modelarts-0084.html)
- [Managing API keys in MaaS](https://support.huaweicloud.com/intl/en-us/usermanual-maas-modelarts/maas-modelarts-0073.html)
- **Authentication**: API key, sent as `Authorization: Bearer `. Keys are **region-scoped** — a key created in CN-Hong Kong only works against the CN-Hong Kong endpoint. Up to 30 keys per account; the value is shown once at creation.
- **Base URL pattern**: `https://api-.modelarts-maas.com/v1`
(e.g. `https://api-ap-southeast-1.modelarts-maas.com/v1`; the chat route is `.../v1/chat/completions`. Some docs also show a `/openai/v1` prefix — verify which form the current gateway serves before pinning the default.)
- **Models**: DeepSeek (`DeepSeek-V3`, `DeepSeek-R1`, …), Qwen3, GLM. `GET /v1/models` lists what the region serves.
- **SDK / client library**: none needed — `dev.langchain4j:langchain4j-open-ai` (already a dependency of this repo) talks to it directly.
## Gradle Dependencies
**No new dependency is required.** `OpenAICompliantProvider` is backed by `dev.langchain4j:langchain4j-open-ai`, which `plugin-ai` already declares.
> **Kestra framework inclusions (do not add as dependencies):**
> Kestra's internal HTTP client (`io.kestra.core.http.client`) and Jackson serializers are provided by the framework. Do not add OkHttp, Apache HttpComponents, or a Huawei Cloud SDK — MaaS is reached over the OpenAI-compatible protocol that langchain4j already implements.
## Plugin Structure
- **Repository**: `kestra-io/plugin-ai` *(already exists — no scaffolding needed)*
- **Namespace**: `io.kestra.plugin.ai.provider`
- **New class**: `HuaweiMaaS extends OpenAICompliantProvider`
- **Sub-plugins**: none — this adds one class to the existing `provider` sub-package
- **Categories**: `AI` (inherited from the existing `io.kestra.plugin.ai.provider` `package-info.java`; no new `@PluginSubGroup` needed)
> **Task class naming**: the class is a provider, not a task — name it `HuaweiMaaS`, matching the `DeepSeek` / `DashScope` / `ZhiPuAI` convention. Do not prefix or suffix with the package name.
### Sketch
```java
@Getter
@SuperBuilder
@NoArgsConstructor
@JsonDeserialize
@Schema(
title = "Use Huawei Cloud MaaS (ModelArts Studio) models",
description = """
Connects to Huawei Cloud ModelArts Studio's OpenAI-compatible endpoint to run
DeepSeek, Qwen3 and GLM models. API keys are region-scoped: a key created in one
region only authenticates against that region's base URL.
"""
)
@Plugin(examples = { /* full = true flows, see below */ })
public class HuaweiMaaS extends OpenAICompliantProvider {
private static final String BASE_URL = "https://api-ap-southeast-1.modelarts-maas.com/v1";
@Schema(
title = "API base URL",
description = """
Region endpoint in the form `https://api-.modelarts-maas.com/v1`.
Defaults to `ap-southeast-1`. The API key must have been created in the same region.
"""
)
@Builder.Default
@PluginProperty(group = "connection")
private Property baseUrl = Property.ofValue(BASE_URL);
}
```
`apiKey` (`@PluginProperty(secret = true, group = "main")`) and `modelName` are inherited from `OpenAICompliantProvider` / `ModelProvider` — do not redeclare them.
**Open design question for the implementer**: whether to expose a `region` convenience property that derives `baseUrl`. `ModelProvider#getBaseUrl()` is a plain field read with no `RunContext`, so a derived value would have to be resolved inside `chatModel(...)` rather than as a field default — added complexity for a URL the user can already state in full. Recommendation: ship `baseUrl` only (YAGNI), and revisit if users ask for it.
## Suggested Tasks
1. Add `HuaweiMaaS extends OpenAICompliantProvider` with the region-scoped `baseUrl` default
2. Verify the live base-URL form (`/v1` vs `/openai/v1`) against a real MaaS account and pin the correct default
3. Confirm which capabilities the endpoint actually serves — chat is certain; **embeddings and image generation must be verified** before relying on the inherited `embeddingModel()` / `imageModel()` implementations. Override with a clear `IllegalArgumentException` for any capability MaaS does not expose, following the `DashScope` precedent of failing fast on unsupported parameters
4. Add `@Plugin(examples = ...)` with full runnable flows (chat completion, structured extraction, RAG)
5. Unit test with WireMock covering the happy path plus an auth-failure case (`testImplementation "org.wiremock:wiremock-jetty12"`), modelled on the existing provider tests
6. Optional live integration test gated on `HUAWEI_MAAS_API_KEY`, only if the gate is wired into `.github/setup-unit.sh` — otherwise it silently never runs and the WireMock test is the real coverage
7. Add the provider to the `## Providers` section of `src/main/resources/doc/io.kestra.plugin.ai.md`, documenting the region-scoped API key behaviour
8. Add the Huawei icon SVG if the provider warrants its own; otherwise it inherits the `provider` sub-group icon
## YAML Examples
### Example 1 — Chat completion against a MaaS-hosted DeepSeek model
```yaml
id: huawei_maas_chat_completion
namespace: company.ai
inputs:
- id: prompt
type: STRING
defaults: Summarise the main risks of running batch ETL without idempotency.
tasks:
- id: chat_completion
type: io.kestra.plugin.ai.completion.ChatCompletion
provider:
type: io.kestra.plugin.ai.provider.HuaweiMaaS
apiKey: "{{ secret('HUAWEI_MAAS_API_KEY') }}"
modelName: DeepSeek-V3
messages:
- type: SYSTEM
content: You are a data engineering assistant. Answer concisely.
- type: USER
content: "{{ inputs.prompt }}"
```
### Example 2 — Structured extraction in a non-default region
```yaml
id: huawei_maas_structured_extraction
namespace: company.ai
inputs:
- id: ticket
type: STRING
defaults: "Payment gateway returned 502 for all EU checkouts between 09:12 and 09:41 UTC."
tasks:
- id: extract
type: io.kestra.plugin.ai.completion.JSONStructuredExtraction
provider:
type: io.kestra.plugin.ai.provider.HuaweiMaaS
apiKey: "{{ secret('HUAWEI_MAAS_CN_HONGKONG_API_KEY') }}"
baseUrl: https://api-cn-southwest-2.modelarts-maas.com/v1
modelName: Qwen3-32B
schemaName: incident
jsonFields:
- component
- severity
- startedAt
prompt: "{{ inputs.ticket }}"
- id: log_extraction
type: io.kestra.plugin.core.log.Log
message: "Extracted incident: {{ outputs.extract.schema }}"
```
### Example 3 — Scheduled classification of new support tickets
> `plugin-ai` ships no triggers of its own — providers are consumed by tasks. This example uses a core `Schedule` trigger to show the provider driving a scheduled flow.
```yaml
id: huawei_maas_ticket_triage
namespace: company.ai
triggers:
- id: every_15_minutes
type: io.kestra.plugin.core.trigger.Schedule
cron: "*/15 * * * *"
tasks:
- id: classify
type: io.kestra.plugin.ai.completion.Classification
provider:
type: io.kestra.plugin.ai.provider.HuaweiMaaS
apiKey: "{{ secret('HUAWEI_MAAS_API_KEY') }}"
modelName: DeepSeek-V3
prompt: New ticket - checkout page returns 502 for EU customers
classes:
- incident
- billing
- feature_request
- id: log_class
type: io.kestra.plugin.core.log.Log
message: "Ticket classified as {{ outputs.classify.textOutput }}"
```
## Acceptance Criteria
### Functional
- [ ] `HuaweiMaaS extends OpenAICompliantProvider` implemented with a documented, region-scoped `baseUrl` default
- [ ] Live base-URL form verified (`/v1` vs `/openai/v1`) and the correct one pinned as the default
- [ ] Embedding and image capability support verified against a live MaaS endpoint; unsupported capabilities fail fast with an actionable message rather than an opaque SDK error
- [ ] WireMock unit test covers the chat happy path and an authentication failure
- [ ] `./gradlew build` and `./gradlew test` pass
### Kestra Plugin Coding Standards
- [ ] No new Gradle dependency added — reuses `dev.langchain4j:langchain4j-open-ai`
- [ ] `baseUrl` declared as `Property` with `@Builder.Default`; `apiKey`/`modelName` inherited, not redeclared
- [ ] Inherited `apiKey` keeps `@PluginProperty(secret = true)`; the class carries `@Getter`, `@SuperBuilder`, `@NoArgsConstructor`, `@JsonDeserialize` per the provider convention
- [ ] `@Schema` on the class and on every declared property
- [ ] `@PluginProperty(group = "connection")` on `baseUrl`
- [ ] Logging via `runContext.logger()` only; no secret ever logged
- [ ] Error messages name the problem and the fix (e.g. `"MaaS returned 401 — API keys are region-scoped; check the key was created in the same region as baseUrl"`)
### Documentation & Structure
- [ ] `@Plugin(examples = ...)` entries each set `full = true` with a complete runnable flow (id + namespace + tasks)
- [ ] Sensitive values in examples use `{{ secret('SECRET_NAME') }}`
- [ ] `src/main/resources/doc/io.kestra.plugin.ai.md` lists the new provider and documents the region-scoped API key constraint
- [ ] Icon present (own SVG, or explicitly inheriting the `provider` sub-group icon)
---
## Repository Setup Checklist
`kestra-io/plugin-ai` already exists and is provisioned, so scaffolding is not required.
### 1. Add to Sanity check page
Confirm `plugin-ai` is listed on the [Sanity check Notion page](https://www.notion.so/kestra-io/32736907f7b580cbb00dc7c061e624b1?v=32736907f7b58002ac2b000ccc63d8a2), and extend its entry to cover the Huawei MaaS provider.
### 2. Run scoped Terraform apply
Only needed if repository settings, labels, or rulesets drift. From `infra/terraform/github`:
```bash
terraform apply \
-target='github_repository.repo["plugin-ai"]' \
-target='github_issue_labels.plugins["plugin-ai"]' \
-target='github_repository_ruleset.branch["plugin-ai"]'
```
`plugin-ai` is a public OSS repository, so no `github_repository_collaborator.escrow` target applies.
## Notes for the Developer
Details that could not be confirmed from public documentation and must be verified against a live MaaS account before implementation is finalised:
- The exact base-URL form served by the gateway (`/v1` vs `/openai/v1`) — documentation shows both
- The full list of MaaS regions and their `api-.modelarts-maas.com` hostnames
- Whether MaaS exposes `/v1/embeddings` and any image-generation route, which determines if the inherited `embeddingModel()` / `imageModel()` are usable or must throw
---
*[View as Artifact](https://claude.ai/code/artifact/559ff8cf-1918-46a1-ad77-1995b4ba1262)*
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.