feat: AI models admin, settings import, Anthropic adapter and the rest of AIService
- golem15_golem_models (encrypted write-only api_key) with its cabana list and form under golem15.golem.access_settings, and the Settings lookups (Models, DefaultModel with PHP's position-0 quirk, Vision, Image, File, ByName), every one Trusted - golem:import-settings copies the WinterCMS golem_settings repeater once - AnthropicAdapter; SendStream, SendFile/SendFilePath/SendFileReader, SendWithModel, SendToImageModel, SendToVisionModel, GenerateImage, Ask - PromptFactory; README documents the config keys, the environment variable, the command and the admin screen
This commit is contained in:
36
README.md
36
README.md
@@ -1,6 +1,6 @@
|
||||
# golem15.golem
|
||||
|
||||
The shared AI plugin for SummerCMS applications: provider adapters for the Anthropic Messages API and the OpenAI-compatible chat completions API, prompts and AI responses, and an AI service that sends them through the framework's guarded outbound client.
|
||||
The shared AI plugin for SummerCMS applications: provider adapters for the Anthropic Messages API and the OpenAI-compatible chat completions API, an AI service that sends prompts through the framework's guarded outbound client, the operator's AI models with their admin screen, and an SSRF guard for untrusted AI URLs.
|
||||
|
||||
```go
|
||||
import _ "git.golem15.com/golem15/sm-golem-plugin"
|
||||
@@ -21,7 +21,12 @@ The plugin's config keys, lang namespace and plugin ID are a contract shared by
|
||||
- **Prompts.** `valueobjects.Prompt` is an immutable request: a system prompt, user and assistant messages, messages with base64 images (`AddUserMessageWithImage`, `AddUserMessageWithImages`) or an uploaded file id (`AddUserMessageWithFileID`), a model override and extra options in insertion order. `ToAPIPayload` builds the OpenAI chat completions body as a `valueobjects.Payload`, a JSON object that keeps its key order.
|
||||
- **Responses.** `valueobjects.AIResponse` carries the content, token usage and the provider's decoded answer (`Raw`, which keeps `finish_reason` and `stop_reason`) or the failure message. `Err` holds the Go error behind a failure for logs and tests and is never shown to a user.
|
||||
- **OpenAI-compatible adapter.** `providers.OpenAIAdapter` posts to `<base>/chat/completions` with `Authorization: Bearer`, drops `response_format`, maps `max_tokens` to `max_completion_tokens` and adds `reasoning_effort: low` for o-series and gpt-5 models. It also serves compatible providers through their base URL. `providers.ForName` picks the adapter by name.
|
||||
- **AI service.** `services.AIService.Send` applies the model's system prompt when the prompt has none and posts the prompt to the config's chat endpoint. It returns the WinterCMS plugin's failure messages: no model configured, no API key, no connection, and an answer that is not JSON. `services.ForApp` returns the application's shared service.
|
||||
- **Anthropic adapter.** `providers.AnthropicAdapter` posts to `<base>/messages` with `x-api-key`, `anthropic-version: 2023-06-01` and `anthropic-beta: files-api-2025-04-14`. It sends `max_tokens` (the prompt's `max_tokens`, else `max_completion_tokens`, else 4096), rewrites image blocks to base64 or URL image sources and file blocks to document blocks, and maps Anthropic's usage to `prompt_tokens`, `completion_tokens` and `total_tokens`. `Raw` keeps `stop_reason`.
|
||||
- **AI service.** `services.AIService.Send` applies the model's system prompt when the prompt has none and posts the prompt to the config's chat endpoint; a nil config uses the default model. It returns the WinterCMS plugin's failure messages: no model configured, no API key, no connection, and an answer that is not JSON. The other calls are `services.AIService.SendStream` (server-sent events, one `{"content":…}` chunk per delta), `services.AIService.SendFile`, `services.AIService.SendFilePath` and `services.AIService.SendFileReader` (multipart upload to the files endpoint of a model with `has_files_endpoint`), `services.AIService.SendWithModel`, `services.AIService.SendToImageModel`, `services.AIService.SendToVisionModel`, `services.AIService.GenerateImage` (the images endpoint; check the returned URL with `security.AssertSafeURL` before fetching it) and `services.AIService.Ask`. `services.ForApp` returns the application's shared service.
|
||||
- **AI models.** Operators configure models in the admin under Golem › AI Models (controller `golem15.golem.models`, permission `golem15.golem.access_settings`). Each model has a name, an adapter (`openai` or `anthropic`), an API key, a base URL, a model id, a default system prompt, flags (enabled, default, generates images, accepts images, has a files endpoint), a completion token budget and an order. The key is encrypted at rest and write-only: the form shows it redacted, and an update that sends it back empty or redacted keeps the stored key. `services.Models`, `services.DefaultModel`, `services.VisionModel`, `services.ImageModel`, `services.FileModel` and `services.ModelByName` read them like the WinterCMS Settings model. Their configs are `Trusted`. `services.DefaultModel` keeps the WinterCMS quirk: without a model marked default it returns the first model only when that one is enabled.
|
||||
- **Settings import.** `golem:import-settings` copies the AI models of a WinterCMS installation (the `models` repeater of the `system_settings` row `golem_settings`, or a JSON file) into the table once, encrypting each plaintext key.
|
||||
- **SSRF guard.** `security.AssertSafeURL` ports the WinterCMS SSRFGuard: https only, a host on the allowlist (`security.AllowedHosts`), and a host that resolves to public addresses only (`fetchguard.IsPrivateAddr`). A failure is a `security.UnsafeURLError` with the WinterCMS message. Apply it to every base URL a user or an organisation supplies and to every URL a provider returns; the guarded client checks the address again when it connects.
|
||||
- **Prompt factory.** `factories.PromptFactory` builds the WinterCMS plugin's ready-made prompts: a form field suggestion, values for several fields, categories for an item, a chat and a completion.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -74,10 +79,16 @@ if !res.Success {
|
||||
|
||||
## API reference
|
||||
|
||||
- `golem`: `Plugin`, the `golem15.golem` plugin.
|
||||
- `golem`: `Plugin`, the `golem15.golem` plugin (config, lang, migrations, models, admin controllers, permissions, navigation, commands).
|
||||
- `classes/valueobjects`: `Prompt`, `NewPrompt`, `Message`, `ContentPart`, `ImageURL`, `FileRef`, `Image`, `Payload`, `NewPayload`, `AIResponse`, `Success`, `Failure`.
|
||||
- `classes/providers`: `Adapter`, `OpenAIAdapter`, `ForName`, `OpenAIBaseURL`, `AnthropicBaseURL`.
|
||||
- `classes/services`: `ModelConfig`, `DefaultModelID`, `AIService`, `NewAIService`, `ForApp`, `ConfigTimeoutSeconds`, `DefaultTimeout`.
|
||||
- `classes/providers`: `Adapter`, `OpenAIAdapter`, `AnthropicAdapter`, `ForName`, `OpenAIBaseURL`, `AnthropicBaseURL`, `AnthropicVersion`, `AnthropicBeta`.
|
||||
- `classes/services`: `ModelConfig`, `DefaultModelID`, `AIService`, `NewAIService`, `ForApp`, `ConfigTimeoutSeconds`, `DefaultTimeout`, `Models`, `DefaultModel`, `VisionModel`, `ImageModel`, `FileModel`, `ModelByName`.
|
||||
- `classes/security`: `AssertSafeURL`, `AllowedHosts`, `UnsafeURLError`, `Resolver`, `DefaultResolver`, `DefaultAllowedHosts`, `ConfigAllowedHosts`, `EnvAllowedHosts`.
|
||||
- `classes/factories`: `PromptFactory`, `Pair`, `FormField`, `Category`, `HumanizeFieldName`.
|
||||
- `models`: `AiModel` (table `golem15_golem_models`), `Register`, `All`.
|
||||
- `updates`: the migrations (`202610030101_create_golem_models`), `Register`, `All`.
|
||||
- `controllers`: `ModelsAdminController`, `AdminControllers`, `PermissionAccessSettings`.
|
||||
- `console`: `ImportSettings`, `ImportModels`, `IsNothingToImport`, `ErrModelsNotEmpty`, `SettingsItem`.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -88,13 +99,22 @@ Defaults live in `config/config.yaml` and are merged under `golem15.golem.*`.
|
||||
| `golem15.golem.ssrf.allowed_hosts` | `oaidalleapiprodscus.blob.core.windows.net`, `.openai.com` | Hosts an untrusted base URL or an AI-returned image URL may use. A leading dot matches any subdomain. |
|
||||
| `golem15.golem.ai.timeout_seconds` | `120` | Timeout of every AI provider request. |
|
||||
|
||||
The environment variable `GOLEM15_SSRF_ALLOWED_HOSTS`, a comma-separated host list, replaces `golem15.golem.ssrf.allowed_hosts` when it is set and not empty. With the default list, a user's Anthropic base URL is refused: add `.anthropic.com` (or the provider's host) to accept it.
|
||||
|
||||
## CLI commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `golem:import-settings [--file <json>]` | Copy the AI models of a WinterCMS `golem_settings` row (or of a JSON file holding the settings value or the models list) into `golem15_golem_models`, in order, with each key encrypted. It refuses to run when the table already has rows, and reports a missing `system_settings` table or row without failing. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `compass`, `fetchguard`, `pact`, `party`).
|
||||
- GORM, as pinned in `go.mod`.
|
||||
- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `cabana`, `compass`, `fetchguard`, `lagoon`, `pact`, `party`).
|
||||
- GORM and gormigrate, as pinned in `go.mod`.
|
||||
- testcontainers-go for the Postgres-backed tests.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go test ./... # starts Postgres with testcontainers (needs Docker)
|
||||
```
|
||||
|
||||
57
admin.go
Normal file
57
admin.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package golem
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/sm-golem-plugin/controllers"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
//go:embed controllers/aimodels/config_list.yaml controllers/aimodels/config_form.yaml models/ai_model/fields.yaml models/ai_model/columns.yaml
|
||||
var adminFS embed.FS
|
||||
|
||||
var (
|
||||
_ pact.AdminAssets = (*Plugin)(nil)
|
||||
_ pact.HasAdminControllers = (*Plugin)(nil)
|
||||
_ pact.HasPermissions = (*Plugin)(nil)
|
||||
_ pact.HasNavigation = (*Plugin)(nil)
|
||||
)
|
||||
|
||||
// AdminFS is the admin YAML of the AI models screen.
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
|
||||
// AdminControllers is the AI models list and form.
|
||||
func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
return controllers.AdminControllers()
|
||||
}
|
||||
|
||||
// Permissions is golem15.golem.access_settings (WinterCMS registers it
|
||||
// for the developer and publisher roles).
|
||||
func (*Plugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{{
|
||||
Code: controllers.PermissionAccessSettings,
|
||||
Tab: "golem15.golem::lang.plugin.name",
|
||||
Label: "golem15.golem::lang.permissions.access_settings",
|
||||
Roles: []string{"developer", "publisher"},
|
||||
}}
|
||||
}
|
||||
|
||||
// Navigation is the Golem menu with its AI models side entry.
|
||||
func (*Plugin) Navigation() []pact.NavigationItem {
|
||||
return []pact.NavigationItem{{
|
||||
Code: "golem",
|
||||
Label: "golem15.golem::lang.plugin.name",
|
||||
Icon: "bot",
|
||||
Permissions: []string{"golem15.golem.*"},
|
||||
Order: 500,
|
||||
Controller: "golem15.golem.models",
|
||||
SideMenu: []pact.NavigationItem{{
|
||||
Code: "models",
|
||||
Label: "golem15.golem::lang.settings.models",
|
||||
Icon: "cpu",
|
||||
Permissions: []string{controllers.PermissionAccessSettings},
|
||||
Controller: "golem15.golem.models",
|
||||
}},
|
||||
}}
|
||||
}
|
||||
212
classes/factories/prompt_factory.go
Normal file
212
classes/factories/prompt_factory.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// Package factories ports the WinterCMS plugin's PromptFactory: ready-made
|
||||
// prompts for form field suggestions, categorisation, chats and
|
||||
// completions.
|
||||
package factories
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-golem-plugin/classes/valueobjects"
|
||||
)
|
||||
|
||||
// Pair is one ordered key and value: a context field, a dropdown option.
|
||||
type Pair struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// FormField is one field to suggest a value for.
|
||||
type FormField struct {
|
||||
Name string
|
||||
Type string
|
||||
}
|
||||
|
||||
// Category is one existing category an item may be assigned to.
|
||||
type Category struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
var fieldStrategies = map[string]string{
|
||||
"text": "Generate a concise, descriptive text value",
|
||||
"textarea": "Generate longer form text content",
|
||||
"slug": "Generate a URL-friendly slug (lowercase, hyphens instead of spaces, no special characters)",
|
||||
"richeditor": "Generate formatted HTML content with appropriate tags",
|
||||
"markdown": "Generate Markdown-formatted content",
|
||||
"code": "Generate code in the appropriate language",
|
||||
"taglist": "Generate comma-separated tags/keywords",
|
||||
"dropdown": "Select the most appropriate option from available choices",
|
||||
}
|
||||
|
||||
// PromptFactory builds prompts (PHP PromptFactory's static methods).
|
||||
type PromptFactory struct{}
|
||||
|
||||
// CreateSuggestionForField is the prompt for one form field's value.
|
||||
// currentValue is nil when the field is empty; options are listed only
|
||||
// for dropdown, radio and checkboxlist fields.
|
||||
func (PromptFactory) CreateSuggestionForField(fieldName, fieldType string, currentValue *string, context []Pair, options []Pair) valueobjects.Prompt {
|
||||
strategy, ok := fieldStrategies[fieldType]
|
||||
if !ok {
|
||||
strategy = "Generate an appropriate value"
|
||||
}
|
||||
system := "You are a helpful assistant that generates content for form fields. " +
|
||||
"Your task is to: " + strategy + ". " +
|
||||
"Respond with ONLY the value, no explanations or quotes."
|
||||
return valueobjects.NewPrompt().
|
||||
WithSystem(system).
|
||||
AddUserMessage(buildFieldPrompt(fieldName, fieldType, currentValue, context, options))
|
||||
}
|
||||
|
||||
// SuggestFieldsValues is the prompt for a JSON object of values for
|
||||
// several fields. notes may be nil.
|
||||
func (PromptFactory) SuggestFieldsValues(fields []FormField, context []Pair, notes *string) valueobjects.Prompt {
|
||||
system := "You are an expert content generation assistant. " +
|
||||
"Based on the provided context, suggest appropriate values for the specified fields. " +
|
||||
"Respond in JSON format with field names as keys and generated values as values. " +
|
||||
"Do not include any explanations or additional text, respond in raw json without any markdown. Never enclose result in ``` markdown."
|
||||
var b strings.Builder
|
||||
b.WriteString("Fields to generate values for:\n")
|
||||
for _, f := range fields {
|
||||
name, typ := f.Name, f.Type
|
||||
if name == "" {
|
||||
name = "unnamed"
|
||||
}
|
||||
if typ == "" {
|
||||
typ = "text"
|
||||
}
|
||||
b.WriteString("- " + name + " (type: " + typ + ")\n")
|
||||
}
|
||||
if len(context) > 0 {
|
||||
b.WriteString("\nContext to base the suggestions on:\n")
|
||||
writeContext(&b, context, "- ", "\n")
|
||||
}
|
||||
if notes != nil && *notes != "" {
|
||||
b.WriteString("\nAdditional notes:\n" + *notes + "\n")
|
||||
}
|
||||
return valueobjects.NewPrompt().WithSystem(system).AddUserMessage(b.String())
|
||||
}
|
||||
|
||||
// SuggestCategoriesForItem is the prompt for the ids of the existing
|
||||
// categories that fit an item. notes may be nil.
|
||||
func (PromptFactory) SuggestCategoriesForItem(categories []Category, context []Pair, notes *string) valueobjects.Prompt {
|
||||
system := "You are an expert content categorization assistant. " +
|
||||
"Based on the provided context, suggest relevant categories from the existing list. " +
|
||||
"Respond with a comma-separated list of category ids that best fit the content. " +
|
||||
"Do not suggest categories that are not in the existing list. Respond with ids only, no quotes or explanations."
|
||||
var b strings.Builder
|
||||
b.WriteString("Existing categories:\n")
|
||||
for _, c := range categories {
|
||||
id, name := c.ID, c.Name
|
||||
if id == "" {
|
||||
id = "unknown"
|
||||
}
|
||||
if name == "" {
|
||||
name = "unnamed"
|
||||
}
|
||||
b.WriteString("- " + id + ": " + name + "\n")
|
||||
}
|
||||
if len(context) > 0 {
|
||||
b.WriteString("\nContext to assign categories to:\n")
|
||||
writeContext(&b, context, "- ", "\n")
|
||||
}
|
||||
if notes != nil && *notes != "" {
|
||||
b.WriteString("\nAdditional notes:\n" + *notes + "\n")
|
||||
}
|
||||
return valueobjects.NewPrompt().WithSystem(system).AddUserMessage(b.String())
|
||||
}
|
||||
|
||||
// CreateChat is an empty chat with an optional system prompt.
|
||||
func (PromptFactory) CreateChat(system string) valueobjects.Prompt {
|
||||
p := valueobjects.NewPrompt()
|
||||
if system != "" {
|
||||
p = p.WithSystem(system)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// CreateCompletion is a one-message prompt with an optional system prompt.
|
||||
func (f PromptFactory) CreateCompletion(userMessage, system string) valueobjects.Prompt {
|
||||
return f.CreateChat(system).AddUserMessage(userMessage)
|
||||
}
|
||||
|
||||
func buildFieldPrompt(fieldName, fieldType string, currentValue *string, context, options []Pair) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Generate a value for the '" + HumanizeFieldName(fieldName) + "' field.")
|
||||
if len(context) > 0 {
|
||||
b.WriteString("\n\nContext from related fields:")
|
||||
writeContext(&b, context, "\n- ", "")
|
||||
}
|
||||
if currentValue != nil && *currentValue != "" {
|
||||
b.WriteString("\n\nCurrent value (improve or keep if already good): " + *currentValue)
|
||||
}
|
||||
if len(options) > 0 && (fieldType == "dropdown" || fieldType == "radio" || fieldType == "checkboxlist") {
|
||||
b.WriteString("\n\nAvailable options:")
|
||||
for _, o := range options {
|
||||
b.WriteString("\n- " + o.Key + ": " + o.Value)
|
||||
}
|
||||
b.WriteString("\n\nRespond with ONLY the option key.")
|
||||
}
|
||||
switch fieldType {
|
||||
case "slug":
|
||||
b.WriteString("\n\nFormat: lowercase letters, numbers, and hyphens only. No spaces or special characters.")
|
||||
case "taglist":
|
||||
b.WriteString("\n\nFormat: comma-separated list of relevant keywords/tags.")
|
||||
case "richeditor":
|
||||
b.WriteString("\n\nFormat: valid HTML with appropriate tags (p, h2, h3, ul, li, strong, em).")
|
||||
case "markdown":
|
||||
b.WriteString("\n\nFormat: valid Markdown syntax.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// writeContext writes each non-empty context value (PHP skips empty()
|
||||
// values, "0" included) as prefix + humanized key + ": " + value + suffix.
|
||||
func writeContext(b *strings.Builder, context []Pair, prefix, suffix string) {
|
||||
for _, c := range context {
|
||||
if c.Value == "" || c.Value == "0" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(prefix + HumanizeFieldName(c.Key) + ": " + c.Value + suffix)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
fieldPrefix = regexp.MustCompile(`^(field_|input_|txt_)`)
|
||||
camelBreak = regexp.MustCompile(`([a-z])([A-Z])`)
|
||||
)
|
||||
|
||||
// HumanizeFieldName turns a field name into words: the field_, input_ or
|
||||
// txt_ prefix dropped, camelCase split, underscores and hyphens to
|
||||
// spaces, each word capitalised (PHP ucwords(strtolower(...))).
|
||||
func HumanizeFieldName(name string) string {
|
||||
name = fieldPrefix.ReplaceAllString(name, "")
|
||||
name = camelBreak.ReplaceAllString(name, "$1 $2")
|
||||
name = strings.NewReplacer("_", " ", "-", " ").Replace(name)
|
||||
return ucwords(asciiLower(name))
|
||||
}
|
||||
|
||||
// asciiLower is PHP 8's strtolower: ASCII letters only.
|
||||
func asciiLower(s string) string {
|
||||
b := []byte(s)
|
||||
for i, c := range b {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
b[i] = c + 'a' - 'A'
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ucwords upper-cases the first ASCII letter of each word, words being
|
||||
// separated by PHP's default delimiters.
|
||||
func ucwords(s string) string {
|
||||
b := []byte(s)
|
||||
start := true
|
||||
for i, c := range b {
|
||||
if start && c >= 'a' && c <= 'z' {
|
||||
b[i] = c - 'a' + 'A'
|
||||
}
|
||||
start = c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == '\v'
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
48
classes/factories/prompt_factory_test.go
Normal file
48
classes/factories/prompt_factory_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package factories
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPromptFactory(t *testing.T) {
|
||||
f := PromptFactory{}
|
||||
for in, want := range map[string]string{
|
||||
"field_firstName": "First Name",
|
||||
"input_meta-title": "Meta Title",
|
||||
"txt_SEO_title": "Seo Title",
|
||||
"catalog_number": "Catalog Number",
|
||||
} {
|
||||
if got := HumanizeFieldName(in); got != want {
|
||||
t.Errorf("HumanizeFieldName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
cur := "old-slug"
|
||||
p := f.CreateSuggestionForField("post_slug", "slug", &cur, []Pair{{"title", "Hello World"}, {"empty", ""}, {"zero", "0"}}, nil)
|
||||
if !strings.Contains(p.SystemPrompt(), "Generate a URL-friendly slug") {
|
||||
t.Fatalf("system = %q", p.SystemPrompt())
|
||||
}
|
||||
want := "Generate a value for the 'Post Slug' field.\n\nContext from related fields:\n- Title: Hello World\n\nCurrent value (improve or keep if already good): old-slug\n\nFormat: lowercase letters, numbers, and hyphens only. No spaces or special characters."
|
||||
if got := p.Messages()[0].Text; got != want {
|
||||
t.Fatalf("user message = %q", got)
|
||||
}
|
||||
p = f.CreateSuggestionForField("format", "dropdown", nil, nil, []Pair{{"LP", "Vinyl LP"}, {"CD", "Compact disc"}})
|
||||
if got := p.Messages()[0].Text; !strings.HasSuffix(got, "Available options:\n- LP: Vinyl LP\n- CD: Compact disc\n\nRespond with ONLY the option key.") {
|
||||
t.Fatalf("dropdown = %q", got)
|
||||
}
|
||||
notes := "Keep it short."
|
||||
p = f.SuggestFieldsValues([]FormField{{Name: "title"}, {Name: "body", Type: "markdown"}}, []Pair{{"topic", "Jazz"}}, ¬es)
|
||||
if got := p.Messages()[0].Text; got != "Fields to generate values for:\n- title (type: text)\n- body (type: markdown)\n\nContext to base the suggestions on:\n- Topic: Jazz\n\nAdditional notes:\nKeep it short.\n" {
|
||||
t.Fatalf("fields = %q", got)
|
||||
}
|
||||
p = f.SuggestCategoriesForItem([]Category{{ID: "1", Name: "Jazz"}, {}}, nil, nil)
|
||||
if got := p.Messages()[0].Text; got != "Existing categories:\n- 1: Jazz\n- unknown: unnamed\n" {
|
||||
t.Fatalf("categories = %q", got)
|
||||
}
|
||||
if p := f.CreateChat(""); p.SystemPrompt() != "" || len(p.Messages()) != 0 {
|
||||
t.Fatal("empty chat")
|
||||
}
|
||||
if p := f.CreateCompletion("hi", "sys"); p.SystemPrompt() != "sys" || p.Messages()[0].Text != "hi" {
|
||||
t.Fatal("completion")
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,9 @@ const (
|
||||
// ForName is ProviderFactory::make: the Anthropic adapter for "anthropic",
|
||||
// the OpenAI-compatible adapter for anything else.
|
||||
func ForName(adapter string) Adapter {
|
||||
if adapter == "anthropic" {
|
||||
return AnthropicAdapter{}
|
||||
}
|
||||
return OpenAIAdapter{}
|
||||
}
|
||||
|
||||
|
||||
195
classes/providers/anthropic.go
Normal file
195
classes/providers/anthropic.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-golem-plugin/classes/valueobjects"
|
||||
"git.golem15.com/golem15/summercms/modules/fetchguard"
|
||||
)
|
||||
|
||||
// Anthropic API headers PHP sends on every request.
|
||||
const (
|
||||
AnthropicVersion = "2023-06-01"
|
||||
AnthropicBeta = "files-api-2025-04-14"
|
||||
)
|
||||
|
||||
// AnthropicAdapter is the Anthropic Messages API mapping (PHP
|
||||
// AnthropicAdapter).
|
||||
type AnthropicAdapter struct{}
|
||||
|
||||
var _ Adapter = AnthropicAdapter{}
|
||||
|
||||
var dataURL = regexp.MustCompile(`^data:([^;]+);base64,(.+)$`)
|
||||
|
||||
func anthropicHeaders(apiKey string) http.Header {
|
||||
return http.Header{
|
||||
"X-Api-Key": {apiKey},
|
||||
"Anthropic-Version": {AnthropicVersion},
|
||||
"Anthropic-Beta": {AnthropicBeta},
|
||||
}
|
||||
}
|
||||
|
||||
// Headers are x-api-key, anthropic-version and anthropic-beta.
|
||||
func (AnthropicAdapter) Headers(apiKey string) http.Header { return anthropicHeaders(apiKey) }
|
||||
|
||||
// ChatEndpoint is <base>/messages.
|
||||
func (AnthropicAdapter) ChatEndpoint(baseURL string) string {
|
||||
return endpoint(baseURL, AnthropicBaseURL, "/messages")
|
||||
}
|
||||
|
||||
// FilesEndpoint is <base>/files.
|
||||
func (AnthropicAdapter) FilesEndpoint(baseURL string) string {
|
||||
return endpoint(baseURL, AnthropicBaseURL, "/files")
|
||||
}
|
||||
|
||||
// ImageEndpoint is "": Anthropic generates no images.
|
||||
func (AnthropicAdapter) ImageEndpoint(string) string { return "" }
|
||||
|
||||
// BuildChatPayload is the Messages body: model (the prompt's override or
|
||||
// modelID), the messages with image_url blocks rewritten to image blocks
|
||||
// (base64 for a data: URL, url otherwise) and file blocks to document
|
||||
// blocks, the system prompt when set, max_tokens (the max_tokens option,
|
||||
// else max_completion_tokens, else 4096) and every other option except
|
||||
// response_format and max_completion_tokens.
|
||||
func (AnthropicAdapter) BuildChatPayload(prompt valueobjects.Prompt, modelID string) *valueobjects.Payload {
|
||||
msgs := prompt.Messages()
|
||||
out := make([]map[string]any, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, map[string]any{"role": m.Role, "content": anthropicContent(m)})
|
||||
}
|
||||
payload := valueobjects.NewPayload()
|
||||
model := modelID
|
||||
if p := prompt.Model(); p != nil {
|
||||
model = *p
|
||||
}
|
||||
payload.Set("model", model)
|
||||
payload.Set("messages", out)
|
||||
if s := prompt.SystemPrompt(); s != "" {
|
||||
payload.Set("system", s)
|
||||
}
|
||||
opts := prompt.Options()
|
||||
var maxTokens any = 4096
|
||||
if opts.IsSet("max_tokens") {
|
||||
maxTokens, _ = opts.Get("max_tokens")
|
||||
} else if opts.IsSet("max_completion_tokens") {
|
||||
maxTokens, _ = opts.Get("max_completion_tokens")
|
||||
}
|
||||
payload.Set("max_tokens", maxTokens)
|
||||
for _, k := range opts.Keys() {
|
||||
if k == "response_format" || k == "max_completion_tokens" {
|
||||
continue
|
||||
}
|
||||
v, _ := opts.Get(k)
|
||||
payload.Set(k, v)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func anthropicContent(m valueobjects.Message) any {
|
||||
if m.Parts == nil {
|
||||
return m.Text
|
||||
}
|
||||
blocks := make([]any, 0, len(m.Parts))
|
||||
for _, part := range m.Parts {
|
||||
switch part.Type {
|
||||
case "image_url":
|
||||
u := ""
|
||||
if part.ImageURL != nil {
|
||||
u = part.ImageURL.URL
|
||||
}
|
||||
if strings.HasPrefix(u, "data:") {
|
||||
media, data := "image/jpeg", ""
|
||||
if mm := dataURL.FindStringSubmatch(u); mm != nil {
|
||||
media, data = mm[1], mm[2]
|
||||
}
|
||||
blocks = append(blocks, map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": media, "data": data}})
|
||||
} else {
|
||||
blocks = append(blocks, map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": u}})
|
||||
}
|
||||
case "file":
|
||||
id := ""
|
||||
if part.File != nil {
|
||||
id = part.File.FileID
|
||||
}
|
||||
blocks = append(blocks, map[string]any{"type": "document", "source": map[string]any{"type": "file", "file_id": id}})
|
||||
default:
|
||||
blocks = append(blocks, part)
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// FileUploadFields is empty: the Anthropic files API takes the file only.
|
||||
func (AnthropicAdapter) FileUploadFields(string) []fetchguard.FormField { return nil }
|
||||
|
||||
// FileUploadHeaders are the Messages headers.
|
||||
func (AnthropicAdapter) FileUploadHeaders(apiKey string) http.Header {
|
||||
return anthropicHeaders(apiKey)
|
||||
}
|
||||
|
||||
// ParseChatResponse reads content[0].text; usage gains PHP's
|
||||
// prompt_tokens, completion_tokens and total_tokens next to Anthropic's
|
||||
// input_tokens and output_tokens; Raw keeps the whole answer, stop_reason
|
||||
// included.
|
||||
func (AnthropicAdapter) ParseChatResponse(data map[string]any) valueobjects.AIResponse {
|
||||
if msg, ok := errorMessage(data); ok {
|
||||
return valueobjects.Failure(msg, data)
|
||||
}
|
||||
content := dig(data, "content", 0, "text")
|
||||
if content == nil {
|
||||
return valueobjects.Failure("No content in AI response.", data)
|
||||
}
|
||||
usage := map[string]any{}
|
||||
if u, ok := data["usage"].(map[string]any); ok {
|
||||
for k, v := range u {
|
||||
usage[k] = v
|
||||
}
|
||||
}
|
||||
if in, ok := usage["input_tokens"]; ok && in != nil {
|
||||
prompt := number(in)
|
||||
completion := number(usage["output_tokens"])
|
||||
usage["prompt_tokens"] = in
|
||||
if usage["output_tokens"] == nil {
|
||||
usage["completion_tokens"] = 0
|
||||
} else {
|
||||
usage["completion_tokens"] = usage["output_tokens"]
|
||||
}
|
||||
usage["total_tokens"] = prompt + completion
|
||||
}
|
||||
return valueobjects.Success(phpString(content), usage, data)
|
||||
}
|
||||
|
||||
func number(v any) float64 {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ParseStreamChunk reads Anthropic's event stream: `event: message_stop`
|
||||
// ends it, a `data:` line of type content_block_delta carries the text
|
||||
// delta, anything else is skipped.
|
||||
func (AnthropicAdapter) ParseStreamChunk(line string) (string, bool) {
|
||||
if line == "" {
|
||||
return "", false
|
||||
}
|
||||
if event, ok := strings.CutPrefix(line, "event: "); ok {
|
||||
return "", event == "message_stop"
|
||||
}
|
||||
payload, ok := strings.CutPrefix(line, "data: ")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
var v map[string]any
|
||||
if json.Unmarshal([]byte(payload), &v) != nil || len(v) == 0 {
|
||||
return "", false
|
||||
}
|
||||
if v["type"] != "content_block_delta" {
|
||||
return "", false
|
||||
}
|
||||
text, _ := dig(v, "delta", "text").(string)
|
||||
return text, false
|
||||
}
|
||||
@@ -179,3 +179,135 @@ func TestOpenAIAdapterPayload(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAnthropicAdapterPayload pins AnthropicAdapter.php: headers,
|
||||
// endpoints, the max_tokens fallback chain, the dropped options, image
|
||||
// and file block rewriting, response parsing and the event stream.
|
||||
func TestAnthropicAdapterPayload(t *testing.T) {
|
||||
a := AnthropicAdapter{}
|
||||
if _, ok := ForName("anthropic").(AnthropicAdapter); !ok {
|
||||
t.Fatal("ForName(anthropic) is not the Anthropic adapter")
|
||||
}
|
||||
if _, ok := ForName("").(OpenAIAdapter); !ok {
|
||||
t.Fatal("ForName(\"\") is not the OpenAI adapter")
|
||||
}
|
||||
|
||||
t.Run("headers and endpoints", func(t *testing.T) {
|
||||
h := a.Headers("sk-ant-test")
|
||||
if h.Get("X-Api-Key") != "sk-ant-test" || h.Get("Anthropic-Version") != "2023-06-01" || h.Get("Anthropic-Beta") != "files-api-2025-04-14" || h.Get("Authorization") != "" {
|
||||
t.Fatalf("headers = %v", h)
|
||||
}
|
||||
if a.FileUploadHeaders("k").Get("X-Api-Key") != "k" {
|
||||
t.Fatal("file upload headers")
|
||||
}
|
||||
if got := a.ChatEndpoint(""); got != "https://api.anthropic.com/v1/messages" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := a.ChatEndpoint("https://proxy.example.test/v1///"); got != "https://proxy.example.test/v1/messages" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := a.FilesEndpoint(""); got != "https://api.anthropic.com/v1/files" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if a.ImageEndpoint("") != "" || a.FileUploadFields("assistants") != nil {
|
||||
t.Fatal("anthropic has no image endpoint and no upload fields")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("max_tokens chain and dropped options", func(t *testing.T) {
|
||||
base := valueobjects.NewPrompt().AddUserMessage("hi")
|
||||
if v, _ := a.BuildChatPayload(base, "claude-sonnet-4-6").Get("max_tokens"); v != 4096 {
|
||||
t.Fatalf("default max_tokens = %v", v)
|
||||
}
|
||||
p := a.BuildChatPayload(base.WithOptions(map[string]any{"max_completion_tokens": 8192, "response_format": map[string]any{"type": "json_object"}, "temperature": 0.2}), "claude-sonnet-4-6")
|
||||
if v, _ := p.Get("max_tokens"); v != 8192 {
|
||||
t.Fatalf("max_completion_tokens fallback = %v", v)
|
||||
}
|
||||
if p.Has("max_completion_tokens") || p.Has("response_format") {
|
||||
t.Fatalf("dropped options leaked: %v", p.Keys())
|
||||
}
|
||||
if v, _ := p.Get("temperature"); v != 0.2 {
|
||||
t.Fatal("other options pass through")
|
||||
}
|
||||
p = a.BuildChatPayload(base.WithOptions(map[string]any{"max_tokens": 100, "max_completion_tokens": 200}, "max_tokens", "max_completion_tokens"), "m")
|
||||
if v, _ := p.Get("max_tokens"); v != 100 {
|
||||
t.Fatalf("explicit max_tokens wins: %v", v)
|
||||
}
|
||||
if p.Has("system") {
|
||||
t.Fatal("system without a system prompt")
|
||||
}
|
||||
if got := strings.Join(a.BuildChatPayload(base.WithSystem("sys"), "m").Keys(), ","); got != "model,messages,system,max_tokens" {
|
||||
t.Fatalf("keys = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("image and file blocks", func(t *testing.T) {
|
||||
prompt := valueobjects.NewPrompt().
|
||||
WithSystem("You catalog physical items.").
|
||||
AddUserMessageWithImage("What is this?", "QUJD", "image/png").
|
||||
AddUserMessageWithFileID("Read this.", "file_123").
|
||||
WithModel("claude-opus")
|
||||
m := payloadMap(t, a.BuildChatPayload(prompt, "claude-sonnet-4-6"))
|
||||
if m["model"] != "claude-opus" || m["system"] != "You catalog physical items." {
|
||||
t.Fatalf("payload = %v", m)
|
||||
}
|
||||
msgs := m["messages"].([]any)
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("messages = %v (the system prompt is not a message)", msgs)
|
||||
}
|
||||
img := msgs[0].(map[string]any)["content"].([]any)[1].(map[string]any)
|
||||
src := img["source"].(map[string]any)
|
||||
if img["type"] != "image" || src["type"] != "base64" || src["media_type"] != "image/png" || src["data"] != "QUJD" {
|
||||
t.Fatalf("image block = %v", img)
|
||||
}
|
||||
doc := msgs[1].(map[string]any)["content"].([]any)[1].(map[string]any)
|
||||
if doc["type"] != "document" || doc["source"].(map[string]any)["file_id"] != "file_123" {
|
||||
t.Fatalf("document block = %v", doc)
|
||||
}
|
||||
remote := valueobjects.Message{Role: "user", Parts: []valueobjects.ContentPart{{Type: "image_url", ImageURL: &valueobjects.ImageURL{URL: "https://img.example.test/a.jpg"}}}}
|
||||
blk := anthropicContent(remote).([]any)[0].(map[string]any)["source"].(map[string]any)
|
||||
if blk["type"] != "url" || blk["url"] != "https://img.example.test/a.jpg" {
|
||||
t.Fatalf("remote image = %v", blk)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parse", func(t *testing.T) {
|
||||
var data map[string]any
|
||||
_ = json.Unmarshal([]byte(`{"content":[{"type":"text","text":"[]"}],"stop_reason":"max_tokens","usage":{"input_tokens":10,"output_tokens":5}}`), &data)
|
||||
r := a.ParseChatResponse(data)
|
||||
if !r.Success || r.Content != "[]" || r.Raw["stop_reason"] != "max_tokens" {
|
||||
t.Fatalf("response = %+v", r)
|
||||
}
|
||||
if r.PromptTokens() != 10 || r.CompletionTokens() != 5 || r.TotalTokens() != 15 || r.Usage["input_tokens"] != float64(10) {
|
||||
t.Fatalf("usage = %v", r.Usage)
|
||||
}
|
||||
r = a.ParseChatResponse(map[string]any{"type": "error", "error": map[string]any{"type": "authentication_error", "message": "invalid x-api-key"}})
|
||||
if r.Success || r.Error != "invalid x-api-key" {
|
||||
t.Fatalf("error = %+v", r)
|
||||
}
|
||||
r = a.ParseChatResponse(map[string]any{"content": []any{}})
|
||||
if r.Error != "No content in AI response." {
|
||||
t.Fatalf("empty = %+v", r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stream", func(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
line string
|
||||
delta string
|
||||
done bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"event: content_block_delta", "", false},
|
||||
{`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}`, "Hi", false},
|
||||
{`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}`, "", false},
|
||||
{"data: {}", "", false},
|
||||
{"event: message_stop", "", true},
|
||||
} {
|
||||
d, done := a.ParseStreamChunk(c.line)
|
||||
if d != c.delta || done != c.done {
|
||||
t.Fatalf("%q: (%q,%v)", c.line, d, done)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -161,7 +169,7 @@ func (s *AIService) Send(ctx context.Context, prompt valueobjects.Prompt, cfg *M
|
||||
|
||||
// DefaultModel is the admin's default model, nil when none is configured.
|
||||
func (s *AIService) DefaultModel(ctx context.Context) (*ModelConfig, error) {
|
||||
return nil, nil
|
||||
return DefaultModel(ctx, s.db())
|
||||
}
|
||||
|
||||
// applyModelSystemPrompt sets the model's system prompt on a prompt that
|
||||
@@ -196,3 +204,326 @@ func failure(msg string, raw map[string]any, err error) valueobjects.AIResponse
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
|
||||
// More of PHP's failure messages.
|
||||
const (
|
||||
msgInternal = "Internal server error"
|
||||
msgNoFileModel = "No file model configured."
|
||||
msgFilesConnect = "Failed to connect to files API at: "
|
||||
msgFilesInvalid = "Invalid response from files API: "
|
||||
msgNoFileID = "No file ID in response."
|
||||
msgNoImageModel = "No image generation model configured."
|
||||
msgNoImageModelLong = `No image generation model configured. Enable a model with "generates_images" in Settings > AI.`
|
||||
msgNoImageEndpoint = "The configured adapter does not support image generation."
|
||||
msgNoImageKey = "API key is not configured for the image model."
|
||||
msgImageConnect = "Failed to connect to image generation service."
|
||||
msgImageInvalid = "Invalid response: "
|
||||
msgNoImageURL = "No image URL in response."
|
||||
msgNoVisionModel = "No vision model configured."
|
||||
msgUnknownAPIError = "Unknown API error"
|
||||
defaultImageModelID = "dall-e-3"
|
||||
defaultImageSize = "1792x1024"
|
||||
defaultImageQuality = "standard"
|
||||
defaultUploadPurpose = "assistants"
|
||||
)
|
||||
|
||||
// safeMessage is SafeExceptionResponse::safeExceptionMessage: the error's
|
||||
// own text with app.debug on, PHP's generic text otherwise.
|
||||
func (s *AIService) safeMessage(err error) string {
|
||||
if s.cfg != nil && s.cfg.Bool("app.debug") {
|
||||
return err.Error()
|
||||
}
|
||||
return msgInternal
|
||||
}
|
||||
|
||||
// SendStream is AIService::sendStream: the prompt is posted with stream
|
||||
// true and Accept: text/event-stream, and onChunk receives, in order, one
|
||||
// {"content":"<delta>"} JSON string per content delta the adapter reads
|
||||
// from the server-sent events, until the provider ends the stream. A nil
|
||||
// config is the default model. It fails with PHP's message when no model
|
||||
// or no key is configured, with the transport error when the connection
|
||||
// fails, and with "HTTP <status> from AI service" on a status of 400 or
|
||||
// more.
|
||||
func (s *AIService) SendStream(ctx context.Context, prompt valueobjects.Prompt, onChunk func(chunk string), cfg *ModelConfig) error {
|
||||
if cfg == nil {
|
||||
def, err := s.DefaultModel(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg = def
|
||||
}
|
||||
if cfg == nil {
|
||||
return errors.New(msgNoModel)
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return errors.New(msgNoAPIKey)
|
||||
}
|
||||
prompt = applyModelSystemPrompt(prompt, cfg)
|
||||
adapter := providers.ForName(cfg.Adapter)
|
||||
payload := adapter.BuildChatPayload(prompt, cfg.modelID(DefaultModelID))
|
||||
payload.Set("stream", true)
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.client(cfg.Trusted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, adapter.ChatEndpoint(cfg.BaseURL), bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header = requestHeaders("application/json", adapter.Headers(cfg.APIKey))
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
r := bufio.NewReader(resp.Body)
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
// A last line without a newline is never processed, as in PHP.
|
||||
if !errors.Is(err, io.EOF) {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
delta, done := adapter.ParseStreamChunk(strings.TrimRight(strings.TrimSuffix(line, "\n"), "\r"))
|
||||
if done {
|
||||
break
|
||||
}
|
||||
if delta != "" && onChunk != nil {
|
||||
chunk, _ := json.Marshal(map[string]string{"content": delta})
|
||||
onChunk(string(chunk))
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("HTTP %d from AI service", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendFile is AIService::sendFile: body uploaded as fileName with
|
||||
// contentType for the assistants purpose (see SendFileReader).
|
||||
func (s *AIService) SendFile(ctx context.Context, body io.Reader, fileName, contentType, modelName string) valueobjects.AIResponse {
|
||||
return s.SendFileReader(ctx, body, defaultUploadPurpose, modelName, contentType, fileName)
|
||||
}
|
||||
|
||||
// SendFilePath is AIService::sendFilePath for a file on disk: mimeType and
|
||||
// fileName default to the sniffed type and the base name. A missing file
|
||||
// is PHP's caught exception: "Internal server error" outside app.debug.
|
||||
func (s *AIService) SendFilePath(ctx context.Context, path, purpose, modelName, mimeType, fileName string) valueobjects.AIResponse {
|
||||
cfg, fail := s.fileModel(ctx, modelName)
|
||||
if fail != nil {
|
||||
return *fail
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return failure(s.safeMessage(fmt.Errorf("File not found: %s", path)), nil, err)
|
||||
}
|
||||
if mimeType == "" {
|
||||
mimeType = http.DetectContentType(data)
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(path)
|
||||
}
|
||||
return s.upload(ctx, cfg, bytes.NewReader(data), purpose, mimeType, fileName)
|
||||
}
|
||||
|
||||
// SendFileReader uploads body to the files endpoint of the model named
|
||||
// modelName (which must have a files endpoint), or of the first model
|
||||
// with one when modelName is empty, and answers the uploaded file's id as
|
||||
// Content. The upload is multipart: the adapter's fields (OpenAI's
|
||||
// purpose), then the file part "file".
|
||||
func (s *AIService) SendFileReader(ctx context.Context, body io.Reader, purpose, modelName, mimeType, fileName string) valueobjects.AIResponse {
|
||||
cfg, fail := s.fileModel(ctx, modelName)
|
||||
if fail != nil {
|
||||
return *fail
|
||||
}
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
return s.upload(ctx, cfg, body, purpose, mimeType, fileName)
|
||||
}
|
||||
|
||||
func (s *AIService) fileModel(ctx context.Context, modelName string) (*ModelConfig, *valueobjects.AIResponse) {
|
||||
var cfg *ModelConfig
|
||||
var err error
|
||||
if modelName != "" {
|
||||
cfg, err = ModelByName(ctx, s.db(), modelName)
|
||||
if err == nil && (cfg == nil || !cfg.HasFilesEndpoint) {
|
||||
r := valueobjects.Failure(fmt.Sprintf("Model '%s' does not support file uploads.", modelName), nil)
|
||||
return nil, &r
|
||||
}
|
||||
} else {
|
||||
cfg, err = FileModel(ctx, s.db())
|
||||
}
|
||||
if err != nil {
|
||||
r := failure(s.safeMessage(err), nil, err)
|
||||
return nil, &r
|
||||
}
|
||||
if cfg == nil {
|
||||
r := valueobjects.Failure(msgNoFileModel, nil)
|
||||
return nil, &r
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *AIService) upload(ctx context.Context, cfg *ModelConfig, body io.Reader, purpose, mimeType, fileName string) valueobjects.AIResponse {
|
||||
adapter := providers.ForName(cfg.Adapter)
|
||||
endpoint := adapter.FilesEndpoint(cfg.BaseURL)
|
||||
c, err := s.client(cfg.Trusted)
|
||||
if err != nil {
|
||||
return failure(msgFilesConnect+endpoint, nil, err)
|
||||
}
|
||||
res, err := c.PostMultipart(ctx, endpoint, requestHeaders("", adapter.FileUploadHeaders(cfg.APIKey)),
|
||||
adapter.FileUploadFields(purpose),
|
||||
[]fetchguard.FormFile{{Field: "file", Filename: fileName, ContentType: mimeType, Body: body}})
|
||||
if err != nil {
|
||||
return failure(msgFilesConnect+endpoint, nil, err)
|
||||
}
|
||||
data, msg := decodeObject(res.Body)
|
||||
if msg != "" {
|
||||
return valueobjects.Failure(msgFilesInvalid+msg, map[string]any{"raw": string(res.Body)})
|
||||
}
|
||||
if e, ok := data["error"]; ok && e != nil {
|
||||
text := msgUnknownAPIError
|
||||
if m, ok := e.(map[string]any); ok {
|
||||
if v, ok := m["message"].(string); ok {
|
||||
text = v
|
||||
}
|
||||
}
|
||||
return valueobjects.Failure(text, data)
|
||||
}
|
||||
id, _ := data["id"].(string)
|
||||
if id == "" {
|
||||
return valueobjects.Failure(msgNoFileID, data)
|
||||
}
|
||||
return valueobjects.Success(id, nil, data)
|
||||
}
|
||||
|
||||
// SendWithModel is AIService::sendWithModel: Send with the enabled model
|
||||
// named name, or PHP's not-found failure.
|
||||
func (s *AIService) SendWithModel(ctx context.Context, prompt valueobjects.Prompt, name string) valueobjects.AIResponse {
|
||||
cfg, err := ModelByName(ctx, s.db(), name)
|
||||
if err != nil {
|
||||
return failure(msgConnectFailed, nil, err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return valueobjects.Failure(fmt.Sprintf("Model '%s' not found in settings.", name), nil)
|
||||
}
|
||||
return s.Send(ctx, prompt, cfg)
|
||||
}
|
||||
|
||||
// SendToImageModel is AIService::sendToImageModel: Send with the first
|
||||
// model that generates images.
|
||||
func (s *AIService) SendToImageModel(ctx context.Context, prompt valueobjects.Prompt) valueobjects.AIResponse {
|
||||
cfg, err := ImageModel(ctx, s.db())
|
||||
if err != nil {
|
||||
return failure(msgConnectFailed, nil, err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return valueobjects.Failure(msgNoImageModel, nil)
|
||||
}
|
||||
return s.Send(ctx, prompt, cfg)
|
||||
}
|
||||
|
||||
// SendToVisionModel is AIService::sendToVisionModel: Send with the first
|
||||
// model that accepts images.
|
||||
func (s *AIService) SendToVisionModel(ctx context.Context, prompt valueobjects.Prompt) valueobjects.AIResponse {
|
||||
cfg, err := s.VisionModel(ctx)
|
||||
if err != nil {
|
||||
return failure(msgConnectFailed, nil, err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return valueobjects.Failure(msgNoVisionModel, nil)
|
||||
}
|
||||
return s.Send(ctx, prompt, cfg)
|
||||
}
|
||||
|
||||
// VisionModel is the admin's vision model, nil when none is configured.
|
||||
func (s *AIService) VisionModel(ctx context.Context) (*ModelConfig, error) {
|
||||
return VisionModel(ctx, s.db())
|
||||
}
|
||||
|
||||
// GenerateImage is AIService::generateImage: the image model's images
|
||||
// endpoint is asked for one image (size defaults to 1792x1024, quality to
|
||||
// standard, the model id to dall-e-3) and Content is the returned URL,
|
||||
// with Raw {"url", "revised_prompt"}. The URL is the provider's: run it
|
||||
// through security.AssertSafeURL before fetching it, as the WinterCMS
|
||||
// plugin does.
|
||||
func (s *AIService) GenerateImage(ctx context.Context, prompt, size, quality string) valueobjects.AIResponse {
|
||||
cfg, err := ImageModel(ctx, s.db())
|
||||
if err != nil {
|
||||
return failure(msgImageConnect, nil, err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return valueobjects.Failure(msgNoImageModelLong, nil)
|
||||
}
|
||||
adapter := providers.ForName(cfg.Adapter)
|
||||
endpoint := adapter.ImageEndpoint(cfg.BaseURL)
|
||||
if endpoint == "" {
|
||||
return valueobjects.Failure(msgNoImageEndpoint, nil)
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return valueobjects.Failure(msgNoImageKey, nil)
|
||||
}
|
||||
if size == "" {
|
||||
size = defaultImageSize
|
||||
}
|
||||
if quality == "" {
|
||||
quality = defaultImageQuality
|
||||
}
|
||||
payload := valueobjects.NewPayload()
|
||||
payload.Set("model", cfg.modelID(defaultImageModelID))
|
||||
payload.Set("prompt", prompt)
|
||||
payload.Set("n", 1)
|
||||
payload.Set("size", size)
|
||||
payload.Set("quality", quality)
|
||||
c, err := s.client(cfg.Trusted)
|
||||
if err != nil {
|
||||
return failure(msgImageConnect, nil, err)
|
||||
}
|
||||
res, err := c.PostJSON(ctx, endpoint, requestHeaders("", adapter.Headers(cfg.APIKey)), payload)
|
||||
if err != nil {
|
||||
return failure(msgImageConnect, nil, err)
|
||||
}
|
||||
data, msg := decodeObject(res.Body)
|
||||
if msg != "" {
|
||||
return valueobjects.Failure(msgImageInvalid+msg, nil)
|
||||
}
|
||||
if e, ok := data["error"]; ok && e != nil {
|
||||
text := msgUnknownAPIError
|
||||
if m, ok := e.(map[string]any); ok {
|
||||
if v, ok := m["message"].(string); ok {
|
||||
text = v
|
||||
}
|
||||
}
|
||||
return valueobjects.Failure(text, nil)
|
||||
}
|
||||
var first map[string]any
|
||||
if list, ok := data["data"].([]any); ok && len(list) > 0 {
|
||||
first, _ = list[0].(map[string]any)
|
||||
}
|
||||
url, _ := first["url"].(string)
|
||||
if url == "" {
|
||||
return valueobjects.Failure(msgNoImageURL, nil)
|
||||
}
|
||||
return valueobjects.Success(url, nil, map[string]any{"url": url, "revised_prompt": first["revised_prompt"]})
|
||||
}
|
||||
|
||||
// Ask is AIService::ask: one user message, with an optional system
|
||||
// prompt, sent with the default model.
|
||||
func (s *AIService) Ask(ctx context.Context, message, system string) valueobjects.AIResponse {
|
||||
p := valueobjects.NewPrompt()
|
||||
if system != "" {
|
||||
p = p.WithSystem(system)
|
||||
}
|
||||
return s.Send(ctx, p.AddUserMessage(message), nil)
|
||||
}
|
||||
|
||||
469
classes/services/ai_service_test.go
Normal file
469
classes/services/ai_service_test.go
Normal file
@@ -0,0 +1,469 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-golem-plugin/classes/security"
|
||||
"git.golem15.com/golem15/sm-golem-plugin/classes/valueobjects"
|
||||
"git.golem15.com/golem15/sm-golem-plugin/internal/pgtest"
|
||||
"git.golem15.com/golem15/sm-golem-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/fetchguard"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// rt is a scripted provider transport.
|
||||
type rt func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f rt) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func answer(status int, body string) *http.Response {
|
||||
return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}
|
||||
}
|
||||
|
||||
func with(t *testing.T, f func(r *http.Request, body []byte) *http.Response) context.Context {
|
||||
t.Helper()
|
||||
return fetchguard.WithTransport(context.Background(), rt(func(r *http.Request) (*http.Response, error) {
|
||||
var body []byte
|
||||
if r.Body != nil {
|
||||
body, _ = io.ReadAll(r.Body)
|
||||
}
|
||||
resp := f(r, body)
|
||||
if resp == nil {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
resp.Request = r
|
||||
return resp, nil
|
||||
}))
|
||||
}
|
||||
|
||||
// freshModels empties golem15_golem_models and inserts rows in order
|
||||
// (sort_order is the slice index).
|
||||
func freshModels(t *testing.T, rows ...models.AiModel) *gorm.DB {
|
||||
t.Helper()
|
||||
db := pgtest.DB(t)
|
||||
if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].SortOrder = i
|
||||
if rows[i].Adapter == "" {
|
||||
rows[i].Adapter = "openai"
|
||||
}
|
||||
if err := db.Create(&rows[i]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func svcFor(db *gorm.DB) *AIService {
|
||||
return NewAIService(func() *gorm.DB { return db }, nil)
|
||||
}
|
||||
|
||||
const pong = `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"total_tokens":3}}`
|
||||
|
||||
// TestAIServiceFailures pins AIService's PHP failure messages and the
|
||||
// request each call sends.
|
||||
func TestAIServiceFailures(t *testing.T) {
|
||||
user := &ModelConfig{Adapter: "openai", APIKey: "sk-user-test", Model: "gpt-4o-mini"}
|
||||
prompt := valueobjects.NewPrompt().AddUserMessage("ping")
|
||||
|
||||
t.Run("no model, no key", func(t *testing.T) {
|
||||
s := NewAIService(nil, nil)
|
||||
if r := s.Send(context.Background(), prompt, nil); r.Success || r.Error != "No AI model configured. Please add a model in Settings > AI." {
|
||||
t.Fatalf("no model = %+v", r)
|
||||
}
|
||||
if r := s.Send(context.Background(), prompt, &ModelConfig{Adapter: "openai"}); r.Error != "API key is not configured for the selected model." {
|
||||
t.Fatalf("no key = %+v", r)
|
||||
}
|
||||
if r := s.Ask(context.Background(), "hi", ""); r.Error != "No AI model configured. Please add a model in Settings > AI." {
|
||||
t.Fatalf("ask = %+v", r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("connection and answers", func(t *testing.T) {
|
||||
s := NewAIService(nil, nil)
|
||||
r := s.Send(with(t, func(*http.Request, []byte) *http.Response { return nil }), prompt, user)
|
||||
if r.Error != "Failed to connect to AI service." || r.Err == nil {
|
||||
t.Fatalf("refused = %+v", r)
|
||||
}
|
||||
r = s.Send(with(t, func(*http.Request, []byte) *http.Response { return answer(502, "<html>Bad gateway</html>") }), prompt, user)
|
||||
if r.Error != "Invalid response from AI service: Syntax error" || r.Raw["raw"] != "<html>Bad gateway</html>" {
|
||||
t.Fatalf("not json = %+v", r)
|
||||
}
|
||||
r = s.Send(with(t, func(*http.Request, []byte) *http.Response { return answer(200, "{\"x\":\"\xff\"}") }), prompt, user)
|
||||
if r.Error != "Invalid response from AI service: Malformed UTF-8 characters, possibly incorrectly encoded" {
|
||||
t.Fatalf("bad utf-8 = %+v", r)
|
||||
}
|
||||
r = s.Send(with(t, func(*http.Request, []byte) *http.Response { return answer(200, "") }), prompt, user)
|
||||
if r.Error != "Invalid response from AI service: Syntax error" {
|
||||
t.Fatalf("empty = %+v", r)
|
||||
}
|
||||
// The status is not judged: a 401 body is parsed.
|
||||
r = s.Send(with(t, func(*http.Request, []byte) *http.Response {
|
||||
return answer(401, `{"error":{"message":"Incorrect API key provided"}}`)
|
||||
}), prompt, user)
|
||||
if r.Error != "Incorrect API key provided" {
|
||||
t.Fatalf("401 = %+v", r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("model system prompt", func(t *testing.T) {
|
||||
s := NewAIService(nil, nil)
|
||||
cfg := *user
|
||||
cfg.SystemPrompt = "Model default."
|
||||
var got []string
|
||||
ctx := with(t, func(r *http.Request, body []byte) *http.Response {
|
||||
var m struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &m)
|
||||
got = append(got, m.Messages[0].Role+":"+m.Messages[0].Content)
|
||||
return answer(200, pong)
|
||||
})
|
||||
s.Send(ctx, prompt, &cfg)
|
||||
s.Send(ctx, prompt.WithSystem("Caller."), &cfg)
|
||||
if strings.Join(got, "|") != "system:Model default.|system:Caller." {
|
||||
t.Fatalf("system prompts = %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings lookups", func(t *testing.T) {
|
||||
db := freshModels(t,
|
||||
models.AiModel{Name: "chat", Model: "gpt-4o", APIKey: lagoon.NewEncrypted("sk-admin-chat"), IsEnabled: true, IsDefault: true},
|
||||
models.AiModel{Name: "claude", Adapter: "anthropic", Model: "claude-sonnet-4-6", APIKey: lagoon.NewEncrypted("sk-admin-claude"), IsEnabled: true},
|
||||
)
|
||||
s := svcFor(db)
|
||||
var url string
|
||||
ctx := with(t, func(r *http.Request, _ []byte) *http.Response {
|
||||
url = r.URL.String()
|
||||
if strings.Contains(url, "anthropic") {
|
||||
return answer(200, `{"content":[{"type":"text","text":"hi"}]}`)
|
||||
}
|
||||
return answer(200, pong)
|
||||
})
|
||||
if r := s.Ask(ctx, "hi", "be brief"); !r.Success || url != "https://api.openai.com/v1/chat/completions" {
|
||||
t.Fatalf("ask with the default model = %+v %s", r, url)
|
||||
}
|
||||
if r := s.SendWithModel(ctx, prompt, "claude"); !r.Success || url != "https://api.anthropic.com/v1/messages" {
|
||||
t.Fatalf("by name = %+v %s", r, url)
|
||||
}
|
||||
if r := s.SendWithModel(ctx, prompt, "nope"); r.Error != "Model 'nope' not found in settings." {
|
||||
t.Fatalf("unknown name = %+v", r)
|
||||
}
|
||||
if r := s.SendToImageModel(ctx, prompt); r.Error != "No image generation model configured." {
|
||||
t.Fatalf("no image model = %+v", r)
|
||||
}
|
||||
if r := s.SendToVisionModel(ctx, prompt); r.Error != "No vision model configured." {
|
||||
t.Fatalf("no vision model = %+v", r)
|
||||
}
|
||||
if r := s.GenerateImage(ctx, "a cat", "", ""); r.Error != `No image generation model configured. Enable a model with "generates_images" in Settings > AI.` {
|
||||
t.Fatalf("generate without model = %+v", r)
|
||||
}
|
||||
if r := s.SendFilePath(ctx, "/nonexistent", "assistants", "", "", ""); r.Error != "No file model configured." {
|
||||
t.Fatalf("no file model = %+v", r)
|
||||
}
|
||||
// A named model without a files endpoint refuses the upload before
|
||||
// anything is read or sent.
|
||||
if r := s.SendFilePath(ctx, "/nonexistent", "assistants", "chat", "", ""); r.Error != "Model 'chat' does not support file uploads." {
|
||||
t.Fatalf("file upload refused = %+v", r)
|
||||
}
|
||||
if r := s.SendFile(ctx, strings.NewReader("x"), "a.txt", "text/plain", "missing"); r.Error != "Model 'missing' does not support file uploads." {
|
||||
t.Fatalf("missing model upload = %+v", r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file upload", func(t *testing.T) {
|
||||
db := freshModels(t,
|
||||
models.AiModel{Name: "files", Model: "gpt-4o", APIKey: lagoon.NewEncrypted("sk-admin-files"), IsEnabled: true, HasFilesEndpoint: true, BaseURL: ptr("https://llm.example.test/v1")},
|
||||
)
|
||||
s := svcFor(db)
|
||||
path := filepath.Join(t.TempDir(), "invoice.pdf")
|
||||
if err := os.WriteFile(path, []byte("%PDF-1.4 parity"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := with(t, func(r *http.Request, body []byte) *http.Response {
|
||||
if r.URL.String() != "https://llm.example.test/v1/files" || r.Header.Get("Authorization") != "Bearer sk-admin-files" {
|
||||
t.Errorf("upload request %s %v", r.URL, r.Header)
|
||||
}
|
||||
mt, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || mt != "multipart/form-data" {
|
||||
t.Errorf("content type %q", r.Header.Get("Content-Type"))
|
||||
return answer(400, "{}")
|
||||
}
|
||||
mr := multipart.NewReader(strings.NewReader(string(body)), params["boundary"])
|
||||
var parts []string
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
data, _ := io.ReadAll(p)
|
||||
parts = append(parts, p.FormName()+"|"+p.FileName()+"|"+p.Header.Get("Content-Type")+"|"+string(data))
|
||||
}
|
||||
if strings.Join(parts, ";") != "purpose|||assistants;file|invoice.pdf|application/pdf|%PDF-1.4 parity" {
|
||||
t.Errorf("parts = %v", parts)
|
||||
}
|
||||
return answer(200, `{"id":"file-abc123","object":"file"}`)
|
||||
})
|
||||
if r := s.SendFilePath(ctx, path, "assistants", "files", "application/pdf", ""); !r.Success || r.Content != "file-abc123" {
|
||||
t.Fatalf("upload = %+v", r)
|
||||
}
|
||||
if r := s.SendFilePath(ctx, filepath.Join(t.TempDir(), "gone.pdf"), "assistants", "", "", ""); r.Error != "Internal server error" {
|
||||
t.Fatalf("missing file = %+v", r)
|
||||
}
|
||||
noID := with(t, func(*http.Request, []byte) *http.Response { return answer(200, `{"object":"file"}`) })
|
||||
if r := s.SendFile(noID, strings.NewReader("x"), "a.txt", "text/plain", ""); r.Error != "No file ID in response." {
|
||||
t.Fatalf("no id = %+v", r)
|
||||
}
|
||||
apiErr := with(t, func(*http.Request, []byte) *http.Response {
|
||||
return answer(400, `{"error":{"message":"purpose is invalid"}}`)
|
||||
})
|
||||
if r := s.SendFile(apiErr, strings.NewReader("x"), "a.txt", "text/plain", ""); r.Error != "purpose is invalid" {
|
||||
t.Fatalf("api error = %+v", r)
|
||||
}
|
||||
down := with(t, func(*http.Request, []byte) *http.Response { return nil })
|
||||
if r := s.SendFile(down, strings.NewReader("x"), "a.txt", "text/plain", ""); r.Error != "Failed to connect to files API at: https://llm.example.test/v1/files" {
|
||||
t.Fatalf("down = %+v", r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generate image", func(t *testing.T) {
|
||||
db := freshModels(t,
|
||||
models.AiModel{Name: "dalle", Model: "dall-e-3", APIKey: lagoon.NewEncrypted("sk-admin-dalle"), IsEnabled: true, GeneratesImages: true},
|
||||
)
|
||||
s := svcFor(db)
|
||||
returned := "https://oaidalleapiprodscus.blob.core.windows.net/private/img-1.png"
|
||||
ctx := with(t, func(r *http.Request, body []byte) *http.Response {
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
if r.URL.String() != "https://api.openai.com/v1/images/generations" || m["model"] != "dall-e-3" || m["prompt"] != "a cat" || m["n"] != float64(1) || m["size"] != "1792x1024" || m["quality"] != "standard" {
|
||||
t.Errorf("image request %s %v", r.URL, m)
|
||||
}
|
||||
return answer(200, `{"data":[{"url":"`+returned+`","revised_prompt":"a fluffy cat"}]}`)
|
||||
})
|
||||
r := s.GenerateImage(ctx, "a cat", "", "")
|
||||
if !r.Success || r.Content != returned || r.Raw["revised_prompt"] != "a fluffy cat" {
|
||||
t.Fatalf("generate = %+v", r)
|
||||
}
|
||||
// The returned URL is checked before anyone fetches it.
|
||||
prev := security.DefaultResolver
|
||||
security.DefaultResolver = staticResolver{"oaidalleapiprodscus.blob.core.windows.net": "20.60.40.1", "evil.example.com": "203.0.114.9"}
|
||||
t.Cleanup(func() { security.DefaultResolver = prev })
|
||||
if err := security.AssertSafeURL(context.Background(), r.Content, security.DefaultAllowedHosts); err != nil {
|
||||
t.Fatalf("returned URL refused: %v", err)
|
||||
}
|
||||
evil := with(t, func(*http.Request, []byte) *http.Response {
|
||||
return answer(200, `{"data":[{"url":"https://evil.example.com/x.png"}]}`)
|
||||
})
|
||||
r = s.GenerateImage(evil, "a cat", "1024x1024", "hd")
|
||||
if err := security.AssertSafeURL(context.Background(), r.Content, security.DefaultAllowedHosts); err == nil || err.Error() != "Host not in allowlist: evil.example.com" {
|
||||
t.Fatalf("a foreign returned URL must fail the guard: %v", err)
|
||||
}
|
||||
if r := s.GenerateImage(with(t, func(*http.Request, []byte) *http.Response { return answer(200, `{"data":[]}`) }), "x", "", ""); r.Error != "No image URL in response." {
|
||||
t.Fatalf("no url = %+v", r)
|
||||
}
|
||||
if r := s.GenerateImage(with(t, func(*http.Request, []byte) *http.Response { return answer(200, `nope`) }), "x", "", ""); r.Error != "Invalid response: Syntax error" {
|
||||
t.Fatalf("bad json = %+v", r)
|
||||
}
|
||||
if r := s.GenerateImage(with(t, func(*http.Request, []byte) *http.Response {
|
||||
return answer(400, `{"error":{"message":"content policy"}}`)
|
||||
}), "x", "", ""); r.Error != "content policy" {
|
||||
t.Fatalf("api error = %+v", r)
|
||||
}
|
||||
if r := s.GenerateImage(with(t, func(*http.Request, []byte) *http.Response { return nil }), "x", "", ""); r.Error != "Failed to connect to image generation service." {
|
||||
t.Fatalf("down = %+v", r)
|
||||
}
|
||||
freshModels(t, models.AiModel{Name: "claude", Adapter: "anthropic", Model: "claude", APIKey: lagoon.NewEncrypted("k"), IsEnabled: true, GeneratesImages: true})
|
||||
if r := s.GenerateImage(ctx, "x", "", ""); r.Error != "The configured adapter does not support image generation." {
|
||||
t.Fatalf("anthropic image = %+v", r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type staticResolver map[string]string
|
||||
|
||||
func (s staticResolver) LookupIPAddr(_ context.Context, host string) ([]net.IPAddr, error) {
|
||||
ip, ok := s[host]
|
||||
if !ok {
|
||||
return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
|
||||
}
|
||||
return []net.IPAddr{{IP: net.ParseIP(ip)}}, nil
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
|
||||
// TestAIServiceStream delivers the content deltas of a server-sent event
|
||||
// stream in order, as {"content":…} chunks, and stops at the end event.
|
||||
func TestAIServiceStream(t *testing.T) {
|
||||
s := NewAIService(nil, nil)
|
||||
stream := func(status int, body string, check func(r *http.Request, payload map[string]any)) context.Context {
|
||||
return fetchguard.WithTransport(context.Background(), rt(func(r *http.Request) (*http.Response, error) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
check(r, m)
|
||||
return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil
|
||||
}))
|
||||
}
|
||||
var chunks []string
|
||||
collect := func(c string) { chunks = append(chunks, c) }
|
||||
openai := "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" +
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n" +
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"lo \\\"you\\\"\"}}]}\r\n\r\n" +
|
||||
"data: [DONE]\n\n" +
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"after done\"}}]}\n"
|
||||
err := s.SendStream(stream(200, openai, func(r *http.Request, m map[string]any) {
|
||||
if m["stream"] != true || r.Header.Get("Accept") != "text/event-stream" || r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("stream request %v %v", r.Header, m)
|
||||
}
|
||||
}), valueobjects.NewPrompt().AddUserMessage("hi"), collect, &ModelConfig{Adapter: "openai", APIKey: "k", Model: "gpt-4o"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(chunks, "") != `{"content":"Hel"}{"content":"lo \"you\""}` {
|
||||
t.Fatalf("openai chunks = %v", chunks)
|
||||
}
|
||||
|
||||
chunks = nil
|
||||
anthropic := "event: message_start\ndata: {\"type\":\"message_start\"}\n\n" +
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Cze\"}}\n\n" +
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"ść\"}}\n\n" +
|
||||
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" +
|
||||
"data: {\"type\":\"content_block_delta\",\"delta\":{\"text\":\"late\"}}\n"
|
||||
err = s.SendStream(stream(200, anthropic, func(r *http.Request, m map[string]any) {
|
||||
if r.Header.Get("X-Api-Key") != "k" || m["stream"] != true {
|
||||
t.Errorf("anthropic stream request %v", r.Header)
|
||||
}
|
||||
}), valueobjects.NewPrompt().AddUserMessage("hi"), collect, &ModelConfig{Adapter: "anthropic", APIKey: "k", Model: "claude"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(chunks, "") != `{"content":"Cze"}{"content":"ść"}` {
|
||||
t.Fatalf("anthropic chunks = %v", chunks)
|
||||
}
|
||||
|
||||
if err := s.SendStream(stream(401, `{"error":{"message":"bad key"}}`, func(*http.Request, map[string]any) {}), valueobjects.NewPrompt(), collect, &ModelConfig{APIKey: "k"}); err == nil || err.Error() != "HTTP 401 from AI service" {
|
||||
t.Fatalf("401 = %v", err)
|
||||
}
|
||||
if err := s.SendStream(context.Background(), valueobjects.NewPrompt(), collect, nil); err == nil || err.Error() != "No AI model configured. Please add a model in Settings > AI." {
|
||||
t.Fatalf("no model = %v", err)
|
||||
}
|
||||
if err := s.SendStream(context.Background(), valueobjects.NewPrompt(), collect, &ModelConfig{}); err == nil || err.Error() != "API key is not configured for the selected model." {
|
||||
t.Fatalf("no key = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultModelQuirk pins Settings::getDefaultModel: the first enabled
|
||||
// default model, else the model at position 0 only when it is enabled.
|
||||
func TestDefaultModelQuirk(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
key := lagoon.NewEncrypted("sk-admin")
|
||||
db := freshModels(t,
|
||||
models.AiModel{Name: "zero", Model: "m0", APIKey: key},
|
||||
models.AiModel{Name: "one", Model: "m1", APIKey: key, IsEnabled: true, AcceptsImages: true},
|
||||
)
|
||||
def, err := DefaultModel(ctx, db)
|
||||
if err != nil || def != nil {
|
||||
t.Fatalf("model 0 disabled, model 1 enabled without is_default: default = %+v, %v; want none", def, err)
|
||||
}
|
||||
all, _ := Models(ctx, db)
|
||||
if len(all) != 1 || all[0].Name != "one" || all[0].Position != 1 || !all[0].Trusted || all[0].APIKey != "sk-admin" {
|
||||
t.Fatalf("models = %+v", all)
|
||||
}
|
||||
if v, _ := VisionModel(ctx, db); v == nil || v.Name != "one" || !v.Trusted {
|
||||
t.Fatalf("vision = %+v", v)
|
||||
}
|
||||
|
||||
db = freshModels(t,
|
||||
models.AiModel{Name: "zero", Model: "m0", APIKey: key},
|
||||
models.AiModel{Name: "one", Model: "m1", APIKey: key, IsEnabled: true, IsDefault: true},
|
||||
)
|
||||
if def, _ := DefaultModel(ctx, db); def == nil || def.Name != "one" {
|
||||
t.Fatalf("explicit default = %+v", def)
|
||||
}
|
||||
db = freshModels(t,
|
||||
models.AiModel{Name: "zero", Model: "m0", APIKey: key, IsEnabled: true},
|
||||
models.AiModel{Name: "one", Model: "m1", APIKey: key, IsEnabled: true},
|
||||
)
|
||||
if def, _ := DefaultModel(ctx, db); def == nil || def.Name != "zero" {
|
||||
t.Fatalf("position 0 fallback = %+v", def)
|
||||
}
|
||||
// A disabled default never counts.
|
||||
db = freshModels(t,
|
||||
models.AiModel{Name: "zero", Model: "m0", APIKey: key, IsEnabled: true},
|
||||
models.AiModel{Name: "one", Model: "m1", APIKey: key, IsDefault: true},
|
||||
)
|
||||
if def, _ := DefaultModel(ctx, db); def == nil || def.Name != "zero" {
|
||||
t.Fatalf("disabled default = %+v", def)
|
||||
}
|
||||
db = freshModels(t,
|
||||
models.AiModel{Name: "a", Model: "m", APIKey: key, IsEnabled: true, HasFilesEndpoint: true, BaseURL: ptr("https://x.test/v1"), SystemPrompt: ptr("sys"), MaxCompletionTokens: func() *int { n := 2048; return &n }()},
|
||||
models.AiModel{Name: "b", Model: "m", APIKey: key, IsEnabled: true, GeneratesImages: true},
|
||||
)
|
||||
if m, _ := FileModel(ctx, db); m == nil || m.Name != "a" || m.BaseURL != "https://x.test/v1" || m.SystemPrompt != "sys" || m.MaxCompletionTokens != 2048 {
|
||||
t.Fatalf("file model = %+v", m)
|
||||
}
|
||||
if m, _ := ImageModel(ctx, db); m == nil || m.Name != "b" {
|
||||
t.Fatalf("image model = %+v", m)
|
||||
}
|
||||
if m, _ := ModelByName(ctx, db, "b"); m == nil || m.Name != "b" {
|
||||
t.Fatalf("by name = %+v", m)
|
||||
}
|
||||
if m, _ := Models(ctx, nil); m != nil {
|
||||
t.Fatal("no database means no models")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminModelTrusted: an operator's model reaches a plain-http
|
||||
// loopback endpoint (fetchguard TrustedMode); a user's config to a
|
||||
// loopback endpoint is refused by the dial guard.
|
||||
func TestAdminModelTrusted(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, pong)
|
||||
}))
|
||||
defer srv.Close()
|
||||
tls := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, pong)
|
||||
}))
|
||||
defer tls.Close()
|
||||
s := NewAIService(nil, nil)
|
||||
prompt := valueobjects.NewPrompt().AddUserMessage("ping")
|
||||
|
||||
admin := &ModelConfig{Adapter: "openai", APIKey: "sk-admin", Model: "local", BaseURL: srv.URL + "/v1", Trusted: true}
|
||||
if r := s.Send(context.Background(), prompt, admin); !r.Success || r.Content != "pong" {
|
||||
t.Fatalf("admin model on the local network = %+v", r)
|
||||
}
|
||||
|
||||
user := &ModelConfig{Adapter: "openai", APIKey: "sk-user", Model: "local", BaseURL: tls.URL + "/v1"}
|
||||
r := s.Send(context.Background(), prompt, user)
|
||||
var fe *fetchguard.Error
|
||||
if r.Success || r.Error != "Failed to connect to AI service." || !errors.As(r.Err, &fe) || fe.Reason != fetchguard.ReasonPrivateIP {
|
||||
t.Fatalf("user config to loopback = %+v (%v)", r, r.Err)
|
||||
}
|
||||
user.BaseURL = srv.URL + "/v1"
|
||||
r = s.Send(context.Background(), prompt, user)
|
||||
if !errors.As(r.Err, &fe) || fe.Reason != fetchguard.ReasonScheme {
|
||||
t.Fatalf("user config over http = %+v (%v)", r, r.Err)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,12 @@ type ModelConfig struct {
|
||||
AcceptsImages bool `json:"-"`
|
||||
GeneratesImages bool `json:"-"`
|
||||
Trusted bool `json:"-"`
|
||||
// Position is an admin model's place among all configured models
|
||||
// (the WinterCMS repeater index); only the configs Models returns
|
||||
// carry one.
|
||||
Position int `json:"-"`
|
||||
|
||||
isDefault bool
|
||||
}
|
||||
|
||||
// DefaultModelID is the model a config without one uses (PHP's
|
||||
|
||||
119
classes/services/settings.go
Normal file
119
classes/services/settings.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.golem15.com/golem15/sm-golem-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The functions below port the WinterCMS plugin's Settings model over the
|
||||
// golem15_golem_models rows (in sort_order, then id order). Every config
|
||||
// they return is an operator's model: Trusted is set. A nil db means no
|
||||
// model is configured.
|
||||
|
||||
// Models is Settings::getModels: the enabled models, in order. Each keeps
|
||||
// its Position among all models, enabled or not, as PHP's array_filter
|
||||
// keeps the repeater keys.
|
||||
func Models(ctx context.Context, db *gorm.DB) ([]*ModelConfig, error) {
|
||||
if db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []models.AiModel
|
||||
if err := db.WithContext(ctx).Order("sort_order, id").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []*ModelConfig
|
||||
for i := range rows {
|
||||
if !rows[i].IsEnabled {
|
||||
continue
|
||||
}
|
||||
c := modelConfigFrom(&rows[i])
|
||||
c.Position = i
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DefaultModel is Settings::getDefaultModel: the first enabled model
|
||||
// marked default, else the model at position 0 when it is enabled. PHP's
|
||||
// `$models[0] ?? null` reads the filtered array by key, so a disabled
|
||||
// first model means no default even when later models are enabled.
|
||||
func DefaultModel(ctx context.Context, db *gorm.DB) (*ModelConfig, error) {
|
||||
all, err := Models(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range all {
|
||||
if m.isDefault {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
for _, m := range all {
|
||||
if m.Position == 0 {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// VisionModel is Settings::getVisionModel: the first enabled model that
|
||||
// accepts images.
|
||||
func VisionModel(ctx context.Context, db *gorm.DB) (*ModelConfig, error) {
|
||||
return firstModel(ctx, db, func(m *ModelConfig) bool { return m.AcceptsImages })
|
||||
}
|
||||
|
||||
// ImageModel is Settings::getImageModel: the first enabled model that
|
||||
// generates images.
|
||||
func ImageModel(ctx context.Context, db *gorm.DB) (*ModelConfig, error) {
|
||||
return firstModel(ctx, db, func(m *ModelConfig) bool { return m.GeneratesImages })
|
||||
}
|
||||
|
||||
// FileModel is Settings::getFileModel: the first enabled model with a
|
||||
// files endpoint.
|
||||
func FileModel(ctx context.Context, db *gorm.DB) (*ModelConfig, error) {
|
||||
return firstModel(ctx, db, func(m *ModelConfig) bool { return m.HasFilesEndpoint })
|
||||
}
|
||||
|
||||
// ModelByName is Settings::getModelByName: the first enabled model named
|
||||
// name.
|
||||
func ModelByName(ctx context.Context, db *gorm.DB, name string) (*ModelConfig, error) {
|
||||
return firstModel(ctx, db, func(m *ModelConfig) bool { return m.Name == name })
|
||||
}
|
||||
|
||||
func firstModel(ctx context.Context, db *gorm.DB, match func(*ModelConfig) bool) (*ModelConfig, error) {
|
||||
all, err := Models(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range all {
|
||||
if match(m) {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func modelConfigFrom(row *models.AiModel) *ModelConfig {
|
||||
c := &ModelConfig{
|
||||
Name: row.Name,
|
||||
Adapter: row.Adapter,
|
||||
APIKey: row.APIKey.Reveal(),
|
||||
Model: row.Model,
|
||||
HasFilesEndpoint: row.HasFilesEndpoint,
|
||||
AcceptsImages: row.AcceptsImages,
|
||||
GeneratesImages: row.GeneratesImages,
|
||||
Trusted: true,
|
||||
isDefault: row.IsDefault,
|
||||
}
|
||||
if row.BaseURL != nil {
|
||||
c.BaseURL = *row.BaseURL
|
||||
}
|
||||
if row.SystemPrompt != nil {
|
||||
c.SystemPrompt = *row.SystemPrompt
|
||||
}
|
||||
if row.MaxCompletionTokens != nil {
|
||||
c.MaxCompletionTokens = *row.MaxCompletionTokens
|
||||
}
|
||||
return c
|
||||
}
|
||||
240
console/import_settings.go
Normal file
240
console/import_settings.go
Normal file
@@ -0,0 +1,240 @@
|
||||
// Package console holds the golem15.golem operator commands.
|
||||
package console
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-golem-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SettingsItem is the WinterCMS system_settings item that holds the
|
||||
// plugin's AI models repeater.
|
||||
const SettingsItem = "golem_settings"
|
||||
|
||||
// ImportSettings is golem:import-settings: a one-time copy of the
|
||||
// WinterCMS plugin's AI models (the models repeater of the system_settings
|
||||
// row golem_settings, or of a JSON file given with --file) into
|
||||
// golem15_golem_models, in repeater order, each plaintext API key
|
||||
// encrypted on write. It refuses to run when the table already has rows,
|
||||
// so a second run changes nothing.
|
||||
func ImportSettings(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "golem:import-settings",
|
||||
Description: "Import the AI models of a WinterCMS golem_settings row (or a JSON file) into golem15_golem_models, once.",
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "file",
|
||||
Description: "A JSON file holding the settings value ({\"models\":[...]}) or the models list, read instead of system_settings",
|
||||
}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
if app == nil {
|
||||
return errors.New("golem:import-settings: app is nil")
|
||||
}
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return errors.New("golem:import-settings: database is not configured")
|
||||
}
|
||||
file, _ := in.Flag("file")
|
||||
n, err := ImportModels(ctx, db, strings.TrimSpace(file))
|
||||
var notice *importNotice
|
||||
switch {
|
||||
case errors.As(err, ¬ice):
|
||||
out.Info(notice.msg)
|
||||
return nil
|
||||
case err != nil:
|
||||
out.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
out.Success(fmt.Sprintf("Imported %d AI models.", n))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ErrModelsNotEmpty refuses an import into a table that already has rows.
|
||||
var ErrModelsNotEmpty = errors.New("golem15_golem_models already has rows; nothing was imported")
|
||||
|
||||
// importNotice is a clean stop: there is nothing to import.
|
||||
type importNotice struct{ msg string }
|
||||
|
||||
func (n *importNotice) Error() string { return n.msg }
|
||||
|
||||
// IsNothingToImport reports an import that stopped because the source
|
||||
// holds nothing (no system_settings table, no golem_settings row, no
|
||||
// models).
|
||||
func IsNothingToImport(err error) bool {
|
||||
var n *importNotice
|
||||
return errors.As(err, &n)
|
||||
}
|
||||
|
||||
// ImportModels runs the import and returns the number of models written.
|
||||
// file, when not empty, is read instead of the system_settings row.
|
||||
func ImportModels(ctx context.Context, db *gorm.DB, file string) (int, error) {
|
||||
var count int64
|
||||
if err := db.WithContext(ctx).Model(&models.AiModel{}).Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if count > 0 {
|
||||
return 0, ErrModelsNotEmpty
|
||||
}
|
||||
raw, err := settingsValue(ctx, db, file)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
items, err := repeaterItems(raw)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return 0, &importNotice{msg: "The settings hold no AI models: nothing to import."}
|
||||
}
|
||||
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
for i, item := range items {
|
||||
row := modelFromItem(item)
|
||||
row.SortOrder = i
|
||||
if err := tx.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(items), nil
|
||||
}
|
||||
|
||||
func settingsValue(ctx context.Context, db *gorm.DB, file string) ([]byte, error) {
|
||||
if file != "" {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("golem:import-settings: read %s: %w", file, err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
if !db.WithContext(ctx).Migrator().HasTable("system_settings") {
|
||||
return nil, &importNotice{msg: "No system_settings table: nothing to import."}
|
||||
}
|
||||
var values []string
|
||||
if err := db.WithContext(ctx).Raw(`SELECT value FROM system_settings WHERE item = ?`, SettingsItem).Scan(&values).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, &importNotice{msg: "No golem_settings row in system_settings: nothing to import."}
|
||||
}
|
||||
return []byte(values[0]), nil
|
||||
}
|
||||
|
||||
// repeaterItems reads the models list of a settings value
|
||||
// ({"models":[...]}) or a bare list.
|
||||
func repeaterItems(raw []byte) ([]map[string]any, error) {
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return nil, fmt.Errorf("golem:import-settings: the settings are not JSON: %w", err)
|
||||
}
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
v = m["models"]
|
||||
}
|
||||
var list []any
|
||||
switch t := v.(type) {
|
||||
case []any:
|
||||
list = t
|
||||
case map[string]any:
|
||||
// A repeater saved with non-sequential keys decodes as an object.
|
||||
for i := 0; i < len(t); i++ {
|
||||
if item, ok := t[strconv.Itoa(i)]; ok {
|
||||
list = append(list, item)
|
||||
}
|
||||
}
|
||||
case nil:
|
||||
default:
|
||||
return nil, errors.New("golem:import-settings: models is not a list")
|
||||
}
|
||||
out := make([]map[string]any, 0, len(list))
|
||||
for _, item := range list {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func modelFromItem(item map[string]any) models.AiModel {
|
||||
row := models.AiModel{
|
||||
Name: str(item["name"]),
|
||||
Adapter: str(item["adapter"]),
|
||||
Model: str(item["model"]),
|
||||
IsEnabled: truthy(item["is_enabled"]),
|
||||
IsDefault: truthy(item["is_default"]),
|
||||
GeneratesImages: truthy(item["generates_images"]),
|
||||
AcceptsImages: truthy(item["accepts_images"]),
|
||||
HasFilesEndpoint: truthy(item["has_files_endpoint"]),
|
||||
}
|
||||
if row.Adapter == "" {
|
||||
row.Adapter = "openai"
|
||||
}
|
||||
if key := str(item["api_key"]); key != "" {
|
||||
row.APIKey = lagoon.NewEncrypted(key)
|
||||
}
|
||||
if s := str(item["base_url"]); s != "" {
|
||||
row.BaseURL = &s
|
||||
}
|
||||
if s := str(item["system_prompt"]); s != "" {
|
||||
row.SystemPrompt = &s
|
||||
}
|
||||
if n, ok := integer(item["max_completion_tokens"]); ok {
|
||||
row.MaxCompletionTokens = &n
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case bool:
|
||||
if t {
|
||||
return "1"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// truthy is PHP's truthiness of a stored switch ("1", true, 1).
|
||||
func truthy(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return t != "" && t != "0"
|
||||
case float64:
|
||||
return t != 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func integer(v any) (int, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
if t == math.Trunc(t) {
|
||||
return int(t), true
|
||||
}
|
||||
case string:
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
9
controllers/admin_registry.go
Normal file
9
controllers/admin_registry.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Package controllers holds the golem15.golem admin controllers.
|
||||
package controllers
|
||||
|
||||
import "git.golem15.com/golem15/summercms/modules/pact"
|
||||
|
||||
// AdminControllers lists the plugin's admin controllers.
|
||||
func AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{ModelsAdminController{}}
|
||||
}
|
||||
12
controllers/aimodels/config_form.yaml
Normal file
12
controllers/aimodels/config_form.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
name: golem15.golem::lang.settings.add_model
|
||||
form: ~/plugins/golem15/golem/models/ai_model/fields.yaml
|
||||
modelClass: Golem15\Golem\Models\AiModel
|
||||
defaultRedirect: golem15/golem/models
|
||||
|
||||
create:
|
||||
redirect: golem15/golem/models/update/:id
|
||||
redirectClose: golem15/golem/models
|
||||
|
||||
update:
|
||||
redirect: golem15/golem/models
|
||||
redirectClose: golem15/golem/models
|
||||
12
controllers/aimodels/config_list.yaml
Normal file
12
controllers/aimodels/config_list.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
list: ~/plugins/golem15/golem/models/ai_model/columns.yaml
|
||||
modelClass: Golem15\Golem\Models\AiModel
|
||||
title: golem15.golem::lang.settings.models
|
||||
recordUrl: golem15/golem/models/update/:id
|
||||
noRecordsMessage: backend::lang.list.no_records
|
||||
recordsPerPage: 20
|
||||
showCheckboxes: true
|
||||
|
||||
toolbar:
|
||||
buttons: [create, delete]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
90
controllers/models_admin_controller.go
Normal file
90
controllers/models_admin_controller.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.golem15.com/golem15/sm-golem-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// PermissionAccessSettings guards the AI models screen.
|
||||
const PermissionAccessSettings = "golem15.golem.access_settings"
|
||||
|
||||
// ModelsAdminController is the AI models list and form
|
||||
// (golem15.golem.models). The API key is write-only: the form shows it
|
||||
// redacted, and an update that sends it back empty or redacted keeps the
|
||||
// stored key.
|
||||
type ModelsAdminController struct{}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = ModelsAdminController{}
|
||||
_ pact.AdminPermissioned = ModelsAdminController{}
|
||||
_ pact.AdminRecordSource = ModelsAdminController{}
|
||||
_ pact.FormBeforeCreate = ModelsAdminController{}
|
||||
_ pact.FormBeforeUpdate = ModelsAdminController{}
|
||||
)
|
||||
|
||||
// ID is golem15.golem.models.
|
||||
func (ModelsAdminController) ID() string { return "golem15.golem.models" }
|
||||
|
||||
// ModelName is the model's WinterCMS class name.
|
||||
func (ModelsAdminController) ModelName() string { return `Golem15\Golem\Models\AiModel` }
|
||||
|
||||
// ConfigDir holds config_list.yaml and config_form.yaml.
|
||||
func (ModelsAdminController) ConfigDir() string { return "controllers/aimodels" }
|
||||
|
||||
// RequiredPermissions is golem15.golem.access_settings.
|
||||
func (ModelsAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionAccessSettings}
|
||||
}
|
||||
|
||||
// NewRecord returns an empty AI model.
|
||||
func (ModelsAdminController) NewRecord() any { return &models.AiModel{} }
|
||||
|
||||
// redacted is how lagoon.Encrypted serializes, so how the form shows the
|
||||
// stored key.
|
||||
const redacted = "[redacted]"
|
||||
|
||||
func keyMissing(m *models.AiModel) bool {
|
||||
k := m.APIKey.Reveal()
|
||||
return k == "" || k == redacted
|
||||
}
|
||||
|
||||
// FormBeforeCreate requires an API key.
|
||||
func (ModelsAdminController) FormBeforeCreate(_ context.Context, model any) error {
|
||||
m, ok := model.(*models.AiModel)
|
||||
if !ok {
|
||||
return errors.New("golem: unexpected admin record")
|
||||
}
|
||||
if keyMissing(m) {
|
||||
return &cabana.ValidationError{Details: map[string]any{"api_key": []string{"The api key field is required."}}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormBeforeUpdate keeps the stored key when the form sent it back empty
|
||||
// or redacted.
|
||||
func (ModelsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
m, ok := model.(*models.AiModel)
|
||||
if !ok {
|
||||
return errors.New("golem: unexpected admin record")
|
||||
}
|
||||
if !keyMissing(m) {
|
||||
return nil
|
||||
}
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return errors.New("golem: no transaction for the model update")
|
||||
}
|
||||
var stored models.AiModel
|
||||
if err := tx.Select("api_key").Where("id = ?", m.ID).Take(&stored).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
m.APIKey = stored.APIKey
|
||||
if keyMissing(m) {
|
||||
return &cabana.ValidationError{Details: map[string]any{"api_key": []string{"The api key field is required."}}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
80
go.mod
80
go.mod
@@ -4,35 +4,113 @@ go 1.27.0
|
||||
|
||||
require (
|
||||
git.golem15.com/golem15/summercms v0.0.0
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.1 // indirect
|
||||
github.com/knadh/koanf/providers/confmap v1.0.1 // indirect
|
||||
github.com/knadh/koanf/providers/env/v2 v2.0.1 // indirect
|
||||
github.com/knadh/koanf/providers/file v1.2.1 // indirect
|
||||
github.com/knadh/koanf/v2 v2.3.6 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.55.0 // indirect
|
||||
github.com/moby/moby/client v0.5.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/riverqueue/river v0.47.0 // indirect
|
||||
github.com/riverqueue/river/riverdriver v0.47.0 // indirect
|
||||
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 // indirect
|
||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect
|
||||
github.com/riverqueue/river/rivershared v0.47.0 // indirect
|
||||
github.com/riverqueue/river/rivertype v0.47.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
github.com/testcontainers/testcontainers-go v0.44.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/wneessen/go-mail v0.8.1 // indirect
|
||||
github.com/yuin/goldmark v1.8.6 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
gocloud.dev v0.46.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/image v0.46.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/term v0.46.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/api v0.272.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gorm.io/driver/postgres v1.6.3 // indirect
|
||||
)
|
||||
|
||||
replace git.golem15.com/golem15/summercms => ../../../../summercms.go
|
||||
|
||||
203
go.sum
203
go.sum
@@ -1,15 +1,79 @@
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg=
|
||||
cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 h1:w5OoDiMN6x53ROmiIImGzmVcxXv2q1GXY+aKV4WAJYM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3/go.mod h1:dAhgYp776bX3LuWvnSCFwQEjNs6fuFg7YXIy5PXcP3Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
@@ -21,6 +85,11 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
@@ -29,26 +98,69 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w=
|
||||
github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
|
||||
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -67,12 +179,18 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP
|
||||
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
|
||||
github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
|
||||
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
||||
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
@@ -101,8 +219,23 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/riverqueue/river v0.47.0 h1:j8HOEyiOE8gRRhVS2wllKams372TH1WWiH6xCakXHqc=
|
||||
github.com/riverqueue/river v0.47.0/go.mod h1:Wgmwx475ZBd8lQnNrJgyG2DWH7BfyNiSAtv0rC9bJBQ=
|
||||
github.com/riverqueue/river/riverdriver v0.47.0 h1:qU8VkjdMl9plqeRg57SxsDUM/i/eECaSYejZ7HynC60=
|
||||
github.com/riverqueue/river/riverdriver v0.47.0/go.mod h1:NOXl0fUiF1AT/TaQOjdx2A/c0Davn+SKbW8nAXWjfC4=
|
||||
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 h1:4wckhMlLN08F76/GWxGF6vlrbs5HCMuV01dpUnvOGI4=
|
||||
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0/go.mod h1:EME1EGr7RU/vMUf61mpf9YXhsZJ+GlFk4AHrIHT+YdE=
|
||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 h1:5N9nvemhQwbUElMxASw4oEaYJ/v6hiS5Y9VcOQfdC5g=
|
||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0/go.mod h1:ZboiXXZKC4+fTkxBxGRVmAsCuUu0NPYliqbWYuQAZyw=
|
||||
github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOtPKQZAmThkjFpLs=
|
||||
github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
|
||||
github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
|
||||
github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
@@ -112,10 +245,27 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
|
||||
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
@@ -128,27 +278,80 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
|
||||
gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
|
||||
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
|
||||
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE=
|
||||
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
|
||||
gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
|
||||
189
golem_test.go
189
golem_test.go
@@ -2,11 +2,19 @@ package golem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-golem-plugin/console"
|
||||
"git.golem15.com/golem15/sm-golem-plugin/internal/pgtest"
|
||||
"git.golem15.com/golem15/sm-golem-plugin/models"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
@@ -63,3 +71,184 @@ func TestGolemPluginBoot(t *testing.T) {
|
||||
t.Fatalf("access_settings label = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportSettings: golem:import-settings copies the WinterCMS
|
||||
// golem_settings repeater once, in order, encrypting each plaintext key;
|
||||
// a second run is refused; a missing system_settings table is a clean
|
||||
// notice.
|
||||
func TestImportSettings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := pgtest.DB(t)
|
||||
if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`DROP TABLE IF EXISTS system_settings`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Exec(`DROP TABLE IF EXISTS system_settings`) })
|
||||
|
||||
if cmd := console.ImportSettings(nil); cmd.Name != "golem:import-settings" || len(cmd.Flags) != 1 || cmd.Flags[0].Name != "file" {
|
||||
t.Fatalf("command = %+v", cmd)
|
||||
}
|
||||
if (&Plugin{}).Commands()[0].Name != "golem:import-settings" {
|
||||
t.Fatal("the plugin does not register golem:import-settings")
|
||||
}
|
||||
|
||||
_, err := console.ImportModels(ctx, db, "")
|
||||
if !console.IsNothingToImport(err) || err.Error() != "No system_settings table: nothing to import." {
|
||||
t.Fatalf("missing table = %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE system_settings (id SERIAL PRIMARY KEY, item TEXT, value TEXT)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := console.ImportModels(ctx, db, ""); !console.IsNothingToImport(err) || err.Error() != "No golem_settings row in system_settings: nothing to import." {
|
||||
t.Fatalf("missing row = %v", err)
|
||||
}
|
||||
const plain = "sk-test-plain-01"
|
||||
value := `{"models":[` +
|
||||
`{"name":"Chat","adapter":"openai","api_key":"` + plain + `","base_url":"https://api.openai.com/v1","model":"gpt-4o","system_prompt":"","is_enabled":"1","is_default":"1","generates_images":"0","accepts_images":true,"has_files_endpoint":"0","max_completion_tokens":"4096"},` +
|
||||
`{"name":"Claude","adapter":"anthropic","api_key":"sk-ant-parity-0002","base_url":"","model":"claude-sonnet-4-6","system_prompt":"Be terse.","is_enabled":"0","is_default":"0","max_completion_tokens":8192}` +
|
||||
`]}`
|
||||
if err := db.Exec(`INSERT INTO system_settings (item, value) VALUES ('golem_settings', ?)`, value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := console.ImportModels(ctx, db, "")
|
||||
if err != nil || n != 2 {
|
||||
t.Fatalf("import = %d, %v", n, err)
|
||||
}
|
||||
var rows []models.AiModel
|
||||
if err := db.Order("sort_order").Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 || rows[0].Name != "Chat" || rows[0].SortOrder != 0 || rows[1].Name != "Claude" || rows[1].SortOrder != 1 {
|
||||
t.Fatalf("rows = %+v", rows)
|
||||
}
|
||||
r0, r1 := rows[0], rows[1]
|
||||
if !r0.IsEnabled || !r0.IsDefault || r0.GeneratesImages || !r0.AcceptsImages || r0.HasFilesEndpoint || r0.MaxCompletionTokens == nil || *r0.MaxCompletionTokens != 4096 || r0.BaseURL == nil || *r0.BaseURL != "https://api.openai.com/v1" || r0.SystemPrompt != nil {
|
||||
t.Fatalf("row 0 = %+v", r0)
|
||||
}
|
||||
if r1.Adapter != "anthropic" || r1.IsEnabled || r1.BaseURL != nil || r1.SystemPrompt == nil || *r1.SystemPrompt != "Be terse." || *r1.MaxCompletionTokens != 8192 {
|
||||
t.Fatalf("row 1 = %+v", r1)
|
||||
}
|
||||
if r0.APIKey.Reveal() != plain {
|
||||
t.Fatal("the imported key does not decrypt to the plaintext")
|
||||
}
|
||||
var stored string
|
||||
if err := db.Raw(`SELECT api_key FROM golem15_golem_models WHERE name = 'Chat'`).Scan(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored == "" || strings.Contains(stored, plain) || strings.Contains(stored, "sk-") {
|
||||
t.Fatalf("the key is stored in plaintext: %q", stored)
|
||||
}
|
||||
|
||||
if _, err := console.ImportModels(ctx, db, ""); !errors.Is(err, console.ErrModelsNotEmpty) {
|
||||
t.Fatalf("second run = %v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.AiModel{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("second run wrote rows: %d", count)
|
||||
}
|
||||
|
||||
// --file reads a JSON file instead (a bare models list here).
|
||||
if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "golem_settings.json")
|
||||
if err := os.WriteFile(file, []byte(`[{"name":"Local","model":"llama3","api_key":"local-key","base_url":"http://10.0.0.5:11434/v1","is_enabled":true}]`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := console.ImportModels(ctx, db, file); err != nil || n != 1 {
|
||||
t.Fatalf("file import = %d, %v", n, err)
|
||||
}
|
||||
var local models.AiModel
|
||||
if err := db.Take(&local).Error; err != nil || local.Adapter != "openai" || local.APIKey.Reveal() != "local-key" {
|
||||
t.Fatalf("file row = %+v, %v", local, err)
|
||||
}
|
||||
if _, err := console.ImportModels(ctx, db, filepath.Join(t.TempDir(), "missing.json")); err == nil {
|
||||
t.Fatal("a missing file must fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminModelsForm compiles the AI models list and form and saves
|
||||
// through cabana's CRUD service: the key is required on create, shown
|
||||
// redacted, kept when an update sends it back redacted or empty, and
|
||||
// replaced by a new value.
|
||||
func TestAdminModelsForm(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := pgtest.DB(t)
|
||||
if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &Plugin{}
|
||||
ctls := p.AdminControllers()
|
||||
if len(ctls) != 1 || ctls[0].ID() != "golem15.golem.models" {
|
||||
t.Fatalf("controllers = %v", ctls)
|
||||
}
|
||||
ctl := ctls[0]
|
||||
if _, err := cabana.CompileList("golem15.golem", ctl, p.AdminFS()); err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
form, err := cabana.CompileForm("golem15.golem", ctl, p.AdminFS())
|
||||
if err != nil {
|
||||
t.Fatalf("form: %v", err)
|
||||
}
|
||||
cc := &cabana.CompiledController{PluginID: "golem15.golem", Controller: ctl, Form: form}
|
||||
if err := cabana.BindWritableFields(cc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perms := p.Permissions(); len(perms) != 1 || perms[0].Code != "golem15.golem.access_settings" {
|
||||
t.Fatalf("permissions = %+v", perms)
|
||||
}
|
||||
if nav := p.Navigation(); len(nav) != 1 || nav[0].SideMenu[0].Controller != "golem15.golem.models" {
|
||||
t.Fatalf("navigation = %+v", nav)
|
||||
}
|
||||
svc := cabana.CRUDService{DB: db}
|
||||
body := map[string]any{"name": "Vision", "adapter": "anthropic", "model": "claude-sonnet-4-6", "is_enabled": true, "accepts_images": true}
|
||||
_, err = svc.Create(ctx, cc, cabana.RecordInput{Body: body})
|
||||
var ve *cabana.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Details["api_key"] == nil {
|
||||
t.Fatalf("create without a key = %v", err)
|
||||
}
|
||||
body["api_key"] = "sk-ant-admin-0001"
|
||||
rec, err := svc.Create(ctx, cc, cabana.RecordInput{Body: body})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(rec)
|
||||
if strings.Contains(string(raw), "sk-ant-admin-0001") {
|
||||
t.Fatalf("the record echoes the key: %s", raw)
|
||||
}
|
||||
id := rec["id"]
|
||||
keyOf := func() string {
|
||||
var m models.AiModel
|
||||
if err := db.Where("id = ?", id).Take(&m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m.APIKey.Reveal()
|
||||
}
|
||||
if keyOf() != "sk-ant-admin-0001" {
|
||||
t.Fatal("created key")
|
||||
}
|
||||
for _, sent := range []any{"[redacted]", ""} {
|
||||
body["api_key"] = sent
|
||||
body["model"] = "claude-opus"
|
||||
if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: body}); err != nil {
|
||||
t.Fatalf("update with %q: %v", sent, err)
|
||||
}
|
||||
if keyOf() != "sk-ant-admin-0001" {
|
||||
t.Fatalf("an update with %q replaced the key", sent)
|
||||
}
|
||||
}
|
||||
body["api_key"] = "sk-ant-admin-0002"
|
||||
if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: body}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if keyOf() != "sk-ant-admin-0002" {
|
||||
t.Fatal("a new key was not stored")
|
||||
}
|
||||
body["adapter"] = "gemini"
|
||||
if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: body}); !errors.As(err, &ve) {
|
||||
t.Fatalf("unknown adapter = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
72
internal/pgtest/pgtest.go
Normal file
72
internal/pgtest/pgtest.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Package pgtest starts one Postgres container per test binary for the
|
||||
// plugin's database tests and migrates the plugin's tables into it.
|
||||
package pgtest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-golem-plugin/updates"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
gdb *gorm.DB
|
||||
sqlDB *sql.DB
|
||||
err error
|
||||
)
|
||||
|
||||
// DB returns the shared, migrated database; the column encryption keys
|
||||
// are test-only. Tests that need an empty table delete its rows first.
|
||||
func DB(t testing.TB) *gorm.DB {
|
||||
t.Helper()
|
||||
once.Do(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
if err = lagoon.PublishEncryptionKeys(nil, bytes.Repeat([]byte("G"), 32), nil); err != nil {
|
||||
return
|
||||
}
|
||||
var ctr *postgres.PostgresContainer
|
||||
ctr, err = postgres.Run(ctx, "postgres:16-alpine",
|
||||
postgres.WithDatabase("golem"),
|
||||
postgres.WithUsername("golem"),
|
||||
postgres.WithPassword("golem"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var dsn string
|
||||
if dsn, err = ctr.ConnectionString(ctx, "sslmode=disable"); err != nil {
|
||||
return
|
||||
}
|
||||
if sqlDB, err = sql.Open("pgx", dsn); err != nil {
|
||||
return
|
||||
}
|
||||
if gdb, err = lagoon.Use(ctx, sqlDB); err != nil {
|
||||
return
|
||||
}
|
||||
err = gormigrate.New(gdb, &gormigrate.Options{
|
||||
TableName: "summer_migrations_golem15_golem",
|
||||
UseTransaction: true,
|
||||
}, updates.All()).Migrate()
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("pgtest: %v", err)
|
||||
}
|
||||
return gdb
|
||||
}
|
||||
|
||||
// SQL is the database's *sql.DB.
|
||||
func SQL(t testing.TB) *sql.DB {
|
||||
DB(t)
|
||||
return sqlDB
|
||||
}
|
||||
62
models/ai_model.go
Normal file
62
models/ai_model.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
// AiModel is one AI model an operator configured in the admin
|
||||
// (golem15_golem_models), the Go form of one item of the WinterCMS
|
||||
// plugin's models repeater. APIKey is encrypted at rest, never serialized
|
||||
// (json:"-", Hidden) and never echoed by the admin form. BaseURL nil or
|
||||
// empty means the adapter's default. SortOrder keeps the repeater's order.
|
||||
type AiModel struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Adapter string `gorm:"column:adapter"`
|
||||
APIKey lagoon.Encrypted `gorm:"column:api_key" json:"-"`
|
||||
BaseURL *string `gorm:"column:base_url"`
|
||||
Model string `gorm:"column:model"`
|
||||
SystemPrompt *string `gorm:"column:system_prompt"`
|
||||
IsEnabled bool `gorm:"column:is_enabled"`
|
||||
IsDefault bool `gorm:"column:is_default"`
|
||||
GeneratesImages bool `gorm:"column:generates_images"`
|
||||
AcceptsImages bool `gorm:"column:accepts_images"`
|
||||
HasFilesEndpoint bool `gorm:"column:has_files_endpoint"`
|
||||
MaxCompletionTokens *int `gorm:"column:max_completion_tokens"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
// TableName is golem15_golem_models.
|
||||
func (AiModel) TableName() string { return "golem15_golem_models" }
|
||||
|
||||
// Fillable is every column the admin form writes.
|
||||
func (AiModel) Fillable() []string {
|
||||
return []string{
|
||||
"name", "adapter", "api_key", "base_url", "model", "system_prompt",
|
||||
"is_enabled", "is_default", "generates_images", "accepts_images",
|
||||
"has_files_endpoint", "max_completion_tokens", "sort_order",
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden keeps the API key out of every serialization.
|
||||
func (AiModel) Hidden() []string { return []string{"api_key"} }
|
||||
|
||||
// Rules are the form's checks: a name, a model id, a known adapter and an
|
||||
// integer token budget.
|
||||
func (AiModel) Rules() map[string]string {
|
||||
return map[string]string{
|
||||
"name": "required",
|
||||
"model": "required",
|
||||
"adapter": "required|in:openai,anthropic",
|
||||
"max_completion_tokens": "nullable|integer|min:1",
|
||||
"sort_order": "nullable|integer",
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(AiModel{})
|
||||
}
|
||||
21
models/ai_model/columns.yaml
Normal file
21
models/ai_model/columns.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
columns:
|
||||
name:
|
||||
label: golem15.golem::lang.settings.model_name
|
||||
searchable: true
|
||||
adapter:
|
||||
label: golem15.golem::lang.settings.adapter
|
||||
model:
|
||||
label: golem15.golem::lang.settings.model_id
|
||||
searchable: true
|
||||
is_enabled:
|
||||
label: golem15.golem::lang.settings.is_enabled
|
||||
type: switch
|
||||
is_default:
|
||||
label: golem15.golem::lang.settings.is_default
|
||||
type: switch
|
||||
accepts_images:
|
||||
label: golem15.golem::lang.settings.accepts_images
|
||||
type: switch
|
||||
sort_order:
|
||||
label: golem15.golem::lang.settings.sort_order
|
||||
sortable: true
|
||||
72
models/ai_model/fields.yaml
Normal file
72
models/ai_model/fields.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
fields:
|
||||
name:
|
||||
label: golem15.golem::lang.settings.model_name
|
||||
type: text
|
||||
span: left
|
||||
required: true
|
||||
adapter:
|
||||
label: golem15.golem::lang.settings.adapter
|
||||
type: dropdown
|
||||
span: right
|
||||
default: openai
|
||||
comment: golem15.golem::lang.settings.adapter_comment
|
||||
options:
|
||||
openai: OpenAI (compatible)
|
||||
anthropic: Anthropic
|
||||
api_key:
|
||||
label: golem15.golem::lang.settings.api_key
|
||||
type: text
|
||||
span: right
|
||||
comment: golem15.golem::lang.settings.api_key_comment
|
||||
attributes:
|
||||
type: password
|
||||
autocomplete: off
|
||||
base_url:
|
||||
label: golem15.golem::lang.settings.base_url
|
||||
type: text
|
||||
span: left
|
||||
default: https://api.openai.com/v1
|
||||
comment: golem15.golem::lang.settings.base_url_comment
|
||||
model:
|
||||
label: golem15.golem::lang.settings.model_id
|
||||
type: text
|
||||
span: right
|
||||
required: true
|
||||
comment: golem15.golem::lang.settings.model_id_comment
|
||||
system_prompt:
|
||||
label: golem15.golem::lang.settings.system_prompt
|
||||
type: textarea
|
||||
span: full
|
||||
size: small
|
||||
comment: golem15.golem::lang.settings.system_prompt_comment
|
||||
is_enabled:
|
||||
label: golem15.golem::lang.settings.is_enabled
|
||||
type: switch
|
||||
span: right
|
||||
is_default:
|
||||
label: golem15.golem::lang.settings.is_default
|
||||
type: switch
|
||||
span: left
|
||||
generates_images:
|
||||
label: golem15.golem::lang.settings.generates_images
|
||||
type: switch
|
||||
span: auto
|
||||
accepts_images:
|
||||
label: golem15.golem::lang.settings.accepts_images
|
||||
type: switch
|
||||
span: auto
|
||||
has_files_endpoint:
|
||||
label: golem15.golem::lang.settings.has_files_endpoint
|
||||
type: switch
|
||||
span: auto
|
||||
max_completion_tokens:
|
||||
label: golem15.golem::lang.settings.max_completion_tokens
|
||||
type: number
|
||||
span: left
|
||||
default: 4096
|
||||
comment: golem15.golem::lang.settings.max_completion_tokens_comment
|
||||
sort_order:
|
||||
label: golem15.golem::lang.settings.sort_order
|
||||
type: number
|
||||
span: right
|
||||
default: 0
|
||||
16
models/registry.go
Normal file
16
models/registry.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Package models holds the golem15.golem GORM models. It is a leaf
|
||||
// package: it imports no other package of the plugin.
|
||||
package models
|
||||
|
||||
var all []any
|
||||
|
||||
// Register appends GORM models, so a model file adds itself without
|
||||
// editing plugin.go.
|
||||
func Register(models ...any) {
|
||||
all = append(all, models...)
|
||||
}
|
||||
|
||||
// All returns every model registered by this package.
|
||||
func All() []any {
|
||||
return all
|
||||
}
|
||||
28
plugin.go
28
plugin.go
@@ -2,22 +2,31 @@
|
||||
// SummerCMS applications. It ports the WinterCMS Golem15.Golem plugin's AI
|
||||
// service (provider adapters for the Anthropic Messages API and the
|
||||
// OpenAI-compatible chat completions API, prompts, image generation, file
|
||||
// uploads, streaming) and its SSRF guard.
|
||||
// uploads, streaming), its AI model settings with their admin screen and
|
||||
// its SSRF guard.
|
||||
package golem
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/sm-golem-plugin/console"
|
||||
"git.golem15.com/golem15/sm-golem-plugin/models"
|
||||
"git.golem15.com/golem15/sm-golem-plugin/updates"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
_ party.Plugin = (*Plugin)(nil)
|
||||
_ pact.HasConfig = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
_ party.Plugin = (*Plugin)(nil)
|
||||
_ pact.HasConfig = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
_ pact.HasCommands = (*Plugin)(nil)
|
||||
)
|
||||
|
||||
//go:embed config
|
||||
@@ -55,6 +64,17 @@ func (p *Plugin) ConfigFS() fs.FS { return configFS }
|
||||
// LangFS ships the golem15.golem lang namespace.
|
||||
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||
|
||||
// Migrations creates golem15_golem_models.
|
||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||
|
||||
// Models is AiModel.
|
||||
func (p *Plugin) Models() []any { return models.All() }
|
||||
|
||||
// Commands is golem:import-settings.
|
||||
func (p *Plugin) Commands() []bonfire.Command {
|
||||
return []bonfire.Command{console.ImportSettings(p.app)}
|
||||
}
|
||||
|
||||
func init() {
|
||||
party.Register(&Plugin{})
|
||||
}
|
||||
|
||||
41
updates/01_ai_models.go
Normal file
41
updates/01_ai_models.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// aiModelsMigration creates golem15_golem_models, one row per AI model
|
||||
// (the WinterCMS plugin kept them as a repeater inside its system_settings
|
||||
// row; golem:import-settings copies them over once).
|
||||
var aiModelsMigration = &gormigrate.Migration{
|
||||
ID: "202610030101_create_golem_models",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`
|
||||
CREATE TABLE golem15_golem_models (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
adapter TEXT NOT NULL DEFAULT 'openai',
|
||||
api_key TEXT,
|
||||
base_url TEXT,
|
||||
model TEXT NOT NULL,
|
||||
system_prompt TEXT,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
generates_images BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
accepts_images BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
has_files_endpoint BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
max_completion_tokens INTEGER DEFAULT 4096,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS golem15_golem_models`).Error
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(aiModelsMigration)
|
||||
}
|
||||
17
updates/registry.go
Normal file
17
updates/registry.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Package updates holds the golem15.golem migrations.
|
||||
package updates
|
||||
|
||||
import "github.com/go-gormigrate/gormigrate/v2"
|
||||
|
||||
var all []*gormigrate.Migration
|
||||
|
||||
// Register appends gormigrate steps, so a migration file adds itself
|
||||
// without editing plugin.go.
|
||||
func Register(ms ...*gormigrate.Migration) {
|
||||
all = append(all, ms...)
|
||||
}
|
||||
|
||||
// All returns every migration registered by this package, in order.
|
||||
func All() []*gormigrate.Migration {
|
||||
return all
|
||||
}
|
||||
Reference in New Issue
Block a user