epam / epam/ai-dial-client-python
Skills: write side — whole-resource, grouping folder and single-file writes (/v2/skills)
- 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
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