kestra-io / kestra-io/plugin-ai

Add Huawei Cloud MaaS (ModelArts Studio) provider tasks for AI Plugin

Open
#384 0 comments 0 reactions 1 assignee Claimed by @jymaire View on GitHub
area/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.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.