The client API described in OpenAPI, and both APIs in Swagger UI
CI / test (push) Failing after 6m12s
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:
1 parent
41601bc5ef
commit
fd3c7bda32
26 files changed
+3956
-48
No files matched your search
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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{}},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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.
|
||||||
@@ -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*/
|
||||||
@@ -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}}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user