From fbb58486c4fbd3959961e4b981e9ed4eb3f4ece4 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 14:34:51 +0100 Subject: [PATCH] feat(api): WithChatCompletions option + bug fixes in chat_completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 4 +- api.go | 4 + chat_completions.go | 159 ++++++++++++++++++++++++++------------- chat_completions_test.go | 158 ++++++++++++++++++++++++++++++++++++++ cmd/api/cmd.go | 4 +- cmd/api/cmd_sdk.go | 2 +- cmd/api/cmd_test.go | 2 +- docs/index.md | 2 +- go-io/go.mod | 3 - go-io/local.go | 29 ------- go-log/error.go | 14 ---- go-log/go.mod | 3 - go.mod | 16 ++-- go.sum | 12 +-- openapi.go | 81 ++++++++++++-------- openapi_test.go | 5 +- options.go | 39 ++++++++++ 17 files changed, 380 insertions(+), 157 deletions(-) create mode 100644 chat_completions_test.go delete mode 100644 go-io/go.mod delete mode 100644 go-io/local.go delete mode 100644 go-log/error.go delete mode 100644 go-log/go.mod diff --git a/CLAUDE.md b/CLAUDE.md index 80ec574..a538231 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/api.go b/api.go index d875ae8..42d7f78 100644 --- a/api.go +++ b/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 } diff --git a/chat_completions.go b/chat_completions.go index 9d9ef66..a071f6a 100644 --- a/chat_completions.go +++ b/chat_completions.go @@ -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) } - diff --git a/chat_completions_test.go b/chat_completions_test.go new file mode 100644 index 0000000..ed038e2 --- /dev/null +++ b/chat_completions_test.go @@ -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) + } +} diff --git a/cmd/api/cmd.go b/cmd/api/cmd.go index 0dd63cc..b23d546 100644 --- a/cmd/api/cmd.go +++ b/cmd/api/cmd.go @@ -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", "") diff --git a/cmd/api/cmd_sdk.go b/cmd/api/cmd_sdk.go index dfd5a38..f309ec8 100644 --- a/cmd/api/cmd_sdk.go +++ b/cmd/api/cmd_sdk.go @@ -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" diff --git a/cmd/api/cmd_test.go b/cmd/api/cmd_test.go index 09361b7..2f81e64 100644 --- a/cmd/api/cmd_test.go +++ b/cmd/api/cmd_test.go @@ -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" ) diff --git a/docs/index.md b/docs/index.md index d4292c3..15b11b3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/go-io/go.mod b/go-io/go.mod deleted file mode 100644 index af101a6..0000000 --- a/go-io/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module dappco.re/go/core/io - -go 1.26.0 diff --git a/go-io/local.go b/go-io/local.go deleted file mode 100644 index 5bfb15d..0000000 --- a/go-io/local.go +++ /dev/null @@ -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 -} diff --git a/go-log/error.go b/go-log/error.go deleted file mode 100644 index 939c8bf..0000000 --- a/go-log/error.go +++ /dev/null @@ -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) -} diff --git a/go-log/go.mod b/go-log/go.mod deleted file mode 100644 index c513da7..0000000 --- a/go-log/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module dappco.re/go/core/log - -go 1.26.0 diff --git a/go.mod b/go.mod index a66b7dc..4e3203c 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 1adac8d..3255d67 100644 --- a/go.sum +++ b/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= diff --git a/openapi.go b/openapi.go index 2035bb2..7db8325 100644 --- a/openapi.go +++ b/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) } diff --git a/openapi_test.go b/openapi_test.go index aa67c86..adb6225 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -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{ diff --git a/options.go b/options.go index ae3b97c..33138b5 100644 --- a/options.go +++ b/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 + } +}