276 lines
7.4 KiB
Go
276 lines
7.4 KiB
Go
|
|
package skill
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"gopkg.in/yaml.v3"
|
||
|
|
)
|
||
|
|
|
||
|
|
// CandidateTool is the intermediate representation produced by an importer
|
||
|
|
// (OpenAPI, manual, ...) before the LLM synthesis pass refines it into a
|
||
|
|
// finished Tool. Importers aim to fill as many fields as the source provides.
|
||
|
|
type CandidateTool struct {
|
||
|
|
Name string `json:"name"`
|
||
|
|
Description string `json:"description"`
|
||
|
|
Parameters jsonObject `json:"parameters"` // JSON Schema
|
||
|
|
Endpoint Endpoint `json:"endpoint"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// ParseOpenAPI accepts JSON or YAML OpenAPI 3.x (and best-effort Swagger 2.0)
|
||
|
|
// text and returns one CandidateTool per operation under paths. This is a
|
||
|
|
// deterministic, no-LLM pass: it just translates the spec into our endpoint
|
||
|
|
// shape. The synthesis pass later decides which candidates to keep and how to
|
||
|
|
// phrase descriptions.
|
||
|
|
func ParseOpenAPI(raw []byte, baseURL string) ([]CandidateTool, error) {
|
||
|
|
spec, err := unmarshalSpec(raw)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if baseURL == "" {
|
||
|
|
baseURL = spec.baseURL()
|
||
|
|
}
|
||
|
|
|
||
|
|
var out []CandidateTool
|
||
|
|
// Stable iteration order so generated skills are deterministic across runs.
|
||
|
|
paths := make([]string, 0, len(spec.Paths))
|
||
|
|
for p := range spec.Paths {
|
||
|
|
paths = append(paths, p)
|
||
|
|
}
|
||
|
|
sort.Strings(paths)
|
||
|
|
|
||
|
|
for _, p := range paths {
|
||
|
|
item := spec.Paths[p]
|
||
|
|
for _, method := range []string{"get", "post", "put", "patch", "delete"} {
|
||
|
|
op := item.operation(method)
|
||
|
|
if op == nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
ct, err := buildCandidate(method, p, op, baseURL)
|
||
|
|
if err != nil {
|
||
|
|
// Skip a malformed operation rather than failing the whole spec.
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
out = append(out, ct)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- intermediate openapi structures ---------------------------------------
|
||
|
|
//
|
||
|
|
// Only the fields we actually consume are modelled. Anything else is ignored.
|
||
|
|
// Both JSON and YAML unmarshal into these via the json tags (yaml.v3 reads the
|
||
|
|
// same lower-cased keys).
|
||
|
|
|
||
|
|
type oaSpec struct {
|
||
|
|
OpenAPI string `json:"openapi"`
|
||
|
|
Info oaInfo `json:"info"`
|
||
|
|
Servers []oaServer `json:"servers"`
|
||
|
|
Host string `json:"host"` // swagger 2.0
|
||
|
|
BasePath string `json:"basePath"` // swagger 2.0
|
||
|
|
Paths map[string]*oaPathItem `json:"paths"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type oaInfo struct {
|
||
|
|
Title string `json:"title"`
|
||
|
|
Description string `json:"description"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type oaServer struct {
|
||
|
|
URL string `json:"url"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type oaPathItem struct {
|
||
|
|
Get *oaOperation `json:"get"`
|
||
|
|
Post *oaOperation `json:"post"`
|
||
|
|
Put *oaOperation `json:"put"`
|
||
|
|
Patch *oaOperation `json:"patch"`
|
||
|
|
Delete *oaOperation `json:"delete"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type oaOperation struct {
|
||
|
|
Summary string `json:"summary"`
|
||
|
|
Description string `json:"description"`
|
||
|
|
OperationID string `json:"operationId"`
|
||
|
|
Parameters []oaParameter `json:"parameters"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type oaParameter struct {
|
||
|
|
Name string `json:"name"`
|
||
|
|
In string `json:"in"` // query | path | header
|
||
|
|
Required bool `json:"required"`
|
||
|
|
Schema *oaSchemaRef `json:"schema"`
|
||
|
|
Description string `json:"description"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type oaSchemaRef struct {
|
||
|
|
Type string `json:"type"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *oaSpec) baseURL() string {
|
||
|
|
if len(s.Servers) > 0 && s.Servers[0].URL != "" {
|
||
|
|
return strings.TrimRight(s.Servers[0].URL, "/")
|
||
|
|
}
|
||
|
|
if s.Host != "" {
|
||
|
|
bp := strings.Trim(s.BasePath, "/")
|
||
|
|
scheme := "https"
|
||
|
|
if strings.HasPrefix(s.Host, "localhost") || strings.HasPrefix(s.Host, "127.") {
|
||
|
|
scheme = "http"
|
||
|
|
}
|
||
|
|
if bp != "" {
|
||
|
|
return fmt.Sprintf("%s://%s/%s", scheme, s.Host, bp)
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("%s://%s", scheme, s.Host)
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *oaPathItem) operation(method string) *oaOperation {
|
||
|
|
switch method {
|
||
|
|
case "get":
|
||
|
|
return p.Get
|
||
|
|
case "post":
|
||
|
|
return p.Post
|
||
|
|
case "put":
|
||
|
|
return p.Put
|
||
|
|
case "patch":
|
||
|
|
return p.Patch
|
||
|
|
case "delete":
|
||
|
|
return p.Delete
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// buildCandidate converts one OpenAPI operation into a CandidateTool.
|
||
|
|
func buildCandidate(method, p string, op *oaOperation, baseURL string) (CandidateTool, error) {
|
||
|
|
name := op.OperationID
|
||
|
|
if name == "" {
|
||
|
|
// operationId absent: synthesise from method + path.
|
||
|
|
name = method + strings.ReplaceAll(strings.ReplaceAll(p, "/", "_"), "{", "")
|
||
|
|
name = strings.ReplaceAll(name, "}", "")
|
||
|
|
name = strings.Trim(name, "_")
|
||
|
|
}
|
||
|
|
if name == "" {
|
||
|
|
return CandidateTool{}, fmt.Errorf("cannot derive name")
|
||
|
|
}
|
||
|
|
|
||
|
|
desc := strings.TrimSpace(op.Description)
|
||
|
|
if desc == "" {
|
||
|
|
desc = strings.TrimSpace(op.Summary)
|
||
|
|
}
|
||
|
|
if desc == "" {
|
||
|
|
desc = name
|
||
|
|
}
|
||
|
|
|
||
|
|
// Split parameters into query / path / header and build a flat JSON Schema
|
||
|
|
// of the arguments the LLM needs to supply.
|
||
|
|
schemaProps := map[string]any{}
|
||
|
|
var required []string
|
||
|
|
var query, hdr map[string]string
|
||
|
|
pathParams := map[string]string{}
|
||
|
|
for _, param := range op.Parameters {
|
||
|
|
if param.Name == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
t := "string"
|
||
|
|
if param.Schema != nil && param.Schema.Type != "" {
|
||
|
|
t = param.Schema.Type
|
||
|
|
}
|
||
|
|
entry := map[string]any{"type": t}
|
||
|
|
if param.Description != "" {
|
||
|
|
entry["description"] = param.Description
|
||
|
|
}
|
||
|
|
schemaProps[param.Name] = entry
|
||
|
|
if param.Required {
|
||
|
|
required = append(required, param.Name)
|
||
|
|
}
|
||
|
|
switch param.In {
|
||
|
|
case "query":
|
||
|
|
if query == nil {
|
||
|
|
query = map[string]string{}
|
||
|
|
}
|
||
|
|
query[param.Name] = "{{" + param.Name + "}}"
|
||
|
|
case "path":
|
||
|
|
pathParams[param.Name] = "{{" + param.Name + "}}"
|
||
|
|
case "header":
|
||
|
|
if hdr == nil {
|
||
|
|
hdr = map[string]string{}
|
||
|
|
}
|
||
|
|
hdr[param.Name] = "{{" + param.Name + "}}"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
schema := map[string]any{
|
||
|
|
"type": "object",
|
||
|
|
"properties": schemaProps,
|
||
|
|
}
|
||
|
|
if len(required) > 0 {
|
||
|
|
sort.Strings(required)
|
||
|
|
schema["required"] = required
|
||
|
|
}
|
||
|
|
paramsJSON, _ := json.Marshal(schema)
|
||
|
|
|
||
|
|
// Render the full URL: combine baseURL + path. Path params stay as
|
||
|
|
// {name}; the agent executor substitutes them at call time. We avoid
|
||
|
|
// url.Parse here because it would percent-encode the braces.
|
||
|
|
fullURL := joinURL(baseURL, p)
|
||
|
|
|
||
|
|
return CandidateTool{
|
||
|
|
Name: name,
|
||
|
|
Description: desc,
|
||
|
|
Parameters: jsonObject(paramsJSON),
|
||
|
|
Endpoint: Endpoint{
|
||
|
|
Method: strings.ToUpper(method),
|
||
|
|
URL: fullURL,
|
||
|
|
Path: pathParams,
|
||
|
|
Query: query,
|
||
|
|
Header: hdr,
|
||
|
|
},
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// joinURL merges a base URL with an OpenAPI path. We deliberately do NOT use
|
||
|
|
// net/url for the join: it would percent-encode the "{name}" path-parameter
|
||
|
|
// placeholders into "%7Bname%7D", which the executor's literal replace would
|
||
|
|
// then fail to match. String concatenation keeps the braces intact.
|
||
|
|
func joinURL(base, p string) string {
|
||
|
|
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||
|
|
p = strings.TrimSpace(p)
|
||
|
|
if base == "" {
|
||
|
|
return p
|
||
|
|
}
|
||
|
|
if p == "" {
|
||
|
|
return base
|
||
|
|
}
|
||
|
|
if !strings.HasPrefix(p, "/") {
|
||
|
|
p = "/" + p
|
||
|
|
}
|
||
|
|
return base + p
|
||
|
|
}
|
||
|
|
|
||
|
|
// unmarshalSpec accepts either JSON or YAML (which is a JSON superset) by
|
||
|
|
// routing through yaml.v3, then re-marshals to JSON so the typed oaSpec can
|
||
|
|
// decode cleanly with consistent key casing.
|
||
|
|
func unmarshalSpec(raw []byte) (*oaSpec, error) {
|
||
|
|
var generic map[string]any
|
||
|
|
if err := yaml.Unmarshal(raw, &generic); err != nil {
|
||
|
|
return nil, fmt.Errorf("parse spec (yaml/json): %w", err)
|
||
|
|
}
|
||
|
|
// OpenAPI paths keys can be "/foo": {...}; keep them as-is.
|
||
|
|
jb, err := json.Marshal(generic)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("re-marshal spec: %w", err)
|
||
|
|
}
|
||
|
|
var spec oaSpec
|
||
|
|
if err := json.Unmarshal(jb, &spec); err != nil {
|
||
|
|
return nil, fmt.Errorf("decode spec: %w", err)
|
||
|
|
}
|
||
|
|
if spec.Paths == nil {
|
||
|
|
return nil, fmt.Errorf("spec has no paths")
|
||
|
|
}
|
||
|
|
return &spec, nil
|
||
|
|
}
|