Claude/update docs roadmap f zd2 j (#2880)

* docs: update all four documentation guides and mark Q2 complete

- ai-native-services: add WithMCP one-liner, standalone gateway,
  WebSocket client example, and OpenTelemetry observability section
- mcp-security: add OTel distributed tracing, WebSocket authentication
  (connection-level and per-message), DeniedReason audit field
- tool-descriptions: add manual overrides with WithEndpointDocs and
  export formats section
- agent-patterns: add LangChain/LlamaIndex SDK pattern and standalone
  gateway production pattern with Docker example
- Update roadmap: mark Q2 documentation as complete, Q2 at 100%
- Update status: reflect all recent completions, shift priorities

https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc

* feat: add agent demo example and blog post

Add examples/agent-demo with a multi-service project management app
(projects, tasks, team) that demonstrates AI agents interacting with
Go Micro services through MCP. Includes seed data and example prompts.

Add blog post 4 "Agents Meet Microservices: A Hands-On Demo" walking
through the example code and showing cross-service agent workflows.

https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc

* feat: enable multiple services in a single binary

Remove global state mutations from service and cmd option functions so
that configuring one service no longer overwrites another's settings.

Key changes:
- service/options.go: remove all DefaultXxx global writes from option
  functions; newOptions() now creates fresh Server, Client, Store, and
  Cache per service while sharing Registry, Broker, and Transport
- cmd/cmd.go: newCmd() uses local copies instead of pointers to package
  globals; Before() no longer mutates DefaultXxx vars
- cmd/options.go: remove global mutations from all option functions
- service/service.go: export ServiceImpl type for cross-package use
- service/group.go: new Group type for multi-service lifecycle
- micro.go: add Start/Stop to Service interface, expose Group and
  NewGroup convenience function
- examples/multi-service: working example with two services

https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc

* docs: highlight multi-service binary support

Add multi-service section to README with code example, update features
list, add to examples index, and note in status summary.

https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc

* feat: unify service API and clean up developer experience

- Unified service creation: micro.New("name", opts...) as canonical API
- Clean handler registration: service.Handle(handler, opts...) accepts
  server.HandlerOption args directly, no need to reach through Server()
- Unexported serviceImpl: users interact through Service interface only
- Service groups use Service interface (not concrete type)
- Fixed Stop() to properly propagate BeforeStop/AfterStop errors
- Fixed store init: error-level log instead of fatal on init failure
- Updated all examples to use consistent patterns
- Updated README, getting-started, MCP docs, and guides
- Added blog post about the DX cleanup

https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc

* fix: add blog post 5 to blog index

Blog post 5 (Developer Experience Cleanup) existed as a file but was
missing from the blog index page.

https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc

* feat: make micro new generate MCP-enabled services by default

- main.go template includes mcp.WithMCP(":3001") by default
- Handler template has agent-friendly doc comments with @example tags
- Proto template has descriptive field comments
- README includes MCP usage, Claude Code config, and tool description tips
- Makefile adds mcp-tools, mcp-test, mcp-serve targets
- go.mod updated to Go 1.22
- Added --no-mcp flag to opt out of MCP integration
- Post-create output shows MCP endpoint URLs

https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc

* docs: add MCP migration guide and troubleshooting guide

- Migration guide: 3 approaches to add MCP to existing services
  (WithMCP one-liner, standalone gateway, CLI)
- Troubleshooting guide: common issues with agents, WebSocket,
  Claude Code, auth, rate limiting, and performance

https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc

* refactor: rename model/ package to ai/ for AI model providers

The model/ package name conflicted with the conventional use of "model"
for data models. Renamed to ai/ which better describes the package's
purpose (AI provider abstraction for Anthropic, OpenAI, etc.) and frees
up model/ for future data model layer use.

- Rename model/ → ai/ with package name change
- Update all Go imports from go-micro.dev/v5/model to go-micro.dev/v5/ai
- Update cmd/micro/server/server.go references (model.X → ai.X)
- Update all documentation and roadmap references
- All tests pass, CLI builds successfully

https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc

* feat: add model package for typed data access with CRUD and queries

New model/ package provides a typed data model layer using Go generics.
Supports structured CRUD operations, WHERE filters, ordering, pagination,
and automatic schema creation from struct tags.

Three backends:
- memory: in-memory for development and testing
- sqlite: embedded SQL for dev and single-node production
- postgres: full PostgreSQL for production deployments

Key features:
- Generic Model[T] with Create/Read/Update/Delete/List/Count
- Query builder: Where(), WhereOp(), OrderAsc/Desc(), Limit(), Offset()
- Struct tags: model:"key" for primary key, model:"index" for indexes
- Auto table creation from struct schema
- 19 tests passing across memory and sqlite backends

https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc

* feat: add model code generation to protoc-gen-micro

Extend the micro plugin to generate model structs from proto messages
annotated with // @model. Generated alongside client/server code in
the same .pb.micro.go file.

For a proto message like:
  // @model
  message User { string id = 1; string name = 2; }

Generates:
- UserModel struct with model:"key" and json tags
- NewUserModel(db) factory returning *model.Model[UserModel]
- UserModelFromProto(*User) *UserModel converter
- (*UserModel).ToProto() *User converter

Supports @model(table=custom_table, key=custom_field) options.
Adds GetComments() to generator for plugin comment inspection.

https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc

* feat: add Model() to Service interface for Client/Server/Model trifecta

Every service now exposes Client(), Server(), and Model() — call services,
handle requests, and save/query data from the same interface. Includes
README docs, blog post, and a full model guide on the docs site.

https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Asim Aslam
2026-03-04 13:13:34 +00:00
committed by GitHub
co-authored by Claude
parent f07a49e0d9
commit 76bfeae456
39 changed files with 3539 additions and 523 deletions
+8 -2
View File
@@ -14,6 +14,7 @@ make test
# Run tests for a specific package
go test ./gateway/mcp/...
go test ./ai/...
go test ./model/...
# Lint
@@ -48,9 +49,13 @@ go-micro/
├── health/ # Health checking
├── logger/ # Logging
├── metadata/ # Context metadata
├── model/ # AI model providers
├── ai/ # AI model providers
│ ├── anthropic/ # Claude provider
│ └── openai/ # GPT provider
├── model/ # Typed data models (CRUD, queries, schemas)
│ ├── memory/ # In-memory backend (dev/testing)
│ ├── sqlite/ # SQLite backend (dev/single-node)
│ └── postgres/ # PostgreSQL backend (production)
├── registry/ # Service discovery (mDNS, Consul, etcd)
├── selector/ # Client-side load balancing
├── server/ # RPC server
@@ -118,7 +123,8 @@ Build compelling demos showing agents interacting with go-micro services in real
|---------|------|
| MCP Gateway | `gateway/mcp/mcp.go` |
| MCP Docs | `gateway/mcp/DOCUMENTATION.md` |
| Model Interface | `model/model.go` |
| AI Interface | `ai/model.go` |
| Model Layer | `model/model.go` |
| CLI Entry | `cmd/micro/main.go` |
| MCP CLI | `cmd/micro/mcp/` |
| Server (run/server) | `cmd/micro/server/server.go` |
+73
View File
@@ -24,6 +24,10 @@ Go Micro abstracts away the details of distributed systems. Here are the main fe
- **Data Storage** - A simple data store interface to read, write and delete records. It includes support for many storage backends
in the plugins repo. State and persistence becomes a core requirement beyond prototyping and Micro looks to build that into the framework.
- **Data Model** - A typed data model layer with CRUD operations, queries, and multiple backends (memory, SQLite, Postgres). Define Go
structs with tags and get type-safe Create/Read/Update/Delete/List/Count operations. Accessible via `service.Model()` alongside
`service.Client()` and `service.Server()` for a complete service experience: call services, handle requests, save and query data.
- **Service Discovery** - Automatic service registration and name resolution. Service discovery is at the core of micro service
development. When service A needs to speak to service B it needs the location of that service. The default discovery mechanism is
multicast DNS (mdns), a zeroconf system.
@@ -165,6 +169,74 @@ Each service gets its own server, client, store, and cache while sharing the reg
See the [multi-service example](examples/multi-service/) for a working demo.
## Data Model
Go Micro includes a typed data model layer for persistence. Define a struct, tag a key field, and get type-safe CRUD and query operations backed by memory, SQLite, or Postgres.
```go
import (
"go-micro.dev/v5/model"
"go-micro.dev/v5/model/sqlite"
)
// Define your data type
type User struct {
ID string `json:"id" model:"key"`
Name string `json:"name"`
Email string `json:"email" model:"index"`
Age int `json:"age"`
}
```
Create a model from the service's database and use it:
```go
service := micro.New("users")
// Create a typed model using the service's database
users := model.New[User](service.Model())
// CRUD operations
users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30})
user, _ := users.Read(ctx, "1")
user.Name = "Alice Smith"
users.Update(ctx, user)
users.Delete(ctx, "1")
```
Query with filters, ordering, and pagination:
```go
// Find users by field
results, _ := users.List(ctx, model.Where("email", "alice@example.com"))
// Complex queries
results, _ = users.List(ctx,
model.WhereOp("age", ">=", 18),
model.OrderDesc("name"),
model.Limit(10),
model.Offset(20),
)
count, _ := users.Count(ctx, model.Where("age", 30))
```
Swap backends with an option:
```go
// Development: in-memory (default)
service := micro.New("users")
// Production: SQLite or Postgres
db, _ := sqlite.New(model.WithDSN("file:app.db"))
service := micro.New("users", micro.Model(db))
```
Every service gets `Client()`, `Server()`, and `Model()` — call services, handle requests, and save data all from the same interface.
## Examples
Check out [/examples](examples/) for runnable code:
@@ -296,6 +368,7 @@ Package reference: https://pkg.go.dev/go-micro.dev/v5
**User Guides:**
- [Getting Started](internal/website/docs/getting-started.md)
- [Data Model](internal/website/docs/model.md)
- [MCP & AI Agents](internal/website/docs/mcp.md)
- [Plugins Overview](internal/website/docs/plugins.md)
- [Learn by Example](internal/website/docs/examples/index.md)
+4 -4
View File
@@ -17,11 +17,11 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
- [ ] Plugin discovery dashboard
### AI & Model Integration
- [x] Model package with provider abstraction (`model.Model` interface)
- [x] Anthropic Claude provider (`model/anthropic`)
- [x] OpenAI GPT provider (`model/openai`)
- [x] AI package with provider abstraction (`ai.Model` interface)
- [x] Anthropic Claude provider (`ai/anthropic`)
- [x] OpenAI GPT provider (`ai/openai`)
- [x] Tool execution with auto-calling support
- [x] Streaming support via `model.Stream`
- [x] Streaming support via `ai.Stream`
### Observability
- [ ] OpenTelemetry native support
+268
View File
@@ -0,0 +1,268 @@
# AI Package
The `ai` package provides a simple, high-level interface for AI model providers like Anthropic Claude and OpenAI GPT.
## Interface
The Model interface follows the same patterns as other go-micro packages (Registry, Client, Broker):
```go
type Model interface {
Init(...Option) error
Options() Options
Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error)
Stream(ctx context.Context, req *Request, opts ...GenerateOption) (Stream, error)
String() string
}
```
## Quick Start
```go
import (
"context"
"go-micro.dev/v5/ai"
_ "go-micro.dev/v5/ai/anthropic"
_ "go-micro.dev/v5/ai/openai"
)
// Create a model
m := ai.New("openai",
ai.WithAPIKey("your-api-key"),
ai.WithModel("gpt-4o"),
)
// Generate a response
req := &ai.Request{
Prompt: "What is Go?",
SystemPrompt: "You are a helpful programming assistant",
}
resp, err := m.Generate(context.Background(), req)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Reply)
```
## Options
Configure the model using functional options:
```go
m := ai.New("anthropic",
ai.WithAPIKey("your-key"), // Required
ai.WithModel("claude-sonnet-4-20250514"), // Optional, uses provider default
ai.WithBaseURL("https://api.anthropic.com"), // Optional, uses provider default
)
```
You can also update options after creation:
```go
m.Init(
ai.WithModel("gpt-4o-mini"),
ai.WithAPIKey("new-key"),
)
```
## Using Tools
The model can automatically execute tool calls when provided with a tool handler:
```go
// Define a tool handler
toolHandler := func(name string, input map[string]any) (result any, content string) {
// Execute the tool and return results
switch name {
case "get_weather":
return map[string]string{"temp": "72F"}, `{"temp": "72F"}`
default:
return nil, `{"error": "unknown tool"}`
}
}
// Create model with tool handler
m := ai.New("openai",
ai.WithAPIKey("your-key"),
ai.WithToolHandler(toolHandler),
)
// Provide tools in the request
req := &ai.Request{
Prompt: "What's the weather?",
SystemPrompt: "You are a helpful assistant",
Tools: []ai.Tool{
{
Name: "get_weather",
Description: "Get current weather",
Properties: map[string]any{
"location": map[string]any{
"type": "string",
"description": "City name",
},
},
},
},
}
// Generate will automatically call tools and return final answer
resp, err := m.Generate(context.Background(), req)
fmt.Println(resp.Answer) // Final answer after tool execution
```
## Response Structure
```go
type Response struct {
Reply string // Initial reply from model
ToolCalls []ToolCall // Tools the model wants to call
Answer string // Final answer (after tool execution if handler provided)
}
```
- `Reply`: The model's first response
- `ToolCalls`: List of tools the model requested (if any)
- `Answer`: The final answer after tools are executed (only set if ToolHandler is provided)
## Supported Providers
### Anthropic Claude
```go
m := ai.New("anthropic",
ai.WithAPIKey("sk-ant-..."),
ai.WithModel("claude-sonnet-4-20250514"), // default
)
```
Default model: `claude-sonnet-4-20250514`
Default base URL: `https://api.anthropic.com`
### OpenAI GPT
```go
m := ai.New("openai",
ai.WithAPIKey("sk-..."),
ai.WithModel("gpt-4o"), // default
)
```
Default model: `gpt-4o`
Default base URL: `https://api.openai.com`
## Auto-Detection
Use `AutoDetectProvider()` to detect the provider from a base URL:
```go
provider := ai.AutoDetectProvider("https://api.anthropic.com")
// Returns "anthropic"
m := ai.New(provider, ai.WithAPIKey("..."))
```
## Adding a New Provider
1. Create a new package under `ai/`:
```go
package myprovider
import "go-micro.dev/v5/ai"
func init() {
ai.Register("myprovider", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
}
type Provider struct {
opts ai.Options
}
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
// Set defaults
if options.Model == "" {
options.Model = "my-default-model"
}
if options.BaseURL == "" {
options.BaseURL = "https://api.myprovider.com"
}
return &Provider{opts: options}
}
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
func (p *Provider) Options() ai.Options {
return p.opts
}
func (p *Provider) String() string {
return "myprovider"
}
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
// Implement your provider logic
// - Build API request
// - Make HTTP call
// - Parse response
// - Handle tools if ToolHandler is set
return &ai.Response{}, nil
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not implemented")
}
```
2. Import your provider:
```go
import _ "go-micro.dev/v5/ai/myprovider"
```
## Comparison with Other Packages
The ai package follows the same patterns as other go-micro packages:
**Registry:**
```go
r := registry.NewRegistry(registry.Addrs("..."))
r.Register(service)
```
**Client:**
```go
c := client.NewClient(client.Retries(3))
c.Call(ctx, req, rsp)
```
**AI:**
```go
m := ai.New("openai", ai.WithAPIKey("..."))
m.Generate(ctx, req)
```
All use:
- `Init()` to update options
- `Options()` to get current options
- `String()` to get the implementation name
- Functional options pattern
## Testing
```bash
go test ./ai/...
```
## Examples
See the [server implementation](../cmd/micro/server/server.go) for a complete example of using the ai package with tool execution.
@@ -10,24 +10,24 @@ import (
"net/http"
"strings"
"go-micro.dev/v5/model"
"go-micro.dev/v5/ai"
)
func init() {
model.Register("anthropic", func(opts ...model.Option) model.Model {
ai.Register("anthropic", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
}
// Provider implements the model.Model interface for Anthropic Claude
// Provider implements the ai.Model interface for Anthropic Claude
type Provider struct {
opts model.Options
opts ai.Options
}
// NewProvider creates a new Anthropic provider
func NewProvider(opts ...model.Option) *Provider {
options := model.NewOptions(opts...)
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
// Set defaults if not provided
if options.Model == "" {
options.Model = "claude-sonnet-4-20250514"
@@ -35,14 +35,14 @@ func NewProvider(opts ...model.Option) *Provider {
if options.BaseURL == "" {
options.BaseURL = "https://api.anthropic.com"
}
return &Provider{
opts: options,
}
}
// Init initializes the provider with options
func (p *Provider) Init(opts ...model.Option) error {
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
@@ -50,7 +50,7 @@ func (p *Provider) Init(opts ...model.Option) error {
}
// Options returns the provider options
func (p *Provider) Options() model.Options {
func (p *Provider) Options() ai.Options {
return p.opts
}
@@ -60,7 +60,7 @@ func (p *Provider) String() string {
}
// Generate generates a response from the model
func (p *Provider) Generate(ctx context.Context, req *model.Request, opts ...model.GenerateOption) (*model.Response, error) {
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
// Build tools for Anthropic format
var anthropicTools []map[string]any
for _, t := range req.Tools {
@@ -101,10 +101,10 @@ func (p *Provider) Generate(ctx context.Context, req *model.Request, opts ...mod
// If tool handler is provided, execute tools and get final answer
if p.opts.ToolHandler != nil {
var toolResults []model.ToolResult
var toolResults []ai.ToolResult
for _, tc := range resp.ToolCalls {
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
toolResults = append(toolResults, model.ToolResult{
toolResults = append(toolResults, ai.ToolResult{
ID: tc.ID,
Content: content,
})
@@ -142,12 +142,12 @@ func (p *Provider) Generate(ctx context.Context, req *model.Request, opts ...mod
}
// Stream generates a streaming response (not yet implemented)
func (p *Provider) Stream(ctx context.Context, req *model.Request, opts ...model.GenerateOption) (model.Stream, error) {
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for anthropic provider")
}
// callAPI makes an HTTP request to the Anthropic API
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*model.Response, any, error) {
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, any, error) {
// Marshal request
reqBody, err := json.Marshal(req)
if err != nil {
@@ -195,7 +195,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*model.Resp
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
response := &model.Response{}
response := &ai.Response{}
// Extract text reply
var replyParts []string
@@ -215,7 +215,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*model.Resp
if err := json.Unmarshal(block.Input, &input); err != nil {
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, model.ToolCall{
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: block.ID,
Name: block.Name,
Input: input,
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"go-micro.dev/v5/model"
"go-micro.dev/v5/ai"
)
func TestProvider_String(t *testing.T) {
@@ -16,17 +16,17 @@ func TestProvider_String(t *testing.T) {
func TestProvider_Init(t *testing.T) {
p := NewProvider()
err := p.Init(
model.WithModel("test-model"),
model.WithAPIKey("test-key"),
model.WithBaseURL("https://test.com"),
ai.WithModel("test-model"),
ai.WithAPIKey("test-key"),
ai.WithBaseURL("https://test.com"),
)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
opts := p.Options()
if opts.Model != "test-model" {
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
@@ -41,10 +41,10 @@ func TestProvider_Init(t *testing.T) {
func TestProvider_Options(t *testing.T) {
p := NewProvider(
model.WithModel("custom-model"),
model.WithAPIKey("my-key"),
ai.WithModel("custom-model"),
ai.WithAPIKey("my-key"),
)
opts := p.Options()
if opts.Model != "custom-model" {
t.Errorf("Expected model 'custom-model', got '%s'", opts.Model)
@@ -56,7 +56,7 @@ func TestProvider_Options(t *testing.T) {
func TestProvider_Defaults(t *testing.T) {
p := NewProvider()
opts := p.Options()
if opts.Model != "claude-sonnet-4-20250514" {
t.Errorf("Expected default model 'claude-sonnet-4-20250514', got '%s'", opts.Model)
@@ -68,12 +68,12 @@ func TestProvider_Defaults(t *testing.T) {
func TestProvider_Generate_NoAPIKey(t *testing.T) {
p := NewProvider()
req := &model.Request{
req := &ai.Request{
Prompt: "Hello",
SystemPrompt: "You are helpful",
}
_, err := p.Generate(context.Background(), req)
if err == nil {
t.Error("Expected error when API key is missing, got nil")
@@ -82,11 +82,11 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
func TestProvider_Stream_NotImplemented(t *testing.T) {
p := NewProvider()
req := &model.Request{
req := &ai.Request{
Prompt: "Hello",
}
_, err := p.Stream(context.Background(), req)
if err == nil {
t.Error("Expected error for unimplemented streaming, got nil")
+130
View File
@@ -0,0 +1,130 @@
// Package ai provides abstraction for AI model providers
package ai
import (
"context"
"strings"
)
// Model provides an interface for interacting with AI model providers
type Model interface {
// Init initializes the model with options
Init(...Option) error
// Options returns the model options
Options() Options
// Generate generates a response from the model
Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error)
// Stream generates a streaming response (for future implementation)
Stream(ctx context.Context, req *Request, opts ...GenerateOption) (Stream, error)
// String returns the name of the provider
String() string
}
// Tool represents a tool/function that can be called by the model
type Tool struct {
Name string // LLM-safe name (e.g., "greeter_Greeter_Hello")
OriginalName string // Original name (e.g., "greeter.Greeter.Hello")
Description string
Properties map[string]any // JSON schema for tool parameters
}
// Request represents a request to generate content from a model
type Request struct {
// Prompt is the user's message/prompt
Prompt string
// SystemPrompt is the system instruction for the model
SystemPrompt string
// Tools available for the model to use
Tools []Tool
// Messages for continuing a conversation (optional)
Messages []Message
}
// Message represents a conversation message
type Message struct {
Role string // "user", "assistant", "system", "tool"
Content any // Can be string or structured content
}
// Response represents the response from a model
type Response struct {
// Reply is the text response from the model
Reply string
// ToolCalls are tool calls requested by the model
ToolCalls []ToolCall
// Answer is the final answer after tool execution (if tools were used)
Answer string
}
// ToolCall represents a request to call a tool
type ToolCall struct {
ID string // Tool call ID (for correlation)
Name string // Tool name
Input map[string]any // Tool input arguments
}
// ToolResult represents the result of a tool execution
type ToolResult struct {
ID string // Tool call ID (for correlation)
Content string // Tool execution result (JSON string)
}
// Stream is the interface for streaming responses (future implementation)
type Stream interface {
// Recv receives the next chunk of the response
Recv() (*Response, error)
// Close closes the stream
Close() error
}
// ToolHandler is a function that handles tool calls
type ToolHandler func(name string, input map[string]any) (result any, content string)
// NewFunc creates a new Model instance
type NewFunc func(...Option) Model
var providers = make(map[string]NewFunc)
// Register registers a model provider
func Register(name string, fn NewFunc) {
providers[name] = fn
}
// New creates a new Model instance based on the provider name
func New(provider string, opts ...Option) Model {
if fn, ok := providers[provider]; ok {
return fn(opts...)
}
// Default to first registered provider
if len(providers) > 0 {
for _, fn := range providers {
return fn(opts...)
}
}
return nil
}
// AutoDetectProvider attempts to detect the provider from the base URL
func AutoDetectProvider(baseURL string) string {
if baseURL == "" {
return "openai"
}
// Simple detection based on URL
if strings.Contains(baseURL, "anthropic") {
return "anthropic"
}
return "openai"
}
// DefaultModel is a default model instance
var DefaultModel Model
// Generate generates a response using the default model
func Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
if DefaultModel == nil {
return nil, nil
}
return DefaultModel.Generate(ctx, req, opts...)
}
+15 -15
View File
@@ -10,24 +10,24 @@ import (
"net/http"
"strings"
"go-micro.dev/v5/model"
"go-micro.dev/v5/ai"
)
func init() {
model.Register("openai", func(opts ...model.Option) model.Model {
ai.Register("openai", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
}
// Provider implements the model.Model interface for OpenAI
// Provider implements the ai.Model interface for OpenAI
type Provider struct {
opts model.Options
opts ai.Options
}
// NewProvider creates a new OpenAI provider
func NewProvider(opts ...model.Option) *Provider {
options := model.NewOptions(opts...)
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
// Set defaults if not provided
if options.Model == "" {
options.Model = "gpt-4o"
@@ -35,14 +35,14 @@ func NewProvider(opts ...model.Option) *Provider {
if options.BaseURL == "" {
options.BaseURL = "https://api.openai.com"
}
return &Provider{
opts: options,
}
}
// Init initializes the provider with options
func (p *Provider) Init(opts ...model.Option) error {
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
@@ -50,7 +50,7 @@ func (p *Provider) Init(opts ...model.Option) error {
}
// Options returns the provider options
func (p *Provider) Options() model.Options {
func (p *Provider) Options() ai.Options {
return p.opts
}
@@ -60,7 +60,7 @@ func (p *Provider) String() string {
}
// Generate generates a response from the model
func (p *Provider) Generate(ctx context.Context, req *model.Request, opts ...model.GenerateOption) (*model.Response, error) {
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
// Build tools for OpenAI format
var openaiTools []map[string]any
for _, t := range req.Tools {
@@ -138,12 +138,12 @@ func (p *Provider) Generate(ctx context.Context, req *model.Request, opts ...mod
}
// Stream generates a streaming response (not yet implemented)
func (p *Provider) Stream(ctx context.Context, req *model.Request, opts ...model.GenerateOption) (model.Stream, error) {
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for openai provider")
}
// callAPI makes an HTTP request to the OpenAI API
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*model.Response, map[string]any, error) {
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
// Marshal request
reqBody, err := json.Marshal(req)
if err != nil {
@@ -199,7 +199,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*model.Resp
}
choice := chatResp.Choices[0]
response := &model.Response{
response := &ai.Response{
Reply: choice.Message.Content,
}
@@ -209,7 +209,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*model.Resp
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, model.ToolCall{
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"go-micro.dev/v5/model"
"go-micro.dev/v5/ai"
)
func TestProvider_String(t *testing.T) {
@@ -16,17 +16,17 @@ func TestProvider_String(t *testing.T) {
func TestProvider_Init(t *testing.T) {
p := NewProvider()
err := p.Init(
model.WithModel("test-model"),
model.WithAPIKey("test-key"),
model.WithBaseURL("https://test.com"),
ai.WithModel("test-model"),
ai.WithAPIKey("test-key"),
ai.WithBaseURL("https://test.com"),
)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
opts := p.Options()
if opts.Model != "test-model" {
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
@@ -41,10 +41,10 @@ func TestProvider_Init(t *testing.T) {
func TestProvider_Options(t *testing.T) {
p := NewProvider(
model.WithModel("custom-model"),
model.WithAPIKey("my-key"),
ai.WithModel("custom-model"),
ai.WithAPIKey("my-key"),
)
opts := p.Options()
if opts.Model != "custom-model" {
t.Errorf("Expected model 'custom-model', got '%s'", opts.Model)
@@ -56,7 +56,7 @@ func TestProvider_Options(t *testing.T) {
func TestProvider_Defaults(t *testing.T) {
p := NewProvider()
opts := p.Options()
if opts.Model != "gpt-4o" {
t.Errorf("Expected default model 'gpt-4o', got '%s'", opts.Model)
@@ -68,12 +68,12 @@ func TestProvider_Defaults(t *testing.T) {
func TestProvider_Generate_NoAPIKey(t *testing.T) {
p := NewProvider()
req := &model.Request{
req := &ai.Request{
Prompt: "Hello",
SystemPrompt: "You are helpful",
}
_, err := p.Generate(context.Background(), req)
if err == nil {
t.Error("Expected error when API key is missing, got nil")
@@ -82,11 +82,11 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
func TestProvider_Stream_NotImplemented(t *testing.T) {
p := NewProvider()
req := &model.Request{
req := &ai.Request{
Prompt: "Hello",
}
_, err := p.Stream(context.Background(), req)
if err == nil {
t.Error("Expected error for unimplemented streaming, got nil")
+77
View File
@@ -0,0 +1,77 @@
package ai
import (
"context"
)
// Options for model configuration
type Options struct {
// Context for the model
Context context.Context
// Model name (e.g., "gpt-4o", "claude-sonnet-4-20250514")
Model string
// APIKey for authentication
APIKey string
// BaseURL for the API endpoint
BaseURL string
// ToolHandler handles tool calls (optional, for automatic tool execution)
ToolHandler ToolHandler
}
// GenerateOptions for generate call
type GenerateOptions struct {
// Context for this specific generate call
Context context.Context
}
// Option is a function that modifies Options
type Option func(*Options)
// GenerateOption is a function that modifies GenerateOptions
type GenerateOption func(*GenerateOptions)
// NewOptions creates new Options with defaults
func NewOptions(opts ...Option) Options {
options := Options{
Context: context.Background(),
}
for _, o := range opts {
o(&options)
}
return options
}
// WithModel sets the model name
func WithModel(m string) Option {
return func(o *Options) {
o.Model = m
}
}
// WithAPIKey sets the API key
func WithAPIKey(key string) Option {
return func(o *Options) {
o.APIKey = key
}
}
// WithBaseURL sets the base URL
func WithBaseURL(url string) Option {
return func(o *Options) {
o.BaseURL = url
}
}
// WithContext sets the context
func WithContext(ctx context.Context) Option {
return func(o *Options) {
o.Context = ctx
}
}
// WithToolHandler sets the tool handler
func WithToolHandler(handler ToolHandler) Option {
return func(o *Options) {
o.ToolHandler = handler
}
}
+14 -14
View File
@@ -28,9 +28,9 @@ import (
"go-micro.dev/v5/client"
"go-micro.dev/v5/cmd"
codecBytes "go-micro.dev/v5/codec/bytes"
"go-micro.dev/v5/model"
_ "go-micro.dev/v5/model/anthropic"
_ "go-micro.dev/v5/model/openai"
"go-micro.dev/v5/ai"
_ "go-micro.dev/v5/ai/anthropic"
_ "go-micro.dev/v5/ai/openai"
"go-micro.dev/v5/registry"
"go-micro.dev/v5/store"
"golang.org/x/crypto/bcrypt"
@@ -633,12 +633,12 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
// Auto-detect provider if not explicitly set
if provider == "" {
provider = model.AutoDetectProvider(baseURL)
provider = ai.AutoDetectProvider(baseURL)
}
// Discover tools from registry
services, _ := registry.ListServices()
var discoveredTools []model.Tool
var discoveredTools []ai.Tool
// safeNameMap maps LLM-safe names back to original dotted names
safeNameMap := map[string]string{}
for _, svc := range services {
@@ -665,7 +665,7 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
}
}
}
discoveredTools = append(discoveredTools, model.Tool{
discoveredTools = append(discoveredTools, ai.Tool{
Name: safeName,
OriginalName: tName,
Description: desc,
@@ -753,24 +753,24 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
}
// Create model with options
var modelOpts []model.Option
modelOpts = append(modelOpts, model.WithAPIKey(apiKey))
var modelOpts []ai.Option
modelOpts = append(modelOpts, ai.WithAPIKey(apiKey))
if modelName != "" {
modelOpts = append(modelOpts, model.WithModel(modelName))
modelOpts = append(modelOpts, ai.WithModel(modelName))
}
if baseURL != "" {
modelOpts = append(modelOpts, model.WithBaseURL(baseURL))
modelOpts = append(modelOpts, ai.WithBaseURL(baseURL))
}
modelOpts = append(modelOpts, model.WithToolHandler(executeToolCall))
m := model.New(provider, modelOpts...)
modelOpts = append(modelOpts, ai.WithToolHandler(executeToolCall))
m := ai.New(provider, modelOpts...)
if m == nil {
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to create model provider"})
return
}
// Build request
modelReq := &model.Request{
modelReq := &ai.Request{
Prompt: req.Prompt,
SystemPrompt: agentSystemPrompt,
Tools: discoveredTools,
@@ -0,0 +1,158 @@
// Code generated by protoc-gen-micro. DO NOT EDIT.
// source: user.proto
package user
import (
fmt "fmt"
proto "google.golang.org/protobuf/proto"
math "math"
)
import (
context "context"
client "go-micro.dev/v5/client"
server "go-micro.dev/v5/server"
model "go-micro.dev/v5/model"
)
// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
var _ = fmt.Errorf
var _ = math.Inf
// Reference imports to suppress errors if they are not otherwise used.
var _ context.Context
var _ client.Option
var _ server.Option
var _ model.Database
// Client API for UserService service
type UserServiceService interface {
Create(ctx context.Context, in *CreateUserRequest, opts ...client.CallOption) (*CreateUserResponse, error)
Get(ctx context.Context, in *GetUserRequest, opts ...client.CallOption) (*GetUserResponse, error)
Delete(ctx context.Context, in *DeleteUserRequest, opts ...client.CallOption) (*DeleteUserResponse, error)
}
type userServiceService struct {
c client.Client
name string
}
func NewUserServiceService(name string, c client.Client) UserServiceService {
return &userServiceService{
c: c,
name: name,
}
}
func (c *userServiceService) Create(ctx context.Context, in *CreateUserRequest, opts ...client.CallOption) (*CreateUserResponse, error) {
req := c.c.NewRequest(c.name, "UserService.Create", in)
out := new(CreateUserResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceService) Get(ctx context.Context, in *GetUserRequest, opts ...client.CallOption) (*GetUserResponse, error) {
req := c.c.NewRequest(c.name, "UserService.Get", in)
out := new(GetUserResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceService) Delete(ctx context.Context, in *DeleteUserRequest, opts ...client.CallOption) (*DeleteUserResponse, error) {
req := c.c.NewRequest(c.name, "UserService.Delete", in)
out := new(DeleteUserResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// Server API for UserService service
type UserServiceHandler interface {
Create(context.Context, *CreateUserRequest, *CreateUserResponse) error
Get(context.Context, *GetUserRequest, *GetUserResponse) error
Delete(context.Context, *DeleteUserRequest, *DeleteUserResponse) error
}
func RegisterUserServiceHandler(s server.Server, hdlr UserServiceHandler, opts ...server.HandlerOption) error {
type userService interface {
Create(ctx context.Context, in *CreateUserRequest, out *CreateUserResponse) error
Get(ctx context.Context, in *GetUserRequest, out *GetUserResponse) error
Delete(ctx context.Context, in *DeleteUserRequest, out *DeleteUserResponse) error
}
type UserService struct {
userService
}
h := &userServiceHandler{hdlr}
return s.Handle(s.NewHandler(&UserService{h}, opts...))
}
type userServiceHandler struct {
UserServiceHandler
}
func (h *userServiceHandler) Create(ctx context.Context, in *CreateUserRequest, out *CreateUserResponse) error {
return h.UserServiceHandler.Create(ctx, in, out)
}
func (h *userServiceHandler) Get(ctx context.Context, in *GetUserRequest, out *GetUserResponse) error {
return h.UserServiceHandler.Get(ctx, in, out)
}
func (h *userServiceHandler) Delete(ctx context.Context, in *DeleteUserRequest, out *DeleteUserResponse) error {
return h.UserServiceHandler.Delete(ctx, in, out)
}
// UserModel is a model struct generated from User.
// Use NewUserModel to create a typed model backed by any model.Database.
type UserModel struct {
Id string `json:"id" model:"key"`
Name string `json:"name"`
Email string `json:"email"`
Age int32 `json:"age"`
Status string `json:"status"`
}
// NewUserModel creates a typed model for User backed by the given database.
func NewUserModel(db model.Database) *model.Model[UserModel] {
return model.New[UserModel](db, model.WithTable("users"))
}
// UserModelFromProto converts a User proto message to a UserModel.
func UserModelFromProto(p *User) *UserModel {
if p == nil {
return nil
}
return &UserModel{
Id: p.GetId(),
Name: p.GetName(),
Email: p.GetEmail(),
Age: p.GetAge(),
Status: p.GetStatus(),
}
}
// ToProto converts a UserModel to a User proto message.
func (m *UserModel) ToProto() *User {
if m == nil {
return nil
}
return &User{
Id: m.Id,
Name: m.Name,
Email: m.Email,
Age: m.Age,
Status: m.Status,
}
}
@@ -0,0 +1,41 @@
syntax = "proto3";
option go_package = "../user";
// UserService manages user accounts.
service UserService {
rpc Create(CreateUserRequest) returns (CreateUserResponse) {}
rpc Get(GetUserRequest) returns (GetUserResponse) {}
rpc Delete(DeleteUserRequest) returns (DeleteUserResponse) {}
}
// @model
message User {
string id = 1;
string name = 2;
string email = 3;
int32 age = 4;
string status = 5;
}
message CreateUserRequest {
User user = 1;
}
message CreateUserResponse {
User user = 1;
}
message GetUserRequest {
string id = 1;
}
message GetUserResponse {
User user = 1;
}
message DeleteUserRequest {
string id = 1;
}
message DeleteUserResponse {}
@@ -1193,6 +1193,15 @@ func (g *Generator) PrintComments(path string) bool {
return false
}
// GetComments returns the raw leading comment text for the given path, if any.
func (g *Generator) GetComments(path string) (string, bool) {
loc, ok := g.file.comments[path]
if !ok {
return "", false
}
return loc.GetLeadingComments(), true
}
// makeComments generates the comment string for the field, no "\n" at the end
func (g *Generator) makeComments(path string) (string, bool) {
loc, ok := g.file.comments[path]
+230 -6
View File
@@ -18,6 +18,7 @@ const (
contextPkgPath = "context"
clientPkgPath = "go-micro.dev/v5/client"
serverPkgPath = "go-micro.dev/v5/server"
modelPkgPath = "go-micro.dev/v5/model"
)
func init() {
@@ -42,6 +43,7 @@ var (
contextPkg string
clientPkg string
serverPkg string
modelPkg string
pkgImports map[generator.GoPackageName]bool
)
@@ -51,6 +53,7 @@ func (g *micro) Init(gen *generator.Generator) {
contextPkg = generator.RegisterUniquePackageName("context", nil)
clientPkg = generator.RegisterUniquePackageName("client", nil)
serverPkg = generator.RegisterUniquePackageName("server", nil)
modelPkg = generator.RegisterUniquePackageName("model", nil)
}
// Given a type name defined in a .proto, return its object.
@@ -70,29 +73,66 @@ func (g *micro) P(args ...interface{}) { g.gen.P(args...) }
// Generate generates code for the services in the given file.
func (g *micro) Generate(file *generator.FileDescriptor) {
if len(file.FileDescriptorProto.Service) == 0 {
// Check if any messages have @model annotation
hasModels := false
for i := range file.FileDescriptorProto.MessageType {
if g.isModelMessage(i) {
hasModels = true
break
}
}
if len(file.FileDescriptorProto.Service) == 0 && !hasModels {
return
}
g.P("// Reference imports to suppress errors if they are not otherwise used.")
g.P("var _ ", contextPkg, ".Context")
g.P("var _ ", clientPkg, ".Option")
g.P("var _ ", serverPkg, ".Option")
if len(file.FileDescriptorProto.Service) > 0 {
g.P("var _ ", clientPkg, ".Option")
g.P("var _ ", serverPkg, ".Option")
}
if hasModels {
g.P("var _ ", modelPkg, ".Database")
}
g.P()
for i, service := range file.FileDescriptorProto.Service {
g.generateService(file, service, i)
}
// Generate model structs for @model annotated messages
for i, msg := range file.FileDescriptorProto.MessageType {
if g.isModelMessage(i) {
g.generateModel(msg, i)
}
}
}
// GenerateImports generates the import declaration for this file.
func (g *micro) GenerateImports(file *generator.FileDescriptor, imports map[generator.GoImportPath]generator.GoPackageName) {
if len(file.FileDescriptorProto.Service) == 0 {
hasServices := len(file.FileDescriptorProto.Service) > 0
hasModels := false
for i := range file.FileDescriptorProto.MessageType {
if g.isModelMessage(i) {
hasModels = true
break
}
}
if !hasServices && !hasModels {
return
}
g.P("import (")
g.P(contextPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, contextPkgPath)))
g.P(clientPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, clientPkgPath)))
g.P(serverPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, serverPkgPath)))
if hasServices {
g.P(clientPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, clientPkgPath)))
g.P(serverPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, serverPkgPath)))
}
if hasModels {
g.P(modelPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, modelPkgPath)))
}
g.P(")")
g.P()
@@ -529,3 +569,187 @@ func (g *micro) generateServerMethod(servName string, method *pb.MethodDescripto
return hname
}
// isModelMessage checks if the message at the given index has a // @model annotation.
// Path "4,<index>" refers to message_type[index] in FileDescriptorProto.
func (g *micro) isModelMessage(msgIndex int) bool {
commentPath := fmt.Sprintf("4,%d", msgIndex)
comment, ok := g.gen.GetComments(commentPath)
if !ok {
return false
}
return strings.Contains(comment, "@model")
}
// parseModelOptions extracts options from the @model annotation comment.
// Supports: @model, @model(table=my_table), @model(key=custom_id)
func parseModelOptions(comment string) (table string, key string) {
idx := strings.Index(comment, "@model")
if idx < 0 {
return "", ""
}
rest := comment[idx+len("@model"):]
rest = strings.TrimSpace(rest)
if !strings.HasPrefix(rest, "(") {
return "", ""
}
end := strings.Index(rest, ")")
if end < 0 {
return "", ""
}
opts := rest[1:end]
for _, part := range strings.Split(opts, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch strings.TrimSpace(kv[0]) {
case "table":
table = strings.TrimSpace(kv[1])
case "key":
key = strings.TrimSpace(kv[1])
}
}
return table, key
}
// protoFieldGoType returns the Go type string for a proto field for use in model structs.
// Only supports scalar types (no nested messages or enums in model structs).
func protoFieldGoType(field *pb.FieldDescriptorProto) string {
switch field.GetType() {
case pb.FieldDescriptorProto_TYPE_DOUBLE:
return "float64"
case pb.FieldDescriptorProto_TYPE_FLOAT:
return "float32"
case pb.FieldDescriptorProto_TYPE_INT64, pb.FieldDescriptorProto_TYPE_SINT64, pb.FieldDescriptorProto_TYPE_SFIXED64:
return "int64"
case pb.FieldDescriptorProto_TYPE_UINT64, pb.FieldDescriptorProto_TYPE_FIXED64:
return "uint64"
case pb.FieldDescriptorProto_TYPE_INT32, pb.FieldDescriptorProto_TYPE_SINT32, pb.FieldDescriptorProto_TYPE_SFIXED32:
return "int32"
case pb.FieldDescriptorProto_TYPE_UINT32, pb.FieldDescriptorProto_TYPE_FIXED32:
return "uint32"
case pb.FieldDescriptorProto_TYPE_BOOL:
return "bool"
case pb.FieldDescriptorProto_TYPE_STRING:
return "string"
case pb.FieldDescriptorProto_TYPE_BYTES:
return "[]byte"
default:
return "string"
}
}
// generateModel generates the model struct, factory, and proto conversion for a message.
func (g *micro) generateModel(msg *pb.DescriptorProto, msgIndex int) {
msgName := generator.CamelCase(msg.GetName())
modelName := msgName + "Model"
// Parse options from comment
commentPath := fmt.Sprintf("4,%d", msgIndex)
comment, _ := g.gen.GetComments(commentPath)
tableName, keyField := parseModelOptions(comment)
// Default table: lowercase message name + "s"
if tableName == "" {
tableName = strings.ToLower(msg.GetName()) + "s"
}
// Default key: first field, or "id" if a field named "id" exists
if keyField == "" {
for _, field := range msg.Field {
if field.GetName() == "id" {
keyField = "id"
break
}
}
if keyField == "" && len(msg.Field) > 0 {
keyField = msg.Field[0].GetName()
}
}
// Filter to scalar fields only (skip nested messages, maps, oneofs)
type modelField struct {
goName string
jsonName string
goType string
isKey bool
proto *pb.FieldDescriptorProto
}
var fields []modelField
for _, field := range msg.Field {
ft := field.GetType()
// Skip message and enum types (not directly storable as scalars)
if ft == pb.FieldDescriptorProto_TYPE_MESSAGE || ft == pb.FieldDescriptorProto_TYPE_GROUP {
continue
}
// Skip repeated fields (slices aren't directly storable)
if field.GetLabel() == pb.FieldDescriptorProto_LABEL_REPEATED {
continue
}
goName := generator.CamelCase(field.GetName())
jsonName := field.GetJsonName()
if jsonName == "" {
jsonName = field.GetName()
}
fields = append(fields, modelField{
goName: goName,
jsonName: jsonName,
goType: protoFieldGoType(field),
isKey: field.GetName() == keyField,
proto: field,
})
}
if len(fields) == 0 {
return
}
// Generate model struct
g.P()
g.P("// ", modelName, " is a model struct generated from ", msgName, ".")
g.P("// Use New", modelName, " to create a typed model backed by any model.Database.")
g.P("type ", modelName, " struct {")
for _, f := range fields {
tags := fmt.Sprintf("`json:%q", f.jsonName)
if f.isKey {
tags += ` model:"key"`
}
tags += "`"
g.P(f.goName, " ", f.goType, " ", tags)
}
g.P("}")
g.P()
// Generate factory: NewXModel(db) *model.Model[XModel]
g.P("// New", modelName, " creates a typed model for ", msgName, " backed by the given database.")
g.P("func New", modelName, "(db ", modelPkg, ".Database) *", modelPkg, ".Model[", modelName, "] {")
g.P("return ", modelPkg, ".New[", modelName, "](db, ", modelPkg, `.WithTable("`, tableName, `"))`)
g.P("}")
g.P()
// Generate FromProto: XModelFromProto(*X) *XModel
g.P("// ", modelName, "FromProto converts a ", msgName, " proto message to a ", modelName, ".")
g.P("func ", modelName, "FromProto(p *", msgName, ") *", modelName, " {")
g.P("if p == nil { return nil }")
g.P("return &", modelName, "{")
for _, f := range fields {
getter := "Get" + f.goName
g.P(f.goName, ": p.", getter, "(),")
}
g.P("}")
g.P("}")
g.P()
// Generate ToProto: (*XModel).ToProto() *X
g.P("// ToProto converts a ", modelName, " to a ", msgName, " proto message.")
g.P("func (m *", modelName, ") ToProto() *", msgName, " {")
g.P("if m == nil { return nil }")
g.P("return &", msgName, "{")
for _, f := range fields {
g.P(f.goName, ": m.", f.goName, ",")
}
g.P("}")
g.P("}")
g.P()
}
@@ -0,0 +1,37 @@
package micro
import "testing"
func TestParseModelOptions(t *testing.T) {
tests := []struct {
comment string
wantTable string
wantKey string
}{
{" @model\n", "", ""},
{" @model(table=app_users)\n", "app_users", ""},
{" @model(key=user_id)\n", "", "user_id"},
{" @model(table=users, key=user_id)\n", "users", "user_id"},
{" some description\n @model(table=items)\n", "items", ""},
{" no annotation here\n", "", ""},
}
for _, tt := range tests {
table, key := parseModelOptions(tt.comment)
if table != tt.wantTable {
t.Errorf("parseModelOptions(%q): table = %q, want %q", tt.comment, table, tt.wantTable)
}
if key != tt.wantKey {
t.Errorf("parseModelOptions(%q): key = %q, want %q", tt.comment, key, tt.wantKey)
}
}
}
func TestProtoFieldGoType(t *testing.T) {
// Smoke test - just verify it doesn't panic with nil
typ := protoFieldGoType(nil)
if typ != "string" {
// nil field returns default based on zero value TYPE_DOUBLE=0
t.Logf("protoFieldGoType(nil) = %q", typ)
}
}
+1
View File
@@ -85,6 +85,7 @@ require (
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/minio/highwayhash v1.0.3 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
+2
View File
@@ -238,6 +238,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/micro/plugins/v5/auth/jwt v0.0.0-20250502062951-be3f35ce6464 h1:einNYloNFQ4h52c0CBvWv67frSq1xS0EUXCf1ncr1UM=
github.com/micro/plugins/v5/auth/jwt v0.0.0-20250502062951-be3f35ce6464/go.mod h1:Mqqsr1LYrIiAuqKUI/C0sJRoIB80SATNBagcXjqK7oQ=
+8 -8
View File
@@ -3,7 +3,7 @@
## Executive Summary
**Go Micro's MCP integration is 3-4 months ahead of schedule**, with Q1 2026 complete, most Q2 2026 features delivered, and core Q3 security features already in production. The model package now provides a unified AI provider interface (Anthropic + OpenAI) powering the agent playground.
**Go Micro's MCP integration is 3-4 months ahead of schedule**, with Q1 2026 complete, most Q2 2026 features delivered, and core Q3 security features already in production. The ai package now provides a unified AI provider interface (Anthropic + OpenAI) powering the agent playground.
### Quick Status
- **Q1 2026 (MCP Foundation):** COMPLETE (100%)
@@ -73,13 +73,13 @@
- Struct tag parsing for parameter descriptions
- Manual override via `WithEndpointDocs()`
### Model Package (NEW - February 2026)
- **`model.Model` interface** - Unified AI provider abstraction
### AI Package (NEW - February 2026)
- **`ai.Model` interface** - Unified AI provider abstraction
- `Generate()` for request/response
- `Stream()` for streaming responses
- Tool execution with auto-calling support
- **Anthropic Claude provider** (`model/anthropic`)
- **OpenAI GPT provider** (`model/openai`)
- **Anthropic Claude provider** (`ai/anthropic`)
- **OpenAI GPT provider** (`ai/openai`)
- Provider auto-detection from base URL
- Powers the agent playground in `micro run`
@@ -244,7 +244,7 @@ Build compelling examples and demos that show agents interacting with go-micro s
- WebSocket transport (bidirectional JSON-RPC 2.0)
- LangChain SDK (Python package in contrib/)
- LlamaIndex SDK (Python package in contrib/ with RAG examples)
- Model package with Anthropic + OpenAI providers
- AI package with Anthropic + OpenAI providers
**REMAINING:**
- Agent SDKs (AutoGPT)
@@ -281,7 +281,7 @@ Build compelling examples and demos that show agents interacting with go-micro s
2. **[ROADMAP_2026.md](./ROADMAP_2026.md)** - AI-native roadmap with business model
3. **[/gateway/mcp/DOCUMENTATION.md](./gateway/mcp/DOCUMENTATION.md)** - Complete MCP documentation
4. **[/examples/mcp/README.md](./examples/mcp/README.md)** - Examples and usage guide
5. **[/model/README.md](./model/README.md)** - Model package documentation
5. **[/ai/README.md](./ai/README.md)** - AI package documentation
---
@@ -291,7 +291,7 @@ Build compelling examples and demos that show agents interacting with go-micro s
2. **Security-First** - Auth, scopes, audit from day one
3. **Developer-Friendly** - 3 lines of code to enable MCP
4. **Claude Code Ready** - Works with Anthropic's flagship IDE
5. **Unified AI Model Interface** - Anthropic + OpenAI with tool auto-calling
5. **Unified AI Interface** - Anthropic + OpenAI with tool auto-calling
6. **Comprehensive Testing** - 90%+ test coverage
7. **Well-Documented** - 90+ docs, examples, and blog post
+4 -4
View File
@@ -21,7 +21,7 @@ The **Q1 2026: MCP Foundation** milestone is **COMPLETE** with significant progr
| **CLI Integration** | ✅ COMPLETE | 100% |
| **CLI Export Commands (Q2 Feature)** | ✅ COMPLETE | 100% |
| **LangChain SDK (Q2 Feature)** | ✅ COMPLETE | 100% |
| **Model Package (Q2 Feature)** | ✅ COMPLETE | 100% |
| **AI Package (Q2 Feature)** | ✅ COMPLETE | 100% |
| **Documentation Extraction** | ✅ COMPLETE | 100% |
| **Tracing & Audit** | ✅ COMPLETE | 100% |
| **Rate Limiting** | ✅ COMPLETE | 100% |
@@ -237,7 +237,7 @@ This was planned for Q2 2026 but has been fully implemented:
})
```
### ✅ Model Package (Q2 2026 Feature)
### ✅ AI Package (Q2 2026 Feature)
**Status:** COMPLETE (February 2026)
@@ -257,8 +257,8 @@ This was delivered as part of the agent integration push:
```
2. **Providers:**
- Anthropic Claude (`model/anthropic`) - Default: claude-sonnet-4-20250514
- OpenAI GPT (`model/openai`) - Default: gpt-4o
- Anthropic Claude (`ai/anthropic`) - Default: claude-sonnet-4-20250514
- OpenAI GPT (`ai/openai`) - Default: gpt-4o
- Provider auto-detection from base URL
3. **Tool Execution:**
+5 -5
View File
@@ -163,14 +163,14 @@ Create official SDKs for popular agent frameworks:
- [x] Example: Multi-agent workflow with go-micro services
- [x] Published to contrib/langchain-go-micro/
#### Model Package ✅ COMPLETE
- [x] `model.Model` interface with Generate and Stream
- [x] Anthropic Claude provider (`model/anthropic`)
- [x] OpenAI GPT provider (`model/openai`)
#### AI Package ✅ COMPLETE
- [x] `ai.Model` interface with Generate and Stream
- [x] Anthropic Claude provider (`ai/anthropic`)
- [x] OpenAI GPT provider (`ai/openai`)
- [x] Tool execution with auto-calling support
- [x] Provider auto-detection from base URL
**Why:** The model package powers the agent playground and enables services to call AI models directly.
**Why:** The ai package powers the agent playground and enables services to call AI models directly.
#### LlamaIndex Integration ✅ COMPLETE
- [x] `go-micro-llamaindex` package
+159
View File
@@ -0,0 +1,159 @@
---
layout: blog
title: "The Model Package: Client, Server, and Now Data"
permalink: /blog/6
description: "Go Micro now has a typed data model layer — define structs, get CRUD and queries, swap backends. Every service gets Client, Server, and Model."
---
# The Model Package: Client, Server, and Now Data
*March 4, 2026 — By the Go Micro Team*
Go Micro has always given you `service.Client()` to call other services and `service.Server()` to handle requests. But most services also need to save and query data. Until now, that meant either using the low-level `store` package (key-value only) or wiring up your own database layer.
Today we're shipping the `model` package — a typed data model layer that completes the service trifecta: **Client, Server, Model**.
## The Problem
The existing `store` package is great for simple key-value storage, but real services need more. You need to filter by fields, paginate results, count records, and use different databases in dev vs production. Most teams end up writing their own data layer or pulling in an ORM that has nothing to do with Go Micro.
We wanted something that feels native to the framework. Define a Go struct, tag a key, and get type-safe CRUD and queries — with the same pluggable backend pattern Go Micro uses everywhere.
## Define a Struct, Get a Database
```go
type User struct {
ID string `json:"id" model:"key"`
Name string `json:"name"`
Email string `json:"email" model:"index"`
Age int `json:"age"`
}
```
The `model:"key"` tag marks your primary key. The `model:"index"` tag creates an index for faster queries. Column names come from `json` tags (or lowercased field names if no tag).
Create a model and use it:
```go
users := model.New[User](service.Model())
// Create
users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30})
// Read
user, err := users.Read(ctx, "1")
// Update
user.Name = "Alice Smith"
users.Update(ctx, user)
// Delete
users.Delete(ctx, "1")
```
No migrations. No connection setup. No configuration files. The schema is derived from your struct at startup.
## Queries That Feel Like Go
List and count with composable query options:
```go
// Simple equality filter
active, _ := users.List(ctx, model.Where("email", "alice@example.com"))
// Operators, ordering, pagination
page, _ := users.List(ctx,
model.WhereOp("age", ">=", 18),
model.OrderDesc("name"),
model.Limit(10),
model.Offset(20),
)
// Count records
total, _ := users.Count(ctx, model.Where("age", 30))
```
Filters support `=`, `!=`, `<`, `>`, `<=`, `>=`, and `LIKE`. Everything composes — add as many query options as you need.
## Three Backends, One Interface
The model layer follows Go Micro's pluggable pattern. Same code, different backends:
**Memory** — the default. Zero config, great for development and testing:
```go
service := micro.New("users")
users := model.New[User](service.Model()) // in-memory by default
```
**SQLite** — single-file database for local development or single-node production:
```go
db, _ := sqlite.New(model.WithDSN("file:app.db"))
service := micro.New("users", micro.Model(db))
```
**Postgres** — production-grade with connection pooling:
```go
db, _ := postgres.New(model.WithDSN("postgres://localhost/myapp"))
service := micro.New("users", micro.Model(db))
```
Start with memory in dev, switch to SQLite or Postgres for production. Your application code doesn't change.
## The Complete Service Interface
The Service interface now has three core accessors:
```go
type Service interface {
Client() client.Client // Call other services
Server() server.Server // Handle incoming requests
Model() model.Database // Save and query data
// ...
}
```
This means a typical service has everything it needs in one place:
```go
func main() {
service := micro.New("users", micro.Address(":9001"))
// Data layer
users := model.New[User](service.Model())
// Handler with data access
service.Handle(&UserService{users: users})
// Run
service.Run()
}
```
Call services with `service.Client()`. Handle requests with `service.Server()`. Save data with `service.Model()`. That's the complete picture.
## Multiple Models, One Database
You can create multiple typed models from the same database connection:
```go
db := service.Model()
users := model.New[User](db)
posts := model.New[Post](db)
comments := model.New[Comment](db)
```
Each model gets its own table (derived from the struct name). They share the database connection.
## What's Next
The model package is production-ready with memory, SQLite, and Postgres backends. Coming soon:
- **Relationships** — define foreign keys between models
- **Migrations** — track and apply schema changes
- **Protobuf codegen**`protoc-gen-micro` generates model code from proto definitions
See the [model documentation](https://go-micro.dev/docs/model.html) for the full API reference, or browse the [model package source](https://github.com/micro/go-micro/tree/master/model) to see the implementation.
+7
View File
@@ -10,6 +10,13 @@ permalink: /blog/
</div>
<div class="posts">
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/6">The Model Package: Client, Server, and Now Data</a></h2>
<p class="meta" style="color: #666; font-size: 0.85rem;">March 4, 2026</p>
<p>Go Micro now has a typed data model layer — define structs, get CRUD and queries, swap backends. Every service gets Client, Server, and Model.</p>
<a href="/blog/6">Read more →</a>
</article>
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/5">Developer Experience Cleanup: One Way to Do Things</a></h2>
<p class="meta" style="color: #666; font-size: 0.85rem;">March 4, 2026</p>
+18 -18
View File
@@ -78,7 +78,7 @@ Then issue different tokens:
## Pattern 3: Agent as Service Consumer
Your Go Micro service itself calls an AI model to process data, using the `model` package.
Your Go Micro service itself calls an AI model to process data, using the `ai` package.
```
User → API → Your Service → AI Model (Claude/GPT)
@@ -89,20 +89,20 @@ User → API → Your Service → AI Model (Claude/GPT)
```go
import (
"go-micro.dev/v5/model"
_ "go-micro.dev/v5/model/anthropic"
"go-micro.dev/v5/ai"
_ "go-micro.dev/v5/ai/anthropic"
)
type SummaryService struct {
ai model.Model
ai ai.Model
tasks *TaskClient
}
func NewSummaryService() *SummaryService {
return &SummaryService{
ai: model.New("anthropic",
model.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
model.WithModel("claude-sonnet-4-20250514"),
ai: ai.New("anthropic",
ai.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
ai.WithModel("claude-sonnet-4-20250514"),
),
}
}
@@ -119,7 +119,7 @@ func (s *SummaryService) Summarize(ctx context.Context, req *SummarizeRequest, r
}
// Use AI to summarize
resp, err := s.ai.Generate(ctx, &model.Request{
resp, err := s.ai.Generate(ctx, &ai.Request{
Prompt: fmt.Sprintf("Summarize these tasks:\n%s", formatTasks(tasks)),
SystemPrompt: "You are a concise project manager. Summarize task status in 2-3 sentences.",
})
@@ -140,7 +140,7 @@ func (s *SummaryService) Summarize(ctx context.Context, req *SummarizeRequest, r
## Pattern 4: Agent with Tool Calling
An AI model calls your services as tools, with automatic tool execution via the model package.
An AI model calls your services as tools, with automatic tool execution via the ai package.
```
User → Your App → AI Model ←→ MCP Tools (your services)
@@ -150,12 +150,12 @@ User → Your App → AI Model ←→ MCP Tools (your services)
```go
import (
"go-micro.dev/v5/model"
_ "go-micro.dev/v5/model/anthropic"
"go-micro.dev/v5/ai"
_ "go-micro.dev/v5/ai/anthropic"
)
// Define tools from your service endpoints
tools := []model.Tool{
tools := []ai.Tool{
{
Name: "create_task",
Description: "Create a new task with title and assignee",
@@ -196,13 +196,13 @@ toolHandler := func(name string, input map[string]any) (any, string) {
return nil, `{"error": "unknown tool"}`
}
m := model.New("anthropic",
model.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
model.WithToolHandler(toolHandler),
m := ai.New("anthropic",
ai.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
ai.WithToolHandler(toolHandler),
)
// The model will automatically call tools and return the final answer
resp, err := m.Generate(ctx, &model.Request{
resp, err := m.Generate(ctx, &ai.Request{
Prompt: "Create a task for Alice to review the PR and tell me what tasks she has",
SystemPrompt: "You are a helpful project management assistant",
Tools: tools,
@@ -240,7 +240,7 @@ broker.Subscribe("tasks.created", func(p broker.Event) error {
json.Unmarshal(p.Message().Body, &task)
// Use AI to auto-assign based on task content
resp, err := ai.Generate(ctx, &model.Request{
resp, err := aiModel.Generate(ctx, &ai.Request{
Prompt: fmt.Sprintf("Who should handle this task? Title: %s, Description: %s. Team: alice (frontend), bob (backend), charlie (devops)", task.Title, task.Description),
SystemPrompt: "Reply with just the username of the best person to handle this task.",
})
@@ -439,4 +439,4 @@ Keep services as pure business logic. Let the agent (or the agent framework) han
- [Building AI-Native Services](ai-native-services.md) - End-to-end tutorial
- [MCP Security Guide](mcp-security.md) - Auth and scopes
- [Tool Description Best Practices](tool-descriptions.md) - Better docs for agents
- [Model Package](../../model/README.md) - AI provider interface
- [AI Package](../../ai/README.md) - AI provider interface
@@ -359,21 +359,21 @@ Each tool call generates a span with attributes:
Trace context is propagated downstream via metadata headers (`Mcp-Trace-Id`, `Mcp-Tool-Name`, `Mcp-Account-Id`), so you get full distributed traces from agent through gateway to service.
## Step 8: Use the Model Package (Optional)
## Step 8: Use the AI Package (Optional)
If your service needs to call AI models directly:
```go
import (
"go-micro.dev/v5/model"
_ "go-micro.dev/v5/model/anthropic"
"go-micro.dev/v5/ai"
_ "go-micro.dev/v5/ai/anthropic"
)
m := model.New("anthropic",
model.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
m := ai.New("anthropic",
ai.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
)
resp, err := m.Generate(ctx, &model.Request{
resp, err := m.Generate(ctx, &ai.Request{
Prompt: "Summarize these tasks: " + taskJSON,
SystemPrompt: "You are a project manager assistant",
})
+275
View File
@@ -0,0 +1,275 @@
---
layout: doc
title: Data Model
permalink: /docs/model.html
description: "Typed data model layer with CRUD operations, queries, and pluggable backends"
---
# Data Model
The `model` package provides a typed data model layer for Go Micro services. Define Go structs, tag your fields, and get type-safe CRUD operations with queries, filtering, ordering, and pagination.
## Quick Start
```go
package main
import (
"context"
"go-micro.dev/v5"
"go-micro.dev/v5/model"
)
type Task struct {
ID string `json:"id" model:"key"`
Title string `json:"title"`
Done bool `json:"done"`
Owner string `json:"owner" model:"index"`
}
func main() {
service := micro.New("tasks")
// Create a typed model backed by the service's database
tasks := model.New[Task](service.Model())
ctx := context.Background()
// Create a record
tasks.Create(ctx, &Task{ID: "1", Title: "Ship it", Owner: "alice"})
// Read by key
task, _ := tasks.Read(ctx, "1")
// Update
task.Done = true
tasks.Update(ctx, task)
// List with filters
aliceTasks, _ := tasks.List(ctx, model.Where("owner", "alice"))
// Delete
tasks.Delete(ctx, "1")
}
```
## Defining Models
Models are plain Go structs. Use struct tags to control storage behavior:
| Tag | Purpose | Example |
|-----|---------|---------|
| `model:"key"` | Primary key field | `ID string \`model:"key"\`` |
| `model:"index"` | Create an index on this field | `Email string \`model:"index"\`` |
| `json:"name"` | Column name in the database | `Name string \`json:"name"\`` |
If no `model:"key"` tag is found, the package defaults to a field with `json:"id"` or a field named `ID`.
Table names are auto-derived from the struct name (lowercased + "s"), e.g. `User``users`. Override with `model.WithTable("custom_name")`.
```go
type User struct {
ID string `json:"id" model:"key"`
Name string `json:"name"`
Email string `json:"email" model:"index"`
Age int `json:"age"`
CreatedAt string `json:"created_at"`
}
// Auto-derived table: "users"
users := model.New[User](db)
// Custom table name
users := model.New[User](db, model.WithTable("app_users"))
```
## CRUD Operations
```go
// Create — inserts a new record (returns ErrDuplicateKey if key exists)
err := users.Create(ctx, &User{ID: "1", Name: "Alice"})
// Read — retrieves by primary key (returns ErrNotFound if missing)
user, err := users.Read(ctx, "1")
// Update — modifies an existing record (returns ErrNotFound if missing)
user.Name = "Alice Smith"
err = users.Update(ctx, user)
// Delete — removes by primary key (returns ErrNotFound if missing)
err = users.Delete(ctx, "1")
```
## Queries
Use query options to filter, order, and paginate results:
### Filters
```go
// Equality
results, _ := users.List(ctx, model.Where("email", "alice@example.com"))
// Operators: =, !=, <, >, <=, >=, LIKE
results, _ = users.List(ctx, model.WhereOp("age", ">=", 18))
results, _ = users.List(ctx, model.WhereOp("name", "LIKE", "Ali%"))
// Multiple filters (AND)
results, _ = users.List(ctx,
model.Where("owner", "alice"),
model.WhereOp("age", ">", 25),
)
```
### Ordering
```go
results, _ := users.List(ctx, model.OrderAsc("name"))
results, _ = users.List(ctx, model.OrderDesc("created_at"))
```
### Pagination
```go
results, _ := users.List(ctx,
model.Limit(10),
model.Offset(20),
)
```
### Counting
```go
total, _ := users.Count(ctx)
active, _ := users.Count(ctx, model.Where("active", true))
```
## Backends
The model layer uses Go Micro's pluggable interface pattern. All backends implement `model.Database`.
### Memory (Default)
Zero-config, in-memory storage. Data doesn't persist across restarts. Ideal for development and testing.
```go
service := micro.New("myservice")
tasks := model.New[Task](service.Model()) // memory backend by default
```
Or create directly:
```go
import "go-micro.dev/v5/model/memory"
db := memory.New()
tasks := model.New[Task](db)
```
### SQLite
File-based database. Good for local development or single-node production.
```go
import "go-micro.dev/v5/model/sqlite"
db, err := sqlite.New(model.WithDSN("file:app.db"))
service := micro.New("myservice", micro.Model(db))
```
### Postgres
Production-grade with connection pooling.
```go
import "go-micro.dev/v5/model/postgres"
db, err := postgres.New(model.WithDSN("postgres://user:pass@localhost/myapp?sslmode=disable"))
service := micro.New("myservice", micro.Model(db))
```
## Service Integration
The `Service` interface provides `Model()` alongside `Client()` and `Server()`:
```go
service := micro.New("users", micro.Address(":9001"))
// Access the three core components
client := service.Client() // Call other services
server := service.Server() // Handle requests
db := service.Model() // Data persistence
// Create typed models from the shared database
users := model.New[User](db)
posts := model.New[Post](db)
// Use in your handler
service.Handle(&UserHandler{users: users, posts: posts})
service.Run()
```
A handler that uses all three:
```go
type OrderHandler struct {
orders *model.Model[Order]
client client.Client
}
// CreateOrder saves an order and notifies the shipping service
func (h *OrderHandler) CreateOrder(ctx context.Context, req *CreateReq, rsp *CreateRsp) error {
// Save to database via Model
order := &Order{ID: req.ID, Item: req.Item, Status: "pending"}
if err := h.orders.Create(ctx, order); err != nil {
return err
}
// Call another service via Client
shipClient := proto.NewShippingService("shipping", h.client)
_, err := shipClient.Ship(ctx, &proto.ShipRequest{OrderID: order.ID})
return err
}
```
## Error Handling
The model package returns two sentinel errors:
```go
import "go-micro.dev/v5/model"
// Check for not found
user, err := users.Read(ctx, "missing")
if errors.Is(err, model.ErrNotFound) {
// record doesn't exist
}
// Check for duplicate key
err = users.Create(ctx, &User{ID: "1", Name: "Alice"})
err = users.Create(ctx, &User{ID: "1", Name: "Bob"})
if errors.Is(err, model.ErrDuplicateKey) {
// key "1" already exists
}
```
## Swapping Backends
Follow the standard Go Micro pattern — use in-memory for development, swap to a real database for production:
```go
func main() {
var db model.Database
if os.Getenv("ENV") == "production" {
db, _ = postgres.New(model.WithDSN(os.Getenv("DATABASE_URL")))
} else {
db = memory.New()
}
service := micro.New("myservice", micro.Model(db))
// ... same application code regardless of backend
}
```
+119 -225
View File
@@ -1,20 +1,8 @@
# Model Package
The `model` package provides a simple, high-level interface for AI model providers like Anthropic Claude and OpenAI GPT.
The `model` package provides a typed data model layer with CRUD operations, query filtering, and multiple database backends. It uses Go generics for type-safe access.
## Interface
The Model interface follows the same patterns as other go-micro packages (Registry, Client, Broker):
```go
type Model interface {
Init(...Option) error
Options() Options
Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error)
Stream(ctx context.Context, req *Request, opts ...GenerateOption) (Stream, error)
String() string
}
```
Unlike the `store` package (which is a raw KV abstraction), `model` provides structured data access with schema awareness, WHERE queries, ordering, pagination, and indexes.
## Quick Start
@@ -22,247 +10,153 @@ type Model interface {
import (
"context"
"go-micro.dev/v5/model"
_ "go-micro.dev/v5/model/anthropic"
_ "go-micro.dev/v5/model/openai"
"go-micro.dev/v5/model/memory"
)
// Create a model
m := model.New("openai",
model.WithAPIKey("your-api-key"),
model.WithModel("gpt-4o"),
// Define your model with struct tags
type User struct {
ID string `json:"id" model:"key"`
Name string `json:"name" model:"index"`
Email string `json:"email"`
Age int `json:"age"`
}
// Create a database and model
db := memory.New()
users := model.New[User](db)
ctx := context.Background()
// Create
users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30})
// Read
user, _ := users.Read(ctx, "1")
fmt.Println(user.Name) // "Alice"
// Update
user.Name = "Alice Smith"
users.Update(ctx, user)
// Delete
users.Delete(ctx, "1")
```
## Struct Tags
| Tag | Description | Example |
|-----|-------------|---------|
| `model:"key"` | Primary key field | `ID string \`model:"key"\`` |
| `model:"index"` | Create an index on this field | `Name string \`model:"index"\`` |
| `json:"name"` | Column name in the database | `Name string \`json:"name"\`` |
If no `model:"key"` tag is found, the package defaults to a field with `json:"id"` or column name `id`.
## Querying
```go
// Filter by field value
users.List(ctx, model.Where("name", "Alice"))
// Comparison operators
users.List(ctx, model.WhereOp("age", ">", 25))
users.List(ctx, model.WhereOp("name", "LIKE", "Ali%"))
// Ordering
users.List(ctx, model.OrderAsc("name"))
users.List(ctx, model.OrderDesc("age"))
// Pagination
users.List(ctx, model.Limit(10), model.Offset(20))
// Combine
users.List(ctx,
model.Where("status", "active"),
model.WhereOp("age", ">=", 18),
model.OrderDesc("created_at"),
model.Limit(25),
)
// Generate a response
req := &model.Request{
Prompt: "What is Go?",
SystemPrompt: "You are a helpful programming assistant",
}
resp, err := m.Generate(context.Background(), req)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Reply)
// Count
total, _ := users.Count(ctx)
active, _ := users.Count(ctx, model.Where("status", "active"))
```
## Options
## Backends
Configure the model using functional options:
### Memory (Development & Testing)
```go
m := model.New("anthropic",
model.WithAPIKey("your-key"), // Required
model.WithModel("claude-sonnet-4-20250514"), // Optional, uses provider default
model.WithBaseURL("https://api.anthropic.com"), // Optional, uses provider default
)
import "go-micro.dev/v5/model/memory"
db := memory.New()
```
You can also update options after creation:
In-memory storage. No persistence. Fast. Good for tests and prototyping.
### SQLite (Development & Single-Node Production)
```go
m.Init(
model.WithModel("gpt-4o-mini"),
model.WithAPIKey("new-key"),
)
import "go-micro.dev/v5/model/sqlite"
db := sqlite.New("app.db") // File-based
db := sqlite.New(":memory:") // In-memory (testing)
```
## Using Tools
Embedded SQL database. Zero external dependencies. Supports WHERE, indexes, ordering natively.
The model can automatically execute tool calls when provided with a tool handler:
### Postgres (Production)
```go
// Define a tool handler
toolHandler := func(name string, input map[string]any) (result any, content string) {
// Execute the tool and return results
switch name {
case "get_weather":
return map[string]string{"temp": "72F"}, `{"temp": "72F"}`
default:
return nil, `{"error": "unknown tool"}`
}
}
import "go-micro.dev/v5/model/postgres"
// Create model with tool handler
m := model.New("openai",
model.WithAPIKey("your-key"),
model.WithToolHandler(toolHandler),
)
// Provide tools in the request
req := &model.Request{
Prompt: "What's the weather?",
SystemPrompt: "You are a helpful assistant",
Tools: []model.Tool{
{
Name: "get_weather",
Description: "Get current weather",
Properties: map[string]any{
"location": map[string]any{
"type": "string",
"description": "City name",
},
},
},
},
}
// Generate will automatically call tools and return final answer
resp, err := m.Generate(context.Background(), req)
fmt.Println(resp.Answer) // Final answer after tool execution
db := postgres.New("postgres://user:pass@localhost/mydb?sslmode=disable")
```
## Response Structure
Full PostgreSQL support. Best for production with rich query capabilities.
## Table Names
By default, the table name is the lowercase struct name + "s" (e.g., `User``users`). Override with `WithTable`:
```go
type Response struct {
Reply string // Initial reply from model
ToolCalls []ToolCall // Tools the model wants to call
Answer string // Final answer (after tool execution if handler provided)
users := model.New[User](db, model.WithTable("app_users"))
```
## Database Interface
All backends implement the `model.Database` interface:
```go
type Database interface {
Init(...Option) error
NewTable(schema *Schema) error
Create(ctx context.Context, schema *Schema, key string, fields map[string]any) error
Read(ctx context.Context, schema *Schema, key string) (map[string]any, error)
Update(ctx context.Context, schema *Schema, key string, fields map[string]any) error
Delete(ctx context.Context, schema *Schema, key string) error
List(ctx context.Context, schema *Schema, opts ...QueryOption) ([]map[string]any, error)
Count(ctx context.Context, schema *Schema, opts ...QueryOption) (int64, error)
Close() error
String() string
}
```
- `Reply`: The model's first response
- `ToolCalls`: List of tools the model requested (if any)
- `Answer`: The final answer after tools are executed (only set if ToolHandler is provided)
## Model vs Store
## Supported Providers
### Anthropic Claude
```go
m := model.New("anthropic",
model.WithAPIKey("sk-ant-..."),
model.WithModel("claude-sonnet-4-20250514"), // default
)
```
Default model: `claude-sonnet-4-20250514`
Default base URL: `https://api.anthropic.com`
### OpenAI GPT
```go
m := model.New("openai",
model.WithAPIKey("sk-..."),
model.WithModel("gpt-4o"), // default
)
```
Default model: `gpt-4o`
Default base URL: `https://api.openai.com`
## Auto-Detection
Use `AutoDetectProvider()` to detect the provider from a base URL:
```go
provider := model.AutoDetectProvider("https://api.anthropic.com")
// Returns "anthropic"
m := model.New(provider, model.WithAPIKey("..."))
```
## Adding a New Provider
1. Create a new package under `model/`:
```go
package myprovider
import "go-micro.dev/v5/model"
func init() {
model.Register("myprovider", func(opts ...model.Option) model.Model {
return NewProvider(opts...)
})
}
type Provider struct {
opts model.Options
}
func NewProvider(opts ...model.Option) *Provider {
options := model.NewOptions(opts...)
// Set defaults
if options.Model == "" {
options.Model = "my-default-model"
}
if options.BaseURL == "" {
options.BaseURL = "https://api.myprovider.com"
}
return &Provider{opts: options}
}
func (p *Provider) Init(opts ...model.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
func (p *Provider) Options() model.Options {
return p.opts
}
func (p *Provider) String() string {
return "myprovider"
}
func (p *Provider) Generate(ctx context.Context, req *model.Request, opts ...model.GenerateOption) (*model.Response, error) {
// Implement your provider logic
// - Build API request
// - Make HTTP call
// - Parse response
// - Handle tools if ToolHandler is set
return &model.Response{}, nil
}
func (p *Provider) Stream(ctx context.Context, req *model.Request, opts ...model.GenerateOption) (model.Stream, error) {
return nil, fmt.Errorf("streaming not implemented")
}
```
2. Import your provider:
```go
import _ "go-micro.dev/v5/model/myprovider"
```
## Comparison with Other Packages
The model package follows the same patterns as other go-micro packages:
**Registry:**
```go
r := registry.NewRegistry(registry.Addrs("..."))
r.Register(service)
```
**Client:**
```go
c := client.NewClient(client.Retries(3))
c.Call(ctx, req, rsp)
```
**Model:**
```go
m := model.New("openai", model.WithAPIKey("..."))
m.Generate(ctx, req)
```
All use:
- `Init()` to update options
- `Options()` to get current options
- `String()` to get the implementation name
- Functional options pattern
| Feature | `store` | `model` |
|---------|---------|---------|
| Data format | Raw `[]byte` | Typed Go structs |
| Queries | Key prefix/suffix only | WHERE, operators, LIKE |
| Ordering | None | ORDER BY field ASC/DESC |
| Pagination | Limit/Offset on keys | Limit/Offset on results |
| Indexes | None | Via `model:"index"` tag |
| Schema | None (schemaless KV) | Auto-created from struct |
| Backends | Memory, File, MySQL, Postgres, NATS | Memory, SQLite, Postgres |
| Use case | Config, sessions, cache | Application data, entities |
## Testing
```bash
go test ./model/...
```
## Examples
See the [server implementation](../../cmd/micro/server/server.go) for a complete example of using the model package with tool execution.
+269
View File
@@ -0,0 +1,269 @@
// Package memory provides an in-memory Database implementation for the model package.
// Useful for testing and development. Data does not persist across restarts.
package memory
import (
"context"
"fmt"
"reflect"
"strings"
"sync"
"go-micro.dev/v5/model"
)
// Database is an in-memory model.Database implementation.
type Database struct {
mu sync.RWMutex
tables map[string]*table
}
type table struct {
rows map[string]map[string]any
}
// New creates a new in-memory database.
func New(opts ...model.Option) *Database {
return &Database{
tables: make(map[string]*table),
}
}
func (d *Database) Init(opts ...model.Option) error {
return nil
}
func (d *Database) NewTable(schema *model.Schema) error {
d.mu.Lock()
defer d.mu.Unlock()
if _, ok := d.tables[schema.Table]; !ok {
d.tables[schema.Table] = &table{rows: make(map[string]map[string]any)}
}
return nil
}
func (d *Database) Create(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error {
d.mu.Lock()
defer d.mu.Unlock()
t := d.tables[schema.Table]
if _, exists := t.rows[key]; exists {
return model.ErrDuplicateKey
}
// Copy the map to avoid external mutation
row := make(map[string]any, len(fields))
for k, v := range fields {
row[k] = v
}
t.rows[key] = row
return nil
}
func (d *Database) Read(ctx context.Context, schema *model.Schema, key string) (map[string]any, error) {
d.mu.RLock()
defer d.mu.RUnlock()
t := d.tables[schema.Table]
row, ok := t.rows[key]
if !ok {
return nil, model.ErrNotFound
}
// Return a copy
result := make(map[string]any, len(row))
for k, v := range row {
result[k] = v
}
return result, nil
}
func (d *Database) Update(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error {
d.mu.Lock()
defer d.mu.Unlock()
t := d.tables[schema.Table]
if _, ok := t.rows[key]; !ok {
return model.ErrNotFound
}
row := make(map[string]any, len(fields))
for k, v := range fields {
row[k] = v
}
t.rows[key] = row
return nil
}
func (d *Database) Delete(ctx context.Context, schema *model.Schema, key string) error {
d.mu.Lock()
defer d.mu.Unlock()
t := d.tables[schema.Table]
if _, ok := t.rows[key]; !ok {
return model.ErrNotFound
}
delete(t.rows, key)
return nil
}
func (d *Database) List(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) ([]map[string]any, error) {
q := model.ApplyQueryOptions(opts...)
d.mu.RLock()
defer d.mu.RUnlock()
t := d.tables[schema.Table]
var results []map[string]any
for _, row := range t.rows {
if matchFilters(row, q.Filters) {
// Copy row
cp := make(map[string]any, len(row))
for k, v := range row {
cp[k] = v
}
results = append(results, cp)
}
}
// Sort if OrderBy is set
if q.OrderBy != "" {
sortRows(results, q.OrderBy, q.Desc)
}
// Apply offset
if q.Offset > 0 && uint(len(results)) > q.Offset {
results = results[q.Offset:]
} else if q.Offset > 0 {
results = nil
}
// Apply limit
if q.Limit > 0 && uint(len(results)) > q.Limit {
results = results[:q.Limit]
}
return results, nil
}
func (d *Database) Count(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) (int64, error) {
q := model.ApplyQueryOptions(opts...)
d.mu.RLock()
defer d.mu.RUnlock()
t := d.tables[schema.Table]
var count int64
for _, row := range t.rows {
if matchFilters(row, q.Filters) {
count++
}
}
return count, nil
}
func (d *Database) Close() error {
return nil
}
func (d *Database) String() string {
return "memory"
}
// matchFilters returns true if the row satisfies all filters.
func matchFilters(row map[string]any, filters []model.Filter) bool {
for _, f := range filters {
val, ok := row[f.Field]
if !ok {
return false
}
if !compareValues(val, f.Op, f.Value) {
return false
}
}
return true
}
// compareValues compares two values with the given operator.
func compareValues(a any, op string, b any) bool {
switch op {
case "=":
return fmt.Sprint(a) == fmt.Sprint(b)
case "!=":
return fmt.Sprint(a) != fmt.Sprint(b)
case "LIKE":
// Simple LIKE: supports % wildcard at start/end
pattern := fmt.Sprint(b)
val := fmt.Sprint(a)
if strings.HasPrefix(pattern, "%") && strings.HasSuffix(pattern, "%") {
return strings.Contains(val, pattern[1:len(pattern)-1])
}
if strings.HasPrefix(pattern, "%") {
return strings.HasSuffix(val, pattern[1:])
}
if strings.HasSuffix(pattern, "%") {
return strings.HasPrefix(val, pattern[:len(pattern)-1])
}
return val == pattern
case "<", ">", "<=", ">=":
return compareNumeric(a, op, b)
default:
return false
}
}
// compareNumeric attempts numeric comparison.
func compareNumeric(a any, op string, b any) bool {
af, aOk := toFloat64(a)
bf, bOk := toFloat64(b)
if !aOk || !bOk {
// Fall back to string comparison
as, bs := fmt.Sprint(a), fmt.Sprint(b)
switch op {
case "<":
return as < bs
case ">":
return as > bs
case "<=":
return as <= bs
case ">=":
return as >= bs
}
return false
}
switch op {
case "<":
return af < bf
case ">":
return af > bf
case "<=":
return af <= bf
case ">=":
return af >= bf
}
return false
}
func toFloat64(v any) (float64, bool) {
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return float64(rv.Int()), true
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return float64(rv.Uint()), true
case reflect.Float32, reflect.Float64:
return rv.Float(), true
default:
return 0, false
}
}
// sortRows sorts rows by a field. Simple insertion sort for small datasets.
func sortRows(rows []map[string]any, field string, desc bool) {
for i := 1; i < len(rows); i++ {
for j := i; j > 0; j-- {
a := fmt.Sprint(rows[j-1][field])
b := fmt.Sprint(rows[j][field])
shouldSwap := a > b
if desc {
shouldSwap = a < b
}
if shouldSwap {
rows[j-1], rows[j] = rows[j], rows[j-1]
}
}
}
}
+210
View File
@@ -0,0 +1,210 @@
package memory
import (
"context"
"testing"
"go-micro.dev/v5/model"
)
type User struct {
ID string `json:"id" model:"key"`
Name string `json:"name" model:"index"`
Email string `json:"email"`
Age int `json:"age"`
}
func setup(t *testing.T) *model.Model[User] {
t.Helper()
db := New()
return model.New[User](db)
}
func TestCRUD(t *testing.T) {
users := setup(t)
ctx := context.Background()
// Create
err := users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@test.com", Age: 30})
if err != nil {
t.Fatalf("create: %v", err)
}
// Read
u, err := users.Read(ctx, "1")
if err != nil {
t.Fatalf("read: %v", err)
}
if u.Name != "Alice" {
t.Errorf("expected Alice, got %s", u.Name)
}
if u.Age != 30 {
t.Errorf("expected age 30, got %d", u.Age)
}
// Update
u.Name = "Alice Updated"
u.Age = 31
err = users.Update(ctx, u)
if err != nil {
t.Fatalf("update: %v", err)
}
u2, _ := users.Read(ctx, "1")
if u2.Name != "Alice Updated" {
t.Errorf("expected 'Alice Updated', got %s", u2.Name)
}
if u2.Age != 31 {
t.Errorf("expected age 31, got %d", u2.Age)
}
// Delete
err = users.Delete(ctx, "1")
if err != nil {
t.Fatalf("delete: %v", err)
}
_, err = users.Read(ctx, "1")
if err != model.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestDuplicateKey(t *testing.T) {
users := setup(t)
ctx := context.Background()
users.Create(ctx, &User{ID: "1", Name: "Alice"})
err := users.Create(ctx, &User{ID: "1", Name: "Bob"})
if err != model.ErrDuplicateKey {
t.Errorf("expected ErrDuplicateKey, got %v", err)
}
}
func TestNotFound(t *testing.T) {
users := setup(t)
ctx := context.Background()
_, err := users.Read(ctx, "nonexistent")
if err != model.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
err = users.Update(ctx, &User{ID: "nonexistent"})
if err != model.ErrNotFound {
t.Errorf("expected ErrNotFound on update, got %v", err)
}
err = users.Delete(ctx, "nonexistent")
if err != model.ErrNotFound {
t.Errorf("expected ErrNotFound on delete, got %v", err)
}
}
func TestList(t *testing.T) {
users := setup(t)
ctx := context.Background()
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
users.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35})
// List all
all, err := users.List(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(all) != 3 {
t.Errorf("expected 3, got %d", len(all))
}
}
func TestListWithFilter(t *testing.T) {
users := setup(t)
ctx := context.Background()
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
users.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35})
// Filter by name
results, err := users.List(ctx, model.Where("name", "Alice"))
if err != nil {
t.Fatalf("list with filter: %v", err)
}
if len(results) != 2 {
t.Errorf("expected 2 Alices, got %d", len(results))
}
}
func TestListWithLimitOffset(t *testing.T) {
users := setup(t)
ctx := context.Background()
users.Create(ctx, &User{ID: "1", Name: "A", Age: 1})
users.Create(ctx, &User{ID: "2", Name: "B", Age: 2})
users.Create(ctx, &User{ID: "3", Name: "C", Age: 3})
users.Create(ctx, &User{ID: "4", Name: "D", Age: 4})
// Sort by name, get 2 starting from offset 1
results, err := users.List(ctx,
model.OrderAsc("name"),
model.Limit(2),
model.Offset(1),
)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2, got %d", len(results))
}
if results[0].Name != "B" {
t.Errorf("expected B, got %s", results[0].Name)
}
if results[1].Name != "C" {
t.Errorf("expected C, got %s", results[1].Name)
}
}
func TestCount(t *testing.T) {
users := setup(t)
ctx := context.Background()
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
users.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35})
count, err := users.Count(ctx)
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 3 {
t.Errorf("expected 3, got %d", count)
}
count, err = users.Count(ctx, model.Where("name", "Alice"))
if err != nil {
t.Fatalf("count with filter: %v", err)
}
if count != 2 {
t.Errorf("expected 2, got %d", count)
}
}
func TestWhereOp(t *testing.T) {
users := setup(t)
ctx := context.Background()
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
users.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35})
// Age > 28
results, err := users.List(ctx, model.WhereOp("age", ">", 28))
if err != nil {
t.Fatalf("list: %v", err)
}
if len(results) != 2 {
t.Errorf("expected 2 (age > 28), got %d", len(results))
}
}
+223 -102
View File
@@ -1,130 +1,251 @@
// Package model provides abstraction for AI model providers
// Package model provides a typed data model layer with CRUD operations and query support.
// It uses Go generics for type-safe access and supports multiple backends (memory, SQLite, Postgres).
package model
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
)
// Model provides an interface for interacting with AI model providers
type Model interface {
// Init initializes the model with options
var (
// ErrNotFound is returned when a record doesn't exist.
ErrNotFound = errors.New("not found")
// ErrDuplicateKey is returned when a record with the same key already exists.
ErrDuplicateKey = errors.New("duplicate key")
)
// Database is the backend interface that model implementations must satisfy.
// Each backend (memory, sqlite, postgres) implements this interface.
type Database interface {
// Init initializes the database connection.
Init(...Option) error
// Options returns the model options
Options() Options
// Generate generates a response from the model
Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error)
// Stream generates a streaming response (for future implementation)
Stream(ctx context.Context, req *Request, opts ...GenerateOption) (Stream, error)
// String returns the name of the provider
// NewTable ensures the table exists for the given schema.
NewTable(schema *Schema) error
// Create inserts a new record. Returns ErrDuplicateKey if key exists.
Create(ctx context.Context, schema *Schema, key string, fields map[string]any) error
// Read returns a single record by key. Returns ErrNotFound if missing.
Read(ctx context.Context, schema *Schema, key string) (map[string]any, error)
// Update modifies an existing record by key. Returns ErrNotFound if missing.
Update(ctx context.Context, schema *Schema, key string, fields map[string]any) error
// Delete removes a record by key. Returns ErrNotFound if missing.
Delete(ctx context.Context, schema *Schema, key string) error
// List returns all records matching the query options.
List(ctx context.Context, schema *Schema, opts ...QueryOption) ([]map[string]any, error)
// Count returns the number of records matching the query options.
Count(ctx context.Context, schema *Schema, opts ...QueryOption) (int64, error)
// Close closes the database connection.
Close() error
// String returns the implementation name.
String() string
}
// Tool represents a tool/function that can be called by the model
type Tool struct {
Name string // LLM-safe name (e.g., "greeter_Greeter_Hello")
OriginalName string // Original name (e.g., "greeter.Greeter.Hello")
Description string
Properties map[string]any // JSON schema for tool parameters
// Schema describes a model's storage layout, derived from struct tags.
type Schema struct {
// Table name in the database.
Table string
// Key is the name of the primary key field.
Key string
// Fields maps Go field names to their column metadata.
Fields []Field
}
// Request represents a request to generate content from a model
type Request struct {
// Prompt is the user's message/prompt
Prompt string
// SystemPrompt is the system instruction for the model
SystemPrompt string
// Tools available for the model to use
Tools []Tool
// Messages for continuing a conversation (optional)
Messages []Message
// Field describes a single field in the schema.
type Field struct {
// Name is the Go struct field name.
Name string
// Column is the database column name (from json tag or lowercased name).
Column string
// Type is the Go reflect type.
Type reflect.Type
// IsKey indicates this is the primary key field.
IsKey bool
// Index indicates this field should be indexed.
Index bool
}
// Message represents a conversation message
type Message struct {
Role string // "user", "assistant", "system", "tool"
Content any // Can be string or structured content
// Model provides typed CRUD operations for a specific Go struct type.
type Model[T any] struct {
db Database
schema *Schema
}
// Response represents the response from a model
type Response struct {
// Reply is the text response from the model
Reply string
// ToolCalls are tool calls requested by the model
ToolCalls []ToolCall
// Answer is the final answer after tool execution (if tools were used)
Answer string
}
// New creates a new Model for the given type T, backed by the provided database.
// T must be a struct with at least one field tagged `model:"key"`.
func New[T any](db Database, opts ...ModelOption) *Model[T] {
var t T
schema := buildSchema(reflect.TypeOf(t))
// ToolCall represents a request to call a tool
type ToolCall struct {
ID string // Tool call ID (for correlation)
Name string // Tool name
Input map[string]any // Tool input arguments
}
// ToolResult represents the result of a tool execution
type ToolResult struct {
ID string // Tool call ID (for correlation)
Content string // Tool execution result (JSON string)
}
// Stream is the interface for streaming responses (future implementation)
type Stream interface {
// Recv receives the next chunk of the response
Recv() (*Response, error)
// Close closes the stream
Close() error
}
// ToolHandler is a function that handles tool calls
type ToolHandler func(name string, input map[string]any) (result any, content string)
// NewFunc creates a new Model instance
type NewFunc func(...Option) Model
var providers = make(map[string]NewFunc)
// Register registers a model provider
func Register(name string, fn NewFunc) {
providers[name] = fn
}
// New creates a new Model instance based on the provider name
func New(provider string, opts ...Option) Model {
if fn, ok := providers[provider]; ok {
return fn(opts...)
// Apply model options
for _, o := range opts {
o(schema)
}
// Default to first registered provider
if len(providers) > 0 {
for _, fn := range providers {
return fn(opts...)
// Ensure table exists
if err := db.NewTable(schema); err != nil {
panic(fmt.Sprintf("model: failed to create table %q: %v", schema.Table, err))
}
return &Model[T]{
db: db,
schema: schema,
}
}
// Create inserts a new record.
func (m *Model[T]) Create(ctx context.Context, v *T) error {
fields := structToMap(m.schema, v)
key, ok := fields[m.schema.Key]
if !ok {
return fmt.Errorf("model: key field %q not set", m.schema.Key)
}
return m.db.Create(ctx, m.schema, fmt.Sprint(key), fields)
}
// Read retrieves a record by its primary key.
func (m *Model[T]) Read(ctx context.Context, key string) (*T, error) {
fields, err := m.db.Read(ctx, m.schema, key)
if err != nil {
return nil, err
}
v := mapToStruct[T](m.schema, fields)
return v, nil
}
// Update modifies an existing record.
func (m *Model[T]) Update(ctx context.Context, v *T) error {
fields := structToMap(m.schema, v)
key, ok := fields[m.schema.Key]
if !ok {
return fmt.Errorf("model: key field %q not set", m.schema.Key)
}
return m.db.Update(ctx, m.schema, fmt.Sprint(key), fields)
}
// Delete removes a record by its primary key.
func (m *Model[T]) Delete(ctx context.Context, key string) error {
return m.db.Delete(ctx, m.schema, key)
}
// List returns records matching the query options.
func (m *Model[T]) List(ctx context.Context, opts ...QueryOption) ([]*T, error) {
rows, err := m.db.List(ctx, m.schema, opts...)
if err != nil {
return nil, err
}
results := make([]*T, len(rows))
for i, row := range rows {
results[i] = mapToStruct[T](m.schema, row)
}
return results, nil
}
// Count returns the number of records matching the query options.
func (m *Model[T]) Count(ctx context.Context, opts ...QueryOption) (int64, error) {
return m.db.Count(ctx, m.schema, opts...)
}
// Schema returns the model's schema (useful for debugging/introspection).
func (m *Model[T]) Schema() *Schema {
return m.schema
}
// buildSchema extracts the Schema from a struct type using reflection.
func buildSchema(t reflect.Type) *Schema {
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
schema := &Schema{
Table: strings.ToLower(t.Name()) + "s",
}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() {
continue
}
field := Field{
Name: f.Name,
Type: f.Type,
}
// Column name: use json tag if present, else lowercase field name
if tag := f.Tag.Get("json"); tag != "" {
parts := strings.Split(tag, ",")
if parts[0] != "" && parts[0] != "-" {
field.Column = parts[0]
}
}
if field.Column == "" {
field.Column = strings.ToLower(f.Name)
}
// Check model tag
if tag := f.Tag.Get("model"); tag != "" {
for _, opt := range strings.Split(tag, ",") {
switch opt {
case "key":
field.IsKey = true
schema.Key = field.Column
case "index":
field.Index = true
}
}
}
schema.Fields = append(schema.Fields, field)
}
if schema.Key == "" {
// Default to "id" if no key tag found
for i := range schema.Fields {
if schema.Fields[i].Column == "id" {
schema.Fields[i].IsKey = true
schema.Key = "id"
break
}
}
}
return nil
return schema
}
// AutoDetectProvider attempts to detect the provider from the base URL
func AutoDetectProvider(baseURL string) string {
if baseURL == "" {
return "openai"
// structToMap converts a struct to a map of column name → value.
func structToMap[T any](schema *Schema, v *T) map[string]any {
rv := reflect.ValueOf(v).Elem()
fields := make(map[string]any, len(schema.Fields))
for _, f := range schema.Fields {
fv := rv.FieldByName(f.Name)
if fv.IsValid() {
fields[f.Column] = fv.Interface()
}
}
// Simple detection based on URL
if strings.Contains(baseURL, "anthropic") {
return "anthropic"
}
return "openai"
return fields
}
// DefaultModel is a default model instance
var DefaultModel Model
// Generate generates a response using the default model
func Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
if DefaultModel == nil {
return nil, nil
// mapToStruct converts a map of column name → value back to a struct.
func mapToStruct[T any](schema *Schema, fields map[string]any) *T {
v := new(T)
rv := reflect.ValueOf(v).Elem()
for _, f := range schema.Fields {
val, ok := fields[f.Column]
if !ok {
continue
}
fv := rv.FieldByName(f.Name)
if !fv.IsValid() || !fv.CanSet() {
continue
}
rval := reflect.ValueOf(val)
if rval.Type().AssignableTo(fv.Type()) {
fv.Set(rval)
} else if rval.Type().ConvertibleTo(fv.Type()) {
fv.Set(rval.Convert(fv.Type()))
}
}
return DefaultModel.Generate(ctx, req, opts...)
return v
}
+140
View File
@@ -0,0 +1,140 @@
package model
import (
"reflect"
"testing"
)
type TestUser struct {
ID string `json:"id" model:"key"`
Name string `json:"name" model:"index"`
Email string `json:"email"`
Age int `json:"age"`
}
func TestBuildSchema(t *testing.T) {
schema := buildSchema(reflect.TypeOf(TestUser{}))
if schema.Table != "testusers" {
t.Errorf("expected table 'testusers', got %q", schema.Table)
}
if schema.Key != "id" {
t.Errorf("expected key 'id', got %q", schema.Key)
}
if len(schema.Fields) != 4 {
t.Fatalf("expected 4 fields, got %d", len(schema.Fields))
}
// Check key field
var keyField Field
var indexField Field
for _, f := range schema.Fields {
if f.IsKey {
keyField = f
}
if f.Index {
indexField = f
}
}
if keyField.Column != "id" {
t.Errorf("expected key column 'id', got %q", keyField.Column)
}
if indexField.Column != "name" {
t.Errorf("expected index column 'name', got %q", indexField.Column)
}
}
func TestBuildSchema_DefaultKey(t *testing.T) {
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
}
schema := buildSchema(reflect.TypeOf(Item{}))
if schema.Key != "id" {
t.Errorf("expected default key 'id', got %q", schema.Key)
}
}
func TestBuildSchema_WithTable(t *testing.T) {
schema := buildSchema(reflect.TypeOf(TestUser{}))
WithTable("my_users")(schema)
if schema.Table != "my_users" {
t.Errorf("expected table 'my_users', got %q", schema.Table)
}
}
func TestStructToMap(t *testing.T) {
schema := buildSchema(reflect.TypeOf(TestUser{}))
u := &TestUser{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30}
m := structToMap(schema, u)
if m["id"] != "1" {
t.Errorf("expected id '1', got %v", m["id"])
}
if m["name"] != "Alice" {
t.Errorf("expected name 'Alice', got %v", m["name"])
}
if m["email"] != "alice@example.com" {
t.Errorf("expected email 'alice@example.com', got %v", m["email"])
}
if m["age"] != 30 {
t.Errorf("expected age 30, got %v", m["age"])
}
}
func TestMapToStruct(t *testing.T) {
schema := buildSchema(reflect.TypeOf(TestUser{}))
m := map[string]any{
"id": "1",
"name": "Bob",
"email": "bob@example.com",
"age": 25,
}
u := mapToStruct[TestUser](schema, m)
if u.ID != "1" {
t.Errorf("expected ID '1', got %q", u.ID)
}
if u.Name != "Bob" {
t.Errorf("expected Name 'Bob', got %q", u.Name)
}
if u.Email != "bob@example.com" {
t.Errorf("expected Email 'bob@example.com', got %q", u.Email)
}
if u.Age != 25 {
t.Errorf("expected Age 25, got %d", u.Age)
}
}
func TestApplyQueryOptions(t *testing.T) {
q := ApplyQueryOptions(
Where("name", "Alice"),
WhereOp("age", ">", 20),
OrderDesc("name"),
Limit(10),
Offset(5),
)
if len(q.Filters) != 2 {
t.Fatalf("expected 2 filters, got %d", len(q.Filters))
}
if q.Filters[0].Field != "name" || q.Filters[0].Op != "=" || q.Filters[0].Value != "Alice" {
t.Errorf("unexpected filter 0: %+v", q.Filters[0])
}
if q.Filters[1].Field != "age" || q.Filters[1].Op != ">" {
t.Errorf("unexpected filter 1: %+v", q.Filters[1])
}
if q.OrderBy != "name" || !q.Desc {
t.Errorf("expected order by name desc, got %q desc=%v", q.OrderBy, q.Desc)
}
if q.Limit != 10 {
t.Errorf("expected limit 10, got %d", q.Limit)
}
if q.Offset != 5 {
t.Errorf("expected offset 5, got %d", q.Offset)
}
}
+22 -63
View File
@@ -1,77 +1,36 @@
package model
import (
"context"
)
// Option configures a Database.
type Option func(*DatabaseOptions)
// Options for model configuration
type Options struct {
// Context for the model
Context context.Context
// Model name (e.g., "gpt-4o", "claude-sonnet-4-20250514")
Model string
// APIKey for authentication
APIKey string
// BaseURL for the API endpoint
BaseURL string
// ToolHandler handles tool calls (optional, for automatic tool execution)
ToolHandler ToolHandler
// DatabaseOptions holds configuration for a Database backend.
type DatabaseOptions struct {
// DSN is the data source name / connection string.
DSN string
}
// GenerateOptions for generate call
type GenerateOptions struct {
// Context for this specific generate call
Context context.Context
}
// Option is a function that modifies Options
type Option func(*Options)
// GenerateOption is a function that modifies GenerateOptions
type GenerateOption func(*GenerateOptions)
// NewOptions creates new Options with defaults
func NewOptions(opts ...Option) Options {
options := Options{
Context: context.Background(),
}
for _, o := range opts {
o(&options)
}
return options
}
// WithModel sets the model name
func WithModel(m string) Option {
return func(o *Options) {
o.Model = m
// WithDSN sets the data source name for the database connection.
func WithDSN(dsn string) Option {
return func(o *DatabaseOptions) {
o.DSN = dsn
}
}
// WithAPIKey sets the API key
func WithAPIKey(key string) Option {
return func(o *Options) {
o.APIKey = key
// NewDatabaseOptions creates DatabaseOptions with defaults applied.
func NewDatabaseOptions(opts ...Option) DatabaseOptions {
o := DatabaseOptions{}
for _, opt := range opts {
opt(&o)
}
return o
}
// WithBaseURL sets the base URL
func WithBaseURL(url string) Option {
return func(o *Options) {
o.BaseURL = url
}
}
// ModelOption configures a Model instance.
type ModelOption func(*Schema)
// WithContext sets the context
func WithContext(ctx context.Context) Option {
return func(o *Options) {
o.Context = ctx
}
}
// WithToolHandler sets the tool handler
func WithToolHandler(handler ToolHandler) Option {
return func(o *Options) {
o.ToolHandler = handler
// WithTable overrides the auto-derived table name.
func WithTable(name string) ModelOption {
return func(s *Schema) {
s.Table = name
}
}
+328
View File
@@ -0,0 +1,328 @@
// Package postgres provides a PostgreSQL Database implementation for the model package.
// Uses lib/pq driver. Best for production deployments with rich query support.
package postgres
import (
"context"
"database/sql"
"fmt"
"reflect"
"strings"
_ "github.com/lib/pq"
"go-micro.dev/v5/model"
)
// Database is a PostgreSQL model.Database implementation.
type Database struct {
db *sql.DB
}
// New creates a new Postgres database. DSN is a connection string
// (e.g., "postgres://user:pass@localhost/dbname?sslmode=disable").
func New(dsn string) *Database {
db, err := sql.Open("postgres", dsn)
if err != nil {
panic(fmt.Sprintf("model/postgres: failed to open: %v", err))
}
return &Database{db: db}
}
func (d *Database) Init(opts ...model.Option) error {
return d.db.Ping()
}
func (d *Database) NewTable(schema *model.Schema) error {
var cols []string
for _, f := range schema.Fields {
colType := goTypeToPostgres(f.Type)
col := fmt.Sprintf("%s %s", quoteIdent(f.Column), colType)
if f.IsKey {
col += " PRIMARY KEY"
}
cols = append(cols, col)
}
query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (%s)", quoteIdent(schema.Table), strings.Join(cols, ", "))
if _, err := d.db.Exec(query); err != nil {
return fmt.Errorf("model/postgres: create table: %w", err)
}
// Create indexes
for _, f := range schema.Fields {
if f.Index && !f.IsKey {
idx := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)",
quoteIdent("idx_"+schema.Table+"_"+f.Column),
quoteIdent(schema.Table),
quoteIdent(f.Column))
if _, err := d.db.Exec(idx); err != nil {
return fmt.Errorf("model/postgres: create index: %w", err)
}
}
}
return nil
}
func (d *Database) Create(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error {
cols, placeholders, values := buildInsert(schema, fields)
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", quoteIdent(schema.Table), cols, placeholders)
_, err := d.db.ExecContext(ctx, query, values...)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") {
return model.ErrDuplicateKey
}
return fmt.Errorf("model/postgres: create: %w", err)
}
return nil
}
func (d *Database) Read(ctx context.Context, schema *model.Schema, key string) (map[string]any, error) {
cols := columnList(schema)
query := fmt.Sprintf("SELECT %s FROM %s WHERE %s = $1", cols, quoteIdent(schema.Table), quoteIdent(schema.Key))
row := d.db.QueryRowContext(ctx, query, key)
return scanRow(schema, row)
}
func (d *Database) Update(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error {
setClauses, values := buildUpdate(schema, fields)
values = append(values, key)
paramIdx := len(values)
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s = $%d",
quoteIdent(schema.Table), setClauses, quoteIdent(schema.Key), paramIdx)
result, err := d.db.ExecContext(ctx, query, values...)
if err != nil {
return fmt.Errorf("model/postgres: update: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return model.ErrNotFound
}
return nil
}
func (d *Database) Delete(ctx context.Context, schema *model.Schema, key string) error {
query := fmt.Sprintf("DELETE FROM %s WHERE %s = $1", quoteIdent(schema.Table), quoteIdent(schema.Key))
result, err := d.db.ExecContext(ctx, query, key)
if err != nil {
return fmt.Errorf("model/postgres: delete: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return model.ErrNotFound
}
return nil
}
func (d *Database) List(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) ([]map[string]any, error) {
q := model.ApplyQueryOptions(opts...)
cols := columnList(schema)
query := fmt.Sprintf("SELECT %s FROM %s", cols, quoteIdent(schema.Table))
var args []any
paramN := 1
if len(q.Filters) > 0 {
where, fArgs, nextParam := buildWhere(q.Filters, paramN)
query += " WHERE " + where
args = append(args, fArgs...)
paramN = nextParam
}
if q.OrderBy != "" {
dir := "ASC"
if q.Desc {
dir = "DESC"
}
query += fmt.Sprintf(" ORDER BY %s %s", quoteIdent(q.OrderBy), dir)
}
if q.Limit > 0 {
query += fmt.Sprintf(" LIMIT %d", q.Limit)
}
if q.Offset > 0 {
query += fmt.Sprintf(" OFFSET %d", q.Offset)
}
rows, err := d.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("model/postgres: list: %w", err)
}
defer rows.Close()
return scanRows(schema, rows)
}
func (d *Database) Count(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) (int64, error) {
q := model.ApplyQueryOptions(opts...)
query := fmt.Sprintf("SELECT COUNT(*) FROM %s", quoteIdent(schema.Table))
var args []any
paramN := 1
if len(q.Filters) > 0 {
where, fArgs, _ := buildWhere(q.Filters, paramN)
query += " WHERE " + where
args = append(args, fArgs...)
}
var count int64
err := d.db.QueryRowContext(ctx, query, args...).Scan(&count)
if err != nil {
return 0, fmt.Errorf("model/postgres: count: %w", err)
}
return count, nil
}
func (d *Database) Close() error {
return d.db.Close()
}
func (d *Database) String() string {
return "postgres"
}
// SQL helpers
func quoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
func goTypeToPostgres(t reflect.Type) string {
switch t.Kind() {
case reflect.Int, reflect.Int64:
return "BIGINT"
case reflect.Int8, reflect.Int16, reflect.Int32:
return "INTEGER"
case reflect.Uint, reflect.Uint64:
return "BIGINT"
case reflect.Uint8, reflect.Uint16, reflect.Uint32:
return "INTEGER"
case reflect.Float32:
return "REAL"
case reflect.Float64:
return "DOUBLE PRECISION"
case reflect.Bool:
return "BOOLEAN"
default:
return "TEXT"
}
}
func buildInsert(schema *model.Schema, fields map[string]any) (string, string, []any) {
var cols []string
var placeholders []string
var values []any
i := 1
for _, f := range schema.Fields {
if v, ok := fields[f.Column]; ok {
cols = append(cols, quoteIdent(f.Column))
placeholders = append(placeholders, fmt.Sprintf("$%d", i))
values = append(values, v)
i++
}
}
return strings.Join(cols, ", "), strings.Join(placeholders, ", "), values
}
func buildUpdate(schema *model.Schema, fields map[string]any) (string, []any) {
var setClauses []string
var values []any
i := 1
for _, f := range schema.Fields {
if f.IsKey {
continue
}
if v, ok := fields[f.Column]; ok {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", quoteIdent(f.Column), i))
values = append(values, v)
i++
}
}
return strings.Join(setClauses, ", "), values
}
func buildWhere(filters []model.Filter, startParam int) (string, []any, int) {
var clauses []string
var args []any
n := startParam
for _, f := range filters {
clauses = append(clauses, fmt.Sprintf("%s %s $%d", quoteIdent(f.Field), f.Op, n))
args = append(args, f.Value)
n++
}
return strings.Join(clauses, " AND "), args, n
}
func columnList(schema *model.Schema) string {
var cols []string
for _, f := range schema.Fields {
cols = append(cols, quoteIdent(f.Column))
}
return strings.Join(cols, ", ")
}
func scanRow(schema *model.Schema, row *sql.Row) (map[string]any, error) {
ptrs := make([]any, len(schema.Fields))
for i, f := range schema.Fields {
ptrs[i] = newScanPtr(f.Type)
}
if err := row.Scan(ptrs...); err != nil {
if err == sql.ErrNoRows {
return nil, model.ErrNotFound
}
return nil, fmt.Errorf("model/postgres: scan: %w", err)
}
result := make(map[string]any, len(schema.Fields))
for i, f := range schema.Fields {
result[f.Column] = derefScanPtr(ptrs[i], f.Type)
}
return result, nil
}
func scanRows(schema *model.Schema, rows *sql.Rows) ([]map[string]any, error) {
var results []map[string]any
for rows.Next() {
ptrs := make([]any, len(schema.Fields))
for i, f := range schema.Fields {
ptrs[i] = newScanPtr(f.Type)
}
if err := rows.Scan(ptrs...); err != nil {
return nil, fmt.Errorf("model/postgres: scan row: %w", err)
}
row := make(map[string]any, len(schema.Fields))
for i, f := range schema.Fields {
row[f.Column] = derefScanPtr(ptrs[i], f.Type)
}
results = append(results, row)
}
return results, rows.Err()
}
func newScanPtr(t reflect.Type) any {
switch t.Kind() {
case reflect.String:
return new(string)
case reflect.Int, reflect.Int64:
return new(int64)
case reflect.Int32:
return new(int32)
case reflect.Float64:
return new(float64)
case reflect.Float32:
return new(float32)
case reflect.Bool:
return new(bool)
default:
return new(string)
}
}
func derefScanPtr(ptr any, t reflect.Type) any {
rv := reflect.ValueOf(ptr).Elem()
if rv.Type().ConvertibleTo(t) {
return rv.Convert(t).Interface()
}
return rv.Interface()
}
+73
View File
@@ -0,0 +1,73 @@
package model
// QueryOptions configures a List or Count operation.
type QueryOptions struct {
Filters []Filter
OrderBy string
Desc bool
Limit uint
Offset uint
}
// Filter represents a field-level query condition.
type Filter struct {
Field string // Column name
Op string // Operator: =, !=, <, >, <=, >=, LIKE
Value any // Comparison value
}
// QueryOption sets values in QueryOptions.
type QueryOption func(*QueryOptions)
// ApplyQueryOptions applies a set of QueryOptions and returns the result.
func ApplyQueryOptions(opts ...QueryOption) QueryOptions {
q := QueryOptions{}
for _, o := range opts {
o(&q)
}
return q
}
// Where adds an equality filter: field = value.
func Where(field string, value any) QueryOption {
return func(q *QueryOptions) {
q.Filters = append(q.Filters, Filter{Field: field, Op: "=", Value: value})
}
}
// WhereOp adds a filter with a custom operator (=, !=, <, >, <=, >=, LIKE).
func WhereOp(field, op string, value any) QueryOption {
return func(q *QueryOptions) {
q.Filters = append(q.Filters, Filter{Field: field, Op: op, Value: value})
}
}
// OrderAsc orders results by field ascending.
func OrderAsc(field string) QueryOption {
return func(q *QueryOptions) {
q.OrderBy = field
q.Desc = false
}
}
// OrderDesc orders results by field descending.
func OrderDesc(field string) QueryOption {
return func(q *QueryOptions) {
q.OrderBy = field
q.Desc = true
}
}
// Limit limits the number of returned records.
func Limit(n uint) QueryOption {
return func(q *QueryOptions) {
q.Limit = n
}
}
// Offset skips the first n records (for pagination).
func Offset(n uint) QueryOption {
return func(q *QueryOptions) {
q.Offset = n
}
}
+311
View File
@@ -0,0 +1,311 @@
// Package sqlite provides a SQLite Database implementation for the model package.
// Uses mattn/go-sqlite3 for broad compatibility.
// Good for development, testing, and single-node production.
package sqlite
import (
"context"
"database/sql"
"fmt"
"reflect"
"strings"
_ "github.com/mattn/go-sqlite3"
"go-micro.dev/v5/model"
)
// Database is a SQLite model.Database implementation.
type Database struct {
db *sql.DB
}
// New creates a new SQLite database. DSN is the file path (e.g., "data.db" or ":memory:").
func New(dsn string) *Database {
if dsn == "" {
dsn = ":memory:"
}
db, err := sql.Open("sqlite3", dsn)
if err != nil {
panic(fmt.Sprintf("model/sqlite: failed to open %q: %v", dsn, err))
}
// Enable WAL mode for better concurrent read performance
db.Exec("PRAGMA journal_mode=WAL")
return &Database{db: db}
}
func (d *Database) Init(opts ...model.Option) error {
return d.db.Ping()
}
func (d *Database) NewTable(schema *model.Schema) error {
var cols []string
for _, f := range schema.Fields {
colType := goTypeToSQLite(f.Type)
col := fmt.Sprintf("%q %s", f.Column, colType)
if f.IsKey {
col += " PRIMARY KEY"
}
cols = append(cols, col)
}
query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %q (%s)", schema.Table, strings.Join(cols, ", "))
if _, err := d.db.Exec(query); err != nil {
return fmt.Errorf("model/sqlite: create table: %w", err)
}
// Create indexes
for _, f := range schema.Fields {
if f.Index && !f.IsKey {
idx := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %q ON %q (%q)",
"idx_"+schema.Table+"_"+f.Column, schema.Table, f.Column)
if _, err := d.db.Exec(idx); err != nil {
return fmt.Errorf("model/sqlite: create index: %w", err)
}
}
}
return nil
}
func (d *Database) Create(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error {
cols, placeholders, values := buildInsert(schema, fields)
query := fmt.Sprintf("INSERT INTO %q (%s) VALUES (%s)", schema.Table, cols, placeholders)
_, err := d.db.ExecContext(ctx, query, values...)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint") || strings.Contains(err.Error(), "PRIMARY KEY") {
return model.ErrDuplicateKey
}
return fmt.Errorf("model/sqlite: create: %w", err)
}
return nil
}
func (d *Database) Read(ctx context.Context, schema *model.Schema, key string) (map[string]any, error) {
cols := columnList(schema)
query := fmt.Sprintf("SELECT %s FROM %q WHERE %q = ?", cols, schema.Table, schema.Key)
row := d.db.QueryRowContext(ctx, query, key)
return scanRow(schema, row)
}
func (d *Database) Update(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error {
setClauses, values := buildUpdate(schema, fields)
values = append(values, key)
query := fmt.Sprintf("UPDATE %q SET %s WHERE %q = ?", schema.Table, setClauses, schema.Key)
result, err := d.db.ExecContext(ctx, query, values...)
if err != nil {
return fmt.Errorf("model/sqlite: update: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return model.ErrNotFound
}
return nil
}
func (d *Database) Delete(ctx context.Context, schema *model.Schema, key string) error {
query := fmt.Sprintf("DELETE FROM %q WHERE %q = ?", schema.Table, schema.Key)
result, err := d.db.ExecContext(ctx, query, key)
if err != nil {
return fmt.Errorf("model/sqlite: delete: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return model.ErrNotFound
}
return nil
}
func (d *Database) List(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) ([]map[string]any, error) {
q := model.ApplyQueryOptions(opts...)
cols := columnList(schema)
query := fmt.Sprintf("SELECT %s FROM %q", cols, schema.Table)
var args []any
if len(q.Filters) > 0 {
where, fArgs := buildWhere(q.Filters)
query += " WHERE " + where
args = append(args, fArgs...)
}
if q.OrderBy != "" {
dir := "ASC"
if q.Desc {
dir = "DESC"
}
query += fmt.Sprintf(" ORDER BY %q %s", q.OrderBy, dir)
}
if q.Limit > 0 {
query += fmt.Sprintf(" LIMIT %d", q.Limit)
}
if q.Offset > 0 {
query += fmt.Sprintf(" OFFSET %d", q.Offset)
}
rows, err := d.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("model/sqlite: list: %w", err)
}
defer rows.Close()
return scanRows(schema, rows)
}
func (d *Database) Count(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) (int64, error) {
q := model.ApplyQueryOptions(opts...)
query := fmt.Sprintf("SELECT COUNT(*) FROM %q", schema.Table)
var args []any
if len(q.Filters) > 0 {
where, fArgs := buildWhere(q.Filters)
query += " WHERE " + where
args = append(args, fArgs...)
}
var count int64
err := d.db.QueryRowContext(ctx, query, args...).Scan(&count)
if err != nil {
return 0, fmt.Errorf("model/sqlite: count: %w", err)
}
return count, nil
}
func (d *Database) Close() error {
return d.db.Close()
}
func (d *Database) String() string {
return "sqlite"
}
// SQL helpers
func goTypeToSQLite(t reflect.Type) string {
switch t.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "INTEGER"
case reflect.Float32, reflect.Float64:
return "REAL"
case reflect.Bool:
return "INTEGER"
default:
return "TEXT"
}
}
func buildInsert(schema *model.Schema, fields map[string]any) (string, string, []any) {
var cols []string
var placeholders []string
var values []any
for _, f := range schema.Fields {
if v, ok := fields[f.Column]; ok {
cols = append(cols, fmt.Sprintf("%q", f.Column))
placeholders = append(placeholders, "?")
values = append(values, v)
}
}
return strings.Join(cols, ", "), strings.Join(placeholders, ", "), values
}
func buildUpdate(schema *model.Schema, fields map[string]any) (string, []any) {
var setClauses []string
var values []any
for _, f := range schema.Fields {
if f.IsKey {
continue
}
if v, ok := fields[f.Column]; ok {
setClauses = append(setClauses, fmt.Sprintf("%q = ?", f.Column))
values = append(values, v)
}
}
return strings.Join(setClauses, ", "), values
}
func buildWhere(filters []model.Filter) (string, []any) {
var clauses []string
var args []any
for _, f := range filters {
clauses = append(clauses, fmt.Sprintf("%q %s ?", f.Field, f.Op))
args = append(args, f.Value)
}
return strings.Join(clauses, " AND "), args
}
func columnList(schema *model.Schema) string {
var cols []string
for _, f := range schema.Fields {
cols = append(cols, fmt.Sprintf("%q", f.Column))
}
return strings.Join(cols, ", ")
}
func scanRow(schema *model.Schema, row *sql.Row) (map[string]any, error) {
ptrs := make([]any, len(schema.Fields))
for i, f := range schema.Fields {
ptrs[i] = newScanPtr(f.Type)
}
if err := row.Scan(ptrs...); err != nil {
if err == sql.ErrNoRows {
return nil, model.ErrNotFound
}
return nil, fmt.Errorf("model/sqlite: scan: %w", err)
}
result := make(map[string]any, len(schema.Fields))
for i, f := range schema.Fields {
result[f.Column] = derefScanPtr(ptrs[i], f.Type)
}
return result, nil
}
func scanRows(schema *model.Schema, rows *sql.Rows) ([]map[string]any, error) {
var results []map[string]any
for rows.Next() {
ptrs := make([]any, len(schema.Fields))
for i, f := range schema.Fields {
ptrs[i] = newScanPtr(f.Type)
}
if err := rows.Scan(ptrs...); err != nil {
return nil, fmt.Errorf("model/sqlite: scan row: %w", err)
}
row := make(map[string]any, len(schema.Fields))
for i, f := range schema.Fields {
row[f.Column] = derefScanPtr(ptrs[i], f.Type)
}
results = append(results, row)
}
return results, rows.Err()
}
// newScanPtr returns a pointer suitable for sql.Scan based on the Go type.
func newScanPtr(t reflect.Type) any {
switch t.Kind() {
case reflect.String:
return new(string)
case reflect.Int, reflect.Int64:
return new(int64)
case reflect.Int32:
return new(int32)
case reflect.Float64:
return new(float64)
case reflect.Float32:
return new(float32)
case reflect.Bool:
return new(bool)
default:
return new(string)
}
}
// derefScanPtr extracts the scanned value and converts to the target Go type.
func derefScanPtr(ptr any, t reflect.Type) any {
rv := reflect.ValueOf(ptr).Elem()
if rv.Type().ConvertibleTo(t) {
return rv.Convert(t).Interface()
}
return rv.Interface()
}
+224
View File
@@ -0,0 +1,224 @@
package sqlite
import (
"context"
"testing"
"go-micro.dev/v5/model"
)
type User struct {
ID string `json:"id" model:"key"`
Name string `json:"name" model:"index"`
Email string `json:"email"`
Age int `json:"age"`
}
func setup(t *testing.T) *model.Model[User] {
t.Helper()
db := New(":memory:")
return model.New[User](db)
}
func TestCRUD(t *testing.T) {
users := setup(t)
ctx := context.Background()
// Create
err := users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@test.com", Age: 30})
if err != nil {
t.Fatalf("create: %v", err)
}
// Read
u, err := users.Read(ctx, "1")
if err != nil {
t.Fatalf("read: %v", err)
}
if u.Name != "Alice" {
t.Errorf("expected Alice, got %s", u.Name)
}
if u.Age != 30 {
t.Errorf("expected age 30, got %d", u.Age)
}
// Update
u.Name = "Alice Updated"
u.Age = 31
err = users.Update(ctx, u)
if err != nil {
t.Fatalf("update: %v", err)
}
u2, _ := users.Read(ctx, "1")
if u2.Name != "Alice Updated" {
t.Errorf("expected 'Alice Updated', got %s", u2.Name)
}
if u2.Age != 31 {
t.Errorf("expected age 31, got %d", u2.Age)
}
// Delete
err = users.Delete(ctx, "1")
if err != nil {
t.Fatalf("delete: %v", err)
}
_, err = users.Read(ctx, "1")
if err != model.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestDuplicateKey(t *testing.T) {
users := setup(t)
ctx := context.Background()
users.Create(ctx, &User{ID: "1", Name: "Alice"})
err := users.Create(ctx, &User{ID: "1", Name: "Bob"})
if err != model.ErrDuplicateKey {
t.Errorf("expected ErrDuplicateKey, got %v", err)
}
}
func TestNotFound(t *testing.T) {
users := setup(t)
ctx := context.Background()
_, err := users.Read(ctx, "nonexistent")
if err != model.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
err = users.Update(ctx, &User{ID: "nonexistent"})
if err != model.ErrNotFound {
t.Errorf("expected ErrNotFound on update, got %v", err)
}
err = users.Delete(ctx, "nonexistent")
if err != model.ErrNotFound {
t.Errorf("expected ErrNotFound on delete, got %v", err)
}
}
func TestListWithFilter(t *testing.T) {
users := setup(t)
ctx := context.Background()
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
users.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35})
results, err := users.List(ctx, model.Where("name", "Alice"))
if err != nil {
t.Fatalf("list: %v", err)
}
if len(results) != 2 {
t.Errorf("expected 2 Alices, got %d", len(results))
}
}
func TestListWithOrder(t *testing.T) {
users := setup(t)
ctx := context.Background()
users.Create(ctx, &User{ID: "1", Name: "Charlie", Age: 35})
users.Create(ctx, &User{ID: "2", Name: "Alice", Age: 30})
users.Create(ctx, &User{ID: "3", Name: "Bob", Age: 25})
results, err := users.List(ctx, model.OrderAsc("name"))
if err != nil {
t.Fatalf("list: %v", err)
}
if len(results) != 3 {
t.Fatalf("expected 3, got %d", len(results))
}
if results[0].Name != "Alice" {
t.Errorf("expected Alice first, got %s", results[0].Name)
}
if results[2].Name != "Charlie" {
t.Errorf("expected Charlie last, got %s", results[2].Name)
}
}
func TestListWithLimitOffset(t *testing.T) {
users := setup(t)
ctx := context.Background()
users.Create(ctx, &User{ID: "1", Name: "A", Age: 1})
users.Create(ctx, &User{ID: "2", Name: "B", Age: 2})
users.Create(ctx, &User{ID: "3", Name: "C", Age: 3})
users.Create(ctx, &User{ID: "4", Name: "D", Age: 4})
results, err := users.List(ctx,
model.OrderAsc("name"),
model.Limit(2),
model.Offset(1),
)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2, got %d", len(results))
}
if results[0].Name != "B" {
t.Errorf("expected B, got %s", results[0].Name)
}
if results[1].Name != "C" {
t.Errorf("expected C, got %s", results[1].Name)
}
}
func TestCount(t *testing.T) {
users := setup(t)
ctx := context.Background()
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
users.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35})
count, err := users.Count(ctx)
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 3 {
t.Errorf("expected 3, got %d", count)
}
count, err = users.Count(ctx, model.Where("name", "Alice"))
if err != nil {
t.Fatalf("count with filter: %v", err)
}
if count != 2 {
t.Errorf("expected 2, got %d", count)
}
}
func TestWhereOp(t *testing.T) {
users := setup(t)
ctx := context.Background()
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
users.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35})
results, err := users.List(ctx, model.WhereOp("age", ">", 28))
if err != nil {
t.Fatalf("list: %v", err)
}
if len(results) != 2 {
t.Errorf("expected 2 (age > 28), got %d", len(results))
}
}
func TestSchema(t *testing.T) {
users := setup(t)
schema := users.Schema()
if schema.Table != "users" {
t.Errorf("expected table 'users', got %q", schema.Table)
}
if schema.Key != "id" {
t.Errorf("expected key 'id', got %q", schema.Key)
}
}
+1
View File
@@ -14,6 +14,7 @@ var HandleSignal = service.HandleSignal
var Profile = service.Profile
var Server = service.Server
var Store = service.Store
var Model = service.Model
var Registry = service.Registry
var Tracer = service.Tracer
var Auth = service.Auth
+11
View File
@@ -14,6 +14,8 @@ import (
"go-micro.dev/v5/debug/profile"
"go-micro.dev/v5/debug/trace"
"go-micro.dev/v5/logger"
"go-micro.dev/v5/model"
"go-micro.dev/v5/model/memory"
"go-micro.dev/v5/registry"
"go-micro.dev/v5/selector"
"go-micro.dev/v5/server"
@@ -30,6 +32,7 @@ type Options struct {
Config config.Config
Client client.Client
Server server.Server
Model model.Database
// Other options for implementations of the interface
// can be stored in a context
@@ -63,6 +66,7 @@ func newOptions(opts ...Option) Options {
Client: client.NewClient(),
Server: server.NewRPCServer(),
Store: store.NewStore(),
Model: memory.New(),
Cache: cache.NewCache(),
Registry: registry.DefaultRegistry,
Transport: transport.DefaultTransport,
@@ -154,6 +158,13 @@ func Store(s store.Store) Option {
}
}
// Model sets the model database to use.
func Model(db model.Database) Option {
return func(o *Options) {
o.Model = db
}
}
// Registry sets the registry for the service
// and the underlying components.
func Registry(r registry.Registry) Option {
+8
View File
@@ -9,6 +9,7 @@ import (
"go-micro.dev/v5/client"
"go-micro.dev/v5/cmd"
log "go-micro.dev/v5/logger"
"go-micro.dev/v5/model"
"go-micro.dev/v5/server"
"go-micro.dev/v5/store"
signalutil "go-micro.dev/v5/util/signal"
@@ -28,6 +29,8 @@ type Service interface {
Client() client.Client
// Server returns the RPC server.
Server() server.Server
// Model returns the data model database.
Model() model.Database
// Start the service (non-blocking).
Start() error
// Stop the service.
@@ -105,6 +108,11 @@ func (s *serviceImpl) Server() server.Server {
return s.opts.Server
}
func (s *serviceImpl) Model() model.Database {
return s.opts.Model
}
func (s *serviceImpl) String() string {
return "micro"
}