Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package plugins
package a2aparser

import (
"bytes"
Expand All @@ -7,6 +7,8 @@ import (
"log/slog"

"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/internal/parsercommon"
)

// A2AParser parses A2A JSON-RPC 2.0 request bodies and populates
Expand All @@ -17,7 +19,7 @@ type A2AParser struct{}
func NewA2AParser() *A2AParser { return &A2AParser{} }

func init() {
RegisterPlugin("a2a-parser", func() pipeline.Plugin { return NewA2AParser() })
plugins.RegisterPlugin("a2a-parser", func() pipeline.Plugin { return NewA2AParser() })
}

func (p *A2AParser) Name() string { return "a2a-parser" }
Expand All @@ -40,7 +42,7 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin
return pipeline.Action{Type: pipeline.Continue}
}

var rpc jsonRPCRequest
var rpc parsercommon.JSONRPCRequest
if err := json.Unmarshal(pctx.Body, &rpc); err != nil {
slog.Debug("a2a-parser: invalid JSON-RPC", "error", err, "bodyLen", len(pctx.Body))
return pipeline.Action{Type: pipeline.Continue}
Expand All @@ -54,12 +56,12 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin
// Extract message fields generically — any method with params.message
// gets full extraction (forward-compatible with future A2A methods).
// A2A spec uses "contextId" (current) or "sessionId" (older drafts).
ext.SessionID = rpc.stringParam("contextId")
ext.SessionID = rpc.StringParam("contextId")
if ext.SessionID == "" {
ext.SessionID = rpc.stringParam("sessionId")
ext.SessionID = rpc.StringParam("sessionId")
}
ext.TaskID = rpc.stringParam("taskId")
if msg := rpc.mapParam("message"); msg != nil {
ext.TaskID = rpc.StringParam("taskId")
if msg := rpc.MapParam("message"); msg != nil {
if role, ok := msg["role"].(string); ok {
ext.Role = role
}
Expand All @@ -82,7 +84,7 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin
"parts", len(ext.Parts),
)
for i, part := range ext.Parts {
slog.Debug("a2a-parser: part", "index", i, "kind", part.Kind, "content", truncate(part.Content, debugBodyMax))
slog.Debug("a2a-parser: part", "index", i, "kind", part.Kind, "content", parsercommon.Truncate(part.Content, parsercommon.DebugBodyMax))
}
pctx.Observe("matched_" + rpc.Method)
return pipeline.Action{Type: pipeline.Continue}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package plugins
package a2aparser

import (
"context"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package plugins
package inferenceparser

import (
"bytes"
Expand All @@ -8,6 +8,8 @@ import (
"strings"

"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/internal/parsercommon"
)

// InferenceParser parses outbound OpenAI-compatible LLM inference requests
Expand All @@ -17,7 +19,7 @@ type InferenceParser struct{}
func NewInferenceParser() *InferenceParser { return &InferenceParser{} }

func init() {
RegisterPlugin("inference-parser", func() pipeline.Plugin { return NewInferenceParser() })
plugins.RegisterPlugin("inference-parser", func() pipeline.Plugin { return NewInferenceParser() })
}

func (p *InferenceParser) Name() string { return "inference-parser" }
Expand Down Expand Up @@ -82,7 +84,7 @@ func (p *InferenceParser) OnRequest(_ context.Context, pctx *pipeline.Context) p
slog.Info("inference-parser", "model", ext.Model)
slog.Debug("inference-parser: extracted", "model", ext.Model, "messages", len(ext.Messages), "stream", ext.Stream, "tools", len(ext.Tools))
for i, m := range ext.Messages {
slog.Debug("inference-parser: message", "index", i, "role", m.Role, "content", truncate(m.Content, debugBodyMax))
slog.Debug("inference-parser: message", "index", i, "role", m.Role, "content", parsercommon.Truncate(m.Content, parsercommon.DebugBodyMax))
}

pctx.Observe("matched_" + ext.Model)
Expand Down Expand Up @@ -112,7 +114,7 @@ func (p *InferenceParser) OnResponse(_ context.Context, pctx *pipeline.Context)
"promptTokens", ext.PromptTokens,
"completionTokens", ext.CompletionTokens,
)
slog.Debug("inference-parser: completion", "text", truncate(ext.Completion, debugBodyMax))
slog.Debug("inference-parser: completion", "text", parsercommon.Truncate(ext.Completion, parsercommon.DebugBodyMax))
pctx.Observe("matched_" + ext.Model + "_response")
return pipeline.Action{Type: pipeline.Continue}
}
Expand Down Expand Up @@ -157,7 +159,7 @@ func parseInferenceSSE(body []byte, ext *pipeline.InferenceExtension) {
}
var chunk inferenceStreamChunk
if err := json.Unmarshal(data, &chunk); err != nil {
slog.Debug("inference-parser: skipping malformed SSE data frame", "error", err, "data", truncate(string(data), 128))
slog.Debug("inference-parser: skipping malformed SSE data frame", "error", err, "data", parsercommon.Truncate(string(data), 128))
continue
}
for _, c := range chunk.Choices {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package plugins
package inferenceparser

import (
"context"
Expand Down Expand Up @@ -442,7 +442,7 @@ func TestInferenceParser_OnResponse_SSE(t *testing.T) {
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"total_tokens\":13}}\n\n" +
"data: [DONE]\n\n"
pctx := &pipeline.Context{
Extensions: pipeline.Extensions{Inference: &pipeline.InferenceExtension{Model: "gpt-4", Stream: true}},
Extensions: pipeline.Extensions{Inference: &pipeline.InferenceExtension{Model: "gpt-4", Stream: true}},
ResponseBody: []byte(body),
}
action := p.OnResponse(context.Background(), pctx)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Package parsercommon holds small helpers shared by the in-tree
// protocol parser plugins (a2a-parser, mcp-parser, inference-parser).
// Lives under internal/ so only packages under authlib/plugins/ can
// import it — third-party plugins are expected to either depend on
// the public pipeline surface or roll their own parsing. The contents
// here are stable but intentionally undocumented as a public API.
package parsercommon

// JSONRPCRequest is the minimal JSON-RPC 2.0 request shape the a2a and
// mcp parsers decode. Fields intentionally match the permissive
// `any`-typed variant of the protocol: the parsers never re-emit these
// on the wire so strict typing here would only cost flexibility.
type JSONRPCRequest struct {
Method string `json:"method"`
ID any `json:"id"`
Params map[string]any `json:"params"`
}

// StringParam returns the Params[key] value cast to string, or "" if
// absent / wrong type.
func (r *JSONRPCRequest) StringParam(key string) string {
v, _ := r.Params[key].(string)
return v
}

// MapParam returns the Params[key] value cast to map[string]any, or
// nil if absent / wrong type.
func (r *JSONRPCRequest) MapParam(key string) map[string]any {
v, _ := r.Params[key].(map[string]any)
return v
}

// DebugBodyMax caps how many characters of a body/content string a
// parser writes into debug logs. Large enough to capture a short user
// message or a tool_call response verbatim, small enough to keep log
// lines tractable.
const DebugBodyMax = 512

// Truncate clips s to max characters, appending "..." when truncated.
// Shared across parsers for consistent debug-log body formatting.
func Truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package plugins
package mcpparser

import (
"bytes"
Expand All @@ -7,6 +7,8 @@ import (
"log/slog"

"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/internal/parsercommon"
)

// MCPParser parses MCP JSON-RPC 2.0 request bodies and populates
Expand All @@ -17,7 +19,7 @@ type MCPParser struct{}
func NewMCPParser() *MCPParser { return &MCPParser{} }

func init() {
RegisterPlugin("mcp-parser", func() pipeline.Plugin { return NewMCPParser() })
plugins.RegisterPlugin("mcp-parser", func() pipeline.Plugin { return NewMCPParser() })
}

func (p *MCPParser) Name() string { return "mcp-parser" }
Expand All @@ -39,14 +41,14 @@ func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin
return pipeline.Action{Type: pipeline.Continue}
}

var rpc jsonRPCRequest
var rpc parsercommon.JSONRPCRequest
if err := json.Unmarshal(pctx.Body, &rpc); err != nil {
slog.Debug("mcp-parser: body is not valid JSON-RPC", "error", err, "bodyLen", len(pctx.Body))
return pipeline.Action{Type: pipeline.Continue}
}
// Empty method → body parses as JSON but isn't a JSON-RPC request
// (e.g. an OpenAI chat/completions body also unmarshals into
// jsonRPCRequest with zero-value fields). Don't attach a useless
// JSONRPCRequest with zero-value fields). Don't attach a useless
// MCPExtension to non-MCP traffic — downstream consumers shouldn't
// see a phantom "mcp: {}" on every inference event.
if rpc.Method == "" {
Expand All @@ -61,7 +63,7 @@ func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin
}

slog.Info("mcp-parser: request", "method", rpc.Method)
slog.Debug("mcp-parser: payload", "method", rpc.Method, "body", truncate(string(pctx.Body), debugBodyMax))
slog.Debug("mcp-parser: payload", "method", rpc.Method, "body", parsercommon.Truncate(string(pctx.Body), parsercommon.DebugBodyMax))

pctx.Observe("matched_" + rpc.Method)
return pipeline.Action{Type: pipeline.Continue}
Expand Down Expand Up @@ -98,7 +100,7 @@ func (p *MCPParser) OnResponse(_ context.Context, pctx *pipeline.Context) pipeli
if rpc.Result != nil {
pctx.Extensions.MCP.Result = rpc.Result
slog.Info("mcp-parser: response", "method", pctx.Extensions.MCP.Method, "resultKeys", resultKeys(rpc.Result))
slog.Debug("mcp-parser: response detail", "method", pctx.Extensions.MCP.Method, "body", truncate(string(pctx.ResponseBody), debugBodyMax))
slog.Debug("mcp-parser: response detail", "method", pctx.Extensions.MCP.Method, "body", parsercommon.Truncate(string(pctx.ResponseBody), parsercommon.DebugBodyMax))
}

pctx.Observe("matched_" + pctx.Extensions.MCP.Method + "_response")
Expand All @@ -125,7 +127,7 @@ func parseMCPResponse(body []byte) (jsonRPCResponse, bool) {
}
var r jsonRPCResponse
if err := json.Unmarshal(data, &r); err != nil {
slog.Debug("mcp-parser: skipping malformed SSE data frame", "error", err, "data", truncate(string(data), 128))
slog.Debug("mcp-parser: skipping malformed SSE data frame", "error", err, "data", parsercommon.Truncate(string(data), 128))
continue
}
if r.Result != nil || r.Error != nil {
Expand Down Expand Up @@ -154,34 +156,3 @@ func resultKeys(m map[string]any) []string {
}
return keys
}

type jsonRPCRequest struct {
Method string `json:"method"`
ID any `json:"id"`
Params map[string]any `json:"params"`
}

// stringParam and mapParam are shared helpers used by both mcp-parser and a2a-parser.
func (r *jsonRPCRequest) stringParam(key string) string {
v, _ := r.Params[key].(string)
return v
}

func (r *jsonRPCRequest) mapParam(key string) map[string]any {
v, _ := r.Params[key].(map[string]any)
return v
}

// debugBodyMax caps how many characters of a body/content string a parser
// writes into debug logs. Large enough to capture a short user message or
// a tool_call response verbatim, small enough to keep log lines tractable.
const debugBodyMax = 512

// truncate clips s to max characters, appending "..." when truncated.
// Shared across parsers for consistent debug-log body formatting.
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package plugins
package mcpparser

import (
"context"
Expand Down
35 changes: 33 additions & 2 deletions authbridge/authlib/plugins/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,44 @@ import (
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins"
// Side-effect imports register the bundled plugins. Same pattern
// main.go uses — ensures Build("jwt-validation") / Build("token-exchange")
// resolve during tests.
// main.go uses — each plugin lives in its own subpackage and
// advertises itself via init(); importing here makes the name
// resolvable to plugins.Build in these tests.
_ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/a2aparser"
_ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/inferenceparser"
jwtvalidation "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation"
_ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/mcpparser"
_ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenbroker"
tokenexchange "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange"
)

// TestBuiltinsRegistered verifies every in-tree plugin is discoverable
// through the registry after its side-effect import. Lives in the
// external test package because importing the plugin subpackages from
// inside the plugins package would cycle (plugin subpackages import
// plugins for RegisterPlugin). The side-effect imports at the top of
// this file drive what's present in the registry during the test run.
func TestBuiltinsRegistered(t *testing.T) {
want := map[string]bool{
"jwt-validation": true,
"token-exchange": true,
"token-broker": true,
"a2a-parser": true,
"mcp-parser": true,
"inference-parser": true,
}
got := plugins.RegisteredPlugins()
gotSet := make(map[string]bool, len(got))
for _, n := range got {
gotSet[n] = true
}
for name := range want {
if !gotSet[name] {
t.Errorf("built-in plugin %q missing from registry; got: %v", name, got)
}
}
}

// TestAuthbridgeCombinedYAML_Loads asserts that the in-repo default
// config consumed by the combined sidecar image parses and produces
// working pipelines. A future rename of any plugin default constant
Expand Down
23 changes: 0 additions & 23 deletions authbridge/authlib/plugins/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,6 @@ import (
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
)

// TestBuiltinsRegistered verifies every in-tree plugin is discoverable
// through the new registry — the list is the public contract that
// operator YAML depends on, so a regression here breaks deployments.
func TestBuiltinsRegistered(t *testing.T) {
want := map[string]bool{
"jwt-validation": true,
"token-exchange": true,
"a2a-parser": true,
"mcp-parser": true,
"inference-parser": true,
}
got := RegisteredPlugins()
gotSet := make(map[string]bool, len(got))
for _, n := range got {
gotSet[n] = true
}
for name := range want {
if !gotSet[name] {
t.Errorf("built-in plugin %q missing from registry; got: %v", name, got)
}
}
}

// TestRegisterPlugin_DoubleRegistration_Panics locks the strict-fail
// policy. Silent last-write-wins would let a deployment with two
// incompatible copies of the same plugin corrupt the pipeline
Expand Down
Loading
Loading