Skip to content

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

Description

@andrii-novikov

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) 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@developmentopen_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 Skills: read side — get_metadata, list_files, get_file, download (/v2/skills) #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 Skills: read side — get_metadata, list_files, get_file, download (/v2/skills) #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 Skills: read side — get_metadata, list_files, get_file, download (/v2/skills) #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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions