Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b41c71681 | |||
| 550033dcce | |||
| 584a9f2132 | |||
| 6488d8402d | |||
| d3be610367 | |||
| 35bc58e5d2 | |||
| e416ea4a75 | |||
| 830c8d84c2 | |||
| 6a73608e9c | |||
| cb33decd97 | |||
| 9bed04ced0 | |||
| 2e89961386 | |||
| 39e8dc7311 | |||
| c93bcdf4d3 | |||
| cd760a29f1 | |||
| e45d3df0ad | |||
| 6731ee2f0c | |||
| f7c042ef26 | |||
| 1bc886fa82 | |||
| 844ac5bc52 | |||
| bc570d674f | |||
| e59d056f97 | |||
| e13ef7eed5 | |||
| 23f682181c | |||
| bc1092b18a | |||
| 16918669ca | |||
| a357d1870d | |||
| 91a545c6f2 | |||
| 00392aa1f2 | |||
| b54507710d | |||
| d541625e32 | |||
| 7662b26b07 | |||
| 397010a82a | |||
| d3c4981326 | |||
| 96280678ee | |||
| 69fc228c73 | |||
| 5d7609027b | |||
| 5601be009a | |||
| 888dbbca4a | |||
| f48d81c760 | |||
| 3acf74a29a | |||
| c4b4cbef25 | |||
| a9421e5b7e | |||
| 03f912f68a | |||
| 594ee107b7 | |||
| 2f164595f2 | |||
| 740980a9cc | |||
| 3d2d9acd68 | |||
| 88aa0cc666 | |||
| 601c67675f | |||
| b97f45106d | |||
| 28fa44b2ca | |||
| 985621f4b4 | |||
| 7a1ab14847 | |||
| c26d74a8a1 | |||
| 669481224c | |||
| 86782e6d77 | |||
| 1e2901ad0e | |||
| 426d7e4e6c | |||
| 5968fce2d6 | |||
| 415b0a76c2 | |||
| 7ef9a60441 | |||
| be156ecb95 | |||
| 713dabbb5a | |||
| bbea48da78 | |||
| a0ad9ee566 | |||
| f5e33317b3 | |||
| 081e375f29 | |||
| 2f78103fed | |||
| 60527e1fe8 | |||
| defb2786ef | |||
| 15cc876848 | |||
| d3cfb3a99a | |||
| b1680ca2da | |||
| 90f7617fac | |||
| 79722e0c27 | |||
| 49070afa8c | |||
| 03f4759474 | |||
| f6fb541ace | |||
| 1426c8f349 | |||
| 5aedb601ba | |||
| bd13975acf | |||
| 213a09276e | |||
| f4291957e8 | |||
| 07411b1ef6 | |||
| 544496eec6 | |||
| 2e95c4c610 | |||
| 7b785df302 | |||
| d96f12f57b | |||
| 8608c15fbb | |||
| 9823f6df5c | |||
| 28f7f53143 | |||
| 0842431050 | |||
| 7a5d86a2a4 | |||
| 8ecdf07e6b | |||
| 1bb25d6e7f | |||
| b8fc0902d7 | |||
| 524e16296b | |||
| d91812b476 | |||
| 76bfeae456 | |||
| f07a49e0d9 | |||
| 6247b1d065 | |||
| c800dd3729 | |||
| 870b540922 | |||
| fad2fd7af4 | |||
| 19892f2c67 | |||
| d2036b880d | |||
| cad0ff1e49 | |||
| ffe43e0e6d | |||
| ec9473f86f | |||
| 4c673f61b1 | |||
| bed94bfa95 | |||
| d02ff2ecfa | |||
| 6d9645adce | |||
| beeaad748e | |||
| 076b7c37be | |||
| ab6f027741 | |||
| 14ec9b955f | |||
| a789c9e94b | |||
| e110ccb5ff | |||
| e3337efd81 | |||
| 13d1116dee | |||
| 1db7903010 | |||
| 5e1042e5ae | |||
| 0f6453488e |
@@ -48,4 +48,4 @@ Add any other context about the problem here.
|
||||
- [Troubleshooting Guide](https://github.com/micro/go-micro/tree/master/internal/website/docs/getting-started.md)
|
||||
- [Examples](https://github.com/micro/go-micro/tree/master/examples)
|
||||
- [API Reference](https://pkg.go.dev/go-micro.dev/v5)
|
||||
- [Discord Community](https://discord.gg/jwTYuUVAGh)
|
||||
- [Discord Community](https://discord.gg/WeMU5AGxD)
|
||||
|
||||
@@ -39,4 +39,4 @@ Add any other context, code examples, or screenshots about the feature request h
|
||||
- [Roadmap](https://github.com/micro/go-micro/blob/master/ROADMAP.md)
|
||||
- [Contributing Guide](https://github.com/micro/go-micro/blob/master/CONTRIBUTING.md)
|
||||
- [Architecture Docs](https://github.com/micro/go-micro/tree/master/internal/website/docs/architecture.md)
|
||||
- [Discord Community](https://discord.gg/jwTYuUVAGh)
|
||||
- [Discord Community](https://discord.gg/WeMU5AGxD)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: goreleaser
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
packages: write
|
||||
attestations: write
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
-
|
||||
name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
-
|
||||
name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
-
|
||||
name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -57,3 +57,7 @@ examples/mcp/hello/hello
|
||||
# IDE-specific files
|
||||
.DS_Store
|
||||
/micro
|
||||
|
||||
# Built example/harness binaries (go build ./path/... drops these at repo root)
|
||||
/plan-delegate
|
||||
/agent-plan-delegate
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- main: ./cmd/micro
|
||||
id: micro
|
||||
binary: micro
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
- >-
|
||||
{{- if eq .Os "darwin" }}
|
||||
{{- if eq .Arch "amd64"}}CC=o64-clang{{- end }}
|
||||
{{- if eq .Arch "arm64"}}CC=aarch64-apple-darwin20.2-clang{{- end }}
|
||||
{{- end }}
|
||||
{{- if eq .Os "windows" }}
|
||||
{{- if eq .Arch "amd64" }}CC=x86_64-w64-mingw32-gcc{{- end }}
|
||||
{{- end }}
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
goarm:
|
||||
- 7
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
- main: ./cmd/protoc-gen-micro
|
||||
id: protoc-gen-micro
|
||||
binary: protoc-gen-micro
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
- >-
|
||||
{{- if eq .Os "darwin" }}
|
||||
{{- if eq .Arch "amd64"}}CC=o64-clang{{- end }}
|
||||
{{- if eq .Arch "arm64"}}CC=aarch64-apple-darwin20.2-clang{{- end }}
|
||||
{{- end }}
|
||||
{{- if eq .Os "windows" }}
|
||||
{{- if eq .Arch "amd64" }}CC=x86_64-w64-mingw32-gcc{{- end }}
|
||||
{{- end }}
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
goarm:
|
||||
- 7
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
archives:
|
||||
- id: micro
|
||||
ids:
|
||||
- micro
|
||||
formats: [tar.gz]
|
||||
name_template: >-
|
||||
{{ .Binary }}_
|
||||
{{- .Os }}_
|
||||
{{- .Arch }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
files:
|
||||
- none*
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
- id: protoc-gen-micro
|
||||
ids:
|
||||
- protoc-gen-micro
|
||||
formats: [tar.gz]
|
||||
name_template: >-
|
||||
{{ .Binary }}_
|
||||
{{- .Os }}_
|
||||
{{- .Arch }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
files:
|
||||
- none*
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
report_sizes: true
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
|
||||
dockers_v2:
|
||||
-
|
||||
ids:
|
||||
- micro
|
||||
- protoc-gen-micro
|
||||
images:
|
||||
- "micro/micro"
|
||||
- "ghcr.io/micro/go-micro"
|
||||
tags:
|
||||
- "v{{ .Version }}"
|
||||
- "{{ if .IsNightly }}nightly{{ end }}"
|
||||
- "{{ if not .IsNightly }}latest{{ end }}"
|
||||
labels:
|
||||
"io.artifacthub.package.readme-url": "https://raw.githubusercontent.com/micro/go-micro/refs/heads/master/README.md"
|
||||
"io.artifacthub.package.logo-url": "https://www.gravatar.com/avatar/09d1da3ea9ee61753219a19016d6a672?s=120&r=g&d=404"
|
||||
"org.opencontainers.image.description": "A Go Platform built for Developers"
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"org.opencontainers.image.url": "{{.GitURL}}"
|
||||
"org.opencontainers.image.licenses": "MIT"
|
||||
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
|
||||
retry:
|
||||
attempts: 5
|
||||
delay: 5s
|
||||
max_delay: 2m
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Go Micro are documented here.
|
||||
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/). Go Micro uses
|
||||
calendar-based versions (YYYY.MM) for the AI-native era.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Agent platform showcase** — full platform example (Users, Posts, Comments, Mail) mirroring [micro/blog](https://github.com/micro/blog), demonstrating how existing microservices become agent-accessible with zero code changes (`examples/mcp/platform/`).
|
||||
- **Blog post: "Your Microservices Are Already an AI Platform"** — walkthrough of agent-service interaction patterns using real-world services (`internal/website/blog/7.md`).
|
||||
- **Circuit breakers for MCP gateway** — per-tool circuit breakers protect downstream services from cascading failures. Configurable max failures, open-state timeout, and half-open probing. Available via `Options.CircuitBreaker` and `--circuit-breaker` CLI flag (`gateway/mcp/circuitbreaker.go`).
|
||||
- **Helm chart for MCP gateway** — official Helm chart at `deploy/helm/mcp-gateway/` with Deployment, Service, ServiceAccount, HPA, and Ingress templates. Supports Consul/etcd/mDNS registries, JWT auth, rate limiting, audit logging, per-tool scopes, TLS ingress, and auto-scaling.
|
||||
- **MCP gateway benchmarks** — comprehensive benchmark suite for tool listing, lookup, auth, rate limiting, and JSON serialization (`gateway/mcp/benchmark_test.go`)
|
||||
- **Workflow example** — cross-service orchestration demo with Inventory, Orders, and Notifications services showing agents chaining multi-step workflows from natural language (`examples/mcp/workflow/`)
|
||||
- **Docker Compose deployment** — production-like setup with Consul registry, standalone MCP gateway, and Jaeger tracing in one `docker-compose up` (`examples/deployment/`)
|
||||
|
||||
---
|
||||
|
||||
## [2026.03] - March 2026
|
||||
|
||||
### Added
|
||||
|
||||
#### Developer Experience
|
||||
- **`micro new` MCP templates** — `micro new myservice` generates MCP-enabled services with doc comments, `@example` tags, and `WithMCP()` wired in. Use `--no-mcp` to opt out.
|
||||
- **`micro.New("name")` unified API** — single way to create services: `micro.New("greeter")` or `micro.New("greeter", micro.Address(":8080"))`. Replaces `micro.NewService()` + `service.New()` dual API.
|
||||
- **`service.Handle()` simplified registration** — register handlers with `service.Handle(new(Greeter))` instead of manual `server.NewHandler` + `server.Handle`.
|
||||
- **`micro.NewGroup()` modular monoliths** — run multiple services in one binary with shared lifecycle: `micro.NewGroup(users, orders).Run()`.
|
||||
- **`mcp.WithMCP()` one-liner** — add MCP to any service with a single option: `micro.New("name", mcp.WithMCP(":3001"))`.
|
||||
- **CRUD example** — contact book service with 6 operations, rich agent docs, and validation patterns (`examples/mcp/crud/`).
|
||||
|
||||
#### MCP Gateway
|
||||
- **WebSocket transport** — bidirectional JSON-RPC 2.0 streaming over WebSocket for real-time agent communication (`gateway/mcp/websocket.go`).
|
||||
- **OpenTelemetry integration** — full span instrumentation across HTTP, stdio, and WebSocket transports with W3C trace context propagation (`gateway/mcp/otel.go`).
|
||||
- **Standalone gateway binary** — `micro-mcp-gateway` with Docker support for running the MCP gateway independently of services.
|
||||
- **Per-tool auth scopes** — service-level (`server.WithEndpointScopes()`) and gateway-level (`Options.Scopes`) scope enforcement with bearer token auth.
|
||||
- **Rate limiting** — per-tool token bucket rate limiting (`Options.RateLimit`).
|
||||
- **Audit logging** — immutable audit records per tool call with trace ID, account, scopes, duration, and errors (`Options.AuditFunc`).
|
||||
|
||||
#### AI Model Package
|
||||
- **`model.Model` interface** — unified AI provider abstraction with `Generate()` and `Stream()` methods.
|
||||
- **Anthropic Claude provider** — `model/anthropic` with tool execution and auto-calling.
|
||||
- **OpenAI GPT provider** — `model/openai` with provider auto-detection from base URL.
|
||||
|
||||
#### Agent SDKs
|
||||
- **LangChain SDK** — `contrib/langchain-go-micro/` Python package with auto-discovery, tool generation, and multi-agent workflow examples.
|
||||
- **LlamaIndex SDK** — `contrib/go-micro-llamaindex/` Python package with RAG integration examples.
|
||||
|
||||
#### Documentation
|
||||
- **AI-native services guide** — building services for AI agents from scratch
|
||||
- **MCP security guide** — auth, scopes, and audit logging
|
||||
- **Tool descriptions guide** — writing doc comments that improve agent performance
|
||||
- **Agent patterns guide** — architecture patterns for agent integration
|
||||
- **Error handling guide** — writing agent-friendly error responses with typed errors
|
||||
- **Troubleshooting guide** — common MCP issues and solutions
|
||||
- **Migration guide** — add MCP to existing services in 5 minutes
|
||||
|
||||
#### CLI
|
||||
- **`micro mcp serve`** — start MCP server (stdio for Claude Code, HTTP for web agents)
|
||||
- **`micro mcp list`** — list available tools (human-readable or JSON)
|
||||
- **`micro mcp test`** — test tools with JSON input
|
||||
- **`micro mcp docs`** — generate tool documentation
|
||||
- **`micro mcp export`** — export to LangChain, OpenAPI, or JSON formats
|
||||
|
||||
#### Agent Playground
|
||||
- **Chat-focused UI** — redesigned playground with collapsible tool calls, real-time status, and thinking indicators
|
||||
- **Provider settings** — configurable OpenAI/Anthropic provider, model, and API key
|
||||
|
||||
### Changed
|
||||
- Service interface moved to `service.Service` with `micro.Service` as a type alias for backward compatibility.
|
||||
- `service.New()` returns `service.Service` interface (was `*ServiceImpl`).
|
||||
- `service.NewGroup()` accepts `service.Service` interface (was `*ServiceImpl`).
|
||||
- `go.mod` template in `micro new` updated to Go 1.22.
|
||||
|
||||
### Fixed
|
||||
- Handler `Handle()` method accepts variadic `server.HandlerOption` for scopes and metadata.
|
||||
- Store initialization uses service name as table automatically.
|
||||
- Service `Stop()` properly aggregates errors from lifecycle hooks.
|
||||
|
||||
---
|
||||
|
||||
## [2026.02] - February 2026
|
||||
|
||||
### Added
|
||||
- **MCP gateway library** — `gateway/mcp/` with HTTP/SSE and stdio transports, service discovery, tool generation, and JSON schema generation from Go types (2,500+ lines).
|
||||
- **CLI integration** — `micro run --mcp-address` flag to start MCP alongside services.
|
||||
- **Documentation extraction** — auto-extract tool descriptions from Go doc comments with `@example` tag and struct tag parsing.
|
||||
- **Blog post** — "Making Microservices AI-Native with MCP"
|
||||
- **MCP examples** — `examples/mcp/hello/` and `examples/mcp/documented/`
|
||||
|
||||
---
|
||||
|
||||
## [2026.01] - January 2026
|
||||
|
||||
### Added
|
||||
- **`micro deploy`** — deploy services to any Linux server via SSH + systemd with `micro deploy user@server`.
|
||||
- **`micro build`** — build Go binaries and Docker images with `micro build --docker`.
|
||||
- **Blog post** — "Introducing micro deploy"
|
||||
|
||||
---
|
||||
|
||||
_For earlier changes, see the [git log](https://github.com/micro/go-micro/commits/master)._
|
||||
@@ -0,0 +1,148 @@
|
||||
# CLAUDE.md - Go Micro Project Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
Go Micro is a framework for distributed systems development in Go. It provides pluggable abstractions for service discovery, RPC, pub/sub, config, auth, storage, and more.
|
||||
|
||||
The framework is evolving into an **AI-native platform** where every microservice is automatically accessible to AI agents via the Model Context Protocol (MCP).
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
|
||||
# Run tests for a specific package
|
||||
go test ./gateway/mcp/...
|
||||
go test ./ai/...
|
||||
go test ./model/...
|
||||
|
||||
# Lint
|
||||
make lint
|
||||
|
||||
# Format
|
||||
make fmt
|
||||
|
||||
# Build CLI
|
||||
go build -o micro ./cmd/micro
|
||||
|
||||
# Run locally with hot reload
|
||||
micro run
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
go-micro/
|
||||
├── agent/ # Agent abstraction (intelligent service management)
|
||||
├── ai/ # AI model providers (Anthropic, OpenAI, Gemini, etc.)
|
||||
├── auth/ # Authentication (JWT, no-op)
|
||||
├── broker/ # Message broker (NATS, RabbitMQ)
|
||||
├── cache/ # Caching (Redis)
|
||||
├── client/ # RPC client (gRPC)
|
||||
├── cmd/micro/ # CLI tool (run, deploy, mcp, build, server)
|
||||
├── codec/ # Message codecs (JSON, Proto)
|
||||
├── config/ # Dynamic config (env, file, etcd, NATS)
|
||||
├── errors/ # Error handling
|
||||
├── events/ # Event system (NATS JetStream)
|
||||
├── flow/ # Event-driven LLM orchestration
|
||||
├── gateway/
|
||||
│ ├── api/ # REST API gateway
|
||||
│ └── mcp/ # MCP gateway (core AI integration)
|
||||
│ └── deploy/ # Helm charts for MCP gateway
|
||||
├── health/ # Health checking
|
||||
├── logger/ # Logging
|
||||
├── metadata/ # Context metadata
|
||||
├── model/ # Typed data models (CRUD, queries, schemas)
|
||||
├── registry/ # Service discovery (mDNS, Consul, etcd)
|
||||
├── selector/ # Client-side load balancing
|
||||
├── server/ # RPC server
|
||||
├── service/ # Service interface + profiles
|
||||
├── store/ # Data persistence (Postgres, NATS KV)
|
||||
├── transport/ # Network transport
|
||||
├── wrapper/ # Middleware (auth, trace, metrics)
|
||||
├── examples/ # Working examples
|
||||
└── internal/ # Non-public: docs, utils, test harness
|
||||
```
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
- **Plugin architecture**: All abstractions use Go interfaces. Defaults work out of the box, everything is swappable.
|
||||
- **Progressive complexity**: Zero-config for development, full control for production.
|
||||
- **AI-native by default**: Every service is automatically an MCP tool. No extra code needed.
|
||||
- **In-repo plugins**: Plugins live in the main repo to avoid version compatibility issues.
|
||||
- **Reflection-based registration**: Handlers are registered via reflection for minimal boilerplate.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Standard Go conventions (gofmt, golint)
|
||||
- Functional options pattern for configuration (`WithX()` functions)
|
||||
- Interface-first design: define the interface, then implement
|
||||
- Tests alongside code (not in separate test directories)
|
||||
- Commit messages: imperative mood, concise summary line
|
||||
|
||||
## Current Focus & Priorities (March 2026)
|
||||
|
||||
### Status
|
||||
- **Q1 2026 (MCP Foundation):** COMPLETE
|
||||
- **Q2 2026 (Agent DX):** COMPLETE (100%)
|
||||
- **Q3 2026 (Production):** 50% complete (ahead of schedule)
|
||||
|
||||
### Priority 1: Agent Showcase & Examples
|
||||
Build compelling demos showing agents interacting with go-micro services in realistic scenarios.
|
||||
|
||||
### Priority 2: Additional Protocol Support
|
||||
- gRPC reflection-based MCP
|
||||
- HTTP/3 support
|
||||
|
||||
### Priority 3: Kubernetes & Deployment
|
||||
- Helm Charts for MCP gateway
|
||||
- Kubernetes Operator with CRDs
|
||||
|
||||
### Recently Completed
|
||||
- **Agent Plan & Delegate** - Two built-in agent tools: `plan` (ordered plan persisted to store-backed memory, surfaced in the prompt) and `delegate` (hand a subtask to another agent — RPC to a registered agent, else an ephemeral sub-agent with isolated context). Added automatically to every agent; no harness or graph. (`agent/builtin.go`, `examples/agent-plan-delegate/`)
|
||||
- **`micro new` MCP Templates** - Scaffolds MCP-enabled services with doc comments, `@example` tags, `WithMCP()`. `--no-mcp` to opt out.
|
||||
- **CRUD Example** - Contact book service with 6 operations, rich agent docs (`examples/mcp/crud/`)
|
||||
- **Migration Guide** - "Add MCP to Existing Services" guide with 3 approaches
|
||||
- **Troubleshooting Guide** - Common MCP issues and solutions
|
||||
- **Error Handling Guide** - Patterns for agent-friendly error responses
|
||||
- **Documentation Guides** - Six guides: AI-native services, MCP security, tool descriptions, agent patterns, error handling, troubleshooting
|
||||
- **WithMCP Option** - One-line MCP setup (`gateway/mcp/option.go`)
|
||||
- **Agent Playground Redesign** - Chat-focused UI with collapsible tool calls
|
||||
- **Standalone Gateway Binary** - `micro-mcp-gateway` with Docker support
|
||||
- **WebSocket Transport** - Bidirectional JSON-RPC 2.0 streaming (`gateway/mcp/websocket.go`)
|
||||
- **OpenTelemetry Integration** - Full span instrumentation with W3C trace context (`gateway/mcp/otel.go`)
|
||||
- **LlamaIndex SDK** - Python package with RAG examples (`contrib/go-micro-llamaindex/`)
|
||||
|
||||
## Key Files
|
||||
|
||||
| Purpose | File |
|
||||
|---------|------|
|
||||
| MCP Gateway | `gateway/mcp/mcp.go` |
|
||||
| MCP Docs | `gateway/mcp/DOCUMENTATION.md` |
|
||||
| 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` |
|
||||
| Roadmap | `internal/docs/ROADMAP_2026.md` |
|
||||
| Status | `internal/docs/CURRENT_STATUS_SUMMARY.md` |
|
||||
| Changelog | `CHANGELOG.md` |
|
||||
| Docs Site | `internal/website/docs/` |
|
||||
|
||||
## Roadmap & Status Documents
|
||||
|
||||
- **[ROADMAP.md](ROADMAP.md)** - General framework roadmap
|
||||
- **[internal/docs/ROADMAP_2026.md](internal/docs/ROADMAP_2026.md)** - AI-native era roadmap with business model
|
||||
- **[internal/docs/CURRENT_STATUS_SUMMARY.md](internal/docs/CURRENT_STATUS_SUMMARY.md)** - Quick status overview
|
||||
- **[internal/docs/PROJECT_STATUS_2026.md](internal/docs/PROJECT_STATUS_2026.md)** - Detailed technical status
|
||||
- **[internal/docs/IMPLEMENTATION_SUMMARY.md](internal/docs/IMPLEMENTATION_SUMMARY.md)** - Implementation notes
|
||||
- **[CHANGELOG.md](CHANGELOG.md)** - What changed and when
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines. Key points:
|
||||
- Open an issue before large changes
|
||||
- Include tests for new features
|
||||
- Run `make test` and `make lint` before submitting
|
||||
- Follow commit message format: `type: description` (e.g., `feat: add WebSocket transport`)
|
||||
@@ -1,282 +0,0 @@
|
||||
# Go Micro - Current Status Summary
|
||||
**Updated:** February 11, 2026
|
||||
|
||||
## 🎯 Executive Summary
|
||||
|
||||
**Go Micro's MCP integration is 3-4 months ahead of schedule**, with Q1 2026 goals complete and significant Q2/Q3 2026 features already delivered.
|
||||
|
||||
### Quick Status
|
||||
- ✅ **Q1 2026 (MCP Foundation):** 100% COMPLETE
|
||||
- 🟢 **Q2 2026 (Agent DX):** 60% COMPLETE (ahead of schedule)
|
||||
- 🟢 **Q3 2026 (Production):** 40% COMPLETE (ahead of schedule)
|
||||
- 🟡 **Q4 2026 (Ecosystem):** 0% COMPLETE (on track)
|
||||
|
||||
---
|
||||
|
||||
## 📊 What's Been Built
|
||||
|
||||
### ✅ Core MCP Integration (Q1 - COMPLETE)
|
||||
- **MCP Gateway Library** (`gateway/mcp/`) - 2,083 lines
|
||||
- HTTP/SSE transport
|
||||
- Stdio JSON-RPC 2.0 transport
|
||||
- Service discovery & tool generation
|
||||
- Schema generation from Go types
|
||||
|
||||
- **CLI Commands** (`micro mcp`)
|
||||
- `micro mcp serve` - Start MCP server (stdio or HTTP)
|
||||
- `micro mcp list` - List available tools
|
||||
- `micro mcp test` - Test tools (placeholder)
|
||||
|
||||
- **Documentation**
|
||||
- Complete API documentation
|
||||
- 2 working examples (hello, documented)
|
||||
- Blog post: "Making Microservices AI-Native with MCP"
|
||||
|
||||
### ✅ Advanced Features (Q2/Q3 - DELIVERED EARLY)
|
||||
|
||||
#### 🔒 Security & Auth
|
||||
- **Per-Tool Scopes**
|
||||
- Service-level: `server.WithEndpointScopes("Blog.Create", "blog:write")`
|
||||
- Gateway-level: `Options.Scopes` map for overrides
|
||||
- Bearer token authentication
|
||||
- Scope enforcement before RPC execution
|
||||
|
||||
#### 📊 Observability
|
||||
- **Tracing**
|
||||
- UUID trace IDs per tool call
|
||||
- Metadata propagation (`Mcp-Trace-Id`, `Mcp-Tool-Name`, `Mcp-Account-Id`)
|
||||
- Full call chain tracking
|
||||
|
||||
- **Audit Logging**
|
||||
- Immutable audit records per tool call
|
||||
- Captures: tool, account, scopes, allowed/denied, duration, errors
|
||||
- Callback function: `Options.AuditFunc`
|
||||
|
||||
#### 🚦 Rate Limiting
|
||||
- Per-tool rate limiters
|
||||
- Configurable requests/second and burst
|
||||
- Token bucket algorithm
|
||||
|
||||
#### 📝 Documentation Extraction
|
||||
- Auto-extract from Go doc comments
|
||||
- `@example` tag support for JSON examples
|
||||
- Struct tag parsing for parameter descriptions
|
||||
- Manual override via `WithEndpointDocs()`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 What Works Today
|
||||
|
||||
### For Claude Code Users
|
||||
```bash
|
||||
# Start MCP server for Claude Code
|
||||
micro mcp serve
|
||||
|
||||
# Add to ~/.claude/claude_desktop_config.json:
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-services": {
|
||||
"command": "micro",
|
||||
"args": ["mcp", "serve"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### For Library Users
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.NewService(micro.Name("myservice"))
|
||||
service.Init()
|
||||
|
||||
// Add MCP gateway (3 lines!)
|
||||
go mcp.ListenAndServe(":3000", mcp.Options{
|
||||
Registry: service.Options().Registry,
|
||||
Auth: authProvider, // Optional: auth.Auth
|
||||
Scopes: map[string][]string{ // Optional: per-tool scopes
|
||||
"myservice.Handler.Create": {"write"},
|
||||
},
|
||||
RateLimit: &mcp.RateLimitConfig{ // Optional
|
||||
RequestsPerSecond: 10,
|
||||
Burst: 20,
|
||||
},
|
||||
AuditFunc: func(r mcp.AuditRecord) { // Optional
|
||||
log.Printf("[audit] %+v", r)
|
||||
},
|
||||
})
|
||||
|
||||
service.Run()
|
||||
}
|
||||
```
|
||||
|
||||
### For Service Developers
|
||||
```go
|
||||
// Just add Go comments - docs extracted automatically!
|
||||
|
||||
// GetUser retrieves a user by ID. Returns full profile with email and preferences.
|
||||
//
|
||||
// @example {"id": "user-123"}
|
||||
func (s *UserService) GetUser(ctx context.Context, req *GetUserRequest, rsp *GetUserResponse) error {
|
||||
// implementation
|
||||
}
|
||||
|
||||
// Register with scopes
|
||||
handler := service.Server().NewHandler(
|
||||
new(UserService),
|
||||
server.WithEndpointScopes("UserService.Delete", "users:admin"),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Test Coverage
|
||||
|
||||
**568 lines** of comprehensive tests covering:
|
||||
- ✅ Scope validation & enforcement
|
||||
- ✅ Auth provider integration
|
||||
- ✅ Trace ID generation & propagation
|
||||
- ✅ Audit record creation
|
||||
- ✅ Rate limiting
|
||||
- ✅ HTTP & Stdio transports
|
||||
- ✅ Tool discovery & schema generation
|
||||
|
||||
---
|
||||
|
||||
## 🎯 What's Next (Recommended Priorities)
|
||||
|
||||
### Immediate (Next 2 Weeks)
|
||||
1. **Complete `micro mcp test` command** (~1 day)
|
||||
- Implement actual tool testing with JSON input/output
|
||||
|
||||
2. **LangChain SDK** (~1 week)
|
||||
- Python package: `go-micro-langchain`
|
||||
- Auto-generate LangChain tools from registry
|
||||
- Example multi-agent workflow
|
||||
- **Impact:** Largest agent framework integration
|
||||
|
||||
3. **Interactive Playground** (~1 week)
|
||||
- Web UI for testing services with AI
|
||||
- Real-time tool call visualization
|
||||
- **Impact:** Critical for demos and sales
|
||||
|
||||
### Short-Term (Next Month)
|
||||
4. **WebSocket Transport** (~3 days)
|
||||
- Bidirectional streaming for long-running operations
|
||||
|
||||
5. **LlamaIndex SDK** (~1 week)
|
||||
- Python package for RAG integration
|
||||
|
||||
6. **Case Studies** (ongoing)
|
||||
- Document real-world usage
|
||||
|
||||
---
|
||||
|
||||
## 📊 By The Numbers
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Production Code** | 2,083 lines |
|
||||
| **Test Code** | 568 lines |
|
||||
| **Documentation Files** | 4+ |
|
||||
| **Working Examples** | 2 |
|
||||
| **CLI Commands** | 3 |
|
||||
| **Transports** | 2 (HTTP/SSE, Stdio) |
|
||||
| **Q1 Completion** | 100% |
|
||||
| **Ahead of Schedule** | 3-4 months |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Where We Are on the Roadmap
|
||||
|
||||
### Q1 2026: MCP Foundation
|
||||
**Status:** ✅ COMPLETE (100%)
|
||||
- All 6 planned deliverables complete
|
||||
- Production-ready implementation
|
||||
- Comprehensive documentation
|
||||
|
||||
### Q2 2026: Agent Developer Experience
|
||||
**Status:** 🟢 IN PROGRESS (60% complete)
|
||||
|
||||
**COMPLETED (ahead of schedule):**
|
||||
- ✅ Stdio transport for Claude Code
|
||||
- ✅ `micro mcp serve` and `list` commands
|
||||
- ✅ Tool descriptions from comments
|
||||
- ✅ `@example` tag support
|
||||
- ✅ Schema generation from struct tags
|
||||
- ✅ HTTP/SSE with auth
|
||||
|
||||
**NOT YET STARTED:**
|
||||
- ❌ `micro mcp test` (full implementation)
|
||||
- ❌ `micro mcp docs` and `export` commands
|
||||
- ❌ Agent SDKs (LangChain, LlamaIndex, AutoGPT)
|
||||
- ❌ Interactive Agent Playground
|
||||
- ❌ Multi-protocol (WebSocket, gRPC, HTTP/3)
|
||||
|
||||
### Q3 2026: Production & Scale
|
||||
**Status:** 🟢 IN PROGRESS (40% complete)
|
||||
|
||||
**COMPLETED (ahead of schedule):**
|
||||
- ✅ Per-tool authentication & scopes
|
||||
- ✅ Agent call tracing
|
||||
- ✅ Rate limiting
|
||||
- ✅ Audit logging
|
||||
- ✅ Bearer token auth
|
||||
|
||||
**NOT YET STARTED:**
|
||||
- ❌ Standalone MCP Gateway binary
|
||||
- ❌ Kubernetes Operator
|
||||
- ❌ Helm Charts
|
||||
- ❌ OpenTelemetry integration
|
||||
- ❌ Full observability dashboards
|
||||
|
||||
### Q4 2026: Ecosystem & Monetization
|
||||
**Status:** 🟡 PLANNING (0% complete)
|
||||
- All features planned for Q4 2026
|
||||
- On track to start in Q4
|
||||
|
||||
---
|
||||
|
||||
## 📖 Key Documents
|
||||
|
||||
1. **[PROJECT_STATUS_2026.md](./PROJECT_STATUS_2026.md)** - Comprehensive 20-page status report
|
||||
2. **[ROADMAP_2026.md](./ROADMAP_2026.md)** - Updated roadmap with completion markers
|
||||
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. **[/internal/website/blog/2.md](./internal/website/blog/2.md)** - Launch blog post
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Key Achievements
|
||||
|
||||
1. **✅ Production-Ready in Q1** - Ahead of schedule
|
||||
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. **✅ Comprehensive Testing** - 90%+ test coverage
|
||||
6. **✅ Well-Documented** - Multiple docs + examples + blog post
|
||||
|
||||
---
|
||||
|
||||
## 💡 Bottom Line
|
||||
|
||||
**Go Micro is production-ready for AI agent integration TODAY.**
|
||||
|
||||
The Q1 2026 foundation is solid, with advanced Q2/Q3 features already delivered. The framework is:
|
||||
- ✅ Ready for production use
|
||||
- ✅ Secure by default
|
||||
- ✅ Easy to use (3 lines of code)
|
||||
- ✅ Well-tested and documented
|
||||
- ✅ Compatible with Claude Code and other AI tools
|
||||
|
||||
**Next focus:** Agent SDKs and developer tools to drive adoption.
|
||||
|
||||
---
|
||||
|
||||
**For detailed technical analysis, see [PROJECT_STATUS_2026.md](./PROJECT_STATUS_2026.md)**
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
FROM alpine:latest
|
||||
ARG TARGETPLATFORM
|
||||
ENV USER=micro
|
||||
ENV GROUPNAME=$USER
|
||||
ARG UID=1001
|
||||
ARG GID=1001
|
||||
RUN addgroup --gid "$GID" "$GROUPNAME" \
|
||||
&& adduser \
|
||||
--disabled-password \
|
||||
--gecos "" \
|
||||
--home "/micro" \
|
||||
--ingroup "$GROUPNAME" \
|
||||
--no-create-home \
|
||||
--uid "$UID" "$USER"
|
||||
|
||||
ENV PATH=/usr/local/go/bin:$PATH
|
||||
RUN apk --no-cache add git make curl
|
||||
COPY --from=golang:1.26.0-alpine /usr/local/go /usr/local/go
|
||||
|
||||
COPY $TARGETPLATFORM/micro /usr/local/go/bin/
|
||||
COPY $TARGETPLATFORM/protoc-gen-micro /usr/local/go/bin/
|
||||
|
||||
WORKDIR /micro
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/go/bin/micro"]
|
||||
CMD ["server"]
|
||||
@@ -1,4 +1,14 @@
|
||||
.PHONY: test test-race test-coverage lint fmt install-tools proto clean help
|
||||
NAME = micro
|
||||
GIT_COMMIT = $(shell git rev-parse --short HEAD)
|
||||
GIT_TAG = $(shell git describe --abbrev=0 --tags --always --match "v*")
|
||||
GIT_IMPORT = go-micro.dev/v5/cmd/micro
|
||||
BUILD_DATE = $(shell date +%s)
|
||||
LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(GIT_COMMIT) -X $(GIT_IMPORT).GitTag=$(GIT_TAG)
|
||||
|
||||
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
|
||||
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
|
||||
|
||||
.PHONY: test test-race test-coverage lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -13,6 +23,9 @@ help:
|
||||
@echo " make proto - Generate protobuf code"
|
||||
@echo " make clean - Clean build artifacts"
|
||||
|
||||
$(NAME):
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w ${LDFLAGS}" -o $(NAME) cmd/micro/main.go
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
@@ -56,3 +69,14 @@ clean:
|
||||
find . -name "*.test" -type f -delete
|
||||
go clean -cache -testcache
|
||||
|
||||
# Try binary release
|
||||
gorelease-dry-run:
|
||||
docker run \
|
||||
--rm \
|
||||
-e CGO_ENABLED=0 \
|
||||
-v $(CURDIR):/$(NAME) \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /$(NAME) \
|
||||
$(GORELEASER_DOCKER_IMAGE) \
|
||||
--clean --verbose --skip=publish,validate --snapshot
|
||||
|
||||
|
||||
@@ -1,292 +1,328 @@
|
||||
# Go Micro [](https://pkg.go.dev/go-micro.dev/v5?tab=doc) [](https://goreportcard.com/report/github.com/go-micro/go-micro)
|
||||
# Go Micro [](https://pkg.go.dev/go-micro.dev/v5?tab=doc) [](https://goreportcard.com/report/github.com/go-micro/go-micro)
|
||||
|
||||
Go Micro is a framework for distributed systems development.
|
||||
Go Micro is a framework for building services and agents in Go.
|
||||
|
||||
**[📖 Documentation](https://go-micro.dev/docs/)** | [Sponsor the project](https://github.com/sponsors/micro) | [Discord](https://discord.gg/jwTYuUVAGh)
|
||||
Write services — they register, discover each other, and communicate via RPC and events. Every endpoint is automatically an AI-callable tool via [MCP](https://modelcontextprotocol.io/). Build agents to manage them intelligently. Both are Go code, both use the same primitives, both deploy the same way.
|
||||
|
||||
## Overview
|
||||
## Sponsors
|
||||
|
||||
Go Micro provides the core requirements for distributed systems development including RPC and Event driven communication.
|
||||
The Go Micro philosophy is sane defaults with a pluggable architecture. We provide defaults to get you started quickly
|
||||
but everything can be easily swapped out.
|
||||
<a href="https://go-micro.dev/blog/3"><img src="https://upload.wikimedia.org/wikipedia/commons/7/78/Anthropic_logo.svg" height="26" /></a>
|
||||
|
||||
<a href="https://go-micro.dev/blog/8"><img src="https://www.atlascloud.ai/logo.svg" height="26" /></a>
|
||||
|
||||
## Features
|
||||
## Quick Start
|
||||
|
||||
Go Micro abstracts away the details of distributed systems. Here are the main features.
|
||||
|
||||
- **Authentication** - Auth is built in as a first class citizen. Authentication and authorization enable secure
|
||||
zero trust networking by providing every service an identity and certificates. This additionally includes rule
|
||||
based access control.
|
||||
|
||||
- **Dynamic Config** - Load and hot reload dynamic config from anywhere. The config interface provides a way to load application
|
||||
level config from any source such as env vars, file, etcd. You can merge the sources and even define fallbacks.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **Load Balancing** - Client side load balancing built on service discovery. Once we have the addresses of any number of instances
|
||||
of a service we now need a way to decide which node to route to. We use random hashed load balancing to provide even distribution
|
||||
across the services and retry a different node if there's a problem.
|
||||
|
||||
- **Message Encoding** - Dynamic message encoding based on content-type. The client and server will use codecs along with content-type
|
||||
to seamlessly encode and decode Go types for you. Any variety of messages could be encoded and sent from different clients. The client
|
||||
and server handle this by default. This includes protobuf and json by default.
|
||||
|
||||
- **RPC Client/Server** - RPC based request/response with support for bidirectional streaming. We provide an abstraction for synchronous
|
||||
communication. A request made to a service will be automatically resolved, load balanced, dialled and streamed.
|
||||
|
||||
- **Async Messaging** - PubSub is built in as a first class citizen for asynchronous communication and event driven architectures.
|
||||
Event notifications are a core pattern in micro service development. The default messaging system is a HTTP event message broker.
|
||||
|
||||
- **MCP Integration** - An MCP gateway you can integrate as a library, server or CLI command which automatically exposes services
|
||||
as tools for agents or other AI applications. Every service/endpoint get's converted into a callable tool.
|
||||
|
||||
- **Pluggable Interfaces** - Go Micro makes use of Go interfaces for each distributed system abstraction. Because of this these interfaces
|
||||
are pluggable and allows Go Micro to be runtime agnostic. You can plugin any underlying technology.
|
||||
|
||||
## Getting Started
|
||||
|
||||
To make use of Go Micro
|
||||
Install the CLI:
|
||||
|
||||
```bash
|
||||
go get go-micro.dev/v5@latest
|
||||
# Binary (no Go required)
|
||||
curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
|
||||
# Or with Go
|
||||
go install go-micro.dev/v5/cmd/micro@v5.27.0
|
||||
```
|
||||
|
||||
Create a service and register a handler
|
||||
### Fastest start — no API key
|
||||
|
||||
Scaffold a service, run it, call it:
|
||||
|
||||
```bash
|
||||
micro new helloworld
|
||||
cd helloworld
|
||||
micro run
|
||||
```
|
||||
|
||||
Then in another terminal:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
|
||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
||||
```
|
||||
|
||||
### Generate from a prompt — with an LLM key
|
||||
|
||||
Set a provider key, describe what you want, and the AI designs services, writes handlers, compiles, and starts them:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, ...
|
||||
micro run --prompt "a task management system with categories" --provider anthropic
|
||||
```
|
||||
|
||||
The AI designs the architecture, you review it, then it generates handlers with real business logic, compiles them, and starts them:
|
||||
|
||||
```
|
||||
Services:
|
||||
● task — Task management with status tracking
|
||||
● project — Project organization
|
||||
|
||||
Generate? [Y/n]
|
||||
|
||||
Micro
|
||||
Services:
|
||||
● task
|
||||
● project
|
||||
Agents:
|
||||
◆ agent
|
||||
```
|
||||
|
||||
Then talk to your services from the console:
|
||||
|
||||
```
|
||||
> Create a project called Launch, then add three tasks to it
|
||||
|
||||
→ project_Project_Create({"name":"Launch"})
|
||||
← {"record":{"id":"p1..."},"success":true}
|
||||
→ task_Task_Create({"title":"Design specs","project_id":"p1..."})
|
||||
→ task_Task_Create({"title":"Write code","project_id":"p1..."})
|
||||
→ task_Task_Create({"title":"Ship it","project_id":"p1..."})
|
||||
|
||||
Created Work category and added 'Finish report' task to it.
|
||||
```
|
||||
|
||||
When you need a capability that doesn't exist, the agent generates a new service mid-conversation:
|
||||
|
||||
```
|
||||
> I need to track shipping. Create a shipment for order 123 to London.
|
||||
|
||||
⚡ generating shipping service...
|
||||
✓ shipping
|
||||
→ shipping_Shipping_Create({"order_id":"123","destination":"London"})
|
||||
← {"record":{"id":"xyz...","status":"pending"}}
|
||||
|
||||
Created shipment for order 123 going to London.
|
||||
```
|
||||
|
||||
Edit the generated code by hand at any time — re-running preserves your changes. [Read more](https://go-micro.dev/blog/13).
|
||||
|
||||
## Writing Services
|
||||
|
||||
Under the hood, a service is a struct with methods. Doc comments and `@example` tags become tool descriptions for AI agents automatically.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Message string `json:"message"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type Say struct{}
|
||||
|
||||
func (h *Say) Hello(ctx context.Context, req *Request, rsp *Response) error {
|
||||
rsp.Message = "Hello " + req.Name
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
// create the service
|
||||
service := micro.New("helloworld")
|
||||
|
||||
// register handler
|
||||
service.Handle(new(Say))
|
||||
|
||||
// run the service
|
||||
service.Run()
|
||||
}
|
||||
```
|
||||
|
||||
Set a fixed address
|
||||
|
||||
```go
|
||||
service := micro.NewService(
|
||||
micro.Name("helloworld"),
|
||||
micro.Address(":8080"),
|
||||
)
|
||||
```
|
||||
|
||||
Call it via curl
|
||||
|
||||
```bash
|
||||
curl -XPOST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Micro-Endpoint: Say.Hello' \
|
||||
-d '{"name": "alice"}' \
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
## MCP & AI Agents
|
||||
|
||||
Go Micro is designed for an **agent-first** workflow. Every service you build automatically becomes a tool that AI agents can discover and use via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/).
|
||||
|
||||
- **[🤖 Agent Playground](https://go-micro.dev/docs/mcp.html)** — Chat with your services through an interactive AI agent at `/agent`
|
||||
- **[🔧 MCP Tools Registry](https://go-micro.dev/docs/mcp.html)** — Browse all services exposed as AI-callable tools at `/api/mcp/tools`
|
||||
- **[📖 MCP Documentation](https://go-micro.dev/docs/mcp.html)** — Full guide to MCP integration, auth, and scopes
|
||||
|
||||
### Services as Tools
|
||||
|
||||
Write a normal Go Micro service and it's instantly available as an MCP tool:
|
||||
|
||||
```go
|
||||
// SayHello greets a person by name.
|
||||
// Hello greets a person by name.
|
||||
// @example {"name": "Alice"}
|
||||
func (g *GreeterService) SayHello(ctx context.Context, req *HelloRequest, rsp *HelloResponse) error {
|
||||
func (h *Say) Hello(ctx context.Context, req *Request, rsp *Response) error {
|
||||
rsp.Message = "Hello " + req.Name
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
service := micro.New("greeter")
|
||||
service.Handle(new(Say))
|
||||
service.Run()
|
||||
}
|
||||
```
|
||||
|
||||
Run with `micro run` and the agent playground and MCP tools registry are ready:
|
||||
Run it and everything is accessible — REST, gRPC, MCP, agent playground:
|
||||
|
||||
```bash
|
||||
micro run
|
||||
# Agent Playground: http://localhost:8080/agent
|
||||
# MCP Tools: http://localhost:8080/api/mcp/tools
|
||||
# Dashboard: http://localhost:8080
|
||||
# API: http://localhost:8080/api/{service}/{method}
|
||||
# Agent: http://localhost:8080/agent
|
||||
# MCP Tools: http://localhost:8080/mcp/tools
|
||||
```
|
||||
|
||||
Use `micro mcp serve` for local AI tools like Claude Code, or connect any MCP-compatible agent to the HTTP endpoint.
|
||||
|
||||
See the [MCP guide](https://go-micro.dev/docs/mcp.html) for authentication, scopes, and advanced usage.
|
||||
|
||||
## Examples
|
||||
|
||||
Check out [/examples](examples/) for runnable code:
|
||||
- [hello-world](examples/hello-world/) - Basic RPC service
|
||||
- [web-service](examples/web-service/) - HTTP REST API
|
||||
- [mcp](examples/mcp/) - MCP integration with AI agents
|
||||
|
||||
See [all examples](examples/README.md) for more.
|
||||
|
||||
## Protobuf
|
||||
|
||||
Install the code generator and see usage in the docs:
|
||||
You can also scaffold a service from a template:
|
||||
|
||||
```bash
|
||||
go install go-micro.dev/v5/cmd/protoc-gen-micro@v5.16.0
|
||||
micro new helloworld
|
||||
micro new contacts --template crud
|
||||
```
|
||||
|
||||
> **Note:** Use a specific version instead of `@latest` to avoid module path conflicts. See [releases](https://github.com/micro/go-micro/releases) for the latest version.
|
||||
## Building Agents
|
||||
|
||||
Docs: [`internal/website/docs/getting-started.md`](internal/website/docs/getting-started.md)
|
||||
An Agent is a service with an LLM inside it. It has a proto-defined `Agent.Chat` RPC endpoint, registers in the registry, and is callable like any service:
|
||||
|
||||
## Command Line
|
||||
|
||||
Install the CLI:
|
||||
|
||||
```
|
||||
go install go-micro.dev/v5/cmd/micro@v5.16.0
|
||||
```go
|
||||
agent := micro.NewAgent("task-mgr",
|
||||
micro.AgentServices("task", "project"),
|
||||
micro.AgentPrompt("You manage tasks and projects. You understand deadlines and priorities."),
|
||||
micro.AgentProvider("anthropic"),
|
||||
)
|
||||
agent.Run()
|
||||
```
|
||||
|
||||
> **Note:** Use a specific version instead of `@latest` to avoid module path conflicts. See [releases](https://github.com/micro/go-micro/releases) for the latest version.
|
||||
The agent discovers its services from the registry, scopes its tools to their endpoints, and maintains conversation memory in the store. It registers itself so `micro chat` and other agents can find it.
|
||||
|
||||
### Quick Start
|
||||
```go
|
||||
// Programmatic interaction
|
||||
resp, _ := agent.Ask(ctx, "What tasks are overdue?")
|
||||
fmt.Println(resp.Reply)
|
||||
```
|
||||
|
||||
Multiple agents coordinate via RPC — each is a service with an `Agent.Chat` endpoint. `micro chat` routes to the right one.
|
||||
|
||||
```bash
|
||||
micro new helloworld # Create a new service
|
||||
cd helloworld
|
||||
micro run # Run with API gateway and hot reload
|
||||
micro agent list # list registered agents
|
||||
micro call task-mgr Agent.Chat '{"message": "What tasks are overdue?"}'
|
||||
```
|
||||
|
||||
Then open http://localhost:8080 to see your service and call it from the browser.
|
||||
### Plan & Delegate
|
||||
|
||||
### Development Workflow
|
||||
Every agent gets two built-in capabilities, exposed as tools — no extra setup, no harness:
|
||||
|
||||
| Stage | Command | Purpose |
|
||||
|-------|---------|---------|
|
||||
| **Develop** | `micro run` | Local dev with hot reload and API gateway |
|
||||
| **Build** | `micro build` | Compile production binaries |
|
||||
| **Deploy** | `micro deploy` | Push to a remote Linux server via SSH + systemd |
|
||||
| **Dashboard** | `micro server` | Optional production web UI with JWT auth |
|
||||
- **`plan`** — for multi-step work, the agent records an ordered plan in its store-backed memory and stays oriented across turns.
|
||||
- **`delegate`** — the agent hands a self-contained subtask to another agent. If a registered agent already owns the relevant services, the hand-off goes over RPC to that agent; otherwise a focused, short-lived sub-agent is created for the subtask with its own isolated context.
|
||||
|
||||
### micro run
|
||||
This keeps intelligence distributed: an agent doesn't need to know *how* to do everything, only *who* does. See [examples/agent-plan-delegate](examples/agent-plan-delegate/).
|
||||
|
||||
`micro run` starts your services with:
|
||||
- **Web Dashboard** - Browse and call services at `/`
|
||||
- **Agent Playground** - AI chat with MCP tools at `/agent`
|
||||
- **API Explorer** - Browse endpoints and schemas at `/api`
|
||||
- **API Gateway** - HTTP to RPC proxy at `/api/{service}/{method}` (no auth in dev mode)
|
||||
- **MCP Tools** - Services as AI tools at `/api/mcp/tools`
|
||||
- **Health Checks** - Aggregated health at `/health`
|
||||
- **Hot Reload** - Auto-rebuild on file changes
|
||||
|
||||
> **Note:** `micro run` and `micro server` use a unified gateway architecture. See [Gateway Architecture](cmd/micro/README.md#gateway-architecture) for details.
|
||||
|
||||
```bash
|
||||
micro run # Gateway on :8080
|
||||
micro run --address :3000 # Custom gateway port
|
||||
micro run --no-gateway # Services only
|
||||
micro run --env production # Use production environment
|
||||
```go
|
||||
// A sub-agent is just an agent — created with New, talked to with Ask.
|
||||
// delegate-first: reuse a registered agent, or spin up a focused one.
|
||||
resp, _ := agent.Ask(ctx, "Plan the launch, create the tasks, and have comms notify the owner.")
|
||||
```
|
||||
|
||||
### Configuration
|
||||
## Features
|
||||
|
||||
For multi-service projects, create a `micro.mu` file:
|
||||
### AI
|
||||
|
||||
| Feature | Details |
|
||||
|---------|---------|
|
||||
| Agents | `micro.NewAgent()` — intelligent layer that manages services |
|
||||
| Plan & delegate | Built-in agent tools — plan multi-step work, delegate subtasks to other agents |
|
||||
| Guardrails | `MaxSteps` (stopping condition) and `ApproveTool` (human-in-the-loop) on every agent |
|
||||
| Workflows | `micro.NewFlow()` — event-driven; runs a step or triggers an agent |
|
||||
| MCP gateway | Every endpoint is an AI tool automatically |
|
||||
| 7 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud |
|
||||
| Interactive console | `micro run` includes a chat console for talking to services |
|
||||
| Service generation | `micro run --prompt` — describe a system, get running services |
|
||||
|
||||
### Framework
|
||||
|
||||
| Feature | Details |
|
||||
|---------|---------|
|
||||
| Service registry | mDNS (default), Consul, etcd |
|
||||
| RPC client/server | gRPC transport, load balancing, streaming |
|
||||
| Pub/sub events | NATS, RabbitMQ, HTTP broker |
|
||||
| Key-value store | File (bbolt), Postgres, NATS KV |
|
||||
| Typed model layer | CRUD + queries, SQLite/Postgres backends |
|
||||
| Everything swappable | All abstractions are Go interfaces |
|
||||
|
||||
### Developer experience & deployment
|
||||
|
||||
| Feature | Details |
|
||||
|---------|---------|
|
||||
| Hot reload | `micro run` watches files, rebuilds on change |
|
||||
| Templates | `micro new --template crud/pubsub/api` |
|
||||
| One-command deploy | `micro deploy user@server` — SSH + systemd, no Docker |
|
||||
|
||||
## CLI
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `micro run --prompt "..."` | Generate services + agent, start with interactive console |
|
||||
| `micro run` | Dev mode: hot reload, gateway, interactive console |
|
||||
| `micro run -d` | Detached mode (no console) |
|
||||
| `micro chat` | Standalone chat (when not using micro run) |
|
||||
| `micro agent list` | List registered agents |
|
||||
| `micro new myservice` | Scaffold a service |
|
||||
| `micro call service endpoint '{}'` | Call a service or agent from the CLI |
|
||||
| `micro build` | Compile production binaries |
|
||||
| `micro deploy user@server` | Deploy via SSH + systemd |
|
||||
|
||||
## Multi-Service Projects
|
||||
|
||||
Run multiple services together:
|
||||
|
||||
```go
|
||||
users := micro.New("users", micro.Address(":9001"))
|
||||
orders := micro.New("orders", micro.Address(":9002"))
|
||||
|
||||
users.Handle(new(Users))
|
||||
orders.Handle(new(Orders))
|
||||
|
||||
g := micro.NewGroup(users, orders)
|
||||
g.Run()
|
||||
```
|
||||
|
||||
Or use a `micro.mu` config file:
|
||||
|
||||
```
|
||||
service users
|
||||
path ./users
|
||||
port 8081
|
||||
|
||||
service posts
|
||||
path ./posts
|
||||
port 8082
|
||||
service orders
|
||||
path ./orders
|
||||
depends users
|
||||
|
||||
env development
|
||||
DATABASE_URL sqlite://./dev.db
|
||||
```
|
||||
|
||||
The gateway runs on :8080 by default, so services should use other ports.
|
||||
## Data Model
|
||||
|
||||
### Deployment
|
||||
Typed persistence with CRUD and queries:
|
||||
|
||||
Deploy to any Linux server with systemd:
|
||||
```go
|
||||
type User struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email" model:"index"`
|
||||
}
|
||||
|
||||
```bash
|
||||
# On your server (one-time setup)
|
||||
curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
sudo micro init --server
|
||||
db := service.Model()
|
||||
db.Register(&User{})
|
||||
db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com"})
|
||||
|
||||
# From your laptop
|
||||
micro deploy user@your-server
|
||||
var results []*User
|
||||
db.List(ctx, &results, model.Where("email", "alice@example.com"))
|
||||
```
|
||||
|
||||
The deploy command:
|
||||
1. Builds binaries for Linux
|
||||
2. Copies via SSH to the server
|
||||
3. Sets up systemd services
|
||||
4. Verifies services are healthy
|
||||
Backends: memory (default), SQLite, Postgres.
|
||||
|
||||
Optionally run `micro server` on the deployed machine for a production web dashboard with JWT auth, user management, and API explorer.
|
||||
## AI Providers
|
||||
|
||||
Manage deployed services:
|
||||
```bash
|
||||
micro status --remote user@server # Check status
|
||||
micro logs --remote user@server # View logs
|
||||
micro logs myservice --remote user@server -f # Follow specific service
|
||||
Swap providers with a single import — same interface everywhere:
|
||||
|
||||
| Provider | Default Model |
|
||||
|----------|---------------|
|
||||
| Anthropic | `claude-sonnet-4-20250514` |
|
||||
| OpenAI | `gpt-4o` |
|
||||
| Google Gemini | `gemini-2.5-flash` |
|
||||
| Groq | `llama-3.3-70b-versatile` |
|
||||
| Mistral | `mistral-large-latest` |
|
||||
| Together AI | `Llama-3.3-70B-Instruct-Turbo` |
|
||||
| Atlas Cloud | `llama-3.3-70b` |
|
||||
|
||||
```go
|
||||
m := ai.New("anthropic", ai.WithAPIKey(key))
|
||||
resp, _ := m.Generate(ctx, &ai.Request{Prompt: "hello"})
|
||||
```
|
||||
|
||||
No Docker required. No Kubernetes. Just systemd.
|
||||
## Examples
|
||||
|
||||
See [internal/website/docs/deployment.md](internal/website/docs/deployment.md) for full deployment guide.
|
||||
- [hello-world](examples/hello-world/) — Basic RPC service
|
||||
- [multi-service](examples/multi-service/) — Multiple services in one binary
|
||||
- [mcp](examples/mcp/) — MCP integration with AI agents
|
||||
- [agent-plan-delegate](examples/agent-plan-delegate/) — Agent planning and multi-agent delegation
|
||||
- [grpc-interop](examples/grpc-interop/) — Call go-micro from any gRPC client
|
||||
|
||||
See [cmd/micro/README.md](cmd/micro/README.md) for full CLI documentation.
|
||||
See [all examples](examples/README.md).
|
||||
|
||||
Docs: [`internal/website/docs`](internal/website/docs)
|
||||
## Docs
|
||||
|
||||
- [Getting Started](internal/website/docs/getting-started.md)
|
||||
- [AI Integration](internal/website/docs/ai-integration.md)
|
||||
- [Agents and Workflows](internal/website/docs/guides/agents-and-workflows.md)
|
||||
- [Agent Design](internal/docs/AGENT_DESIGN.md)
|
||||
- [Plan & Delegate](internal/website/docs/guides/plan-delegate.md)
|
||||
- [MCP & AI Agents](internal/website/docs/mcp.md)
|
||||
- [Data Model](internal/website/docs/model.md)
|
||||
- [Deployment](internal/website/docs/deployment.md)
|
||||
- [Plugins](internal/website/docs/plugins.md)
|
||||
|
||||
Package reference: https://pkg.go.dev/go-micro.dev/v5
|
||||
|
||||
**User Guides:**
|
||||
- [Getting Started](internal/website/docs/getting-started.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)
|
||||
- [Deployment Guide](internal/website/docs/deployment.md)
|
||||
|
||||
**Architecture & Performance:**
|
||||
- [Performance Considerations](internal/website/docs/performance.md)
|
||||
- [Reflection Usage & Philosophy](internal/website/docs/REFLECTION-EVALUATION-SUMMARY.md)
|
||||
|
||||
**Security:**
|
||||
- [TLS Security Migration](internal/website/docs/TLS_SECURITY_UPDATE.md)
|
||||
- [Security Migration Guide](internal/website/docs/SECURITY_MIGRATION.md)
|
||||
|
||||
## Adopters
|
||||
|
||||
- [Sourse](https://sourse.eu) - Work in the field of earth observation, including embedded Kubernetes running onboard aircraft, and we’ve built a mission management SaaS platform using Go Micro.
|
||||
|
||||
+31
-14
@@ -2,18 +2,27 @@
|
||||
|
||||
This roadmap outlines the planned features and improvements for Go Micro. Community feedback and contributions are welcome!
|
||||
|
||||
> **🚀 NEW:** See [ROADMAP_2026.md](ROADMAP_2026.md) for the **AI-Native Era roadmap** focused on MCP integration, agent-first development, and business sustainability. This document covers general framework improvements.
|
||||
> **See [internal/docs/ROADMAP_2026.md](internal/docs/ROADMAP_2026.md) for the AI-Native Era roadmap** focused on MCP integration, agent-first development, and business sustainability. This document covers general framework improvements.
|
||||
|
||||
## Current Focus (Q1 2026)
|
||||
## Current Focus (Q1 2026) - COMPLETE
|
||||
|
||||
### Documentation & Developer Experience
|
||||
- [x] Modernize documentation structure
|
||||
- [x] Add learn-by-example guides
|
||||
- [x] Update issue templates
|
||||
- [x] MCP integration documentation
|
||||
- [x] Agent playground and MCP tools registry
|
||||
- [ ] Create video tutorials
|
||||
- [ ] Interactive documentation site
|
||||
- [ ] Plugin discovery dashboard
|
||||
|
||||
### AI & Model Integration
|
||||
- [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 `ai.Stream`
|
||||
|
||||
### Observability
|
||||
- [ ] OpenTelemetry native support
|
||||
- [ ] Auto-instrumentation for handlers
|
||||
@@ -22,7 +31,10 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
- [ ] Integration with popular observability platforms
|
||||
|
||||
### Developer Tools
|
||||
- [ ] `micro dev` with hot reload
|
||||
- [x] `micro run` with hot reload and unified gateway
|
||||
- [x] `micro deploy` with SSH + systemd deployment
|
||||
- [x] `micro mcp` command suite (serve, list, test, docs, export)
|
||||
- [ ] `micro dev` with enhanced hot reload
|
||||
- [ ] Service templates (`micro new --template`)
|
||||
- [ ] Better error messages with suggestions
|
||||
- [ ] Debug tooling improvements
|
||||
@@ -31,8 +43,8 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
## Q2 2026
|
||||
|
||||
### Production Readiness
|
||||
- [ ] Health check standardization
|
||||
- [ ] Graceful shutdown improvements
|
||||
- [x] Health check standardization
|
||||
- [x] Graceful shutdown improvements
|
||||
- [ ] Resource cleanup best practices
|
||||
- [ ] Load testing framework integration
|
||||
- [ ] Performance benchmarking suite
|
||||
@@ -45,6 +57,10 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
- [ ] Multi-cluster patterns
|
||||
|
||||
### Security
|
||||
- [x] Bearer token authentication for MCP
|
||||
- [x] Per-tool scope enforcement
|
||||
- [x] Audit logging
|
||||
- [x] Rate limiting
|
||||
- [ ] mTLS by default option
|
||||
- [ ] Secret management integration (Vault, AWS Secrets Manager)
|
||||
- [ ] RBAC improvements
|
||||
@@ -62,7 +78,7 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
|
||||
### Streaming & Async
|
||||
- [ ] Improved streaming support
|
||||
- [ ] Server-sent events (SSE) support
|
||||
- [x] Server-sent events (SSE) support (via MCP gateway)
|
||||
- [ ] WebSocket plugin
|
||||
- [ ] Event sourcing patterns
|
||||
- [ ] CQRS examples
|
||||
@@ -116,6 +132,7 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
### Differentiation
|
||||
- **Batteries included, fully swappable** - Start simple, scale complex
|
||||
- **Zero-config local development** - No infrastructure required to start
|
||||
- **AI-native by default** - Every service is an MCP tool automatically
|
||||
- **Plugin ecosystem in-repo** - No version compatibility hell
|
||||
- **Progressive complexity** - Learn as you grow
|
||||
- **Cloud-native first** - Built for Kubernetes and containers
|
||||
@@ -125,11 +142,11 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
We welcome contributions to any roadmap items! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
### High Priority Areas
|
||||
1. Documentation improvements
|
||||
2. Real-world examples
|
||||
3. Plugin development
|
||||
4. Performance optimizations
|
||||
5. Testing infrastructure
|
||||
1. Documentation improvements (guides, tutorials)
|
||||
2. Multi-protocol MCP support (WebSocket, gRPC)
|
||||
3. Agent SDK integrations (LlamaIndex, AutoGPT)
|
||||
4. OpenTelemetry integration
|
||||
5. Kubernetes operator and Helm charts
|
||||
|
||||
### How to Contribute
|
||||
- Pick an item from the roadmap
|
||||
@@ -139,7 +156,7 @@ We welcome contributions to any roadmap items! See [CONTRIBUTING.md](CONTRIBUTIN
|
||||
|
||||
## Feedback
|
||||
|
||||
Have suggestions for the roadmap?
|
||||
Have suggestions for the roadmap?
|
||||
|
||||
- Open a [feature request](.github/ISSUE_TEMPLATE/feature_request.md)
|
||||
- Start a discussion in GitHub Discussions
|
||||
@@ -160,6 +177,6 @@ We follow semantic versioning:
|
||||
|
||||
---
|
||||
|
||||
Last updated: November 2025
|
||||
Last updated: March 2026
|
||||
|
||||
This roadmap is subject to change based on community needs and priorities. Star the repo to stay updated! ⭐
|
||||
This roadmap is subject to change based on community needs and priorities.
|
||||
|
||||
+1
-1
@@ -174,6 +174,6 @@ We currently do not offer a bug bounty program, but we greatly appreciate respon
|
||||
|
||||
For security questions that are not vulnerabilities, please:
|
||||
- Open a discussion: https://github.com/micro/go-micro/discussions
|
||||
- Join Discord: https://discord.gg/jwTYuUVAGh
|
||||
- Join Discord: https://discord.gg/WeMU5AGxD
|
||||
- Email: support@go-micro.dev
|
||||
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
// Package agent provides the Agent abstraction for Go Micro.
|
||||
//
|
||||
// An Agent is a service with an LLM inside it. It registers a Chat
|
||||
// RPC endpoint, discovers its assigned services' tools, and
|
||||
// orchestrates them intelligently.
|
||||
//
|
||||
// agent := micro.NewAgent("task-mgr",
|
||||
// micro.AgentServices("task"),
|
||||
// micro.AgentPrompt("You manage tasks."),
|
||||
// micro.AgentProvider("anthropic"),
|
||||
// )
|
||||
// agent.Run()
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
pb "go-micro.dev/v5/agent/proto"
|
||||
"go-micro.dev/v5/ai"
|
||||
"go-micro.dev/v5/server"
|
||||
"go-micro.dev/v5/store"
|
||||
|
||||
_ "go-micro.dev/v5/ai/anthropic"
|
||||
_ "go-micro.dev/v5/ai/atlascloud"
|
||||
_ "go-micro.dev/v5/ai/gemini"
|
||||
_ "go-micro.dev/v5/ai/groq"
|
||||
_ "go-micro.dev/v5/ai/mistral"
|
||||
_ "go-micro.dev/v5/ai/openai"
|
||||
_ "go-micro.dev/v5/ai/together"
|
||||
)
|
||||
|
||||
// Agent is the interface for an AI agent that manages services.
|
||||
type Agent interface {
|
||||
Name() string
|
||||
Init(...Option)
|
||||
Options() Options
|
||||
Ask(ctx context.Context, message string) (*Response, error)
|
||||
Run() error
|
||||
Stop() error
|
||||
String() string
|
||||
}
|
||||
|
||||
// Response is what an agent returns from Chat.
|
||||
type Response struct {
|
||||
Reply string
|
||||
ToolCalls []ai.ToolCall
|
||||
Agent string
|
||||
}
|
||||
|
||||
type agentImpl struct {
|
||||
opts Options
|
||||
model ai.Model
|
||||
tools *ai.Tools
|
||||
hist *ai.History
|
||||
server server.Server
|
||||
mu sync.Mutex
|
||||
|
||||
// ephemeral marks a short-lived sub-agent created by delegation.
|
||||
// Ephemeral agents run with an isolated context: they load and
|
||||
// persist no history, and have no built-in tools (so they cannot
|
||||
// plan or re-delegate).
|
||||
ephemeral bool
|
||||
|
||||
// steps counts tool executions in the current Ask, for MaxSteps.
|
||||
steps int
|
||||
}
|
||||
|
||||
// New creates a new Agent.
|
||||
func New(opts ...Option) Agent {
|
||||
return &agentImpl{
|
||||
opts: newOptions(opts...),
|
||||
}
|
||||
}
|
||||
|
||||
// newEphemeral creates a short-lived sub-agent for a delegated subtask.
|
||||
// It shares the parent's provider, model, and infrastructure but runs
|
||||
// with an isolated context: it loads and persists no history and has no
|
||||
// built-in tools (so it can neither plan nor re-delegate). Returns the
|
||||
// concrete type because ephemeral is an internal construction detail,
|
||||
// not a public option.
|
||||
func newEphemeral(opts ...Option) *agentImpl {
|
||||
return &agentImpl{
|
||||
opts: newOptions(opts...),
|
||||
ephemeral: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agentImpl) Name() string {
|
||||
return a.opts.Name
|
||||
}
|
||||
|
||||
func (a *agentImpl) Init(opts ...Option) {
|
||||
for _, o := range opts {
|
||||
o(&a.opts)
|
||||
}
|
||||
a.setup()
|
||||
}
|
||||
|
||||
func (a *agentImpl) Options() Options {
|
||||
return a.opts
|
||||
}
|
||||
|
||||
func (a *agentImpl) String() string {
|
||||
return "agent"
|
||||
}
|
||||
|
||||
func (a *agentImpl) setup() {
|
||||
var modelOpts []ai.Option
|
||||
modelOpts = append(modelOpts, ai.WithAPIKey(a.opts.APIKey))
|
||||
if a.opts.Model != "" {
|
||||
modelOpts = append(modelOpts, ai.WithModel(a.opts.Model))
|
||||
}
|
||||
|
||||
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
|
||||
modelOpts = append(modelOpts, ai.WithToolHandler(a.toolHandler()))
|
||||
a.model = ai.New(a.opts.Provider, modelOpts...)
|
||||
|
||||
a.hist = ai.NewHistory(a.opts.HistoryLimit)
|
||||
if !a.ephemeral {
|
||||
a.loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// Ask sends a message and returns the agent's response.
|
||||
// This is the programmatic API for direct use.
|
||||
func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.model == nil {
|
||||
a.setup()
|
||||
}
|
||||
|
||||
toolList, err := a.discoverTools()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover tools: %w", err)
|
||||
}
|
||||
|
||||
a.hist.Add("user", message)
|
||||
a.steps = 0
|
||||
|
||||
resp, err := a.model.Generate(ctx, &ai.Request{
|
||||
Prompt: message,
|
||||
SystemPrompt: a.buildPrompt(),
|
||||
Tools: toolList,
|
||||
Messages: a.hist.Messages(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.Reply != "" {
|
||||
a.hist.Add("assistant", resp.Reply)
|
||||
}
|
||||
if resp.Answer != "" {
|
||||
a.hist.Add("assistant", resp.Answer)
|
||||
}
|
||||
|
||||
a.saveHistory()
|
||||
|
||||
reply := resp.Reply
|
||||
if resp.Answer != "" {
|
||||
if reply != "" {
|
||||
reply += "\n\n"
|
||||
}
|
||||
reply += resp.Answer
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Reply: reply,
|
||||
ToolCalls: resp.ToolCalls,
|
||||
Agent: a.opts.Name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Chat implements the proto AgentHandler interface for RPC.
|
||||
// @example {"message": "What tasks are overdue?"}
|
||||
func (a *agentImpl) Chat(ctx context.Context, req *pb.ChatRequest, rsp *pb.ChatResponse) error {
|
||||
resp, err := a.Ask(ctx, req.Message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rsp.Reply = resp.Reply
|
||||
rsp.Agent = resp.Agent
|
||||
for _, tc := range resp.ToolCalls {
|
||||
input, _ := json.Marshal(tc.Input)
|
||||
rsp.ToolCalls = append(rsp.ToolCalls, &pb.ToolCall{
|
||||
Id: tc.ID,
|
||||
Name: tc.Name,
|
||||
Input: string(input),
|
||||
Result: tc.Result,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts the agent as a service with a Chat RPC endpoint.
|
||||
func (a *agentImpl) Run() error {
|
||||
if a.model == nil {
|
||||
a.setup()
|
||||
}
|
||||
|
||||
a.server = server.NewServer(
|
||||
server.Name(a.opts.Name),
|
||||
server.Registry(a.opts.Registry),
|
||||
server.Metadata(map[string]string{
|
||||
"type": "agent",
|
||||
"services": strings.Join(a.opts.Services, ","),
|
||||
}),
|
||||
)
|
||||
|
||||
pb.RegisterAgentHandler(a.server, a)
|
||||
|
||||
if err := a.server.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start agent: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Agent %s registered (manages: %s)\n", a.opts.Name, strings.Join(a.opts.Services, ", "))
|
||||
|
||||
ch := make(chan struct{})
|
||||
<-ch
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *agentImpl) Stop() error {
|
||||
if a.server != nil {
|
||||
return a.server.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *agentImpl) discoverTools() ([]ai.Tool, error) {
|
||||
all, err := a.tools.Discover()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var scoped []ai.Tool
|
||||
for _, t := range all {
|
||||
if strings.HasPrefix(t.OriginalName, a.opts.Name+".") {
|
||||
continue
|
||||
}
|
||||
if len(a.opts.Services) == 0 {
|
||||
scoped = append(scoped, t)
|
||||
continue
|
||||
}
|
||||
for _, svc := range a.opts.Services {
|
||||
if strings.HasPrefix(t.OriginalName, svc+".") {
|
||||
scoped = append(scoped, t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose the agent's own capabilities (plan, delegate) as tools.
|
||||
// Ephemeral sub-agents don't get them.
|
||||
if !a.ephemeral {
|
||||
scoped = append(scoped, builtinTools()...)
|
||||
}
|
||||
return scoped, nil
|
||||
}
|
||||
|
||||
func (a *agentImpl) buildPrompt() string {
|
||||
var base string
|
||||
switch {
|
||||
case a.opts.Prompt != "":
|
||||
base = a.opts.Prompt
|
||||
case len(a.opts.Services) > 0:
|
||||
base = fmt.Sprintf("You are the %s agent. You manage these services: %s. Use the available tools to fulfill requests.",
|
||||
a.opts.Name, strings.Join(a.opts.Services, ", "))
|
||||
default:
|
||||
base = fmt.Sprintf("You are the %s agent. Use the available tools to fulfill requests.", a.opts.Name)
|
||||
}
|
||||
|
||||
// Keep the agent oriented: surface its saved plan, if any.
|
||||
if !a.ephemeral {
|
||||
if plan := a.loadPlan(); plan != "" {
|
||||
base += "\n\nYour current plan (update it with the plan tool as you make progress):\n" + plan
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func (a *agentImpl) historyKey() string {
|
||||
return "agent/" + a.opts.Name + "/history"
|
||||
}
|
||||
|
||||
func (a *agentImpl) loadHistory() {
|
||||
recs, err := a.opts.Store.Read(a.historyKey())
|
||||
if err != nil || len(recs) == 0 {
|
||||
return
|
||||
}
|
||||
var messages []ai.Message
|
||||
if err := json.Unmarshal(recs[0].Value, &messages); err != nil {
|
||||
return
|
||||
}
|
||||
for _, m := range messages {
|
||||
a.hist.Add(m.Role, m.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agentImpl) saveHistory() {
|
||||
if a.ephemeral {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(a.hist.Messages())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
a.opts.Store.Write(&store.Record{
|
||||
Key: a.historyKey(),
|
||||
Value: data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
a := New(
|
||||
Name("test-agent"),
|
||||
Services("task", "project"),
|
||||
Prompt("You manage tasks."),
|
||||
Provider("anthropic"),
|
||||
)
|
||||
|
||||
if a.Name() != "test-agent" {
|
||||
t.Errorf("Name() = %q, want %q", a.Name(), "test-agent")
|
||||
}
|
||||
|
||||
opts := a.Options()
|
||||
if opts.Provider != "anthropic" {
|
||||
t.Errorf("Provider = %q, want %q", opts.Provider, "anthropic")
|
||||
}
|
||||
if len(opts.Services) != 2 {
|
||||
t.Fatalf("Services = %v, want 2 items", opts.Services)
|
||||
}
|
||||
if opts.Services[0] != "task" || opts.Services[1] != "project" {
|
||||
t.Errorf("Services = %v, want [task project]", opts.Services)
|
||||
}
|
||||
if opts.Prompt != "You manage tasks." {
|
||||
t.Errorf("Prompt = %q, want %q", opts.Prompt, "You manage tasks.")
|
||||
}
|
||||
if opts.HistoryLimit != 50 {
|
||||
t.Errorf("HistoryLimit = %d, want 50", opts.HistoryLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt(t *testing.T) {
|
||||
// Custom prompt
|
||||
a := New(Name("test"), Prompt("custom prompt")).(*agentImpl)
|
||||
if got := a.buildPrompt(); got != "custom prompt" {
|
||||
t.Errorf("buildPrompt() = %q, want %q", got, "custom prompt")
|
||||
}
|
||||
|
||||
// Auto-generated prompt with services
|
||||
a = New(Name("test"), Services("task", "project")).(*agentImpl)
|
||||
got := a.buildPrompt()
|
||||
if got == "" {
|
||||
t.Error("buildPrompt() returned empty")
|
||||
}
|
||||
if !contains(got, "task") || !contains(got, "project") {
|
||||
t.Errorf("buildPrompt() = %q, should mention services", got)
|
||||
}
|
||||
|
||||
// Auto-generated prompt without services
|
||||
a = New(Name("test")).(*agentImpl)
|
||||
got = a.buildPrompt()
|
||||
if !contains(got, "test") {
|
||||
t.Errorf("buildPrompt() = %q, should mention agent name", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
a := New(Name("test"))
|
||||
opts := a.Options()
|
||||
|
||||
if opts.Registry == nil {
|
||||
t.Error("Registry should default to DefaultRegistry")
|
||||
}
|
||||
if opts.Client == nil {
|
||||
t.Error("Client should default to DefaultClient")
|
||||
}
|
||||
if opts.Store == nil {
|
||||
t.Error("Store should default to DefaultStore")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
|
||||
}
|
||||
|
||||
func containsStr(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
codecBytes "go-micro.dev/v5/codec/bytes"
|
||||
"go-micro.dev/v5/store"
|
||||
)
|
||||
|
||||
// Built-in agent tools. These are not service endpoints — they are
|
||||
// capabilities the agent has over itself: maintaining a plan in its
|
||||
// memory, and delegating a subtask to another agent.
|
||||
//
|
||||
// They are plain tools, wired into the agent's tool handler alongside
|
||||
// the discovered service tools. There is no separate harness or graph:
|
||||
// the LLM calls them like any other tool.
|
||||
const (
|
||||
toolPlan = "plan"
|
||||
toolDelegate = "delegate"
|
||||
)
|
||||
|
||||
// builtinTools returns the tool definitions exposed to the model in
|
||||
// addition to the agent's scoped service tools.
|
||||
func builtinTools() []ai.Tool {
|
||||
return []ai.Tool{
|
||||
{
|
||||
Name: toolPlan,
|
||||
OriginalName: toolPlan,
|
||||
Description: "Record or update your plan as an ordered list of steps before doing multi-step work. " +
|
||||
"Call this whenever the plan changes. The plan is saved to your memory and shown back to you on later turns.",
|
||||
Properties: map[string]any{
|
||||
"steps": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Ordered plan steps. Each step has a 'task' (string) and a " +
|
||||
"'status' (one of: pending, in_progress, done).",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: toolDelegate,
|
||||
OriginalName: toolDelegate,
|
||||
Description: "Delegate a self-contained subtask to another agent. If 'to' names an agent that already " +
|
||||
"manages the relevant services, that agent handles it; otherwise a focused sub-agent is created for the " +
|
||||
"subtask. The sub-agent works in an isolated context and returns only its result. Use this to keep your " +
|
||||
"own context focused and to let domain experts handle their own services.",
|
||||
Properties: map[string]any{
|
||||
"task": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The subtask to delegate, described completely and self-contained.",
|
||||
},
|
||||
"to": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional. The agent or service name best suited to the subtask.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Builtins returns the built-in agent tools (plan, delegate) together
|
||||
// with a handler for them, so the same capabilities can be wired into a
|
||||
// tool loop that isn't a running Agent — for example the `micro chat`
|
||||
// fallback. The handler's third return value is false when the name is
|
||||
// not a built-in, so callers can fall through to their own tools.
|
||||
//
|
||||
// Configure it with the same options as an Agent (Name, Provider,
|
||||
// WithStore, WithRegistry, WithClient, ...); these back plan's memory
|
||||
// and delegate's RPC/sub-agent behaviour.
|
||||
func Builtins(opts ...Option) (tools []ai.Tool, handle func(name string, input map[string]any) (result any, content string, ok bool)) {
|
||||
a := &agentImpl{opts: newOptions(opts...)}
|
||||
handle = func(name string, input map[string]any) (any, string, bool) {
|
||||
switch name {
|
||||
case toolPlan:
|
||||
r, c := a.handlePlan(input)
|
||||
return r, c, true
|
||||
case toolDelegate:
|
||||
r, c := a.handleDelegate(input)
|
||||
return r, c, true
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
return builtinTools(), handle
|
||||
}
|
||||
|
||||
// toolHandler returns the agent's tool-call handler. It intercepts the
|
||||
// built-in tools and falls through to RPC service execution for the
|
||||
// rest. Ephemeral sub-agents get the bare service handler so they can
|
||||
// neither plan nor re-delegate (which prevents runaway recursion).
|
||||
func (a *agentImpl) toolHandler() ai.ToolHandler {
|
||||
base := a.tools.Handler()
|
||||
if a.ephemeral {
|
||||
return base
|
||||
}
|
||||
return func(name string, input map[string]any) (any, string) {
|
||||
// plan is internal bookkeeping, not an action — never gated.
|
||||
if name == toolPlan {
|
||||
return a.handlePlan(input)
|
||||
}
|
||||
|
||||
// Stopping condition: bound the number of actions per Ask.
|
||||
if a.opts.MaxSteps > 0 {
|
||||
a.steps++
|
||||
if a.steps > a.opts.MaxSteps {
|
||||
return errResult(fmt.Sprintf(
|
||||
"step limit reached (%d). Do not call any more tools; stop and summarize what you have so far.",
|
||||
a.opts.MaxSteps))
|
||||
}
|
||||
}
|
||||
|
||||
// Human-in-the-loop / policy: gate the action before it runs.
|
||||
if a.opts.Approve != nil {
|
||||
if ok, reason := a.opts.Approve(name, input); !ok {
|
||||
msg := "tool call was not approved"
|
||||
if reason != "" {
|
||||
msg += ": " + reason
|
||||
}
|
||||
return errResult(msg)
|
||||
}
|
||||
}
|
||||
|
||||
if name == toolDelegate {
|
||||
return a.handleDelegate(input)
|
||||
}
|
||||
return base(name, input)
|
||||
}
|
||||
}
|
||||
|
||||
// handlePlan persists the supplied plan to the agent's memory and
|
||||
// echoes it back so the model can see the stored state.
|
||||
func (a *agentImpl) handlePlan(input map[string]any) (any, string) {
|
||||
data, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return errResult("invalid plan: " + err.Error())
|
||||
}
|
||||
if a.opts.Store != nil {
|
||||
a.opts.Store.Write(&store.Record{Key: a.planKey(), Value: data})
|
||||
}
|
||||
return input, string(data)
|
||||
}
|
||||
|
||||
// handleDelegate hands a subtask to another agent. Delegate-first:
|
||||
// if 'to' names a registered agent, it is called via RPC. Otherwise an
|
||||
// ephemeral sub-agent is created with a fresh, isolated context, asked
|
||||
// the subtask, and its reply returned.
|
||||
func (a *agentImpl) handleDelegate(input map[string]any) (any, string) {
|
||||
task, _ := input["task"].(string)
|
||||
if task == "" {
|
||||
return errResult("task is required")
|
||||
}
|
||||
to, _ := input["to"].(string)
|
||||
|
||||
// Delegate-first: an existing agent that owns the domain handles it.
|
||||
if to != "" && a.isAgent(to) {
|
||||
reply, err := a.callAgentRPC(context.Background(), to, task)
|
||||
if err != nil {
|
||||
return errResult("delegate to agent " + to + ": " + err.Error())
|
||||
}
|
||||
out := map[string]any{"agent": to, "reply": reply}
|
||||
b, _ := json.Marshal(out)
|
||||
return out, string(b)
|
||||
}
|
||||
|
||||
// Otherwise create a focused, ephemeral sub-agent. Fresh context:
|
||||
// it loads no history and persists none.
|
||||
var svcs []string
|
||||
if to != "" {
|
||||
svcs = []string{to}
|
||||
}
|
||||
sub := newEphemeral(
|
||||
Name(a.opts.Name+".sub"),
|
||||
Services(svcs...),
|
||||
Prompt("You are a sub-agent handling a single delegated subtask. "+
|
||||
"Complete it using the available tools and report the result concisely."),
|
||||
Provider(a.opts.Provider),
|
||||
Model(a.opts.Model),
|
||||
APIKey(a.opts.APIKey),
|
||||
WithRegistry(a.opts.Registry),
|
||||
WithClient(a.opts.Client),
|
||||
WithStore(a.opts.Store),
|
||||
)
|
||||
|
||||
resp, err := sub.Ask(context.Background(), task)
|
||||
if err != nil {
|
||||
return errResult("sub-agent: " + err.Error())
|
||||
}
|
||||
out := map[string]any{"reply": resp.Reply}
|
||||
b, _ := json.Marshal(out)
|
||||
return out, string(b)
|
||||
}
|
||||
|
||||
// isAgent reports whether name resolves to a registered agent (a
|
||||
// service advertising type=agent in its metadata).
|
||||
func (a *agentImpl) isAgent(name string) bool {
|
||||
if a.opts.Registry == nil {
|
||||
return false
|
||||
}
|
||||
recs, err := a.opts.Registry.GetService(name)
|
||||
if err != nil || len(recs) == 0 {
|
||||
return false
|
||||
}
|
||||
if recs[0].Metadata != nil && recs[0].Metadata["type"] == "agent" {
|
||||
return true
|
||||
}
|
||||
for _, n := range recs[0].Nodes {
|
||||
if n.Metadata != nil && n.Metadata["type"] == "agent" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// callAgentRPC calls another agent's Agent.Chat endpoint and returns
|
||||
// its reply.
|
||||
func (a *agentImpl) callAgentRPC(ctx context.Context, name, msg string) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{"message": msg})
|
||||
req := a.opts.Client.NewRequest(name, "Agent.Chat", &codecBytes.Frame{Data: body})
|
||||
var rsp codecBytes.Frame
|
||||
if err := a.opts.Client.Call(ctx, req, &rsp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
Reply string `json:"reply"`
|
||||
}
|
||||
if err := json.Unmarshal(rsp.Data, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Reply, nil
|
||||
}
|
||||
|
||||
func (a *agentImpl) planKey() string {
|
||||
return "agent/" + a.opts.Name + "/plan"
|
||||
}
|
||||
|
||||
// loadPlan returns the stored plan as a JSON string, or "" if none.
|
||||
func (a *agentImpl) loadPlan() string {
|
||||
if a.opts.Store == nil {
|
||||
return ""
|
||||
}
|
||||
recs, err := a.opts.Store.Read(a.planKey())
|
||||
if err != nil || len(recs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return string(recs[0].Value)
|
||||
}
|
||||
|
||||
func errResult(msg string) (any, string) {
|
||||
m := map[string]string{"error": msg}
|
||||
b, _ := json.Marshal(m)
|
||||
return m, string(b)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/store"
|
||||
)
|
||||
|
||||
func TestBuiltinTools(t *testing.T) {
|
||||
tools := builtinTools()
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("builtinTools() = %d tools, want 2", len(tools))
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, tl := range tools {
|
||||
names[tl.Name] = true
|
||||
}
|
||||
if !names[toolPlan] || !names[toolDelegate] {
|
||||
t.Errorf("builtin tools = %v, want plan and delegate", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePlanPersists(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
|
||||
|
||||
steps := map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "gather requirements", "status": "done"},
|
||||
map[string]any{"task": "write code", "status": "in_progress"},
|
||||
},
|
||||
}
|
||||
_, content := a.handlePlan(steps)
|
||||
if content == "" {
|
||||
t.Fatal("handlePlan returned empty content")
|
||||
}
|
||||
|
||||
// The plan must be retrievable from memory.
|
||||
got := a.loadPlan()
|
||||
if got == "" {
|
||||
t.Fatal("loadPlan() returned empty after handlePlan")
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &decoded); err != nil {
|
||||
t.Fatalf("stored plan is not valid JSON: %v", err)
|
||||
}
|
||||
if _, ok := decoded["steps"]; !ok {
|
||||
t.Errorf("stored plan missing steps: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanShowsInPrompt(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(Name("planner"), Prompt("base prompt"), WithStore(mem)).(*agentImpl)
|
||||
|
||||
if got := a.buildPrompt(); got != "base prompt" {
|
||||
t.Errorf("buildPrompt() with no plan = %q, want %q", got, "base prompt")
|
||||
}
|
||||
|
||||
a.handlePlan(map[string]any{"steps": []any{map[string]any{"task": "do it", "status": "pending"}}})
|
||||
|
||||
got := a.buildPrompt()
|
||||
if got == "base prompt" {
|
||||
t.Error("buildPrompt() should include the plan once one is saved")
|
||||
}
|
||||
if !containsStr(got, "do it") {
|
||||
t.Errorf("buildPrompt() = %q, should contain the saved plan", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverToolsIncludesBuiltins(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
a := New(Name("a"), WithRegistry(reg), WithStore(store.NewMemoryStore())).(*agentImpl)
|
||||
a.setup()
|
||||
|
||||
tools, err := a.discoverTools()
|
||||
if err != nil {
|
||||
t.Fatalf("discoverTools: %v", err)
|
||||
}
|
||||
// No services registered, so the only tools should be the builtins.
|
||||
if len(tools) != len(builtinTools()) {
|
||||
t.Fatalf("discoverTools() = %d tools, want %d builtins", len(tools), len(builtinTools()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEphemeralAgentHasNoBuiltins(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
a := New(Name("a.sub"), WithRegistry(reg), WithStore(store.NewMemoryStore())).(*agentImpl)
|
||||
a.ephemeral = true
|
||||
a.setup()
|
||||
|
||||
tools, err := a.discoverTools()
|
||||
if err != nil {
|
||||
t.Fatalf("discoverTools: %v", err)
|
||||
}
|
||||
if len(tools) != 0 {
|
||||
t.Errorf("ephemeral agent discoverTools() = %d tools, want 0", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinsAccessor(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
tools, handle := Builtins(
|
||||
Name("chat"),
|
||||
WithStore(mem),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
)
|
||||
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("Builtins() returned %d tools, want 2", len(tools))
|
||||
}
|
||||
|
||||
// A name that isn't a built-in falls through (ok == false).
|
||||
if _, _, ok := handle("not_a_builtin", nil); ok {
|
||||
t.Error("handle(non-builtin) ok = true, want false")
|
||||
}
|
||||
|
||||
// plan is handled and persisted under the configured name.
|
||||
_, content, ok := handle(toolPlan, map[string]any{
|
||||
"steps": []any{map[string]any{"task": "x", "status": "pending"}},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("handle(plan) ok = false, want true")
|
||||
}
|
||||
if content == "" {
|
||||
t.Fatal("handle(plan) returned empty content")
|
||||
}
|
||||
if recs, err := mem.Read("agent/chat/plan"); err != nil || len(recs) == 0 {
|
||||
t.Errorf("plan not persisted under agent/chat/plan: err=%v recs=%d", err, len(recs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAgent(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
|
||||
// A plain service.
|
||||
if err := reg.Register(®istry.Service{
|
||||
Name: "task",
|
||||
Nodes: []*registry.Node{{Id: "task-1", Address: "127.0.0.1:0"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("register service: %v", err)
|
||||
}
|
||||
// An agent (advertises type=agent).
|
||||
if err := reg.Register(®istry.Service{
|
||||
Name: "task-mgr",
|
||||
Metadata: map[string]string{"type": "agent"},
|
||||
Nodes: []*registry.Node{{Id: "task-mgr-1", Address: "127.0.0.1:0"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("register agent: %v", err)
|
||||
}
|
||||
|
||||
a := New(Name("root"), WithRegistry(reg)).(*agentImpl)
|
||||
|
||||
if a.isAgent("task") {
|
||||
t.Error("isAgent(task) = true, want false (plain service)")
|
||||
}
|
||||
if !a.isAgent("task-mgr") {
|
||||
t.Error("isAgent(task-mgr) = false, want true (agent)")
|
||||
}
|
||||
if a.isAgent("nonexistent") {
|
||||
t.Error("isAgent(nonexistent) = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/store"
|
||||
)
|
||||
|
||||
// MaxSteps refuses tool calls once the per-Ask limit is exceeded; plan
|
||||
// is bookkeeping and is never counted.
|
||||
func TestMaxStepsStopsActions(t *testing.T) {
|
||||
a := newTestAgent(Name("limited"), MaxSteps(2))
|
||||
|
||||
h := a.toolHandler()
|
||||
|
||||
// plan must not consume a step.
|
||||
a.steps = 0
|
||||
h(toolPlan, map[string]any{"steps": []any{}})
|
||||
if a.steps != 0 {
|
||||
t.Fatalf("plan consumed a step: steps=%d", a.steps)
|
||||
}
|
||||
|
||||
// First two actions are allowed (they fall through to RPC, which
|
||||
// fails harmlessly — we only care they weren't refused by the limit).
|
||||
for i := 1; i <= 2; i++ {
|
||||
_, content := h("demo_Svc_Do", map[string]any{})
|
||||
if strings.Contains(content, "step limit") {
|
||||
t.Fatalf("action %d wrongly hit the step limit", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Third action exceeds MaxSteps(2) and must be refused.
|
||||
_, content := h("demo_Svc_Do", map[string]any{})
|
||||
if !strings.Contains(content, "step limit") {
|
||||
t.Errorf("third action should hit the step limit; got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
// ApproveTool blocks an action when the hook denies it, and the denial
|
||||
// reason is surfaced to the model.
|
||||
func TestApproveToolBlocks(t *testing.T) {
|
||||
var sawTool string
|
||||
a := newTestAgent(Name("gated"),
|
||||
ApproveTool(func(tool string, input map[string]any) (bool, string) {
|
||||
sawTool = tool
|
||||
return false, "needs sign-off"
|
||||
}),
|
||||
)
|
||||
|
||||
_, content := a.toolHandler()("demo_Svc_Do", map[string]any{})
|
||||
if sawTool != "demo_Svc_Do" {
|
||||
t.Errorf("approver saw %q, want demo_Svc_Do", sawTool)
|
||||
}
|
||||
if !strings.Contains(content, "not approved") || !strings.Contains(content, "needs sign-off") {
|
||||
t.Errorf("blocked call should surface the reason; got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
// A denying approver must not gate the internal plan tool.
|
||||
func TestApproveToolDoesNotGatePlan(t *testing.T) {
|
||||
mem := store.NewMemoryStore()
|
||||
a := New(
|
||||
Name("gated"),
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(mem),
|
||||
ApproveTool(func(tool string, input map[string]any) (bool, string) {
|
||||
return false, "deny everything"
|
||||
}),
|
||||
).(*agentImpl)
|
||||
a.setup()
|
||||
|
||||
_, content := a.toolHandler()(toolPlan, map[string]any{
|
||||
"steps": []any{map[string]any{"task": "x", "status": "pending"}},
|
||||
})
|
||||
if strings.Contains(content, "not approved") {
|
||||
t.Errorf("plan must not be gated by ApproveTool; got %q", content)
|
||||
}
|
||||
if recs, _ := mem.Read("agent/gated/plan"); len(recs) == 0 {
|
||||
t.Error("plan should have been persisted despite the denying approver")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
"go-micro.dev/v5/client"
|
||||
codecBytes "go-micro.dev/v5/codec/bytes"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/store"
|
||||
)
|
||||
|
||||
// fakeGen drives the fake provider's Generate. Tests set it and reset
|
||||
// it with a deferred cleanup. Tests in this package are not parallel,
|
||||
// so a package-level hook is safe.
|
||||
var fakeGen func(opts ai.Options, req *ai.Request) (*ai.Response, error)
|
||||
|
||||
type fakeModel struct{ opts ai.Options }
|
||||
|
||||
func (m *fakeModel) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&m.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *fakeModel) Options() ai.Options { return m.opts }
|
||||
func (m *fakeModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
|
||||
if fakeGen != nil {
|
||||
return fakeGen(m.opts, req)
|
||||
}
|
||||
return &ai.Response{Reply: "ok"}, nil
|
||||
}
|
||||
func (m *fakeModel) Stream(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *fakeModel) String() string { return "fake" }
|
||||
|
||||
func init() {
|
||||
ai.Register("fake", func(opts ...ai.Option) ai.Model {
|
||||
m := &fakeModel{}
|
||||
_ = m.Init(opts...)
|
||||
return m
|
||||
})
|
||||
}
|
||||
|
||||
// fakeClient embeds the default client (so NewRequest works) and
|
||||
// overrides Call with a test-supplied function.
|
||||
type fakeClient struct {
|
||||
client.Client
|
||||
callFn func(ctx context.Context, req client.Request, rsp interface{}) error
|
||||
}
|
||||
|
||||
func (c *fakeClient) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
|
||||
return c.callFn(ctx, req, rsp)
|
||||
}
|
||||
|
||||
func newTestAgent(opts ...Option) *agentImpl {
|
||||
base := []Option{
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
}
|
||||
a := New(append(base, opts...)...).(*agentImpl)
|
||||
a.setup()
|
||||
return a
|
||||
}
|
||||
|
||||
// The model is offered the plan and delegate tools, and calling the
|
||||
// plan tool persists the plan to memory.
|
||||
func TestAskExposesAndRunsPlan(t *testing.T) {
|
||||
var sawPlan, sawDelegate bool
|
||||
fakeGen = func(opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
for _, tl := range req.Tools {
|
||||
switch tl.Name {
|
||||
case toolPlan:
|
||||
sawPlan = true
|
||||
case toolDelegate:
|
||||
sawDelegate = true
|
||||
}
|
||||
}
|
||||
// Simulate the model recording a plan.
|
||||
if opts.ToolHandler != nil {
|
||||
opts.ToolHandler(toolPlan, map[string]any{
|
||||
"steps": []any{map[string]any{"task": "step one", "status": "pending"}},
|
||||
})
|
||||
}
|
||||
return &ai.Response{Answer: "done"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("worker"))
|
||||
resp, err := a.Ask(context.Background(), "do some multi-step work")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if !sawPlan || !sawDelegate {
|
||||
t.Errorf("model should be offered plan and delegate tools: plan=%v delegate=%v", sawPlan, sawDelegate)
|
||||
}
|
||||
if resp.Reply == "" {
|
||||
t.Error("Ask returned empty reply")
|
||||
}
|
||||
if plan := a.loadPlan(); !strings.Contains(plan, "step one") {
|
||||
t.Errorf("plan tool result not persisted; loadPlan() = %q", plan)
|
||||
}
|
||||
}
|
||||
|
||||
// Delegating with no matching agent creates an ephemeral sub-agent with
|
||||
// a fresh, isolated context (no builtin tools) and returns its reply.
|
||||
func TestDelegateEphemeral(t *testing.T) {
|
||||
fakeGen = func(opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if strings.Contains(req.SystemPrompt, "sub-agent") {
|
||||
for _, tl := range req.Tools {
|
||||
if tl.Name == toolPlan || tl.Name == toolDelegate {
|
||||
t.Errorf("ephemeral sub-agent must not have builtin tool %q", tl.Name)
|
||||
}
|
||||
}
|
||||
return &ai.Response{Reply: "subtask complete"}, nil
|
||||
}
|
||||
return &ai.Response{Reply: "parent"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("root"))
|
||||
_, content := a.handleDelegate(map[string]any{"task": "summarize the report"})
|
||||
if !strings.Contains(content, "subtask complete") {
|
||||
t.Errorf("delegate should return the sub-agent's reply; got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
// Delegating to a name that resolves to a registered agent goes over
|
||||
// RPC to that agent rather than spawning a sub-agent.
|
||||
func TestDelegateToRegisteredAgent(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
if err := reg.Register(®istry.Service{
|
||||
Name: "comms",
|
||||
Metadata: map[string]string{"type": "agent"},
|
||||
Nodes: []*registry.Node{{Id: "comms-1", Address: "127.0.0.1:0"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("register agent: %v", err)
|
||||
}
|
||||
|
||||
var calledService, calledEndpoint string
|
||||
fc := &fakeClient{Client: client.DefaultClient}
|
||||
fc.callFn = func(ctx context.Context, req client.Request, rsp interface{}) error {
|
||||
calledService, calledEndpoint = req.Service(), req.Endpoint()
|
||||
frame := rsp.(*codecBytes.Frame)
|
||||
frame.Data = []byte(`{"reply":"notified alice","agent":"comms"}`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeGen guards against the ephemeral path being taken by mistake.
|
||||
fakeGen = func(opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
t.Error("delegate to a registered agent must not spawn a sub-agent")
|
||||
return &ai.Response{}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("root"), WithRegistry(reg), WithClient(fc))
|
||||
_, content := a.handleDelegate(map[string]any{"task": "notify alice", "to": "comms"})
|
||||
|
||||
if calledService != "comms" || calledEndpoint != "Agent.Chat" {
|
||||
t.Errorf("expected RPC to comms Agent.Chat, got %s %s", calledService, calledEndpoint)
|
||||
}
|
||||
if !strings.Contains(content, "notified alice") {
|
||||
t.Errorf("delegate-first result missing agent reply; got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate requires a task.
|
||||
func TestDelegateRequiresTask(t *testing.T) {
|
||||
a := newTestAgent(Name("root"))
|
||||
_, content := a.handleDelegate(map[string]any{})
|
||||
if !strings.Contains(content, "error") {
|
||||
t.Errorf("delegate with no task should error; got %q", content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/store"
|
||||
)
|
||||
|
||||
// Option configures an Agent.
|
||||
type Option func(*Options)
|
||||
|
||||
// ApproveFunc decides whether an agent may execute a tool call before it
|
||||
// runs. Returning false blocks the call; the reason is shown to the
|
||||
// model so it can adapt. Use it for human-in-the-loop approval or policy
|
||||
// checks. It is called for actions (service tools and delegate), not for
|
||||
// the internal plan tool.
|
||||
type ApproveFunc func(tool string, input map[string]any) (approved bool, reason string)
|
||||
|
||||
// Options holds agent configuration.
|
||||
type Options struct {
|
||||
Name string
|
||||
Services []string
|
||||
Prompt string
|
||||
Provider string
|
||||
Model string
|
||||
APIKey string
|
||||
Registry registry.Registry
|
||||
Client client.Client
|
||||
Store store.Store
|
||||
HistoryLimit int
|
||||
|
||||
// MaxSteps bounds the number of tool executions per Ask (0 =
|
||||
// unbounded). Once exceeded, further tool calls are refused and the
|
||||
// model is told to stop and summarize. A stopping condition.
|
||||
MaxSteps int
|
||||
// Approve gates each action before it runs. Nil = allow all.
|
||||
Approve ApproveFunc
|
||||
}
|
||||
|
||||
func newOptions(opts ...Option) Options {
|
||||
o := Options{
|
||||
Registry: registry.DefaultRegistry,
|
||||
Client: client.DefaultClient,
|
||||
Store: store.DefaultStore,
|
||||
HistoryLimit: 50,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// Name sets the agent name.
|
||||
func Name(n string) Option {
|
||||
return func(o *Options) { o.Name = n }
|
||||
}
|
||||
|
||||
// Services sets which services this agent manages.
|
||||
func Services(names ...string) Option {
|
||||
return func(o *Options) { o.Services = names }
|
||||
}
|
||||
|
||||
// Prompt sets the system prompt.
|
||||
func Prompt(p string) Option {
|
||||
return func(o *Options) { o.Prompt = p }
|
||||
}
|
||||
|
||||
// Provider sets the LLM provider.
|
||||
func Provider(p string) Option {
|
||||
return func(o *Options) { o.Provider = p }
|
||||
}
|
||||
|
||||
// Model sets the LLM model name.
|
||||
func Model(m string) Option {
|
||||
return func(o *Options) { o.Model = m }
|
||||
}
|
||||
|
||||
// APIKey sets the API key for the LLM provider.
|
||||
func APIKey(k string) Option {
|
||||
return func(o *Options) { o.APIKey = k }
|
||||
}
|
||||
|
||||
// WithRegistry sets the service registry.
|
||||
func WithRegistry(r registry.Registry) Option {
|
||||
return func(o *Options) { o.Registry = r }
|
||||
}
|
||||
|
||||
// WithClient sets the RPC client.
|
||||
func WithClient(c client.Client) Option {
|
||||
return func(o *Options) { o.Client = c }
|
||||
}
|
||||
|
||||
// WithStore sets the store for agent memory.
|
||||
func WithStore(s store.Store) Option {
|
||||
return func(o *Options) { o.Store = s }
|
||||
}
|
||||
|
||||
// HistoryLimit sets the max conversation messages to retain.
|
||||
func HistoryLimit(n int) Option {
|
||||
return func(o *Options) { o.HistoryLimit = n }
|
||||
}
|
||||
|
||||
// MaxSteps bounds tool executions per Ask (0 = unbounded). A stopping
|
||||
// condition: beyond the limit, tool calls are refused and the model is
|
||||
// told to stop and summarize.
|
||||
func MaxSteps(n int) Option {
|
||||
return func(o *Options) { o.MaxSteps = n }
|
||||
}
|
||||
|
||||
// ApproveTool sets a human-in-the-loop / policy hook called before each
|
||||
// action (service tools and delegate). Returning false blocks the call.
|
||||
func ApproveTool(fn ApproveFunc) Option {
|
||||
return func(o *Options) { o.Approve = fn }
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.21.12
|
||||
// source: proto/agent.proto
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type ChatRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ChatRequest) Reset() {
|
||||
*x = ChatRequest{}
|
||||
mi := &file_proto_agent_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ChatRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ChatRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ChatRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_agent_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ChatRequest) Descriptor() ([]byte, []int) {
|
||||
return file_proto_agent_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *ChatRequest) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ChatResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Reply string `protobuf:"bytes,1,opt,name=reply,proto3" json:"reply,omitempty"`
|
||||
Agent string `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"`
|
||||
ToolCalls []*ToolCall `protobuf:"bytes,3,rep,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ChatResponse) Reset() {
|
||||
*x = ChatResponse{}
|
||||
mi := &file_proto_agent_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ChatResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ChatResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ChatResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_agent_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ChatResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ChatResponse) Descriptor() ([]byte, []int) {
|
||||
return file_proto_agent_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *ChatResponse) GetReply() string {
|
||||
if x != nil {
|
||||
return x.Reply
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ChatResponse) GetAgent() string {
|
||||
if x != nil {
|
||||
return x.Agent
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ChatResponse) GetToolCalls() []*ToolCall {
|
||||
if x != nil {
|
||||
return x.ToolCalls
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Input string `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"`
|
||||
Result string `protobuf:"bytes,4,opt,name=result,proto3" json:"result,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ToolCall) Reset() {
|
||||
*x = ToolCall{}
|
||||
mi := &file_proto_agent_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ToolCall) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ToolCall) ProtoMessage() {}
|
||||
|
||||
func (x *ToolCall) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_agent_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ToolCall.ProtoReflect.Descriptor instead.
|
||||
func (*ToolCall) Descriptor() ([]byte, []int) {
|
||||
return file_proto_agent_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *ToolCall) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ToolCall) GetName() string {
|
||||
if x != nil {
|
||||
return x.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ToolCall) GetInput() string {
|
||||
if x != nil {
|
||||
return x.Input
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ToolCall) GetResult() string {
|
||||
if x != nil {
|
||||
return x.Result
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_proto_agent_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_proto_agent_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x11proto/agent.proto\x12\x05agent\"'\n" +
|
||||
"\vChatRequest\x12\x18\n" +
|
||||
"\amessage\x18\x01 \x01(\tR\amessage\"j\n" +
|
||||
"\fChatResponse\x12\x14\n" +
|
||||
"\x05reply\x18\x01 \x01(\tR\x05reply\x12\x14\n" +
|
||||
"\x05agent\x18\x02 \x01(\tR\x05agent\x12.\n" +
|
||||
"\n" +
|
||||
"tool_calls\x18\x03 \x03(\v2\x0f.agent.ToolCallR\ttoolCalls\"\\\n" +
|
||||
"\bToolCall\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" +
|
||||
"\x05input\x18\x03 \x01(\tR\x05input\x12\x16\n" +
|
||||
"\x06result\x18\x04 \x01(\tR\x06result2:\n" +
|
||||
"\x05Agent\x121\n" +
|
||||
"\x04Chat\x12\x12.agent.ChatRequest\x1a\x13.agent.ChatResponse\"\x00B\x0fZ\r./proto;agentb\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_agent_proto_rawDescOnce sync.Once
|
||||
file_proto_agent_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_proto_agent_proto_rawDescGZIP() []byte {
|
||||
file_proto_agent_proto_rawDescOnce.Do(func() {
|
||||
file_proto_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_agent_proto_rawDesc), len(file_proto_agent_proto_rawDesc)))
|
||||
})
|
||||
return file_proto_agent_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||
var file_proto_agent_proto_goTypes = []any{
|
||||
(*ChatRequest)(nil), // 0: agent.ChatRequest
|
||||
(*ChatResponse)(nil), // 1: agent.ChatResponse
|
||||
(*ToolCall)(nil), // 2: agent.ToolCall
|
||||
}
|
||||
var file_proto_agent_proto_depIdxs = []int32{
|
||||
2, // 0: agent.ChatResponse.tool_calls:type_name -> agent.ToolCall
|
||||
0, // 1: agent.Agent.Chat:input_type -> agent.ChatRequest
|
||||
1, // 2: agent.Agent.Chat:output_type -> agent.ChatResponse
|
||||
2, // [2:3] is the sub-list for method output_type
|
||||
1, // [1:2] is the sub-list for method input_type
|
||||
1, // [1:1] is the sub-list for extension type_name
|
||||
1, // [1:1] is the sub-list for extension extendee
|
||||
0, // [0:1] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_proto_agent_proto_init() }
|
||||
func file_proto_agent_proto_init() {
|
||||
if File_proto_agent_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_agent_proto_rawDesc), len(file_proto_agent_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 3,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_proto_agent_proto_goTypes,
|
||||
DependencyIndexes: file_proto_agent_proto_depIdxs,
|
||||
MessageInfos: file_proto_agent_proto_msgTypes,
|
||||
}.Build()
|
||||
File_proto_agent_proto = out.File
|
||||
file_proto_agent_proto_goTypes = nil
|
||||
file_proto_agent_proto_depIdxs = nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Code generated by protoc-gen-micro. DO NOT EDIT.
|
||||
// source: proto/agent.proto
|
||||
|
||||
package agent
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// Client API for Agent service
|
||||
|
||||
type AgentService interface {
|
||||
Chat(ctx context.Context, in *ChatRequest, opts ...client.CallOption) (*ChatResponse, error)
|
||||
}
|
||||
|
||||
type agentService struct {
|
||||
c client.Client
|
||||
name string
|
||||
}
|
||||
|
||||
func NewAgentService(name string, c client.Client) AgentService {
|
||||
return &agentService{
|
||||
c: c,
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *agentService) Chat(ctx context.Context, in *ChatRequest, opts ...client.CallOption) (*ChatResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "Agent.Chat", in)
|
||||
out := new(ChatResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Server API for Agent service
|
||||
|
||||
type AgentHandler interface {
|
||||
Chat(context.Context, *ChatRequest, *ChatResponse) error
|
||||
}
|
||||
|
||||
func RegisterAgentHandler(s server.Server, hdlr AgentHandler, opts ...server.HandlerOption) error {
|
||||
type agent interface {
|
||||
Chat(ctx context.Context, in *ChatRequest, out *ChatResponse) error
|
||||
}
|
||||
type Agent struct {
|
||||
agent
|
||||
}
|
||||
h := &agentHandler{hdlr}
|
||||
return s.Handle(s.NewHandler(&Agent{h}, opts...))
|
||||
}
|
||||
|
||||
type agentHandler struct {
|
||||
AgentHandler
|
||||
}
|
||||
|
||||
func (h *agentHandler) Chat(ctx context.Context, in *ChatRequest, out *ChatResponse) error {
|
||||
return h.AgentHandler.Chat(ctx, in, out)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package agent;
|
||||
|
||||
option go_package = "./proto;agent";
|
||||
|
||||
// Agent is the RPC interface for an AI agent.
|
||||
service Agent {
|
||||
rpc Chat(ChatRequest) returns (ChatResponse) {}
|
||||
}
|
||||
|
||||
message ChatRequest {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
message ChatResponse {
|
||||
string reply = 1;
|
||||
string agent = 2;
|
||||
repeated ToolCall tool_calls = 3;
|
||||
}
|
||||
|
||||
message ToolCall {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string input = 3;
|
||||
string result = 4;
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
# AI Package
|
||||
|
||||
The `ai` package provides simple, high-level interfaces for AI model providers. It supports text generation (`Model`), image generation (`ImageModel`), and video generation (`VideoModel`).
|
||||
|
||||
## Interfaces
|
||||
|
||||
### Text Generation (Model)
|
||||
|
||||
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)
|
||||
```
|
||||
|
||||
### Image Generation (ImageModel)
|
||||
|
||||
```go
|
||||
type ImageModel interface {
|
||||
GenerateImage(ctx context.Context, req *ImageRequest, opts ...GenerateOption) (*ImageResponse, error)
|
||||
String() string
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
import (
|
||||
"go-micro.dev/v5/ai"
|
||||
_ "go-micro.dev/v5/ai/atlascloud"
|
||||
)
|
||||
|
||||
ig := ai.NewImage("atlascloud",
|
||||
ai.WithAPIKey("your-api-key"),
|
||||
)
|
||||
|
||||
resp, err := ig.GenerateImage(context.Background(), &ai.ImageRequest{
|
||||
Prompt: "A Go gopher in space",
|
||||
Size: "1024x1024",
|
||||
})
|
||||
|
||||
fmt.Println(resp.Images[0].URL)
|
||||
```
|
||||
|
||||
Providers that support image generation: **Atlas Cloud**, **OpenAI**.
|
||||
|
||||
### Video Generation (VideoModel)
|
||||
|
||||
```go
|
||||
type VideoModel interface {
|
||||
GenerateVideo(ctx context.Context, req *VideoRequest, opts ...GenerateOption) (*VideoResponse, error)
|
||||
String() string
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
import (
|
||||
"go-micro.dev/v5/ai"
|
||||
_ "go-micro.dev/v5/ai/atlascloud"
|
||||
)
|
||||
|
||||
vg := ai.NewVideo("atlascloud",
|
||||
ai.WithAPIKey("your-api-key"),
|
||||
)
|
||||
|
||||
resp, err := vg.GenerateVideo(context.Background(), &ai.VideoRequest{
|
||||
Prompt: "Microservices nodes animating with data flowing between them",
|
||||
Images: []string{"https://example.com/diagram.png"}, // optional: image-to-video
|
||||
Duration: 6,
|
||||
})
|
||||
|
||||
fmt.Println(resp.URL)
|
||||
```
|
||||
|
||||
Providers that support video generation: **Atlas Cloud**.
|
||||
|
||||
## 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`
|
||||
|
||||
### Google Gemini
|
||||
|
||||
```go
|
||||
m := ai.New("gemini",
|
||||
ai.WithAPIKey("your-key"),
|
||||
ai.WithModel("gemini-2.5-flash"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `gemini-2.5-flash`
|
||||
Default base URL: `https://generativelanguage.googleapis.com`
|
||||
|
||||
Google Gemini uses its own API format with `system_instruction`, `contents` (not `messages`), and `functionDeclarations` for tool calling. The provider handles the translation automatically.
|
||||
|
||||
### Groq
|
||||
|
||||
```go
|
||||
m := ai.New("groq",
|
||||
ai.WithAPIKey("your-key"),
|
||||
ai.WithModel("llama-3.3-70b-versatile"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `llama-3.3-70b-versatile`
|
||||
Default base URL: `https://api.groq.com/openai`
|
||||
|
||||
Groq provides ultra-fast inference for open-weight models via an OpenAI-compatible endpoint.
|
||||
|
||||
### Mistral
|
||||
|
||||
```go
|
||||
m := ai.New("mistral",
|
||||
ai.WithAPIKey("your-key"),
|
||||
ai.WithModel("mistral-large-latest"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `mistral-large-latest`
|
||||
Default base URL: `https://api.mistral.ai`
|
||||
|
||||
Mistral AI is a European AI company offering high-performance models via an OpenAI-compatible endpoint.
|
||||
|
||||
### Together AI
|
||||
|
||||
```go
|
||||
m := ai.New("together",
|
||||
ai.WithAPIKey("your-key"),
|
||||
ai.WithModel("meta-llama/Llama-3.3-70B-Instruct-Turbo"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `meta-llama/Llama-3.3-70B-Instruct-Turbo`
|
||||
Default base URL: `https://api.together.xyz`
|
||||
|
||||
Together AI provides fast inference for open-weight models via an OpenAI-compatible endpoint.
|
||||
|
||||
### Atlas Cloud
|
||||
|
||||
```go
|
||||
m := ai.New("atlascloud",
|
||||
ai.WithAPIKey("your-key"),
|
||||
ai.WithModel("llama-3.3-70b"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `llama-3.3-70b`
|
||||
Default base URL: `https://api.atlascloud.ai`
|
||||
|
||||
Atlas Cloud is an enterprise AI infrastructure platform offering high-performance LLM APIs. It exposes an OpenAI-compatible chat completions endpoint with tool calling support.
|
||||
|
||||
## 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
|
||||
|
||||
See the full **[AI Provider Integration Guide](../internal/website/docs/guides/ai-provider-guide.md)** for a step-by-step walkthrough, checklist, and design notes.
|
||||
|
||||
Quick summary:
|
||||
|
||||
1. Create `ai/yourprovider/yourprovider.go` implementing `ai.Model`.
|
||||
2. Call `ai.Register("yourprovider", ...)` in `init()`.
|
||||
3. Add tests in `ai/yourprovider/yourprovider_test.go`.
|
||||
4. Users enable the provider with a blank import:
|
||||
|
||||
```go
|
||||
import _ "go-micro.dev/v5/ai/yourprovider"
|
||||
```
|
||||
|
||||
We welcome contributions and sponsorships from AI infrastructure companies — see the guide for details.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,272 @@
|
||||
// Package anthropic implements the Anthropic Claude model provider
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("anthropic", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for Anthropic Claude
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
// NewProvider creates a new Anthropic provider
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
|
||||
// Set defaults if not provided
|
||||
if options.Model == "" {
|
||||
options.Model = "claude-sonnet-4-20250514"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.anthropic.com"
|
||||
}
|
||||
|
||||
return &Provider{
|
||||
opts: options,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the provider with options
|
||||
func (p *Provider) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&p.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Options returns the provider options
|
||||
func (p *Provider) Options() ai.Options {
|
||||
return p.opts
|
||||
}
|
||||
|
||||
// String returns the provider name
|
||||
func (p *Provider) String() string {
|
||||
return "anthropic"
|
||||
}
|
||||
|
||||
// Generate generates a response from the model
|
||||
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 {
|
||||
anthropicTools = append(anthropicTools, map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"input_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Build initial request
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"max_tokens": 8192,
|
||||
"system": req.SystemPrompt,
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
},
|
||||
}
|
||||
|
||||
if len(anthropicTools) > 0 {
|
||||
apiReq["tools"] = anthropicTools
|
||||
}
|
||||
|
||||
// Make API call
|
||||
resp, rawContent, err := p.callAPI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no tool calls or no handler, return as-is
|
||||
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Tool execution loop: execute tools, send results back, repeat
|
||||
// until the model responds with text only (no more tool calls)
|
||||
messages := []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
{"role": "assistant", "content": cleanContent(rawContent)},
|
||||
}
|
||||
|
||||
pendingCalls := resp.ToolCalls
|
||||
|
||||
for rounds := 0; rounds < 10; rounds++ {
|
||||
var toolResultBlocks []map[string]any
|
||||
for i := range pendingCalls {
|
||||
_, content := p.opts.ToolHandler(pendingCalls[i].Name, pendingCalls[i].Input)
|
||||
pendingCalls[i].Result = content
|
||||
toolResultBlocks = append(toolResultBlocks, map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": pendingCalls[i].ID,
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
|
||||
messages = append(messages, map[string]any{
|
||||
"role": "user",
|
||||
"content": toolResultBlocks,
|
||||
})
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"max_tokens": 8192,
|
||||
"system": req.SystemPrompt,
|
||||
"messages": messages,
|
||||
}
|
||||
if len(anthropicTools) > 0 {
|
||||
followUpReq["tools"] = anthropicTools
|
||||
}
|
||||
|
||||
followUpResp, followUpRaw, err := p.callAPI(ctx, followUpReq)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if len(followUpResp.ToolCalls) > 0 {
|
||||
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
|
||||
pendingCalls = followUpResp.ToolCalls
|
||||
messages = append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": cleanContent(followUpRaw),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
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) (*ai.Response, any, error) {
|
||||
// Marshal request
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Build HTTP request
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/messages"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("x-api-key", p.opts.APIKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
// Make request
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
// Read response
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var anthropicResp struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
} `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &anthropicResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
response := &ai.Response{}
|
||||
|
||||
// Extract text reply
|
||||
var replyParts []string
|
||||
for _, block := range anthropicResp.Content {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
replyParts = append(replyParts, block.Text)
|
||||
}
|
||||
}
|
||||
if len(replyParts) > 0 {
|
||||
response.Reply = strings.Join(replyParts, "\n")
|
||||
}
|
||||
|
||||
// Extract tool calls
|
||||
for _, block := range anthropicResp.Content {
|
||||
if block.Type == "tool_use" {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal(block.Input, &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: block.ID,
|
||||
Name: block.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return response, anthropicResp.Content, nil
|
||||
}
|
||||
|
||||
// cleanContent strips fields from response content blocks that Anthropic
|
||||
// rejects when sent back as assistant message content (e.g. "id" on text blocks).
|
||||
func cleanContent(raw any) any {
|
||||
blocks, ok := raw.([]struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
})
|
||||
if !ok {
|
||||
return raw
|
||||
}
|
||||
var cleaned []map[string]any
|
||||
for _, b := range blocks {
|
||||
switch b.Type {
|
||||
case "text":
|
||||
cleaned = append(cleaned, map[string]any{"type": "text", "text": b.Text})
|
||||
case "tool_use":
|
||||
var input any
|
||||
json.Unmarshal(b.Input, &input)
|
||||
cleaned = append(cleaned, map[string]any{"type": "tool_use", "id": b.ID, "name": b.Name, "input": input})
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if p.String() != "anthropic" {
|
||||
t.Errorf("Expected provider name 'anthropic', got '%s'", p.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
err := p.Init(
|
||||
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)
|
||||
}
|
||||
if opts.APIKey != "test-key" {
|
||||
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
if opts.BaseURL != "https://test.com" {
|
||||
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Options(t *testing.T) {
|
||||
p := NewProvider(
|
||||
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)
|
||||
}
|
||||
if opts.APIKey != "my-key" {
|
||||
t.Errorf("Expected API key 'my-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if opts.BaseURL != "https://api.anthropic.com" {
|
||||
t.Errorf("Expected default base URL 'https://api.anthropic.com', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
// Package atlascloud implements the Atlas Cloud model provider.
|
||||
//
|
||||
// Atlas Cloud is an enterprise AI infrastructure platform offering
|
||||
// high-performance LLM, image, and video APIs. It exposes
|
||||
// OpenAI-compatible endpoints for chat completions and image
|
||||
// generation.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import _ "go-micro.dev/v5/ai/atlascloud"
|
||||
//
|
||||
// m := ai.New("atlascloud",
|
||||
// ai.WithAPIKey("your-api-key"),
|
||||
// )
|
||||
//
|
||||
// // Image generation
|
||||
// ig := ai.NewImage("atlascloud",
|
||||
// ai.WithAPIKey("your-api-key"),
|
||||
// )
|
||||
package atlascloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("atlascloud", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterImage("atlascloud", func(opts ...ai.Option) ai.ImageModel {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterVideo("atlascloud", func(opts ...ai.Option) ai.VideoModel {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for Atlas Cloud.
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
// NewProvider creates a new Atlas Cloud provider.
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
|
||||
if options.Model == "" {
|
||||
options.Model = "deepseek-ai/DeepSeek-V3-0324"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.atlascloud.ai"
|
||||
}
|
||||
|
||||
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 "atlascloud" }
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
var tools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
apiReq["tools"] = tools
|
||||
}
|
||||
|
||||
resp, rawMessage, err := p.callAPI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if p.opts.ToolHandler != nil {
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": rawMessage["content"],
|
||||
"tool_calls": rawMessage["tool_calls"],
|
||||
})
|
||||
|
||||
for _, tc := range resp.ToolCalls {
|
||||
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
|
||||
followUpMessages = append(followUpMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.ID,
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
|
||||
followUpResp, _, err := p.callAPI(ctx, followUpReq)
|
||||
if err == nil && followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
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 atlascloud provider")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, nil, fmt.Errorf("no response from API")
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
response := &ai.Response{
|
||||
Reply: choice.Message.Content,
|
||||
}
|
||||
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
rawMessage := map[string]any{
|
||||
"content": choice.Message.Content,
|
||||
"tool_calls": choice.Message.ToolCalls,
|
||||
}
|
||||
|
||||
return response, rawMessage, nil
|
||||
}
|
||||
|
||||
const defaultImageModel = "openai/gpt-image-2/text-to-image"
|
||||
|
||||
// GenerateImage creates an image using Atlas Cloud's async image API.
|
||||
// It submits the job and polls until completion or context cancellation.
|
||||
func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts ...ai.GenerateOption) (*ai.ImageResponse, error) {
|
||||
model := req.Model
|
||||
if model == "" {
|
||||
model = defaultImageModel
|
||||
}
|
||||
quality := req.Quality
|
||||
if quality == "" {
|
||||
quality = "medium"
|
||||
}
|
||||
outputFmt := req.OutputFormat
|
||||
if outputFmt == "" {
|
||||
outputFmt = "png"
|
||||
}
|
||||
size := req.Size
|
||||
if size == "" {
|
||||
size = "1024x1024"
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": model,
|
||||
"prompt": req.Prompt,
|
||||
"quality": quality,
|
||||
"output_format": outputFmt,
|
||||
"size": size,
|
||||
"enable_sync_mode": false,
|
||||
"enable_base64_output": false,
|
||||
"moderation": "low",
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/api/v1/model/generateImage"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
var submitResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"message"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &submitResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse submit response: %w", err)
|
||||
}
|
||||
if submitResp.Code != 200 {
|
||||
return nil, fmt.Errorf("API error: %s", submitResp.Msg)
|
||||
}
|
||||
|
||||
predictionID := submitResp.Data.ID
|
||||
pollURL := strings.TrimRight(p.opts.BaseURL, "/") + "/api/v1/model/prediction/" + predictionID
|
||||
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-ticker.C:
|
||||
result, err := p.pollPrediction(ctx, pollURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result != nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) pollPrediction(ctx context.Context, url string) (*ai.ImageResponse, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(httpResp.Body)
|
||||
|
||||
var pollResp struct {
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Outputs []string `json:"outputs"`
|
||||
Error string `json:"error"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &pollResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse poll response: %w", err)
|
||||
}
|
||||
|
||||
switch pollResp.Data.Status {
|
||||
case "completed":
|
||||
resp := &ai.ImageResponse{}
|
||||
for _, output := range pollResp.Data.Outputs {
|
||||
resp.Images = append(resp.Images, ai.Image{URL: output})
|
||||
}
|
||||
return resp, nil
|
||||
case "failed":
|
||||
return nil, fmt.Errorf("image generation failed: %s", pollResp.Data.Error)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
const defaultVideoModel = "google/gemini-omni-flash/image-to-video-developer"
|
||||
|
||||
// GenerateVideo creates a video using Atlas Cloud's async video API.
|
||||
// Supports text-to-video and image-to-video depending on whether
|
||||
// Images are provided in the request.
|
||||
func (p *Provider) GenerateVideo(ctx context.Context, req *ai.VideoRequest, opts ...ai.GenerateOption) (*ai.VideoResponse, error) {
|
||||
model := req.Model
|
||||
if model == "" {
|
||||
model = defaultVideoModel
|
||||
}
|
||||
duration := req.Duration
|
||||
if duration <= 0 {
|
||||
duration = 6
|
||||
}
|
||||
aspect := req.AspectRatio
|
||||
if aspect == "" {
|
||||
aspect = "16:9"
|
||||
}
|
||||
resolution := req.Resolution
|
||||
if resolution == "" {
|
||||
resolution = "720p"
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": model,
|
||||
"prompt": req.Prompt,
|
||||
"duration": duration,
|
||||
"aspect_ratio": aspect,
|
||||
"resolution": resolution,
|
||||
"seed": -1,
|
||||
}
|
||||
if len(req.Images) > 0 {
|
||||
apiReq["images"] = req.Images
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/api/v1/model/generateVideo"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
var submitResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"message"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &submitResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse submit response: %w", err)
|
||||
}
|
||||
if submitResp.Code != 200 {
|
||||
return nil, fmt.Errorf("API error: %s", submitResp.Msg)
|
||||
}
|
||||
|
||||
pollURL := strings.TrimRight(p.opts.BaseURL, "/") + "/api/v1/model/prediction/" + submitResp.Data.ID
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-ticker.C:
|
||||
result, err := p.pollVideo(ctx, pollURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result != nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) pollVideo(ctx context.Context, url string) (*ai.VideoResponse, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(httpResp.Body)
|
||||
|
||||
var pollResp struct {
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Outputs []string `json:"outputs"`
|
||||
Error string `json:"error"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &pollResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse poll response: %w", err)
|
||||
}
|
||||
|
||||
switch pollResp.Data.Status {
|
||||
case "completed", "succeeded":
|
||||
if len(pollResp.Data.Outputs) == 0 {
|
||||
return nil, fmt.Errorf("video completed but no outputs returned")
|
||||
}
|
||||
return &ai.VideoResponse{URL: pollResp.Data.Outputs[0]}, nil
|
||||
case "failed":
|
||||
return nil, fmt.Errorf("video generation failed: %s", pollResp.Data.Error)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package atlascloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if p.String() != "atlascloud" {
|
||||
t.Errorf("Expected provider name 'atlascloud', got '%s'", p.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
err := p.Init(
|
||||
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)
|
||||
}
|
||||
if opts.APIKey != "test-key" {
|
||||
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
if opts.BaseURL != "https://test.com" {
|
||||
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Options(t *testing.T) {
|
||||
p := NewProvider(
|
||||
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)
|
||||
}
|
||||
if opts.APIKey != "my-key" {
|
||||
t.Errorf("Expected API key 'my-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "deepseek-ai/DeepSeek-V3-0324" {
|
||||
t.Errorf("Expected default model 'deepseek-ai/DeepSeek-V3-0324', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "https://api.atlascloud.ai" {
|
||||
t.Errorf("Expected default base URL 'https://api.atlascloud.ai', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Registration(t *testing.T) {
|
||||
m := ai.New("atlascloud", ai.WithAPIKey("test"))
|
||||
if m == nil {
|
||||
t.Fatal("ai.New('atlascloud') returned nil — provider not registered")
|
||||
}
|
||||
if m.String() != "atlascloud" {
|
||||
t.Errorf("Expected 'atlascloud', got '%s'", m.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ImageRegistration(t *testing.T) {
|
||||
ig := ai.NewImage("atlascloud", ai.WithAPIKey("test"))
|
||||
if ig == nil {
|
||||
t.Fatal("ai.NewImage('atlascloud') returned nil — image provider not registered")
|
||||
}
|
||||
if ig.String() != "atlascloud" {
|
||||
t.Errorf("Expected 'atlascloud', got '%s'", ig.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateImage_NoAPIKey(t *testing.T) {
|
||||
p := NewProvider()
|
||||
_, err := p.GenerateImage(context.Background(), &ai.ImageRequest{Prompt: "a cat"})
|
||||
if err == nil {
|
||||
t.Error("Expected error when API key is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ImplementsImageModel(t *testing.T) {
|
||||
var _ ai.ImageModel = (*Provider)(nil)
|
||||
}
|
||||
|
||||
func TestProvider_VideoRegistration(t *testing.T) {
|
||||
vg := ai.NewVideo("atlascloud", ai.WithAPIKey("test"))
|
||||
if vg == nil {
|
||||
t.Fatal("ai.NewVideo('atlascloud') returned nil — video provider not registered")
|
||||
}
|
||||
if vg.String() != "atlascloud" {
|
||||
t.Errorf("Expected 'atlascloud', got '%s'", vg.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateVideo_NoAPIKey(t *testing.T) {
|
||||
p := NewProvider()
|
||||
_, err := p.GenerateVideo(context.Background(), &ai.VideoRequest{Prompt: "a cat"})
|
||||
if err == nil {
|
||||
t.Error("Expected error when API key is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ImplementsVideoModel(t *testing.T) {
|
||||
var _ ai.VideoModel = (*Provider)(nil)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Package flow is maintained for backward compatibility.
|
||||
// The canonical import is go-micro.dev/v5/flow.
|
||||
package flow
|
||||
|
||||
import "go-micro.dev/v5/flow"
|
||||
|
||||
// Re-export types for backward compatibility.
|
||||
type Flow = flow.Flow
|
||||
type Options = flow.Options
|
||||
type Option = flow.Option
|
||||
type Result = flow.Result
|
||||
|
||||
var New = flow.New
|
||||
var Trigger = flow.Trigger
|
||||
var Prompt = flow.Prompt
|
||||
var SystemPrompt = flow.SystemPrompt
|
||||
var Provider = flow.Provider
|
||||
var APIKey = flow.APIKey
|
||||
var Model = flow.Model
|
||||
var BaseURL = flow.BaseURL
|
||||
var HistoryLimit = flow.HistoryLimit
|
||||
var OnResult = flow.OnResult
|
||||
@@ -0,0 +1,226 @@
|
||||
// Package gemini implements the Google Gemini model provider.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import _ "go-micro.dev/v5/ai/gemini"
|
||||
//
|
||||
// m := ai.New("gemini",
|
||||
// ai.WithAPIKey("your-api-key"),
|
||||
// )
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("gemini", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for Google Gemini.
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
// NewProvider creates a new Gemini provider.
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
|
||||
if options.Model == "" {
|
||||
options.Model = "gemini-2.5-flash"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://generativelanguage.googleapis.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 "gemini" }
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
var tools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
tools = append(tools, map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
contents := []map[string]any{
|
||||
{"role": "user", "parts": []map[string]any{{"text": req.Prompt}}},
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"contents": contents,
|
||||
}
|
||||
|
||||
if req.SystemPrompt != "" {
|
||||
apiReq["system_instruction"] = map[string]any{
|
||||
"parts": []map[string]any{{"text": req.SystemPrompt}},
|
||||
}
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
apiReq["tools"] = []map[string]any{
|
||||
{"functionDeclarations": tools},
|
||||
}
|
||||
}
|
||||
|
||||
resp, rawParts, err := p.callAPI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if p.opts.ToolHandler != nil {
|
||||
var resultParts []map[string]any
|
||||
for _, tc := range resp.ToolCalls {
|
||||
result, _ := p.opts.ToolHandler(tc.Name, tc.Input)
|
||||
resultParts = append(resultParts, map[string]any{
|
||||
"functionResponse": map[string]any{
|
||||
"name": tc.Name,
|
||||
"id": tc.ID,
|
||||
"response": result,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
followUpContents := append(contents,
|
||||
map[string]any{"role": "model", "parts": rawParts},
|
||||
map[string]any{"role": "user", "parts": resultParts},
|
||||
)
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"contents": followUpContents,
|
||||
}
|
||||
if req.SystemPrompt != "" {
|
||||
followUpReq["system_instruction"] = map[string]any{
|
||||
"parts": []map[string]any{{"text": req.SystemPrompt}},
|
||||
}
|
||||
}
|
||||
|
||||
followUpResp, _, err := p.callAPI(ctx, followUpReq)
|
||||
if err == nil && followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
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 gemini provider")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, []map[string]any, error) {
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") +
|
||||
"/v1beta/models/" + p.opts.Model + ":generateContent"
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("x-goog-api-key", p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
var geminiResp struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
FunctionCall *functionCallPB `json:"functionCall"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &geminiResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(geminiResp.Candidates) == 0 {
|
||||
return nil, nil, fmt.Errorf("no response from API")
|
||||
}
|
||||
|
||||
parts := geminiResp.Candidates[0].Content.Parts
|
||||
response := &ai.Response{}
|
||||
|
||||
var replyParts []string
|
||||
var rawParts []map[string]any
|
||||
|
||||
for _, part := range parts {
|
||||
if part.Text != "" {
|
||||
replyParts = append(replyParts, part.Text)
|
||||
rawParts = append(rawParts, map[string]any{"text": part.Text})
|
||||
}
|
||||
if part.FunctionCall != nil {
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: part.FunctionCall.ID,
|
||||
Name: part.FunctionCall.Name,
|
||||
Input: part.FunctionCall.Args,
|
||||
})
|
||||
rawParts = append(rawParts, map[string]any{
|
||||
"functionCall": map[string]any{
|
||||
"id": part.FunctionCall.ID,
|
||||
"name": part.FunctionCall.Name,
|
||||
"args": part.FunctionCall.Args,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(replyParts) > 0 {
|
||||
response.Reply = strings.Join(replyParts, "\n")
|
||||
}
|
||||
|
||||
return response, rawParts, nil
|
||||
}
|
||||
|
||||
type functionCallPB struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"args"`
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if p.String() != "gemini" {
|
||||
t.Errorf("Expected provider name 'gemini', got '%s'", p.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
err := p.Init(
|
||||
ai.WithModel("gemini-2.0-flash"),
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL("https://test.com"),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "gemini-2.0-flash" {
|
||||
t.Errorf("Expected model 'gemini-2.0-flash', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.APIKey != "test-key" {
|
||||
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
if opts.BaseURL != "https://test.com" {
|
||||
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Options(t *testing.T) {
|
||||
p := NewProvider(
|
||||
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)
|
||||
}
|
||||
if opts.APIKey != "my-key" {
|
||||
t.Errorf("Expected API key 'my-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "gemini-2.5-flash" {
|
||||
t.Errorf("Expected default model 'gemini-2.5-flash', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "https://generativelanguage.googleapis.com" {
|
||||
t.Errorf("Expected default base URL 'https://generativelanguage.googleapis.com', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Registration(t *testing.T) {
|
||||
m := ai.New("gemini", ai.WithAPIKey("test"))
|
||||
if m == nil {
|
||||
t.Fatal("ai.New('gemini') returned nil — provider not registered")
|
||||
}
|
||||
if m.String() != "gemini" {
|
||||
t.Errorf("Expected 'gemini', got '%s'", m.String())
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
// Package groq implements the Groq model provider.
|
||||
//
|
||||
// Groq provides ultra-fast inference for open-weight models via an
|
||||
// OpenAI-compatible chat completions endpoint.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import _ "go-micro.dev/v5/ai/groq"
|
||||
//
|
||||
// m := ai.New("groq",
|
||||
// ai.WithAPIKey("your-api-key"),
|
||||
// )
|
||||
package groq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("groq", 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...)
|
||||
if options.Model == "" {
|
||||
options.Model = "llama-3.3-70b-versatile"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.groq.com/openai"
|
||||
}
|
||||
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 "groq" }
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
var tools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
apiReq["tools"] = tools
|
||||
}
|
||||
|
||||
resp, rawMessage, err := p.callAPI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if p.opts.ToolHandler != nil {
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": rawMessage["content"],
|
||||
"tool_calls": rawMessage["tool_calls"],
|
||||
})
|
||||
for _, tc := range resp.ToolCalls {
|
||||
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
|
||||
followUpMessages = append(followUpMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.ID,
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
followUpResp, _, err := p.callAPI(ctx, map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
})
|
||||
if err == nil && followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
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 groq provider")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, nil, fmt.Errorf("no response from API")
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
response := &ai.Response{Reply: choice.Message.Content}
|
||||
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
rawMessage := map[string]any{
|
||||
"content": choice.Message.Content,
|
||||
"tool_calls": choice.Message.ToolCalls,
|
||||
}
|
||||
|
||||
return response, rawMessage, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package groq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
if NewProvider().String() != "groq" {
|
||||
t.Errorf("got %q", NewProvider().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
opts := NewProvider().Options()
|
||||
if opts.Model != "llama-3.3-70b-versatile" {
|
||||
t.Errorf("default model = %q", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "https://api.groq.com/openai" {
|
||||
t.Errorf("default base URL = %q", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if err := p.Init(ai.WithModel("m"), ai.WithAPIKey("k")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Options().Model != "m" || p.Options().APIKey != "k" {
|
||||
t.Error("Init did not apply options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
if _, err := NewProvider().Generate(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error without API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Registration(t *testing.T) {
|
||||
m := ai.New("groq", ai.WithAPIKey("test"))
|
||||
if m == nil {
|
||||
t.Fatal("provider not registered")
|
||||
}
|
||||
if m.String() != "groq" {
|
||||
t.Errorf("got %q", m.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ai
|
||||
|
||||
// History is a convenience for accumulating conversation messages
|
||||
// with automatic truncation. Use it to build Request.Messages for
|
||||
// multi-turn conversations.
|
||||
//
|
||||
// hist := ai.NewHistory(50)
|
||||
// hist.Add("user", "hello")
|
||||
// resp, _ := m.Generate(ctx, &ai.Request{Messages: hist.Messages(), Prompt: "next"})
|
||||
// hist.Add("assistant", resp.Reply)
|
||||
type History struct {
|
||||
messages []Message
|
||||
limit int
|
||||
}
|
||||
|
||||
// NewHistory creates an empty History. limit controls the maximum
|
||||
// number of messages retained (0 = unlimited).
|
||||
func NewHistory(limit int) *History {
|
||||
return &History{limit: limit}
|
||||
}
|
||||
|
||||
// Add appends a message and truncates if over limit.
|
||||
func (h *History) Add(role string, content any) {
|
||||
h.messages = append(h.messages, Message{Role: role, Content: content})
|
||||
if h.limit > 0 && len(h.messages) > h.limit {
|
||||
h.messages = h.messages[len(h.messages)-h.limit:]
|
||||
}
|
||||
}
|
||||
|
||||
// Messages returns a copy of the accumulated messages.
|
||||
func (h *History) Messages() []Message {
|
||||
out := make([]Message, len(h.messages))
|
||||
copy(out, h.messages)
|
||||
return out
|
||||
}
|
||||
|
||||
// Len returns the number of messages.
|
||||
func (h *History) Len() int {
|
||||
return len(h.messages)
|
||||
}
|
||||
|
||||
// Reset clears all messages.
|
||||
func (h *History) Reset() {
|
||||
h.messages = nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ai
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHistory_Add(t *testing.T) {
|
||||
h := NewHistory(0)
|
||||
h.Add("user", "hello")
|
||||
h.Add("assistant", "hi")
|
||||
|
||||
if h.Len() != 2 {
|
||||
t.Errorf("len = %d, want 2", h.Len())
|
||||
}
|
||||
msgs := h.Messages()
|
||||
if msgs[0].Role != "user" || msgs[0].Content != "hello" {
|
||||
t.Errorf("first = %+v", msgs[0])
|
||||
}
|
||||
if msgs[1].Role != "assistant" || msgs[1].Content != "hi" {
|
||||
t.Errorf("second = %+v", msgs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistory_Truncation(t *testing.T) {
|
||||
h := NewHistory(3)
|
||||
for _, m := range []string{"a", "b", "c", "d", "e"} {
|
||||
h.Add("user", m)
|
||||
}
|
||||
if h.Len() != 3 {
|
||||
t.Errorf("len = %d, want 3", h.Len())
|
||||
}
|
||||
if h.Messages()[0].Content != "c" {
|
||||
t.Errorf("first retained = %+v", h.Messages()[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistory_Reset(t *testing.T) {
|
||||
h := NewHistory(0)
|
||||
h.Add("user", "hello")
|
||||
h.Reset()
|
||||
if h.Len() != 0 {
|
||||
t.Errorf("len after reset = %d", h.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistory_SnapshotIsCopy(t *testing.T) {
|
||||
h := NewHistory(0)
|
||||
h.Add("user", "hello")
|
||||
msgs := h.Messages()
|
||||
msgs[0].Content = "mutated"
|
||||
if h.Messages()[0].Content == "mutated" {
|
||||
t.Error("snapshot returned reference, not copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistory_Unlimited(t *testing.T) {
|
||||
h := NewHistory(0)
|
||||
for i := 0; i < 100; i++ {
|
||||
h.Add("user", "msg")
|
||||
}
|
||||
if h.Len() != 100 {
|
||||
t.Errorf("len = %d, want 100", h.Len())
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package ai
|
||||
|
||||
import "context"
|
||||
|
||||
// ImageModel provides an interface for image generation providers.
|
||||
// Providers that support image generation implement this alongside
|
||||
// or instead of Model. Use NewImage to construct, or type-assert
|
||||
// a provider that implements both:
|
||||
//
|
||||
// p := atlascloud.NewProvider(ai.WithAPIKey(key))
|
||||
// if ig, ok := p.(ai.ImageModel); ok {
|
||||
// resp, _ := ig.GenerateImage(ctx, req)
|
||||
// }
|
||||
type ImageModel interface {
|
||||
GenerateImage(ctx context.Context, req *ImageRequest, opts ...GenerateOption) (*ImageResponse, error)
|
||||
String() string
|
||||
}
|
||||
|
||||
// ImageRequest describes what image to generate.
|
||||
type ImageRequest struct {
|
||||
// Prompt is the text description of the image to generate.
|
||||
Prompt string
|
||||
// Model overrides the provider's default image model.
|
||||
Model string
|
||||
// Size of the generated image (e.g. "1024x1024"). Provider-specific.
|
||||
Size string
|
||||
// N is the number of images to generate. Defaults to 1.
|
||||
N int
|
||||
// Quality controls generation quality. Provider-specific (e.g. "low", "medium", "high").
|
||||
Quality string
|
||||
// OutputFormat sets the image format (e.g. "png", "jpeg"). Provider-specific.
|
||||
OutputFormat string
|
||||
}
|
||||
|
||||
// ImageResponse holds the generated images.
|
||||
type ImageResponse struct {
|
||||
Images []Image
|
||||
}
|
||||
|
||||
// Image is a single generated image, returned as a URL, base64 data, or both
|
||||
// depending on the provider and request options.
|
||||
type Image struct {
|
||||
// URL is a remote URL where the image can be fetched.
|
||||
URL string
|
||||
// Base64 is the base64-encoded image data.
|
||||
Base64 string
|
||||
}
|
||||
|
||||
// NewImageFunc creates a new ImageModel instance.
|
||||
type NewImageFunc func(...Option) ImageModel
|
||||
|
||||
var imageProviders = make(map[string]NewImageFunc)
|
||||
|
||||
// RegisterImage registers an image generation provider.
|
||||
func RegisterImage(name string, fn NewImageFunc) {
|
||||
imageProviders[name] = fn
|
||||
}
|
||||
|
||||
// NewImage creates a new ImageModel instance based on the provider name.
|
||||
func NewImage(provider string, opts ...Option) ImageModel {
|
||||
if fn, ok := imageProviders[provider]; ok {
|
||||
return fn(opts...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Package mistral implements the Mistral AI model provider.
|
||||
//
|
||||
// Mistral AI is a European AI company offering high-performance models
|
||||
// via an OpenAI-compatible chat completions endpoint.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import _ "go-micro.dev/v5/ai/mistral"
|
||||
//
|
||||
// m := ai.New("mistral",
|
||||
// ai.WithAPIKey("your-api-key"),
|
||||
// )
|
||||
package mistral
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("mistral", 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...)
|
||||
if options.Model == "" {
|
||||
options.Model = "mistral-large-latest"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.mistral.ai"
|
||||
}
|
||||
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 "mistral" }
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
var tools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
apiReq["tools"] = tools
|
||||
}
|
||||
|
||||
resp, rawMessage, err := p.callAPI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if p.opts.ToolHandler != nil {
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": rawMessage["content"],
|
||||
"tool_calls": rawMessage["tool_calls"],
|
||||
})
|
||||
for _, tc := range resp.ToolCalls {
|
||||
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
|
||||
followUpMessages = append(followUpMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.ID,
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
followUpResp, _, err := p.callAPI(ctx, map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
})
|
||||
if err == nil && followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
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 mistral provider")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, nil, fmt.Errorf("no response from API")
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
response := &ai.Response{Reply: choice.Message.Content}
|
||||
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
rawMessage := map[string]any{
|
||||
"content": choice.Message.Content,
|
||||
"tool_calls": choice.Message.ToolCalls,
|
||||
}
|
||||
|
||||
return response, rawMessage, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package mistral
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
if NewProvider().String() != "mistral" {
|
||||
t.Errorf("got %q", NewProvider().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
opts := NewProvider().Options()
|
||||
if opts.Model != "mistral-large-latest" {
|
||||
t.Errorf("default model = %q", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "https://api.mistral.ai" {
|
||||
t.Errorf("default base URL = %q", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if err := p.Init(ai.WithModel("m"), ai.WithAPIKey("k")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Options().Model != "m" || p.Options().APIKey != "k" {
|
||||
t.Error("Init did not apply options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
if _, err := NewProvider().Generate(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error without API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Registration(t *testing.T) {
|
||||
m := ai.New("mistral", ai.WithAPIKey("test"))
|
||||
if m == nil {
|
||||
t.Fatal("provider not registered")
|
||||
}
|
||||
if m.String() != "mistral" {
|
||||
t.Errorf("got %q", m.String())
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
// 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).
|
||||
// Use ai.History to accumulate these across turns.
|
||||
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 and its result
|
||||
type ToolCall struct {
|
||||
ID string // Tool call ID (for correlation)
|
||||
Name string // Tool name
|
||||
Input map[string]any // Tool input arguments
|
||||
Result string // Tool execution result (populated after execution)
|
||||
Error string // Tool execution error (populated after execution)
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(baseURL, "anthropic"):
|
||||
return "anthropic"
|
||||
case strings.Contains(baseURL, "atlascloud"):
|
||||
return "atlascloud"
|
||||
case strings.Contains(baseURL, "googleapis.com"), strings.Contains(baseURL, "google"):
|
||||
return "gemini"
|
||||
case strings.Contains(baseURL, "groq"):
|
||||
return "groq"
|
||||
case strings.Contains(baseURL, "mistral"):
|
||||
return "mistral"
|
||||
case strings.Contains(baseURL, "together"):
|
||||
return "together"
|
||||
default:
|
||||
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...)
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// Package openai implements the OpenAI model provider
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("openai", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterImage("openai", func(opts ...ai.Option) ai.ImageModel {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for OpenAI
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
// NewProvider creates a new OpenAI provider
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
|
||||
// Set defaults if not provided
|
||||
if options.Model == "" {
|
||||
options.Model = "gpt-4o"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.openai.com"
|
||||
}
|
||||
|
||||
return &Provider{
|
||||
opts: options,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the provider with options
|
||||
func (p *Provider) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&p.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Options returns the provider options
|
||||
func (p *Provider) Options() ai.Options {
|
||||
return p.opts
|
||||
}
|
||||
|
||||
// String returns the provider name
|
||||
func (p *Provider) String() string {
|
||||
return "openai"
|
||||
}
|
||||
|
||||
// Generate generates a response from the model
|
||||
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 {
|
||||
openaiTools = append(openaiTools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Build messages
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
|
||||
// Build initial request
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
if len(openaiTools) > 0 {
|
||||
apiReq["tools"] = openaiTools
|
||||
}
|
||||
|
||||
// Make API call
|
||||
resp, rawMessage, err := p.callAPI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no tool calls, return response
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// If tool handler is provided, execute tools and get final answer
|
||||
if p.opts.ToolHandler != nil {
|
||||
// Build follow-up messages
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": rawMessage["content"],
|
||||
"tool_calls": rawMessage["tool_calls"],
|
||||
})
|
||||
|
||||
for _, tc := range resp.ToolCalls {
|
||||
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
|
||||
followUpMessages = append(followUpMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.ID,
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
|
||||
// Make follow-up API call
|
||||
followUpResp, _, err := p.callAPI(ctx, followUpReq)
|
||||
if err == nil && followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
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) (*ai.Response, map[string]any, error) {
|
||||
// Marshal request
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Build HTTP request
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
// Make request
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
// Read response
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, nil, fmt.Errorf("no response from API")
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
response := &ai.Response{
|
||||
Reply: choice.Message.Content,
|
||||
}
|
||||
|
||||
// Extract tool calls
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
// Return raw message for potential follow-up
|
||||
rawMessage := map[string]any{
|
||||
"content": choice.Message.Content,
|
||||
"tool_calls": choice.Message.ToolCalls,
|
||||
}
|
||||
|
||||
return response, rawMessage, nil
|
||||
}
|
||||
|
||||
const defaultImageModel = "gpt-image-1"
|
||||
|
||||
func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts ...ai.GenerateOption) (*ai.ImageResponse, error) {
|
||||
model := req.Model
|
||||
if model == "" {
|
||||
model = defaultImageModel
|
||||
}
|
||||
n := req.N
|
||||
if n <= 0 {
|
||||
n = 1
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": model,
|
||||
"prompt": req.Prompt,
|
||||
"n": n,
|
||||
}
|
||||
if req.Size != "" {
|
||||
apiReq["size"] = req.Size
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/images/generations"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
var imgResp struct {
|
||||
Data []struct {
|
||||
URL string `json:"url"`
|
||||
B64JSON string `json:"b64_json"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &imgResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
response := &ai.ImageResponse{}
|
||||
for _, d := range imgResp.Data {
|
||||
response.Images = append(response.Images, ai.Image{
|
||||
URL: d.URL,
|
||||
Base64: d.B64JSON,
|
||||
})
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if p.String() != "openai" {
|
||||
t.Errorf("Expected provider name 'openai', got '%s'", p.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
err := p.Init(
|
||||
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)
|
||||
}
|
||||
if opts.APIKey != "test-key" {
|
||||
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
if opts.BaseURL != "https://test.com" {
|
||||
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Options(t *testing.T) {
|
||||
p := NewProvider(
|
||||
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)
|
||||
}
|
||||
if opts.APIKey != "my-key" {
|
||||
t.Errorf("Expected API key 'my-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if opts.BaseURL != "https://api.openai.com" {
|
||||
t.Errorf("Expected default base URL 'https://api.openai.com', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ImageRegistration(t *testing.T) {
|
||||
ig := ai.NewImage("openai", ai.WithAPIKey("test"))
|
||||
if ig == nil {
|
||||
t.Fatal("ai.NewImage('openai') returned nil — image provider not registered")
|
||||
}
|
||||
if ig.String() != "openai" {
|
||||
t.Errorf("Expected 'openai', got '%s'", ig.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateImage_NoAPIKey(t *testing.T) {
|
||||
p := NewProvider()
|
||||
_, err := p.GenerateImage(context.Background(), &ai.ImageRequest{Prompt: "a cat"})
|
||||
if err == nil {
|
||||
t.Error("Expected error when API key is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ImplementsImageModel(t *testing.T) {
|
||||
var _ ai.ImageModel = (*Provider)(nil)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// WithTools wires a Tools instance into the model, setting the tool
|
||||
// handler so the model can execute discovered service endpoints. The
|
||||
// tool list itself is passed per-request via Request.Tools.
|
||||
//
|
||||
// tools := ai.NewTools(service.Registry())
|
||||
// list, _ := tools.Discover()
|
||||
// m := ai.New("anthropic", ai.WithAPIKey(key), ai.WithTools(tools))
|
||||
// resp, _ := m.Generate(ctx, &ai.Request{Prompt: input, Tools: list})
|
||||
func WithTools(t *Tools) Option {
|
||||
return func(o *Options) {
|
||||
if t != nil {
|
||||
o.ToolHandler = t.Handler()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Package together implements the Together AI model provider.
|
||||
//
|
||||
// Together AI provides fast inference for open-weight models via an
|
||||
// OpenAI-compatible chat completions endpoint.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import _ "go-micro.dev/v5/ai/together"
|
||||
//
|
||||
// m := ai.New("together",
|
||||
// ai.WithAPIKey("your-api-key"),
|
||||
// )
|
||||
package together
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("together", 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...)
|
||||
if options.Model == "" {
|
||||
options.Model = "meta-llama/Llama-3.3-70B-Instruct-Turbo"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.together.xyz"
|
||||
}
|
||||
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 "together" }
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
var tools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
apiReq["tools"] = tools
|
||||
}
|
||||
|
||||
resp, rawMessage, err := p.callAPI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if p.opts.ToolHandler != nil {
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": rawMessage["content"],
|
||||
"tool_calls": rawMessage["tool_calls"],
|
||||
})
|
||||
for _, tc := range resp.ToolCalls {
|
||||
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
|
||||
followUpMessages = append(followUpMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.ID,
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
followUpResp, _, err := p.callAPI(ctx, map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
})
|
||||
if err == nil && followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
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 together provider")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, nil, fmt.Errorf("no response from API")
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
response := &ai.Response{Reply: choice.Message.Content}
|
||||
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
rawMessage := map[string]any{
|
||||
"content": choice.Message.Content,
|
||||
"tool_calls": choice.Message.ToolCalls,
|
||||
}
|
||||
|
||||
return response, rawMessage, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package together
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
if NewProvider().String() != "together" {
|
||||
t.Errorf("got %q", NewProvider().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
opts := NewProvider().Options()
|
||||
if opts.Model != "meta-llama/Llama-3.3-70B-Instruct-Turbo" {
|
||||
t.Errorf("default model = %q", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "https://api.together.xyz" {
|
||||
t.Errorf("default base URL = %q", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if err := p.Init(ai.WithModel("m"), ai.WithAPIKey("k")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Options().Model != "m" || p.Options().APIKey != "k" {
|
||||
t.Error("Init did not apply options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
if _, err := NewProvider().Generate(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error without API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Registration(t *testing.T) {
|
||||
m := ai.New("together", ai.WithAPIKey("test"))
|
||||
if m == nil {
|
||||
t.Fatal("provider not registered")
|
||||
}
|
||||
if m.String() != "together" {
|
||||
t.Errorf("got %q", m.String())
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go-micro.dev/v5/client"
|
||||
codecBytes "go-micro.dev/v5/codec/bytes"
|
||||
"go-micro.dev/v5/registry"
|
||||
)
|
||||
|
||||
type toolNameMap struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]string
|
||||
}
|
||||
|
||||
func (n *toolNameMap) put(safe, original string) {
|
||||
n.mu.Lock()
|
||||
n.m[safe] = original
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
func (n *toolNameMap) get(safe string) (string, bool) {
|
||||
n.mu.RLock()
|
||||
v, ok := n.m[safe]
|
||||
n.mu.RUnlock()
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Tools discovers go-micro services from a registry and converts their
|
||||
// endpoints into Tool definitions. It also executes tool calls via RPC.
|
||||
//
|
||||
// Create with NewTools, discover the tool list with Discover, and wire
|
||||
// execution into a model with WithTools:
|
||||
//
|
||||
// tools := ai.NewTools(service.Registry())
|
||||
// list, _ := tools.Discover()
|
||||
// m := ai.New("anthropic", ai.WithAPIKey(key), ai.WithTools(tools))
|
||||
// resp, _ := m.Generate(ctx, &ai.Request{Prompt: input, Tools: list})
|
||||
type Tools struct {
|
||||
registry registry.Registry
|
||||
client client.Client
|
||||
names *toolNameMap
|
||||
}
|
||||
|
||||
// ToolOption configures a Tools instance.
|
||||
type ToolOption func(*Tools)
|
||||
|
||||
// ToolClient sets the client used to execute tool calls. Defaults to
|
||||
// client.DefaultClient.
|
||||
func ToolClient(c client.Client) ToolOption {
|
||||
return func(t *Tools) {
|
||||
if c != nil {
|
||||
t.client = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewTools creates a Tools bound to the given registry.
|
||||
func NewTools(reg registry.Registry, opts ...ToolOption) *Tools {
|
||||
t := &Tools{
|
||||
registry: reg,
|
||||
client: client.DefaultClient,
|
||||
names: &toolNameMap{m: map[string]string{}},
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(t)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Discover walks the registry and returns one Tool per service
|
||||
// endpoint. Tool names are LLM-safe (dots replaced with underscores).
|
||||
func (t *Tools) Discover() ([]Tool, error) {
|
||||
services, err := t.registry.ListServices()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Tool
|
||||
for _, svc := range services {
|
||||
full, err := t.registry.GetService(svc.Name)
|
||||
if err != nil || len(full) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, ep := range full[0].Endpoints {
|
||||
original := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
|
||||
safe := strings.ReplaceAll(original, ".", "_")
|
||||
t.names.put(safe, original)
|
||||
|
||||
desc := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name)
|
||||
if ep.Metadata != nil {
|
||||
if d, ok := ep.Metadata["description"]; ok && d != "" {
|
||||
desc = d
|
||||
}
|
||||
}
|
||||
|
||||
props := map[string]any{}
|
||||
if ep.Request != nil {
|
||||
for _, field := range ep.Request.Values {
|
||||
props[field.Name] = map[string]any{
|
||||
"type": toolJSONType(field.Type),
|
||||
"description": fmt.Sprintf("%s (%s)", field.Name, field.Type),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, Tool{
|
||||
Name: safe,
|
||||
OriginalName: original,
|
||||
Description: desc,
|
||||
Properties: props,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Handler returns a ToolHandler that executes tool calls via RPC using
|
||||
// the configured client. Tool names may be LLM-safe (underscored) or
|
||||
// original (dotted). WithTools uses this internally.
|
||||
func (t *Tools) Handler() ToolHandler {
|
||||
c := t.client
|
||||
if c == nil {
|
||||
c = client.DefaultClient
|
||||
}
|
||||
return func(name string, input map[string]any) (any, string) {
|
||||
if orig, ok := t.names.get(name); ok {
|
||||
name = orig
|
||||
}
|
||||
parts := strings.SplitN(name, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return toolErrResult("invalid tool name: " + name)
|
||||
}
|
||||
|
||||
inputBytes, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return toolErrResult("failed to marshal input: " + err.Error())
|
||||
}
|
||||
|
||||
req := c.NewRequest(parts[0], parts[1], &codecBytes.Frame{Data: inputBytes})
|
||||
var rsp codecBytes.Frame
|
||||
if err := c.Call(context.Background(), req, &rsp); err != nil {
|
||||
return toolErrResult(err.Error())
|
||||
}
|
||||
|
||||
var result any
|
||||
if err := json.Unmarshal(rsp.Data, &result); err != nil {
|
||||
result = string(rsp.Data)
|
||||
}
|
||||
return result, string(rsp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoverTools is a convenience that discovers tools from a registry
|
||||
// without creating a Tools instance. For paired discovery + execution,
|
||||
// create a Tools with NewTools instead.
|
||||
func DiscoverTools(reg registry.Registry) ([]Tool, error) {
|
||||
return NewTools(reg).Discover()
|
||||
}
|
||||
|
||||
func toolErrResult(msg string) (any, string) {
|
||||
encoded, _ := json.Marshal(map[string]string{"error": msg})
|
||||
return map[string]string{"error": msg}, string(encoded)
|
||||
}
|
||||
|
||||
func toolJSONType(goType string) string {
|
||||
switch goType {
|
||||
case "string":
|
||||
return "string"
|
||||
case "int", "int32", "int64", "uint", "uint32", "uint64":
|
||||
return "integer"
|
||||
case "float32", "float64":
|
||||
return "number"
|
||||
case "bool":
|
||||
return "boolean"
|
||||
default:
|
||||
return "object"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/registry"
|
||||
)
|
||||
|
||||
func TestToolJSONType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"string": "string",
|
||||
"int": "integer",
|
||||
"int64": "integer",
|
||||
"float64": "number",
|
||||
"bool": "boolean",
|
||||
"User": "object",
|
||||
"": "object",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := toolJSONType(in); got != want {
|
||||
t.Errorf("toolJSONType(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverTools_Empty(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
tools, err := DiscoverTools(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverTools: %v", err)
|
||||
}
|
||||
if len(tools) != 0 {
|
||||
t.Errorf("expected 0 tools, got %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverTools_DiscoversEndpoints(t *testing.T) {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
svc := ®istry.Service{
|
||||
Name: "users",
|
||||
Version: "1.0.0",
|
||||
Nodes: []*registry.Node{
|
||||
{Id: "users-1", Address: "127.0.0.1:9000"},
|
||||
},
|
||||
Endpoints: []*registry.Endpoint{
|
||||
{
|
||||
Name: "Users.Get",
|
||||
Metadata: map[string]string{
|
||||
"description": "Fetch a user by ID",
|
||||
},
|
||||
Request: ®istry.Value{
|
||||
Name: "GetRequest",
|
||||
Type: "GetRequest",
|
||||
Values: []*registry.Value{
|
||||
{Name: "id", Type: "string"},
|
||||
{Name: "expand", Type: "bool"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := reg.Register(svc); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
|
||||
tools, err := DiscoverTools(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverTools: %v", err)
|
||||
}
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(tools))
|
||||
}
|
||||
|
||||
tool := tools[0]
|
||||
if tool.Name != "users_Users_Get" {
|
||||
t.Errorf("safe name = %q", tool.Name)
|
||||
}
|
||||
if tool.OriginalName != "users.Users.Get" {
|
||||
t.Errorf("original = %q", tool.OriginalName)
|
||||
}
|
||||
if tool.Description != "Fetch a user by ID" {
|
||||
t.Errorf("description = %q", tool.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTools_HandlerResolvesSafeName(t *testing.T) {
|
||||
tools := NewTools(registry.NewMemoryRegistry())
|
||||
tools.names.put("users_Users_Get", "users.Users.Get")
|
||||
|
||||
resolved, ok := tools.names.get("users_Users_Get")
|
||||
if !ok || resolved != "users.Users.Get" {
|
||||
t.Errorf("name map lookup = (%q, %v)", resolved, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTools_HandlerInvalidName(t *testing.T) {
|
||||
tools := NewTools(registry.NewMemoryRegistry())
|
||||
h := tools.Handler()
|
||||
|
||||
result, content := h("foo", map[string]any{})
|
||||
if result == nil {
|
||||
t.Fatal("expected error result")
|
||||
}
|
||||
if content == "" {
|
||||
t.Error("expected non-empty content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTools(t *testing.T) {
|
||||
tools := NewTools(registry.NewMemoryRegistry())
|
||||
opts := NewOptions(WithTools(tools))
|
||||
if opts.ToolHandler == nil {
|
||||
t.Error("WithTools did not set a ToolHandler")
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package ai
|
||||
|
||||
import "context"
|
||||
|
||||
// VideoModel provides an interface for video generation providers.
|
||||
// Providers that support video generation implement this alongside
|
||||
// Model and/or ImageModel.
|
||||
type VideoModel interface {
|
||||
GenerateVideo(ctx context.Context, req *VideoRequest, opts ...GenerateOption) (*VideoResponse, error)
|
||||
String() string
|
||||
}
|
||||
|
||||
// VideoRequest describes what video to generate.
|
||||
type VideoRequest struct {
|
||||
// Prompt is the text description or instructions for the video.
|
||||
Prompt string
|
||||
// Model overrides the provider's default video model.
|
||||
Model string
|
||||
// Images are reference image URLs for image-to-video generation.
|
||||
Images []string
|
||||
// Duration in seconds. Provider-specific defaults apply.
|
||||
Duration int
|
||||
// AspectRatio (e.g. "16:9", "9:16"). Provider-specific.
|
||||
AspectRatio string
|
||||
// Resolution (e.g. "720p", "1080p"). Provider-specific.
|
||||
Resolution string
|
||||
}
|
||||
|
||||
// VideoResponse holds the generated video.
|
||||
type VideoResponse struct {
|
||||
// URL is the remote URL where the video can be fetched.
|
||||
URL string
|
||||
}
|
||||
|
||||
// NewVideoFunc creates a new VideoModel instance.
|
||||
type NewVideoFunc func(...Option) VideoModel
|
||||
|
||||
var videoProviders = make(map[string]NewVideoFunc)
|
||||
|
||||
// RegisterVideo registers a video generation provider.
|
||||
func RegisterVideo(name string, fn NewVideoFunc) {
|
||||
videoProviders[name] = fn
|
||||
}
|
||||
|
||||
// NewVideo creates a new VideoModel instance based on the provider name.
|
||||
func NewVideo(provider string, opts ...Option) VideoModel {
|
||||
if fn, ok := videoProviders[provider]; ok {
|
||||
return fn(opts...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+3
-3
@@ -20,9 +20,9 @@ import (
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/registry/cache"
|
||||
"go-micro.dev/v5/transport/headers"
|
||||
maddr "go-micro.dev/v5/util/addr"
|
||||
mnet "go-micro.dev/v5/util/net"
|
||||
mls "go-micro.dev/v5/util/tls"
|
||||
maddr "go-micro.dev/v5/internal/util/addr"
|
||||
mnet "go-micro.dev/v5/internal/util/net"
|
||||
mls "go-micro.dev/v5/internal/util/tls"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
log "go-micro.dev/v5/logger"
|
||||
maddr "go-micro.dev/v5/util/addr"
|
||||
mnet "go-micro.dev/v5/util/net"
|
||||
maddr "go-micro.dev/v5/internal/util/addr"
|
||||
mnet "go-micro.dev/v5/internal/util/net"
|
||||
)
|
||||
|
||||
type memoryBroker struct {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"go-micro.dev/v5/logger"
|
||||
mtls "go-micro.dev/v5/util/tls"
|
||||
mtls "go-micro.dev/v5/internal/util/tls"
|
||||
)
|
||||
|
||||
type MQExchangeType string
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v5/util/backoff"
|
||||
"go-micro.dev/v5/internal/util/backoff"
|
||||
)
|
||||
|
||||
type BackoffFunc func(ctx context.Context, req Request, attempts int) (time.Duration, error)
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ import (
|
||||
"go-micro.dev/v5/metadata"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/selector"
|
||||
pnet "go-micro.dev/v5/util/net"
|
||||
pnet "go-micro.dev/v5/internal/util/net"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/encoding"
|
||||
|
||||
@@ -401,6 +401,12 @@ func WithMessageContentType(ct string) MessageOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithConnectionTimeout(d time.Duration) CallOption {
|
||||
return func(o *CallOptions) {
|
||||
o.ConnectionTimeout = d
|
||||
}
|
||||
}
|
||||
|
||||
// Request Options
|
||||
|
||||
func WithContentType(ct string) RequestOption {
|
||||
|
||||
@@ -20,9 +20,9 @@ import (
|
||||
"go-micro.dev/v5/selector"
|
||||
"go-micro.dev/v5/transport"
|
||||
"go-micro.dev/v5/transport/headers"
|
||||
"go-micro.dev/v5/util/buf"
|
||||
"go-micro.dev/v5/util/net"
|
||||
"go-micro.dev/v5/util/pool"
|
||||
"go-micro.dev/v5/internal/util/buf"
|
||||
"go-micro.dev/v5/internal/util/net"
|
||||
"go-micro.dev/v5/internal/util/pool"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
+31
-31
@@ -23,7 +23,7 @@ import (
|
||||
"go-micro.dev/v5/debug/trace"
|
||||
"go-micro.dev/v5/events"
|
||||
"go-micro.dev/v5/logger"
|
||||
mprofile "go-micro.dev/v5/profile"
|
||||
mprofile "go-micro.dev/v5/service/profile"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/registry/consul"
|
||||
"go-micro.dev/v5/registry/etcd"
|
||||
@@ -299,20 +299,37 @@ func init() {
|
||||
}
|
||||
|
||||
func newCmd(opts ...Option) Cmd {
|
||||
// Create local copies so each cmd instance is isolated.
|
||||
// This allows multiple services in a single binary without
|
||||
// conflicting through shared global pointers.
|
||||
localAuth := auth.DefaultAuth
|
||||
localBroker := broker.DefaultBroker
|
||||
localClient := client.DefaultClient
|
||||
localRegistry := registry.DefaultRegistry
|
||||
localServer := server.DefaultServer
|
||||
localSelector := selector.DefaultSelector
|
||||
localTransport := transport.DefaultTransport
|
||||
localStore := store.DefaultStore
|
||||
localTracer := trace.DefaultTracer
|
||||
localProfile := profile.DefaultProfile
|
||||
localConfig := config.DefaultConfig
|
||||
localCache := cache.DefaultCache
|
||||
localStream := events.DefaultStream
|
||||
|
||||
options := Options{
|
||||
Auth: &auth.DefaultAuth,
|
||||
Broker: &broker.DefaultBroker,
|
||||
Client: &client.DefaultClient,
|
||||
Registry: ®istry.DefaultRegistry,
|
||||
Server: &server.DefaultServer,
|
||||
Selector: &selector.DefaultSelector,
|
||||
Transport: &transport.DefaultTransport,
|
||||
Store: &store.DefaultStore,
|
||||
Tracer: &trace.DefaultTracer,
|
||||
DebugProfile: &profile.DefaultProfile,
|
||||
Config: &config.DefaultConfig,
|
||||
Cache: &cache.DefaultCache,
|
||||
Stream: &events.DefaultStream,
|
||||
Auth: &localAuth,
|
||||
Broker: &localBroker,
|
||||
Client: &localClient,
|
||||
Registry: &localRegistry,
|
||||
Server: &localServer,
|
||||
Selector: &localSelector,
|
||||
Transport: &localTransport,
|
||||
Store: &localStore,
|
||||
Tracer: &localTracer,
|
||||
DebugProfile: &localProfile,
|
||||
Config: &localConfig,
|
||||
Cache: &localCache,
|
||||
Stream: &localStream,
|
||||
|
||||
Brokers: DefaultBrokers,
|
||||
Clients: DefaultClients,
|
||||
@@ -381,13 +398,9 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
return fmt.Errorf("failed to load local profile: %v", ierr)
|
||||
}
|
||||
*c.opts.Registry = imported.Registry
|
||||
registry.DefaultRegistry = imported.Registry
|
||||
*c.opts.Broker = imported.Broker
|
||||
broker.DefaultBroker = imported.Broker
|
||||
*c.opts.Store = imported.Store
|
||||
store.DefaultStore = imported.Store
|
||||
*c.opts.Transport = imported.Transport
|
||||
transport.DefaultTransport = imported.Transport
|
||||
case "nats":
|
||||
imported, ierr := mprofile.NatsProfile()
|
||||
if ierr != nil {
|
||||
@@ -428,7 +441,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
// only change if we have the client and type differs
|
||||
if cl, ok := c.opts.Clients[name]; ok && (*c.opts.Client).String() != name {
|
||||
*c.opts.Client = cl()
|
||||
client.DefaultClient = *c.opts.Client
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +449,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
// only change if we have the server and type differs
|
||||
if s, ok := c.opts.Servers[name]; ok && (*c.opts.Server).String() != name {
|
||||
*c.opts.Server = s()
|
||||
server.DefaultServer = *c.opts.Server
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +460,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
*c.opts.Store = s(store.WithClient(*c.opts.Client))
|
||||
store.DefaultStore = *c.opts.Store
|
||||
}
|
||||
|
||||
// Set the tracer
|
||||
@@ -460,7 +470,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
*c.opts.Tracer = r()
|
||||
trace.DefaultTracer = *c.opts.Tracer
|
||||
}
|
||||
|
||||
// Setup auth
|
||||
@@ -487,7 +496,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
*c.opts.Auth = r(authOpts...)
|
||||
auth.DefaultAuth = *c.opts.Auth
|
||||
}
|
||||
|
||||
// Set the registry
|
||||
@@ -509,7 +517,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
return fmt.Errorf("unsupported profile: %s", name)
|
||||
}
|
||||
*c.opts.DebugProfile = p()
|
||||
profile.DefaultProfile = *c.opts.DebugProfile
|
||||
}
|
||||
|
||||
// Set the broker
|
||||
@@ -534,7 +541,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
|
||||
// No server option here. Should there be?
|
||||
clientOpts = append(clientOpts, client.Selector(*c.opts.Selector))
|
||||
selector.DefaultSelector = *c.opts.Selector
|
||||
}
|
||||
|
||||
// Set the transport
|
||||
@@ -687,7 +693,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
logger.Fatalf("Error configuring config: %v", err)
|
||||
}
|
||||
*c.opts.Config = rc
|
||||
config.DefaultConfig = *c.opts.Config
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -709,7 +714,6 @@ func (c *cmd) setRegistry(r registry.Registry) ([]server.Option, []client.Option
|
||||
if err := (*c.opts.Broker).Init(broker.Registry(*c.opts.Registry)); err != nil {
|
||||
logger.Fatalf("Error configuring broker: %v", err)
|
||||
}
|
||||
registry.DefaultRegistry = *c.opts.Registry
|
||||
return serverOpts, clientOpts
|
||||
}
|
||||
func (c *cmd) setStream(s events.Stream) ([]server.Option, []client.Option) {
|
||||
@@ -720,7 +724,6 @@ func (c *cmd) setStream(s events.Stream) ([]server.Option, []client.Option) {
|
||||
// serverOpts = append(serverOpts, server.Registry(*c.opts.Registry))
|
||||
// clientOpts = append(clientOpts, client.Registry(*c.opts.Registry))
|
||||
|
||||
events.DefaultStream = *c.opts.Stream
|
||||
return serverOpts, clientOpts
|
||||
}
|
||||
|
||||
@@ -730,7 +733,6 @@ func (c *cmd) setBroker(b broker.Broker) ([]server.Option, []client.Option) {
|
||||
*c.opts.Broker = b
|
||||
serverOpts = append(serverOpts, server.Broker(*c.opts.Broker))
|
||||
clientOpts = append(clientOpts, client.Broker(*c.opts.Broker))
|
||||
broker.DefaultBroker = *c.opts.Broker
|
||||
return serverOpts, clientOpts
|
||||
}
|
||||
|
||||
@@ -738,7 +740,6 @@ func (c *cmd) setStore(s store.Store) ([]server.Option, []client.Option) {
|
||||
var serverOpts []server.Option
|
||||
var clientOpts []client.Option
|
||||
*c.opts.Store = s
|
||||
store.DefaultStore = *c.opts.Store
|
||||
return serverOpts, clientOpts
|
||||
}
|
||||
|
||||
@@ -748,7 +749,6 @@ func (c *cmd) setTransport(t transport.Transport) ([]server.Option, []client.Opt
|
||||
*c.opts.Transport = t
|
||||
serverOpts = append(serverOpts, server.Transport(*c.opts.Transport))
|
||||
clientOpts = append(clientOpts, client.Transport(*c.opts.Transport))
|
||||
transport.DefaultTransport = *c.opts.Transport
|
||||
return serverOpts, clientOpts
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /micro-mcp-gateway ./cmd/micro-mcp-gateway
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=builder /micro-mcp-gateway /usr/local/bin/micro-mcp-gateway
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["micro-mcp-gateway"]
|
||||
CMD ["--address", ":3000"]
|
||||
@@ -0,0 +1,242 @@
|
||||
// Command micro-mcp-gateway runs a standalone MCP gateway that discovers
|
||||
// go-micro services via a registry and exposes them as AI-accessible tools
|
||||
// through the Model Context Protocol.
|
||||
//
|
||||
// This is the production deployment binary for the MCP gateway, intended
|
||||
// to run independently of your services.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// # mDNS (development default)
|
||||
// micro-mcp-gateway --address :3000
|
||||
//
|
||||
// # Consul
|
||||
// micro-mcp-gateway --address :3000 --registry consul --registry-address consul:8500
|
||||
//
|
||||
// # etcd
|
||||
// micro-mcp-gateway --address :3000 --registry etcd --registry-address etcd:2379
|
||||
//
|
||||
// # With auth and rate limiting
|
||||
// micro-mcp-gateway --address :3000 --registry consul \
|
||||
// --rate-limit 100 --rate-burst 200 --audit
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v5/auth"
|
||||
"go-micro.dev/v5/auth/jwt"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/registry/consul"
|
||||
"go-micro.dev/v5/registry/etcd"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "micro-mcp-gateway",
|
||||
Usage: "Standalone MCP gateway for go-micro services",
|
||||
Version: version,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "address",
|
||||
Usage: "Address to listen on",
|
||||
Value: ":3000",
|
||||
EnvVars: []string{"MCP_ADDRESS"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "registry",
|
||||
Usage: "Service registry (mdns, consul, etcd)",
|
||||
Value: "mdns",
|
||||
EnvVars: []string{"MICRO_REGISTRY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "registry-address",
|
||||
Usage: "Registry address (e.g., consul:8500, etcd:2379)",
|
||||
EnvVars: []string{"MICRO_REGISTRY_ADDRESS"},
|
||||
},
|
||||
&cli.Float64Flag{
|
||||
Name: "rate-limit",
|
||||
Usage: "Requests per second per tool (0 = unlimited)",
|
||||
EnvVars: []string{"MCP_RATE_LIMIT"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "rate-burst",
|
||||
Usage: "Rate limit burst size",
|
||||
Value: 20,
|
||||
EnvVars: []string{"MCP_RATE_BURST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "auth",
|
||||
Usage: "Enable JWT authentication",
|
||||
EnvVars: []string{"MCP_AUTH"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "audit",
|
||||
Usage: "Enable audit logging to stdout",
|
||||
EnvVars: []string{"MCP_AUDIT"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "scope",
|
||||
Usage: "Tool scope requirement (format: tool=scope1,scope2)",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "circuit-breaker",
|
||||
Usage: "Circuit breaker max failures before opening (0 = disabled)",
|
||||
EnvVars: []string{"MCP_CIRCUIT_BREAKER"},
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "circuit-breaker-timeout",
|
||||
Usage: "Circuit breaker open-state timeout before half-open probe",
|
||||
Value: 30 * time.Second,
|
||||
EnvVars: []string{"MCP_CIRCUIT_BREAKER_TIMEOUT"},
|
||||
},
|
||||
},
|
||||
Action: run,
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(c *cli.Context) error {
|
||||
logger := log.New(os.Stdout, "[mcp-gateway] ", log.LstdFlags)
|
||||
|
||||
// Configure registry
|
||||
reg, err := newRegistry(c.String("registry"), c.String("registry-address"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("registry: %w", err)
|
||||
}
|
||||
|
||||
// Build MCP options
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
opts := mcp.Options{
|
||||
Registry: reg,
|
||||
Address: c.String("address"),
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if rps := c.Float64("rate-limit"); rps > 0 {
|
||||
opts.RateLimit = &mcp.RateLimitConfig{
|
||||
RequestsPerSecond: rps,
|
||||
Burst: c.Int("rate-burst"),
|
||||
}
|
||||
logger.Printf("Rate limit: %.0f req/s, burst %d", rps, c.Int("rate-burst"))
|
||||
}
|
||||
|
||||
// Auth
|
||||
if c.Bool("auth") {
|
||||
opts.Auth = jwt.NewAuth()
|
||||
logger.Printf("JWT authentication enabled")
|
||||
}
|
||||
|
||||
// Scopes
|
||||
if scopes := c.StringSlice("scope"); len(scopes) > 0 {
|
||||
opts.Scopes = parseScopes(scopes)
|
||||
for tool, s := range opts.Scopes {
|
||||
logger.Printf("Scope: %s requires [%s]", tool, strings.Join(s, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// Circuit breaker
|
||||
if maxFail := c.Int("circuit-breaker"); maxFail > 0 {
|
||||
opts.CircuitBreaker = &mcp.CircuitBreakerConfig{
|
||||
MaxFailures: maxFail,
|
||||
Timeout: c.Duration("circuit-breaker-timeout"),
|
||||
}
|
||||
logger.Printf("Circuit breaker: max %d failures, timeout %s", maxFail, c.Duration("circuit-breaker-timeout"))
|
||||
}
|
||||
|
||||
// Audit
|
||||
if c.Bool("audit") {
|
||||
opts.AuditFunc = func(r mcp.AuditRecord) {
|
||||
status := "ALLOWED"
|
||||
if !r.Allowed {
|
||||
status = "DENIED:" + r.DeniedReason
|
||||
}
|
||||
logger.Printf("[audit] %s tool=%s account=%s status=%s duration=%s",
|
||||
r.TraceID, r.Tool, r.AccountID, status, r.Duration)
|
||||
}
|
||||
logger.Printf("Audit logging enabled")
|
||||
}
|
||||
|
||||
// Print startup info
|
||||
logger.Printf("Starting MCP gateway on %s", c.String("address"))
|
||||
logger.Printf("Registry: %s", c.String("registry"))
|
||||
if addr := c.String("registry-address"); addr != "" {
|
||||
logger.Printf("Registry address: %s", addr)
|
||||
}
|
||||
|
||||
// Start gateway in background
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- mcp.ListenAndServe(opts.Address, opts)
|
||||
}()
|
||||
|
||||
// Wait for signal or error
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
logger.Printf("Received %s, shutting down...", sig)
|
||||
cancel()
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
return fmt.Errorf("gateway error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newRegistry(name, address string) (registry.Registry, error) {
|
||||
var opts []registry.Option
|
||||
if address != "" {
|
||||
opts = append(opts, registry.Addrs(strings.Split(address, ",")...))
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "mdns", "":
|
||||
return registry.NewMDNSRegistry(opts...), nil
|
||||
case "consul":
|
||||
return consul.NewConsulRegistry(opts...), nil
|
||||
case "etcd":
|
||||
return etcd.NewEtcdRegistry(opts...), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown registry %q (supported: mdns, consul, etcd)", name)
|
||||
}
|
||||
}
|
||||
|
||||
func parseScopes(raw []string) map[string][]string {
|
||||
scopes := make(map[string][]string)
|
||||
for _, s := range raw {
|
||||
parts := strings.SplitN(s, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
tool := strings.TrimSpace(parts[0])
|
||||
scopeList := strings.Split(parts[1], ",")
|
||||
for i := range scopeList {
|
||||
scopeList[i] = strings.TrimSpace(scopeList[i])
|
||||
}
|
||||
scopes[tool] = scopeList
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
// Ensure auth.Auth interface is satisfied at compile time.
|
||||
var _ auth.Auth = jwt.NewAuth()
|
||||
+108
-3
@@ -19,6 +19,14 @@ Create your service (all setup is now automatic!):
|
||||
micro new helloworld
|
||||
```
|
||||
|
||||
Or use a template for common service patterns:
|
||||
|
||||
```
|
||||
micro new contacts --template crud # CRUD with Create/Read/Update/Delete/List
|
||||
micro new events --template pubsub # Pub/sub with broker integration
|
||||
micro new gateway --template api # API gateway with health check
|
||||
```
|
||||
|
||||
This will:
|
||||
- Create a new service in the `helloworld` directory
|
||||
- Automatically run `go mod tidy` and `make proto` for you
|
||||
@@ -38,7 +46,7 @@ This starts:
|
||||
- **Web Dashboard** at http://localhost:8080
|
||||
- **Agent Playground** at http://localhost:8080/agent
|
||||
- **API Explorer** at http://localhost:8080/api
|
||||
- **MCP Tools** at http://localhost:8080/api/mcp/tools
|
||||
- **MCP Tools** at http://localhost:8080/mcp/tools
|
||||
- **Hot Reload** watching for file changes
|
||||
- **Services** in dependency order
|
||||
|
||||
@@ -343,6 +351,103 @@ micro stop myservice --remote user@server
|
||||
|
||||
See [internal/website/docs/deployment.md](../../internal/website/docs/deployment.md) for the full deployment guide.
|
||||
|
||||
## API Gateway
|
||||
|
||||
Run a standalone HTTP-to-RPC gateway (no dashboard, no auth, no hot reload):
|
||||
|
||||
```bash
|
||||
micro api # listen on :8080
|
||||
micro api --address :3000 # custom port
|
||||
```
|
||||
|
||||
Routes:
|
||||
- `POST /{service}/{endpoint}` — proxies to an RPC call
|
||||
- `GET /` — lists all services and endpoints
|
||||
- `GET /{service}` — describes a service
|
||||
- `GET /health` — health check
|
||||
|
||||
```bash
|
||||
curl -XPOST -d '{"name":"Alice"}' http://localhost:8080/greeter/Greeter.Hello
|
||||
```
|
||||
|
||||
## Inspecting the Framework
|
||||
|
||||
Every core interface has a matching CLI command:
|
||||
|
||||
### Registry
|
||||
|
||||
```bash
|
||||
micro registry list # list all registered services (JSON)
|
||||
micro registry get <name> # show nodes and endpoints for a service
|
||||
micro registry watch # stream registration events
|
||||
```
|
||||
|
||||
### Broker
|
||||
|
||||
```bash
|
||||
micro broker publish <topic> <message> # publish a message
|
||||
micro broker subscribe <topic> # stream messages from a topic
|
||||
```
|
||||
|
||||
### Store
|
||||
|
||||
```bash
|
||||
micro store list [prefix] # list keys (optionally by prefix)
|
||||
micro store read <key> # read a record
|
||||
micro store write <key> <value> # write a record
|
||||
micro store delete <key> # delete a record
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
```bash
|
||||
micro config get <key> # read a config value (dot notation → env var)
|
||||
micro config dump # print all configuration
|
||||
```
|
||||
|
||||
Keys use dot notation: `database.host` reads from `DATABASE_HOST`.
|
||||
|
||||
## AI & Agents
|
||||
|
||||
### micro chat
|
||||
|
||||
Interactive LLM agent that discovers services and orchestrates them through natural language:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-ant-... micro chat --provider anthropic
|
||||
> list all users
|
||||
> send a welcome email to Alice
|
||||
```
|
||||
|
||||
Supports: `--provider` (anthropic, openai, gemini, atlascloud, groq, mistral, together), `--prompt` for single-shot mode, `--model` and `--base_url` for overrides.
|
||||
|
||||
Environment variables: `MICRO_AI_PROVIDER`, `MICRO_AI_API_KEY`, or provider-specific keys like `ANTHROPIC_API_KEY`.
|
||||
|
||||
### micro flow
|
||||
|
||||
Event-driven LLM orchestration:
|
||||
|
||||
```bash
|
||||
# Subscribe to events and react
|
||||
micro flow run --trigger events.user.created \
|
||||
--prompt "New user: {{.Data}}. Send welcome email." \
|
||||
--provider anthropic
|
||||
|
||||
# One-shot execution
|
||||
micro flow exec --prompt "List all users" --provider anthropic
|
||||
```
|
||||
|
||||
### micro mcp
|
||||
|
||||
Expose services as MCP tools for AI agents:
|
||||
|
||||
```bash
|
||||
micro mcp serve # stdio transport (for Claude Code)
|
||||
micro mcp serve --address :3000 # HTTP/SSE transport
|
||||
micro mcp list # list available tools
|
||||
micro mcp test <tool> # test a tool
|
||||
```
|
||||
|
||||
## Protobuf
|
||||
|
||||
Use protobuf for code generation with [protoc-gen-micro](https://github.com/micro/go-micro/tree/master/cmd/protoc-gen-micro)
|
||||
@@ -426,7 +531,7 @@ Both commands provide:
|
||||
- **Hot Service Updates**: Gateway automatically picks up new service registrations
|
||||
- **JWT Authentication**: Tokens, user management, login at `/auth/login`, `/auth/tokens`, `/auth/users`
|
||||
- **Endpoint Scopes**: Restrict which tokens can call which endpoints via `/auth/scopes`
|
||||
- **MCP Integration**: AI tools at `/api/mcp/tools`, agent playground at `/agent`
|
||||
- **MCP Integration**: AI tools at `/mcp/tools`, agent playground at `/agent`
|
||||
|
||||
### Authentication & Scopes
|
||||
|
||||
@@ -437,7 +542,7 @@ Both `micro run` and `micro server` use the same `auth.Account` type from the go
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `POST /api/{service}/{endpoint}` | HTTP API calls |
|
||||
| `POST /api/mcp/call` | MCP tool invocations |
|
||||
| `POST /mcp/call` | MCP tool invocations |
|
||||
| Agent playground | Tool calls made by the AI agent |
|
||||
|
||||
Scopes are configured via the web UI at `/auth/scopes`. Each endpoint can require one or more scopes. A token must carry at least one matching scope to call a protected endpoint. The `*` scope on a token bypasses all checks. Endpoints with no scopes set are open to any authenticated token.
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
// Package api implements the 'micro api' command — a lightweight
|
||||
// HTTP-to-RPC gateway that proxies JSON requests to go-micro services.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// micro api # listen on :8080
|
||||
// micro api --address :3000 # custom port
|
||||
//
|
||||
// Requests:
|
||||
//
|
||||
// POST /service/endpoint → RPC call to service.endpoint
|
||||
// GET /health → {"status":"ok"}
|
||||
//
|
||||
// The request body is forwarded as-is (JSON). The Micro-Endpoint
|
||||
// header can also be used to specify the endpoint.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/broker"
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/cmd"
|
||||
codecBytes "go-micro.dev/v5/codec/bytes"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/store"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "api",
|
||||
Usage: "Run a lightweight HTTP-to-RPC API gateway",
|
||||
Description: `Start an HTTP gateway that proxies JSON requests to go-micro services.
|
||||
|
||||
Requests are routed by URL path:
|
||||
POST /service/endpoint → calls service.endpoint via RPC
|
||||
GET / → lists available services and endpoints
|
||||
|
||||
Examples:
|
||||
# Start on default port
|
||||
micro api
|
||||
|
||||
# Custom port
|
||||
micro api --address :3000
|
||||
|
||||
# Call a service through the gateway
|
||||
curl -XPOST -d '{"name":"Alice"}' http://localhost:8080/greeter/Greeter.Hello
|
||||
|
||||
# Or use the Micro-Endpoint header
|
||||
curl -XPOST -H 'Micro-Endpoint: Greeter.Hello' \
|
||||
-d '{"name":"Alice"}' http://localhost:8080/greeter`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "address",
|
||||
Usage: "Address to listen on",
|
||||
Value: ":8080",
|
||||
EnvVars: []string{"MICRO_API_ADDRESS"},
|
||||
},
|
||||
},
|
||||
Action: run,
|
||||
})
|
||||
}
|
||||
|
||||
func run(c *cli.Context) error {
|
||||
addr := c.String("address")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// Framework primitives under /micro/
|
||||
registerFrameworkRoutes(mux)
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
|
||||
// Root: list services
|
||||
if path == "" {
|
||||
listServices(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse service/endpoint from path
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
serviceName := parts[0]
|
||||
endpoint := ""
|
||||
if len(parts) > 1 {
|
||||
endpoint = parts[1]
|
||||
}
|
||||
|
||||
// Allow Micro-Endpoint header to override
|
||||
if h := r.Header.Get("Micro-Endpoint"); h != "" {
|
||||
endpoint = h
|
||||
}
|
||||
|
||||
if endpoint == "" {
|
||||
describeService(w, serviceName)
|
||||
return
|
||||
}
|
||||
|
||||
// Proxy RPC call
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "failed to read body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(body) == 0 {
|
||||
body = []byte("{}")
|
||||
}
|
||||
|
||||
req := client.DefaultClient.NewRequest(serviceName, endpoint, &codecBytes.Frame{Data: body})
|
||||
var rsp codecBytes.Frame
|
||||
|
||||
if err := client.DefaultClient.Call(r.Context(), req, &rsp); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(rsp.Data)
|
||||
})
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mmicro api\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Listening \033[36m%s\033[0m\n", addr)
|
||||
fmt.Println()
|
||||
fmt.Println(" Routes:")
|
||||
fmt.Println(" \033[32mGET\033[0m / List services")
|
||||
fmt.Println(" \033[32mGET\033[0m /{service} Describe a service")
|
||||
fmt.Println(" \033[33mPOST\033[0m /{service}/{endpoint} Call an endpoint")
|
||||
fmt.Println(" \033[32mGET\033[0m /health Health check")
|
||||
fmt.Println()
|
||||
fmt.Println(" Framework:")
|
||||
fmt.Println(" \033[32mGET\033[0m /micro/registry List registered services")
|
||||
fmt.Println(" \033[32mGET\033[0m /micro/registry/{name} Describe a service")
|
||||
fmt.Println(" \033[32mGET\033[0m /micro/store List store keys")
|
||||
fmt.Println(" \033[32mGET\033[0m /micro/store/{key} Read a record")
|
||||
fmt.Println(" \033[33mPOST\033[0m /micro/store/{key} Write a record")
|
||||
fmt.Println(" \033[33mPOST\033[0m /micro/broker/{topic} Publish a message")
|
||||
fmt.Println()
|
||||
|
||||
server := &http.Server{Addr: addr, Handler: mux}
|
||||
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
|
||||
fmt.Println("\nShutting down...")
|
||||
return server.Close()
|
||||
}
|
||||
|
||||
func listServices(w http.ResponseWriter) {
|
||||
services, err := registry.ListServices()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(services, func(i, j int) bool {
|
||||
return services[i].Name < services[j].Name
|
||||
})
|
||||
|
||||
type svcInfo struct {
|
||||
Name string `json:"name"`
|
||||
Endpoints []string `json:"endpoints,omitempty"`
|
||||
}
|
||||
|
||||
var result []svcInfo
|
||||
for _, svc := range services {
|
||||
info := svcInfo{Name: svc.Name}
|
||||
full, err := registry.GetService(svc.Name)
|
||||
if err == nil && len(full) > 0 {
|
||||
for _, ep := range full[0].Endpoints {
|
||||
info.Endpoints = append(info.Endpoints, ep.Name)
|
||||
}
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func describeService(w http.ResponseWriter, name string) {
|
||||
services, err := registry.GetService(name)
|
||||
if err != nil || len(services) == 0 {
|
||||
writeError(w, http.StatusNotFound, "service not found: "+name)
|
||||
return
|
||||
}
|
||||
|
||||
type epInfo struct {
|
||||
Name string `json:"name"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
svc := services[0]
|
||||
var endpoints []epInfo
|
||||
for _, ep := range svc.Endpoints {
|
||||
endpoints = append(endpoints, epInfo{
|
||||
Name: ep.Name,
|
||||
Metadata: ep.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"name": svc.Name,
|
||||
"version": svc.Version,
|
||||
"endpoints": endpoints,
|
||||
"nodes": len(svc.Nodes),
|
||||
})
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// registerFrameworkRoutes adds /micro/* routes for registry, broker, and store.
|
||||
func registerFrameworkRoutes(mux *http.ServeMux) {
|
||||
// Registry
|
||||
mux.HandleFunc("/micro/registry", func(w http.ResponseWriter, r *http.Request) {
|
||||
listServices(w)
|
||||
})
|
||||
mux.HandleFunc("/micro/registry/", func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/micro/registry/")
|
||||
if name == "" {
|
||||
listServices(w)
|
||||
return
|
||||
}
|
||||
describeService(w, name)
|
||||
})
|
||||
|
||||
// Store
|
||||
mux.HandleFunc("/micro/store", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
keys, err := store.List()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(keys)
|
||||
})
|
||||
mux.HandleFunc("/micro/store/", func(w http.ResponseWriter, r *http.Request) {
|
||||
key := strings.TrimPrefix(r.URL.Path, "/micro/store/")
|
||||
if key == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
keys, _ := store.List()
|
||||
json.NewEncoder(w).Encode(keys)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
records, err := store.Read(key)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if len(records) == 0 {
|
||||
writeError(w, http.StatusNotFound, "key not found")
|
||||
return
|
||||
}
|
||||
w.Write(records[0].Value)
|
||||
case http.MethodPost:
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := store.Write(&store.Record{Key: key, Value: body}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "key": key})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "use GET or POST")
|
||||
}
|
||||
})
|
||||
|
||||
// Broker
|
||||
mux.HandleFunc("/micro/broker/", func(w http.ResponseWriter, r *http.Request) {
|
||||
topic := strings.TrimPrefix(r.URL.Path, "/micro/broker/")
|
||||
if topic == "" {
|
||||
writeError(w, http.StatusBadRequest, "topic required: /micro/broker/{topic}")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "use POST to publish")
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
b := broker.DefaultBroker
|
||||
if err := b.Connect(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "broker connect: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := b.Publish(topic, &broker.Message{Body: body}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "topic": topic})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
// Package chat implements the 'micro chat' interactive agent command.
|
||||
//
|
||||
// micro chat opens a terminal REPL where you can talk to your services
|
||||
// through an LLM. It discovers all services from the registry, exposes
|
||||
// each endpoint as a tool, and lets the model orchestrate calls in
|
||||
// response to natural-language prompts.
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/agent"
|
||||
"go-micro.dev/v5/ai"
|
||||
clt "go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/cmd"
|
||||
"go-micro.dev/v5/cmd/micro/cli/generate"
|
||||
"go-micro.dev/v5/codec/bytes"
|
||||
"go-micro.dev/v5/registry"
|
||||
|
||||
_ "go-micro.dev/v5/ai/anthropic"
|
||||
_ "go-micro.dev/v5/ai/atlascloud"
|
||||
_ "go-micro.dev/v5/ai/gemini"
|
||||
_ "go-micro.dev/v5/ai/groq"
|
||||
_ "go-micro.dev/v5/ai/mistral"
|
||||
_ "go-micro.dev/v5/ai/openai"
|
||||
_ "go-micro.dev/v5/ai/together"
|
||||
)
|
||||
|
||||
const systemPromptTmpl = `You are an agent that orchestrates microservices. Use the available tools to fulfill user requests. When you call a tool, explain what you are doing.
|
||||
|
||||
Available services: %s
|
||||
|
||||
If a user asks for something that no existing service can handle, use the micro_generate_service tool to create it. Pass a short description of what the service should do. After it's created, the new service's endpoints will be available as tools and you can use them immediately.
|
||||
|
||||
Do NOT make up capabilities. Only use the tools that are available. If generation fails, tell the user.`
|
||||
|
||||
var generateTool = ai.Tool{
|
||||
Name: "micro_generate_service",
|
||||
OriginalName: "micro.generate_service",
|
||||
Description: "Generate a new microservice from a description. Use when the user needs a capability that no existing service provides. The service will be created, compiled, and started automatically.",
|
||||
Properties: map[string]any{
|
||||
"description": map[string]any{
|
||||
"type": "string",
|
||||
"description": "What the service should do, e.g. 'a shipping service that tracks parcels and calculates rates'",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "chat",
|
||||
Usage: "Interactive AI chat that orchestrates your services",
|
||||
Description: `Start an interactive chat session that uses an LLM to call your services.
|
||||
|
||||
micro chat discovers every service in the registry, exposes each endpoint as a
|
||||
tool, and lets you ask natural-language questions like "list all users" or
|
||||
"create an order for product 42". The model decides which tool to call and
|
||||
issues RPCs to the right service.
|
||||
|
||||
If you ask for something no existing service handles, the agent will generate
|
||||
a new service automatically and start using it.
|
||||
|
||||
Examples:
|
||||
ANTHROPIC_API_KEY=sk-ant-... micro chat --provider anthropic
|
||||
micro chat --provider openai --prompt "list all users"`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "provider", Usage: "AI provider (anthropic, openai, gemini, groq, mistral, together, atlascloud)", EnvVars: []string{"MICRO_AI_PROVIDER"}},
|
||||
&cli.StringFlag{Name: "api_key", Usage: "API key for the provider", EnvVars: []string{"MICRO_AI_API_KEY"}},
|
||||
&cli.StringFlag{Name: "model", Usage: "Model name (uses provider default if unset)", EnvVars: []string{"MICRO_AI_MODEL"}},
|
||||
&cli.StringFlag{Name: "base_url", Usage: "Override the provider's base URL", EnvVars: []string{"MICRO_AI_BASE_URL"}},
|
||||
&cli.StringFlag{Name: "prompt", Usage: "Send a single prompt and exit (non-interactive)"},
|
||||
},
|
||||
Action: run,
|
||||
})
|
||||
}
|
||||
|
||||
// agentInfo holds metadata about a discovered agent.
|
||||
type agentInfo struct {
|
||||
Name string
|
||||
Services []string
|
||||
}
|
||||
|
||||
type session struct {
|
||||
provider string
|
||||
apiKey string
|
||||
model ai.Model
|
||||
tools *ai.Tools
|
||||
reg registry.Registry
|
||||
cl clt.Client
|
||||
hist *ai.History
|
||||
toolList []ai.Tool
|
||||
sysPrompt string
|
||||
procs []*exec.Cmd
|
||||
agents map[string]agentInfo
|
||||
|
||||
// Built-in agent capabilities (plan, delegate), shared with the
|
||||
// agent package so the direct-service fallback has the same tools a
|
||||
// real agent would.
|
||||
builtinTools []ai.Tool
|
||||
builtinHandle func(name string, input map[string]any) (any, string, bool)
|
||||
}
|
||||
|
||||
// discoverAgents finds agents registered in the registry.
|
||||
func (s *session) discoverAgents() bool {
|
||||
svcs, err := s.reg.ListServices()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
s.agents = make(map[string]agentInfo)
|
||||
|
||||
for _, svc := range svcs {
|
||||
records, err := s.reg.GetService(svc.Name)
|
||||
if err != nil || len(records) == 0 {
|
||||
continue
|
||||
}
|
||||
meta := records[0].Metadata
|
||||
if meta == nil || meta["type"] != "agent" {
|
||||
if len(records[0].Nodes) > 0 {
|
||||
meta = records[0].Nodes[0].Metadata
|
||||
}
|
||||
if meta == nil || meta["type"] != "agent" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var services []string
|
||||
if svcsStr := meta["services"]; svcsStr != "" {
|
||||
services = strings.Split(svcsStr, ",")
|
||||
}
|
||||
|
||||
s.agents[svc.Name] = agentInfo{Name: svc.Name, Services: services}
|
||||
}
|
||||
|
||||
return len(s.agents) > 0
|
||||
}
|
||||
|
||||
// callAgent calls an agent's Chat endpoint via RPC.
|
||||
func (s *session) callAgent(ctx context.Context, name, message string) (*agent.Response, error) {
|
||||
reqBody, _ := json.Marshal(map[string]string{"message": message})
|
||||
req := s.cl.NewRequest(name, "Agent.Chat", &bytes.Frame{Data: reqBody})
|
||||
var rsp bytes.Frame
|
||||
if err := s.cl.Call(ctx, req, &rsp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Reply string `json:"reply"`
|
||||
Agent string `json:"agent"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input string `json:"input"`
|
||||
Result string `json:"result"`
|
||||
} `json:"tool_calls"`
|
||||
}
|
||||
if err := json.Unmarshal(rsp.Data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &agent.Response{
|
||||
Reply: resp.Reply,
|
||||
Agent: resp.Agent,
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
var input map[string]any
|
||||
json.Unmarshal([]byte(tc.Input), &input)
|
||||
r.ToolCalls = append(r.ToolCalls, ai.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Name,
|
||||
Input: input,
|
||||
Result: tc.Result,
|
||||
})
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// buildRouterPrompt creates a system prompt for the router that
|
||||
// knows about all available agents and can dispatch to them.
|
||||
func (s *session) buildRouterPrompt() string {
|
||||
var agentDescs []string
|
||||
for name, info := range s.agents {
|
||||
svcs := strings.Join(info.Services, ", ")
|
||||
agentDescs = append(agentDescs, fmt.Sprintf("- %s (manages: %s)", name, svcs))
|
||||
}
|
||||
sort.Strings(agentDescs)
|
||||
|
||||
return fmt.Sprintf(`You are a router that dispatches user requests to the right agent.
|
||||
|
||||
Available agents:
|
||||
%s
|
||||
|
||||
For each user message, decide which agent should handle it and call the route_to_agent tool with the agent name and the message. If the request spans multiple agents, call route_to_agent multiple times.
|
||||
|
||||
If no agent can handle the request, say so.`, strings.Join(agentDescs, "\n"))
|
||||
}
|
||||
|
||||
func (s *session) refreshTools() {
|
||||
discovered, err := s.tools.Discover()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.toolList = append(discovered, generateTool)
|
||||
s.toolList = append(s.toolList, s.builtinTools...)
|
||||
|
||||
serviceNames := make(map[string]bool)
|
||||
for _, t := range discovered {
|
||||
parts := strings.SplitN(t.OriginalName, ".", 2)
|
||||
if len(parts) == 2 {
|
||||
serviceNames[parts[0]] = true
|
||||
}
|
||||
}
|
||||
var svcList []string
|
||||
for name := range serviceNames {
|
||||
svcList = append(svcList, name)
|
||||
}
|
||||
if len(svcList) == 0 {
|
||||
s.sysPrompt = fmt.Sprintf(systemPromptTmpl, "(none yet)")
|
||||
} else {
|
||||
s.sysPrompt = fmt.Sprintf(systemPromptTmpl, strings.Join(svcList, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) handleGenerate(input map[string]any) (any, string) {
|
||||
desc, _ := input["description"].(string)
|
||||
if desc == "" {
|
||||
return map[string]string{"error": "description is required"}, `{"error":"description is required"}`
|
||||
}
|
||||
|
||||
fmt.Printf("\n \033[36m⚡\033[0m generating service: %s\n", desc)
|
||||
|
||||
design, err := generate.Design(context.Background(), s.provider, s.apiKey, "", ".", desc)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf(`{"error":"design failed: %s"}`, err)
|
||||
return map[string]string{"error": err.Error()}, msg
|
||||
}
|
||||
|
||||
if err := generate.Generate(context.Background(), ".", design, s.provider, s.apiKey, ""); err != nil {
|
||||
msg := fmt.Sprintf(`{"error":"generate failed: %s"}`, err)
|
||||
return map[string]string{"error": err.Error()}, msg
|
||||
}
|
||||
|
||||
// Find which services are new (not already in registry)
|
||||
existing := make(map[string]bool)
|
||||
if svcs, err := s.reg.ListServices(); err == nil {
|
||||
for _, svc := range svcs {
|
||||
existing[svc.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
var created []string
|
||||
for _, svc := range design.Services {
|
||||
name := strings.TrimSuffix(svc.Name, "-service")
|
||||
if existing[name] {
|
||||
continue
|
||||
}
|
||||
created = append(created, svc.Name)
|
||||
|
||||
// Build and start the new service
|
||||
svcDir, _ := filepath.Abs(svc.Name)
|
||||
fmt.Printf(" \033[36m⚡\033[0m starting %s...\n", svc.Name)
|
||||
|
||||
buildCmd := exec.Command("go", "build", "-o", svc.Name, ".")
|
||||
buildCmd.Dir = svcDir
|
||||
if out, err := buildCmd.CombinedOutput(); err != nil {
|
||||
fmt.Printf(" \033[33m⚠\033[0m build failed: %s\n", string(out))
|
||||
continue
|
||||
}
|
||||
|
||||
runCmd := exec.Command(filepath.Join(svcDir, svc.Name))
|
||||
runCmd.Dir = svcDir
|
||||
if err := runCmd.Start(); err != nil {
|
||||
fmt.Printf(" \033[33m⚠\033[0m start failed: %v\n", err)
|
||||
continue
|
||||
}
|
||||
s.procs = append(s.procs, runCmd)
|
||||
}
|
||||
|
||||
if len(created) == 0 {
|
||||
result := map[string]any{"message": "No new services needed — all already exist."}
|
||||
b, _ := json.Marshal(result)
|
||||
return result, string(b)
|
||||
}
|
||||
|
||||
// Wait for services to register
|
||||
fmt.Printf(" \033[36m⚡\033[0m waiting for services to register...\n")
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
s.refreshTools()
|
||||
fmt.Printf(" \033[32m✓\033[0m %d tools available\n\n", len(s.toolList)-1)
|
||||
|
||||
result := map[string]any{
|
||||
"created": created,
|
||||
"message": fmt.Sprintf("Created and started: %s. Their endpoints are now available as tools.", strings.Join(created, ", ")),
|
||||
}
|
||||
b, _ := json.Marshal(result)
|
||||
return result, string(b)
|
||||
}
|
||||
|
||||
func run(c *cli.Context) error {
|
||||
provider := c.String("provider")
|
||||
apiKey := c.String("api_key")
|
||||
modelName := c.String("model")
|
||||
baseURL := c.String("base_url")
|
||||
singlePrompt := c.String("prompt")
|
||||
|
||||
if provider == "" {
|
||||
provider = ai.AutoDetectProvider(baseURL)
|
||||
}
|
||||
if apiKey == "" {
|
||||
apiKey = fallbackAPIKey(provider)
|
||||
}
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("no API key configured; set --api_key or %s", envVarForProvider(provider))
|
||||
}
|
||||
|
||||
reg := registry.DefaultRegistry
|
||||
cl := clt.DefaultClient
|
||||
|
||||
tools := ai.NewTools(reg, ai.ToolClient(cl))
|
||||
|
||||
// Built-in agent capabilities (plan, delegate), reused from the
|
||||
// agent package so the direct-service fallback matches a real agent.
|
||||
builtinTools, builtinHandle := agent.Builtins(
|
||||
agent.Name("chat"),
|
||||
agent.WithRegistry(reg),
|
||||
agent.WithClient(cl),
|
||||
agent.Provider(provider),
|
||||
agent.Model(modelName),
|
||||
agent.APIKey(apiKey),
|
||||
)
|
||||
|
||||
s := &session{
|
||||
provider: provider,
|
||||
apiKey: apiKey,
|
||||
tools: tools,
|
||||
reg: reg,
|
||||
cl: cl,
|
||||
hist: ai.NewHistory(50),
|
||||
builtinTools: builtinTools,
|
||||
builtinHandle: builtinHandle,
|
||||
}
|
||||
s.refreshTools()
|
||||
|
||||
// Wrap the tool handler to intercept generate calls
|
||||
baseHandler := tools.Handler()
|
||||
wrappedHandler := func(name string, input map[string]any) (any, string) {
|
||||
if name == "micro_generate_service" {
|
||||
return s.handleGenerate(input)
|
||||
}
|
||||
if result, content, ok := s.builtinHandle(name, input); ok {
|
||||
return result, content
|
||||
}
|
||||
return baseHandler(name, input)
|
||||
}
|
||||
|
||||
opts := []ai.Option{
|
||||
ai.WithAPIKey(apiKey),
|
||||
ai.WithToolHandler(wrappedHandler),
|
||||
}
|
||||
if modelName != "" {
|
||||
opts = append(opts, ai.WithModel(modelName))
|
||||
}
|
||||
if baseURL != "" {
|
||||
opts = append(opts, ai.WithBaseURL(baseURL))
|
||||
}
|
||||
|
||||
s.model = ai.New(provider, opts...)
|
||||
if s.model == nil {
|
||||
return fmt.Errorf("unknown provider: %s", provider)
|
||||
}
|
||||
|
||||
defer s.cleanup()
|
||||
|
||||
// Discover registered agents
|
||||
hasAgents := s.discoverAgents()
|
||||
|
||||
if singlePrompt != "" {
|
||||
return s.ask(c.Context, singlePrompt)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mmicro chat\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Provider \033[36m%s\033[0m\n", provider)
|
||||
fmt.Printf(" Model \033[36m%s\033[0m\n", s.model.Options().Model)
|
||||
fmt.Println()
|
||||
if hasAgents {
|
||||
fmt.Println(" Agents:")
|
||||
for name, info := range s.agents {
|
||||
fmt.Printf(" \033[35m◆\033[0m %s \033[2m(%s)\033[0m\n", name, strings.Join(info.Services, ", "))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Println(" Tools:")
|
||||
for _, t := range s.toolList {
|
||||
fmt.Printf(" \033[32m●\033[0m %s\n", t.OriginalName)
|
||||
}
|
||||
if len(s.toolList) == 0 && !hasAgents {
|
||||
fmt.Println(" \033[33m(no services found)\033[0m")
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(" Type a prompt and press enter. \033[2mCtrl-D or 'exit' to quit.\033[0m")
|
||||
fmt.Println()
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 4096), 1024*1024)
|
||||
for {
|
||||
fmt.Print("\033[1;36m>\033[0m ")
|
||||
if !scanner.Scan() {
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line == "exit" || line == "quit" {
|
||||
return nil
|
||||
}
|
||||
if line == "reset" {
|
||||
s.hist.Reset()
|
||||
fmt.Println("\033[2m(history cleared)\033[0m")
|
||||
fmt.Println()
|
||||
continue
|
||||
}
|
||||
if err := s.ask(c.Context, line); err != nil {
|
||||
fmt.Printf("\033[31merror:\033[0m %v\n", err)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) ask(ctx context.Context, prompt string) error {
|
||||
// If agents are registered, route to them
|
||||
if len(s.agents) > 0 {
|
||||
return s.routeToAgent(ctx, prompt)
|
||||
}
|
||||
|
||||
// Fallback: direct service access (no agents)
|
||||
s.hist.Add("user", prompt)
|
||||
|
||||
resp, err := s.model.Generate(ctx, &ai.Request{
|
||||
Prompt: prompt,
|
||||
SystemPrompt: s.sysPrompt,
|
||||
Tools: s.toolList,
|
||||
Messages: s.hist.Messages(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.Reply != "" {
|
||||
s.hist.Add("assistant", resp.Reply)
|
||||
}
|
||||
if resp.Answer != "" {
|
||||
s.hist.Add("assistant", resp.Answer)
|
||||
}
|
||||
|
||||
if resp.Reply != "" {
|
||||
fmt.Println(resp.Reply)
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
if tc.Name == "micro_generate_service" {
|
||||
continue
|
||||
}
|
||||
args, _ := json.Marshal(tc.Input)
|
||||
fmt.Printf(" \033[33m→\033[0m \033[2m%s\033[0m(%s)\n", tc.Name, args)
|
||||
if tc.Result != "" {
|
||||
fmt.Printf(" \033[32m←\033[0m \033[2m%s\033[0m\n", truncateResult(tc.Result))
|
||||
}
|
||||
if tc.Error != "" {
|
||||
fmt.Printf(" \033[31m✗\033[0m %s\n", tc.Error)
|
||||
}
|
||||
}
|
||||
if resp.Answer != "" {
|
||||
fmt.Println()
|
||||
fmt.Println(resp.Answer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// routeToAgent dispatches a message to the right agent.
|
||||
// If there's only one agent, sends directly. Otherwise uses the
|
||||
// LLM to classify intent and route.
|
||||
func (s *session) routeToAgent(ctx context.Context, prompt string) error {
|
||||
// Single agent — call directly via RPC
|
||||
if len(s.agents) == 1 {
|
||||
for name := range s.agents {
|
||||
fmt.Printf(" \033[35m◆\033[0m \033[2m%s\033[0m\n", name)
|
||||
resp, err := s.callAgent(ctx, name, prompt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.printAgentResponse(resp)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple agents — use LLM to route
|
||||
routeTool := ai.Tool{
|
||||
Name: "route_to_agent",
|
||||
OriginalName: "route_to_agent",
|
||||
Description: "Route a message to a specific agent for handling.",
|
||||
Properties: map[string]any{
|
||||
"agent": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The agent name to route to",
|
||||
},
|
||||
"message": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The message to send to the agent",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
routerHandler := func(name string, input map[string]any) (any, string) {
|
||||
agentName, _ := input["agent"].(string)
|
||||
message, _ := input["message"].(string)
|
||||
if message == "" {
|
||||
message = prompt
|
||||
}
|
||||
|
||||
if _, ok := s.agents[agentName]; !ok {
|
||||
return map[string]string{"error": "unknown agent: " + agentName}, `{"error":"unknown agent"}`
|
||||
}
|
||||
|
||||
fmt.Printf(" \033[35m◆\033[0m \033[2m%s\033[0m\n", agentName)
|
||||
resp, err := s.callAgent(ctx, agentName, message)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, `{"error":"` + err.Error() + `"}`
|
||||
}
|
||||
|
||||
s.printAgentResponse(resp)
|
||||
|
||||
result := map[string]any{"agent": agentName, "reply": resp.Reply}
|
||||
b, _ := json.Marshal(result)
|
||||
return result, string(b)
|
||||
}
|
||||
|
||||
routerModel := ai.New(s.provider,
|
||||
ai.WithAPIKey(s.apiKey),
|
||||
ai.WithToolHandler(routerHandler),
|
||||
)
|
||||
|
||||
resp, err := routerModel.Generate(ctx, &ai.Request{
|
||||
Prompt: prompt,
|
||||
SystemPrompt: s.buildRouterPrompt(),
|
||||
Tools: []ai.Tool{routeTool},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.Answer != "" {
|
||||
fmt.Println()
|
||||
fmt.Println(resp.Answer)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *session) printAgentResponse(resp *agent.Response) {
|
||||
for _, tc := range resp.ToolCalls {
|
||||
args, _ := json.Marshal(tc.Input)
|
||||
fmt.Printf(" \033[33m→\033[0m \033[2m%s\033[0m(%s)\n", tc.Name, args)
|
||||
if tc.Result != "" {
|
||||
fmt.Printf(" \033[32m←\033[0m \033[2m%s\033[0m\n", truncateResult(tc.Result))
|
||||
}
|
||||
}
|
||||
if resp.Reply != "" {
|
||||
fmt.Println()
|
||||
fmt.Println(resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) cleanup() {
|
||||
for _, p := range s.procs {
|
||||
if p.Process != nil {
|
||||
p.Process.Kill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackAPIKey(provider string) string {
|
||||
if v := os.Getenv(envVarForProvider(provider)); v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func envVarForProvider(provider string) string {
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
return "ANTHROPIC_API_KEY"
|
||||
case "openai":
|
||||
return "OPENAI_API_KEY"
|
||||
case "gemini":
|
||||
return "GEMINI_API_KEY"
|
||||
case "groq":
|
||||
return "GROQ_API_KEY"
|
||||
case "mistral":
|
||||
return "MISTRAL_API_KEY"
|
||||
case "together":
|
||||
return "TOGETHER_API_KEY"
|
||||
case "atlascloud":
|
||||
return "ATLASCLOUD_API_KEY"
|
||||
default:
|
||||
return "MICRO_AI_API_KEY"
|
||||
}
|
||||
}
|
||||
|
||||
func truncateResult(s string) string {
|
||||
if len(s) <= 200 {
|
||||
return s
|
||||
}
|
||||
return s[:200] + "..."
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Package agent registers the 'micro agent' CLI commands.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/cmd"
|
||||
"go-micro.dev/v5/registry"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "agent",
|
||||
Usage: "Manage AI agents",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List registered agents",
|
||||
Action: func(c *cli.Context) error {
|
||||
svcs, err := registry.ListServices()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, svc := range svcs {
|
||||
records, err := registry.GetService(svc.Name)
|
||||
if err != nil || len(records) == 0 {
|
||||
continue
|
||||
}
|
||||
meta := records[0].Metadata
|
||||
if meta == nil || meta["type"] != "agent" {
|
||||
if len(records[0].Nodes) > 0 {
|
||||
meta = records[0].Nodes[0].Metadata
|
||||
}
|
||||
if meta == nil || meta["type"] != "agent" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
found = true
|
||||
services := meta["services"]
|
||||
if services == "" {
|
||||
services = "(all)"
|
||||
}
|
||||
fmt.Printf(" \033[35m◆\033[0m %-20s manages: %s\n", svc.Name, services)
|
||||
}
|
||||
if !found {
|
||||
fmt.Println(" No agents registered.")
|
||||
fmt.Println()
|
||||
fmt.Println(" Start an agent with:")
|
||||
fmt.Println(" micro run (if agents are part of your project)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "describe",
|
||||
Usage: "Describe an agent",
|
||||
ArgsUsage: "[name]",
|
||||
Action: func(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
return fmt.Errorf("usage: micro agent describe [name]")
|
||||
}
|
||||
records, err := registry.GetService(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return fmt.Errorf("agent %s not found", name)
|
||||
}
|
||||
b, _ := json.MarshalIndent(records[0], "", " ")
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func Build(c *cli.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n✓ Built to %s\n", outDir)
|
||||
fmt.Printf("\n \033[32m✓\033[0m Built to \033[36m%s\033[0m\n", outDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func buildService(name, dir, outDir, targetOS, targetArch string) error {
|
||||
}
|
||||
outPath := filepath.Join(outDir, binName)
|
||||
|
||||
fmt.Printf("Building %s (%s/%s)...\n", name, targetOS, targetArch)
|
||||
fmt.Printf(" Building \033[36m%s (%s/%s)...\n", name, targetOS, targetArch)
|
||||
|
||||
// Build command
|
||||
buildCmd := exec.Command("go", "build", "-o", outPath, ".")
|
||||
@@ -100,7 +100,7 @@ func buildService(name, dir, outDir, targetOS, targetArch string) error {
|
||||
return fmt.Errorf("go build failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ %s\n", outPath)
|
||||
fmt.Printf(" \033[32m✓\033[0m %s\n", outPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ func buildDockerImage(name, dir string, port int, tag, registry string, push boo
|
||||
imageName = registry + "/" + imageName
|
||||
}
|
||||
|
||||
fmt.Printf("Building %s...\n", imageName)
|
||||
fmt.Printf(" Building \033[36m%s...\n", imageName)
|
||||
|
||||
buildCmd := exec.Command("docker", "build", "-t", imageName, dir)
|
||||
buildCmd.Stdout = os.Stdout
|
||||
@@ -188,7 +188,7 @@ func buildDockerImage(name, dir string, port int, tag, registry string, push boo
|
||||
return fmt.Errorf("docker build failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Built %s\n", imageName)
|
||||
fmt.Printf(" \033[32m✓\033[0m Built %s\n", imageName)
|
||||
|
||||
if push {
|
||||
fmt.Printf("Pushing %s...\n", imageName)
|
||||
@@ -198,7 +198,7 @@ func buildDockerImage(name, dir string, port int, tag, registry string, push boo
|
||||
if err := pushCmd.Run(); err != nil {
|
||||
return fmt.Errorf("docker push failed: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ Pushed %s\n", imageName)
|
||||
fmt.Printf(" \033[32m✓\033[0m Pushed %s\n", imageName)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -268,7 +268,7 @@ func Compose(c *cli.Context) error {
|
||||
return fmt.Errorf("failed to write docker-compose.yml: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Generated %s\n", output)
|
||||
fmt.Printf(" \033[32m✓\033[0m Generated %s\n", output)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+52
-3
@@ -17,6 +17,7 @@ import (
|
||||
"go-micro.dev/v5/cmd/micro/cli/util"
|
||||
|
||||
// Import packages that register commands via init()
|
||||
_ "go-micro.dev/v5/cmd/micro/cli/agent"
|
||||
_ "go-micro.dev/v5/cmd/micro/cli/build"
|
||||
_ "go-micro.dev/v5/cmd/micro/cli/deploy"
|
||||
_ "go-micro.dev/v5/cmd/micro/cli/init"
|
||||
@@ -39,9 +40,38 @@ func genProtoHandler(c *cli.Context) error {
|
||||
func init() {
|
||||
cmd.Register([]*cli.Command{
|
||||
{
|
||||
Name: "new",
|
||||
Usage: "Create a new service",
|
||||
Name: "new",
|
||||
Usage: "Create a new service",
|
||||
ArgsUsage: "[name]",
|
||||
UsageText: ` micro new helloworld # scaffold a single service
|
||||
micro new --prompt "a todo list with tasks" # AI-design multiple services
|
||||
micro new --prompt "add tags to the task service" # extend existing services`,
|
||||
Action: new.Run,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "no-mcp",
|
||||
Usage: "Disable MCP gateway integration in generated code",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "template",
|
||||
Usage: "Service template: default, crud, pubsub, api",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "prompt",
|
||||
Usage: "Describe the system to generate (uses AI to design & build services with real business logic)",
|
||||
EnvVars: []string{"MICRO_NEW_PROMPT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "provider",
|
||||
Usage: "AI provider for --prompt (anthropic, openai, gemini, atlascloud, groq, mistral, together)",
|
||||
EnvVars: []string{"MICRO_AI_PROVIDER"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "api_key",
|
||||
Usage: "API key for --prompt (or set ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)",
|
||||
EnvVars: []string{"MICRO_AI_API_KEY"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "gen",
|
||||
@@ -71,6 +101,18 @@ func init() {
|
||||
{
|
||||
Name: "call",
|
||||
Usage: "Call a service",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "header",
|
||||
Aliases: []string{"H"},
|
||||
Usage: "Set request headers (can be used multiple times): --header 'Key:Value'",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "metadata",
|
||||
Aliases: []string{"m"},
|
||||
Usage: "Set request metadata (can be used multiple times): --metadata 'Key:Value'",
|
||||
},
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
args := ctx.Args()
|
||||
|
||||
@@ -86,9 +128,16 @@ func init() {
|
||||
request = args.Get(2)
|
||||
}
|
||||
|
||||
// Create context with metadata if provided
|
||||
// Note: This is for the direct 'micro call' command.
|
||||
// Dynamic service calls (e.g., 'micro helloworld call') are handled in CallService.
|
||||
callCtx := context.TODO()
|
||||
callCtx = util.AddMetadataToContext(callCtx, ctx.StringSlice("metadata"))
|
||||
callCtx = util.AddMetadataToContext(callCtx, ctx.StringSlice("header"))
|
||||
|
||||
req := client.NewRequest(service, endpoint, &bytes.Frame{Data: []byte(request)})
|
||||
var rsp bytes.Frame
|
||||
err := client.Call(context.TODO(), req, &rsp)
|
||||
err := client.Call(callCtx, req, &rsp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -103,7 +103,10 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
|
||||
remotePath = defaultRemotePath
|
||||
}
|
||||
|
||||
fmt.Printf("Deploying to %s...\n\n", target)
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mmicro deploy\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Target \033[36m%s\033[0m\n\n", target)
|
||||
|
||||
// Early validation: Check if the requested service exists before SSH checks
|
||||
filterService := c.String("service")
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
// Package generate implements AI-powered service generation for go-micro.
|
||||
// It uses an LLM to design service architecture and generate handler code
|
||||
// with real business logic, then compiles and fixes errors iteratively.
|
||||
package generate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
|
||||
_ "go-micro.dev/v5/ai/anthropic"
|
||||
_ "go-micro.dev/v5/ai/atlascloud"
|
||||
_ "go-micro.dev/v5/ai/gemini"
|
||||
_ "go-micro.dev/v5/ai/groq"
|
||||
_ "go-micro.dev/v5/ai/mistral"
|
||||
_ "go-micro.dev/v5/ai/openai"
|
||||
_ "go-micro.dev/v5/ai/together"
|
||||
)
|
||||
|
||||
const designPrompt = `You are a Go microservices architect using the go-micro framework.
|
||||
Given a system description, design the services needed.
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{
|
||||
"services": [
|
||||
{
|
||||
"name": "service-name",
|
||||
"description": "What this service does",
|
||||
"fields": [
|
||||
{"name": "field_name", "type": "string", "description": "What this field is"}
|
||||
],
|
||||
"endpoints": [
|
||||
{"name": "EndpointName", "description": "What this endpoint does", "example": "{\"key\": \"value\"}"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Service names are lowercase, hyphenated, WITHOUT a "-service" suffix (e.g. "task" not "task-service", "shipping" not "shipping-service")
|
||||
- Each service MUST have CRUD endpoints: Create, Read, Update, Delete, List
|
||||
- Add 1-3 custom endpoints for real business logic (e.g. PlaceOrder, CheckInventory)
|
||||
- Field types: string, int64, bool, float64
|
||||
- Every service needs id (string), created (int64), updated (int64) fields
|
||||
- Endpoint names are PascalCase
|
||||
- Examples should be realistic JSON
|
||||
- 2-4 services max, focused on the domain
|
||||
- Keep services small and focused — one concern per service, max 5-8 fields
|
||||
- Services don't call each other; an AI agent orchestrates across them`
|
||||
|
||||
const designPromptWithExisting = `You are a Go microservices architect using the go-micro framework.
|
||||
The user has an EXISTING system with services already running. They want to extend or modify it.
|
||||
|
||||
Existing services:
|
||||
%s
|
||||
|
||||
Given the user's request, return the COMPLETE set of services (existing + new/modified).
|
||||
For existing services the user hasn't asked to change, return them as-is.
|
||||
For new or modified services, include the full specification.
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{
|
||||
"services": [
|
||||
{
|
||||
"name": "service-name",
|
||||
"description": "What this service does",
|
||||
"fields": [
|
||||
{"name": "field_name", "type": "string", "description": "What this field is"}
|
||||
],
|
||||
"endpoints": [
|
||||
{"name": "EndpointName", "description": "What this endpoint does", "example": "{\"key\": \"value\"}"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Service names are lowercase, hyphenated, WITHOUT a "-service" suffix (e.g. "task" not "task-service", "shipping" not "shipping-service")
|
||||
- Each service MUST have CRUD endpoints: Create, Read, Update, Delete, List
|
||||
- Add custom endpoints for real business logic
|
||||
- Field types: string, int64, bool, float64
|
||||
- Every service needs id (string), created (int64), updated (int64) fields
|
||||
- Endpoint names are PascalCase
|
||||
- Examples should be realistic JSON
|
||||
- Keep existing services unless the user explicitly asks to change them`
|
||||
|
||||
const handlerPrompt = `You are a Go developer writing a handler for a go-micro service.
|
||||
Generate a COMPLETE, COMPILABLE Go handler file.
|
||||
|
||||
The handler must:
|
||||
1. Use package "handler"
|
||||
2. Import the proto package as: pb "%s/proto"
|
||||
3. Import go-micro logger as: log "go-micro.dev/v5/logger"
|
||||
4. Import "github.com/google/uuid" for ID generation
|
||||
5. Use "go-micro.dev/v5/store" for persistent storage (NOT in-memory maps)
|
||||
6. Include REAL business logic — not just CRUD store operations
|
||||
7. Every exported method must have a doc comment explaining what it does
|
||||
8. Every method must have an @example tag with realistic JSON input
|
||||
9. Handle edge cases, validation, and return meaningful errors
|
||||
10. Keep the file under 200 lines — be concise, no boilerplate
|
||||
|
||||
For storage, use the go-micro store package:
|
||||
import "go-micro.dev/v5/store"
|
||||
import "encoding/json"
|
||||
|
||||
// In the struct:
|
||||
store store.Store
|
||||
|
||||
// In the constructor:
|
||||
func New() *%s { return &%s{store: store.DefaultStore} }
|
||||
|
||||
// Write a record:
|
||||
data, _ := json.Marshal(record)
|
||||
store.Write(&store.Record{Key: "prefix/" + id, Value: data})
|
||||
|
||||
// Read a record:
|
||||
recs, err := store.Read("prefix/" + id)
|
||||
json.Unmarshal(recs[0].Value, &record)
|
||||
|
||||
// List keys:
|
||||
keys, _ := store.List(store.ListPrefix("prefix/"))
|
||||
|
||||
// Delete:
|
||||
store.Delete("prefix/" + id)
|
||||
|
||||
Do NOT use sync.Mutex or in-memory maps. Use store for all data.
|
||||
|
||||
The struct name is %s.
|
||||
The constructor is func New() *%s.
|
||||
|
||||
Here is the proto definition:
|
||||
%s
|
||||
|
||||
Here is what each endpoint should do:
|
||||
%s
|
||||
|
||||
Return ONLY the Go code. No markdown, no explanation. Just the .go file content starting with "package handler".`
|
||||
|
||||
// ServiceDesign is the LLM's output.
|
||||
type ServiceDesign struct {
|
||||
Services []ServiceSpec `json:"services"`
|
||||
}
|
||||
|
||||
type ServiceSpec struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Fields []FieldSpec `json:"fields"`
|
||||
Endpoints []EndpointSpec `json:"endpoints"`
|
||||
}
|
||||
|
||||
type FieldSpec struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type EndpointSpec struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Example string `json:"example"`
|
||||
}
|
||||
|
||||
// Design calls an LLM to design services from a prompt.
|
||||
// If baseDir contains existing services, they are included as context
|
||||
// so the LLM extends the system rather than redesigning from scratch.
|
||||
func Design(ctx context.Context, provider, apiKey, model, baseDir, prompt string) (*ServiceDesign, error) {
|
||||
m := newModel(provider, apiKey, model)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("unknown provider: %s", provider)
|
||||
}
|
||||
|
||||
existing := discoverExisting(baseDir)
|
||||
|
||||
var sysPrompt, userPrompt string
|
||||
if len(existing) > 0 {
|
||||
sysPrompt = fmt.Sprintf(designPromptWithExisting, existing)
|
||||
userPrompt = fmt.Sprintf("Extend or modify the system: %s", prompt)
|
||||
} else {
|
||||
sysPrompt = designPrompt
|
||||
userPrompt = fmt.Sprintf("Design a microservices system for: %s", prompt)
|
||||
}
|
||||
|
||||
sp := startSpinner("designing services...")
|
||||
designCtx, designCancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer designCancel()
|
||||
resp, err := m.Generate(designCtx, &ai.Request{
|
||||
Prompt: userPrompt,
|
||||
SystemPrompt: sysPrompt,
|
||||
})
|
||||
sp.Stop()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("design failed: %w", err)
|
||||
}
|
||||
|
||||
reply := firstNonEmpty(resp.Answer, resp.Reply)
|
||||
reply = extractJSON(reply)
|
||||
|
||||
var design ServiceDesign
|
||||
if err := json.Unmarshal([]byte(reply), &design); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse design: %w\nResponse: %s", err, reply)
|
||||
}
|
||||
if len(design.Services) == 0 {
|
||||
return nil, fmt.Errorf("no services designed")
|
||||
}
|
||||
return &design, nil
|
||||
}
|
||||
|
||||
// discoverExisting scans a directory for existing go-micro services
|
||||
// and returns a summary string for inclusion in the design prompt.
|
||||
func discoverExisting(baseDir string) string {
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var summaries []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
svcDir := filepath.Join(baseDir, e.Name())
|
||||
|
||||
// Look for proto files as indicator of a go-micro service
|
||||
protoDir := filepath.Join(svcDir, "proto")
|
||||
protos, err := filepath.Glob(filepath.Join(protoDir, "*.proto"))
|
||||
if err != nil || len(protos) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
proto := readFile(protos[0])
|
||||
if proto == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
summaries = append(summaries, fmt.Sprintf("### %s\nProto:\n```\n%s\n```", e.Name(), proto))
|
||||
}
|
||||
|
||||
return strings.Join(summaries, "\n\n")
|
||||
}
|
||||
|
||||
// Generate creates go-micro service directories from a design.
|
||||
// If a service directory already exists, it skips structure generation
|
||||
// but regenerates the handler (allowing iterative improvement).
|
||||
func Generate(ctx context.Context, baseDir string, design *ServiceDesign, provider, apiKey, model string) error {
|
||||
m := newModel(provider, apiKey, model)
|
||||
|
||||
for i, svc := range design.Services {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
svcDir := filepath.Join(baseDir, svc.Name)
|
||||
handlerFile := filepath.Join(svcDir, "handler", svc.Name+".go")
|
||||
protoFile := filepath.Join(svcDir, "proto", svc.Name+".proto")
|
||||
|
||||
// Snapshot proto hash before structure generation
|
||||
protoBefore := fileHash(protoFile)
|
||||
|
||||
fmt.Printf(" \033[2m[%d/%d]\033[0m generating \033[36m%s\033[0m...\n", i+1, len(design.Services), svc.Name)
|
||||
|
||||
// Step 1: Generate proto (deterministic — from design spec)
|
||||
if err := generateStructure(svcDir, svc); err != nil {
|
||||
return fmt.Errorf("structure %s: %w", svc.Name, err)
|
||||
}
|
||||
|
||||
protoAfter := fileHash(protoFile)
|
||||
protoChanged := protoBefore != protoAfter
|
||||
|
||||
// If proto unchanged and handler unmodified, nothing to do
|
||||
if !protoChanged && protoBefore != "" && !handlerModified(svcDir, handlerFile) {
|
||||
fmt.Printf(" \033[32m✓\033[0m %s \033[2m(unchanged)\033[0m\n", svc.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 2: Run go mod tidy + make proto to get compiled proto
|
||||
runIn(svcDir, "go", "mod", "tidy")
|
||||
runIn(svcDir, "make", "proto")
|
||||
|
||||
// Step 3: Generate handler with business logic (LLM)
|
||||
proto := readFile(protoFile)
|
||||
if err := generateHandler(ctx, m, svcDir, svc, proto); err != nil {
|
||||
return fmt.Errorf("handler %s: %w", svc.Name, err)
|
||||
}
|
||||
|
||||
// Step 4: Compile-fix loop
|
||||
if err := compileFix(ctx, m, svcDir, svc.Name, 3); err != nil {
|
||||
fmt.Printf(" \033[33m⚠\033[0m %s has compile errors (may need manual fix)\n", svc.Name)
|
||||
} else {
|
||||
fmt.Printf(" \033[32m✓\033[0m %s\n", svc.Name)
|
||||
}
|
||||
|
||||
// Record final handler hash (after any compile fixes)
|
||||
recordHandlerHash(svcDir, handlerFile)
|
||||
}
|
||||
|
||||
// Generate an agent that manages all the services
|
||||
var svcNames []string
|
||||
for _, svc := range design.Services {
|
||||
svcNames = append(svcNames, svc.Name)
|
||||
}
|
||||
if err := generateAgent(baseDir, design, svcNames); err != nil {
|
||||
fmt.Printf(" \033[33m⚠\033[0m agent generation failed: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateStructure creates the proto, main.go, go.mod, Makefile.
|
||||
// If the directory already exists, only regenerates the proto
|
||||
// (handler will be regenerated separately by the LLM).
|
||||
func generateStructure(dir string, svc ServiceSpec) error {
|
||||
exists := false
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
exists = true
|
||||
}
|
||||
os.MkdirAll(filepath.Join(dir, "handler"), 0755)
|
||||
os.MkdirAll(filepath.Join(dir, "proto"), 0755)
|
||||
|
||||
name := svc.Name
|
||||
titleName := toTitle(name)
|
||||
dehyphen := strings.ReplaceAll(name, "-", "")
|
||||
|
||||
// Regenerate proto unless user has modified it
|
||||
protoPath := filepath.Join(dir, "proto", name+".proto")
|
||||
if !fileModified(dir, "proto_hash", protoPath) {
|
||||
writeFile(protoPath, buildProto(dehyphen, titleName, svc))
|
||||
recordFileHash(dir, "proto_hash", protoPath)
|
||||
} else {
|
||||
fmt.Printf(" \033[2mkeeping %s proto (modified)\033[0m\n", name)
|
||||
}
|
||||
|
||||
// Only write structural files if directory is new
|
||||
if !exists {
|
||||
writeFile(filepath.Join(dir, "main.go"), buildMain(name, titleName))
|
||||
|
||||
writeFile(filepath.Join(dir, "Makefile"),
|
||||
"GOPATH:=$(shell go env GOPATH)\n\n.PHONY: proto\nproto:\n\tprotoc --proto_path=. --micro_out=. --go_out=. proto/*.proto\n")
|
||||
|
||||
writeFile(filepath.Join(dir, "go.mod"),
|
||||
fmt.Sprintf("module %s\n\ngo 1.24\n\nrequire go-micro.dev/v5 v5.24.0\n", name))
|
||||
|
||||
writeFile(filepath.Join(dir, ".gitignore"),
|
||||
fmt.Sprintf("%s\n.micro\n", name))
|
||||
}
|
||||
|
||||
// Placeholder handler so go mod tidy works (will be overwritten by LLM)
|
||||
handlerPath := filepath.Join(dir, "handler", name+".go")
|
||||
if _, err := os.Stat(handlerPath); os.IsNotExist(err) {
|
||||
writeFile(handlerPath,
|
||||
fmt.Sprintf("package handler\n\ntype %s struct{}\n\nfunc New() *%s { return &%s{} }\n", titleName, titleName, titleName))
|
||||
recordHandlerHash(dir, handlerPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateHandler asks the LLM to write the handler with business logic.
|
||||
// If the handler exists and the user has modified it since generation,
|
||||
// it is left untouched.
|
||||
func generateHandler(ctx context.Context, m ai.Model, dir string, svc ServiceSpec, proto string) error {
|
||||
if m == nil {
|
||||
return nil // no LLM — keep the placeholder
|
||||
}
|
||||
|
||||
handlerFile := filepath.Join(dir, "handler", svc.Name+".go")
|
||||
|
||||
if handlerModified(dir, handlerFile) {
|
||||
fmt.Printf(" \033[2mkeeping %s handler (modified)\033[0m\n", svc.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
titleName := toTitle(svc.Name)
|
||||
|
||||
// Build endpoint descriptions
|
||||
var epDescs []string
|
||||
for _, ep := range svc.Endpoints {
|
||||
epDescs = append(epDescs, fmt.Sprintf("- %s: %s (example input: %s)", ep.Name, ep.Description, ep.Example))
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(handlerPrompt,
|
||||
svc.Name, titleName, titleName, titleName, titleName, proto, strings.Join(epDescs, "\n"))
|
||||
|
||||
sp := startSpinner(fmt.Sprintf("writing %s handler...", svc.Name))
|
||||
genCtx, genCancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer genCancel()
|
||||
resp, err := m.Generate(genCtx, &ai.Request{
|
||||
Prompt: fmt.Sprintf("Generate the handler for the %s service with real business logic.", svc.Name),
|
||||
SystemPrompt: prompt,
|
||||
})
|
||||
sp.Stop()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
code := firstNonEmpty(resp.Answer, resp.Reply)
|
||||
code = extractCode(code)
|
||||
|
||||
if !strings.HasPrefix(strings.TrimSpace(code), "package") {
|
||||
return fmt.Errorf("LLM did not return valid Go code")
|
||||
}
|
||||
|
||||
if isTruncated(code) {
|
||||
fmt.Printf(" \033[33m→\033[0m response truncated, retrying...\n")
|
||||
sp = startSpinner(fmt.Sprintf("rewriting %s handler...", svc.Name))
|
||||
retryCtx, retryCancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer retryCancel()
|
||||
resp, err = m.Generate(retryCtx, &ai.Request{
|
||||
Prompt: fmt.Sprintf("Generate the handler for the %s service with real business logic. Keep it concise — no more than 200 lines.", svc.Name),
|
||||
SystemPrompt: prompt,
|
||||
})
|
||||
sp.Stop()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
code = firstNonEmpty(resp.Answer, resp.Reply)
|
||||
code = extractCode(code)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(strings.TrimSpace(code), "package") {
|
||||
return fmt.Errorf("LLM did not return valid Go code")
|
||||
}
|
||||
|
||||
writeFile(handlerFile, code)
|
||||
recordHandlerHash(dir, handlerFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// compileFix tries to compile, and if it fails, sends the error to
|
||||
// the LLM to fix. Up to maxAttempts iterations.
|
||||
func compileFix(ctx context.Context, m ai.Model, dir, name string, maxAttempts int) error {
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return nil // compiles!
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return fmt.Errorf("compile failed: %s", string(out))
|
||||
}
|
||||
|
||||
// Read current handler
|
||||
handlerPath := filepath.Join(dir, "handler", name+".go")
|
||||
currentCode := readFile(handlerPath)
|
||||
|
||||
sp := startSpinner(fmt.Sprintf("fixing compile errors (attempt %d/%d)...", attempt+1, maxAttempts))
|
||||
fixCtx, fixCancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
resp, fixErr := m.Generate(fixCtx, &ai.Request{
|
||||
Prompt: fmt.Sprintf("This Go code has compile errors. Fix ALL of them and return the COMPLETE corrected file.\n\nErrors:\n%s\n\nCode:\n%s",
|
||||
string(out), currentCode),
|
||||
SystemPrompt: "You are a Go expert. Return ONLY the corrected Go code. No markdown, no explanation. Start with 'package handler'.",
|
||||
})
|
||||
fixCancel()
|
||||
sp.Stop()
|
||||
if fixErr != nil {
|
||||
return fmt.Errorf("fix attempt failed: %w", fixErr)
|
||||
}
|
||||
|
||||
fixed := firstNonEmpty(resp.Answer, resp.Reply)
|
||||
fixed = extractCode(fixed)
|
||||
if strings.HasPrefix(strings.TrimSpace(fixed), "package") && !isTruncated(fixed) {
|
||||
writeFile(handlerPath, fixed)
|
||||
}
|
||||
}
|
||||
|
||||
// Final check
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("still fails after %d attempts: %s", maxAttempts, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newModel(provider, apiKey, model string) ai.Model {
|
||||
if provider == "" {
|
||||
provider = ai.AutoDetectProvider("")
|
||||
}
|
||||
var opts []ai.Option
|
||||
opts = append(opts, ai.WithAPIKey(apiKey))
|
||||
if model != "" {
|
||||
opts = append(opts, ai.WithModel(model))
|
||||
}
|
||||
return ai.New(provider, opts...)
|
||||
}
|
||||
|
||||
func buildProto(dehyphen, titleName string, svc ServiceSpec) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("syntax = \"proto3\";\n\npackage %s;\n\noption go_package = \"./proto;%s\";\n\n", dehyphen, dehyphen))
|
||||
|
||||
b.WriteString(fmt.Sprintf("service %s {\n", titleName))
|
||||
for _, ep := range svc.Endpoints {
|
||||
b.WriteString(fmt.Sprintf("\trpc %s(%sRequest) returns (%sResponse) {}\n", ep.Name, ep.Name, ep.Name))
|
||||
}
|
||||
b.WriteString("}\n\n")
|
||||
|
||||
// Record message
|
||||
b.WriteString(fmt.Sprintf("message %sRecord {\n", titleName))
|
||||
for i, f := range svc.Fields {
|
||||
b.WriteString(fmt.Sprintf("\t%s %s = %d; // %s\n", protoType(f.Type), f.Name, i+1, f.Description))
|
||||
}
|
||||
b.WriteString("}\n\n")
|
||||
|
||||
// Request/response for each endpoint
|
||||
for _, ep := range svc.Endpoints {
|
||||
switch ep.Name {
|
||||
case "Create":
|
||||
b.WriteString(fmt.Sprintf("message CreateRequest {\n"))
|
||||
n := 1
|
||||
for _, f := range svc.Fields {
|
||||
if f.Name == "id" || f.Name == "created" || f.Name == "updated" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\t%s %s = %d;\n", protoType(f.Type), f.Name, n))
|
||||
n++
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("}\n\nmessage CreateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName))
|
||||
case "Read":
|
||||
b.WriteString(fmt.Sprintf("message ReadRequest {\n\tstring id = 1;\n}\n\nmessage ReadResponse {\n\t%sRecord record = 1;\n}\n\n", titleName))
|
||||
case "Update":
|
||||
b.WriteString("message UpdateRequest {\n\tstring id = 1;\n")
|
||||
n := 2
|
||||
for _, f := range svc.Fields {
|
||||
if f.Name == "id" || f.Name == "created" || f.Name == "updated" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\t%s %s = %d;\n", protoType(f.Type), f.Name, n))
|
||||
n++
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("}\n\nmessage UpdateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName))
|
||||
case "Delete":
|
||||
b.WriteString(fmt.Sprintf("message DeleteRequest {\n\tstring id = 1;\n}\n\nmessage DeleteResponse {\n\tbool deleted = 1;\n}\n\n"))
|
||||
case "List":
|
||||
b.WriteString(fmt.Sprintf("message ListRequest {\n\tint64 limit = 1;\n\tint64 offset = 2;\n\tstring query = 3;\n}\n\nmessage ListResponse {\n\trepeated %sRecord records = 1;\n\tint64 total = 2;\n}\n\n", titleName))
|
||||
default:
|
||||
// Custom endpoint — use all fields as input, record as output
|
||||
b.WriteString(fmt.Sprintf("message %sRequest {\n", ep.Name))
|
||||
n := 1
|
||||
for _, f := range svc.Fields {
|
||||
if f.Name == "created" || f.Name == "updated" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\t%s %s = %d;\n", protoType(f.Type), f.Name, n))
|
||||
n++
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("}\n\nmessage %sResponse {\n\t%sRecord record = 1;\n\tstring message = 2;\n\tbool success = 3;\n}\n\n", ep.Name, titleName))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildMain(name, titleName string) string {
|
||||
svcName := strings.TrimSuffix(name, "-service")
|
||||
return fmt.Sprintf(`package main
|
||||
|
||||
import (
|
||||
"%s/handler"
|
||||
pb "%s/proto"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.New("%s",
|
||||
mcp.WithMCP(":0"),
|
||||
)
|
||||
service.Init()
|
||||
pb.Register%sHandler(service.Server(), handler.New())
|
||||
service.Run()
|
||||
}
|
||||
`, name, name, svcName, titleName)
|
||||
}
|
||||
|
||||
func generateAgent(baseDir string, design *ServiceDesign, svcNames []string) error {
|
||||
agentName := "agent"
|
||||
agentDir := filepath.Join(baseDir, agentName)
|
||||
|
||||
if _, err := os.Stat(agentDir); err == nil {
|
||||
return nil // already exists
|
||||
}
|
||||
|
||||
os.MkdirAll(agentDir, 0755)
|
||||
|
||||
// Build a description of all services for the agent prompt
|
||||
var svcDescs []string
|
||||
for _, svc := range design.Services {
|
||||
var eps []string
|
||||
for _, ep := range svc.Endpoints {
|
||||
eps = append(eps, ep.Name)
|
||||
}
|
||||
svcDescs = append(svcDescs, fmt.Sprintf("- %s: %s (%s)", svc.Name, svc.Description, strings.Join(eps, ", ")))
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf("You manage these services:\\n%s\\nUse the available tools to fulfill requests. Be helpful and concise.", strings.Join(svcDescs, "\\n"))
|
||||
quoted := strings.Join(svcNames, `", "`)
|
||||
|
||||
writeFile(filepath.Join(agentDir, "main.go"), fmt.Sprintf(`package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
)
|
||||
|
||||
func main() {
|
||||
agent := micro.NewAgent("agent",
|
||||
micro.AgentServices("%s"),
|
||||
micro.AgentPrompt("%s"),
|
||||
micro.AgentProvider(os.Getenv("MICRO_AI_PROVIDER")),
|
||||
micro.AgentAPIKey(os.Getenv("MICRO_AI_API_KEY")),
|
||||
)
|
||||
agent.Init()
|
||||
agent.Run()
|
||||
}
|
||||
`, quoted, prompt))
|
||||
|
||||
writeFile(filepath.Join(agentDir, "go.mod"),
|
||||
fmt.Sprintf("module %s\n\ngo 1.24\n\nrequire go-micro.dev/v5 v5.25.0\n", agentName))
|
||||
|
||||
runIn(agentDir, "go", "mod", "tidy")
|
||||
|
||||
fmt.Printf(" \033[35m◆\033[0m agent \033[2m(manages: %s)\033[0m\n", strings.Join(svcNames, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractJSON(s string) string {
|
||||
if i := strings.Index(s, "```json"); i >= 0 {
|
||||
s = s[i+7:]
|
||||
if j := strings.Index(s, "```"); j >= 0 {
|
||||
return strings.TrimSpace(s[:j])
|
||||
}
|
||||
}
|
||||
if i := strings.Index(s, "```"); i >= 0 {
|
||||
s = s[i+3:]
|
||||
if j := strings.Index(s, "```"); j >= 0 {
|
||||
return strings.TrimSpace(s[:j])
|
||||
}
|
||||
}
|
||||
if i := strings.Index(s, "{"); i >= 0 {
|
||||
depth := 0
|
||||
for j := i; j < len(s); j++ {
|
||||
switch s[j] {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return s[i : j+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func extractCode(s string) string {
|
||||
if i := strings.Index(s, "```go"); i >= 0 {
|
||||
s = s[i+5:]
|
||||
if j := strings.Index(s, "```"); j >= 0 {
|
||||
return strings.TrimSpace(s[:j])
|
||||
}
|
||||
}
|
||||
if i := strings.Index(s, "```"); i >= 0 {
|
||||
s = s[i+3:]
|
||||
if j := strings.Index(s, "```"); j >= 0 {
|
||||
return strings.TrimSpace(s[:j])
|
||||
}
|
||||
}
|
||||
// Try to find raw package declaration
|
||||
if i := strings.Index(s, "package "); i >= 0 {
|
||||
return strings.TrimSpace(s[i:])
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func isTruncated(code string) bool {
|
||||
trimmed := strings.TrimSpace(code)
|
||||
if len(trimmed) == 0 {
|
||||
return true
|
||||
}
|
||||
// Valid Go files end with a closing brace
|
||||
if trimmed[len(trimmed)-1] != '}' {
|
||||
return true
|
||||
}
|
||||
// Check balanced braces
|
||||
depth := 0
|
||||
for _, c := range trimmed {
|
||||
switch c {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
return depth != 0
|
||||
}
|
||||
|
||||
func protoType(t string) string {
|
||||
switch t {
|
||||
case "int64":
|
||||
return "int64"
|
||||
case "int32":
|
||||
return "int32"
|
||||
case "bool":
|
||||
return "bool"
|
||||
case "float64":
|
||||
return "double"
|
||||
default:
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
func toTitle(s string) string {
|
||||
words := strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == '_' || r == ' ' })
|
||||
for i, w := range words {
|
||||
if len(w) > 0 {
|
||||
words[i] = strings.ToUpper(w[:1]) + w[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(words, "")
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readFile(path string) string {
|
||||
b, _ := os.ReadFile(path)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func writeFile(path, content string) {
|
||||
os.WriteFile(path, []byte(content), 0644)
|
||||
}
|
||||
|
||||
func runIn(dir string, name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "PATH="+os.Getenv("PATH")+":"+os.Getenv("GOPATH")+"/bin:"+os.Getenv("HOME")+"/go/bin")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
type spinner struct {
|
||||
msg string
|
||||
stop chan struct{}
|
||||
done sync.WaitGroup
|
||||
}
|
||||
|
||||
func isTTY() bool {
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
func startSpinner(msg string) *spinner {
|
||||
s := &spinner{msg: msg, stop: make(chan struct{})}
|
||||
if !isTTY() {
|
||||
fmt.Printf(" %s\n", msg)
|
||||
return s
|
||||
}
|
||||
s.done.Add(1)
|
||||
go func() {
|
||||
defer s.done.Done()
|
||||
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
i := 0
|
||||
t := time.NewTicker(100 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
fmt.Printf("\r\033[K")
|
||||
return
|
||||
case <-t.C:
|
||||
fmt.Printf("\r %s %s", frames[i%len(frames)], msg)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *spinner) Stop() {
|
||||
close(s.stop)
|
||||
s.done.Wait()
|
||||
}
|
||||
|
||||
func fileHash(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func metaPath(svcDir string) string {
|
||||
return filepath.Join(svcDir, ".micro")
|
||||
}
|
||||
|
||||
func readMeta(svcDir string) map[string]string {
|
||||
m := make(map[string]string)
|
||||
b, err := os.ReadFile(metaPath(svcDir))
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
json.Unmarshal(b, &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func writeMeta(svcDir string, m map[string]string) {
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
os.WriteFile(metaPath(svcDir), b, 0644)
|
||||
}
|
||||
|
||||
func fileModified(svcDir, key, path string) bool {
|
||||
meta := readMeta(svcDir)
|
||||
savedHash, ok := meta[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return fileHash(path) != savedHash
|
||||
}
|
||||
|
||||
func recordFileHash(svcDir, key, path string) {
|
||||
meta := readMeta(svcDir)
|
||||
meta[key] = fileHash(path)
|
||||
writeMeta(svcDir, meta)
|
||||
}
|
||||
|
||||
func handlerModified(svcDir, handlerFile string) bool {
|
||||
return fileModified(svcDir, "handler_hash", handlerFile)
|
||||
}
|
||||
|
||||
func recordHandlerHash(svcDir, handlerFile string) {
|
||||
recordFileHash(svcDir, "handler_hash", handlerFile)
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"order-service", "OrderService"},
|
||||
{"task", "Task"},
|
||||
{"inventory_item", "InventoryItem"},
|
||||
{"hello world", "HelloWorld"},
|
||||
{"a-b-c", "ABC"},
|
||||
{"already", "Already"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := toTitle(tt.in); got != tt.want {
|
||||
t.Errorf("toTitle(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtoType(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"string", "string"},
|
||||
{"int64", "int64"},
|
||||
{"int32", "int32"},
|
||||
{"bool", "bool"},
|
||||
{"float64", "double"},
|
||||
{"unknown", "string"},
|
||||
{"", "string"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := protoType(tt.in); got != tt.want {
|
||||
t.Errorf("protoType(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstNonEmpty(t *testing.T) {
|
||||
if got := firstNonEmpty("", "", "c"); got != "c" {
|
||||
t.Errorf("got %q, want %q", got, "c")
|
||||
}
|
||||
if got := firstNonEmpty("a", "b"); got != "a" {
|
||||
t.Errorf("got %q, want %q", got, "a")
|
||||
}
|
||||
if got := firstNonEmpty("", ""); got != "" {
|
||||
t.Errorf("got %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, in, want string
|
||||
}{
|
||||
{
|
||||
"fenced json",
|
||||
"Here's the design:\n```json\n{\"services\": []}\n```\nDone.",
|
||||
`{"services": []}`,
|
||||
},
|
||||
{
|
||||
"fenced no lang",
|
||||
"```\n{\"a\": 1}\n```",
|
||||
`{"a": 1}`,
|
||||
},
|
||||
{
|
||||
"raw json",
|
||||
`some text {"key": "val"} trailing`,
|
||||
`{"key": "val"}`,
|
||||
},
|
||||
{
|
||||
"nested braces",
|
||||
`{"a": {"b": 1}}`,
|
||||
`{"a": {"b": 1}}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractJSON(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractJSON() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, in string
|
||||
wantPrefix string
|
||||
}{
|
||||
{
|
||||
"go fence",
|
||||
"Here:\n```go\npackage handler\n\nfunc Foo() {}\n```\nDone.",
|
||||
"package handler",
|
||||
},
|
||||
{
|
||||
"generic fence",
|
||||
"```\npackage main\n```",
|
||||
"package main",
|
||||
},
|
||||
{
|
||||
"raw code",
|
||||
"Sure, here's the code:\npackage handler\n\ntype X struct{}",
|
||||
"package handler",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractCode(tt.in)
|
||||
if !strings.HasPrefix(got, tt.wantPrefix) {
|
||||
t.Errorf("extractCode() = %q, want prefix %q", got, tt.wantPrefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProto(t *testing.T) {
|
||||
svc := ServiceSpec{
|
||||
Name: "task-service",
|
||||
Description: "Manages tasks",
|
||||
Fields: []FieldSpec{
|
||||
{Name: "id", Type: "string", Description: "Task ID"},
|
||||
{Name: "title", Type: "string", Description: "Task title"},
|
||||
{Name: "done", Type: "bool", Description: "Completion status"},
|
||||
{Name: "created", Type: "int64", Description: "Created timestamp"},
|
||||
{Name: "updated", Type: "int64", Description: "Updated timestamp"},
|
||||
},
|
||||
Endpoints: []EndpointSpec{
|
||||
{Name: "Create", Description: "Create a task"},
|
||||
{Name: "Read", Description: "Get a task"},
|
||||
{Name: "Update", Description: "Update a task"},
|
||||
{Name: "Delete", Description: "Delete a task"},
|
||||
{Name: "List", Description: "List tasks"},
|
||||
{Name: "ToggleComplete", Description: "Toggle completion"},
|
||||
},
|
||||
}
|
||||
|
||||
proto := buildProto("taskservice", "TaskService", svc)
|
||||
|
||||
checks := []string{
|
||||
`syntax = "proto3"`,
|
||||
`package taskservice`,
|
||||
`service TaskService`,
|
||||
`rpc Create(CreateRequest) returns (CreateResponse)`,
|
||||
`rpc ToggleComplete(ToggleCompleteRequest) returns (ToggleCompleteResponse)`,
|
||||
`message TaskServiceRecord`,
|
||||
`string title = 2`,
|
||||
`bool done = 3`,
|
||||
`message CreateRequest`,
|
||||
`message ReadRequest`,
|
||||
`message DeleteRequest`,
|
||||
`message ListRequest`,
|
||||
`message ToggleCompleteRequest`,
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(proto, c) {
|
||||
t.Errorf("buildProto() missing %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Create should not include id, created, updated
|
||||
createIdx := strings.Index(proto, "message CreateRequest")
|
||||
createEnd := strings.Index(proto[createIdx:], "}")
|
||||
createBlock := proto[createIdx : createIdx+createEnd]
|
||||
for _, skip := range []string{"string id", "int64 created", "int64 updated"} {
|
||||
if strings.Contains(createBlock, skip) {
|
||||
t.Errorf("CreateRequest should not contain %q", skip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMain(t *testing.T) {
|
||||
// New naming: no -service suffix
|
||||
main := buildMain("order", "Order")
|
||||
checks := []string{
|
||||
`"order/handler"`,
|
||||
`pb "order/proto"`,
|
||||
`micro.New("order"`,
|
||||
`pb.RegisterOrderHandler`,
|
||||
`handler.New()`,
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(main, c) {
|
||||
t.Errorf("buildMain(order) missing %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy naming: -service suffix stripped
|
||||
main = buildMain("order-service", "OrderService")
|
||||
checks = []string{
|
||||
`"order-service/handler"`,
|
||||
`pb "order-service/proto"`,
|
||||
`micro.New("order"`,
|
||||
`pb.RegisterOrderServiceHandler`,
|
||||
`handler.New()`,
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(main, c) {
|
||||
t.Errorf("buildMain() missing %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerModifiedTracking(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
handlerDir := filepath.Join(dir, "handler")
|
||||
os.MkdirAll(handlerDir, 0755)
|
||||
handlerFile := filepath.Join(handlerDir, "test.go")
|
||||
|
||||
// No .micro file → not modified
|
||||
os.WriteFile(handlerFile, []byte("package handler\n"), 0644)
|
||||
if handlerModified(dir, handlerFile) {
|
||||
t.Error("expected not modified when no .micro exists")
|
||||
}
|
||||
|
||||
// Record hash → not modified
|
||||
recordHandlerHash(dir, handlerFile)
|
||||
if handlerModified(dir, handlerFile) {
|
||||
t.Error("expected not modified after recording hash")
|
||||
}
|
||||
|
||||
// Edit the file → modified
|
||||
os.WriteFile(handlerFile, []byte("package handler\n\nfunc Foo() {}\n"), 0644)
|
||||
if !handlerModified(dir, handlerFile) {
|
||||
t.Error("expected modified after editing file")
|
||||
}
|
||||
|
||||
// Re-record → not modified again
|
||||
recordHandlerHash(dir, handlerFile)
|
||||
if handlerModified(dir, handlerFile) {
|
||||
t.Error("expected not modified after re-recording hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaReadWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
m := readMeta(dir)
|
||||
if len(m) != 0 {
|
||||
t.Error("expected empty meta for new dir")
|
||||
}
|
||||
|
||||
m["handler_hash"] = "abc123"
|
||||
m["version"] = "1"
|
||||
writeMeta(dir, m)
|
||||
|
||||
m2 := readMeta(dir)
|
||||
if m2["handler_hash"] != "abc123" || m2["version"] != "1" {
|
||||
t.Errorf("readMeta() = %v, want handler_hash=abc123, version=1", m2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateStructure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
svcDir := filepath.Join(dir, "test-svc")
|
||||
|
||||
svc := ServiceSpec{
|
||||
Name: "test-svc",
|
||||
Description: "Test service",
|
||||
Fields: []FieldSpec{
|
||||
{Name: "id", Type: "string"},
|
||||
{Name: "name", Type: "string"},
|
||||
},
|
||||
Endpoints: []EndpointSpec{
|
||||
{Name: "Create"},
|
||||
{Name: "Read"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := generateStructure(svcDir, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check files exist
|
||||
for _, f := range []string{
|
||||
"proto/test-svc.proto",
|
||||
"handler/test-svc.go",
|
||||
"main.go",
|
||||
"go.mod",
|
||||
"Makefile",
|
||||
".gitignore",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(svcDir, f)); err != nil {
|
||||
t.Errorf("missing %s: %v", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check .micro was created with handler hash
|
||||
meta := readMeta(svcDir)
|
||||
if meta["handler_hash"] == "" {
|
||||
t.Error("expected handler_hash in .micro after generateStructure")
|
||||
}
|
||||
|
||||
// Run again — should not overwrite main.go
|
||||
mainBefore, _ := os.ReadFile(filepath.Join(svcDir, "main.go"))
|
||||
os.WriteFile(filepath.Join(svcDir, "main.go"), []byte("// user edited\n"), 0644)
|
||||
if err := generateStructure(svcDir, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainAfter, _ := os.ReadFile(filepath.Join(svcDir, "main.go"))
|
||||
if string(mainAfter) == string(mainBefore) {
|
||||
t.Error("expected main.go to keep user edit on re-run")
|
||||
}
|
||||
|
||||
// Proto should be protected if user modified it
|
||||
protoFile := filepath.Join(svcDir, "proto", "test-svc.proto")
|
||||
protoBefore, _ := os.ReadFile(protoFile)
|
||||
os.WriteFile(protoFile, []byte("// user-edited proto\n"), 0644)
|
||||
if err := generateStructure(svcDir, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
protoAfter, _ := os.ReadFile(protoFile)
|
||||
if string(protoAfter) != "// user-edited proto\n" {
|
||||
t.Error("expected proto to be preserved after user edit")
|
||||
}
|
||||
|
||||
// Proto should regenerate if NOT modified
|
||||
recordFileHash(svcDir, "proto_hash", protoFile)
|
||||
if err := generateStructure(svcDir, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
protoAfter2, _ := os.ReadFile(protoFile)
|
||||
if string(protoAfter2) == string(protoBefore) {
|
||||
// ok — regenerated from spec
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileModified(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "test.txt")
|
||||
os.WriteFile(f, []byte("original"), 0644)
|
||||
|
||||
// No hash → not modified
|
||||
if fileModified(dir, "test_hash", f) {
|
||||
t.Error("expected not modified with no saved hash")
|
||||
}
|
||||
|
||||
recordFileHash(dir, "test_hash", f)
|
||||
|
||||
// Same content → not modified
|
||||
if fileModified(dir, "test_hash", f) {
|
||||
t.Error("expected not modified with matching hash")
|
||||
}
|
||||
|
||||
// Changed content → modified
|
||||
os.WriteFile(f, []byte("changed"), 0644)
|
||||
if !fileModified(dir, "test_hash", f) {
|
||||
t.Error("expected modified after content change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Empty directory → empty string
|
||||
if got := discoverExisting(dir); got != "" {
|
||||
t.Errorf("expected empty for empty dir, got %q", got)
|
||||
}
|
||||
|
||||
// Non-service directory (no proto) → empty
|
||||
os.MkdirAll(filepath.Join(dir, "not-a-service"), 0755)
|
||||
if got := discoverExisting(dir); got != "" {
|
||||
t.Errorf("expected empty for dir without proto, got %q", got)
|
||||
}
|
||||
|
||||
// Create a real service directory with proto
|
||||
svcDir := filepath.Join(dir, "order-service")
|
||||
os.MkdirAll(filepath.Join(svcDir, "proto"), 0755)
|
||||
os.WriteFile(filepath.Join(svcDir, "proto", "order-service.proto"),
|
||||
[]byte("syntax = \"proto3\";\nservice OrderService {}"), 0644)
|
||||
|
||||
got := discoverExisting(dir)
|
||||
if !strings.Contains(got, "order-service") {
|
||||
t.Errorf("expected to find order-service, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "OrderService") {
|
||||
t.Errorf("expected to find proto content, got %q", got)
|
||||
}
|
||||
|
||||
// Add a second service
|
||||
svc2Dir := filepath.Join(dir, "user-service")
|
||||
os.MkdirAll(filepath.Join(svc2Dir, "proto"), 0755)
|
||||
os.WriteFile(filepath.Join(svc2Dir, "proto", "user-service.proto"),
|
||||
[]byte("syntax = \"proto3\";\nservice UserService {}"), 0644)
|
||||
|
||||
got = discoverExisting(dir)
|
||||
if !strings.Contains(got, "order-service") || !strings.Contains(got, "user-service") {
|
||||
t.Errorf("expected both services, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTruncated(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
want bool
|
||||
}{
|
||||
{"complete", "package handler\n\nfunc New() *H { return &H{} }\n", false},
|
||||
{"empty", "", true},
|
||||
{"no closing brace", "package handler\n\nfunc Foo() {", true},
|
||||
{"unbalanced", "package handler\n\nfunc Foo() {\n\tif true {", true},
|
||||
{"balanced", "package handler\n\nfunc Foo() {\n\tif true {\n\t}\n}", false},
|
||||
{"trailing whitespace ok", "package handler\n\ntype X struct{}\n\n", false},
|
||||
{"mid-expression", "package handler\n\nfunc F() {\n\tx := 1 +", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isTruncated(tt.code); got != tt.want {
|
||||
t.Errorf("isTruncated() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+142
-6
@@ -2,14 +2,20 @@
|
||||
package new
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"go/build"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"go-micro.dev/v5/cmd/micro/cli/generate"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
@@ -84,7 +90,10 @@ func create(c config) error {
|
||||
return fmt.Errorf("%s already exists", c.Dir)
|
||||
}
|
||||
|
||||
fmt.Printf("Creating service %s\n\n", c.Alias)
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mmicro new\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Creating \033[36m%s\033[0m\n\n", c.Alias)
|
||||
|
||||
t := treeprint.New()
|
||||
|
||||
@@ -135,6 +144,11 @@ func addFileToTree(root treeprint.Tree, file string) {
|
||||
}
|
||||
|
||||
func Run(ctx *cli.Context) error {
|
||||
// Handle --prompt: design services with AI, then generate each one
|
||||
if prompt := ctx.String("prompt"); prompt != "" {
|
||||
return runPrompt(ctx, prompt)
|
||||
}
|
||||
|
||||
dir := ctx.Args().First()
|
||||
if len(dir) == 0 {
|
||||
fmt.Println("specify service name")
|
||||
@@ -174,17 +188,23 @@ func Run(ctx *cli.Context) error {
|
||||
}
|
||||
goDir = filepath.Join(goPath, "src", path.Clean(dir))
|
||||
|
||||
noMCP := ctx.Bool("no-mcp")
|
||||
templateName := ctx.String("template")
|
||||
|
||||
// Select templates based on --template flag
|
||||
mainTmpl, handlerTmpl, protoTmpl := selectTemplates(templateName, noMCP)
|
||||
|
||||
c := config{
|
||||
Alias: dir,
|
||||
Comments: nil, // Remove redundant protoComments
|
||||
Comments: nil,
|
||||
Dir: dir,
|
||||
GoDir: goDir,
|
||||
GoPath: goPath,
|
||||
UseGoPath: false,
|
||||
Files: []file{
|
||||
{"main.go", tmpl.MainSRV},
|
||||
{"handler/" + dir + ".go", tmpl.HandlerSRV},
|
||||
{"proto/" + dir + ".proto", tmpl.ProtoSRV},
|
||||
{"main.go", mainTmpl},
|
||||
{"handler/" + dir + ".go", handlerTmpl},
|
||||
{"proto/" + dir + ".proto", protoTmpl},
|
||||
{"Makefile", tmpl.Makefile},
|
||||
{"README.md", tmpl.Readme},
|
||||
{".gitignore", tmpl.GitIgnore},
|
||||
@@ -214,10 +234,53 @@ func Run(ctx *cli.Context) error {
|
||||
fmt.Println("\nProject structure after 'make proto':")
|
||||
printTree(dir)
|
||||
|
||||
fmt.Println("\nService created successfully! Start coding in your new service directory.")
|
||||
fmt.Println()
|
||||
fmt.Printf(" \033[32m✓\033[0m Service \033[36m%s\033[0m created\n\n", dir)
|
||||
fmt.Println(" Next steps:")
|
||||
fmt.Printf(" cd %s\n", dir)
|
||||
fmt.Println(" go run .")
|
||||
if !noMCP {
|
||||
fmt.Println()
|
||||
fmt.Printf(" MCP tools \033[36mhttp://localhost:3001/mcp/tools\033[0m\n")
|
||||
fmt.Println(" Claude Code \033[2mmicro mcp serve\033[0m")
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectTemplates(name string, noMCP bool) (mainTmpl, handlerTmpl, protoTmpl string) {
|
||||
switch name {
|
||||
case "crud":
|
||||
if noMCP {
|
||||
mainTmpl = tmpl.MainSRVNoMCP
|
||||
} else {
|
||||
mainTmpl = tmpl.MainSRV
|
||||
}
|
||||
return mainTmpl, tmpl.CrudHandlerSRV, tmpl.CrudProtoSRV
|
||||
case "pubsub":
|
||||
if noMCP {
|
||||
mainTmpl = tmpl.PubsubMainSRVNoMCP
|
||||
} else {
|
||||
mainTmpl = tmpl.PubsubMainSRV
|
||||
}
|
||||
return mainTmpl, tmpl.PubsubHandlerSRV, tmpl.PubsubProtoSRV
|
||||
case "api":
|
||||
if noMCP {
|
||||
mainTmpl = tmpl.MainSRVNoMCP
|
||||
} else {
|
||||
mainTmpl = tmpl.MainSRV
|
||||
}
|
||||
return mainTmpl, tmpl.ApiHandlerSRV, tmpl.ApiProtoSRV
|
||||
default:
|
||||
if noMCP {
|
||||
mainTmpl = tmpl.MainSRVNoMCP
|
||||
} else {
|
||||
mainTmpl = tmpl.MainSRV
|
||||
}
|
||||
return mainTmpl, tmpl.HandlerSRV, tmpl.ProtoSRV
|
||||
}
|
||||
}
|
||||
|
||||
func runInDir(dir, cmd string) error {
|
||||
parts := strings.Fields(cmd)
|
||||
c := exec.Command(parts[0], parts[1:]...)
|
||||
@@ -255,3 +318,76 @@ func printTree(dir string) {
|
||||
filepath.Walk(dir, walk)
|
||||
fmt.Println(t.String())
|
||||
}
|
||||
|
||||
func runPrompt(cliCtx *cli.Context, prompt string) error {
|
||||
provider := cliCtx.String("provider")
|
||||
apiKey := cliCtx.String("api_key")
|
||||
if apiKey == "" {
|
||||
// Try provider-specific env vars
|
||||
for _, env := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY",
|
||||
"ATLASCLOUD_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "TOGETHER_API_KEY", "MICRO_AI_API_KEY"} {
|
||||
if v := os.Getenv(env); v != "" {
|
||||
apiKey = v
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("--api_key or a provider API key env var is required for --prompt")
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mmicro new --prompt\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Printf(" \033[2mDesigning services for:\033[0m %s\n\n", prompt)
|
||||
|
||||
design, err := generate.Design(ctx, provider, apiKey, "", ".", prompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("design failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(" Services:")
|
||||
for _, svc := range design.Services {
|
||||
fmt.Printf(" \033[32m●\033[0m \033[36m%s\033[0m — %s\n", svc.Name, svc.Description)
|
||||
for _, ep := range svc.Endpoints {
|
||||
fmt.Printf(" %s: %s\n", ep.Name, ep.Description)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if !confirmGenerate() {
|
||||
fmt.Println(" Cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println(" Generating code...")
|
||||
if err := generate.Generate(ctx, ".", design, provider, apiKey, ""); err != nil {
|
||||
return fmt.Errorf("generate failed: %w", err)
|
||||
}
|
||||
|
||||
for _, svc := range design.Services {
|
||||
fmt.Printf(" \033[32m✓\033[0m %s/\n", svc.Name)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println(" \033[32m✓\033[0m All services generated")
|
||||
fmt.Println()
|
||||
fmt.Println(" Next steps:")
|
||||
fmt.Println(" micro run \033[2m# start all services\033[0m")
|
||||
fmt.Println(" micro chat --provider anthropic \033[2m# talk to them\033[0m")
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func confirmGenerate() bool {
|
||||
fmt.Print(" Generate? [Y/n] ")
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
if !scanner.Scan() {
|
||||
return false
|
||||
}
|
||||
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
|
||||
return answer == "" || answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package template
|
||||
|
||||
var (
|
||||
ApiProtoSRV = `syntax = "proto3";
|
||||
|
||||
package {{dehyphen .Alias}};
|
||||
|
||||
option go_package = "./proto;{{dehyphen .Alias}}";
|
||||
|
||||
service {{title .Alias}} {
|
||||
rpc Health(HealthRequest) returns (HealthResponse) {}
|
||||
rpc Endpoint(EndpointRequest) returns (EndpointResponse) {}
|
||||
}
|
||||
|
||||
message HealthRequest {}
|
||||
|
||||
message HealthResponse {
|
||||
string status = 1;
|
||||
int64 uptime = 2;
|
||||
}
|
||||
|
||||
message EndpointRequest {
|
||||
string method = 1;
|
||||
string path = 2;
|
||||
string body = 3;
|
||||
map<string, string> headers = 4;
|
||||
}
|
||||
|
||||
message EndpointResponse {
|
||||
int32 status_code = 1;
|
||||
string body = 2;
|
||||
map<string, string> headers = 3;
|
||||
}
|
||||
`
|
||||
|
||||
ApiHandlerSRV = `package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
log "go-micro.dev/v5/logger"
|
||||
|
||||
pb "{{.Dir}}/proto"
|
||||
)
|
||||
|
||||
type {{title .Alias}} struct {
|
||||
started time.Time
|
||||
routes map[string]http.HandlerFunc
|
||||
}
|
||||
|
||||
func New() *{{title .Alias}} {
|
||||
h := &{{title .Alias}}{
|
||||
started: time.Now(),
|
||||
routes: make(map[string]http.HandlerFunc),
|
||||
}
|
||||
h.registerRoutes()
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *{{title .Alias}}) registerRoutes() {
|
||||
h.routes["GET /hello"] = func(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
if name == "" {
|
||||
name = "World"
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": fmt.Sprintf("Hello %s", name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Health returns the service health status and uptime.
|
||||
//
|
||||
// @example {}
|
||||
func (h *{{title .Alias}}) Health(ctx context.Context, req *pb.HealthRequest, rsp *pb.HealthResponse) error {
|
||||
rsp.Status = "ok"
|
||||
rsp.Uptime = int64(time.Since(h.started).Seconds())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Endpoint handles proxied HTTP requests. The method and path fields
|
||||
// select the route; body and headers are forwarded.
|
||||
//
|
||||
// @example {"method": "GET", "path": "/hello", "body": "", "headers": {}}
|
||||
func (h *{{title .Alias}}) Endpoint(ctx context.Context, req *pb.EndpointRequest, rsp *pb.EndpointResponse) error {
|
||||
key := fmt.Sprintf("%s %s", req.Method, req.Path)
|
||||
handler, ok := h.routes[key]
|
||||
if !ok {
|
||||
log.Infof("Route not found: %s", key)
|
||||
rsp.StatusCode = 404
|
||||
rsp.Body = ` + "`" + `{"error":"not found"}` + "`" + `
|
||||
return nil
|
||||
}
|
||||
|
||||
rec := &responseRecorder{headers: make(map[string]string), statusCode: 200}
|
||||
fakeReq, _ := http.NewRequestWithContext(ctx, req.Method, req.Path, nil)
|
||||
handler(rec, fakeReq)
|
||||
|
||||
rsp.StatusCode = int32(rec.statusCode)
|
||||
rsp.Body = rec.body
|
||||
rsp.Headers = rec.headers
|
||||
return nil
|
||||
}
|
||||
|
||||
type responseRecorder struct {
|
||||
headers map[string]string
|
||||
body string
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Header() http.Header { return http.Header{} }
|
||||
func (r *responseRecorder) WriteHeader(statusCode int) { r.statusCode = statusCode }
|
||||
func (r *responseRecorder) Write(b []byte) (int, error) {
|
||||
r.body = string(b)
|
||||
return len(b), nil
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -0,0 +1,225 @@
|
||||
package template
|
||||
|
||||
var (
|
||||
CrudProtoSRV = `syntax = "proto3";
|
||||
|
||||
package {{dehyphen .Alias}};
|
||||
|
||||
option go_package = "./proto;{{dehyphen .Alias}}";
|
||||
|
||||
service {{title .Alias}} {
|
||||
rpc Create(CreateRequest) returns (CreateResponse) {}
|
||||
rpc Read(ReadRequest) returns (ReadResponse) {}
|
||||
rpc Update(UpdateRequest) returns (UpdateResponse) {}
|
||||
rpc Delete(DeleteRequest) returns (DeleteResponse) {}
|
||||
rpc List(ListRequest) returns (ListResponse) {}
|
||||
}
|
||||
|
||||
message {{title .Alias}}Record {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string email = 3;
|
||||
string phone = 4;
|
||||
string company = 5;
|
||||
int64 created = 6;
|
||||
int64 updated = 7;
|
||||
}
|
||||
|
||||
message CreateRequest {
|
||||
string name = 1;
|
||||
string email = 2;
|
||||
string phone = 3;
|
||||
string company = 4;
|
||||
}
|
||||
|
||||
message CreateResponse {
|
||||
{{title .Alias}}Record record = 1;
|
||||
}
|
||||
|
||||
message ReadRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message ReadResponse {
|
||||
{{title .Alias}}Record record = 1;
|
||||
}
|
||||
|
||||
message UpdateRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string email = 3;
|
||||
string phone = 4;
|
||||
string company = 5;
|
||||
}
|
||||
|
||||
message UpdateResponse {
|
||||
{{title .Alias}}Record record = 1;
|
||||
}
|
||||
|
||||
message DeleteRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message DeleteResponse {
|
||||
bool deleted = 1;
|
||||
}
|
||||
|
||||
message ListRequest {
|
||||
int64 limit = 1;
|
||||
int64 offset = 2;
|
||||
}
|
||||
|
||||
message ListResponse {
|
||||
repeated {{title .Alias}}Record records = 1;
|
||||
int64 total = 2;
|
||||
}
|
||||
`
|
||||
|
||||
CrudHandlerSRV = `package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
log "go-micro.dev/v5/logger"
|
||||
|
||||
pb "{{.Dir}}/proto"
|
||||
)
|
||||
|
||||
type {{title .Alias}} struct {
|
||||
mu sync.RWMutex
|
||||
records map[string]*pb.{{title .Alias}}Record
|
||||
}
|
||||
|
||||
func New() *{{title .Alias}} {
|
||||
return &{{title .Alias}}{
|
||||
records: make(map[string]*pb.{{title .Alias}}Record),
|
||||
}
|
||||
}
|
||||
|
||||
// Create adds a new record and returns it with a generated ID.
|
||||
//
|
||||
// @example {"name": "Alice Smith", "email": "alice@example.com", "phone": "+1-555-0100", "company": "Acme Inc"}
|
||||
func (h *{{title .Alias}}) Create(ctx context.Context, req *pb.CreateRequest, rsp *pb.CreateResponse) error {
|
||||
log.Infof("Creating record: %s", req.Name)
|
||||
|
||||
now := time.Now().Unix()
|
||||
record := &pb.{{title .Alias}}Record{
|
||||
Id: uuid.New().String(),
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Company: req.Company,
|
||||
Created: now,
|
||||
Updated: now,
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.records[record.Id] = record
|
||||
h.mu.Unlock()
|
||||
|
||||
rsp.Record = record
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read retrieves a record by ID.
|
||||
//
|
||||
// @example {"id": "some-uuid"}
|
||||
func (h *{{title .Alias}}) Read(ctx context.Context, req *pb.ReadRequest, rsp *pb.ReadResponse) error {
|
||||
h.mu.RLock()
|
||||
record, ok := h.records[req.Id]
|
||||
h.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("record %s not found", req.Id)
|
||||
}
|
||||
|
||||
rsp.Record = record
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an existing record. Only non-empty fields are updated.
|
||||
//
|
||||
// @example {"id": "some-uuid", "name": "Alice Johnson", "email": "alice.j@example.com"}
|
||||
func (h *{{title .Alias}}) Update(ctx context.Context, req *pb.UpdateRequest, rsp *pb.UpdateResponse) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
record, ok := h.records[req.Id]
|
||||
if !ok {
|
||||
return fmt.Errorf("record %s not found", req.Id)
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
record.Name = req.Name
|
||||
}
|
||||
if req.Email != "" {
|
||||
record.Email = req.Email
|
||||
}
|
||||
if req.Phone != "" {
|
||||
record.Phone = req.Phone
|
||||
}
|
||||
if req.Company != "" {
|
||||
record.Company = req.Company
|
||||
}
|
||||
record.Updated = time.Now().Unix()
|
||||
|
||||
rsp.Record = record
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a record by ID.
|
||||
//
|
||||
// @example {"id": "some-uuid"}
|
||||
func (h *{{title .Alias}}) Delete(ctx context.Context, req *pb.DeleteRequest, rsp *pb.DeleteResponse) error {
|
||||
h.mu.Lock()
|
||||
_, ok := h.records[req.Id]
|
||||
if ok {
|
||||
delete(h.records, req.Id)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
rsp.Deleted = ok
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all records with optional pagination.
|
||||
//
|
||||
// @example {"limit": 10, "offset": 0}
|
||||
func (h *{{title .Alias}}) List(ctx context.Context, req *pb.ListRequest, rsp *pb.ListResponse) error {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
all := make([]*pb.{{title .Alias}}Record, 0, len(h.records))
|
||||
for _, r := range h.records {
|
||||
all = append(all, r)
|
||||
}
|
||||
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return all[i].Created > all[j].Created
|
||||
})
|
||||
|
||||
rsp.Total = int64(len(all))
|
||||
|
||||
offset := int(req.Offset)
|
||||
if offset > len(all) {
|
||||
offset = len(all)
|
||||
}
|
||||
limit := int(req.Limit)
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
|
||||
rsp.Records = all[offset:end]
|
||||
return nil
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -13,19 +13,24 @@ import (
|
||||
|
||||
type {{title .Alias}} struct{}
|
||||
|
||||
// Return a new handler
|
||||
// Return a new handler.
|
||||
func New() *{{title .Alias}} {
|
||||
return &{{title .Alias}}{}
|
||||
}
|
||||
|
||||
// Call is a single request handler called via client.Call or the generated client code
|
||||
// Call greets a person by name and returns a welcome message.
|
||||
//
|
||||
// @example {"name": "Alice"}
|
||||
func (e *{{title .Alias}}) Call(ctx context.Context, req *pb.Request, rsp *pb.Response) error {
|
||||
log.Info("Received {{title .Alias}}.Call request")
|
||||
rsp.Msg = "Hello " + req.Name
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stream is a server side stream handler called via client.Stream or the generated client code
|
||||
// Stream sends a sequence of numbered responses back to the caller.
|
||||
// Use this for streaming large result sets or real-time updates.
|
||||
//
|
||||
// @example {"count": 5}
|
||||
func (e *{{title .Alias}}) Stream(ctx context.Context, req *pb.StreamingRequest, stream pb.{{title .Alias}}_StreamStream) error {
|
||||
log.Infof("Received {{title .Alias}}.Stream request with count: %d", req.Count)
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@ package template
|
||||
var (
|
||||
GitIgnore = `
|
||||
{{.Alias}}
|
||||
.micro
|
||||
`
|
||||
)
|
||||
|
||||
@@ -3,6 +3,33 @@ package template
|
||||
var (
|
||||
MainSRV = `package main
|
||||
|
||||
import (
|
||||
"{{.Dir}}/handler"
|
||||
pb "{{.Dir}}/proto"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create service
|
||||
service := micro.New("{{lower .Alias}}",
|
||||
mcp.WithMCP(":3001"),
|
||||
)
|
||||
|
||||
// Initialize service
|
||||
service.Init()
|
||||
|
||||
// Register handler
|
||||
pb.Register{{title .Alias}}Handler(service.Server(), handler.New())
|
||||
|
||||
// Run service
|
||||
service.Run()
|
||||
}
|
||||
`
|
||||
|
||||
MainSRVNoMCP = `package main
|
||||
|
||||
import (
|
||||
"{{.Dir}}/handler"
|
||||
pb "{{.Dir}}/proto"
|
||||
|
||||
@@ -27,6 +27,18 @@ test-coverage:
|
||||
go test -v -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
# List MCP tools exposed by this service
|
||||
mcp-tools:
|
||||
micro mcp list
|
||||
|
||||
# Test an MCP tool interactively
|
||||
mcp-test:
|
||||
micro mcp test
|
||||
|
||||
# Start MCP server for Claude Code
|
||||
mcp-serve:
|
||||
micro mcp serve
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/ coverage.out coverage.html
|
||||
@@ -35,14 +47,6 @@ clean:
|
||||
docker:
|
||||
docker build -t {{.Alias}}:latest .
|
||||
|
||||
# Run with Docker Compose
|
||||
docker-up:
|
||||
docker-compose up -d
|
||||
|
||||
# Stop Docker Compose
|
||||
docker-down:
|
||||
docker-compose down
|
||||
|
||||
# Lint code
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
@@ -3,7 +3,7 @@ package template
|
||||
var (
|
||||
Module = `module {{.Dir}}
|
||||
|
||||
go 1.18
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
go-micro.dev/v5 latest
|
||||
|
||||
@@ -17,18 +17,22 @@ message Message {
|
||||
}
|
||||
|
||||
message Request {
|
||||
// Name of the person to greet
|
||||
string name = 1;
|
||||
}
|
||||
|
||||
message Response {
|
||||
// Greeting message
|
||||
string msg = 1;
|
||||
}
|
||||
|
||||
message StreamingRequest {
|
||||
// Number of responses to stream back
|
||||
int64 count = 1;
|
||||
}
|
||||
|
||||
message StreamingResponse {
|
||||
// Current sequence number in the stream
|
||||
int64 count = 1;
|
||||
}
|
||||
`
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package template
|
||||
|
||||
var (
|
||||
PubsubProtoSRV = `syntax = "proto3";
|
||||
|
||||
package {{dehyphen .Alias}};
|
||||
|
||||
option go_package = "./proto;{{dehyphen .Alias}}";
|
||||
|
||||
service {{title .Alias}} {
|
||||
rpc Publish(PublishRequest) returns (PublishResponse) {}
|
||||
rpc Stats(StatsRequest) returns (StatsResponse) {}
|
||||
}
|
||||
|
||||
message Event {
|
||||
string id = 1;
|
||||
string type = 2;
|
||||
string source = 3;
|
||||
string data = 4;
|
||||
int64 timestamp = 5;
|
||||
}
|
||||
|
||||
message PublishRequest {
|
||||
string type = 1;
|
||||
string data = 2;
|
||||
}
|
||||
|
||||
message PublishResponse {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message StatsRequest {}
|
||||
|
||||
message StatsResponse {
|
||||
int64 published = 1;
|
||||
int64 received = 2;
|
||||
}
|
||||
`
|
||||
|
||||
PubsubHandlerSRV = `package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-micro.dev/v5/broker"
|
||||
log "go-micro.dev/v5/logger"
|
||||
|
||||
pb "{{.Dir}}/proto"
|
||||
)
|
||||
|
||||
const Topic = "{{lower .Alias}}.events"
|
||||
|
||||
type {{title .Alias}} struct {
|
||||
broker broker.Broker
|
||||
published atomic.Int64
|
||||
received atomic.Int64
|
||||
}
|
||||
|
||||
func New(b broker.Broker) *{{title .Alias}} {
|
||||
return &{{title .Alias}}{broker: b}
|
||||
}
|
||||
|
||||
// Publish sends an event to the message broker.
|
||||
//
|
||||
// @example {"type": "user.created", "data": "{\"id\": \"123\", \"name\": \"Alice\"}"}
|
||||
func (h *{{title .Alias}}) Publish(ctx context.Context, req *pb.PublishRequest, rsp *pb.PublishResponse) error {
|
||||
event := &pb.Event{
|
||||
Id: uuid.New().String(),
|
||||
Type: req.Type,
|
||||
Source: "{{lower .Alias}}",
|
||||
Data: req.Data,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
|
||||
body, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.broker.Publish(Topic, &broker.Message{Body: body}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.published.Add(1)
|
||||
log.Infof("Published event %s type=%s", event.Id, event.Type)
|
||||
|
||||
rsp.Id = event.Id
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats returns the number of events published and received.
|
||||
//
|
||||
// @example {}
|
||||
func (h *{{title .Alias}}) Stats(ctx context.Context, req *pb.StatsRequest, rsp *pb.StatsResponse) error {
|
||||
rsp.Published = h.published.Load()
|
||||
rsp.Received = h.received.Load()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscribe sets up a subscription to the event topic. Call this
|
||||
// after the service has started.
|
||||
func (h *{{title .Alias}}) Subscribe() error {
|
||||
_, err := h.broker.Subscribe(Topic, func(p broker.Event) error {
|
||||
h.received.Add(1)
|
||||
|
||||
var event pb.Event
|
||||
if err := json.Unmarshal(p.Message().Body, &event); err != nil {
|
||||
log.Errorf("Failed to unmarshal event: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Infof("Received event %s type=%s data=%s", event.Id, event.Type, event.Data)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
`
|
||||
|
||||
PubsubMainSRV = `package main
|
||||
|
||||
import (
|
||||
"{{.Dir}}/handler"
|
||||
pb "{{.Dir}}/proto"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
log "go-micro.dev/v5/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.New("{{lower .Alias}}",
|
||||
mcp.WithMCP(":3001"),
|
||||
)
|
||||
|
||||
service.Init()
|
||||
|
||||
h := handler.New(service.Options().Broker)
|
||||
pb.Register{{title .Alias}}Handler(service.Server(), h)
|
||||
|
||||
// Subscribe to events after service starts
|
||||
go func() {
|
||||
if err := h.Subscribe(); err != nil {
|
||||
log.Fatalf("Failed to subscribe: %v", err)
|
||||
}
|
||||
log.Info("Subscribed to ", handler.Topic)
|
||||
}()
|
||||
|
||||
service.Run()
|
||||
}
|
||||
`
|
||||
|
||||
PubsubMainSRVNoMCP = `package main
|
||||
|
||||
import (
|
||||
"{{.Dir}}/handler"
|
||||
pb "{{.Dir}}/proto"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
log "go-micro.dev/v5/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.New("{{lower .Alias}}")
|
||||
|
||||
service.Init()
|
||||
|
||||
h := handler.New(service.Options().Broker)
|
||||
pb.Register{{title .Alias}}Handler(service.Server(), h)
|
||||
|
||||
go func() {
|
||||
if err := h.Subscribe(); err != nil {
|
||||
log.Fatalf("Failed to subscribe: %v", err)
|
||||
}
|
||||
log.Info("Subscribed to ", handler.Topic)
|
||||
}()
|
||||
|
||||
service.Run()
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -3,28 +3,88 @@ package template
|
||||
var (
|
||||
Readme = `# {{title .Alias}} Service
|
||||
|
||||
This is the {{title .Alias}} service
|
||||
|
||||
Generated with
|
||||
|
||||
` + "```" +
|
||||
`
|
||||
` + "```" + `
|
||||
micro new {{.Alias}}
|
||||
` + "```" + `
|
||||
|
||||
## Usage
|
||||
## Getting Started
|
||||
|
||||
Generate the proto code
|
||||
Generate the proto code:
|
||||
|
||||
` + "```" +
|
||||
`
|
||||
` + "```bash" + `
|
||||
make proto
|
||||
` + "```" + `
|
||||
|
||||
Run the service
|
||||
Run the service:
|
||||
|
||||
` + "```" +
|
||||
`
|
||||
micro run .
|
||||
` + "```"
|
||||
` + "```bash" + `
|
||||
go run .
|
||||
` + "```" + `
|
||||
|
||||
## MCP & AI Agents
|
||||
|
||||
This service is MCP-enabled by default. When running, AI agents can discover
|
||||
and call your service endpoints automatically.
|
||||
|
||||
**MCP tools endpoint:** http://localhost:3001/mcp/tools
|
||||
|
||||
### Test with curl
|
||||
|
||||
` + "```bash" + `
|
||||
# List available tools
|
||||
curl http://localhost:3001/mcp/tools | jq
|
||||
|
||||
# Call the service via MCP
|
||||
curl -X POST http://localhost:3001/mcp/call \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"tool": "{{lower .Alias}}.{{title .Alias}}.Call", "arguments": {"name": "Alice"}}'
|
||||
` + "```" + `
|
||||
|
||||
### Use with Claude Code
|
||||
|
||||
` + "```bash" + `
|
||||
# Start MCP server for Claude Code
|
||||
micro mcp serve
|
||||
` + "```" + `
|
||||
|
||||
Or add to your Claude Code config:
|
||||
|
||||
` + "```json" + `
|
||||
{
|
||||
"mcpServers": {
|
||||
"{{lower .Alias}}": {
|
||||
"command": "micro",
|
||||
"args": ["mcp", "serve"]
|
||||
}
|
||||
}
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
### Writing Good Tool Descriptions
|
||||
|
||||
AI agents work best when your handler methods have clear doc comments:
|
||||
|
||||
` + "```go" + `
|
||||
// CreateUser registers a new user account with the given email and name.
|
||||
// Returns the created user with their assigned ID.
|
||||
//
|
||||
// @example {"email": "alice@example.com", "name": "Alice Smith"}
|
||||
func (s *Users) CreateUser(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
|
||||
// ...
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
See the [tool descriptions guide](https://go-micro.dev/docs/guides/tool-descriptions) for more tips.
|
||||
|
||||
## Development
|
||||
|
||||
` + "```bash" + `
|
||||
make proto # Regenerate proto code
|
||||
make build # Build binary
|
||||
make test # Run tests
|
||||
make dev # Run with hot reload (requires air)
|
||||
` + "```" + `
|
||||
`
|
||||
)
|
||||
|
||||
@@ -15,9 +15,30 @@ import (
|
||||
"github.com/stretchr/objx"
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/metadata"
|
||||
"go-micro.dev/v5/registry"
|
||||
)
|
||||
|
||||
// AddMetadataToContext parses metadata strings in the format "Key:Value" and adds them to the context
|
||||
func AddMetadataToContext(ctx context.Context, metadataStrings []string) context.Context {
|
||||
if len(metadataStrings) == 0 {
|
||||
return ctx
|
||||
}
|
||||
|
||||
md := make(metadata.Metadata)
|
||||
for _, m := range metadataStrings {
|
||||
parts := strings.SplitN(m, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
md[key] = value
|
||||
}
|
||||
|
||||
return metadata.MergeContext(ctx, md, true)
|
||||
}
|
||||
|
||||
// LookupService queries the service for a service with the given alias. If
|
||||
// no services are found for a given alias, the registry will return nil and
|
||||
// the error will also be nil. An error is only returned if there was an issue
|
||||
@@ -132,17 +153,27 @@ func CallService(srv *registry.Service, args []string) error {
|
||||
return fmt.Errorf("Endpoint %v not found for service %v", endpoint, srv.Name)
|
||||
}
|
||||
|
||||
// parse the flags
|
||||
// create a context for the call
|
||||
callCtx := context.TODO()
|
||||
|
||||
// parse out --header or --metadata flags before parsing request body
|
||||
// Note: This is for dynamic service calls (e.g., 'micro helloworld call --header X:Y').
|
||||
// Direct 'micro call' commands are handled in cli.go.
|
||||
if headerFlags, ok := flags["header"]; ok {
|
||||
callCtx = AddMetadataToContext(callCtx, headerFlags)
|
||||
delete(flags, "header")
|
||||
}
|
||||
if metadataFlags, ok := flags["metadata"]; ok {
|
||||
callCtx = AddMetadataToContext(callCtx, metadataFlags)
|
||||
delete(flags, "metadata")
|
||||
}
|
||||
|
||||
// parse the flags into request body
|
||||
body, err := FlagsToRequest(flags, ep.Request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create a context for the call based on the cli context
|
||||
callCtx := context.TODO()
|
||||
|
||||
// TODO: parse out --header or --metadata
|
||||
|
||||
// construct and execute the request using the json content type
|
||||
req := client.DefaultClient.NewRequest(srv.Name, endpoint, body, client.WithContentType("application/json"))
|
||||
var rsp json.RawMessage
|
||||
@@ -387,7 +418,7 @@ func FlagsToRequest(flags map[string][]string, req *registry.Value) (map[string]
|
||||
// so we do that here
|
||||
if strings.Contains(key, "-") {
|
||||
parts := strings.Split(key, "-")
|
||||
for i, _ := range parts {
|
||||
for i := range parts {
|
||||
pToCreate := strings.Join(parts[0:i], ".")
|
||||
if i > 0 && i < len(parts) && !result.Has(pToCreate) {
|
||||
result.Set(pToCreate, map[string]interface{}{})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"go-micro.dev/v5/metadata"
|
||||
goregistry "go-micro.dev/v5/registry"
|
||||
)
|
||||
|
||||
@@ -377,3 +379,75 @@ func TestDynamicFlagParsing(t *testing.T) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMetadataToContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
metadataStrs []string
|
||||
expectedKeys []string
|
||||
expectedValues []string
|
||||
}{
|
||||
{
|
||||
name: "Single metadata",
|
||||
metadataStrs: []string{"Key1:Value1"},
|
||||
expectedKeys: []string{"Key1"},
|
||||
expectedValues: []string{"Value1"},
|
||||
},
|
||||
{
|
||||
name: "Multiple metadata",
|
||||
metadataStrs: []string{"Key1:Value1", "Key2:Value2"},
|
||||
expectedKeys: []string{"Key1", "Key2"},
|
||||
expectedValues: []string{"Value1", "Value2"},
|
||||
},
|
||||
{
|
||||
name: "Metadata with spaces",
|
||||
metadataStrs: []string{"Key1: Value1 ", " Key2 : Value2"},
|
||||
expectedKeys: []string{"Key1", "Key2"},
|
||||
expectedValues: []string{"Value1", "Value2"},
|
||||
},
|
||||
{
|
||||
name: "Metadata with colon in value",
|
||||
metadataStrs: []string{"Authorization:Bearer token:123"},
|
||||
expectedKeys: []string{"Authorization"},
|
||||
expectedValues: []string{"Bearer token:123"},
|
||||
},
|
||||
{
|
||||
name: "Empty metadata",
|
||||
metadataStrs: []string{},
|
||||
expectedKeys: []string{},
|
||||
expectedValues: []string{},
|
||||
},
|
||||
{
|
||||
name: "Invalid metadata format",
|
||||
metadataStrs: []string{"InvalidFormat"},
|
||||
expectedKeys: []string{},
|
||||
expectedValues: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx = AddMetadataToContext(ctx, tt.metadataStrs)
|
||||
|
||||
md, ok := metadata.FromContext(ctx)
|
||||
if len(tt.expectedKeys) == 0 && !ok {
|
||||
return // Expected no metadata
|
||||
}
|
||||
|
||||
if !ok && len(tt.expectedKeys) > 0 {
|
||||
t.Fatal("Expected metadata in context but got none")
|
||||
}
|
||||
|
||||
for i, key := range tt.expectedKeys {
|
||||
value, found := md.Get(key)
|
||||
if !found {
|
||||
t.Fatalf("Expected key %s not found in metadata", key)
|
||||
}
|
||||
if value != tt.expectedValues[i] {
|
||||
t.Fatalf("Expected value %s for key %s, got %s", tt.expectedValues[i], key, value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Package flow implements the 'micro flow' command for event-driven
|
||||
// LLM orchestration of microservices.
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
aiflow "go-micro.dev/v5/flow"
|
||||
"go-micro.dev/v5/broker"
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/cmd"
|
||||
"go-micro.dev/v5/registry"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "flow",
|
||||
Usage: "Event-driven LLM orchestration",
|
||||
Description: `Run flows that subscribe to broker events and use an LLM to
|
||||
orchestrate service calls in response.
|
||||
|
||||
Examples:
|
||||
# Run a flow that reacts to user creation events
|
||||
micro flow run --trigger events.user.created \
|
||||
--prompt "New user: {{.Data}}. Send welcome email." \
|
||||
--provider anthropic
|
||||
|
||||
# Run a one-shot flow with inline data
|
||||
micro flow exec --prompt "List all users and count them" \
|
||||
--provider anthropic
|
||||
|
||||
# Run a flow with a specific model
|
||||
micro flow exec --prompt "Create a test user" \
|
||||
--provider atlascloud --model deepseek-ai/DeepSeek-V3-0324`,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "run",
|
||||
Usage: "Start a flow that listens to broker events",
|
||||
Flags: flowFlags(),
|
||||
Action: func(c *cli.Context) error {
|
||||
return runFlow(c, false)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "exec",
|
||||
Usage: "Execute a flow once with inline data",
|
||||
Flags: append(flowFlags(), &cli.StringFlag{
|
||||
Name: "data",
|
||||
Usage: "Input data for the flow (default: reads from --prompt only)",
|
||||
}),
|
||||
Action: func(c *cli.Context) error {
|
||||
return runFlow(c, true)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func flowFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.StringFlag{Name: "trigger", Usage: "Broker topic to subscribe to", EnvVars: []string{"MICRO_FLOW_TRIGGER"}},
|
||||
&cli.StringFlag{Name: "prompt", Usage: "Prompt template (use {{.Data}} for event data)", EnvVars: []string{"MICRO_FLOW_PROMPT"}},
|
||||
&cli.StringFlag{Name: "provider", Usage: "AI provider", Value: "openai", EnvVars: []string{"MICRO_AI_PROVIDER"}},
|
||||
&cli.StringFlag{Name: "api_key", Usage: "API key", EnvVars: []string{"MICRO_AI_API_KEY"}},
|
||||
&cli.StringFlag{Name: "model", Usage: "Model name", EnvVars: []string{"MICRO_AI_MODEL"}},
|
||||
&cli.StringFlag{Name: "base_url", Usage: "Provider base URL", EnvVars: []string{"MICRO_AI_BASE_URL"}},
|
||||
&cli.StringFlag{Name: "name", Usage: "Flow name", Value: "default"},
|
||||
}
|
||||
}
|
||||
|
||||
func runFlow(c *cli.Context, oneShot bool) error {
|
||||
prompt := c.String("prompt")
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("--prompt is required")
|
||||
}
|
||||
|
||||
provider := c.String("provider")
|
||||
apiKey := c.String("api_key")
|
||||
if apiKey == "" {
|
||||
apiKey = fallbackKey(provider)
|
||||
}
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("no API key; set --api_key or the provider's env var")
|
||||
}
|
||||
|
||||
opts := []aiflow.Option{
|
||||
aiflow.Prompt(prompt),
|
||||
aiflow.Provider(provider),
|
||||
aiflow.APIKey(apiKey),
|
||||
}
|
||||
if v := c.String("trigger"); v != "" {
|
||||
opts = append(opts, aiflow.Trigger(v))
|
||||
}
|
||||
if v := c.String("model"); v != "" {
|
||||
opts = append(opts, aiflow.Model(v))
|
||||
}
|
||||
if v := c.String("base_url"); v != "" {
|
||||
opts = append(opts, aiflow.BaseURL(v))
|
||||
}
|
||||
|
||||
opts = append(opts, aiflow.OnResult(func(r aiflow.Result) {
|
||||
out, _ := json.MarshalIndent(r, "", " ")
|
||||
fmt.Println(string(out))
|
||||
}))
|
||||
|
||||
f := aiflow.New(c.String("name"), opts...)
|
||||
|
||||
reg := registry.DefaultRegistry
|
||||
br := broker.DefaultBroker
|
||||
cl := client.DefaultClient
|
||||
|
||||
if err := br.Connect(); err != nil {
|
||||
return fmt.Errorf("broker connect: %w", err)
|
||||
}
|
||||
|
||||
if err := f.Register(reg, br, cl); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if oneShot {
|
||||
data := c.String("data")
|
||||
if data == "" {
|
||||
data = prompt
|
||||
}
|
||||
return f.Execute(context.Background(), data)
|
||||
}
|
||||
|
||||
if c.String("trigger") == "" {
|
||||
return fmt.Errorf("--trigger is required for 'flow run' (use 'flow exec' for one-shot)")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mmicro flow\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Flow \033[36m%s\033[0m\n", f.Name())
|
||||
fmt.Printf(" Topic \033[36m%s\033[0m\n", c.String("trigger"))
|
||||
fmt.Printf(" Provider \033[36m%s\033[0m\n", provider)
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[2mListening for events. Ctrl-C to stop.\033[0m")
|
||||
fmt.Println()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
|
||||
fmt.Printf("\nStopped. %d executions recorded.\n", len(f.Results()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func fallbackKey(provider string) string {
|
||||
envMap := map[string]string{
|
||||
"anthropic": "ANTHROPIC_API_KEY",
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"gemini": "GEMINI_API_KEY",
|
||||
"groq": "GROQ_API_KEY",
|
||||
"mistral": "MISTRAL_API_KEY",
|
||||
"together": "TOGETHER_API_KEY",
|
||||
"atlascloud": "ATLASCLOUD_API_KEY",
|
||||
}
|
||||
if env, ok := envMap[provider]; ok {
|
||||
return os.Getenv(env)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -4,10 +4,14 @@ import (
|
||||
"embed"
|
||||
"go-micro.dev/v5/cmd"
|
||||
|
||||
_ "go-micro.dev/v5/cmd/micro/api"
|
||||
_ "go-micro.dev/v5/cmd/micro/chat"
|
||||
_ "go-micro.dev/v5/cmd/micro/cli"
|
||||
_ "go-micro.dev/v5/cmd/micro/cli/build"
|
||||
_ "go-micro.dev/v5/cmd/micro/cli/deploy"
|
||||
_ "go-micro.dev/v5/cmd/micro/flow"
|
||||
_ "go-micro.dev/v5/cmd/micro/mcp"
|
||||
_ "go-micro.dev/v5/cmd/micro/resource"
|
||||
_ "go-micro.dev/v5/cmd/micro/run"
|
||||
"go-micro.dev/v5/cmd/micro/server"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
# MCP CLI Command Examples
|
||||
|
||||
This document provides examples of using the `micro mcp` commands for AI agent integration.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [List Available Tools](#list-available-tools)
|
||||
- [Test a Tool](#test-a-tool)
|
||||
- [Generate Documentation](#generate-documentation)
|
||||
- [Export to Different Formats](#export-to-different-formats)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need at least one microservice running with the go-micro framework. The service will automatically be discovered via the registry (mdns by default).
|
||||
|
||||
Example service:
|
||||
```bash
|
||||
cd examples/mcp/hello
|
||||
go run main.go
|
||||
```
|
||||
|
||||
## List Available Tools
|
||||
|
||||
### Human-readable list
|
||||
```bash
|
||||
micro mcp list
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Available MCP Tools:
|
||||
|
||||
Service: greeter
|
||||
• greeter.Greeter.SayHello
|
||||
|
||||
Total: 1 tools
|
||||
```
|
||||
|
||||
### JSON output
|
||||
```bash
|
||||
micro mcp list --json
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"count": 1,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Call SayHello on greeter service",
|
||||
"endpoint": "Greeter.SayHello",
|
||||
"name": "greeter.Greeter.SayHello",
|
||||
"service": "greeter"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Test a Tool
|
||||
|
||||
### Basic test
|
||||
```bash
|
||||
micro mcp test greeter.Greeter.SayHello '{"name": "Alice"}'
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Testing tool: greeter.Greeter.SayHello
|
||||
Service: greeter
|
||||
Endpoint: Greeter.SayHello
|
||||
Input: {"name": "Alice"}
|
||||
|
||||
✅ Call successful!
|
||||
|
||||
Response:
|
||||
{
|
||||
"message": "Hello Alice!"
|
||||
}
|
||||
```
|
||||
|
||||
### Test with default empty input
|
||||
```bash
|
||||
micro mcp test greeter.Greeter.SayHello
|
||||
```
|
||||
|
||||
This will call the tool with an empty JSON object `{}`.
|
||||
|
||||
## Generate Documentation
|
||||
|
||||
### Markdown documentation (stdout)
|
||||
```bash
|
||||
micro mcp docs
|
||||
```
|
||||
|
||||
Output:
|
||||
```markdown
|
||||
# MCP Tools Documentation
|
||||
|
||||
Generated: 2026-02-13 14:30:00
|
||||
|
||||
Total Tools: 1
|
||||
|
||||
## Service: greeter
|
||||
|
||||
### greeter.Greeter.SayHello
|
||||
|
||||
**Description:** Greets a person by name. Returns a friendly greeting message.
|
||||
|
||||
**Example Input:**
|
||||
\`\`\`json
|
||||
{"name": "Alice"}
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
### Markdown documentation (save to file)
|
||||
```bash
|
||||
micro mcp docs --output mcp-tools.md
|
||||
```
|
||||
|
||||
This creates a `mcp-tools.md` file with the documentation.
|
||||
|
||||
### JSON documentation
|
||||
```bash
|
||||
micro mcp docs --format json
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"count": 1,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Greets a person by name. Returns a friendly greeting message.",
|
||||
"endpoint": "Greeter.SayHello",
|
||||
"example": "{\"name\": \"Alice\"}",
|
||||
"metadata": {
|
||||
"description": "Greets a person by name. Returns a friendly greeting message.",
|
||||
"example": "{\"name\": \"Alice\"}"
|
||||
},
|
||||
"name": "greeter.Greeter.SayHello",
|
||||
"scopes": null,
|
||||
"service": "greeter"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### JSON documentation (save to file)
|
||||
```bash
|
||||
micro mcp docs --format json --output tools.json
|
||||
```
|
||||
|
||||
## Export to Different Formats
|
||||
|
||||
### Export to LangChain (Python)
|
||||
|
||||
Generate Python code with LangChain tool definitions:
|
||||
|
||||
```bash
|
||||
micro mcp export langchain
|
||||
```
|
||||
|
||||
Output:
|
||||
```python
|
||||
# LangChain Tools for Go Micro Services
|
||||
# Auto-generated from MCP service discovery
|
||||
|
||||
from langchain.tools import Tool
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Configure your MCP gateway endpoint
|
||||
MCP_GATEWAY_URL = 'http://localhost:3000/mcp'
|
||||
|
||||
def call_mcp_tool(tool_name, arguments):
|
||||
"""Call an MCP tool via HTTP gateway"""
|
||||
response = requests.post(
|
||||
f'{MCP_GATEWAY_URL}/call',
|
||||
json={'name': tool_name, 'arguments': arguments}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# Define tools
|
||||
tools = []
|
||||
|
||||
def greeter_Greeter_SayHello(arguments: str) -> str:
|
||||
"""Greets a person by name. Returns a friendly greeting message."""
|
||||
args = json.loads(arguments) if isinstance(arguments, str) else arguments
|
||||
return json.dumps(call_mcp_tool('greeter.Greeter.SayHello', args))
|
||||
|
||||
tools.append(Tool(
|
||||
name='greeter.Greeter.SayHello',
|
||||
func=greeter_Greeter_SayHello,
|
||||
description='Greets a person by name. Returns a friendly greeting message.'
|
||||
))
|
||||
|
||||
# Example usage:
|
||||
# from langchain.agents import initialize_agent, AgentType
|
||||
# from langchain.llms import OpenAI
|
||||
#
|
||||
# llm = OpenAI(temperature=0)
|
||||
# agent = initialize_agent(tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION)
|
||||
# agent.run('Your query here')
|
||||
```
|
||||
|
||||
Save to file:
|
||||
```bash
|
||||
micro mcp export langchain --output langchain_tools.py
|
||||
```
|
||||
|
||||
### Export to OpenAPI 3.0
|
||||
|
||||
Generate an OpenAPI specification:
|
||||
|
||||
```bash
|
||||
micro mcp export openapi
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"scheme": "bearer",
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"description": "Auto-generated OpenAPI spec from MCP service discovery",
|
||||
"title": "Go Micro MCP Services",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"openapi": "3.0.0",
|
||||
"paths": {
|
||||
"/mcp/call/greeter/Greeter/SayHello": {
|
||||
"post": {
|
||||
"description": "Greets a person by name. Returns a friendly greeting message.",
|
||||
"operationId": "greeter_Greeter_SayHello",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
}
|
||||
},
|
||||
"summary": "greeter.Greeter.SayHello"
|
||||
}
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"description": "MCP Gateway",
|
||||
"url": "http://localhost:3000"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Save to file:
|
||||
```bash
|
||||
micro mcp export openapi --output openapi.json
|
||||
```
|
||||
|
||||
### Export to raw JSON
|
||||
|
||||
Export raw tool definitions:
|
||||
|
||||
```bash
|
||||
micro mcp export json
|
||||
```
|
||||
|
||||
This is similar to `micro mcp docs --format json` but specifically for export purposes.
|
||||
|
||||
Save to file:
|
||||
```bash
|
||||
micro mcp export json --output tools.json
|
||||
```
|
||||
|
||||
## Using with Different Registries
|
||||
|
||||
By default, the commands use mdns registry. You can specify a different registry:
|
||||
|
||||
```bash
|
||||
# Using consul
|
||||
micro mcp list --registry consul --registry_address consul:8500
|
||||
|
||||
# Using etcd
|
||||
micro mcp list --registry etcd --registry_address etcd:2379
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Using LangChain Export with Claude
|
||||
|
||||
1. Export your tools to LangChain format:
|
||||
```bash
|
||||
micro mcp export langchain --output my_tools.py
|
||||
```
|
||||
|
||||
2. Use in your Python agent:
|
||||
```python
|
||||
from my_tools import tools
|
||||
from langchain.agents import initialize_agent, AgentType
|
||||
from langchain.chat_models import ChatAnthropic
|
||||
|
||||
llm = ChatAnthropic(model="claude-3-sonnet-20240229")
|
||||
agent = initialize_agent(tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION)
|
||||
|
||||
result = agent.run("Greet Alice")
|
||||
print(result)
|
||||
```
|
||||
|
||||
### Using OpenAPI Export with GPT
|
||||
|
||||
1. Export to OpenAPI:
|
||||
```bash
|
||||
micro mcp export openapi --output openapi.json
|
||||
```
|
||||
|
||||
2. Upload to ChatGPT as a custom GPT action or use with OpenAI Assistants API.
|
||||
|
||||
### Documentation for AI Agents
|
||||
|
||||
Generate documentation that AI agents can read to understand your services:
|
||||
|
||||
```bash
|
||||
micro mcp docs --format json --output service-catalog.json
|
||||
```
|
||||
|
||||
This JSON file can be fed to AI agents for service discovery and understanding.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Piping and Processing
|
||||
|
||||
You can pipe the output to other tools:
|
||||
|
||||
```bash
|
||||
# Count tools per service
|
||||
micro mcp list --json | jq '.tools | group_by(.service) | map({service: .[0].service, count: length})'
|
||||
|
||||
# Extract all tool names
|
||||
micro mcp list --json | jq -r '.tools[].name'
|
||||
|
||||
# Filter tools by service
|
||||
micro mcp list --json | jq '.tools[] | select(.service == "greeter")'
|
||||
```
|
||||
|
||||
### Monitoring and CI/CD
|
||||
|
||||
Use these commands in your CI/CD pipeline:
|
||||
|
||||
```bash
|
||||
# Validate all services are discoverable
|
||||
SERVICE_COUNT=$(micro mcp list --json | jq '.count')
|
||||
if [ "$SERVICE_COUNT" -lt 5 ]; then
|
||||
echo "Error: Expected at least 5 services, found $SERVICE_COUNT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate documentation on each deployment
|
||||
micro mcp docs --output docs/mcp-services.md
|
||||
git add docs/mcp-services.md
|
||||
git commit -m "Update MCP service documentation"
|
||||
```
|
||||
|
||||
### Testing in Development
|
||||
|
||||
Create a script to test all your tools:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# test-all-tools.sh
|
||||
|
||||
TOOLS=$(micro mcp list --json | jq -r '.tools[].name')
|
||||
|
||||
for tool in $TOOLS; do
|
||||
echo "Testing $tool..."
|
||||
micro mcp test "$tool" "{}" || echo "Failed: $tool"
|
||||
done
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No tools found
|
||||
|
||||
If `micro mcp list` shows 0 tools:
|
||||
|
||||
1. Verify services are running:
|
||||
```bash
|
||||
ps aux | grep "your-service"
|
||||
```
|
||||
|
||||
2. Check registry (mdns might need time to discover):
|
||||
```bash
|
||||
# Wait a few seconds and try again
|
||||
sleep 3
|
||||
micro mcp list
|
||||
```
|
||||
|
||||
3. Use a different registry if mdns is unreliable:
|
||||
```bash
|
||||
# Start services with consul
|
||||
micro --registry consul server
|
||||
|
||||
# List with consul
|
||||
micro mcp list --registry consul
|
||||
```
|
||||
|
||||
### Service not responding in tests
|
||||
|
||||
If `micro mcp test` fails:
|
||||
|
||||
1. Verify the tool name is correct:
|
||||
```bash
|
||||
micro mcp list
|
||||
```
|
||||
|
||||
2. Check the JSON input format:
|
||||
```bash
|
||||
# Invalid
|
||||
micro mcp test service.Handler.Method '{invalid}'
|
||||
|
||||
# Valid
|
||||
micro mcp test service.Handler.Method '{"key": "value"}'
|
||||
```
|
||||
|
||||
3. Check service logs for errors.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read the [MCP Documentation](../../gateway/mcp/DOCUMENTATION.md)
|
||||
- Try the [MCP Examples](../../examples/mcp/README.md)
|
||||
- Learn about [Tool Scopes and Security](../../gateway/mcp/DOCUMENTATION.md#authentication-and-scopes)
|
||||
- Explore [Agent SDKs](#) (coming soon)
|
||||
+560
-7
@@ -8,10 +8,14 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/cmd"
|
||||
"go-micro.dev/v5/codec/bytes"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
"go-micro.dev/v5/registry"
|
||||
)
|
||||
@@ -129,6 +133,83 @@ Example:
|
||||
},
|
||||
Action: testAction,
|
||||
},
|
||||
{
|
||||
Name: "docs",
|
||||
Usage: "Generate MCP documentation",
|
||||
Description: `Generate documentation for all available MCP tools.
|
||||
|
||||
The documentation includes tool names, descriptions, parameters, and examples
|
||||
extracted from service metadata and Go comments.
|
||||
|
||||
Examples:
|
||||
# Generate markdown documentation
|
||||
micro mcp docs
|
||||
|
||||
# Generate JSON documentation
|
||||
micro mcp docs --format json
|
||||
|
||||
# Save to file
|
||||
micro mcp docs --output mcp-tools.md`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "registry",
|
||||
Usage: "Registry for service discovery",
|
||||
Value: "mdns",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "registry_address",
|
||||
Usage: "Registry address",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "format",
|
||||
Usage: "Output format (markdown, json)",
|
||||
Value: "markdown",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Output file (default: stdout)",
|
||||
},
|
||||
},
|
||||
Action: docsAction,
|
||||
},
|
||||
{
|
||||
Name: "export",
|
||||
Usage: "Export tools to different formats",
|
||||
Description: `Export MCP tools to various agent framework formats.
|
||||
|
||||
Supported formats:
|
||||
- langchain: LangChain tool definitions (Python)
|
||||
- openapi: OpenAPI 3.0 specification
|
||||
- json: Raw JSON tool definitions
|
||||
|
||||
Examples:
|
||||
# Export to LangChain format
|
||||
micro mcp export langchain
|
||||
|
||||
# Export to OpenAPI
|
||||
micro mcp export openapi --output openapi.yaml
|
||||
|
||||
# Export raw JSON
|
||||
micro mcp export json`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "registry",
|
||||
Usage: "Registry for service discovery",
|
||||
Value: "mdns",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "registry_address",
|
||||
Usage: "Registry address",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Output file (default: stdout)",
|
||||
},
|
||||
},
|
||||
Action: exportAction,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -214,7 +295,9 @@ func listAction(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Printf("Available MCP Tools:\n\n")
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mmicro mcp tools\033[0m")
|
||||
fmt.Println()
|
||||
toolCount := 0
|
||||
for _, svc := range services {
|
||||
fullSvcs, err := opts.Registry.GetService(svc.Name)
|
||||
@@ -222,16 +305,16 @@ func listAction(ctx *cli.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("Service: %s\n", svc.Name)
|
||||
fmt.Printf(" \033[1m%s\033[0m\n", svc.Name)
|
||||
for _, ep := range fullSvcs[0].Endpoints {
|
||||
toolName := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
|
||||
fmt.Printf(" • %s\n", toolName)
|
||||
fmt.Printf(" \033[32m●\033[0m %s\n", toolName)
|
||||
toolCount++
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Printf("Total: %d tools\n", toolCount)
|
||||
fmt.Printf(" \033[2m%d tools\033[0m\n\n", toolCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -247,10 +330,480 @@ func testAction(ctx *cli.Context) error {
|
||||
inputJSON = ctx.Args().Get(1)
|
||||
}
|
||||
|
||||
// Validate input JSON
|
||||
var inputData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(inputJSON), &inputData); err != nil {
|
||||
return fmt.Errorf("invalid JSON input: %w", err)
|
||||
}
|
||||
|
||||
// Get registry
|
||||
reg := registry.DefaultRegistry
|
||||
if regName := ctx.String("registry"); regName != "" {
|
||||
if regName != "mdns" {
|
||||
return fmt.Errorf("registry %s not yet supported, use mdns", regName)
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP options
|
||||
opts := mcp.Options{
|
||||
Registry: reg,
|
||||
Context: context.Background(),
|
||||
Logger: log.New(os.Stderr, "", 0),
|
||||
}
|
||||
|
||||
// Parse tool name (format: "service.endpoint" or "service.Handler.Method")
|
||||
parts := parseTool(toolName)
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("invalid tool name format. Expected: service.endpoint or service.Handler.Method")
|
||||
}
|
||||
|
||||
serviceName := parts[0]
|
||||
endpointName := parts[1]
|
||||
|
||||
// If tool name has 3 parts, combine last two for endpoint (e.g., Handler.Method)
|
||||
if len(parts) == 3 {
|
||||
endpointName = parts[1] + "." + parts[2]
|
||||
}
|
||||
|
||||
// Discover the tool from registry
|
||||
services, err := opts.Registry.GetService(serviceName)
|
||||
if err != nil || len(services) == 0 {
|
||||
return fmt.Errorf("service %s not found: %w", serviceName, err)
|
||||
}
|
||||
|
||||
// Find the endpoint
|
||||
var endpoint *registry.Endpoint
|
||||
for _, ep := range services[0].Endpoints {
|
||||
if ep.Name == endpointName {
|
||||
endpoint = ep
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if endpoint == nil {
|
||||
return fmt.Errorf("endpoint %s not found in service %s", endpointName, serviceName)
|
||||
}
|
||||
|
||||
// Display test info
|
||||
fmt.Printf("Testing tool: %s\n", toolName)
|
||||
fmt.Printf("Input: %s\n", inputJSON)
|
||||
fmt.Println("\nResult:")
|
||||
fmt.Println("(Not yet implemented - coming soon)")
|
||||
fmt.Printf("Service: %s\n", serviceName)
|
||||
fmt.Printf("Endpoint: %s\n", endpointName)
|
||||
fmt.Printf("Input: %s\n\n", inputJSON)
|
||||
|
||||
// Convert input to JSON bytes for RPC call
|
||||
inputBytes, err := json.Marshal(inputData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal input: %w", err)
|
||||
}
|
||||
|
||||
// Make RPC call using bytes codec
|
||||
c := opts.Client
|
||||
if c == nil {
|
||||
c = client.DefaultClient
|
||||
}
|
||||
|
||||
// Create request with bytes frame
|
||||
req := c.NewRequest(serviceName, endpointName, &bytes.Frame{Data: inputBytes})
|
||||
|
||||
// Make the call
|
||||
var rsp bytes.Frame
|
||||
if err := c.Call(opts.Context, req, &rsp); err != nil {
|
||||
fmt.Printf("❌ Call failed: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse and display response
|
||||
fmt.Println("✅ Call successful!")
|
||||
fmt.Println("\nResponse:")
|
||||
|
||||
// Try to pretty-print JSON response
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(rsp.Data, &result); err == nil {
|
||||
prettyJSON, err := json.MarshalIndent(result, "", " ")
|
||||
if err == nil {
|
||||
fmt.Println(string(prettyJSON))
|
||||
} else {
|
||||
fmt.Println(string(rsp.Data))
|
||||
}
|
||||
} else {
|
||||
// Not JSON, print raw
|
||||
fmt.Println(string(rsp.Data))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTool splits a tool name into service and endpoint parts
|
||||
func parseTool(toolName string) []string {
|
||||
return strings.Split(toolName, ".")
|
||||
}
|
||||
|
||||
// docsAction generates documentation for MCP tools
|
||||
func docsAction(ctx *cli.Context) error {
|
||||
// Get registry
|
||||
reg := registry.DefaultRegistry
|
||||
|
||||
// Create temporary MCP server to discover tools
|
||||
opts := mcp.Options{
|
||||
Registry: reg,
|
||||
Context: context.Background(),
|
||||
Logger: log.New(os.Stderr, "", 0),
|
||||
}
|
||||
|
||||
// Discover services
|
||||
services, err := opts.Registry.ListServices()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list services: %w", err)
|
||||
}
|
||||
|
||||
format := ctx.String("format")
|
||||
outputFile := ctx.String("output")
|
||||
|
||||
// Prepare output writer
|
||||
writer := os.Stdout
|
||||
if outputFile != "" {
|
||||
f, err := os.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
writer = f
|
||||
}
|
||||
|
||||
// Collect all tools with metadata
|
||||
type ToolDoc struct {
|
||||
Name string `json:"name"`
|
||||
Service string `json:"service"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Description string `json:"description"`
|
||||
Example string `json:"example,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
var tools []ToolDoc
|
||||
for _, svc := range services {
|
||||
fullSvcs, err := opts.Registry.GetService(svc.Name)
|
||||
if err != nil || len(fullSvcs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ep := range fullSvcs[0].Endpoints {
|
||||
toolDoc := ToolDoc{
|
||||
Name: fmt.Sprintf("%s.%s", svc.Name, ep.Name),
|
||||
Service: svc.Name,
|
||||
Endpoint: ep.Name,
|
||||
Description: fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name),
|
||||
Metadata: ep.Metadata,
|
||||
}
|
||||
|
||||
// Extract description from metadata if available
|
||||
if desc, ok := ep.Metadata["description"]; ok {
|
||||
toolDoc.Description = desc
|
||||
}
|
||||
|
||||
// Extract example from metadata if available
|
||||
if example, ok := ep.Metadata["example"]; ok {
|
||||
toolDoc.Example = example
|
||||
}
|
||||
|
||||
// Extract scopes from metadata if available
|
||||
if scopesStr, ok := ep.Metadata["scopes"]; ok && scopesStr != "" {
|
||||
toolDoc.Scopes = strings.Split(scopesStr, ",")
|
||||
}
|
||||
|
||||
tools = append(tools, toolDoc)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate output based on format
|
||||
switch format {
|
||||
case "json":
|
||||
enc := json.NewEncoder(writer)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(map[string]interface{}{
|
||||
"tools": tools,
|
||||
"count": len(tools),
|
||||
})
|
||||
|
||||
case "markdown":
|
||||
fmt.Fprintf(writer, "# MCP Tools Documentation\n\n")
|
||||
fmt.Fprintf(writer, "Generated: %s\n\n", time.Now().Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(writer, "Total Tools: %d\n\n", len(tools))
|
||||
|
||||
|
||||
// Group by service
|
||||
serviceMap := make(map[string][]ToolDoc)
|
||||
for _, tool := range tools {
|
||||
serviceMap[tool.Service] = append(serviceMap[tool.Service], tool)
|
||||
}
|
||||
|
||||
for service, serviceTools := range serviceMap {
|
||||
fmt.Fprintf(writer, "## Service: %s\n\n", service)
|
||||
|
||||
for _, tool := range serviceTools {
|
||||
fmt.Fprintf(writer, "### %s\n\n", tool.Name)
|
||||
fmt.Fprintf(writer, "**Description:** %s\n\n", tool.Description)
|
||||
|
||||
if len(tool.Scopes) > 0 {
|
||||
fmt.Fprintf(writer, "**Required Scopes:** %s\n\n", strings.Join(tool.Scopes, ", "))
|
||||
}
|
||||
|
||||
if tool.Example != "" {
|
||||
fmt.Fprintf(writer, "**Example Input:**\n```json\n%s\n```\n\n", tool.Example)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported format: %s (supported: markdown, json)", format)
|
||||
}
|
||||
}
|
||||
|
||||
// exportAction exports tools to different formats
|
||||
func exportAction(ctx *cli.Context) error {
|
||||
if ctx.Args().Len() < 1 {
|
||||
return fmt.Errorf("usage: micro mcp export <format>\nSupported formats: langchain, openapi, json")
|
||||
}
|
||||
|
||||
exportFormat := ctx.Args().First()
|
||||
|
||||
// Get registry
|
||||
reg := registry.DefaultRegistry
|
||||
|
||||
// Create temporary MCP server to discover tools
|
||||
opts := mcp.Options{
|
||||
Registry: reg,
|
||||
Context: context.Background(),
|
||||
Logger: log.New(os.Stderr, "", 0),
|
||||
}
|
||||
|
||||
// Discover services
|
||||
services, err := opts.Registry.ListServices()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list services: %w", err)
|
||||
}
|
||||
|
||||
outputFile := ctx.String("output")
|
||||
|
||||
// Prepare output writer
|
||||
writer := os.Stdout
|
||||
if outputFile != "" {
|
||||
f, err := os.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
writer = f
|
||||
}
|
||||
|
||||
switch exportFormat {
|
||||
case "langchain":
|
||||
return exportLangChain(writer, services, opts)
|
||||
case "openapi":
|
||||
return exportOpenAPI(writer, services, opts)
|
||||
case "json":
|
||||
return exportJSON(writer, services, opts)
|
||||
default:
|
||||
return fmt.Errorf("unsupported export format: %s\nSupported: langchain, openapi, json", exportFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// exportLangChain exports tools in LangChain format (Python)
|
||||
func exportLangChain(writer *os.File, services []*registry.Service, opts mcp.Options) error {
|
||||
fmt.Fprintf(writer, "# LangChain Tools for Go Micro Services\n")
|
||||
fmt.Fprintf(writer, "# Auto-generated from MCP service discovery\n\n")
|
||||
fmt.Fprintf(writer, "from langchain.tools import Tool\n")
|
||||
fmt.Fprintf(writer, "import requests\nimport json\n\n")
|
||||
fmt.Fprintf(writer, "# Configure your MCP gateway endpoint\n")
|
||||
fmt.Fprintf(writer, "MCP_GATEWAY_URL = 'http://localhost:3000/mcp'\n\n")
|
||||
|
||||
fmt.Fprintf(writer, "def call_mcp_tool(tool_name, arguments):\n")
|
||||
fmt.Fprintf(writer, " \"\"\"Call an MCP tool via HTTP gateway\"\"\"\n")
|
||||
fmt.Fprintf(writer, " response = requests.post(\n")
|
||||
fmt.Fprintf(writer, " f'{MCP_GATEWAY_URL}/call',\n")
|
||||
fmt.Fprintf(writer, " json={'name': tool_name, 'arguments': arguments}\n")
|
||||
fmt.Fprintf(writer, " )\n")
|
||||
fmt.Fprintf(writer, " response.raise_for_status()\n")
|
||||
fmt.Fprintf(writer, " return response.json()\n\n")
|
||||
|
||||
fmt.Fprintf(writer, "# Define tools\n")
|
||||
fmt.Fprintf(writer, "tools = []\n\n")
|
||||
|
||||
for _, svc := range services {
|
||||
fullSvcs, err := opts.Registry.GetService(svc.Name)
|
||||
if err != nil || len(fullSvcs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ep := range fullSvcs[0].Endpoints {
|
||||
toolName := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
|
||||
description := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name)
|
||||
|
||||
if desc, ok := ep.Metadata["description"]; ok {
|
||||
description = desc
|
||||
}
|
||||
|
||||
// Generate Python function name (replace dots with underscores)
|
||||
funcName := strings.ReplaceAll(toolName, ".", "_")
|
||||
|
||||
fmt.Fprintf(writer, "def %s(arguments: str) -> str:\n", funcName)
|
||||
fmt.Fprintf(writer, " \"\"\"% s\"\"\"\n", description)
|
||||
fmt.Fprintf(writer, " args = json.loads(arguments) if isinstance(arguments, str) else arguments\n")
|
||||
fmt.Fprintf(writer, " return json.dumps(call_mcp_tool('%s', args))\n\n", toolName)
|
||||
|
||||
fmt.Fprintf(writer, "tools.append(Tool(\n")
|
||||
fmt.Fprintf(writer, " name='%s',\n", toolName)
|
||||
fmt.Fprintf(writer, " func=%s,\n", funcName)
|
||||
fmt.Fprintf(writer, " description='%s'\n", strings.ReplaceAll(description, "'", "\\'"))
|
||||
fmt.Fprintf(writer, "))\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(writer, "# Example usage:\n")
|
||||
fmt.Fprintf(writer, "# from langchain.agents import initialize_agent, AgentType\n")
|
||||
fmt.Fprintf(writer, "# from langchain.llms import OpenAI\n")
|
||||
fmt.Fprintf(writer, "#\n")
|
||||
fmt.Fprintf(writer, "# llm = OpenAI(temperature=0)\n")
|
||||
fmt.Fprintf(writer, "# agent = initialize_agent(tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION)\n")
|
||||
fmt.Fprintf(writer, "# agent.run('Your query here')\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// exportOpenAPI exports tools in OpenAPI 3.0 format
|
||||
func exportOpenAPI(writer *os.File, services []*registry.Service, opts mcp.Options) error {
|
||||
spec := map[string]interface{}{
|
||||
"openapi": "3.0.0",
|
||||
"info": map[string]interface{}{
|
||||
"title": "Go Micro MCP Services",
|
||||
"description": "Auto-generated OpenAPI spec from MCP service discovery",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
"servers": []map[string]interface{}{
|
||||
{
|
||||
"url": "http://localhost:3000",
|
||||
"description": "MCP Gateway",
|
||||
},
|
||||
},
|
||||
"paths": make(map[string]interface{}),
|
||||
}
|
||||
|
||||
paths := spec["paths"].(map[string]interface{})
|
||||
|
||||
for _, svc := range services {
|
||||
fullSvcs, err := opts.Registry.GetService(svc.Name)
|
||||
if err != nil || len(fullSvcs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ep := range fullSvcs[0].Endpoints {
|
||||
toolName := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
|
||||
path := fmt.Sprintf("/mcp/call/%s", strings.ReplaceAll(toolName, ".", "/"))
|
||||
|
||||
description := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name)
|
||||
if desc, ok := ep.Metadata["description"]; ok {
|
||||
description = desc
|
||||
}
|
||||
|
||||
operation := map[string]interface{}{
|
||||
"summary": toolName,
|
||||
"description": description,
|
||||
"operationId": strings.ReplaceAll(toolName, ".", "_"),
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Successful response",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add scope security if available
|
||||
if scopesStr, ok := ep.Metadata["scopes"]; ok && scopesStr != "" {
|
||||
operation["security"] = []map[string]interface{}{
|
||||
{
|
||||
"bearerAuth": strings.Split(scopesStr, ","),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
paths[path] = map[string]interface{}{
|
||||
"post": operation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add security schemes
|
||||
spec["components"] = map[string]interface{}{
|
||||
"securitySchemes": map[string]interface{}{
|
||||
"bearerAuth": map[string]interface{}{
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(writer)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(spec)
|
||||
}
|
||||
|
||||
// exportJSON exports raw tool definitions as JSON
|
||||
func exportJSON(writer *os.File, services []*registry.Service, opts mcp.Options) error {
|
||||
var tools []map[string]interface{}
|
||||
|
||||
for _, svc := range services {
|
||||
fullSvcs, err := opts.Registry.GetService(svc.Name)
|
||||
if err != nil || len(fullSvcs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ep := range fullSvcs[0].Endpoints {
|
||||
tool := map[string]interface{}{
|
||||
"name": fmt.Sprintf("%s.%s", svc.Name, ep.Name),
|
||||
"service": svc.Name,
|
||||
"endpoint": ep.Name,
|
||||
"metadata": ep.Metadata,
|
||||
}
|
||||
|
||||
if desc, ok := ep.Metadata["description"]; ok {
|
||||
tool["description"] = desc
|
||||
}
|
||||
|
||||
if example, ok := ep.Metadata["example"]; ok {
|
||||
tool["example"] = example
|
||||
}
|
||||
|
||||
if scopesStr, ok := ep.Metadata["scopes"]; ok && scopesStr != "" {
|
||||
tool["scopes"] = strings.Split(scopesStr, ",")
|
||||
}
|
||||
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(writer)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(map[string]interface{}{
|
||||
"tools": tools,
|
||||
"count": len(tools),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTool(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toolName string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "simple two-part tool",
|
||||
toolName: "service.endpoint",
|
||||
want: []string{"service", "endpoint"},
|
||||
},
|
||||
{
|
||||
name: "three-part tool (service.Handler.Method)",
|
||||
toolName: "greeter.Greeter.Hello",
|
||||
want: []string{"greeter", "Greeter", "Hello"},
|
||||
},
|
||||
{
|
||||
name: "single part (invalid)",
|
||||
toolName: "service",
|
||||
want: []string{"service"},
|
||||
},
|
||||
{
|
||||
name: "four-part tool",
|
||||
toolName: "users.Users.Get.All",
|
||||
want: []string{"users", "Users", "Get", "All"},
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
toolName: "",
|
||||
want: []string{""},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseTool(tt.toolName)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("parseTool(%q) = %v, want %v", tt.toolName, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportFormats(t *testing.T) {
|
||||
// Test that export formats are recognized
|
||||
formats := []string{"langchain", "openapi", "json"}
|
||||
|
||||
for _, format := range formats {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
// This is a basic test to ensure the format strings are defined
|
||||
// The actual export functions are tested through integration tests
|
||||
if format == "" {
|
||||
t.Error("export format should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFormats(t *testing.T) {
|
||||
// Test that docs formats are recognized
|
||||
formats := []string{"markdown", "json"}
|
||||
|
||||
for _, format := range formats {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
// This is a basic test to ensure the format strings are defined
|
||||
// The actual docs functions are tested through integration tests
|
||||
if format == "" {
|
||||
t.Error("docs format should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/broker"
|
||||
)
|
||||
|
||||
// brokerCommand exposes the broker interface: publish, subscribe.
|
||||
func brokerCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "broker",
|
||||
Usage: "Publish and subscribe to broker topics",
|
||||
Description: `Interact with the message broker.
|
||||
|
||||
micro broker publish <topic> <message> Publish a message to a topic
|
||||
micro broker subscribe <topic> Stream messages from a topic`,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "publish",
|
||||
Usage: "Publish a message to a topic",
|
||||
ArgsUsage: "<topic> <message>",
|
||||
Action: brokerPublish,
|
||||
},
|
||||
{
|
||||
Name: "subscribe",
|
||||
Usage: "Stream messages from a topic",
|
||||
ArgsUsage: "<topic>",
|
||||
Action: brokerSubscribe,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func brokerPublish(c *cli.Context) error {
|
||||
topic := c.Args().Get(0)
|
||||
msg := c.Args().Get(1)
|
||||
if topic == "" || msg == "" {
|
||||
return fail("usage: micro broker publish <topic> <message>")
|
||||
}
|
||||
|
||||
b := broker.DefaultBroker
|
||||
if err := b.Connect(); err != nil {
|
||||
return fail("broker connect: %v", err)
|
||||
}
|
||||
|
||||
if err := b.Publish(topic, &broker.Message{Body: []byte(msg)}); err != nil {
|
||||
return fail("publish: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Published to %q\n", topic)
|
||||
return nil
|
||||
}
|
||||
|
||||
func brokerSubscribe(c *cli.Context) error {
|
||||
topic := c.Args().First()
|
||||
if topic == "" {
|
||||
return fail("usage: micro broker subscribe <topic>")
|
||||
}
|
||||
|
||||
b := broker.DefaultBroker
|
||||
if err := b.Connect(); err != nil {
|
||||
return fail("broker connect: %v", err)
|
||||
}
|
||||
|
||||
sub, err := b.Subscribe(topic, func(e broker.Event) error {
|
||||
fmt.Printf("%s\n", string(e.Message().Body))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fail("subscribe: %v", err)
|
||||
}
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
fmt.Printf("Subscribed to %q (Ctrl-C to stop)...\n", topic)
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/config"
|
||||
"go-micro.dev/v5/config/source/env"
|
||||
)
|
||||
|
||||
// configCommand exposes the config interface: get, dump.
|
||||
//
|
||||
// The CLI loads configuration from environment variables (the source
|
||||
// that makes sense without a running service). Keys use dot notation,
|
||||
// e.g. "database.host" reads from DATABASE_HOST.
|
||||
func configCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "config",
|
||||
Usage: "Read dynamic configuration (from environment)",
|
||||
Description: `Read dynamic configuration loaded from environment variables.
|
||||
|
||||
Keys use dot notation: "database.host" maps to DATABASE_HOST.
|
||||
|
||||
micro config get <key> Read a config value
|
||||
micro config dump Print the full config as JSON`,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "get",
|
||||
Usage: "Read a config value",
|
||||
ArgsUsage: "<key>",
|
||||
Action: configGet,
|
||||
},
|
||||
{
|
||||
Name: "dump",
|
||||
Usage: "Print the full config",
|
||||
Action: configDump,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() (config.Config, error) {
|
||||
conf, err := config.NewConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := conf.Load(env.NewSource()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
func configGet(c *cli.Context) error {
|
||||
key := c.Args().First()
|
||||
if key == "" {
|
||||
return fail("usage: micro config get <key>")
|
||||
}
|
||||
conf, err := loadConfig()
|
||||
if err != nil {
|
||||
return fail("load config: %v", err)
|
||||
}
|
||||
path := strings.Split(key, ".")
|
||||
val, err := conf.Get(path...)
|
||||
if err != nil {
|
||||
return fail("get %q: %v", key, err)
|
||||
}
|
||||
fmt.Println(string(val.Bytes()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func configDump(c *cli.Context) error {
|
||||
conf, err := loadConfig()
|
||||
if err != nil {
|
||||
return fail("load config: %v", err)
|
||||
}
|
||||
fmt.Println(string(conf.Bytes()))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/registry"
|
||||
)
|
||||
|
||||
// registryCommand exposes the registry interface: list, get, watch.
|
||||
func registryCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "registry",
|
||||
Usage: "Inspect the service registry",
|
||||
Description: `Interact with the service registry.
|
||||
|
||||
micro registry list List all registered services
|
||||
micro registry get <name> Show nodes and endpoints for a service
|
||||
micro registry watch Stream registration events`,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List all registered services",
|
||||
Action: registryList,
|
||||
},
|
||||
{
|
||||
Name: "get",
|
||||
Usage: "Show details for a service",
|
||||
ArgsUsage: "<name>",
|
||||
Action: registryGet,
|
||||
},
|
||||
{
|
||||
Name: "watch",
|
||||
Usage: "Stream registration events",
|
||||
Action: registryWatch,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func registryList(c *cli.Context) error {
|
||||
services, err := registry.ListServices()
|
||||
if err != nil {
|
||||
return fail("list services: %v", err)
|
||||
}
|
||||
out := make([]map[string]any, 0, len(services))
|
||||
for _, s := range services {
|
||||
out = append(out, map[string]any{
|
||||
"name": s.Name,
|
||||
"version": s.Version,
|
||||
})
|
||||
}
|
||||
return printJSON(out)
|
||||
}
|
||||
|
||||
func registryGet(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
return fail("usage: micro registry get <name>")
|
||||
}
|
||||
services, err := registry.GetService(name)
|
||||
if err != nil {
|
||||
return fail("get service %q: %v", name, err)
|
||||
}
|
||||
if len(services) == 0 {
|
||||
return fail("service %q not found", name)
|
||||
}
|
||||
return printJSON(services)
|
||||
}
|
||||
|
||||
func registryWatch(c *cli.Context) error {
|
||||
w, err := registry.Watch()
|
||||
if err != nil {
|
||||
return fail("watch registry: %v", err)
|
||||
}
|
||||
defer w.Stop()
|
||||
|
||||
fmt.Println("Watching registry for changes (Ctrl-C to stop)...")
|
||||
for {
|
||||
res, err := w.Next()
|
||||
if err != nil {
|
||||
return fail("watch: %v", err)
|
||||
}
|
||||
name := ""
|
||||
version := ""
|
||||
if res.Service != nil {
|
||||
name = res.Service.Name
|
||||
version = res.Service.Version
|
||||
}
|
||||
fmt.Printf("%-10s %s %s\n", res.Action, name, version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Package resource provides CLI commands that map directly onto
|
||||
// go-micro's core interfaces — registry, broker, store, and config.
|
||||
//
|
||||
// Each interface gets its own top-level command with verbs that mirror
|
||||
// the interface methods, so the framework's building blocks are
|
||||
// inspectable and manipulable from the terminal:
|
||||
//
|
||||
// micro registry list
|
||||
// micro broker publish <topic> <message>
|
||||
// micro store read <key>
|
||||
// micro config get <key>
|
||||
//
|
||||
// New resource commands are registered by appending to the commands
|
||||
// slice in init — see registry.go, broker.go, store.go, config.go for
|
||||
// the per-interface implementations.
|
||||
package resource
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/cmd"
|
||||
)
|
||||
|
||||
// commandFunc returns a cli.Command for a single core interface. Add a
|
||||
// new one here to expose another package on the CLI.
|
||||
var commandFuncs = []func() *cli.Command{
|
||||
registryCommand,
|
||||
brokerCommand,
|
||||
storeCommand,
|
||||
configCommand,
|
||||
}
|
||||
|
||||
func init() {
|
||||
for _, fn := range commandFuncs {
|
||||
cmd.Register(fn())
|
||||
}
|
||||
}
|
||||
|
||||
// printJSON writes v as indented JSON to stdout.
|
||||
func printJSON(v any) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
// fail returns a cli error with a consistent prefix.
|
||||
func fail(format string, args ...any) error {
|
||||
return cli.Exit(fmt.Sprintf(format, args...), 1)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package resource
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCommandsRegistered(t *testing.T) {
|
||||
// Each command func must return a command with a name and at least
|
||||
// one subcommand, so the resource surface stays consistent.
|
||||
for _, fn := range commandFuncs {
|
||||
c := fn()
|
||||
if c.Name == "" {
|
||||
t.Error("command with empty name")
|
||||
}
|
||||
if len(c.Subcommands) == 0 {
|
||||
t.Errorf("command %q has no subcommands", c.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpectedCommands(t *testing.T) {
|
||||
names := map[string]bool{}
|
||||
for _, fn := range commandFuncs {
|
||||
names[fn().Name] = true
|
||||
}
|
||||
for _, want := range []string{"registry", "broker", "store", "config"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing %q command", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/store"
|
||||
)
|
||||
|
||||
// storeCommand exposes the store interface: read, write, delete, list.
|
||||
func storeCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "store",
|
||||
Usage: "Read and write records in the store",
|
||||
Description: `Interact with the data store.
|
||||
|
||||
micro store list [prefix] List keys (optionally by prefix)
|
||||
micro store read <key> Read a record
|
||||
micro store write <key> <value> Write a record
|
||||
micro store delete <key> Delete a record`,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List keys",
|
||||
ArgsUsage: "[prefix]",
|
||||
Action: storeList,
|
||||
},
|
||||
{
|
||||
Name: "read",
|
||||
Usage: "Read a record",
|
||||
ArgsUsage: "<key>",
|
||||
Action: storeRead,
|
||||
},
|
||||
{
|
||||
Name: "write",
|
||||
Usage: "Write a record",
|
||||
ArgsUsage: "<key> <value>",
|
||||
Action: storeWrite,
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Usage: "Delete a record",
|
||||
ArgsUsage: "<key>",
|
||||
Action: storeDelete,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func storeList(c *cli.Context) error {
|
||||
var opts []store.ListOption
|
||||
if prefix := c.Args().First(); prefix != "" {
|
||||
opts = append(opts, store.ListPrefix(prefix))
|
||||
}
|
||||
keys, err := store.DefaultStore.List(opts...)
|
||||
if err != nil {
|
||||
return fail("list: %v", err)
|
||||
}
|
||||
return printJSON(keys)
|
||||
}
|
||||
|
||||
func storeRead(c *cli.Context) error {
|
||||
key := c.Args().First()
|
||||
if key == "" {
|
||||
return fail("usage: micro store read <key>")
|
||||
}
|
||||
records, err := store.DefaultStore.Read(key)
|
||||
if err != nil {
|
||||
return fail("read %q: %v", key, err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return fail("key %q not found", key)
|
||||
}
|
||||
// Print the raw value for a single record, JSON for multiple.
|
||||
if len(records) == 1 {
|
||||
fmt.Println(string(records[0].Value))
|
||||
return nil
|
||||
}
|
||||
return printJSON(records)
|
||||
}
|
||||
|
||||
func storeWrite(c *cli.Context) error {
|
||||
key := c.Args().Get(0)
|
||||
value := c.Args().Get(1)
|
||||
if key == "" {
|
||||
return fail("usage: micro store write <key> <value>")
|
||||
}
|
||||
rec := &store.Record{Key: key, Value: []byte(value)}
|
||||
if err := store.DefaultStore.Write(rec); err != nil {
|
||||
return fail("write %q: %v", key, err)
|
||||
}
|
||||
fmt.Printf("Wrote %q\n", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeDelete(c *cli.Context) error {
|
||||
key := c.Args().First()
|
||||
if key == "" {
|
||||
return fail("usage: micro store delete <key>")
|
||||
}
|
||||
if err := store.DefaultStore.Delete(key); err != nil {
|
||||
return fail("delete %q: %v", key, err)
|
||||
}
|
||||
fmt.Printf("Deleted %q\n", key)
|
||||
return nil
|
||||
}
|
||||
+348
-39
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -18,10 +19,22 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/ai"
|
||||
clt "go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/cmd"
|
||||
"go-micro.dev/v5/cmd/micro/cli/generate"
|
||||
"go-micro.dev/v5/cmd/micro/run/config"
|
||||
"go-micro.dev/v5/cmd/micro/run/watcher"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/cmd/micro/server"
|
||||
|
||||
_ "go-micro.dev/v5/ai/anthropic"
|
||||
_ "go-micro.dev/v5/ai/atlascloud"
|
||||
_ "go-micro.dev/v5/ai/gemini"
|
||||
_ "go-micro.dev/v5/ai/groq"
|
||||
_ "go-micro.dev/v5/ai/mistral"
|
||||
_ "go-micro.dev/v5/ai/openai"
|
||||
_ "go-micro.dev/v5/ai/together"
|
||||
)
|
||||
|
||||
// Color codes for log output
|
||||
@@ -175,6 +188,11 @@ func waitForHealth(port int, timeout time.Duration) bool {
|
||||
}
|
||||
|
||||
func Run(c *cli.Context) error {
|
||||
// Handle --prompt: generate services first, then run them
|
||||
if prompt := c.String("prompt"); prompt != "" {
|
||||
return runWithPrompt(c, prompt)
|
||||
}
|
||||
|
||||
dir := c.Args().Get(0)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
@@ -358,7 +376,7 @@ func Run(c *cli.Context) error {
|
||||
}
|
||||
|
||||
// Print startup banner
|
||||
printBanner(services, gw, !c.Bool("no-watch"))
|
||||
printBanner(services, gw, !c.Bool("no-watch"), c.String("mcp-address"))
|
||||
|
||||
// Setup signal handling
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
@@ -387,10 +405,38 @@ func Run(c *cli.Context) error {
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Scan for new services added by micro chat or micro new
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-sigCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
newSvcs := discoverNewServices(absDir, servicesByDir, binDir, runDir, logsDir, envVars, len(services))
|
||||
for _, sp := range newSvcs {
|
||||
services = append(services, sp)
|
||||
servicesByDir[sp.dir] = sp
|
||||
watch.AddDir(sp.dir)
|
||||
if err := sp.start(logsDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[%s] %v\n", sp.name, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("\n \033[32m●\033[0m %s \033[2m(new)\033[0m\n", sp.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for signal
|
||||
<-sigCh
|
||||
// Interactive console or wait for signal
|
||||
if c.Bool("detach") {
|
||||
<-sigCh
|
||||
} else {
|
||||
runConsole(sigCh)
|
||||
}
|
||||
fmt.Println("\nShutting down...")
|
||||
|
||||
if watch != nil {
|
||||
@@ -427,67 +473,232 @@ func processRunning(pidStr string) bool {
|
||||
return proc.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
func printBanner(services []*serviceProcess, gw *server.Gateway, watching bool) {
|
||||
func discoverNewServices(baseDir string, known map[string]*serviceProcess, binDir, runDir, logsDir string, envVars []string, colorOffset int) []*serviceProcess {
|
||||
var newSvcs []*serviceProcess
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
svcDir := filepath.Join(baseDir, e.Name())
|
||||
absSvcDir, _ := filepath.Abs(svcDir)
|
||||
if _, exists := known[absSvcDir]; exists {
|
||||
continue
|
||||
}
|
||||
mainFile := filepath.Join(svcDir, "main.go")
|
||||
if _, err := os.Stat(mainFile); err != nil {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
hash := fmt.Sprintf("%x", md5.Sum([]byte(absSvcDir)))[:8]
|
||||
sp := &serviceProcess{
|
||||
name: name,
|
||||
dir: absSvcDir,
|
||||
binPath: filepath.Join(binDir, name+"-"+hash),
|
||||
pidFile: filepath.Join(runDir, name+"-"+hash+".pid"),
|
||||
logFile: filepath.Join(logsDir, name+"-"+hash+".log"),
|
||||
color: colorFor(colorOffset + len(newSvcs)),
|
||||
env: envVars,
|
||||
}
|
||||
newSvcs = append(newSvcs, sp)
|
||||
}
|
||||
return newSvcs
|
||||
}
|
||||
|
||||
func printBanner(services []*serviceProcess, gw *server.Gateway, watching bool, mcpAddr string) {
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mMicro\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
|
||||
fmt.Println(" │ │")
|
||||
fmt.Println(" │ \033[1mMicro\033[0m │")
|
||||
fmt.Println(" │ │")
|
||||
|
||||
if gw != nil {
|
||||
fmt.Printf(" │ Web: \033[36mhttp://localhost%s\033[0m │\n", gw.Addr())
|
||||
fmt.Printf(" │ API: \033[36mhttp://localhost%s/api/{service}/{method}\033[0m │\n", gw.Addr())
|
||||
fmt.Printf(" │ Health: \033[36mhttp://localhost%s/health\033[0m │\n", gw.Addr())
|
||||
fmt.Printf(" Dashboard \033[36mhttp://localhost%s\033[0m\n", gw.Addr())
|
||||
fmt.Printf(" API \033[36mhttp://localhost%s/api/{service}/{method}\033[0m\n", gw.Addr())
|
||||
fmt.Printf(" Agent \033[36mhttp://localhost%s/agent\033[0m\n", gw.Addr())
|
||||
fmt.Printf(" Health \033[36mhttp://localhost%s/health\033[0m\n", gw.Addr())
|
||||
if mcpAddr != "" {
|
||||
fmt.Printf(" MCP \033[36mhttp://localhost%s\033[0m\n", mcpAddr)
|
||||
fmt.Printf(" MCP Tools \033[36mhttp://localhost%s/mcp/tools\033[0m\n", mcpAddr)
|
||||
fmt.Printf(" WebSocket \033[36mws://localhost%s/mcp/ws\033[0m\n", mcpAddr)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println(" │ │")
|
||||
fmt.Println(" │ Services: │")
|
||||
var agents, svcs []*serviceProcess
|
||||
for _, s := range services {
|
||||
if s.name == "agent" {
|
||||
agents = append(agents, s)
|
||||
} else {
|
||||
svcs = append(svcs, s)
|
||||
}
|
||||
}
|
||||
|
||||
for _, svc := range services {
|
||||
status := "\033[32m●\033[0m" // green dot
|
||||
fmt.Println()
|
||||
fmt.Println(" Services:")
|
||||
for _, svc := range svcs {
|
||||
status := "\033[32m●\033[0m"
|
||||
if !svc.running {
|
||||
status = "\033[31m●\033[0m" // red dot
|
||||
status = "\033[31m●\033[0m"
|
||||
}
|
||||
name := svc.name
|
||||
if len(name) > 20 {
|
||||
name = name[:17] + "..."
|
||||
}
|
||||
fmt.Printf(" │ %s %-20s │\n", status, name)
|
||||
fmt.Printf(" %s %s\n", status, svc.name)
|
||||
}
|
||||
|
||||
fmt.Println(" │ │")
|
||||
if len(agents) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println(" Agents:")
|
||||
for _, a := range agents {
|
||||
status := "\033[35m◆\033[0m"
|
||||
if !a.running {
|
||||
status = "\033[31m◆\033[0m"
|
||||
}
|
||||
fmt.Printf(" %s %s\n", status, a.name)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Auth: \033[32menabled\033[0m (admin / micro)")
|
||||
|
||||
if watching {
|
||||
fmt.Println(" │ \033[33mWatching for changes...\033[0m │")
|
||||
fmt.Println(" │ │")
|
||||
fmt.Println(" \033[33mWatching for changes...\033[0m")
|
||||
}
|
||||
|
||||
fmt.Println(" │ Auth: \033[32menabled\033[0m (admin / micro) │")
|
||||
fmt.Println(" │ │")
|
||||
|
||||
if gw != nil && len(services) > 0 {
|
||||
svc := services[0]
|
||||
fmt.Println(" │ Try: │")
|
||||
fmt.Printf(" │ \033[90mcurl -X POST http://localhost%s/api/%s/...\033[0m │\n", gw.Addr(), svc.name)
|
||||
fmt.Println(" │ │")
|
||||
}
|
||||
|
||||
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func runConsole(sigCh chan os.Signal) {
|
||||
// Detect provider and API key from environment
|
||||
provider := os.Getenv("MICRO_AI_PROVIDER")
|
||||
apiKey := os.Getenv("MICRO_AI_API_KEY")
|
||||
if apiKey == "" {
|
||||
for _, env := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY",
|
||||
"ATLASCLOUD_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "TOGETHER_API_KEY"} {
|
||||
if v := os.Getenv(env); v != "" {
|
||||
apiKey = v
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if provider == "" {
|
||||
provider = ai.AutoDetectProvider("")
|
||||
}
|
||||
|
||||
if apiKey == "" {
|
||||
fmt.Println(" \033[2mSet MICRO_AI_API_KEY to enable the interactive console.\033[0m")
|
||||
fmt.Println(" \033[2mCtrl-C to stop.\033[0m")
|
||||
fmt.Println()
|
||||
<-sigCh
|
||||
return
|
||||
}
|
||||
|
||||
// Wait a moment for services to register
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Set up tools and model
|
||||
reg := registry.DefaultRegistry
|
||||
cl := clt.DefaultClient
|
||||
tools := ai.NewTools(reg, ai.ToolClient(cl))
|
||||
|
||||
var modelOpts []ai.Option
|
||||
modelOpts = append(modelOpts, ai.WithAPIKey(apiKey))
|
||||
modelOpts = append(modelOpts, ai.WithToolHandler(tools.Handler()))
|
||||
m := ai.New(provider, modelOpts...)
|
||||
|
||||
hist := ai.NewHistory(50)
|
||||
|
||||
// Build system prompt with service list
|
||||
discovered, _ := tools.Discover()
|
||||
serviceNames := make(map[string]bool)
|
||||
for _, t := range discovered {
|
||||
parts := strings.SplitN(t.OriginalName, ".", 2)
|
||||
if len(parts) == 2 {
|
||||
serviceNames[parts[0]] = true
|
||||
}
|
||||
}
|
||||
var svcList []string
|
||||
for name := range serviceNames {
|
||||
svcList = append(svcList, name)
|
||||
}
|
||||
|
||||
sysPrompt := fmt.Sprintf("You are an agent that orchestrates microservices. Available services: %s. "+
|
||||
"Use the available tools to fulfill requests. When you call a tool, explain what you are doing. "+
|
||||
"If a capability doesn't exist, say so.",
|
||||
strings.Join(svcList, ", "))
|
||||
|
||||
fmt.Printf(" \033[2m%d tools from %d services. Type a message or Ctrl-C to stop.\033[0m\n\n",
|
||||
len(discovered), len(serviceNames))
|
||||
|
||||
// Interactive REPL
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 4096), 1024*1024)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
fmt.Print("\033[1;36m>\033[0m ")
|
||||
if !scanner.Scan() {
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
hist.Add("user", line)
|
||||
resp, err := m.Generate(context.Background(), &ai.Request{
|
||||
Prompt: line,
|
||||
SystemPrompt: sysPrompt,
|
||||
Tools: discovered,
|
||||
Messages: hist.Messages(),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("\033[31merror:\033[0m %v\n\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.Reply != "" {
|
||||
hist.Add("assistant", resp.Reply)
|
||||
fmt.Println(resp.Reply)
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
args, _ := json.Marshal(tc.Input)
|
||||
fmt.Printf(" \033[33m→\033[0m \033[2m%s\033[0m(%s)\n", tc.Name, args)
|
||||
if tc.Result != "" {
|
||||
result := tc.Result
|
||||
if len(result) > 200 {
|
||||
result = result[:200] + "..."
|
||||
}
|
||||
fmt.Printf(" \033[32m←\033[0m \033[2m%s\033[0m\n", result)
|
||||
}
|
||||
}
|
||||
if resp.Answer != "" {
|
||||
hist.Add("assistant", resp.Answer)
|
||||
fmt.Println()
|
||||
fmt.Println(resp.Answer)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-sigCh:
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "run",
|
||||
Usage: "Run services with API gateway and hot reload",
|
||||
Description: `Run discovers and runs services in a directory.
|
||||
Usage: "Development mode: run services with hot reload and API gateway",
|
||||
Description: `Run discovers and runs services in a directory (development mode).
|
||||
|
||||
Starts an HTTP gateway on :8080 providing:
|
||||
- Web dashboard at /
|
||||
- Agent playground at /agent (AI chat with MCP tools)
|
||||
- API explorer at /api
|
||||
- API proxy at /api/{service}/{endpoint}
|
||||
- MCP tools at /api/mcp/tools
|
||||
- MCP tools at /mcp/tools
|
||||
- Health checks at /health
|
||||
|
||||
With a micro.mu or micro.json config file, services start in dependency order.
|
||||
@@ -499,7 +710,8 @@ Examples:
|
||||
micro run --no-gateway # Services only, no HTTP gateway
|
||||
micro run --no-watch # Disable hot reload
|
||||
micro run --env production # Use production environment
|
||||
micro run --mcp-address :3000 # Enable MCP protocol gateway`,
|
||||
micro run --mcp-address :3000 # Enable MCP protocol gateway
|
||||
micro run --prompt "an order system for dropshipping" # Generate and run`,
|
||||
Action: Run,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
@@ -516,6 +728,11 @@ Examples:
|
||||
Name: "no-watch",
|
||||
Usage: "Disable hot reload (file watching)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "detach",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Run without interactive console (background mode)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "env",
|
||||
Aliases: []string{"e"},
|
||||
@@ -527,6 +744,98 @@ Examples:
|
||||
Usage: "MCP gateway address (e.g., :3000). Enables MCP protocol for AI tools.",
|
||||
EnvVars: []string{"MICRO_MCP_ADDRESS"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "prompt",
|
||||
Usage: "Describe a system to generate and run (AI designs, builds, and starts services)",
|
||||
EnvVars: []string{"MICRO_RUN_PROMPT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "provider",
|
||||
Usage: "AI provider for --prompt (anthropic, openai, gemini, atlascloud, groq, mistral, together)",
|
||||
EnvVars: []string{"MICRO_AI_PROVIDER"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "api_key",
|
||||
Usage: "API key for --prompt (or set ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)",
|
||||
EnvVars: []string{"MICRO_AI_API_KEY"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runWithPrompt(c *cli.Context, prompt string) error {
|
||||
provider := c.String("provider")
|
||||
apiKey := c.String("api_key")
|
||||
if apiKey == "" {
|
||||
for _, env := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY",
|
||||
"ATLASCLOUD_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "TOGETHER_API_KEY", "MICRO_AI_API_KEY"} {
|
||||
if v := os.Getenv(env); v != "" {
|
||||
apiKey = v
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("--api_key or a provider API key env var is required for --prompt")
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mmicro run --prompt\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Printf(" \033[2mDesigning services for:\033[0m %s\n\n", prompt)
|
||||
|
||||
design, err := generate.Design(ctx, provider, apiKey, "", ".", prompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("design failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(" Services:")
|
||||
for _, svc := range design.Services {
|
||||
fmt.Printf(" \033[32m●\033[0m \033[36m%s\033[0m — %s\n", svc.Name, svc.Description)
|
||||
for _, ep := range svc.Endpoints {
|
||||
fmt.Printf(" %s: %s\n", ep.Name, ep.Description)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if !confirmGenerate() {
|
||||
fmt.Println(" Cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println(" Generating code...")
|
||||
if err := generate.Generate(ctx, ".", design, provider, apiKey, ""); err != nil {
|
||||
return fmt.Errorf("generate failed: %w", err)
|
||||
}
|
||||
for _, svc := range design.Services {
|
||||
fmt.Printf(" \033[32m✓\033[0m %s/\n", svc.Name)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Set env vars so the agent process can pick them up
|
||||
if provider != "" {
|
||||
os.Setenv("MICRO_AI_PROVIDER", provider)
|
||||
}
|
||||
os.Setenv("MICRO_AI_API_KEY", apiKey)
|
||||
|
||||
// Now run normally — micro run discovers the generated services + agent
|
||||
fmt.Println(" Starting services...")
|
||||
fmt.Println()
|
||||
|
||||
cancel()
|
||||
c.Set("prompt", "")
|
||||
return Run(c)
|
||||
}
|
||||
|
||||
func confirmGenerate() bool {
|
||||
fmt.Print(" Generate? [Y/n] ")
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
if !scanner.Scan() {
|
||||
return false
|
||||
}
|
||||
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
|
||||
return answer == "" || answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
@@ -75,6 +75,25 @@ func (w *Watcher) Start() {
|
||||
go w.watch()
|
||||
}
|
||||
|
||||
// AddDir adds a new directory to watch
|
||||
func (w *Watcher) AddDir(dir string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for _, d := range w.dirs {
|
||||
if d == dir {
|
||||
return
|
||||
}
|
||||
}
|
||||
w.dirs = append(w.dirs, dir)
|
||||
}
|
||||
|
||||
// Dirs returns the currently watched directories
|
||||
func (w *Watcher) Dirs() []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return append([]string{}, w.dirs...)
|
||||
}
|
||||
|
||||
// Stop stops the watcher
|
||||
func (w *Watcher) Stop() {
|
||||
close(w.done)
|
||||
|
||||
+76
-315
@@ -28,6 +28,14 @@ import (
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/cmd"
|
||||
codecBytes "go-micro.dev/v5/codec/bytes"
|
||||
"go-micro.dev/v5/ai"
|
||||
_ "go-micro.dev/v5/ai/anthropic"
|
||||
_ "go-micro.dev/v5/ai/atlascloud"
|
||||
_ "go-micro.dev/v5/ai/gemini"
|
||||
_ "go-micro.dev/v5/ai/groq"
|
||||
_ "go-micro.dev/v5/ai/mistral"
|
||||
_ "go-micro.dev/v5/ai/openai"
|
||||
_ "go-micro.dev/v5/ai/together"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -58,7 +66,7 @@ type templates struct {
|
||||
authLogin *template.Template
|
||||
authUsers *template.Template
|
||||
playground *template.Template
|
||||
scopes *template.Template
|
||||
scopes *template.Template
|
||||
}
|
||||
type TemplateUser struct {
|
||||
ID string
|
||||
@@ -82,7 +90,7 @@ func parseTemplates() *templates {
|
||||
authLogin: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html")),
|
||||
authUsers: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_users.html")),
|
||||
playground: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/playground.html")),
|
||||
scopes: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/scopes.html")),
|
||||
scopes: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/scopes.html")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +433,7 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
})
|
||||
|
||||
// MCP API endpoints - list tools and call tools through the web server
|
||||
mux.HandleFunc("/api/mcp/tools", wrap(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/mcp/tools", wrap(func(w http.ResponseWriter, r *http.Request) {
|
||||
services, err := registry.ListServices()
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -485,7 +493,7 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
json.NewEncoder(w).Encode(map[string]any{"tools": tools})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/api/mcp/call", wrap(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/mcp/call", wrap(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
@@ -606,7 +614,7 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
}
|
||||
}
|
||||
apiKey := ""
|
||||
model := ""
|
||||
modelName := ""
|
||||
baseURL := ""
|
||||
provider := ""
|
||||
if settings != nil {
|
||||
@@ -614,7 +622,7 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
apiKey = v
|
||||
}
|
||||
if v := settings["model"]; v != "" {
|
||||
model = v
|
||||
modelName = v
|
||||
}
|
||||
if v := settings["base_url"]; v != "" {
|
||||
baseURL = v
|
||||
@@ -630,39 +638,12 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
|
||||
// Auto-detect provider if not explicitly set
|
||||
if provider == "" {
|
||||
if strings.Contains(baseURL, "anthropic") {
|
||||
provider = "anthropic"
|
||||
} else {
|
||||
provider = "openai"
|
||||
}
|
||||
}
|
||||
|
||||
// Set defaults based on provider
|
||||
if provider == "anthropic" {
|
||||
if model == "" {
|
||||
model = "claude-sonnet-4-20250514"
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.anthropic.com"
|
||||
}
|
||||
} else {
|
||||
if model == "" {
|
||||
model = "gpt-4o"
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com"
|
||||
}
|
||||
provider = ai.AutoDetectProvider(baseURL)
|
||||
}
|
||||
|
||||
// Discover tools from registry
|
||||
services, _ := registry.ListServices()
|
||||
type toolInfo struct {
|
||||
Name string // original dotted name (e.g. "greeter.Greeter.Hello")
|
||||
SafeName string // LLM-safe name (dots replaced with underscores)
|
||||
Description string
|
||||
Properties map[string]any
|
||||
}
|
||||
var discoveredTools []toolInfo
|
||||
var discoveredTools []ai.Tool
|
||||
// safeNameMap maps LLM-safe names back to original dotted names
|
||||
safeNameMap := map[string]string{}
|
||||
for _, svc := range services {
|
||||
@@ -689,11 +670,11 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
}
|
||||
}
|
||||
}
|
||||
discoveredTools = append(discoveredTools, toolInfo{
|
||||
Name: tName,
|
||||
SafeName: safeName,
|
||||
Description: desc,
|
||||
Properties: props,
|
||||
discoveredTools = append(discoveredTools, ai.Tool{
|
||||
Name: safeName,
|
||||
OriginalName: tName,
|
||||
Description: desc,
|
||||
Properties: props,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -776,282 +757,54 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
return rpcResult, string(rsp.Data)
|
||||
}
|
||||
|
||||
// callLLMAPI makes an HTTP request to the LLM provider
|
||||
callLLMAPI := func(url string, body []byte) ([]byte, error) {
|
||||
httpReq, err := http.NewRequestWithContext(r.Context(), "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if provider == "anthropic" {
|
||||
httpReq.Header.Set("x-api-key", apiKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
} else {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LLM API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("LLM API error (%s): %s", resp.Status, string(respBody))
|
||||
}
|
||||
return respBody, nil
|
||||
// Create model with options
|
||||
var modelOpts []ai.Option
|
||||
modelOpts = append(modelOpts, ai.WithAPIKey(apiKey))
|
||||
if modelName != "" {
|
||||
modelOpts = append(modelOpts, ai.WithModel(modelName))
|
||||
}
|
||||
if baseURL != "" {
|
||||
modelOpts = append(modelOpts, ai.WithBaseURL(baseURL))
|
||||
}
|
||||
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 := &ai.Request{
|
||||
Prompt: req.Prompt,
|
||||
SystemPrompt: agentSystemPrompt,
|
||||
Tools: discoveredTools,
|
||||
}
|
||||
|
||||
// Generate response
|
||||
response, err := m.Generate(r.Context(), modelReq)
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build result
|
||||
result := map[string]any{}
|
||||
|
||||
if provider == "anthropic" {
|
||||
// --- Anthropic Messages API ---
|
||||
var anthropicTools []map[string]any
|
||||
for _, t := range discoveredTools {
|
||||
anthropicTools = append(anthropicTools, map[string]any{
|
||||
"name": t.SafeName,
|
||||
"description": t.Description,
|
||||
"input_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
if response.Reply != "" {
|
||||
result["reply"] = response.Reply
|
||||
}
|
||||
if len(response.ToolCalls) > 0 {
|
||||
var toolCalls []map[string]any
|
||||
for _, tc := range response.ToolCalls {
|
||||
toolCalls = append(toolCalls, map[string]any{
|
||||
"tool": tc.Name,
|
||||
"input": tc.Input,
|
||||
})
|
||||
}
|
||||
|
||||
anthropicReq := map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": 4096,
|
||||
"system": agentSystemPrompt,
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
},
|
||||
}
|
||||
if len(anthropicTools) > 0 {
|
||||
anthropicReq["tools"] = anthropicTools
|
||||
}
|
||||
chatBody, _ := json.Marshal(anthropicReq)
|
||||
|
||||
apiURL := strings.TrimRight(baseURL, "/") + "/v1/messages"
|
||||
respBody, err := callLLMAPI(apiURL, chatBody)
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse Anthropic response
|
||||
var anthropicResp struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
} `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &anthropicResp); err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to parse LLM response: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract text reply
|
||||
var replyParts []string
|
||||
for _, block := range anthropicResp.Content {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
replyParts = append(replyParts, block.Text)
|
||||
}
|
||||
}
|
||||
if len(replyParts) > 0 {
|
||||
result["reply"] = strings.Join(replyParts, "\n")
|
||||
}
|
||||
|
||||
// Execute tool uses
|
||||
var toolUseBlocks []struct {
|
||||
ID string
|
||||
Name string
|
||||
Input map[string]any
|
||||
}
|
||||
for _, block := range anthropicResp.Content {
|
||||
if block.Type == "tool_use" {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal(block.Input, &input); err != nil {
|
||||
log.Printf("[agent] failed to parse tool input: %v", err)
|
||||
input = map[string]any{}
|
||||
}
|
||||
toolUseBlocks = append(toolUseBlocks, struct {
|
||||
ID string
|
||||
Name string
|
||||
Input map[string]any
|
||||
}{ID: block.ID, Name: block.Name, Input: input})
|
||||
}
|
||||
}
|
||||
|
||||
if len(toolUseBlocks) > 0 {
|
||||
var toolCalls []map[string]any
|
||||
var toolResultBlocks []map[string]any
|
||||
|
||||
for _, tu := range toolUseBlocks {
|
||||
rpcResult, rpcContent := executeToolCall(tu.Name, tu.Input)
|
||||
toolCalls = append(toolCalls, map[string]any{
|
||||
"tool": tu.Name,
|
||||
"input": tu.Input,
|
||||
"result": rpcResult,
|
||||
})
|
||||
toolResultBlocks = append(toolResultBlocks, map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tu.ID,
|
||||
"content": rpcContent,
|
||||
})
|
||||
}
|
||||
result["tool_calls"] = toolCalls
|
||||
|
||||
// Follow-up: send tool results back to Anthropic
|
||||
followUpReq := map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": 4096,
|
||||
"system": agentSystemPrompt,
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
{"role": "assistant", "content": anthropicResp.Content},
|
||||
{"role": "user", "content": toolResultBlocks},
|
||||
},
|
||||
}
|
||||
|
||||
followUpBody, _ := json.Marshal(followUpReq)
|
||||
if followUpRespBody, err := callLLMAPI(apiURL, followUpBody); err == nil {
|
||||
var followUpResp struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(followUpRespBody, &followUpResp) == nil {
|
||||
var answerParts []string
|
||||
for _, block := range followUpResp.Content {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
answerParts = append(answerParts, block.Text)
|
||||
}
|
||||
}
|
||||
if len(answerParts) > 0 {
|
||||
result["answer"] = strings.Join(answerParts, "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// --- OpenAI Chat Completions API ---
|
||||
var openaiTools []map[string]any
|
||||
for _, t := range discoveredTools {
|
||||
openaiTools = append(openaiTools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.SafeName,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": agentSystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
chatReq := map[string]any{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
if len(openaiTools) > 0 {
|
||||
chatReq["tools"] = openaiTools
|
||||
}
|
||||
chatBody, _ := json.Marshal(chatReq)
|
||||
|
||||
apiURL := strings.TrimRight(baseURL, "/") + "/v1/chat/completions"
|
||||
respBody, err := callLLMAPI(apiURL, chatBody)
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to parse LLM response: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "No response from LLM"})
|
||||
return
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
if choice.Message.Content != "" {
|
||||
result["reply"] = choice.Message.Content
|
||||
}
|
||||
|
||||
// Execute any tool calls
|
||||
if len(choice.Message.ToolCalls) > 0 {
|
||||
var toolCalls []map[string]any
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": choice.Message.Content,
|
||||
"tool_calls": choice.Message.ToolCalls,
|
||||
})
|
||||
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
log.Printf("[agent] failed to parse tool arguments: %v", err)
|
||||
}
|
||||
if input == nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
rpcResult, rpcContent := executeToolCall(tc.Function.Name, input)
|
||||
toolCalls = append(toolCalls, map[string]any{
|
||||
"tool": tc.Function.Name,
|
||||
"input": input,
|
||||
"result": rpcResult,
|
||||
})
|
||||
followUpMessages = append(followUpMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.ID,
|
||||
"content": rpcContent,
|
||||
})
|
||||
}
|
||||
result["tool_calls"] = toolCalls
|
||||
|
||||
// Follow-up: send tool results back to LLM for a final answer
|
||||
followUpReq := map[string]any{
|
||||
"model": model,
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
followUpBody, _ := json.Marshal(followUpReq)
|
||||
if followUpRespBody, err := callLLMAPI(apiURL, followUpBody); err == nil {
|
||||
var followUpChat struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if json.Unmarshal(followUpRespBody, &followUpChat) == nil && len(followUpChat.Choices) > 0 {
|
||||
result["answer"] = followUpChat.Choices[0].Message.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
result["tool_calls"] = toolCalls
|
||||
}
|
||||
if response.Answer != "" {
|
||||
result["answer"] = response.Answer
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(result)
|
||||
@@ -1660,7 +1413,11 @@ You can generate tokens on the <a href='/auth/tokens'>Tokens page</a>.
|
||||
if del := r.FormValue("delete"); del != "" {
|
||||
// Delete user
|
||||
storeInst.Delete("auth/" + del)
|
||||
deleteUserTokens(storeInst, del) // Delete all JWT tokens for this user
|
||||
deleteUserTokens(storeInst, del)
|
||||
// Mark default admin as deleted so it won't be recreated on restart
|
||||
if del == "admin" {
|
||||
storeInst.Write(&store.Record{Key: "auth/.admin-deleted", Value: []byte("true")})
|
||||
}
|
||||
http.Redirect(w, r, "/auth/users", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
@@ -1874,11 +1631,15 @@ func initAuth() error {
|
||||
_, _ = os.ReadFile(privPath)
|
||||
_, _ = os.ReadFile(pubPath)
|
||||
storeInst := store.DefaultStore
|
||||
// --- Ensure default admin account exists ---
|
||||
// --- Ensure default admin account exists on first run ---
|
||||
// If the admin was explicitly deleted (marker key exists), don't recreate.
|
||||
adminID := "admin"
|
||||
adminPass := "micro"
|
||||
adminKey := "auth/" + adminID
|
||||
if recs, _ := storeInst.Read(adminKey); len(recs) == 0 {
|
||||
adminDeletedKey := "auth/.admin-deleted"
|
||||
if recs, _ := storeInst.Read(adminDeletedKey); len(recs) > 0 {
|
||||
// Admin was explicitly deleted — don't recreate
|
||||
} else if recs, _ := storeInst.Read(adminKey); len(recs) == 0 {
|
||||
// Hash the admin password with bcrypt
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -1903,7 +1664,7 @@ func parseStartTime(s string) (time.Time, error) {
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "server",
|
||||
Usage: "Run the micro server",
|
||||
Usage: "Production mode: run the micro server with dashboard and auth",
|
||||
Action: Run,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
|
||||
@@ -1,39 +1,152 @@
|
||||
{{define "content"}}
|
||||
<h2>Agent</h2>
|
||||
<p>Chat with your microservices using AI. Configure a model API key in settings, then use the prompt to interact with your services.</p>
|
||||
<style>
|
||||
#agent-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 160px);
|
||||
max-height: 800px;
|
||||
}
|
||||
#agent-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1em 0;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
.msg { padding: 0.7em 1em; border-radius: 8px; margin-bottom: 0.6em; line-height: 1.6; }
|
||||
.msg-user { background: #f0f4ff; border: 1px solid #d0d8f0; }
|
||||
.msg-user b { color: #336; }
|
||||
.msg-assistant { background: #fff; border: 1px solid #ddd; }
|
||||
.msg-assistant b { color: #333; }
|
||||
.msg-error { background: #fff5f5; border: 1px solid #e88; color: #a00; }
|
||||
.msg-thinking { background: #fafafa; border: 1px solid #e5e5e5; color: #888; font-style: italic; }
|
||||
.tool-call {
|
||||
background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 8px;
|
||||
margin-bottom: 0.6em; font-size: 0.93em; overflow: hidden;
|
||||
}
|
||||
.tool-header {
|
||||
padding: 0.5em 1em; cursor: pointer; display: flex; align-items: center;
|
||||
gap: 0.5em; user-select: none; font-weight: 600; color: #555;
|
||||
}
|
||||
.tool-header:hover { background: #f0f0f0; }
|
||||
.tool-header .arrow { transition: transform 0.2s; display: inline-block; }
|
||||
.tool-header .arrow.open { transform: rotate(90deg); }
|
||||
.tool-body { padding: 0 1em 0.8em 1em; display: none; }
|
||||
.tool-body.open { display: block; }
|
||||
.tool-body pre {
|
||||
background: #f5f5f5; padding: 0.6em; border-radius: 4px;
|
||||
overflow-x: auto; font-size: 0.9em; margin: 0.4em 0;
|
||||
}
|
||||
.tool-body .tool-label { font-weight: 600; color: #666; font-size: 0.85em; margin-top: 0.5em; }
|
||||
.tool-status { font-size: 0.8em; font-weight: normal; margin-left: auto; }
|
||||
.tool-status.ok { color: #2a2; }
|
||||
.tool-status.err { color: #c00; }
|
||||
#prompt-bar {
|
||||
display: flex; gap: 0.5em; align-items: center;
|
||||
padding: 0.8em 0 0 0; border-top: 1px solid #eee;
|
||||
}
|
||||
#prompt-bar input {
|
||||
flex: 1; margin-bottom: 0; padding: 0.6em 0.8em;
|
||||
border: 1px solid #ccc; border-radius: 6px; font-size: 1em;
|
||||
}
|
||||
#prompt-bar button { padding: 0.6em 1.5em; border-radius: 6px; font-size: 1em; white-space: nowrap; }
|
||||
#prompt-bar button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.tools-bar {
|
||||
display: flex; gap: 0.5em; align-items: center;
|
||||
padding: 0.5em 0; font-size: 0.85em; color: #888;
|
||||
}
|
||||
.tools-bar .tool-count { font-weight: 600; color: #555; }
|
||||
.settings-toggle {
|
||||
background: none; border: 1px solid #ddd; border-radius: 6px;
|
||||
padding: 0.3em 0.8em; font-size: 0.85em; cursor: pointer; color: #555;
|
||||
}
|
||||
.settings-toggle:hover { background: #f5f5f5; }
|
||||
#settings-panel {
|
||||
display: none; background: #fafafa; border: 1px solid #e5e5e5;
|
||||
border-radius: 8px; padding: 1em 1.2em; margin-bottom: 1em;
|
||||
}
|
||||
#settings-panel.open { display: block; }
|
||||
#settings-panel label { display: block; font-weight: 600; margin-top: 0.6em; font-size: 0.9em; }
|
||||
#settings-panel input, #settings-panel select {
|
||||
width: 100%; padding: 0.4em 0.6em; font-size: 0.9em;
|
||||
border: 1px solid #ddd; border-radius: 4px; margin-top: 0.2em;
|
||||
}
|
||||
#settings-panel .settings-row { display: flex; gap: 1em; }
|
||||
#settings-panel .settings-row > div { flex: 1; }
|
||||
.clear-btn {
|
||||
background: none; border: none; color: #999; cursor: pointer;
|
||||
font-size: 0.85em; padding: 0.3em 0.5em;
|
||||
}
|
||||
.clear-btn:hover { color: #c00; }
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; height: 100%; color: #aaa; text-align: center;
|
||||
}
|
||||
.empty-state .icon { font-size: 3em; margin-bottom: 0.3em; }
|
||||
.empty-state p { margin: 0.3em 0; max-width: 400px; }
|
||||
</style>
|
||||
|
||||
<h3>Prompt</h3>
|
||||
<div id="agent-messages"></div>
|
||||
<form onsubmit="return false;" style="display:flex; gap:0.5em; align-items:flex-end;">
|
||||
<input type="text" id="prompt-input" placeholder="e.g. List all users, Create a blog post..." style="flex:1; margin-bottom:0;">
|
||||
<button id="prompt-btn" onclick="sendPrompt()">Send</button>
|
||||
</form>
|
||||
<div id="agent-chat">
|
||||
<div class="tools-bar">
|
||||
<span>Tools: <span class="tool-count" id="tool-count">...</span></span>
|
||||
<button class="settings-toggle" onclick="toggleSettings()">Settings</button>
|
||||
<button class="clear-btn" onclick="clearChat()">Clear chat</button>
|
||||
</div>
|
||||
|
||||
<h3>Settings</h3>
|
||||
<form id="settings-form" onsubmit="return false;">
|
||||
<label style="display:block; font-weight:600;">Provider</label>
|
||||
<select id="provider">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
<label style="display:block; font-weight:600;">Model API Key</label>
|
||||
<input type="password" id="api-key" placeholder="sk-... or API key for your model provider">
|
||||
<label style="display:block; font-weight:600;">Model (optional)</label>
|
||||
<input type="text" id="model-name" placeholder="e.g. gpt-4o or claude-sonnet-4-20250514">
|
||||
<label style="display:block; font-weight:600;">Base URL (optional)</label>
|
||||
<input type="text" id="base-url" placeholder="Leave blank for default">
|
||||
<button onclick="saveSettings()">Save Settings</button>
|
||||
<span id="settings-status" style="margin-left:0.5em; color:#888;"></span>
|
||||
</form>
|
||||
<div id="settings-panel">
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<label>Provider</label>
|
||||
<select id="provider">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="gemini">Google Gemini</option>
|
||||
<option value="atlascloud">Atlas Cloud</option>
|
||||
<option value="groq">Groq</option>
|
||||
<option value="mistral">Mistral</option>
|
||||
<option value="together">Together AI</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Model (optional)</label>
|
||||
<input type="text" id="model-name" placeholder="e.g. gpt-4o, claude-sonnet-4-20250514">
|
||||
</div>
|
||||
</div>
|
||||
<label>API Key</label>
|
||||
<input type="password" id="api-key" placeholder="sk-... or API key">
|
||||
<label>Base URL (optional)</label>
|
||||
<input type="text" id="base-url" placeholder="Leave blank for default">
|
||||
<div style="margin-top:0.8em; display:flex; gap:0.5em; align-items:center;">
|
||||
<button onclick="saveSettings()" style="padding:0.4em 1em; font-size:0.9em;">Save</button>
|
||||
<span id="settings-status" style="color:#888; font-size:0.85em;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Available Tools</h3>
|
||||
<div id="tools-list">
|
||||
<p style="color:#888;">Loading tools...</p>
|
||||
<div id="agent-messages">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="icon">🤖</div>
|
||||
<p><b>Chat with your services</b></p>
|
||||
<p>Ask the agent to interact with your microservices. It will discover and call the right tools automatically.</p>
|
||||
<p id="empty-tools" style="font-size:0.85em; color:#bbb; margin-top:0.8em;"></p>
|
||||
<div id="setup-hint" style="margin-top:1.2em; padding:1em; background:#f8f9fa; border-radius:8px; border:1px solid #e5e5e5; text-align:left; font-size:0.88em; max-width:400px; margin-left:auto; margin-right:auto;">
|
||||
<p style="margin:0 0 0.5em; font-weight:600;">Getting started:</p>
|
||||
<p style="margin:0 0 0.3em;">1. Click <b>Settings</b> above</p>
|
||||
<p style="margin:0 0 0.3em;">2. Choose a provider and enter your API key</p>
|
||||
<p style="margin:0 0 0.3em;">3. Type a prompt like <em>"list all services"</em></p>
|
||||
<p style="margin:0.6em 0 0; color:#888; font-size:0.9em;">Or use the CLI: <code style="background:#e9ecef; padding:2px 5px; border-radius:3px;">micro chat --provider anthropic</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="prompt-bar">
|
||||
<input type="text" id="prompt-input" placeholder="Ask the agent to call your services..." autofocus>
|
||||
<button id="prompt-btn" onclick="sendPrompt()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var tools = [];
|
||||
var toolCount = document.getElementById('tool-count');
|
||||
|
||||
loadSettings();
|
||||
loadTools();
|
||||
@@ -46,10 +159,25 @@
|
||||
if (data.api_key) document.getElementById('api-key').value = data.api_key;
|
||||
if (data.model) document.getElementById('model-name').value = data.model;
|
||||
if (data.base_url) document.getElementById('base-url').value = data.base_url;
|
||||
// Hide setup hint if API key is configured
|
||||
if (data.api_key) {
|
||||
var hint = document.getElementById('setup-hint');
|
||||
if (hint) hint.style.display = 'none';
|
||||
}
|
||||
// Auto-show settings if no API key configured
|
||||
if (!data.api_key) {
|
||||
document.getElementById('settings-panel').classList.add('open');
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
.catch(function() {
|
||||
document.getElementById('settings-panel').classList.add('open');
|
||||
});
|
||||
}
|
||||
|
||||
window.toggleSettings = function() {
|
||||
document.getElementById('settings-panel').classList.toggle('open');
|
||||
};
|
||||
|
||||
window.saveSettings = function() {
|
||||
var status = document.getElementById('settings-status');
|
||||
status.textContent = 'Saving...';
|
||||
@@ -64,37 +192,42 @@
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function() { status.textContent = 'Saved'; })
|
||||
.then(function() {
|
||||
status.textContent = 'Saved';
|
||||
setTimeout(function() { status.textContent = ''; }, 2000);
|
||||
})
|
||||
.catch(function(err) { status.textContent = 'Error: ' + err; });
|
||||
};
|
||||
|
||||
function loadTools() {
|
||||
fetch('/api/mcp/tools')
|
||||
fetch('/mcp/tools')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
tools = data.tools || [];
|
||||
renderTools();
|
||||
toolCount.textContent = tools.length;
|
||||
var emptyTools = document.getElementById('empty-tools');
|
||||
if (tools.length > 0) {
|
||||
var names = tools.slice(0, 5).map(function(t) { return t.name; });
|
||||
var suffix = tools.length > 5 ? ' and ' + (tools.length - 5) + ' more' : '';
|
||||
emptyTools.textContent = 'Available: ' + names.join(', ') + suffix;
|
||||
} else {
|
||||
emptyTools.textContent = 'No tools found. Start some services first.';
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
document.getElementById('tools-list').innerHTML = '<p style="color:#c00;">Failed to load tools: ' + err + '</p>';
|
||||
.catch(function() {
|
||||
toolCount.textContent = '0';
|
||||
});
|
||||
}
|
||||
|
||||
function renderTools() {
|
||||
var el = document.getElementById('tools-list');
|
||||
if (tools.length === 0) {
|
||||
el.innerHTML = '<p style="color:#888;">No tools available. Start some services and they will appear here.</p>';
|
||||
return;
|
||||
}
|
||||
var html = '<table><thead><tr><th>Tool</th><th>Description</th></tr></thead><tbody>';
|
||||
for (var i = 0; i < tools.length; i++) {
|
||||
var t = tools[i];
|
||||
html += '<tr><td><code>' + escapeHtml(t.name) + '</code></td>';
|
||||
html += '<td>' + escapeHtml(t.description || '') + '</td></tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
window.clearChat = function() {
|
||||
var container = document.getElementById('agent-messages');
|
||||
container.innerHTML = '';
|
||||
var emptyState = document.createElement('div');
|
||||
emptyState.className = 'empty-state';
|
||||
emptyState.id = 'empty-state';
|
||||
emptyState.innerHTML = '<div class="icon">🤖</div><p><b>Chat with your services</b></p><p>Ask the agent to interact with your microservices.</p>';
|
||||
container.appendChild(emptyState);
|
||||
};
|
||||
|
||||
window.sendPrompt = function() {
|
||||
var input = document.getElementById('prompt-input');
|
||||
@@ -102,11 +235,21 @@
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
|
||||
// Remove empty state on first message
|
||||
var emptyState = document.getElementById('empty-state');
|
||||
if (emptyState) emptyState.remove();
|
||||
|
||||
addMessage('user', escapeHtml(text));
|
||||
|
||||
// Show thinking indicator
|
||||
var thinkingId = 'thinking-' + Date.now();
|
||||
addMessage('thinking', 'Agent is thinking...', thinkingId);
|
||||
|
||||
var btn = document.getElementById('prompt-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
btn.textContent = 'Sending...';
|
||||
|
||||
var startTime = Date.now();
|
||||
|
||||
fetch('/api/agent/prompt', {
|
||||
method: 'POST',
|
||||
@@ -115,8 +258,15 @@
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Send';
|
||||
input.focus();
|
||||
|
||||
// Remove thinking indicator
|
||||
var thinking = document.getElementById(thinkingId);
|
||||
if (thinking) thinking.remove();
|
||||
|
||||
if (data.error) {
|
||||
addMessage('error', escapeHtml(data.error));
|
||||
return;
|
||||
@@ -127,9 +277,7 @@
|
||||
if (data.tool_calls && data.tool_calls.length > 0) {
|
||||
for (var i = 0; i < data.tool_calls.length; i++) {
|
||||
var tc = data.tool_calls[i];
|
||||
addMessage('tool', '<b>Tool:</b> <code>' + escapeHtml(tc.tool) + '</code>' +
|
||||
'<pre>' + escapeHtml(JSON.stringify(tc.input, null, 2)) + '</pre>' +
|
||||
'<b>Result:</b><pre>' + escapeHtml(JSON.stringify(tc.result, null, 2)) + '</pre>');
|
||||
addToolCall(tc, elapsed);
|
||||
}
|
||||
}
|
||||
if (data.answer) {
|
||||
@@ -139,35 +287,58 @@
|
||||
.catch(function(err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Send';
|
||||
input.focus();
|
||||
var thinking = document.getElementById(thinkingId);
|
||||
if (thinking) thinking.remove();
|
||||
addMessage('error', 'Error: ' + escapeHtml(String(err)));
|
||||
});
|
||||
};
|
||||
|
||||
function addToolCall(tc, elapsed) {
|
||||
var container = document.getElementById('agent-messages');
|
||||
var div = document.createElement('div');
|
||||
div.className = 'tool-call';
|
||||
|
||||
var hasError = tc.result && tc.result.error;
|
||||
var statusClass = hasError ? 'err' : 'ok';
|
||||
var statusText = hasError ? 'error' : elapsed + 's';
|
||||
|
||||
var inputJson = JSON.stringify(tc.input, null, 2);
|
||||
var resultJson = JSON.stringify(tc.result, null, 2);
|
||||
|
||||
div.innerHTML =
|
||||
'<div class="tool-header" onclick="this.querySelector(\'.arrow\').classList.toggle(\'open\'); this.nextElementSibling.classList.toggle(\'open\');">' +
|
||||
'<span class="arrow">▶</span> ' +
|
||||
'<code>' + escapeHtml(tc.tool) + '</code>' +
|
||||
'<span class="tool-status ' + statusClass + '">' + statusText + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="tool-body">' +
|
||||
'<div class="tool-label">Input</div>' +
|
||||
'<pre>' + escapeHtml(inputJson) + '</pre>' +
|
||||
'<div class="tool-label">Result</div>' +
|
||||
'<pre>' + escapeHtml(resultJson) + '</pre>' +
|
||||
'</div>';
|
||||
|
||||
container.appendChild(div);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
document.getElementById('prompt-input').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); window.sendPrompt(); }
|
||||
});
|
||||
|
||||
function addMessage(type, html) {
|
||||
function addMessage(type, html, id) {
|
||||
var container = document.getElementById('agent-messages');
|
||||
var div = document.createElement('div');
|
||||
div.style.cssText = 'padding:0.8em 1em; border-radius:7px; margin-bottom:0.8em; line-height:1.6;';
|
||||
div.className = 'msg msg-' + type;
|
||||
if (id) div.id = id;
|
||||
if (type === 'user') {
|
||||
div.style.background = '#f7f7f7';
|
||||
div.style.border = '1px solid #eee';
|
||||
div.innerHTML = '<b>You:</b> ' + html;
|
||||
div.innerHTML = '<b>You</b><br>' + html;
|
||||
} else if (type === 'assistant' || type === 'answer') {
|
||||
div.style.background = '#fff';
|
||||
div.style.border = '1px solid #ddd';
|
||||
div.innerHTML = '<b>Agent:</b> ' + html;
|
||||
} else if (type === 'tool') {
|
||||
div.style.background = '#fafafa';
|
||||
div.style.border = '1px solid #e0e0e0';
|
||||
div.style.fontSize = '0.95em';
|
||||
div.innerHTML = '<b>Agent</b><br>' + html;
|
||||
} else if (type === 'thinking') {
|
||||
div.innerHTML = html;
|
||||
} else if (type === 'error') {
|
||||
div.style.background = '#fff';
|
||||
div.style.border = '1px solid #c00';
|
||||
div.style.color = '#c00';
|
||||
div.innerHTML = html;
|
||||
}
|
||||
container.appendChild(div);
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<thead><tr><th>Access method</th><th>How auth works</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>API (<code>/api/service/endpoint</code>)</td><td><code>Authorization: Bearer <token></code> header</td></tr>
|
||||
<tr><td>MCP tools (<code>/api/mcp/call</code>)</td><td><code>Authorization: Bearer <token></code> header</td></tr>
|
||||
<tr><td>MCP tools (<code>/mcp/call</code>)</td><td><code>Authorization: Bearer <token></code> header</td></tr>
|
||||
<tr><td>Agent playground</td><td>Uses your logged-in session and its scopes</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -84,91 +84,78 @@ func Version(v string) Option {
|
||||
func Broker(b *broker.Broker) Option {
|
||||
return func(o *Options) {
|
||||
o.Broker = b
|
||||
broker.DefaultBroker = *b
|
||||
}
|
||||
}
|
||||
|
||||
func Cache(c *cache.Cache) Option {
|
||||
return func(o *Options) {
|
||||
o.Cache = c
|
||||
cache.DefaultCache = *c
|
||||
}
|
||||
}
|
||||
|
||||
func Config(c *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = c
|
||||
config.DefaultConfig = *c
|
||||
}
|
||||
}
|
||||
|
||||
func Selector(s *selector.Selector) Option {
|
||||
return func(o *Options) {
|
||||
o.Selector = s
|
||||
selector.DefaultSelector = *s
|
||||
}
|
||||
}
|
||||
|
||||
func Registry(r *registry.Registry) Option {
|
||||
return func(o *Options) {
|
||||
o.Registry = r
|
||||
registry.DefaultRegistry = *r
|
||||
}
|
||||
}
|
||||
|
||||
func Transport(t *transport.Transport) Option {
|
||||
return func(o *Options) {
|
||||
o.Transport = t
|
||||
transport.DefaultTransport = *t
|
||||
}
|
||||
}
|
||||
|
||||
func Client(c *client.Client) Option {
|
||||
return func(o *Options) {
|
||||
o.Client = c
|
||||
client.DefaultClient = *c
|
||||
}
|
||||
}
|
||||
|
||||
func Server(s *server.Server) Option {
|
||||
return func(o *Options) {
|
||||
o.Server = s
|
||||
server.DefaultServer = *s
|
||||
}
|
||||
}
|
||||
|
||||
func Store(s *store.Store) Option {
|
||||
return func(o *Options) {
|
||||
o.Store = s
|
||||
store.DefaultStore = *s
|
||||
}
|
||||
}
|
||||
|
||||
func Stream(s *events.Stream) Option {
|
||||
return func(o *Options) {
|
||||
o.Stream = s
|
||||
events.DefaultStream = *s
|
||||
}
|
||||
}
|
||||
|
||||
func Tracer(t *trace.Tracer) Option {
|
||||
return func(o *Options) {
|
||||
o.Tracer = t
|
||||
trace.DefaultTracer = *t
|
||||
}
|
||||
}
|
||||
|
||||
func Auth(a *auth.Auth) Option {
|
||||
return func(o *Options) {
|
||||
o.Auth = a
|
||||
auth.DefaultAuth = *a
|
||||
}
|
||||
}
|
||||
|
||||
func Profile(p *profile.Profile) Option {
|
||||
return func(o *Options) {
|
||||
o.DebugProfile = p
|
||||
profile.DefaultProfile = *p
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.Model
|
||||
|
||||
// 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 table backed by any model.Model.
|
||||
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"`
|
||||
}
|
||||
|
||||
// RegisterUserModel registers the UserModel table with the given model backend.
|
||||
func RegisterUserModel(db model.Model) error {
|
||||
return db.Register(&UserModel{}, 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]
|
||||
|
||||
@@ -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 table backed by any model.Model.")
|
||||
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 Register helper: RegisterXModel(db) registers the model with the given backend.
|
||||
g.P("// Register", modelName, " registers the ", modelName, " table with the given model backend.")
|
||||
g.P("func Register", modelName, "(db ", modelPkg, ".Model) error {")
|
||||
g.P("return db.Register(&", modelName, "{}, ", 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()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user