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
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
go-version-file: go.mod
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: v2.11.4
version: v2.14.0
install-mode: goinstall

build:
Expand Down
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ linters:
- gosec
path: kong2kic/route\.go
text: G115
# Repeated string literals are idiomatic in tests (table-driven cases, fixtures)
- linters:
- goconst
path: _test\.go
- linters:
- unparam
- unused
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ARG GO_VERSION=1.26.10@sha256:fb456410ab4c979617acaf28c46b2b9fe60b11bcc3dc8ceea0eaeb3e7cd0f8a8
ARG GO_VERSION=1.27.3@sha256:sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc
FROM golang:${GO_VERSION} AS build
WORKDIR /deck
COPY go.mod ./
Expand Down
53 changes: 53 additions & 0 deletions cmd/ai.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package cmd

import (
"errors"
"fmt"
"io"
"os"
"slices"

ai2kong "github.com/Kong/ai-deck-converter/convert"
"github.com/kong/go-database-reconciler/pkg/file"
"github.com/spf13/cobra"
)
Expand All @@ -14,6 +16,10 @@ import (
// configuration on Konnect.
const aiGatewayDocsURL = "https://developer.konghq.com/ai-gateway/kongctl"

// aiGatewayMinRuntimeVersion is the newest AI Gateway release the converter
// knows, so conversion keeps emitting the newest output shape.
const aiGatewayMinRuntimeVersion = "2.2"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now a required field - context https://github.com/Kong/ai-deck-converter/pull/225/changes

Since gets used in deck file ai2kong command, we can't call gateway to load version and then populate this - so leaving it hardcoded.

Ideal scenario - file ai2kong and ai sync accept a new flag --min-runtime-version and override this, but I'd leave that up as an improvement


// flagIncludeCustomPolicyDefinitions is the `ai sync`/`ai dump` flag that opts
// in to managing/dumping the Kong plugin definition entity that AI Gateway
// custom_policies convert to (and revert from)
Expand All @@ -35,6 +41,53 @@ func newAiSubCmd() *cobra.Command {
return aiSubCmd
}

// convertAIGatewaySource converts an AI Gateway source to decK YAML. All ERROR
// issues are returned as a single joined error, because the converter leaves
// the affected entities out of the output and syncing that would prune them.
// Warnings are returned even on error, as they often explain it.
func convertAIGatewaySource(src []byte) ([]byte, []string, error) {
result, err := ai2kong.Convert(src, ai2kong.Options{MinRuntimeVersion: aiGatewayMinRuntimeVersion})
if err != nil {
return nil, nil, err
}

var warnings []string
var errs []error
for _, issue := range result.Issues {
if issue.Severity != ai2kong.Error {
warnings = append(warnings, formatAIIssue(issue))
} else {
errs = append(errs, errors.New(formatAIIssue(issue)))
}
}
if len(errs) > 0 {
return nil, warnings, errors.Join(errs...)
}

converted, err := result.Output.ToYAML()
if err != nil {
return nil, warnings, err
}
return converted, warnings, nil
}

// formatAIIssue renders an issue's reason prefixed with the entity it came from
// (e.g. `model "gpt-4o": ...`), as the reason alone does not say which entity
// is affected. The prefix is omitted for whichever of kind and name is empty.
func formatAIIssue(issue ai2kong.ConversionIssue) string {
src := issue.Source
switch {
case src.Kind != "" && src.Name != "":
return fmt.Sprintf("%s %q: %s", src.Kind, src.Name, issue.Reason)
case src.Kind != "":
return fmt.Sprintf("%s: %s", src.Kind, issue.Reason)
case src.Name != "":
return fmt.Sprintf("%q: %s", src.Name, issue.Reason)
default:
return issue.Reason
}
}

// printAIWarnings writes conversion warnings, one per line, to w. When w is nil
// it defaults to os.Stderr. It is a no-op when there are no warnings.
func printAIWarnings(w io.Writer, warnings []string) {
Expand Down
5 changes: 2 additions & 3 deletions cmd/ai_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"slices"

"dario.cat/mergo"
ai2kong "github.com/Kong/ai-deck-converter/convert"
"github.com/kong/go-database-reconciler/pkg/cprint"
"github.com/kong/go-database-reconciler/pkg/file"
"github.com/kong/go-database-reconciler/pkg/utils"
Expand Down Expand Up @@ -66,11 +65,11 @@ func buildAiSyncTargetContent(filenames []string) (*file.Content, error) {

var merged file.Content
for _, src := range sources {
convertedYAML, warnings, err := ai2kong.Convert(src.content, ai2kong.Options{})
convertedYAML, warnings, err := convertAIGatewaySource(src.content)
reportAiConversionWarnings(warnings, aiSyncJSONOutput)
if err != nil {
return nil, fmt.Errorf("converting %s: %w", src.name, err)
}
reportAiConversionWarnings(warnings, aiSyncJSONOutput)

content, err := file.GetContentFromReader(bytes.NewReader(convertedYAML), file.EnvVarsSkip)
if err != nil {
Expand Down
85 changes: 82 additions & 3 deletions cmd/ai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

ai2kong "github.com/Kong/ai-deck-converter/convert"
Expand Down Expand Up @@ -40,7 +41,7 @@ const aiGatewaySourceYAML = `models:
balancer:
algorithm: round-robin
slots: 10000
providers:
model_providers:
- type: openai
display_name: OpenAI Main
name: openai-main
Expand Down Expand Up @@ -267,15 +268,93 @@ func TestAiConvertJSONInputParity(t *testing.T) {
jsonSource, err := yaml.YAMLToJSON([]byte(aiGatewaySourceYAML))
require.NoError(t, err)

fromYAML, _, err := ai2kong.Convert([]byte(aiGatewaySourceYAML), ai2kong.Options{OutputMode: "deck"})
fromYAML, _, err := convertAIGatewaySource([]byte(aiGatewaySourceYAML))
require.NoError(t, err)
fromJSON, _, err := ai2kong.Convert(jsonSource, ai2kong.Options{OutputMode: "deck"})
fromJSON, _, err := convertAIGatewaySource(jsonSource)
require.NoError(t, err)

require.True(t, bytes.Equal(fromYAML, fromJSON),
"JSON and YAML inputs should convert to identical decK output")
}

// TestFormatAIIssue verifies the entity prefix is built from whichever of the
// issue source's kind and name are set.
func TestFormatAIIssue(t *testing.T) {
tests := []struct {
name string
source ai2kong.ConversionIssueSource
want string
}{
{"kind and name", ai2kong.ConversionIssueSource{Kind: "model", Name: "gpt-4o"}, `model "gpt-4o": boom`},
{"kind only", ai2kong.ConversionIssueSource{Kind: "option"}, "option: boom"},
{"name only", ai2kong.ConversionIssueSource{Name: "gpt-4o"}, `"gpt-4o": boom`},
{"neither", ai2kong.ConversionIssueSource{}, "boom"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatAIIssue(ai2kong.ConversionIssue{Reason: "boom", Source: tt.source})
require.Equal(t, tt.want, got)
})
}
}

// TestConvertAIGatewaySourceIssues verifies that an ERROR issue fails the
// conversion rather than yielding a document with the affected entity left out.
func TestConvertAIGatewaySourceIssues(t *testing.T) {
t.Run("error issue fails the conversion", func(t *testing.T) {
src := "models:\n - name: m1\n access:\n acls:\n allow: [a]\n deny: [b]\n"
out, warnings, err := convertAIGatewaySource([]byte(src))
require.Error(t, err)
require.Nil(t, out)
require.Empty(t, warnings)
})

t.Run("all error issues are reported together", func(t *testing.T) {
src := "models:\n" +
" - name: m1\n access:\n acls:\n allow: [a]\n deny: [b]\n" +
" - name: m2\n access:\n acls:\n allow: [a]\n deny: [b]\n"
out, _, err := convertAIGatewaySource([]byte(src))
require.Error(t, err)
require.Nil(t, out)
lines := strings.Split(err.Error(), "\n")
require.Len(t, lines, 2)
require.Contains(t, lines[0], `model "m1": `)
require.Contains(t, lines[1], `model "m2": `)
})

t.Run("warning issue is returned and does not fail", func(t *testing.T) {
const orphanTarget = " targets:\n" +
" - name: orphan\n" +
" provider: no-such-provider\n" +
" config:\n" +
" type: openai\n"
src := strings.Replace(aiGatewaySourceYAML, " targets:\n", orphanTarget, 1)
out, warnings, err := convertAIGatewaySource([]byte(src))
require.NoError(t, err)
require.NotEmpty(t, out)
require.Len(t, warnings, 1)
require.Contains(t, warnings[0], "no-such-provider")
})

t.Run("warnings that explain an error are returned with it", func(t *testing.T) {
src := strings.Replace(aiGatewaySourceYAML, "provider: openai-main", "provider: no-such-provider", 1)
out, warnings, err := convertAIGatewaySource([]byte(src))
require.Error(t, err)
require.Nil(t, out)
require.Len(t, warnings, 1)
require.Contains(t, warnings[0], "no-such-provider")
})
}

// TestConvertAIGatewaySourceInvalidInput verifies that a source the converter
// cannot parse is returned as an error, with no output and no warnings.
func TestConvertAIGatewaySourceInvalidInput(t *testing.T) {
out, warnings, err := convertAIGatewaySource([]byte("models: ["))
require.Error(t, err)
require.Nil(t, out)
require.Nil(t, warnings)
}

// TestAi2KongOutputFormats drives the ai2kong command end-to-end with JSON input
// and asserts both YAML (default) and JSON output are valid and carry the AI tag,
// and that an unknown format is rejected.
Expand Down
8 changes: 2 additions & 6 deletions cmd/file_ai2kong.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"os"
"strings"

ai2kong "github.com/Kong/ai-deck-converter/convert"
"github.com/kong/go-apiops/filebasics"
"github.com/spf13/cobra"
"sigs.k8s.io/yaml"
Expand Down Expand Up @@ -59,15 +58,12 @@ func execute(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("failed to read source file: %w", err)
}

converted, warnings, err := ai2kong.Convert(sourceContent, ai2kong.Options{
OutputMode: "deck",
})
converted, warnings, err := convertAIGatewaySource(sourceContent)
printAIWarnings(os.Stderr, warnings)
if err != nil {
return fmt.Errorf("conversion failed: %w", err)
}

printAIWarnings(os.Stderr, warnings)

// Add the default select_tags to the converted document's _info section
doc, err := addDefaultSelectTags(converted)
if err != nil {
Expand Down
Loading
Loading