feat(api): WithChatCompletions option + bug fixes in chat_completions
- options.go: new WithChatCompletions(resolver) and WithChatCompletionsPath(path); api.New(...) now auto-mounts at /v1/chat/completions when a resolver is configured (previously the resolver could be attached but never mounted, which would have panicked Gin) - chat_completions.go: fixed missing net/http import, dropped ModelType during discovery, Retry-After header set after c.JSON silently lost, swapped OpenAI error type/code fields, swapped validate call site, redundant nil check, builder length read before nil-receiver check - openapi.go: effective*Path helpers surface an explicit path even when the corresponding Enabled flag is false so CLI callers still get x-*-path extensions; /swagger always in authentik public paths - chat_completions_test.go: Good/Bad/Ugly coverage for new options, validation, no-resolver behaviour - openapi_test.go: fix stale assertion for CacheEnabled-gated X-Cache - go.mod: bump dappco.re/go/core/cli to v0.5.2 - Removed local go-io / go-log stubs — replace points to outer modules for single source of truth - Migrated forge.lthn.ai/core/cli imports to dappco.re/go/core/cli across cmd/api/*.go + docs Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
996b5a801a
commit
fbb58486c4
17 changed files with 380 additions and 157 deletions
|
|
@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
Core API is the REST framework for the Lethean ecosystem, providing both a **Go HTTP engine** (Gin-based, with OpenAPI generation, WebSocket/SSE, ToolBridge) and a **PHP Laravel package** (rate limiting, webhooks, API key management, OpenAPI documentation). Both halves serve the same purpose in their respective stacks.
|
||||
|
||||
Module: `forge.lthn.ai/core/api` | Package: `lthn/api` | Licence: EUPL-1.2
|
||||
Module: `dappco.re/go/core/api` | Package: `dappco.re/php/service` | Licence: EUPL-1.2
|
||||
|
||||
## Build and Test Commands
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ Key services: `WebhookService`, `RateLimitService`, `IpRestrictionService`, `Ope
|
|||
|
||||
| Go module | Role |
|
||||
|-----------|------|
|
||||
| `forge.lthn.ai/core/cli` | CLI command registration |
|
||||
| `dappco.re/go/core/cli` | CLI command registration |
|
||||
| `github.com/gin-gonic/gin` | HTTP router |
|
||||
| `github.com/casbin/casbin/v2` | Authorisation policies |
|
||||
| `github.com/coreos/go-oidc/v3` | OIDC / Authentik |
|
||||
|
|
|
|||
4
api.go
4
api.go
|
|
@ -86,6 +86,10 @@ func New(opts ...Option) (*Engine, error) {
|
|||
for _, opt := range opts {
|
||||
opt(e)
|
||||
}
|
||||
// Apply calibrated defaults for optional subsystems.
|
||||
if e.chatCompletionsResolver != nil && core.Trim(e.chatCompletionsPath) == "" {
|
||||
e.chatCompletionsPath = defaultChatCompletionsPath
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -15,8 +15,6 @@ import (
|
|||
"time"
|
||||
"unicode"
|
||||
|
||||
"math/rand"
|
||||
|
||||
"dappco.re/go/core"
|
||||
inference "dappco.re/go/core/inference"
|
||||
|
||||
|
|
@ -43,15 +41,15 @@ const channelMarker = "<|channel>"
|
|||
// Stream: true,
|
||||
// }
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
Temperature *float32 `json:"temperature,omitempty"`
|
||||
TopP *float32 `json:"top_p,omitempty"`
|
||||
TopK *int `json:"top_k,omitempty"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Stop []string `json:"stop,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
Temperature *float32 `json:"temperature,omitempty"`
|
||||
TopP *float32 `json:"top_p,omitempty"`
|
||||
TopK *int `json:"top_k,omitempty"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Stop []string `json:"stop,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// ChatMessage is a single turn in a conversation.
|
||||
|
|
@ -66,13 +64,13 @@ type ChatMessage struct {
|
|||
//
|
||||
// resp.Choices[0].Message.Content // "4"
|
||||
type ChatCompletionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChatChoice `json:"choices"`
|
||||
Usage ChatUsage `json:"usage"`
|
||||
Thought *string `json:"thought,omitempty"`
|
||||
Usage ChatUsage `json:"usage"`
|
||||
Thought *string `json:"thought,omitempty"`
|
||||
}
|
||||
|
||||
// ChatChoice is a single response option.
|
||||
|
|
@ -98,12 +96,12 @@ type ChatUsage struct {
|
|||
//
|
||||
// chunk.Choices[0].Delta.Content // Partial token text
|
||||
type ChatCompletionChunk struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChatChunkChoice `json:"choices"`
|
||||
Thought *string `json:"thought,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChatChunkChoice `json:"choices"`
|
||||
Thought *string `json:"thought,omitempty"`
|
||||
}
|
||||
|
||||
// ChatChunkChoice is a streaming delta.
|
||||
|
|
@ -111,7 +109,7 @@ type ChatCompletionChunk struct {
|
|||
// delta.Content // New token(s) in this chunk
|
||||
type ChatChunkChoice struct {
|
||||
Index int `json:"index"`
|
||||
Delta ChatMessageDelta `json:"delta"`
|
||||
Delta ChatMessageDelta `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
|
|
@ -151,9 +149,9 @@ func (e *modelResolutionError) Error() string {
|
|||
//
|
||||
// Resolution order:
|
||||
//
|
||||
// 1) Exact cache hit
|
||||
// 2) ~/.core/models.yaml path mapping
|
||||
// 3) discovery by architecture via inference.Discover()
|
||||
// 1. Exact cache hit
|
||||
// 2. ~/.core/models.yaml path mapping
|
||||
// 3. discovery by architecture via inference.Discover()
|
||||
type ModelResolver struct {
|
||||
mu sync.RWMutex
|
||||
loadedByName map[string]inference.TextModel
|
||||
|
|
@ -161,6 +159,12 @@ type ModelResolver struct {
|
|||
discovery map[string]string
|
||||
}
|
||||
|
||||
// NewModelResolver constructs a ModelResolver with empty caches. The returned
|
||||
// resolver is safe for concurrent use — ResolveModel serialises cache updates
|
||||
// through an internal sync.RWMutex.
|
||||
//
|
||||
// resolver := api.NewModelResolver()
|
||||
// engine, _ := api.New(api.WithChatCompletions(resolver))
|
||||
func NewModelResolver() *ModelResolver {
|
||||
return &ModelResolver{
|
||||
loadedByName: make(map[string]inference.TextModel),
|
||||
|
|
@ -174,9 +178,9 @@ func NewModelResolver() *ModelResolver {
|
|||
func (r *ModelResolver) ResolveModel(name string) (inference.TextModel, error) {
|
||||
if r == nil {
|
||||
return nil, &modelResolutionError{
|
||||
code: "model_not_found",
|
||||
code: "model_not_found",
|
||||
param: "model",
|
||||
msg: "model resolver is not configured",
|
||||
msg: "model resolver is not configured",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -343,16 +347,23 @@ func (r *ModelResolver) resolveDiscoveredPath(name string) (string, bool) {
|
|||
}
|
||||
|
||||
type discoveredModel struct {
|
||||
Path string
|
||||
Path string
|
||||
ModelType string
|
||||
}
|
||||
|
||||
// discoveryModels enumerates locally discovered models under base and
|
||||
// returns Path + ModelType pairs for name resolution.
|
||||
//
|
||||
// for _, m := range discoveryModels(base) {
|
||||
// _ = m.Path
|
||||
// }
|
||||
func discoveryModels(base string) []discoveredModel {
|
||||
var out []discoveredModel
|
||||
for m := range inference.Discover(base) {
|
||||
if m.Path == "" || m.ModelType == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, discoveredModel{Path: m.Path})
|
||||
out = append(out, discoveredModel{Path: m.Path, ModelType: m.ModelType})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -372,16 +383,34 @@ type ThinkingExtractor struct {
|
|||
thought strings.Builder
|
||||
}
|
||||
|
||||
// NewThinkingExtractor constructs a ThinkingExtractor that starts on the
|
||||
// "assistant" channel. Tokens are routed to Content() until a
|
||||
// "<|channel>thought" marker switches the stream to the thinking channel (and
|
||||
// similarly back).
|
||||
//
|
||||
// extractor := api.NewThinkingExtractor()
|
||||
func NewThinkingExtractor() *ThinkingExtractor {
|
||||
return &ThinkingExtractor{
|
||||
currentChannel: "assistant",
|
||||
}
|
||||
}
|
||||
|
||||
// Process feeds a single generated token into the extractor. Tokens are
|
||||
// appended to the current channel buffer (content or thought), switching on
|
||||
// the "<|channel>NAME" marker. Non-streaming handlers call Process in a loop
|
||||
// and then read Content and Thinking when generation completes.
|
||||
//
|
||||
// for tok := range model.Chat(ctx, messages) {
|
||||
// extractor.Process(tok)
|
||||
// }
|
||||
func (te *ThinkingExtractor) Process(token inference.Token) {
|
||||
te.writeDeltas(token.Text)
|
||||
}
|
||||
|
||||
// Content returns all text accumulated on the user-facing "assistant" channel
|
||||
// so far. Safe to call on a nil receiver (returns "").
|
||||
//
|
||||
// text := extractor.Content()
|
||||
func (te *ThinkingExtractor) Content() string {
|
||||
if te == nil {
|
||||
return ""
|
||||
|
|
@ -389,6 +418,13 @@ func (te *ThinkingExtractor) Content() string {
|
|||
return te.content.String()
|
||||
}
|
||||
|
||||
// Thinking returns all text accumulated on the internal "thought" channel so
|
||||
// far or nil when no thinking tokens were produced. Safe to call on a nil
|
||||
// receiver.
|
||||
//
|
||||
// if thinking := extractor.Thinking(); thinking != nil {
|
||||
// response.Thought = thinking
|
||||
// }
|
||||
func (te *ThinkingExtractor) Thinking() *string {
|
||||
if te == nil {
|
||||
return nil
|
||||
|
|
@ -400,14 +436,20 @@ func (te *ThinkingExtractor) Thinking() *string {
|
|||
return &out
|
||||
}
|
||||
|
||||
// writeDeltas tokenises text into the current channel, switching channels
|
||||
// whenever it encounters the "<|channel>NAME" marker. It returns the content
|
||||
// and thought fragments that were added to the builders during this call so
|
||||
// streaming handlers can emit only the new bytes to the wire.
|
||||
//
|
||||
// contentDelta, thoughtDelta := extractor.writeDeltas(tok.Text)
|
||||
func (te *ThinkingExtractor) writeDeltas(text string) (string, string) {
|
||||
beforeContentLen := te.content.Len()
|
||||
beforeThoughtLen := te.thought.Len()
|
||||
|
||||
if te == nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
beforeContentLen := te.content.Len()
|
||||
beforeThoughtLen := te.thought.Len()
|
||||
|
||||
remaining := text
|
||||
for {
|
||||
next := strings.Index(remaining, channelMarker)
|
||||
|
|
@ -500,20 +542,17 @@ func (h *chatCompletionsHandler) ServeHTTP(c *gin.Context) {
|
|||
if err := validateChatRequest(&req); err != nil {
|
||||
chatErr, ok := err.(*chatCompletionRequestError)
|
||||
if !ok {
|
||||
writeChatCompletionError(c, 400, "invalid_request_error", "body", err.Error(), "")
|
||||
writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "body", err.Error(), "")
|
||||
return
|
||||
}
|
||||
writeChatCompletionError(c, chatErr.Status, chatErr.Code, chatErr.Param, chatErr.Message, chatErr.Type)
|
||||
writeChatCompletionError(c, chatErr.Status, chatErr.Type, chatErr.Param, chatErr.Message, chatErr.Code)
|
||||
return
|
||||
}
|
||||
|
||||
model, err := h.resolver.ResolveModel(req.Model)
|
||||
if err != nil {
|
||||
status, chatErrType, chatErrCode, chatErrParam := mapResolverError(err)
|
||||
writeChatCompletionError(c, status, "invalid_request_error", chatErrParam, err.Error(), chatErrType)
|
||||
if chatErrCode != "" {
|
||||
chatErrType = chatErrCode
|
||||
}
|
||||
status, errType, errCode, errParam := mapResolverError(err)
|
||||
writeChatCompletionError(c, status, errType, errParam, err.Error(), errCode)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -670,8 +709,8 @@ func (h *chatCompletionsHandler) serveStreaming(c *gin.Context, model inference.
|
|||
Model: req.Model,
|
||||
Choices: []ChatChunkChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Delta: ChatMessageDelta{},
|
||||
Index: 0,
|
||||
Delta: ChatMessageDelta{},
|
||||
FinishReason: &finished,
|
||||
},
|
||||
},
|
||||
|
|
@ -751,6 +790,14 @@ func chatRequestOptions(req *ChatCompletionRequest) ([]inference.GenerateOption,
|
|||
return opts, nil
|
||||
}
|
||||
|
||||
// chatResolvedFloat honours an explicitly set float sampling parameter or
|
||||
// falls back to the calibrated default when the pointer is nil.
|
||||
//
|
||||
// Spec §11.2: "When a parameter is omitted (nil), the server applies the
|
||||
// calibrated default. When explicitly set (including 0.0), the server honours
|
||||
// the caller's value."
|
||||
//
|
||||
// temperature := chatResolvedFloat(req.Temperature, chatDefaultTemperature)
|
||||
func chatResolvedFloat(v *float32, def float32) float32 {
|
||||
if v == nil {
|
||||
return def
|
||||
|
|
@ -758,6 +805,10 @@ func chatResolvedFloat(v *float32, def float32) float32 {
|
|||
return *v
|
||||
}
|
||||
|
||||
// chatResolvedInt honours an explicitly set integer sampling parameter or
|
||||
// falls back to the calibrated default when the pointer is nil.
|
||||
//
|
||||
// topK := chatResolvedInt(req.TopK, chatDefaultTopK)
|
||||
func chatResolvedInt(v *int, def int) int {
|
||||
if v == nil {
|
||||
return def
|
||||
|
|
@ -785,10 +836,14 @@ func parsedStopTokens(stops []string) ([]int32, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// isTokenLengthCapReached reports whether the generated token count meets or
|
||||
// exceeds the caller's max_tokens budget. Nil or non-positive caps disable the
|
||||
// check (streams terminate by backend signal only).
|
||||
//
|
||||
// if isTokenLengthCapReached(req.MaxTokens, metrics.GeneratedTokens) {
|
||||
// finishReason = "length"
|
||||
// }
|
||||
func isTokenLengthCapReached(maxTokens *int, generated int) bool {
|
||||
if maxTokens == nil {
|
||||
return false
|
||||
}
|
||||
if maxTokens == nil || *maxTokens <= 0 {
|
||||
return false
|
||||
}
|
||||
|
|
@ -812,7 +867,7 @@ func mapResolverError(err error) (int, string, string, string) {
|
|||
|
||||
func writeChatCompletionError(c *gin.Context, status int, errType, param, message, code string) {
|
||||
if status <= 0 {
|
||||
status = 500
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
resp := chatCompletionErrorResponse{
|
||||
Error: chatCompletionError{
|
||||
|
|
@ -823,10 +878,13 @@ func writeChatCompletionError(c *gin.Context, status int, errType, param, messag
|
|||
},
|
||||
}
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.JSON(status, resp)
|
||||
if status == http.StatusServiceUnavailable {
|
||||
// Retry-After must be set BEFORE c.JSON commits headers to the
|
||||
// wire. RFC 9110 §10.2.3 allows either seconds or an HTTP-date;
|
||||
// we use seconds for simplicity and OpenAI parity.
|
||||
c.Header("Retry-After", "10")
|
||||
}
|
||||
c.JSON(status, resp)
|
||||
}
|
||||
|
||||
func codeOrDefault(code, fallback string) string {
|
||||
|
|
@ -848,4 +906,3 @@ func decodeJSONBody(reader io.Reader, dest any) error {
|
|||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(dest)
|
||||
}
|
||||
|
||||
|
|
|
|||
158
chat_completions_test.go
Normal file
158
chat_completions_test.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// TestChatCompletions_WithChatCompletions_Good verifies that WithChatCompletions
|
||||
// mounts the endpoint and unknown model names produce a 404 body conforming to
|
||||
// RFC §11.7.
|
||||
func TestChatCompletions_WithChatCompletions_Good(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
resolver := api.NewModelResolver()
|
||||
engine, err := api.New(api.WithChatCompletions(resolver))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model": "missing-model",
|
||||
"messages": [{"role":"user","content":"hi"}]
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
engine.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d (body=%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Param string `json:"param"`
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("invalid JSON body: %v", err)
|
||||
}
|
||||
if payload.Error.Code != "model_not_found" {
|
||||
t.Fatalf("expected code=model_not_found, got %q", payload.Error.Code)
|
||||
}
|
||||
if payload.Error.Type != "model_not_found" {
|
||||
t.Fatalf("expected type=model_not_found, got %q", payload.Error.Type)
|
||||
}
|
||||
if payload.Error.Param != "model" {
|
||||
t.Fatalf("expected param=model, got %q", payload.Error.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_WithChatCompletionsPath_Good verifies the custom mount path override.
|
||||
func TestChatCompletions_WithChatCompletionsPath_Good(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
resolver := api.NewModelResolver()
|
||||
engine, err := api.New(
|
||||
api.WithChatCompletions(resolver),
|
||||
api.WithChatCompletionsPath("/chat"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(`{
|
||||
"model": "missing-model",
|
||||
"messages": [{"role":"user","content":"hi"}]
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
engine.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d (body=%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_ValidateRequest_Bad verifies that missing messages produces a 400.
|
||||
func TestChatCompletions_ValidateRequest_Bad(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
resolver := api.NewModelResolver()
|
||||
engine, _ := api.New(api.WithChatCompletions(resolver))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
code string
|
||||
}{
|
||||
{
|
||||
name: "missing-messages",
|
||||
body: `{"model":"lemer"}`,
|
||||
code: "invalid_request_error",
|
||||
},
|
||||
{
|
||||
name: "missing-model",
|
||||
body: `{"messages":[{"role":"user","content":"hi"}]}`,
|
||||
code: "invalid_request_error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader([]byte(tc.body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
engine.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d (body=%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("invalid JSON body: %v", err)
|
||||
}
|
||||
if payload.Error.Type != tc.code {
|
||||
t.Fatalf("expected type=%q, got %q", tc.code, payload.Error.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_NoResolver_Ugly verifies graceful handling when an engine
|
||||
// is constructed WITHOUT a resolver — no route is mounted.
|
||||
func TestChatCompletions_NoResolver_Ugly(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
engine, _ := api.New()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{}`))
|
||||
rec := httptest.NewRecorder()
|
||||
engine.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 when no resolver is configured, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
package api
|
||||
|
||||
import "forge.lthn.ai/core/cli/pkg/cli"
|
||||
import "dappco.re/go/core/cli/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddAPICommands)
|
||||
|
|
@ -12,7 +12,7 @@ func init() {
|
|||
//
|
||||
// Example:
|
||||
//
|
||||
// root := &cli.Command{Use: "root"}
|
||||
// root := cli.NewGroup("root", "", "")
|
||||
// api.AddAPICommands(root)
|
||||
func AddAPICommands(root *cli.Command) {
|
||||
apiCmd := cli.NewGroup("api", "API specification and SDK generation", "")
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/core/cli/pkg/cli"
|
||||
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"dappco.re/go/core/cli/pkg/cli"
|
||||
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ engine.Register(&Routes{service: svc})
|
|||
| `go.opentelemetry.io/contrib/.../otelgin` | OpenTelemetry Gin instrumentation |
|
||||
| `golang.org/x/text` | BCP 47 language tag matching |
|
||||
| `gopkg.in/yaml.v3` | YAML export of OpenAPI specs |
|
||||
| `forge.lthn.ai/core/cli` | CLI command registration (for `cmd/api/` subcommands) |
|
||||
| `dappco.re/go/core/cli` | CLI command registration (for `cmd/api/` subcommands) |
|
||||
|
||||
### Ecosystem position
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
module dappco.re/go/core/io
|
||||
|
||||
go 1.26.0
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package io
|
||||
|
||||
import "os"
|
||||
|
||||
// LocalFS provides simple local filesystem helpers used by the API module.
|
||||
var Local localFS
|
||||
|
||||
type localFS struct{}
|
||||
|
||||
// EnsureDir creates the directory path if it does not already exist.
|
||||
func (localFS) EnsureDir(path string) error {
|
||||
if path == "" || path == "." {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(path, 0o755)
|
||||
}
|
||||
|
||||
// Delete removes the named file, ignoring missing files.
|
||||
func (localFS) Delete(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package log
|
||||
|
||||
import "fmt"
|
||||
|
||||
// E wraps an operation label and message in a conventional error.
|
||||
// If err is non-nil, it is wrapped with %w.
|
||||
func E(op, message string, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %s: %w", op, message, err)
|
||||
}
|
||||
return fmt.Errorf("%s: %s", op, message)
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
module dappco.re/go/core/log
|
||||
|
||||
go 1.26.0
|
||||
16
go.mod
16
go.mod
|
|
@ -3,9 +3,11 @@ module dappco.re/go/core/api
|
|||
go 1.26.0
|
||||
|
||||
require (
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
dappco.re/go/core/cli v0.5.2
|
||||
dappco.re/go/core/inference v0.2.1
|
||||
dappco.re/go/core/io v0.1.7
|
||||
dappco.re/go/core/log v0.0.4
|
||||
dappco.re/go/core/cli v0.3.7
|
||||
dappco.re/go/core/log v0.1.2
|
||||
github.com/99designs/gqlgen v0.17.88
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/casbin/casbin/v2 v2.135.0
|
||||
|
|
@ -38,10 +40,7 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
dappco.re/go/core v0.3.2 // indirect
|
||||
dappco.re/go/core/i18n v0.1.7 // indirect
|
||||
dappco.re/go/core/inference v0.1.7 // indirect
|
||||
dappco.re/go/core/log v0.0.4 // indirect
|
||||
dappco.re/go/core/i18n v0.2.3 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
|
|
@ -132,6 +131,7 @@ require (
|
|||
replace (
|
||||
dappco.re/go/core => ../go
|
||||
dappco.re/go/core/i18n => ../go-i18n
|
||||
dappco.re/go/core/io => ./go-io
|
||||
dappco.re/go/core/log => ./go-log
|
||||
dappco.re/go/core/inference => ../go-inference
|
||||
dappco.re/go/core/io => ../go-io
|
||||
dappco.re/go/core/log => ../go-log
|
||||
)
|
||||
|
|
|
|||
12
go.sum
12
go.sum
|
|
@ -1,13 +1,5 @@
|
|||
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
|
||||
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
|
||||
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
|
||||
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
|
||||
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
|
||||
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
|
||||
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
|
||||
forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
dappco.re/go/core/cli v0.5.2 h1:mo+PERo3lUytE+r3ArHr8o2nTftXjgPPsU/rn3ETXDM=
|
||||
dappco.re/go/core/cli v0.5.2/go.mod h1:D4zfn3ec/hb72AWX/JWDvkW+h2WDKQcxGUrzoss7q2s=
|
||||
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
|
||||
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
|
|
|
|||
81
openapi.go
81
openapi.go
|
|
@ -1914,17 +1914,21 @@ func sseResponseHeaders() map[string]any {
|
|||
}
|
||||
|
||||
// effectiveGraphQLPath returns the configured GraphQL path or the default
|
||||
// GraphQL path when GraphQL is enabled without an explicit path. Returns an
|
||||
// empty string when neither GraphQL nor the playground is enabled.
|
||||
// GraphQL path when GraphQL is enabled without an explicit path. An explicit
|
||||
// path also surfaces on its own so spec generation reflects configuration
|
||||
// authored ahead of runtime activation. Returns an empty string only when no
|
||||
// configuration is present.
|
||||
//
|
||||
// sb.effectiveGraphQLPath() // "/graphql" when enabled or configured
|
||||
func (sb *SpecBuilder) effectiveGraphQLPath() string {
|
||||
if !sb.GraphQLEnabled && !sb.GraphQLPlayground {
|
||||
return ""
|
||||
}
|
||||
graphqlPath := core.Trim(sb.GraphQLPath)
|
||||
if graphqlPath == "" {
|
||||
if graphqlPath != "" {
|
||||
return graphqlPath
|
||||
}
|
||||
if sb.GraphQLEnabled || sb.GraphQLPlayground {
|
||||
return defaultGraphQLPath
|
||||
}
|
||||
return graphqlPath
|
||||
return ""
|
||||
}
|
||||
|
||||
// effectiveGraphQLPlaygroundPath returns the configured playground path when
|
||||
|
|
@ -1948,45 +1952,57 @@ func (sb *SpecBuilder) effectiveGraphQLPlaygroundPath() string {
|
|||
}
|
||||
|
||||
// effectiveSwaggerPath returns the configured Swagger UI path or the default
|
||||
// path when Swagger is enabled without an explicit override. Returns an empty
|
||||
// string when Swagger is disabled.
|
||||
// path when Swagger is enabled without an explicit override. An explicit path
|
||||
// also surfaces on its own so spec generation reflects configuration authored
|
||||
// ahead of runtime activation. Returns an empty string only when no
|
||||
// configuration is present.
|
||||
//
|
||||
// sb.effectiveSwaggerPath() // "/swagger" when enabled or configured
|
||||
func (sb *SpecBuilder) effectiveSwaggerPath() string {
|
||||
if !sb.SwaggerEnabled {
|
||||
return ""
|
||||
}
|
||||
swaggerPath := core.Trim(sb.SwaggerPath)
|
||||
if swaggerPath == "" {
|
||||
if swaggerPath != "" {
|
||||
return swaggerPath
|
||||
}
|
||||
if sb.SwaggerEnabled {
|
||||
return defaultSwaggerPath
|
||||
}
|
||||
return swaggerPath
|
||||
return ""
|
||||
}
|
||||
|
||||
// effectiveWSPath returns the configured WebSocket path or the default path
|
||||
// when WebSockets are enabled without an explicit override. Returns an empty
|
||||
// string when WebSockets are disabled.
|
||||
// when WebSockets are enabled without an explicit override. An explicit path
|
||||
// also surfaces on its own so spec generation reflects configuration authored
|
||||
// ahead of runtime activation. Returns an empty string only when no
|
||||
// configuration is present.
|
||||
//
|
||||
// sb.effectiveWSPath() // "/ws" when enabled or configured
|
||||
func (sb *SpecBuilder) effectiveWSPath() string {
|
||||
if !sb.WSEnabled {
|
||||
return ""
|
||||
}
|
||||
wsPath := core.Trim(sb.WSPath)
|
||||
if wsPath == "" {
|
||||
if wsPath != "" {
|
||||
return wsPath
|
||||
}
|
||||
if sb.WSEnabled {
|
||||
return defaultWSPath
|
||||
}
|
||||
return wsPath
|
||||
return ""
|
||||
}
|
||||
|
||||
// effectiveSSEPath returns the configured SSE path or the default path when
|
||||
// SSE is enabled without an explicit override. Returns an empty string when
|
||||
// SSE is disabled.
|
||||
// SSE is enabled without an explicit override. An explicit path also surfaces
|
||||
// on its own so spec generation reflects configuration authored ahead of
|
||||
// runtime activation. Returns an empty string only when no configuration is
|
||||
// present.
|
||||
//
|
||||
// sb.effectiveSSEPath() // "/events" when enabled or configured
|
||||
func (sb *SpecBuilder) effectiveSSEPath() string {
|
||||
if !sb.SSEEnabled {
|
||||
return ""
|
||||
}
|
||||
ssePath := core.Trim(sb.SSEPath)
|
||||
if ssePath == "" {
|
||||
if ssePath != "" {
|
||||
return ssePath
|
||||
}
|
||||
if sb.SSEEnabled {
|
||||
return defaultSSEPath
|
||||
}
|
||||
return ssePath
|
||||
return ""
|
||||
}
|
||||
|
||||
// effectiveCacheTTL returns a normalised cache TTL when it parses to a
|
||||
|
|
@ -2006,13 +2022,18 @@ func (sb *SpecBuilder) effectiveCacheTTL() string {
|
|||
}
|
||||
|
||||
// effectiveAuthentikPublicPaths returns the public paths that Authentik skips
|
||||
// in practice, including the always-public health and Swagger endpoints.
|
||||
// in practice, including the always-public health endpoint and both the
|
||||
// default and any configured Swagger UI paths. The runtime middleware also
|
||||
// always skips "/swagger" unconditionally (see authentikMiddleware), so the
|
||||
// spec mirrors that behaviour even when a custom swagger path is mounted.
|
||||
//
|
||||
// paths := sb.effectiveAuthentikPublicPaths() // [/health /swagger ...]
|
||||
func (sb *SpecBuilder) effectiveAuthentikPublicPaths() []string {
|
||||
if !sb.hasAuthentikMetadata() {
|
||||
return nil
|
||||
}
|
||||
|
||||
paths := []string{"/health"}
|
||||
paths := []string{"/health", defaultSwaggerPath}
|
||||
if swaggerPath := sb.effectiveSwaggerPath(); swaggerPath != "" {
|
||||
paths = append(paths, swaggerPath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1763,8 +1763,9 @@ func TestSpecBuilder_Good_AuthentikPublicPathsMakeBuiltInEndpointsPublic(t *test
|
|||
|
||||
func TestSpecBuilder_Good_EnvelopeWrapping(t *testing.T) {
|
||||
sb := &api.SpecBuilder{
|
||||
Title: "Test",
|
||||
Version: "1.0.0",
|
||||
Title: "Test",
|
||||
Version: "1.0.0",
|
||||
CacheEnabled: true,
|
||||
}
|
||||
|
||||
group := &specStubGroup{
|
||||
|
|
|
|||
39
options.go
39
options.go
|
|
@ -689,3 +689,42 @@ func WithGraphQL(schema graphql.ExecutableSchema, opts ...GraphQLOption) Option
|
|||
e.graphql = cfg
|
||||
}
|
||||
}
|
||||
|
||||
// WithChatCompletions mounts an OpenAI-compatible POST /v1/chat/completions
|
||||
// endpoint backed by the given ModelResolver. The resolver maps model names to
|
||||
// loaded inference.TextModel instances (see chat_completions.go).
|
||||
//
|
||||
// Use WithChatCompletionsPath to override the default "/v1/chat/completions"
|
||||
// mount point. The endpoint streams Server-Sent Events when the request body
|
||||
// sets "stream": true, and otherwise returns a single JSON response that
|
||||
// mirrors OpenAI's chat completion payload.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// resolver := api.NewModelResolver()
|
||||
// engine, _ := api.New(api.WithChatCompletions(resolver))
|
||||
func WithChatCompletions(resolver *ModelResolver) Option {
|
||||
return func(e *Engine) {
|
||||
e.chatCompletionsResolver = resolver
|
||||
}
|
||||
}
|
||||
|
||||
// WithChatCompletionsPath sets a custom URL path for the chat completions
|
||||
// endpoint. The default path is "/v1/chat/completions".
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithChatCompletionsPath("/api/v1/chat/completions"))
|
||||
func WithChatCompletionsPath(path string) Option {
|
||||
return func(e *Engine) {
|
||||
path = core.Trim(path)
|
||||
if path == "" {
|
||||
e.chatCompletionsPath = defaultChatCompletionsPath
|
||||
return
|
||||
}
|
||||
if !core.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
e.chatCompletionsPath = path
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue