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:
Snider 2026-04-14 14:34:51 +01:00
parent 996b5a801a
commit fbb58486c4
17 changed files with 380 additions and 157 deletions

View file

@ -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
View file

@ -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
}

View file

@ -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
View 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)
}
}

View file

@ -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", "")

View file

@ -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"

View file

@ -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"
)

View file

@ -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

View file

@ -1,3 +0,0 @@
module dappco.re/go/core/io
go 1.26.0

View file

@ -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
}

View file

@ -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)
}

View file

@ -1,3 +0,0 @@
module dappco.re/go/core/log
go 1.26.0

16
go.mod
View file

@ -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
View file

@ -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=

View file

@ -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)
}

View file

@ -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{

View file

@ -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
}
}