feat(api): canonical webhook events + chat completions transport discovery
Implements gaps between RFC.md spec and code: - Export canonical webhook event identifiers (RFC §6) as Go constants: WebhookEventWorkspaceCreated, WebhookEventLinkClicked, etc. Plus WebhookEvents() and IsKnownWebhookEvent(name) helpers for SDK consumers and middleware validation. - Surface the chat completions endpoint (RFC §11.1) through TransportConfig (ChatCompletionsEnabled + ChatCompletionsPath) and the OpenAPI spec extensions (x-chat-completions-enabled, x-chat-completions-path) so clients can auto-discover the local OpenAI-compatible endpoint. - Add internal test coverage for chat completions sampling defaults (Gemma 4 calibrated temp=1.0, top_p=0.95, top_k=64, max_tokens=2048) and the ThinkingExtractor channel routing (RFC §11.6). Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
da1839f730
commit
fb498f0b88
8 changed files with 524 additions and 17 deletions
218
chat_completions_internal_test.go
Normal file
218
chat_completions_internal_test.go
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
inference "dappco.re/go/core/inference"
|
||||
)
|
||||
|
||||
// TestChatCompletions_chatResolvedFloat_Good_ReturnsDefaultWhenNil verifies the
|
||||
// calibrated default wins when the caller omits the parameter (pointer is nil).
|
||||
//
|
||||
// Spec §11.2 — "When a parameter is omitted (nil), the server applies the
|
||||
// calibrated default."
|
||||
func TestChatCompletions_chatResolvedFloat_Good_ReturnsDefaultWhenNil(t *testing.T) {
|
||||
got := chatResolvedFloat(nil, chatDefaultTemperature)
|
||||
if got != chatDefaultTemperature {
|
||||
t.Fatalf("expected default %v, got %v", chatDefaultTemperature, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_chatResolvedFloat_Good_HonoursExplicitZero verifies that
|
||||
// an explicitly-set zero value overrides the default.
|
||||
//
|
||||
// Spec §11.2 — "When explicitly set (including 0.0), the server honours the
|
||||
// caller's value."
|
||||
func TestChatCompletions_chatResolvedFloat_Good_HonoursExplicitZero(t *testing.T) {
|
||||
zero := float32(0.0)
|
||||
got := chatResolvedFloat(&zero, chatDefaultTemperature)
|
||||
if got != 0.0 {
|
||||
t.Fatalf("expected explicit 0.0 to be honoured, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_chatResolvedInt_Good_ReturnsDefaultWhenNil mirrors the
|
||||
// float variant for integer sampling parameters (top_k, max_tokens).
|
||||
func TestChatCompletions_chatResolvedInt_Good_ReturnsDefaultWhenNil(t *testing.T) {
|
||||
got := chatResolvedInt(nil, chatDefaultTopK)
|
||||
if got != chatDefaultTopK {
|
||||
t.Fatalf("expected default %d, got %d", chatDefaultTopK, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_chatResolvedInt_Good_HonoursExplicitZero verifies that
|
||||
// an explicitly-set zero integer overrides the default.
|
||||
func TestChatCompletions_chatResolvedInt_Good_HonoursExplicitZero(t *testing.T) {
|
||||
zero := 0
|
||||
got := chatResolvedInt(&zero, chatDefaultTopK)
|
||||
if got != 0 {
|
||||
t.Fatalf("expected explicit 0 to be honoured, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_chatRequestOptions_Good_AppliesGemmaDefaults verifies
|
||||
// that an otherwise-empty request produces the Gemma 4 calibrated sampling
|
||||
// defaults documented in RFC §11.2.
|
||||
//
|
||||
// temperature 1.0, top_p 0.95, top_k 64, max_tokens 2048
|
||||
func TestChatCompletions_chatRequestOptions_Good_AppliesGemmaDefaults(t *testing.T) {
|
||||
req := &ChatCompletionRequest{Model: "lemer"}
|
||||
|
||||
opts, err := chatRequestOptions(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(opts) == 0 {
|
||||
t.Fatal("expected at least one inference option for defaults")
|
||||
}
|
||||
|
||||
cfg := inference.ApplyGenerateOpts(opts)
|
||||
if cfg.Temperature != chatDefaultTemperature {
|
||||
t.Fatalf("expected default temperature %v, got %v", chatDefaultTemperature, cfg.Temperature)
|
||||
}
|
||||
if cfg.TopP != chatDefaultTopP {
|
||||
t.Fatalf("expected default top_p %v, got %v", chatDefaultTopP, cfg.TopP)
|
||||
}
|
||||
if cfg.TopK != chatDefaultTopK {
|
||||
t.Fatalf("expected default top_k %d, got %d", chatDefaultTopK, cfg.TopK)
|
||||
}
|
||||
if cfg.MaxTokens != chatDefaultMaxTokens {
|
||||
t.Fatalf("expected default max_tokens %d, got %d", chatDefaultMaxTokens, cfg.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_chatRequestOptions_Good_HonoursExplicitSampling verifies
|
||||
// that caller-supplied sampling parameters (including zero for greedy decoding)
|
||||
// override the Gemma 4 calibrated defaults.
|
||||
func TestChatCompletions_chatRequestOptions_Good_HonoursExplicitSampling(t *testing.T) {
|
||||
temp := float32(0.0)
|
||||
topP := float32(0.5)
|
||||
topK := 10
|
||||
maxTokens := 512
|
||||
|
||||
req := &ChatCompletionRequest{
|
||||
Model: "lemer",
|
||||
Temperature: &temp,
|
||||
TopP: &topP,
|
||||
TopK: &topK,
|
||||
MaxTokens: &maxTokens,
|
||||
}
|
||||
|
||||
opts, err := chatRequestOptions(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
cfg := inference.ApplyGenerateOpts(opts)
|
||||
if cfg.Temperature != 0.0 {
|
||||
t.Fatalf("expected explicit temperature 0.0, got %v", cfg.Temperature)
|
||||
}
|
||||
if cfg.TopP != 0.5 {
|
||||
t.Fatalf("expected explicit top_p 0.5, got %v", cfg.TopP)
|
||||
}
|
||||
if cfg.TopK != 10 {
|
||||
t.Fatalf("expected explicit top_k 10, got %d", cfg.TopK)
|
||||
}
|
||||
if cfg.MaxTokens != 512 {
|
||||
t.Fatalf("expected explicit max_tokens 512, got %d", cfg.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_chatRequestOptions_Bad_RejectsMalformedStop verifies the
|
||||
// stop-token parser surfaces malformed values rather than silently ignoring
|
||||
// them.
|
||||
func TestChatCompletions_chatRequestOptions_Bad_RejectsMalformedStop(t *testing.T) {
|
||||
req := &ChatCompletionRequest{
|
||||
Model: "lemer",
|
||||
Stop: []string{"oops"},
|
||||
}
|
||||
if _, err := chatRequestOptions(req); err == nil {
|
||||
t.Fatal("expected malformed stop entry to produce an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_chatRequestOptions_Ugly_EmptyStopEntryRejected ensures
|
||||
// an all-whitespace stop entry is treated as invalid rather than as zero.
|
||||
func TestChatCompletions_chatRequestOptions_Ugly_EmptyStopEntryRejected(t *testing.T) {
|
||||
req := &ChatCompletionRequest{
|
||||
Model: "lemer",
|
||||
Stop: []string{" "},
|
||||
}
|
||||
if _, err := chatRequestOptions(req); err == nil {
|
||||
t.Fatal("expected empty stop entry to produce an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_isTokenLengthCapReached_Good_RespectsCap documents the
|
||||
// finish_reason=length contract — generation that meets the caller's
|
||||
// max_tokens budget is reported as length-capped.
|
||||
func TestChatCompletions_isTokenLengthCapReached_Good_RespectsCap(t *testing.T) {
|
||||
cap := 10
|
||||
if !isTokenLengthCapReached(&cap, 10) {
|
||||
t.Fatal("expected cap to be reached when generated == max_tokens")
|
||||
}
|
||||
if !isTokenLengthCapReached(&cap, 20) {
|
||||
t.Fatal("expected cap to be reached when generated > max_tokens")
|
||||
}
|
||||
if isTokenLengthCapReached(&cap, 5) {
|
||||
t.Fatal("expected cap not reached when generated < max_tokens")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_isTokenLengthCapReached_Ugly_NilOrZeroDisablesCap
|
||||
// documents that the cap is disabled when max_tokens is unset or non-positive.
|
||||
func TestChatCompletions_isTokenLengthCapReached_Ugly_NilOrZeroDisablesCap(t *testing.T) {
|
||||
if isTokenLengthCapReached(nil, 999_999) {
|
||||
t.Fatal("expected nil max_tokens to disable the cap")
|
||||
}
|
||||
zero := 0
|
||||
if isTokenLengthCapReached(&zero, 999_999) {
|
||||
t.Fatal("expected zero max_tokens to disable the cap")
|
||||
}
|
||||
neg := -1
|
||||
if isTokenLengthCapReached(&neg, 999_999) {
|
||||
t.Fatal("expected negative max_tokens to disable the cap")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_ThinkingExtractor_Good_SeparatesThoughtFromContent
|
||||
// verifies the <|channel>thought marker routes tokens to Thinking() and
|
||||
// subsequent <|channel>assistant tokens land back in Content(). Covers RFC
|
||||
// §11.6.
|
||||
//
|
||||
// The extractor skips whitespace between the marker and the channel name
|
||||
// ("<|channel>thought ...") but preserves whitespace inside channel bodies —
|
||||
// so "Hello " + thought block + " World" arrives as "Hello World" with
|
||||
// both separating spaces retained.
|
||||
func TestChatCompletions_ThinkingExtractor_Good_SeparatesThoughtFromContent(t *testing.T) {
|
||||
ex := NewThinkingExtractor()
|
||||
ex.Process(inference.Token{Text: "Hello"})
|
||||
ex.Process(inference.Token{Text: "<|channel>thought planning... "})
|
||||
ex.Process(inference.Token{Text: "<|channel>assistant World"})
|
||||
|
||||
content := ex.Content()
|
||||
if content != "Hello World" {
|
||||
t.Fatalf("expected content %q, got %q", "Hello World", content)
|
||||
}
|
||||
thinking := ex.Thinking()
|
||||
if thinking == nil {
|
||||
t.Fatal("expected thinking content to be captured")
|
||||
}
|
||||
if *thinking != " planning... " {
|
||||
t.Fatalf("expected thinking %q, got %q", " planning... ", *thinking)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatCompletions_ThinkingExtractor_Ugly_NilReceiverIsSafe documents the
|
||||
// nil-safe accessors so middleware can call them defensively.
|
||||
func TestChatCompletions_ThinkingExtractor_Ugly_NilReceiverIsSafe(t *testing.T) {
|
||||
var ex *ThinkingExtractor
|
||||
if got := ex.Content(); got != "" {
|
||||
t.Fatalf("expected empty content on nil receiver, got %q", got)
|
||||
}
|
||||
if got := ex.Thinking(); got != nil {
|
||||
t.Fatalf("expected nil thinking on nil receiver, got %v", got)
|
||||
}
|
||||
}
|
||||
25
openapi.go
25
openapi.go
|
|
@ -51,6 +51,8 @@ type SpecBuilder struct {
|
|||
ExternalDocsURL string
|
||||
PprofEnabled bool
|
||||
ExpvarEnabled bool
|
||||
ChatCompletionsEnabled bool
|
||||
ChatCompletionsPath string
|
||||
CacheEnabled bool
|
||||
CacheTTL string
|
||||
CacheMaxEntries int
|
||||
|
|
@ -150,6 +152,12 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
|
|||
if sb.ExpvarEnabled {
|
||||
spec["x-expvar-enabled"] = true
|
||||
}
|
||||
if sb.ChatCompletionsEnabled {
|
||||
spec["x-chat-completions-enabled"] = true
|
||||
}
|
||||
if path := sb.effectiveChatCompletionsPath(); path != "" {
|
||||
spec["x-chat-completions-path"] = normaliseOpenAPIPath(path)
|
||||
}
|
||||
if sb.CacheEnabled {
|
||||
spec["x-cache-enabled"] = true
|
||||
}
|
||||
|
|
@ -2030,6 +2038,23 @@ func (sb *SpecBuilder) effectiveSSEPath() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// effectiveChatCompletionsPath returns the configured chat completions path or
|
||||
// the RFC §11.1 default when chat completions is enabled without an explicit
|
||||
// override. An explicit path also surfaces on its own so spec generation
|
||||
// reflects configuration authored ahead of runtime activation.
|
||||
//
|
||||
// sb.effectiveChatCompletionsPath() // "/v1/chat/completions" when enabled
|
||||
func (sb *SpecBuilder) effectiveChatCompletionsPath() string {
|
||||
path := core.Trim(sb.ChatCompletionsPath)
|
||||
if path != "" {
|
||||
return path
|
||||
}
|
||||
if sb.ChatCompletionsEnabled {
|
||||
return defaultChatCompletionsPath
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// effectiveCacheTTL returns a normalised cache TTL when it parses to a
|
||||
// positive duration.
|
||||
func (sb *SpecBuilder) effectiveCacheTTL() string {
|
||||
|
|
|
|||
|
|
@ -710,6 +710,85 @@ func TestSpecBuilder_Good_GraphQLPlaygroundDefaultsToGraphQLTag(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestSpecBuilder_Good_ChatCompletionsEndpointExtension verifies the chat
|
||||
// completions path surfaces as x-chat-completions-path/enabled extensions per
|
||||
// RFC §11.1 so SDK consumers can auto-discover the local endpoint.
|
||||
func TestSpecBuilder_Good_ChatCompletionsEndpointExtension(t *testing.T) {
|
||||
sb := &api.SpecBuilder{
|
||||
Title: "Test",
|
||||
Version: "1.0.0",
|
||||
ChatCompletionsEnabled: true,
|
||||
}
|
||||
|
||||
data, err := sb.Build(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var spec map[string]any
|
||||
if err := json.Unmarshal(data, &spec); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if got := spec["x-chat-completions-enabled"]; got != true {
|
||||
t.Fatalf("expected x-chat-completions-enabled=true, got %v", got)
|
||||
}
|
||||
if got := spec["x-chat-completions-path"]; got != "/v1/chat/completions" {
|
||||
t.Fatalf("expected default chat completions path, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpecBuilder_Good_ChatCompletionsHonoursCustomPath verifies an explicit
|
||||
// path override surfaces through the spec extension.
|
||||
func TestSpecBuilder_Good_ChatCompletionsHonoursCustomPath(t *testing.T) {
|
||||
sb := &api.SpecBuilder{
|
||||
Title: "Test",
|
||||
Version: "1.0.0",
|
||||
ChatCompletionsEnabled: true,
|
||||
ChatCompletionsPath: "/chat",
|
||||
}
|
||||
|
||||
data, err := sb.Build(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var spec map[string]any
|
||||
if err := json.Unmarshal(data, &spec); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if got := spec["x-chat-completions-path"]; got != "/chat" {
|
||||
t.Fatalf("expected custom chat completions path /chat, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpecBuilder_Good_ChatCompletionsOmittedWhenDisabled ensures the
|
||||
// extension keys are absent when chat completions is not configured.
|
||||
func TestSpecBuilder_Good_ChatCompletionsOmittedWhenDisabled(t *testing.T) {
|
||||
sb := &api.SpecBuilder{
|
||||
Title: "Test",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
|
||||
data, err := sb.Build(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var spec map[string]any
|
||||
if err := json.Unmarshal(data, &spec); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := spec["x-chat-completions-enabled"]; ok {
|
||||
t.Fatal("expected x-chat-completions-enabled to be absent when disabled")
|
||||
}
|
||||
if _, ok := spec["x-chat-completions-path"]; ok {
|
||||
t.Fatal("expected x-chat-completions-path to be absent when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecBuilder_Good_EnabledTransportsUseDefaultPaths(t *testing.T) {
|
||||
sb := &api.SpecBuilder{
|
||||
Title: "Test",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ func (e *Engine) OpenAPISpecBuilder() *SpecBuilder {
|
|||
builder.SSEEnabled = runtime.Transport.SSEEnabled
|
||||
builder.PprofEnabled = runtime.Transport.PprofEnabled
|
||||
builder.ExpvarEnabled = runtime.Transport.ExpvarEnabled
|
||||
builder.ChatCompletionsEnabled = runtime.Transport.ChatCompletionsEnabled
|
||||
builder.ChatCompletionsPath = runtime.Transport.ChatCompletionsPath
|
||||
|
||||
builder.CacheEnabled = runtime.Cache.Enabled
|
||||
if runtime.Cache.TTL > 0 {
|
||||
|
|
|
|||
|
|
@ -497,6 +497,47 @@ func TestEngine_Good_TransportConfigReportsDisabledSwaggerWithoutUI(t *testing.T
|
|||
}
|
||||
}
|
||||
|
||||
// TestEngine_Good_TransportConfigReportsChatCompletions verifies that the
|
||||
// chat completions resolver surfaces through TransportConfig so callers can
|
||||
// discover the RFC §11.1 endpoint without rebuilding the engine.
|
||||
func TestEngine_Good_TransportConfigReportsChatCompletions(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
resolver := api.NewModelResolver()
|
||||
e, err := api.New(api.WithChatCompletions(resolver))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
cfg := e.TransportConfig()
|
||||
if !cfg.ChatCompletionsEnabled {
|
||||
t.Fatal("expected chat completions to be enabled")
|
||||
}
|
||||
if cfg.ChatCompletionsPath != "/v1/chat/completions" {
|
||||
t.Fatalf("expected chat completions path /v1/chat/completions, got %q", cfg.ChatCompletionsPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngine_Good_TransportConfigHonoursChatCompletionsPathOverride verifies
|
||||
// that WithChatCompletionsPath surfaces through TransportConfig.
|
||||
func TestEngine_Good_TransportConfigHonoursChatCompletionsPathOverride(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
resolver := api.NewModelResolver()
|
||||
e, err := api.New(
|
||||
api.WithChatCompletions(resolver),
|
||||
api.WithChatCompletionsPath("/chat"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
cfg := e.TransportConfig()
|
||||
if cfg.ChatCompletionsPath != "/chat" {
|
||||
t.Fatalf("expected chat completions path /chat, got %q", cfg.ChatCompletionsPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_Good_OpenAPISpecBuilderExportsDefaultSwaggerPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
|
|
|||
53
transport.go
53
transport.go
|
|
@ -13,18 +13,20 @@ import core "dappco.re/go/core"
|
|||
//
|
||||
// cfg := api.TransportConfig{SwaggerPath: "/swagger", WSPath: "/ws"}
|
||||
type TransportConfig struct {
|
||||
SwaggerEnabled bool
|
||||
SwaggerPath string
|
||||
GraphQLPath string
|
||||
GraphQLEnabled bool
|
||||
GraphQLPlayground bool
|
||||
GraphQLPlaygroundPath string
|
||||
WSEnabled bool
|
||||
WSPath string
|
||||
SSEEnabled bool
|
||||
SSEPath string
|
||||
PprofEnabled bool
|
||||
ExpvarEnabled bool
|
||||
SwaggerEnabled bool
|
||||
SwaggerPath string
|
||||
GraphQLPath string
|
||||
GraphQLEnabled bool
|
||||
GraphQLPlayground bool
|
||||
GraphQLPlaygroundPath string
|
||||
WSEnabled bool
|
||||
WSPath string
|
||||
SSEEnabled bool
|
||||
SSEPath string
|
||||
PprofEnabled bool
|
||||
ExpvarEnabled bool
|
||||
ChatCompletionsEnabled bool
|
||||
ChatCompletionsPath string
|
||||
}
|
||||
|
||||
// TransportConfig returns the currently configured transport metadata for the engine.
|
||||
|
|
@ -41,11 +43,12 @@ func (e *Engine) TransportConfig() TransportConfig {
|
|||
}
|
||||
|
||||
cfg := TransportConfig{
|
||||
SwaggerEnabled: e.swaggerEnabled,
|
||||
WSEnabled: e.wsHandler != nil || e.wsGinHandler != nil,
|
||||
SSEEnabled: e.sseBroker != nil,
|
||||
PprofEnabled: e.pprofEnabled,
|
||||
ExpvarEnabled: e.expvarEnabled,
|
||||
SwaggerEnabled: e.swaggerEnabled,
|
||||
WSEnabled: e.wsHandler != nil || e.wsGinHandler != nil,
|
||||
SSEEnabled: e.sseBroker != nil,
|
||||
PprofEnabled: e.pprofEnabled,
|
||||
ExpvarEnabled: e.expvarEnabled,
|
||||
ChatCompletionsEnabled: e.chatCompletionsResolver != nil,
|
||||
}
|
||||
gql := e.GraphQLConfig()
|
||||
cfg.GraphQLEnabled = gql.Enabled
|
||||
|
|
@ -64,6 +67,22 @@ func (e *Engine) TransportConfig() TransportConfig {
|
|||
if e.sseBroker != nil || core.Trim(e.ssePath) != "" {
|
||||
cfg.SSEPath = resolveSSEPath(e.ssePath)
|
||||
}
|
||||
if e.chatCompletionsResolver != nil || core.Trim(e.chatCompletionsPath) != "" {
|
||||
cfg.ChatCompletionsPath = resolveChatCompletionsPath(e.chatCompletionsPath)
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// resolveChatCompletionsPath returns the configured chat completions path or
|
||||
// the spec §11.1 default when no override has been provided.
|
||||
func resolveChatCompletionsPath(path string) string {
|
||||
path = core.Trim(path)
|
||||
if path == "" {
|
||||
return defaultChatCompletionsPath
|
||||
}
|
||||
if !core.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
|
|
|||
56
webhook.go
56
webhook.go
|
|
@ -8,12 +8,68 @@ import (
|
|||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
)
|
||||
|
||||
// Canonical webhook event identifiers from RFC §6. These constants mirror the
|
||||
// PHP-side event catalogue so Go senders and receivers reference the same
|
||||
// namespaced strings.
|
||||
//
|
||||
// evt := api.WebhookEventLinkClicked // "link.clicked"
|
||||
const (
|
||||
// WebhookEventWorkspaceCreated fires when a new workspace is provisioned.
|
||||
WebhookEventWorkspaceCreated = "workspace.created"
|
||||
// WebhookEventWorkspaceDeleted fires when a workspace is permanently removed.
|
||||
WebhookEventWorkspaceDeleted = "workspace.deleted"
|
||||
// WebhookEventSubscriptionChanged fires when a subscription plan changes.
|
||||
WebhookEventSubscriptionChanged = "subscription.changed"
|
||||
// WebhookEventSubscriptionCancelled fires when a subscription is cancelled.
|
||||
WebhookEventSubscriptionCancelled = "subscription.cancelled"
|
||||
// WebhookEventBiolinkCreated fires when a new biolink page is created.
|
||||
WebhookEventBiolinkCreated = "biolink.created"
|
||||
// WebhookEventLinkClicked fires when a tracked short link is clicked.
|
||||
// High-volume event — recipients should opt in explicitly.
|
||||
WebhookEventLinkClicked = "link.clicked"
|
||||
// WebhookEventTicketCreated fires when a support ticket is opened.
|
||||
WebhookEventTicketCreated = "ticket.created"
|
||||
// WebhookEventTicketReplied fires when a support ticket receives a reply.
|
||||
WebhookEventTicketReplied = "ticket.replied"
|
||||
)
|
||||
|
||||
// WebhookEvents returns the canonical list of webhook event identifiers
|
||||
// documented in RFC §6. The order is stable: catalogue groups share a prefix
|
||||
// (workspace → subscription → biolink → link → ticket).
|
||||
//
|
||||
// for _, evt := range api.WebhookEvents() {
|
||||
// registry.Enable(evt)
|
||||
// }
|
||||
func WebhookEvents() []string {
|
||||
return []string{
|
||||
WebhookEventWorkspaceCreated,
|
||||
WebhookEventWorkspaceDeleted,
|
||||
WebhookEventSubscriptionChanged,
|
||||
WebhookEventSubscriptionCancelled,
|
||||
WebhookEventBiolinkCreated,
|
||||
WebhookEventLinkClicked,
|
||||
WebhookEventTicketCreated,
|
||||
WebhookEventTicketReplied,
|
||||
}
|
||||
}
|
||||
|
||||
// IsKnownWebhookEvent reports whether the given event name is one of the
|
||||
// canonical identifiers documented in RFC §6.
|
||||
//
|
||||
// if !api.IsKnownWebhookEvent(evt) {
|
||||
// return errors.New("unknown webhook event")
|
||||
// }
|
||||
func IsKnownWebhookEvent(name string) bool {
|
||||
return slices.Contains(WebhookEvents(), core.Trim(name))
|
||||
}
|
||||
|
||||
// WebhookSigner produces and verifies HMAC-SHA256 signatures over webhook
|
||||
// payloads. Spec §6: signed payloads (HMAC-SHA256) include a timestamp and
|
||||
// a signature header so receivers can validate authenticity, integrity, and
|
||||
|
|
|
|||
|
|
@ -208,6 +208,73 @@ func TestWebhook_VerifyRequest_Ugly_NilRequestReturnsFalse(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestWebhook_WebhookEvents_Good_ListsCanonicalIdentifiers verifies the
|
||||
// exported list of canonical event names documented in RFC §6.
|
||||
func TestWebhook_WebhookEvents_Good_ListsCanonicalIdentifiers(t *testing.T) {
|
||||
got := WebhookEvents()
|
||||
want := []string{
|
||||
"workspace.created",
|
||||
"workspace.deleted",
|
||||
"subscription.changed",
|
||||
"subscription.cancelled",
|
||||
"biolink.created",
|
||||
"link.clicked",
|
||||
"ticket.created",
|
||||
"ticket.replied",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %d events, got %d: %v", len(want), len(got), got)
|
||||
}
|
||||
for i, evt := range want {
|
||||
if got[i] != evt {
|
||||
t.Fatalf("index %d: expected %q, got %q", i, evt, got[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_WebhookEvents_Good_ReturnsFreshSlice ensures the returned slice
|
||||
// is safe to mutate — callers never corrupt the canonical list.
|
||||
func TestWebhook_WebhookEvents_Good_ReturnsFreshSlice(t *testing.T) {
|
||||
first := WebhookEvents()
|
||||
first[0] = "mutated"
|
||||
second := WebhookEvents()
|
||||
if second[0] != "workspace.created" {
|
||||
t.Fatalf("expected canonical list to be immutable, got %q", second[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_IsKnownWebhookEvent_Good_RecognisesCanonical confirms canonical
|
||||
// identifiers pass the known-event predicate.
|
||||
func TestWebhook_IsKnownWebhookEvent_Good_RecognisesCanonical(t *testing.T) {
|
||||
for _, evt := range WebhookEvents() {
|
||||
if !IsKnownWebhookEvent(evt) {
|
||||
t.Fatalf("expected %q to be recognised", evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_IsKnownWebhookEvent_Good_TrimsWhitespace ensures whitespace
|
||||
// around user-supplied event names does not defeat the lookup.
|
||||
func TestWebhook_IsKnownWebhookEvent_Good_TrimsWhitespace(t *testing.T) {
|
||||
if !IsKnownWebhookEvent(" workspace.created ") {
|
||||
t.Fatal("expected whitespace-padded identifier to be recognised")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_IsKnownWebhookEvent_Bad_RejectsUnknown guards against silent
|
||||
// acceptance of typos or out-of-catalogue event identifiers.
|
||||
func TestWebhook_IsKnownWebhookEvent_Bad_RejectsUnknown(t *testing.T) {
|
||||
if IsKnownWebhookEvent("") {
|
||||
t.Fatal("expected empty string to be rejected")
|
||||
}
|
||||
if IsKnownWebhookEvent("workspace.created.extra") {
|
||||
t.Fatal("expected suffixed event to be rejected")
|
||||
}
|
||||
if IsKnownWebhookEvent("Workspace.Created") {
|
||||
t.Fatal("expected differently-cased identifier to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_IsTimestampValid_Good_UsesConfiguredTolerance exercises the
|
||||
// inclusive boundary where the timestamp falls right at the tolerance edge.
|
||||
func TestWebhook_IsTimestampValid_Good_UsesConfiguredTolerance(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue