epam / epam/ai-dial-client-python

Skills: write side — whole-resource, grouping folder and single-file writes (/v2/skills)

Open
#137 0 comments 0 reactions 0 assignees View on GitHub
enhancement
Dominant language
Python
Stars
7
Forks
2
Avg merge
1d 18h
Merged PRs (30d)
8

Description

### Name and Version

aidial-client 0.16.1

### What is the problem this feature will solve?

Child of #135.

DIAL Core's `/v2/skills` API ([epam/ai-dial-core#1633](https://github.com/epam/ai-dial-core/issues/1633)) exposes six write operations, and the Python client can reach none of them. With only the read side (#136) a caller can consume skills that already exist but cannot create, update or delete one, cannot organise skills into grouping folders, and cannot edit a single file inside a skill — so there is no programmatic authoring path at all.

### What is the feature you are proposing to solve the problem?

Extend the `Skills` / `AsyncSkills` resource added by #136 with the write half of Core's API.

| Method | Endpoint | Notes |
|---|---|---|
| `save` / `upload` | `PUT /v2/skills/{bucket}/{path}` | `multipart/form-data`, replaces the whole skill |
| `delete` | `DELETE /v2/skills/{bucket}/{path}` | tombstones the resource |
| `create_folder` | `PUT /v2/skills/{bucket}/{path}/` | grouping folder; `400` if it already exists |
| `delete_folder` | `DELETE /v2/skills/{bucket}/{path}/` | `409` if not empty |
| `upload_file` | `PUT /v2/skills/{bucket}/{path}/files/{filePath}` | one part; atomic within the skill |
| `delete_file` | `DELETE /v2/skills/{bucket}/{path}/files/{filePath}` | rejects deleting `SKILL.md` |

**Core behaviour that must drive the design** (checked against `epam/ai-dial-core@development` — `open_api_core.yaml` is lossy here):

- **The whole-resource `PUT` is one multipart part per file.** The spec advertises a single `file` binary part, but `ComplexResourceController.put` collects uploads into a map keyed by `upload.filename()`, and **each part's filename is the file's relative path inside the skill**. The `.dial-resource` marker is synthesized server-side, so a client can never write or corrupt it. `_internal_types/_http_request.py`'s `RequestFiles` already allows `Sequence[tuple[str, FileTypes]]`, so the transport supports this today.
- **Server-side validation** (`SkillHandler`): `SKILL.md` must exist at the skill root and open with YAML frontmatter delimited by `---`, carrying a non-empty `name` and `description`; `version` is optional and cached if present. Violations are `400`. Single-file mutations re-run validation — editing `SKILL.md` re-parses the frontmatter, and deleting it is rejected outright.
- **Writes return an empty body with only an `ETag` response header.** This is unlike `files.upload`, which returns a parsed `FileItem`, so these methods need a deliberate return type (see the open questions below).
- **`If-Match` semantics are unusual.** Per Core's own parameter docs on the whole-resource `PUT`: supply the current ETag to replace that version, `*` to overwrite whatever exists, or **omit the header to create only if the resource does not already exist**. That is the inverse of the `If-None-Match: "*"` convention `files.upload` uses, and it needs to be explicit in both the signature and the README.
- Grouping-folder `DELETE` succeeds only if the folder is empty (`409` otherwise); `GET` on a trailing-slash path answers `400` by design — use the metadata listing from #136.
- Per-resource limits are configurable in Core (`maxFiles`, default ~1000; `maxTotalBytes`, default ~1 GB) on top of the existing 512 MB per-file cap.

**Open questions to settle in this issue before implementing:**

1. **Return type for writes.** These endpoints hand back only an `ETag`. Options: a small typed result model (e.g. `SkillWriteResult(etag: str | None)`), a plain `str | None`, or `None` as `files.delete` does. Returning the ETag matters because it is the input to the next write's `If-Match`, and — per #136 — the children metadata listing does *not* expose it, so a write response is one of the few places to obtain it.
2. **Local-folder helpers.** A skill is inherently a directory on disk. Whether to add `upload_folder(url, local_dir)` (walk a directory into multipart parts) and `download_to(url, local_dir)` (extract the ZIP from #136's `download`) over stdlib `zipfile` + `os.walk`, or to keep the surface strictly byte-level and let callers do it.
3. **Client-side pre-validation.** Whether to check the `SKILL.md` frontmatter contract locally before the request to give a better error than Core's `400`. This would want a YAML parser, which the library does not currently depend on — probably not worth a new dependency, but worth recording the decision.

**Tests** extend `tests/resources/skills/`: multipart part-naming (assert one part per file, part filename = relative path), the `If-Match` create-vs-overwrite matrix, `ETag` surfaced on every write, `409` on deleting a non-empty grouping folder, and `400` when `SKILL.md` is missing or its frontmatter lacks `name` / `description`.

Per `CLAUDE.md`'s PR checklist: `README.md`'s **Skills** section gains sync + async write examples and sample responses, and any new types are exported.

### What alternatives have you considered?

**Accept a ZIP archive for the whole-resource `PUT`, mirroring the GET.** Rejected — it does not match the endpoint. Core's contract is deliberately asymmetric: the GET streams a ZIP, but the PUT takes `multipart/form-data` with one part per file so the server can validate and synthesize the marker itself. Sending a ZIP would simply be rejected.

**Let callers assemble the multipart body themselves and expose only a thin passthrough.** Rejected. Part-naming is the single easiest thing to get wrong here — the part filename must be the *relative path inside the skill*, which is neither obvious nor documented correctly in `open_api_core.yaml` — so encoding it once in the library is most of the value.

**Fold this into #136 and ship the whole API at once.** Rejected. The reads have no open design questions; this issue has three. Bundling them would delay a complete, useful read capability behind an unrelated discussion.

**Model the write path on `prompts.py`.** Rejected. `Prompts.save` is `json_data`-shaped and returns a parsed `PromptItem`; every operation here is multipart- or ETag-header-shaped, so `files.py` is the closer template — as it is for #136.

Contributor guide

Open the contributing guide

Research direction

Start with the Skills/AsyncSkills resource from #136 and compare the multipart and ETag patterns in files.py. Resolve the return type, local-folder helpers, and client-side validation questions before implementing the six operations. Extend tests/resources/skills/ for multipart naming, If-Match behavior, ETags, and validation errors, then update README.md and exports as required by CLAUDE.md.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.