The client API described in OpenAPI, and both APIs in Swagger UI
CI / test (push) Failing after 6m12s

/v1/openapi.json is the API clients use — OpenAI's endpoints, Anthropic's,
and what llmgw adds — in OpenAPI 3.1, generated from internal/clientapi's
route table by the generator the admin API's document comes from, now
told each document's title, server, token and error. A test reads the
router's mux.HandleFunc calls and refuses a route described and not
mounted, or mounted and not described. Asked for before there is a key,
so it needs none.

/docs/api shows it in Swagger UI 5.33.1 (Apache 2.0, served from the
gateway as the page's policy requires): Authorize takes a key, Try it out
sends to this gateway. Staff are offered the admin API's document too,
from /docs/api/admin.json, which nobody else is given.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-10 20:22:48 +02:00
1 parent 41601bc5ef
commit fd3c7bda32
26 files changed
+3956 -48

No files matched your search

+7
View File
@@ -26,6 +26,13 @@ APIs change between minors without a deprecation period.
## Unreleased ## Unreleased
### Added
- **The client API described**: OpenAPI 3.1 at `/v1/openapi.json`, no key
needed, generated from a route table a test holds to the router's own;
and `/docs/api`, Swagger UI (served from the gateway) over it and, for
staff, over the admin API's document.
## v0.14.3 — 2026-10-10 ## v0.14.3 — 2026-10-10
3D models from a picture, as jobs: made by ComfyUI on the cluster's cards, 3D models from a picture, as jobs: made by ComfyUI on the cluster's cards,
+6
View File
@@ -96,6 +96,12 @@ func titleOf(src, path string) string {
//go:embed docs/admin-api.openapi.json //go:embed docs/admin-api.openapi.json
var AdminOpenAPI []byte var AdminOpenAPI []byte
// ClientOpenAPI is the client API described in OpenAPI 3.1, generated from
// internal/clientapi's route table and served at /v1/openapi.json.
//
//go:embed docs/client-api.openapi.json
var ClientOpenAPI []byte
// EvalSuites are the example quality suites under deploy/eval, which the // EvalSuites are the example quality suites under deploy/eval, which the
// control plane runs by name when its eval directory has none of the name. // control plane runs by name when its eval directory has none of the name.
// //
+1 -1
View File
@@ -5203,7 +5203,7 @@
} }
}, },
"Unauthorized": { "Unauthorized": {
"description": "The admin token is missing or wrong.", "description": "The token is missing or wrong.",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
File diff suppressed because it is too large. Load diff
+8
View File
@@ -38,6 +38,14 @@ export ANTHROPIC_BASE_URL=https://llm.example.com
export ANTHROPIC_API_KEY=$LLMGW_API_KEY export ANTHROPIC_API_KEY=$LLMGW_API_KEY
``` ```
### The API described
Every endpoint is described in OpenAPI 3.1 at `/v1/openapi.json`. It needs
no key, so a client generator can be pointed at it. `/docs/api` shows it in
Swagger UI, where *Authorize* takes a key and *Try it out* sends a request to
the gateway. People with a part in running the gateway also find the admin
API's document there.
## A first request ## A first request
### curl ### curl
+479
View File
@@ -0,0 +1,479 @@
// Package clientapi describes the API clients use, the one on :8080 — OpenAI's
// shape and what llmgw adds to it — for its OpenAPI document.
//
// The types here are the bodies as they go over the wire, written for the
// document: the handlers read a request's routing fields and pass the rest
// to the engine as it came, so a request may carry more than is listed, and
// an engine's answer may say more than is listed. The route table is checked
// against the router's own by a test, so an endpoint cannot be added without
// being described.
package clientapi
import "encoding/json"
// Error is every refusal: OpenAI's shape.
type Error struct {
Error ErrorBody `json:"error"`
}
// ErrorBody says why.
type ErrorBody struct {
Message string `json:"message"`
// Type is invalid_request_error, server_error, rate_limit_exceeded …
Type string `json:"type"`
// Code, where there is one a client branches on: model_not_found,
// content_policy_violation, job_not_found, system_stopped.
Code *string `json:"code"`
}
// ChatMessage is one turn.
type ChatMessage struct {
// Role is system, user, assistant or tool.
Role string `json:"role"`
// Content is a string, or a list of parts: text, and image_url for a
// model with vision.
Content json.RawMessage `json:"content"`
Name string `json:"name,omitempty"`
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// StreamOptions asks for usage in the last chunk of a stream.
type StreamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
// ChatCompletionRequest is a chat turn asked of a model.
type ChatCompletionRequest struct {
// Model is a model's name or alias, or an adapter's name.
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
// Stream answers as server-sent events.
Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Seed *int64 `json:"seed,omitempty"`
// Stop is a string or a list of them.
Stop json.RawMessage `json:"stop,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
ResponseFormat json.RawMessage `json:"response_format,omitempty"`
// User is the client's own name for whoever it serves, kept with the
// usage.
User string `json:"user,omitempty"`
// Metadata are tags kept with the usage, never sent to the engine.
Metadata map[string]string `json:"metadata,omitempty"`
}
// Usage is what a request used.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// ChatChoice is one answer.
type ChatChoice struct {
Index int `json:"index"`
Message ChatMessage `json:"message"`
// FinishReason is stop, length or tool_calls.
FinishReason string `json:"finish_reason"`
}
// ChatCompletion is a model's answer.
type ChatCompletion struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChatChoice `json:"choices"`
Usage Usage `json:"usage"`
}
// CompletionRequest is a prompt to continue.
type CompletionRequest struct {
Model string `json:"model"`
// Prompt is a string or a list of them.
Prompt json.RawMessage `json:"prompt"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
Stop json.RawMessage `json:"stop,omitempty"`
Seed *int64 `json:"seed,omitempty"`
}
// CompletionChoice is one continuation.
type CompletionChoice struct {
Index int `json:"index"`
Text string `json:"text"`
FinishReason string `json:"finish_reason"`
}
// Completion is a continued prompt.
type Completion struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []CompletionChoice `json:"choices"`
Usage Usage `json:"usage"`
}
// EmbeddingRequest asks for vectors.
type EmbeddingRequest struct {
Model string `json:"model"`
// Input is a string or a list of them; each is embedded on its own, and
// the longest must fit the model's context.
Input json.RawMessage `json:"input"`
EncodingFormat string `json:"encoding_format,omitempty"`
Dimensions int `json:"dimensions,omitempty"`
}
// Embedding is one input's vector.
type Embedding struct {
Object string `json:"object"`
Index int `json:"index"`
Embedding []float64 `json:"embedding"`
}
// EmbeddingList is every input's vector.
type EmbeddingList struct {
Object string `json:"object"`
Data []Embedding `json:"data"`
Model string `json:"model"`
Usage Usage `json:"usage"`
}
// RerankRequest orders documents by a query, for a reranker vLLM serves.
type RerankRequest struct {
Model string `json:"model"`
Query string `json:"query"`
Documents []string `json:"documents"`
TopN int `json:"top_n,omitempty"`
}
// ScoreRequest scores pairs of texts, for a scorer vLLM serves.
type ScoreRequest struct {
Model string `json:"model"`
// TextA and TextB are strings or lists of them.
TextA json.RawMessage `json:"text_1"`
TextB json.RawMessage `json:"text_2"`
}
// TranscriptionForm is speech to text, sent as a form.
type TranscriptionForm struct {
// File is the audio.
File []byte `json:"file"`
Model string `json:"model"`
Language string `json:"language,omitempty"`
Prompt string `json:"prompt,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
}
// Transcription is the text.
type Transcription struct {
Text string `json:"text"`
}
// ImageRequest asks for pictures, of a model that makes them.
type ImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
// N is how many, at most 4.
N int `json:"n,omitempty"`
// Size is WIDTHxHEIGHT, each a multiple of 16 from 256 to 2048.
Size string `json:"size,omitempty"`
// ResponseFormat is b64_json, the only one offered.
ResponseFormat string `json:"response_format,omitempty"`
Seed *int64 `json:"seed,omitempty"`
Steps int `json:"steps,omitempty"`
NegativePrompt string `json:"negative_prompt,omitempty"`
Guidance *float64 `json:"guidance,omitempty"`
}
// Image is one picture.
type Image struct {
B64JSON string `json:"b64_json"`
}
// ImagesResponse is the pictures made; x-llmgw-seed says the seed.
type ImagesResponse struct {
Created int64 `json:"created"`
Data []Image `json:"data"`
}
// ModerationRequest asks the guardrails' judge.
type ModerationRequest struct {
// Input is a string or up to 32 of them.
Input json.RawMessage `json:"input"`
Model string `json:"model,omitempty"`
}
// ModerationResult is one input's verdict.
type ModerationResult struct {
Flagged bool `json:"flagged"`
Categories map[string]bool `json:"categories"`
CategoryScores map[string]float64 `json:"category_scores"`
}
// ModerationResponse is every input's verdict.
type ModerationResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Results []ModerationResult `json:"results"`
}
// ResponsesRequest is OpenAI's Responses API, translated to chat completions.
type ResponsesRequest struct {
Model string `json:"model"`
// Input is a string or a list of items: messages, function calls and
// their outputs.
Input json.RawMessage `json:"input"`
Instructions string `json:"instructions,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"`
// Text carries format: a JSON schema the answer follows.
Text json.RawMessage `json:"text,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
}
// MessagesRequest is Anthropic's Messages API, translated to chat completions.
type MessagesRequest struct {
Model string `json:"model"`
Messages json.RawMessage `json:"messages"`
System json.RawMessage `json:"system,omitempty"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"`
}
// TokenCount is how many tokens a Messages request would be.
type TokenCount struct {
InputTokens int `json:"input_tokens"`
}
// License is the terms what a model makes comes under.
type License struct {
Name string `json:"name"`
URL string `json:"url,omitempty"`
Notice string `json:"notice,omitempty"`
}
// ModelInfo is a model a key may use.
type ModelInfo struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
// ContextLength and MaxOutputTokens, where the catalog says.
ContextLength int `json:"context_length,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Description string `json:"description,omitempty"`
// Capabilities: tools, vision, embeddings, images, meshes.
Capabilities map[string]bool `json:"capabilities"`
License *License `json:"license,omitempty"`
}
// ModelList is the models a key may use.
type ModelList struct {
Object string `json:"object"`
Data []ModelInfo `json:"data"`
}
// FileForm is a file uploaded, as a form.
type FileForm struct {
File []byte `json:"file"`
// Purpose is batch for a batch's input.
Purpose string `json:"purpose"`
}
// File is a file kept: a batch's input or output, a job's result.
type File struct {
ID string `json:"id"`
Object string `json:"object"`
Bytes int64 `json:"bytes"`
CreatedAt int64 `json:"created_at"`
Filename string `json:"filename"`
// Purpose is batch, batch_output or job_output.
Purpose string `json:"purpose"`
Status string `json:"status"`
ExpiresAt int64 `json:"expires_at,omitempty"`
}
// FileList is the caller's files.
type FileList struct {
Object string `json:"object"`
Data []File `json:"data"`
}
// Deleted says a thing is gone.
type Deleted struct {
ID string `json:"id"`
Object string `json:"object"`
Deleted bool `json:"deleted"`
}
// BatchRequest runs a file of requests, at the lowest priority.
type BatchRequest struct {
InputFileID string `json:"input_file_id"`
// Endpoint is /v1/chat/completions, /v1/completions or /v1/embeddings.
Endpoint string `json:"endpoint"`
// CompletionWindow is 24h.
CompletionWindow string `json:"completion_window"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// RequestCounts is how a batch's lines went.
type RequestCounts struct {
Total int `json:"total"`
Completed int `json:"completed"`
Failed int `json:"failed"`
}
// Batch is a batch and how far it is.
type Batch struct {
ID string `json:"id"`
Object string `json:"object"`
Endpoint string `json:"endpoint"`
InputFileID string `json:"input_file_id"`
CompletionWindow string `json:"completion_window"`
Status string `json:"status"`
OutputFileID *string `json:"output_file_id"`
ErrorFileID *string `json:"error_file_id"`
CreatedAt int64 `json:"created_at"`
InProgressAt *int64 `json:"in_progress_at"`
ExpiresAt *int64 `json:"expires_at"`
FinalizingAt *int64 `json:"finalizing_at"`
CompletedAt *int64 `json:"completed_at"`
FailedAt *int64 `json:"failed_at"`
ExpiredAt *int64 `json:"expired_at"`
CancellingAt *int64 `json:"cancelling_at"`
CancelledAt *int64 `json:"cancelled_at"`
RequestCounts RequestCounts `json:"request_counts"`
Errors json.RawMessage `json:"errors"`
Metadata json.RawMessage `json:"metadata"`
}
// BatchList is the caller's batches.
type BatchList struct {
Object string `json:"object"`
Data []Batch `json:"data"`
HasMore bool `json:"has_more"`
}
// MeshRequest asks for a 3D model from a picture, of a model that makes
// them: a job.
type MeshRequest struct {
Model string `json:"model"`
// Image is the picture, base64 or a data URL: PNG, JPEG or WebP, one
// object on a plain background.
Image string `json:"image"`
Seed *int64 `json:"seed,omitempty"`
Steps int `json:"steps,omitempty"`
// Resolution is the model's grid, a multiple of 32 from 64 to 2048.
Resolution int `json:"resolution,omitempty"`
}
// JobProgress is how far along a job is.
type JobProgress struct {
// State is queued, running, done or failed.
State string `json:"state"`
// Ahead is how many wait before it, while queued.
Ahead int `json:"ahead,omitempty"`
NodeClass string `json:"node_class,omitempty"`
Step int `json:"step,omitempty"`
Steps int `json:"steps,omitempty"`
Percent int `json:"percent,omitempty"`
}
// JobUsage is what a finished job used.
type JobUsage struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
Images int64 `json:"images"`
Meshes int64 `json:"meshes"`
}
// Job is a 3D model asked for: its status, progress and, when done, the
// file its result is in.
type Job struct {
ID string `json:"id"`
Object string `json:"object"`
Endpoint string `json:"endpoint"`
Model string `json:"model"`
// Status is queued, running, cancelling, completed, failed, cancelled
// or expired.
Status string `json:"status"`
Progress *JobProgress `json:"progress"`
OutputFileID *string `json:"output_file_id"`
ContentType *string `json:"content_type"`
Error *ErrorBody `json:"error"`
CreatedAt int64 `json:"created_at"`
StartedAt *int64 `json:"started_at"`
FinishedAt *int64 `json:"finished_at"`
ExpiresAt *int64 `json:"expires_at"`
Usage *JobUsage `json:"usage"`
License *License `json:"license,omitempty"`
}
// JobList is the caller's jobs.
type JobList struct {
Object string `json:"object"`
Data []Job `json:"data"`
HasMore bool `json:"has_more"`
FirstID *string `json:"first_id"`
LastID *string `json:"last_id"`
}
// MemoryWrite is a note kept for the caller's project.
type MemoryWrite struct {
Text string `json:"text"`
Tags []string `json:"tags,omitempty"`
}
// MemoryNote is a note kept.
type MemoryNote struct {
Key string `json:"key"`
Text string `json:"text"`
Tags []string `json:"tags"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// MemoryList is the project's notes.
type MemoryList struct {
Object string `json:"object"`
Data []MemoryNote `json:"data"`
}
// Me is the caller as the gateway sees it: its key, place and limits.
type Me struct {
Authenticated bool `json:"authenticated"`
KeyID string `json:"key_id,omitempty"`
Key string `json:"key,omitempty"`
Project string `json:"project,omitempty"`
Team string `json:"team,omitempty"`
Organization string `json:"organization,omitempty"`
// Limits and Usage are what applies and what has been used of it.
Limits json.RawMessage `json:"limits,omitempty"`
Usage json.RawMessage `json:"usage,omitempty"`
}
// MCPRequest is a JSON-RPC 2.0 call to the gateway's MCP server: its tools
// are the memory notes.
type MCPRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// KBDeleted says a document left a collection.
type KBDeleted struct {
Name string `json:"name"`
Deleted bool `json:"deleted"`
}
+110
View File
@@ -0,0 +1,110 @@
package clientapi
import (
"encoding/json"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/router"
)
type (
// Route and Param are the admin API's, which one generator reads.
Route = adminapi.Route
Param = adminapi.Param
)
// PathParams describes the parameters in Routes' paths.
var PathParams = map[string]string{
"files/{id}": "The file's ID, file-….",
"batches/{id}": "The batch's ID, batch_….",
"generations/{id}": "The job's ID, job_….",
"threads/{id}": "The conversation's ID, as sent in x-llmgw-thread.",
"memory/{key}": "The note's key, chosen by the caller.",
"collections/{id}": "The collection's ID, its name when that is unique, or ORG/NAME.",
}
// any is a body whose shape is the engine's or the provider's own.
var anyJSON = json.RawMessage{}
// Routes is every endpoint the client API serves, but /health, /ready and
// /leader, which are a load balancer's and need no key.
var Routes = []Route{
{Method: "POST", Path: "/v1/chat/completions", Summary: "A chat turn",
Description: "A model's name, an alias, or an adapter's name. Fields not listed go to the engine as sent. " +
"With a knowledge base, x-llmgw-knowledge names the collections to answer from; x-llmgw-thread continues a stored conversation.",
Request: ChatCompletionRequest{}, Response: ChatCompletion{}, Stream: true},
{Method: "POST", Path: "/v1/completions", Summary: "A prompt continued",
Request: CompletionRequest{}, Response: Completion{}, Stream: true},
{Method: "POST", Path: "/v1/embeddings", Summary: "Vectors for texts",
Request: EmbeddingRequest{}, Response: EmbeddingList{}},
{Method: "POST", Path: "/v1/rerank", Summary: "Documents ordered by a query, for a reranker vLLM serves",
Request: RerankRequest{}, Response: anyJSON},
{Method: "POST", Path: "/v1/score", Summary: "Pairs of texts scored, for a scorer vLLM serves",
Request: ScoreRequest{}, Response: anyJSON},
{Method: "POST", Path: "/v1/audio/transcriptions", Summary: "Speech to text",
Request: TranscriptionForm{}, Form: true, Response: Transcription{}},
{Method: "POST", Path: "/v1/audio/translations", Summary: "Speech to English text",
Request: TranscriptionForm{}, Form: true, Response: Transcription{}},
{Method: "POST", Path: "/v1/images/generations", Summary: "Pictures, of a model that makes them",
Description: "Made by ComfyUI. Pictures come back as b64_json and are kept nowhere; x-llmgw-seed says the seed.",
Request: ImageRequest{}, Response: ImagesResponse{}},
{Method: "POST", Path: "/v1/3d/generations", Summary: "A 3D model from a picture, as a job",
Description: "Answered 202 with the job; ask after it until it is completed, then download output_file_id from /v1/files.",
Request: MeshRequest{}, Response: Job{}, Status: 202},
{Method: "GET", Path: "/v1/3d/generations", Summary: "The caller's 3D jobs, newest first",
Query: []Param{
{Name: "limit", Type: "integer", Description: "The most jobs to answer with, at most 100; 20 when absent."},
{Name: "after", Description: "A job's ID: those asked for before it."},
},
Response: JobList{}},
{Method: "GET", Path: "/v1/3d/generations/{id}", Summary: "A 3D job: its status, progress and result", Response: Job{}},
{Method: "POST", Path: "/v1/3d/generations/{id}/cancel", Summary: "Stop a 3D job, in the engine's queue too", Response: Job{}},
{Method: "POST", Path: "/v1/moderations", Summary: "Texts judged by the guardrails' judge model",
Request: ModerationRequest{}, Response: ModerationResponse{}},
{Method: "POST", Path: "/v1/responses", Summary: "OpenAI's Responses API, for Codex and the like",
Request: ResponsesRequest{}, Response: anyJSON, Stream: true},
{Method: "POST", Path: "/v1/messages", Summary: "Anthropic's Messages API, for Claude Code and Anthropic's SDKs",
Description: "The key is sent as x-api-key or as a bearer token.",
Request: MessagesRequest{}, Response: anyJSON, Stream: true},
{Method: "POST", Path: "/v1/messages/count_tokens", Summary: "How many tokens a Messages request would be",
Request: MessagesRequest{}, Response: TokenCount{}},
{Method: "GET", Path: "/v1/models", Summary: "The models the key may use", Response: ModelList{}},
{Method: "POST", Path: "/v1/files", Summary: "Upload a file: a batch's input",
Request: FileForm{}, Form: true, Response: File{}},
{Method: "GET", Path: "/v1/files", Summary: "The caller's files",
Query: []Param{{Name: "purpose", Description: "Only files of this purpose: batch, batch_output, job_output."}},
Response: FileList{}},
{Method: "GET", Path: "/v1/files/{id}", Summary: "A file", Response: File{}},
{Method: "GET", Path: "/v1/files/{id}/content", Summary: "A file's content",
Binary: "application/octet-stream"},
{Method: "DELETE", Path: "/v1/files/{id}", Summary: "Delete a file", Response: Deleted{}},
{Method: "POST", Path: "/v1/batches", Summary: "Run a file of requests at the lowest priority",
Request: BatchRequest{}, Response: Batch{}},
{Method: "GET", Path: "/v1/batches", Summary: "The caller's batches",
Query: []Param{
{Name: "limit", Type: "integer", Description: "The most batches to answer with; 20 when absent."},
{Name: "after", Description: "A batch's ID: those made before it."},
},
Response: BatchList{}},
{Method: "GET", Path: "/v1/batches/{id}", Summary: "A batch and how far it is", Response: Batch{}},
{Method: "POST", Path: "/v1/batches/{id}/cancel", Summary: "Stop a batch", Response: Batch{}},
{Method: "DELETE", Path: "/v1/threads/{id}", Summary: "Forget a stored conversation"},
{Method: "PUT", Path: "/v1/memory/{key}", Summary: "Keep a note for the project",
Request: MemoryWrite{}, Response: MemoryNote{}},
{Method: "GET", Path: "/v1/memory/{key}", Summary: "A note", Response: MemoryNote{}},
{Method: "GET", Path: "/v1/memory", Summary: "The project's notes", Response: MemoryList{}},
{Method: "DELETE", Path: "/v1/memory/{key}", Summary: "Forget a note"},
{Method: "POST", Path: "/mcp", Summary: "The gateway's MCP server: the memory notes as tools",
Request: MCPRequest{}, Response: anyJSON},
{Method: "GET", Path: "/me", Summary: "The caller: its key, its place in the tree, its limits and what it has used",
Response: Me{}},
{Method: "GET", Path: "/v1/kb/collections", Summary: "The knowledge-base collections the caller may read",
Response: []router.KBCollectionView{}},
{Method: "GET", Path: "/v1/kb/collections/{id}/manifest", Summary: "A collection and its documents",
Response: router.KBManifest{}},
{Method: "PUT", Path: "/v1/kb/collections/{id}/documents", Summary: "Add or replace a document, read and embedded by the client",
Request: router.KBPutDocument{}, Response: router.KBPutResult{}},
{Method: "DELETE", Path: "/v1/kb/collections/{id}/documents", Summary: "Take a document out of a collection",
Query: []Param{{Name: "name", Required: true, Description: "The document's name, as the manifest lists it."}},
Response: KBDeleted{}},
}
+76
View File
@@ -0,0 +1,76 @@
package clientapi
import (
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"testing"
)
// routerRoutes are the client API's routes as the router mounts them, read
// from its source: every mux.HandleFunc under /v1, /mcp and /me, with the
// path constants it builds some of them from put back.
func routerRoutes(t *testing.T) []string {
t.Helper()
_, file, _, _ := runtime.Caller(0)
dir := filepath.Join(filepath.Dir(file), "..", "router")
consts := map[string]string{"imagesPath": "/v1/images/generations", "meshesPath": "/v1/3d/generations"}
literal := regexp.MustCompile(`mux\.HandleFunc\("([A-Z]+) (/[^"]*)"`)
built := regexp.MustCompile(`mux\.HandleFunc\("([A-Z]+) "\+([A-Za-z]+Path)(\+"([^"]*)")?`)
files, _ := filepath.Glob(filepath.Join(dir, "*.go"))
var out []string
add := func(method, path string) {
if strings.HasPrefix(path, "/v1/") || path == "/mcp" || path == "/me" {
out = append(out, method+" "+path)
}
}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
b, _ := os.ReadFile(f)
for _, m := range literal.FindAllStringSubmatch(string(b), -1) {
add(m[1], m[2])
}
for _, m := range built.FindAllStringSubmatch(string(b), -1) {
c, ok := consts[m[2]]
if !ok {
t.Fatalf("a route built from %s, which this test does not know", m[2])
}
add(m[1], c+m[4])
}
}
slices.Sort(out)
return slices.Compact(out)
}
func TestEveryClientRouteIsDescribedAndNothingElse(t *testing.T) {
// An endpoint added to the router and not to this table would be missing
// from the document every SDK is generated from; one here and not in the
// router would be a path that answers 404.
mounted := routerRoutes(t)
if len(mounted) < 30 {
t.Fatalf("read only %d routes from the router: %v", len(mounted), mounted)
}
var described []string
for _, r := range Routes {
described = append(described, r.Method+" "+r.Path)
}
slices.Sort(described)
for _, m := range mounted {
if m == "GET /v1/openapi.json" {
continue // the document itself
}
if !slices.Contains(described, m) {
t.Errorf("%s is mounted and not described in clientapi.Routes", m)
}
}
for _, d := range described {
if !slices.Contains(mounted, d) {
t.Errorf("%s is described and not mounted", d)
}
}
}
+7
View File
@@ -42,6 +42,13 @@ type Route struct {
Status int Status int
// CSV is set when the answer is CSV unless JSON is asked for. // CSV is set when the answer is CSV unless JSON is asked for.
CSV bool CSV bool
// Form is a request sent as multipart/form-data — a file uploaded —
// whose fields are Request's.
Form bool
// Stream is an answer that may come as server-sent events instead.
Stream bool
// Binary is the type of an answer that is a file, not JSON.
Binary string
} }
// Param is a query parameter. // Param is a query parameter.
+12
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"gitea.mynodes.xyz/ilker/llmgw/internal/clientapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/docgen" "gitea.mynodes.xyz/ilker/llmgw/internal/docgen"
"gitea.mynodes.xyz/ilker/llmgw/internal/docgen/openapi" "gitea.mynodes.xyz/ilker/llmgw/internal/docgen/openapi"
@@ -57,4 +58,15 @@ func main() {
os.Exit(1) os.Exit(1)
} }
fmt.Printf("docs/admin-api.openapi.json: %d routes\n", len(adminapi.Routes)) fmt.Printf("docs/admin-api.openapi.json: %d routes\n", len(adminapi.Routes))
spec, err = openapi.GenerateDoc(root, openapi.Client, clientapi.Routes, clientapi.PathParams)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
if err := os.WriteFile(filepath.Join(root, "docs", "client-api.openapi.json"), spec, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Printf("docs/client-api.openapi.json: %d routes\n", len(clientapi.Routes))
} }
+119 -44
View File
@@ -28,6 +28,7 @@ import (
"time" "time"
"unicode" "unicode"
"gitea.mynodes.xyz/ilker/llmgw/internal/clientapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
) )
@@ -35,10 +36,68 @@ import (
// from any other is described without comments. // from any other is described without comments.
const Module = "gitea.mynodes.xyz/ilker/llmgw" const Module = "gitea.mynodes.xyz/ilker/llmgw"
// Generate renders the document for routes, reading doc comments from the // Doc is what one document says of itself and its API: the two APIs llmgw
// has, the admin listener's and the client's, are described by one
// generator from their route tables.
type Doc struct {
Title, Description string
// ServerURL and ServerDescription are where it is served by default.
ServerURL, ServerDescription string
// Scheme is the bearer token's name in the document, SchemeDescription
// what it is.
Scheme, SchemeDescription string
// Error is a value of the type every refusal answers with.
Error any
// Prefix is taken off paths to name operations and tags.
Prefix string
// Home is the package whose types are named alone; others are named
// with their package.
Home string
// Actor is whether changes take the admin API's X-Llmgw-Actor header.
Actor bool
}
// Admin is the admin API's document.
var Admin = Doc{
Title: "llmgw admin API", Description: infoDescription,
ServerURL: "http://127.0.0.1:9090", ServerDescription: "The admin listener at its default address, admin_listen.",
Scheme: "adminToken",
SchemeDescription: "The admin token: the contents of /var/lib/llmgw/admin-token on the " +
"control plane (admin_token_file), readable by root and the server. " +
"Sent as Authorization: Bearer TOKEN.",
Error: adminapi.ErrorView{}, Prefix: "/api/v1/", Home: reflect.TypeOf(adminapi.Route{}).PkgPath(), Actor: true,
}
// Client is the client API's document: the API on :8080 clients use.
var Client = Doc{
Title: "llmgw client API", Description: clientDescription,
ServerURL: "http://localhost:8080", ServerDescription: "A gateway at its default address, listen.",
Scheme: "apiKey",
SchemeDescription: "An API key, sk-llmgw-…, made with `llmgw keys create` or on the portal's profile page. " +
"Sent as Authorization: Bearer KEY; /v1/messages also takes it as x-api-key.",
Error: clientapi.Error{}, Prefix: "/v1/", Home: reflect.TypeOf(clientapi.Error{}).PkgPath(),
}
const clientDescription = "OpenAI's API, as llmgw serves it, and what llmgw adds: pictures and 3D models from " +
"ComfyUI, knowledge bases, memory notes, stored conversations.\n\n" +
"The SDKs of OpenAI and Anthropic work against it unchanged: set the base URL to the gateway's /v1 and the " +
"key to an llmgw key. A request carries more than is listed here: what the gateway does not read goes to " +
"the engine as it came. Headers llmgw reads: x-llmgw-thread (continue a stored conversation), " +
"x-llmgw-knowledge (answer from these collections), x-llmgw-tags (tags kept with the usage), " +
"x-session-id (stay on one replica). Headers it sends back: x-request-id, x-llmgw-served-model, " +
"x-llmgw-hardware, x-llmgw-seed, and Retry-After when waiting helps.\n\n" +
"Generated from the route table in internal/clientapi; see docs/clients.md."
// Generate renders the admin API's document, reading doc comments from the
// repository at root. // repository at root.
func Generate(root string, routes []adminapi.Route, pathParams map[string]string) ([]byte, error) { func Generate(root string, routes []adminapi.Route, pathParams map[string]string) ([]byte, error) {
g := &generator{root: root, docs: map[string]map[string]typeDoc{}, names: map[reflect.Type]string{}} return GenerateDoc(root, Admin, routes, pathParams)
}
// GenerateDoc renders one API's document.
func GenerateDoc(root string, d Doc, routes []adminapi.Route, pathParams map[string]string) ([]byte, error) {
errorType := reflect.TypeOf(d.Error)
g := &generator{root: root, docs: map[string]map[string]typeDoc{}, names: map[reflect.Type]string{}, d: d}
// Every named struct reachable from a body first, so a name two // Every named struct reachable from a body first, so a name two
// packages share can be told apart before either is written: by the name // packages share can be told apart before either is written: by the name
@@ -68,7 +127,7 @@ func Generate(root string, routes []adminapi.Route, pathParams map[string]string
if err != nil { if err != nil {
return nil, err return nil, err
} }
tags[tagOf(r.Path)] = true tags[g.tagOf(r.Path)] = true
item.set(strings.ToLower(r.Method), op) item.set(strings.ToLower(r.Method), op)
} }
slices.Sort(order) slices.Sort(order)
@@ -92,42 +151,42 @@ func Generate(root string, routes []adminapi.Route, pathParams map[string]string
tagList = append(tagList, obj("name", t)) tagList = append(tagList, obj("name", t))
} }
components := obj(
"securitySchemes", obj(d.Scheme, obj(
"type", "http",
"scheme", "bearer",
"description", d.SchemeDescription,
)),
)
if d.Actor {
components.set("parameters", obj("Actor", obj(
"name", "X-Llmgw-Actor",
"in", "header",
"required", false,
"description", "Who the change is recorded as made by in the audit log: letters, digits "+
"and ._-@, at most 64. unknown when absent.",
"schema", obj("type", "string"),
)))
}
components.set("responses", obj(
"Error", obj("description", "Refused: the error says why.",
"content", obj("application/json", obj("schema", errorSchema))),
"Unauthorized", obj("description", "The token is missing or wrong.",
"content", obj("application/json", obj("schema", errorSchema))),
))
components.set("schemas", schemas)
doc := obj( doc := obj(
"openapi", "3.1.0", "openapi", "3.1.0",
"info", obj( "info", obj(
"title", "llmgw admin API", "title", d.Title,
"version", "v1", "version", "v1",
"description", infoDescription, "description", d.Description,
), ),
"servers", []any{obj("url", "http://127.0.0.1:9090", "servers", []any{obj("url", d.ServerURL, "description", d.ServerDescription)},
"description", "The admin listener at its default address, admin_listen.")}, "security", []any{obj(d.Scheme, []any{})},
"security", []any{obj("adminToken", []any{})},
"tags", tagList, "tags", tagList,
"paths", pathsObj, "paths", pathsObj,
"components", obj( "components", components,
"securitySchemes", obj("adminToken", obj(
"type", "http",
"scheme", "bearer",
"description", "The admin token: the contents of /var/lib/llmgw/admin-token on the "+
"control plane (admin_token_file), readable by root and the server. "+
"Sent as Authorization: Bearer TOKEN.",
)),
"parameters", obj("Actor", obj(
"name", "X-Llmgw-Actor",
"in", "header",
"required", false,
"description", "Who the change is recorded as made by in the audit log: letters, digits "+
"and ._-@, at most 64. unknown when absent.",
"schema", obj("type", "string"),
)),
"responses", obj(
"Error", obj("description", "Refused: the error says why.",
"content", obj("application/json", obj("schema", errorSchema))),
"Unauthorized", obj("description", "The admin token is missing or wrong.",
"content", obj("application/json", obj("schema", errorSchema))),
),
"schemas", schemas,
),
) )
b, err := marshal(doc) b, err := marshal(doc)
if err != nil { if err != nil {
@@ -154,12 +213,12 @@ const infoDescription = "The control plane's management API: what `llmgw ctl` an
// operation builds one route's operation object. // operation builds one route's operation object.
func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (object, error) { func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (object, error) {
var op object var op object
op.set("operationId", operationID(r.Method, r.Path)) op.set("operationId", g.operationID(r.Method, r.Path))
op.set("summary", r.Summary) op.set("summary", r.Summary)
if r.Description != "" { if r.Description != "" {
op.set("description", r.Description) op.set("description", r.Description)
} }
op.set("tags", []any{tagOf(r.Path)}) op.set("tags", []any{g.tagOf(r.Path)})
var params []any var params []any
segs := strings.Split(strings.Trim(r.Path, "/"), "/") segs := strings.Split(strings.Trim(r.Path, "/"), "/")
@@ -174,7 +233,7 @@ func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (o
} }
desc, ok := pathParams[key] desc, ok := pathParams[key]
if !ok { if !ok {
return nil, fmt.Errorf("%s %s: no description of %s in adminapi.PathParams", r.Method, r.Path, key) return nil, fmt.Errorf("%s %s: no description of %s in the path parameters", r.Method, r.Path, key)
} }
params = append(params, obj("name", name, "in", "path", "required", true, params = append(params, obj("name", name, "in", "path", "required", true,
"description", desc, "schema", obj("type", "string"))) "description", desc, "schema", obj("type", "string")))
@@ -192,7 +251,7 @@ func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (o
p.set("schema", obj("type", t)) p.set("schema", obj("type", t))
params = append(params, p) params = append(params, p)
} }
if r.Method != "GET" && r.Method != "HEAD" { if g.d.Actor && r.Method != "GET" && r.Method != "HEAD" {
params = append(params, obj("$ref", "#/components/parameters/Actor")) params = append(params, obj("$ref", "#/components/parameters/Actor"))
} }
if len(params) > 0 { if len(params) > 0 {
@@ -200,8 +259,12 @@ func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (o
} }
if r.Request != nil { if r.Request != nil {
ct := "application/json"
if r.Form {
ct = "multipart/form-data"
}
body := obj("required", !r.OptionalBody, body := obj("required", !r.OptionalBody,
"content", obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Request))))) "content", obj(ct, obj("schema", g.schema(reflect.TypeOf(r.Request)))))
op.set("requestBody", body) op.set("requestBody", body)
} }
@@ -209,10 +272,22 @@ func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (o
if status == 0 { if status == 0 {
status = 200 status = 200
} }
content := obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Response)))) var content object
switch {
case r.Binary != "":
content = obj(r.Binary, obj("schema", obj("type", "string", "format", "binary")))
case r.Response == nil:
content = obj("application/json", obj("schema", obj()))
default:
content = obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Response))))
}
if r.CSV { if r.CSV {
content.set("text/csv", obj("schema", obj("type", "string"))) content.set("text/csv", obj("schema", obj("type", "string")))
} }
if r.Stream {
content.set("text/event-stream", obj("schema", obj("type", "string",
"description", "With stream: true, server-sent events: one JSON chunk per data: line, then data: [DONE].")))
}
desc := "OK" desc := "OK"
if status == 202 { if status == 202 {
desc = "Accepted: started, and running on." desc = "Accepted: started, and running on."
@@ -226,10 +301,10 @@ func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (o
} }
// operationID is the method and the path's words, getModelsNameTarget. // operationID is the method and the path's words, getModelsNameTarget.
func operationID(method, p string) string { func (g *generator) operationID(method, p string) string {
var b strings.Builder var b strings.Builder
b.WriteString(strings.ToLower(method)) b.WriteString(strings.ToLower(method))
for _, s := range strings.Split(strings.TrimPrefix(p, "/api/v1/"), "/") { for _, s := range strings.Split(strings.TrimPrefix(p, g.d.Prefix), "/") {
for _, w := range strings.FieldsFunc(s, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) { for _, w := range strings.FieldsFunc(s, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) {
b.WriteString(strings.ToUpper(w[:1]) + w[1:]) b.WriteString(strings.ToUpper(w[:1]) + w[1:])
} }
@@ -238,8 +313,8 @@ func operationID(method, p string) string {
} }
// tagOf groups an operation by the first word of its path. // tagOf groups an operation by the first word of its path.
func tagOf(p string) string { func (g *generator) tagOf(p string) string {
first, _, _ := strings.Cut(strings.TrimPrefix(p, "/api/v1/"), "/") first, _, _ := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(p, g.d.Prefix), "/"), "/")
return strings.TrimSuffix(first, ".json") return strings.TrimSuffix(first, ".json")
} }
@@ -256,13 +331,13 @@ type generator struct {
names map[reflect.Type]string names map[reflect.Type]string
schemas map[string]any schemas map[string]any
seen []reflect.Type seen []reflect.Type
d Doc
} }
var ( var (
timeType = reflect.TypeOf(time.Time{}) timeType = reflect.TypeOf(time.Time{})
durationType = reflect.TypeOf(time.Duration(0)) durationType = reflect.TypeOf(time.Duration(0))
rawType = reflect.TypeOf(json.RawMessage{}) rawType = reflect.TypeOf(json.RawMessage{})
errorType = reflect.TypeOf(adminapi.ErrorView{})
marshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem() marshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem()
textType = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem() textType = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem()
) )
@@ -294,7 +369,7 @@ func (g *generator) collect(t reflect.Type) {
// modelserve.Job. Qualified always rather than only on a collision, so a // modelserve.Job. Qualified always rather than only on a collision, so a
// type added elsewhere one day cannot rename a schema clients already use. // type added elsewhere one day cannot rename a schema clients already use.
func (g *generator) assignNames() { func (g *generator) assignNames() {
own := reflect.TypeOf(adminapi.Route{}).PkgPath() own := g.d.Home
for _, t := range g.seen { for _, t := range g.seen {
n := t.Name() n := t.Name()
if t.PkgPath() != own { if t.PkgPath() != own {
+37
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/clientapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
) )
@@ -161,3 +162,39 @@ func TestAPathParameterWithNoDescriptionIsAnError(t *testing.T) {
t.Error("a parameter nobody described was let through") t.Error("a parameter nobody described was let through")
} }
} }
func TestTheClientAPIDocumentIsNotStaleAndDescribesWhatClientsSend(t *testing.T) {
want, err := GenerateDoc(repoRoot(t), Client, clientapi.Routes, clientapi.PathParams)
if err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(repoRoot(t), "docs", "client-api.openapi.json"))
if err != nil || string(got) != string(want) {
t.Fatal("docs/client-api.openapi.json no longer matches the client API's routes and types.\n" +
"Run: go generate ./internal/docgen")
}
var doc map[string]any
if err := json.Unmarshal(want, &doc); err != nil {
t.Fatal(err)
}
chat := at(t, doc, "paths", "/v1/chat/completions", "post")
if at(t, chat, "requestBody", "content", "application/json", "schema", "$ref") != "#/components/schemas/ChatCompletionRequest" {
t.Error("chat's body is not its request type")
}
if _, ok := at(t, chat, "responses", "200", "content").(map[string]any)["text/event-stream"]; !ok {
t.Error("chat does not say it may stream")
}
if _, ok := at(t, doc, "paths", "/v1/files", "post", "requestBody", "content").(map[string]any)["multipart/form-data"]; !ok {
t.Error("an upload is not a form")
}
if at(t, doc, "paths", "/v1/3d/generations", "post", "responses", "202", "description") == nil {
t.Error("a 3D job is not answered 202")
}
if _, ok := at(t, doc, "components", "securitySchemes").(map[string]any)["apiKey"]; !ok {
t.Error("no API key scheme")
}
// The admin API's actor header is the admin API's.
if _, ok := at(t, doc, "components").(map[string]any)["parameters"]; ok {
t.Error("the client API takes the admin API's X-Llmgw-Actor")
}
}
+20 -1
View File
@@ -8,6 +8,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
llmgw "gitea.mynodes.xyz/ilker/llmgw"
"io" "io"
"net/http" "net/http"
"slices" "slices"
@@ -119,6 +120,12 @@ func (r *Router) Handler() http.Handler {
mux.HandleFunc("POST /v1/messages", r.handleMessages) mux.HandleFunc("POST /v1/messages", r.handleMessages)
mux.HandleFunc("POST /v1/messages/count_tokens", r.handleCountTokens) mux.HandleFunc("POST /v1/messages/count_tokens", r.handleCountTokens)
mux.HandleFunc("GET /v1/models", r.handleModels) mux.HandleFunc("GET /v1/models", r.handleModels)
// The API described, for SDK generators and the portal's reference.
// Not secret, and asked for before a key is in hand: no key needed.
mux.HandleFunc("GET /v1/openapi.json", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(llmgw.ClientOpenAPI)
})
// Files and batches: a file of requests served at the lowest priority. // Files and batches: a file of requests served at the lowest priority.
mux.HandleFunc("POST /v1/files", r.handleUploadFile) mux.HandleFunc("POST /v1/files", r.handleUploadFile)
mux.HandleFunc("GET /v1/files", r.handleListFiles) mux.HandleFunc("GET /v1/files", r.handleListFiles)
@@ -270,6 +277,17 @@ func (r *Router) Handler() http.Handler {
docs := pages.DocsPage(web.Docs(version.Version, true), r.landing.DocsSignIn) docs := pages.DocsPage(web.Docs(version.Version, true), r.landing.DocsSignIn)
mux.HandleFunc("GET /docs", docs) mux.HandleFunc("GET /docs", docs)
mux.HandleFunc("GET /docs/{doc}", docs) mux.HandleFunc("GET /docs/{doc}", docs)
// The APIs' reference, in Swagger UI: the client API's document to
// whoever may read the docs, the admin API's to staff.
mux.HandleFunc("GET /docs/api", pages.DocsPage(web.APIDocs(version.Version, true), r.landing.DocsSignIn))
mux.HandleFunc("GET /docs/api/admin.json", pages.Guard(func(w http.ResponseWriter, req *http.Request, user store.User) {
if !user.Staff() {
http.NotFound(w, req)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(llmgw.AdminOpenAPI)
}))
r.registerChat(mux, pages) r.registerChat(mux, pages)
// An organization's own page, for the people who manage it. // An organization's own page, for the people who manage it.
pages.RegisterOrgPages(mux, r.store, r.acct) pages.RegisterOrgPages(mux, r.store, r.acct)
@@ -326,12 +344,13 @@ func (r *Router) Handler() http.Handler {
docs := web.Docs(version.Version, false) docs := web.Docs(version.Version, false)
mux.HandleFunc("GET /docs", docs) mux.HandleFunc("GET /docs", docs)
mux.HandleFunc("GET /docs/{doc}", docs) mux.HandleFunc("GET /docs/{doc}", docs)
mux.HandleFunc("GET /docs/api", web.APIDocs(version.Version, false))
} }
if r.auth == nil { if r.auth == nil {
return r.refuseWhileStopped(r.refuseLoops(mux)) return r.refuseWhileStopped(r.refuseLoops(mux))
} }
exempt := []string{"/health", "/ready", "/leader", "/", "/about", "/_static/", "/_lang/", "/claim/", exempt := []string{"/health", "/ready", "/leader", "/v1/openapi.json", "/", "/about", "/_static/", "/_lang/", "/claim/",
"/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/", "/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/",
"/admin", "/admin/", "/chat", "/chat/", "/images", "/images/", "/3d", "/3d/", "/org", "/org/", "/setup/"} "/admin", "/admin/", "/chat", "/chat/", "/images", "/images/", "/3d", "/3d/", "/org", "/org/", "/setup/"}
// The docs are public unless the site said otherwise, and then guarded by // The docs are public unless the site said otherwise, and then guarded by
+24
View File
@@ -40,3 +40,27 @@ func TestTheEditorSetupScriptNeedsNoKey(t *testing.T) {
t.Errorf("%d %s", w.Code, w.Body.String()) t.Errorf("%d %s", w.Code, w.Body.String())
} }
} }
func TestTheAPIIsDescribedToWhoeverAsksAndTheAdminAPIToStaffOnly(t *testing.T) {
rt, _ := chatRouter(t, "http://127.0.0.1:1")
h := rt.Handler()
get := func(path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
return w
}
// Asked for before there is a key, by SDK generators among others.
if w := get("/v1/openapi.json"); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"title": "llmgw client API"`) {
t.Errorf("/v1/openapi.json: %d %.80s", w.Code, w.Body.String())
}
w := get("/docs/api")
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "/v1/openapi.json") || !strings.Contains(w.Body.String(), "swagger-ui-bundle.js") {
t.Fatalf("/docs/api: %d", w.Code)
}
if strings.Contains(w.Body.String(), "/docs/api/admin.json") {
t.Error("the admin API's document is offered to somebody not signed in")
}
if w := get("/docs/api/admin.json"); w.Code == http.StatusOK {
t.Errorf("the admin API's document without a session: %d", w.Code)
}
}
+38
View File
@@ -0,0 +1,38 @@
package web
import (
"encoding/json"
"html/template"
"net/http"
)
type apiDocsData struct {
Version string
Nav Nav
SignedIn bool
SignIn bool
// Specs are the documents the page offers, as Swagger UI's urls: the
// client API's to everyone, the admin API's to staff.
Specs template.JS
}
// APIDocs renders the APIs' reference: their OpenAPI documents in Swagger
// UI, served from the gateway like every other script here.
func APIDocs(version string, signIn bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
d := apiDocsData{Version: version, SignIn: signIn}
if n, ok := r.Context().Value(navKey{}).(Nav); ok {
n.Current = "docs"
d.Nav, d.SignedIn = n, true
}
specs := []map[string]string{{"name": "client API (/v1)", "url": "/v1/openapi.json"}}
if d.Nav.Admin {
// The admin API is on its own listener, which a browser here
// does not reach: its document is offered to read, not to try.
specs = append(specs, map[string]string{"name": "admin API (admin listener)", "url": "/docs/api/admin.json"})
}
b, _ := json.Marshal(specs)
d.Specs = template.JS(b) //nolint:gosec // our own JSON
render(w, r, "apidocs", d)
}
}
+22
View File
@@ -1,6 +1,7 @@
package web package web
import ( import (
"context"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
@@ -59,3 +60,24 @@ func TestThe3DPageDrawsInEveryLanguageWithItsViewer(t *testing.T) {
} }
} }
} }
func TestTheAPIReferenceOffersTheAdminAPIToStaff(t *testing.T) {
page := func(n *Nav) string {
req := httptest.NewRequest("GET", "/docs/api", nil)
if n != nil {
req = req.WithContext(context.WithValue(req.Context(), navKey{}, *n))
}
rec := httptest.NewRecorder()
APIDocs("v0", true)(rec, req)
if rec.Code != 200 {
t.Fatalf("%d", rec.Code)
}
return rec.Body.String()
}
if p := page(&Nav{}); strings.Contains(p, "admin.json") || !strings.Contains(p, "/v1/openapi.json") {
t.Error("a person not staff is offered the admin API, or not the client API")
}
if p := page(&Nav{Admin: true}); !strings.Contains(p, "/docs/api/admin.json") {
t.Error("staff are not offered the admin API")
}
}
+4 -1
View File
@@ -10,5 +10,8 @@
"docs.every": "Every document", "docs.every": "Every document",
"docs.general": "The documents describe llmgw in general, not this installation:\nthey name no address, model or key of it.", "docs.general": "The documents describe llmgw in general, not this installation:\nthey name no address, model or key of it.",
"docs.english_only": "", "docs.english_only": "",
"docs.footer": "docs" "docs.footer": "docs",
"docs.api_title": "API reference",
"docs.api_sub": "Every endpoint of the API clients use, from its OpenAPI document (<a href=\"/v1/openapi.json\">/v1/openapi.json</a>). <em>Authorize</em> takes an API key, and <em>Try it out</em> sends the request to this gateway.",
"docs.api_link": "API reference: every endpoint, to read and to try"
} }
+4 -1
View File
@@ -10,5 +10,8 @@
"docs.every": "Tüm belgeler", "docs.every": "Tüm belgeler",
"docs.general": "Belgeler bu kurulumu değil, genel olarak llmgw'yi anlatır:\nbu kuruluma ait hiçbir adres, model ya da anahtar içermez.", "docs.general": "Belgeler bu kurulumu değil, genel olarak llmgw'yi anlatır:\nbu kuruluma ait hiçbir adres, model ya da anahtar içermez.",
"docs.english_only": "Belgelerin kendisi yalnızca İngilizcedir.", "docs.english_only": "Belgelerin kendisi yalnızca İngilizcedir.",
"docs.footer": "belgeler" "docs.footer": "belgeler",
"docs.api_title": "API başvurusu",
"docs.api_sub": "İstemcilerin kullandığı API'nin bütün uçları, OpenAPI dokümanından (<a href=\"/v1/openapi.json\">/v1/openapi.json</a>). <em>Authorize</em> bir API anahtarı alır; <em>Try it out</em> isteği bu ağ geçidine gönderir.",
"docs.api_link": "API başvurusu: bütün uçlar, okumak ve denemek için"
} }
+3
View File
@@ -48,3 +48,6 @@
.docs-side { position: static; max-height: none; border-bottom: 1px solid var(--line); .docs-side { position: static; max-height: none; border-bottom: 1px solid var(--line);
padding-bottom: 1rem; } padding-bottom: 1rem; }
} }
/* The API reference: Swagger UI, on the docs page's own background. */
.api-docs { margin: 1rem 0 2rem; background: #fff; border-radius: 6px; }
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+2
View File
@@ -0,0 +1,2 @@
swagger-ui
Copyright 2020-2021 SmartBear Software Inc.
@@ -0,0 +1,2 @@
/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */
!function webpackUniversalModuleDefinition(s,o){"object"==typeof exports&&"object"==typeof module?module.exports=o():"function"==typeof define&&define.amd?define([],o):"object"==typeof exports?exports.SwaggerUIBundle=o():s.SwaggerUIBundle=o()}(this,(()=>(()=>{var s={67526(s,o){"use strict";o.byteLength=function byteLength(s){var o=getLens(s),i=o[0],a=o[1];return 3*(i+a)/4-a},o.toByteArray=function toByteArray(s){var o,i,_=getLens(s),w=_[0],x=_[1],C=new u(function _byteLength(s,o,i){return 3*(o+i)/4-i}(0,w,x)),j=0,L=x>0?w-4:w;for(i=0;i<L;i+=4)o=a[s.charCodeAt(i)]<<18|a[s.charCodeAt(i+1)]<<12|a[s.charCodeAt(i+2)]<<6|a[s.charCodeAt(i+3)],C[j++]=o>>16&255,C[j++]=o>>8&255,C[j++]=255&o;2===x&&(o=a[s.charCodeAt(i)]<<2|a[s.charCodeAt(i+1)]>>4,C[j++]=255&o);1===x&&(o=a[s.charCodeAt(i)]<<10|a[s.charCodeAt(i+1)]<<4|a[s.charCodeAt(i+2)]>>2,C[j++]=o>>8&255,C[j++]=255&o);return C},o.fromByteArray=function fromByteArray(s){for(var o,a=s.length,u=a%3,_=[],w=16383,x=0,C=a-u;x<C;x+=w)_.push(encodeChunk(s,x,x+w>C?C:x+w));1===u?(o=s[a-1],_.push(i[o>>2]+i[o<<4&63]+"==")):2===u&&(o=(s[a-2]<<8)+s[a-1],_.push(i[o>>10]+i[o>>4&63]+i[o<<2&63]+"="));return _.join("")};for(var i=[],a=[],u="undefined"!=typeof Uint8Array?Uint8Array:Array,_="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",w=0;w<64;++w)i[w]=_[w],a[_.charCodeAt(w)]=w;function getLens(s){var o=s.length;if(o%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var i=s.indexOf("=");return-1===i&&(i=o),[i,i===o?0:4-i%4]}function encodeChunk(s,o,a){for(var u,_,w=[],x=o;x<a;x+=3)u=(s[x]<<16&16711680)+(s[x+1]<<8&65280)+(255&s[x+2]),w.push(i[(_=u)>>18&63]+i[_>>12&63]+i[_>>6&63]+i[63&_]);return w.join("")}a["-".charCodeAt(0)]=62,a["_".charCodeAt(0)]=63},48287(s,o,i){"use strict";const a=i(67526),u=i(251),_="function"==typeof Symbol&&"function"==typeof Symbol.for?Symbol.for("nodejs.util.inspect.custom"):null;o.Buffer=Buffer,o.SlowBuffer=function SlowBuffer(s){+s!=s&&(s=0);return Buffer.alloc(+s)},o.INSPECT_MAX_BYTES=50;const w=2147483647;function createBuffer(s){if(s>w)throw new RangeError('The value "'+s+'" is invalid for option "size"');const o=new Uint8Array(s);return Object.setPrototypeOf(o,Buffer.prototype),o}function Buffer(s,o,i){if("number"==typeof s){if("string"==typeof o)throw new TypeError('The "string" argument must be of type string. Received type number');return allocUnsafe(s)}return from(s,o,i)}function from(s,o,i){if("string"==typeof s)return function fromString(s,o){"string"==typeof o&&""!==o||(o="utf8");if(!Buffer.isEncoding(o))throw new TypeError("Unknown encoding: "+o);const i=0|byteLength(s,o);let a=createBuffer(i);const u=a.write(s,o);u!==i&&(a=a.slice(0,u));return a}(s,o);if(ArrayBuffer.isView(s))return function fromArrayView(s){if(isInstance(s,Uint8Array)){const o=new Uint8Array(s);return fromArrayBuffer(o.buffer,o.byteOffset,o.byteLength)}return fromArrayLike(s)}(s);if(null==s)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof s);if(isInstance(s,ArrayBuffer)||s&&isInstance(s.buffer,ArrayBuffer))return fromArrayBuffer(s,o,i);if("undefined"!=typeof SharedArrayBuffer&&(isInstance(s,SharedArrayBuffer)||s&&isInstance(s.buffer,SharedArrayBuffer)))return fromArrayBuffer(s,o,i);if("number"==typeof s)throw new TypeError('The "value" argument must not be of type number. Received type number');const a=s.valueOf&&s.valueOf();if(null!=a&&a!==s)return Buffer.from(a,o,i);const u=function fromObject(s){if(Buffer.isBuffer(s)){const o=0|checked(s.length),i=createBuffer(o);return 0===i.length||s.copy(i,0,0,o),i}if(void 0!==s.length)return"number"!=typeof s.length||numberIsNaN(s.length)?createBuffer(0):fromArrayLike(s);if("Buffer"===s.type&&Array.isArray(s.data))return fromArrayLike(s.data)}(s);if(u)return u;if("undefined"!=typeof Symbol&&null!=Symbol.toPrimitive&&"function"==typeof s[Symbol.toPrimitive])return Buffer.from(s[Symbol.toPrimitive]("string"),o,i);throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof s)}function assertSize(s){if("number"!=typeof s)throw new TypeError('"size" argument must be of type number');if(s<0)throw new RangeError('The value "'+s+'" is invalid for option "size"')}function allocUnsafe(s){return assertSize(s),createBuffer(s<0?0:0|checked(s))}function fromArrayLike(s){const o=s.length<0?0:0|checked(s.length),i=createBuffer(o);for(let a=0;a<o;a+=1)i[a]=255&s[a];return i}function fromArrayBuffer(s,o,i){if(o<0||s.byteLength<o)throw new RangeError('"offset" is outside of buffer bounds');if(s.byteLength<o+(i||0))throw new RangeError('"length" is outside of buffer bounds');let a;return a=void 0===o&&void 0===i?new Uint8Array(s):void 0===i?new Uint8Array(s,o):new Uint8Array(s,o,i),Object.setPrototypeOf(a,Buffer.prototype),a}function checked(s){if(s>=w)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+w.toString(16)+" Line truncated
@@ -0,0 +1,2 @@
/*! For license information please see swagger-ui-standalone-preset.js.LICENSE.txt */
!function webpackUniversalModuleDefinition(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SwaggerUIStandalonePreset=e():t.SwaggerUIStandalonePreset=e()}(this,(()=>(()=>{var t={7526(t,e){"use strict";e.byteLength=function byteLength(t){var e=getLens(t),r=e[0],n=e[1];return 3*(r+n)/4-n},e.toByteArray=function toByteArray(t){var e,r,i=getLens(t),a=i[0],s=i[1],u=new o(function _byteLength(t,e,r){return 3*(e+r)/4-r}(0,a,s)),c=0,l=s>0?a-4:a;for(r=0;r<l;r+=4)e=n[t.charCodeAt(r)]<<18|n[t.charCodeAt(r+1)]<<12|n[t.charCodeAt(r+2)]<<6|n[t.charCodeAt(r+3)],u[c++]=e>>16&255,u[c++]=e>>8&255,u[c++]=255&e;2===s&&(e=n[t.charCodeAt(r)]<<2|n[t.charCodeAt(r+1)]>>4,u[c++]=255&e);1===s&&(e=n[t.charCodeAt(r)]<<10|n[t.charCodeAt(r+1)]<<4|n[t.charCodeAt(r+2)]>>2,u[c++]=e>>8&255,u[c++]=255&e);return u},e.fromByteArray=function fromByteArray(t){for(var e,n=t.length,o=n%3,i=[],a=16383,s=0,u=n-o;s<u;s+=a)i.push(encodeChunk(t,s,s+a>u?u:s+a));1===o?(e=t[n-1],i.push(r[e>>2]+r[e<<4&63]+"==")):2===o&&(e=(t[n-2]<<8)+t[n-1],i.push(r[e>>10]+r[e>>4&63]+r[e<<2&63]+"="));return i.join("")};for(var r=[],n=[],o="undefined"!=typeof Uint8Array?Uint8Array:Array,i="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",a=0;a<64;++a)r[a]=i[a],n[i.charCodeAt(a)]=a;function getLens(t){var e=t.length;if(e%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var r=t.indexOf("=");return-1===r&&(r=e),[r,r===e?0:4-r%4]}function encodeChunk(t,e,n){for(var o,i,a=[],s=e;s<n;s+=3)o=(t[s]<<16&16711680)+(t[s+1]<<8&65280)+(255&t[s+2]),a.push(r[(i=o)>>18&63]+r[i>>12&63]+r[i>>6&63]+r[63&i]);return a.join("")}n["-".charCodeAt(0)]=62,n["_".charCodeAt(0)]=63},8287(t,e,r){"use strict";const n=r(7526),o=r(251),i="function"==typeof Symbol&&"function"==typeof Symbol.for?Symbol.for("nodejs.util.inspect.custom"):null;e.Buffer=Buffer,e.SlowBuffer=function SlowBuffer(t){+t!=t&&(t=0);return Buffer.alloc(+t)},e.INSPECT_MAX_BYTES=50;const a=2147483647;function createBuffer(t){if(t>a)throw new RangeError('The value "'+t+'" is invalid for option "size"');const e=new Uint8Array(t);return Object.setPrototypeOf(e,Buffer.prototype),e}function Buffer(t,e,r){if("number"==typeof t){if("string"==typeof e)throw new TypeError('The "string" argument must be of type string. Received type number');return allocUnsafe(t)}return from(t,e,r)}function from(t,e,r){if("string"==typeof t)return function fromString(t,e){"string"==typeof e&&""!==e||(e="utf8");if(!Buffer.isEncoding(e))throw new TypeError("Unknown encoding: "+e);const r=0|byteLength(t,e);let n=createBuffer(r);const o=n.write(t,e);o!==r&&(n=n.slice(0,o));return n}(t,e);if(ArrayBuffer.isView(t))return function fromArrayView(t){if(isInstance(t,Uint8Array)){const e=new Uint8Array(t);return fromArrayBuffer(e.buffer,e.byteOffset,e.byteLength)}return fromArrayLike(t)}(t);if(null==t)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof t);if(isInstance(t,ArrayBuffer)||t&&isInstance(t.buffer,ArrayBuffer))return fromArrayBuffer(t,e,r);if("undefined"!=typeof SharedArrayBuffer&&(isInstance(t,SharedArrayBuffer)||t&&isInstance(t.buffer,SharedArrayBuffer)))return fromArrayBuffer(t,e,r);if("number"==typeof t)throw new TypeError('The "value" argument must not be of type number. Received type number');const n=t.valueOf&&t.valueOf();if(null!=n&&n!==t)return Buffer.from(n,e,r);const o=function fromObject(t){if(Buffer.isBuffer(t)){const e=0|checked(t.length),r=createBuffer(e);return 0===r.length||t.copy(r,0,0,e),r}if(void 0!==t.length)return"number"!=typeof t.length||numberIsNaN(t.length)?createBuffer(0):fromArrayLike(t);if("Buffer"===t.type&&Array.isArray(t.data))return fromArrayLike(t.data)}(t);if(o)return o;if("undefined"!=typeof Symbol&&null!=Symbol.toPrimitive&&"function"==typeof t[Symbol.toPrimitive])return Buffer.from(t[Symbol.toPrimitive]("string"),e,r);throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof t)}function assertSize(t){if("number"!=typeof t)throw new TypeError('"size" argument must be of type number');if(t<0)throw new RangeError('The value "'+t+'" is invalid for option "size"')}function allocUnsafe(t){return assertSize(t),createBuffer(t<0?0:0|checked(t))}function fromArrayLike(t){const e=t.length<0?0:0|checked(t.length),r=createBuffer(e);for(let n=0;n<e;n+=1)r[n]=255&t[n];return r}function fromArrayBuffer(t,e,r){if(e<0||t.byteLength<e)throw new RangeError('"offset" is outside of buffer bounds');if(t.byteLength<e+(r||0))throw new RangeError('"length" is outside of buffer bounds');let n;return n=void 0===e&&void 0===r?new Uint8Array(t):void 0===r?new Uint8Array(t,e):new Uint8Array(t,e,r),Object.setPrototypeOf(n,Buffer.prototype),n}function checked(t){if(t>=a)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+Line truncated
@@ -0,0 +1,3 @@
.swagger-ui{color:#3b4151;container-name:swagger-ui;container-type:inline-size;/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */font-family:sans-serif}.swagger-ui html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}.swagger-ui body{margin:0}.swagger-ui article,.swagger-ui aside,.swagger-ui footer,.swagger-ui header,.swagger-ui nav,.swagger-ui section{display:block}.swagger-ui h1{font-size:2em;margin:.67em 0}.swagger-ui figcaption,.swagger-ui figure,.swagger-ui main{display:block}.swagger-ui figure{margin:1em 40px}.swagger-ui hr{box-sizing:content-box;height:0;overflow:visible}.swagger-ui pre{font-family:monospace,monospace;font-size:1em}.swagger-ui a{background-color:transparent;-webkit-text-decoration-skip:objects}.swagger-ui abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.swagger-ui b,.swagger-ui strong{font-weight:inherit;font-weight:bolder}.swagger-ui code,.swagger-ui kbd,.swagger-ui samp{font-family:monospace,monospace;font-size:1em}.swagger-ui dfn{font-style:italic}.swagger-ui mark{background-color:#ff0;color:#000}.swagger-ui small{font-size:80%}.swagger-ui sub,.swagger-ui sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}.swagger-ui sub{bottom:-.25em}.swagger-ui sup{top:-.5em}.swagger-ui audio,.swagger-ui video{display:inline-block}.swagger-ui audio:not([controls]){display:none;height:0}.swagger-ui img{border-style:none}.swagger-ui svg:not(:root){overflow:hidden}.swagger-ui button,.swagger-ui input,.swagger-ui optgroup,.swagger-ui select,.swagger-ui textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}.swagger-ui button,.swagger-ui input{overflow:visible}.swagger-ui button,.swagger-ui select{text-transform:none}.swagger-ui [type=reset],.swagger-ui [type=submit],.swagger-ui button,.swagger-ui html [type=button]{-webkit-appearance:button}.swagger-ui [type=button]::-moz-focus-inner,.swagger-ui [type=reset]::-moz-focus-inner,.swagger-ui [type=submit]::-moz-focus-inner,.swagger-ui button::-moz-focus-inner{border-style:none;padding:0}.swagger-ui [type=button]:-moz-focusring,.swagger-ui [type=reset]:-moz-focusring,.swagger-ui [type=submit]:-moz-focusring,.swagger-ui button:-moz-focusring{outline:1px dotted ButtonText}.swagger-ui fieldset{padding:.35em .75em .625em}.swagger-ui legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}.swagger-ui progress{display:inline-block;vertical-align:baseline}.swagger-ui textarea{overflow:auto}.swagger-ui [type=checkbox],.swagger-ui [type=radio]{box-sizing:border-box;padding:0}.swagger-ui [type=number]::-webkit-inner-spin-button,.swagger-ui [type=number]::-webkit-outer-spin-button{height:auto}.swagger-ui [type=search]{-webkit-appearance:textfield;outline-offset:-2px}.swagger-ui [type=search]::-webkit-search-cancel-button,.swagger-ui [type=search]::-webkit-search-decoration{-webkit-appearance:none}.swagger-ui ::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}.swagger-ui details,.swagger-ui menu{display:block}.swagger-ui summary{display:list-item}.swagger-ui canvas{display:inline-block}.swagger-ui [hidden],.swagger-ui template{display:none}.swagger-ui .debug *{outline:1px solid gold}.swagger-ui .debug-white *{outline:1px solid #fff}.swagger-ui .debug-black *{outline:1px solid #000}.swagger-ui .debug-grid{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) repeat 0 0}.swagger-ui .debug-grid-16{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpLine truncated
/*# sourceMappingURL=swagger-ui.css.map*/
+41
View File
@@ -0,0 +1,41 @@
{{define "apidocs"}}<!doctype html>
<html lang="{{lang}}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{t "docs.api_title"}} · {{t "docs.title"}}</title>
<link rel="stylesheet" href="{{stylesheet}}">
<link rel="stylesheet" href="{{asset "docs.css"}}">
<link rel="stylesheet" href="{{asset "swagger/swagger-ui.css"}}">
</head><body><div class="wrap">
<header class="docs-head">
<p class="docs-brand"><a href="/docs">{{t "docs.title"}}</a></p>
{{if .SignedIn}}{{template "nav" .Nav}}{{else}}
<nav class="menu" aria-label="{{t "nav.aria"}}">
<a href="/">{{t "docs.this_gateway"}}</a>
<a href="/docs">{{t "nav.docs"}}</a>
<a href="/about">{{t "docs.about"}}</a>
{{if .SignIn}}<a href="/login">{{t "landing.sign_in"}}</a>{{end}}
</nav>{{end}}
</header>
<h1>{{t "docs.api_title"}}</h1>
<p class="sub">{{th "docs.api_sub"}}</p>
<div id="swagger" class="api-docs"></div>
<footer><span><a href="/about">llmgw {{.Version}}</a></span><span><a href="/docs">{{t "docs.footer"}}</a></span>{{template "langs"}}</footer>
</div>
<script src="{{asset "swagger/swagger-ui-bundle.js"}}"></script>
<script src="{{asset "swagger/swagger-ui-standalone-preset.js"}}"></script>
<script>
const specs = {{.Specs}};
window.addEventListener("load", function () {
SwaggerUIBundle({
urls: specs,
"urls.primaryName": specs[0].name,
dom_id: "#swagger",
deepLinking: true,
// The key is typed into Authorize and kept for this page only.
persistAuthorization: false,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: "StandaloneLayout",
});
});
</script>
</body></html>{{end}}
+3
View File
@@ -23,6 +23,7 @@
<div class="docs"> <div class="docs">
<aside class="docs-side" aria-label="{{t "docs.documents"}}"> <aside class="docs-side" aria-label="{{t "docs.documents"}}">
<ul> <ul>
<li><a href="/docs/api">{{t "docs.api_title"}}</a></li>
{{range .Docs}} {{range .Docs}}
<li><a href="/docs/{{.Key}}"{{if and $.Doc (eq $.Doc.Key .Key)}} aria-current="page"{{end}}>{{.Title}}</a> <li><a href="/docs/{{.Key}}"{{if and $.Doc (eq $.Doc.Key .Key)}} aria-current="page"{{end}}>{{.Title}}</a>
{{- if and $.Doc (eq $.Doc.Key .Key) $.Doc.Sections}} {{- if and $.Doc (eq $.Doc.Key .Key) $.Doc.Sections}}
@@ -48,6 +49,8 @@
</section> </section>
{{end}} {{end}}
<p><a href="/docs/api">{{t "docs.api_link"}}</a></p>
<h2>{{t "docs.every"}}</h2> <h2>{{t "docs.every"}}</h2>
<ul class="docs-index"> <ul class="docs-index">
{{range .Docs}} <li><a href="/docs/{{.Key}}">{{.Title}}</a></li> {{range .Docs}} <li><a href="/docs/{{.Key}}">{{.Title}}</a></li>