Initial project scaffold

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wangbo 2026-05-09 14:36:35 +08:00
commit 6323e70e49
39 changed files with 8664 additions and 0 deletions

25
.env.example Normal file
View File

@ -0,0 +1,25 @@
APP_ENV=development
HTTP_ADDR=:8088
# Reuse the same PostgreSQL instance as Agent memory, but use an independent
# database. When running from the host, use the externally reachable host/port.
AI_GATEWAY_DATABASE_NAME=easyai_ai_gateway
AI_GATEWAY_DATABASE_URL=postgresql://easyai:easyai2025@localhost:5432/easyai_ai_gateway?sslmode=disable
# When running inside the EasyAI docker network, use the container DNS name:
# AI_GATEWAY_DATABASE_URL=postgresql://easyai:easyai2025@easyai-pgvector:5432/easyai_ai_gateway?schema=public
#
# If AI_GATEWAY_DATABASE_URL is omitted, the service can derive host/user/pass
# from MEMORY_DATABASE_URL but will still replace the database with
# AI_GATEWAY_DATABASE_NAME.
# MEMORY_DATABASE_URL=postgresql://easyai:easyai2025@easyai-pgvector:5432/easyai_memory?schema=public
# Keep this aligned with easyai-server-main CONFIG_JWT_SECRET in the first migration phase.
CONFIG_JWT_SECRET=this is a very secret secret
# Used when the gateway delegates OpenAPI sk-* validation, file upload, and settlement callbacks.
SERVER_MAIN_BASE_URL=http://localhost:3000
SERVER_MAIN_INTERNAL_TOKEN=change-me
CORS_ALLOWED_ORIGIN=http://localhost:5178
VITE_GATEWAY_API_BASE_URL=http://localhost:8088

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
dist/
node_modules/
.nx/
.turbo/
.DS_Store
.env
*.log
apps/api/bin/
apps/api/tmp/
coverage/

60
README.md Normal file
View File

@ -0,0 +1,60 @@
# EasyAI AI Gateway
独立的 AI 网关中台脚手架,用于把现有 `integration-platform` 的平台管理、模型路由、计费预估、队列执行、Chat / 生图 / 生视频等生成能力逐步从 `easyai-server-main` 拆成可独立运行的项目。
## 技术选型
- 后端Go + PostgreSQL复用 Agent memory 的 `easyai-pgvector`,保留 `server-main` 的 JWT / API Key 授权语义。
- 前端React + TypeScript + TSXUI 体系按 `shadcn-ui` / Radix / Tailwind 方向沉淀,先提供运维控制台骨架。
- MonorepoNx 负责任务编排Go 使用 `go.work` 管理模块。
- 集成:完成后由 `easyai-server-main` 通过内部 HTTP SDK 直连本服务,前端经网关访问本服务。
## 目录
```text
apps/
api/ Go HTTP API, auth middleware, PG store, migrations
web/ React TSX admin console
packages/
contracts/ Shared TypeScript DTO contracts
docs/
design.md Detailed architecture and migration design
```
## 本地启动
```bash
cp .env.example .env
pnpm install
pnpm dev
```
服务默认地址:
- API: `http://localhost:8088`
- Web: `http://localhost:5178`
- PostgreSQL: 默认使用宿主机 `localhost:5432` 上的 `postgres` 容器,并使用独立库 `easyai_ai_gateway`
默认 EasyAI 部署里,`easyai-pgvector` 在容器网络内的连接串是:
```dotenv
AI_GATEWAY_DATABASE_URL=postgresql://easyai:easyai2025@easyai-pgvector:5432/easyai_ai_gateway?schema=public
```
宿主机直跑时需要使用宿主机可访问的 Postgres 地址。如果 `easyai-pgvector``5432` 映射到了本机,可使用:
```dotenv
AI_GATEWAY_DATABASE_URL=postgresql://easyai:easyai2025@localhost:5432/easyai_ai_gateway?sslmode=disable
```
如果现有 `easyai-pgvector` 没有把 `5432` 映射到宿主机,就需要补端口映射,或者把 AI Gateway 后端容器化后接入同一个 `easyai` Docker network。
## 迁移原则
1. 新服务先并行运行,不直接删除 `easyai-server-main` 内现有模块。
2. 授权先复用 `server-main` 的 JWT secret、claim、角色权限模型。
3. OpenAPI `sk-*` 校验、文件上传、扣费结算仍由 `server-main` 承担。
4. 网关服务负责基准模型库、平台模型路由、TPM/RPM/并发限流、任务队列、三方平台执行、任务进度推送。
5. 切流时优先让 `server-main``OpenaiService` 变成薄门面,内部调用本服务。
详细设计见 [docs/design.md](docs/design.md)。

View File

@ -0,0 +1,56 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/httpapi"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func main() {
cfg := config.Load()
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: cfg.LogLevel,
}))
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
db, err := store.Connect(ctx, cfg.DatabaseURL)
if err != nil {
logger.Error("connect postgres failed", "error", err)
os.Exit(1)
}
defer db.Close()
server := &http.Server{
Addr: cfg.HTTPAddr,
Handler: httpapi.NewServer(cfg, db, logger),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
logger.Info("easyai ai gateway api started", "addr", cfg.HTTPAddr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("http server failed", "error", err)
os.Exit(1)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("http shutdown failed", "error", err)
os.Exit(1)
}
logger.Info("easyai ai gateway api stopped")
}

View File

@ -0,0 +1,85 @@
package main
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/jackc/pgx/v5"
)
func main() {
cfg := config.Load()
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx := context.Background()
conn, err := pgx.Connect(ctx, cfg.DatabaseURL)
if err != nil {
logger.Error("connect postgres failed", "error", err)
os.Exit(1)
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
);`); err != nil {
logger.Error("ensure schema_migrations failed", "error", err)
os.Exit(1)
}
files, err := filepath.Glob("migrations/*.sql")
if err != nil {
logger.Error("read migrations failed", "error", err)
os.Exit(1)
}
sort.Strings(files)
for _, file := range files {
version := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
var exists bool
if err := conn.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version=$1)", version).Scan(&exists); err != nil {
logger.Error("check migration failed", "version", version, "error", err)
os.Exit(1)
}
if exists {
logger.Info("migration skipped", "version", version)
continue
}
sqlBytes, err := os.ReadFile(file)
if err != nil {
logger.Error("read migration file failed", "file", file, "error", err)
os.Exit(1)
}
tx, err := conn.Begin(ctx)
if err != nil {
logger.Error("begin migration failed", "version", version, "error", err)
os.Exit(1)
}
if _, err := tx.Exec(ctx, string(sqlBytes)); err != nil {
_ = tx.Rollback(ctx)
logger.Error("execute migration failed", "version", version, "error", err)
os.Exit(1)
}
if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations(version) VALUES($1)", version); err != nil {
_ = tx.Rollback(ctx)
logger.Error("record migration failed", "version", version, "error", err)
os.Exit(1)
}
if err := tx.Commit(ctx); err != nil {
logger.Error("commit migration failed", "version", version, "error", err)
os.Exit(1)
}
logger.Info("migration applied", "version", version)
}
fmt.Println("migrations complete")
}

8
apps/api/go.mod Normal file
View File

@ -0,0 +1,8 @@
module github.com/easyai/easyai-ai-gateway/apps/api
go 1.23
require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/jackc/pgx/v5 v5.7.2
)

View File

@ -0,0 +1,222 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Permission string
const (
PermissionPublic Permission = "public"
PermissionBasic Permission = "basic"
PermissionCreat Permission = "creat"
PermissionPower Permission = "power"
PermissionManager Permission = "manager"
)
type User struct {
ID string `json:"sub"`
Username string `json:"username"`
Roles []string `json:"role,omitempty"`
TenantID string `json:"tenantId,omitempty"`
SSOID string `json:"sso_id,omitempty"`
APIKeyID string `json:"apiKeyId,omitempty"`
APIKeySecret string `json:"apiKeySecret,omitempty"`
APIKeyName string `json:"apiKeyName,omitempty"`
}
type contextKey string
const userContextKey contextKey = "easyai-auth-user"
var ErrUnauthorized = errors.New("unauthorized")
type Authenticator struct {
JWTSecret string
ServerMainBaseURL string
ServerMainInternalToken string
HTTPClient *http.Client
}
func New(jwtSecret string, serverMainBaseURL string, internalToken string) *Authenticator {
return &Authenticator{
JWTSecret: jwtSecret,
ServerMainBaseURL: strings.TrimRight(serverMainBaseURL, "/"),
ServerMainInternalToken: internalToken,
HTTPClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func UserFromContext(ctx context.Context) (*User, bool) {
user, ok := ctx.Value(userContextKey).(*User)
return user, ok
}
func (a *Authenticator) Require(permission Permission, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := a.Authenticate(r)
if err != nil {
if permission == PermissionPublic {
next.ServeHTTP(w, r)
return
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !hasPermission(user.Roles, permission) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
})
}
func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
token := extractBearer(r.Header.Get("Authorization"))
if token == "" {
token = strings.TrimSpace(r.Header.Get("x-comfy-api-key"))
}
if token == "" {
return nil, ErrUnauthorized
}
if strings.HasPrefix(token, "sk-") {
return a.verifyAPIKey(r.Context(), token)
}
return a.verifyJWT(token)
}
func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
token, err := jwt.ParseWithClaims(tokenString, jwt.MapClaims{}, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(a.JWTSecret), nil
})
if err != nil || !token.Valid {
return nil, ErrUnauthorized
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, ErrUnauthorized
}
user := &User{
ID: stringClaim(claims, "sub"),
Username: stringClaim(claims, "username"),
Roles: stringSliceClaim(claims, "role"),
TenantID: stringClaim(claims, "tenantId"),
SSOID: stringClaim(claims, "sso_id"),
APIKeyID: stringClaim(claims, "apiKeyId"),
APIKeySecret: stringClaim(claims, "apiKeySecret"),
APIKeyName: stringClaim(claims, "apiKeyName"),
}
if user.ID == "" {
return nil, ErrUnauthorized
}
return user, nil
}
func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, error) {
if a.ServerMainBaseURL == "" || a.ServerMainInternalToken == "" {
return nil, ErrUnauthorized
}
body, _ := json.Marshal(map[string]string{"apiKey": apiKey})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.ServerMainBaseURL+"/internal/platform/auth/verify-api-key", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.ServerMainInternalToken)
resp, err := a.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, ErrUnauthorized
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
if user.ID == "" {
return nil, ErrUnauthorized
}
return &user, nil
}
func extractBearer(value string) string {
fields := strings.Fields(value)
if len(fields) == 2 && strings.EqualFold(fields[0], "bearer") {
return fields[1]
}
return ""
}
func hasPermission(roles []string, required Permission) bool {
if required == PermissionPublic {
return true
}
granted := map[Permission]bool{PermissionPublic: true}
for _, role := range roles {
for _, permission := range permissionsForRole(role) {
granted[permission] = true
}
}
return granted[required]
}
func permissionsForRole(role string) []Permission {
switch role {
case "admin", "manager":
return []Permission{PermissionPublic, PermissionBasic, PermissionCreat, PermissionPower, PermissionManager}
case "operator":
return []Permission{PermissionPublic, PermissionBasic, PermissionCreat, PermissionPower}
case "creator":
return []Permission{PermissionPublic, PermissionBasic, PermissionCreat}
case "user":
return []Permission{PermissionPublic, PermissionBasic}
default:
return []Permission{PermissionPublic}
}
}
func stringClaim(claims jwt.MapClaims, key string) string {
value, _ := claims[key].(string)
return value
}
func stringSliceClaim(claims jwt.MapClaims, key string) []string {
value := claims[key]
switch typed := value.(type) {
case []string:
return typed
case []any:
out := make([]string, 0, len(typed))
for _, item := range typed {
if s, ok := item.(string); ok && s != "" {
out = append(out, s)
}
}
return out
case string:
if typed == "" {
return nil
}
return []string{typed}
default:
return nil
}
}

View File

@ -0,0 +1,99 @@
package config
import (
"log/slog"
"net/url"
"os"
"strings"
)
type Config struct {
AppEnv string
HTTPAddr string
DatabaseURL string
JWTSecret string
ServerMainBaseURL string
ServerMainInternalToken string
CORSAllowedOrigin string
LogLevel slog.Level
}
func Load() Config {
return Config{
AppEnv: env("APP_ENV", "development"),
HTTPAddr: env("HTTP_ADDR", ":8088"),
DatabaseURL: gatewayDatabaseURL(),
JWTSecret: env("CONFIG_JWT_SECRET", "this is a very secret secret"),
ServerMainBaseURL: strings.TrimRight(
env("SERVER_MAIN_BASE_URL", "http://localhost:3000"),
"/",
),
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178"),
LogLevel: logLevel(env("LOG_LEVEL", "info")),
}
}
func gatewayDatabaseURL() string {
if value := envValue("AI_GATEWAY_DATABASE_URL"); value != "" {
return normalizePostgresURL(value)
}
if value := envValue("DATABASE_URL"); value != "" {
return normalizePostgresURL(value)
}
if memoryURL := envValue("MEMORY_DATABASE_URL"); memoryURL != "" {
return normalizePostgresURL(withDatabase(memoryURL, env("AI_GATEWAY_DATABASE_NAME", "easyai_ai_gateway")))
}
return normalizePostgresURL("postgresql://easyai:easyai2025@localhost:5432/easyai_ai_gateway?sslmode=disable")
}
func normalizePostgresURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
values := parsed.Query()
schema := values.Get("schema")
if schema == "" {
return raw
}
values.Del("schema")
if values.Get("search_path") == "" {
values.Set("search_path", schema)
}
parsed.RawQuery = values.Encode()
return parsed.String()
}
func withDatabase(raw string, databaseName string) string {
parsed, err := url.Parse(raw)
if err != nil || databaseName == "" {
return raw
}
parsed.Path = "/" + databaseName
return parsed.String()
}
func envValue(key string) string {
return strings.TrimSpace(os.Getenv(key))
}
func env(key string, fallback string) string {
if value := envValue(key); value != "" {
return value
}
return fallback
}
func logLevel(value string) slog.Level {
switch strings.ToLower(value) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@ -0,0 +1,218 @@
package httpapi
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"service": "easyai-ai-gateway",
"env": s.cfg.AppEnv,
})
}
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
if err := s.store.Ping(r.Context()); err != nil {
writeError(w, http.StatusServiceUnavailable, "postgres unavailable")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context())
writeJSON(w, http.StatusOK, user)
}
func (s *Server) listPlatforms(w http.ResponseWriter, r *http.Request) {
platforms, err := s.store.ListPlatforms(r.Context())
if err != nil {
s.logger.Error("list platforms failed", "error", err)
writeError(w, http.StatusInternalServerError, "list platforms failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": platforms})
}
func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) {
var input store.CreatePlatformInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
if input.Provider == "" || input.Name == "" {
writeError(w, http.StatusBadRequest, "provider and name are required")
return
}
if input.AuthType == "" {
input.AuthType = "bearer"
}
platform, err := s.store.CreatePlatform(r.Context(), input)
if err != nil {
s.logger.Error("create platform failed", "error", err)
writeError(w, http.StatusInternalServerError, "create platform failed")
return
}
writeJSON(w, http.StatusCreated, platform)
}
func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
models, err := s.store.ListModels(r.Context())
if err != nil {
s.logger.Error("list models failed", "error", err)
writeError(w, http.StatusInternalServerError, "list models failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": models})
}
func (s *Server) listCatalogProviders(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListCatalogProviders(r.Context())
if err != nil {
s.logger.Error("list catalog providers failed", "error", err)
writeError(w, http.StatusInternalServerError, "list catalog providers failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) listBaseModels(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListBaseModels(r.Context())
if err != nil {
s.logger.Error("list base models failed", "error", err)
writeError(w, http.StatusInternalServerError, "list base models failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) listPricingRules(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListPricingRules(r.Context())
if err != nil {
s.logger.Error("list pricing rules failed", "error", err)
writeError(w, http.StatusInternalServerError, "list pricing rules failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": []any{},
"resolver": "effective-pricing-placeholder",
"request": body,
})
}
func (s *Server) listRateLimitWindows(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListRateLimitWindows(r.Context())
if err != nil {
s.logger.Error("list rate limit windows failed", "error", err)
writeError(w, http.StatusInternalServerError, "list rate limit windows failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) createTask(kind string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
model, _ := body["model"].(string)
if model == "" {
writeError(w, http.StatusBadRequest, "model is required")
return
}
task, err := s.store.CreateTask(r.Context(), store.CreateTaskInput{
Kind: kind,
Model: model,
Request: body,
}, user)
if err != nil {
s.logger.Error("create task failed", "kind", kind, "error", err)
writeError(w, http.StatusInternalServerError, "create task failed")
return
}
writeJSON(w, http.StatusAccepted, map[string]any{
"task": task,
"next": map[string]string{
"events": fmt.Sprintf("/api/v1/tasks/%s/events", task.ID),
"detail": fmt.Sprintf("/api/v1/tasks/%s", task.ID),
},
})
})
}
func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
if err == nil {
writeJSON(w, http.StatusOK, task)
return
}
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "task not found")
return
}
s.logger.Error("get task failed", "error", err)
writeError(w, http.StatusInternalServerError, "get task failed")
}
func (s *Server) taskEvents(w http.ResponseWriter, r *http.Request) {
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
if err != nil {
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "task not found")
return
}
writeError(w, http.StatusInternalServerError, "get task failed")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
sendSSE(w, "task.accepted", map[string]any{
"taskId": task.ID,
"status": task.Status,
})
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
timer := time.NewTimer(250 * time.Millisecond)
defer timer.Stop()
select {
case <-r.Context().Done():
return
case <-timer.C:
sendSSE(w, "task.placeholder", map[string]any{
"taskId": task.ID,
"message": "runtime worker is not wired yet",
})
}
}

View File

@ -0,0 +1,28 @@
package httpapi
import (
"encoding/json"
"fmt"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]any{
"error": map[string]any{
"message": message,
"status": status,
},
})
}
func sendSSE(w http.ResponseWriter, event string, payload any) {
bytes, _ := json.Marshal(payload)
_, _ = fmt.Fprintf(w, "event: %s\n", event)
_, _ = fmt.Fprintf(w, "data: %s\n\n", bytes)
}

View File

@ -0,0 +1,78 @@
package httpapi
import (
"log/slog"
"net/http"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type Server struct {
cfg config.Config
store *store.Store
auth *auth.Authenticator
logger *slog.Logger
}
func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Handler {
server := &Server{
cfg: cfg,
store: db,
auth: auth.New(cfg.JWTSecret, cfg.ServerMainBaseURL, cfg.ServerMainInternalToken),
logger: logger,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", server.health)
mux.HandleFunc("GET /readyz", server.ready)
mux.Handle("GET /api/v1/me", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.me)))
mux.Handle("GET /api/v1/catalog/providers", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listCatalogProviders)))
mux.Handle("GET /api/v1/catalog/base-models", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listBaseModels)))
mux.Handle("GET /api/v1/pricing/rules", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPricingRules)))
mux.Handle("POST /api/v1/pricing/estimate", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.estimatePricing)))
mux.Handle("GET /api/v1/platforms", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPlatforms)))
mux.Handle("POST /api/v1/platforms", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.createPlatform)))
mux.Handle("GET /api/v1/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listModels)))
mux.Handle("GET /api/v1/runtime/rate-limit-windows", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows)))
mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions")))
mux.Handle("POST /api/v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations")))
mux.Handle("POST /api/v1/videos/generations", server.auth.Require(auth.PermissionBasic, server.createTask("videos.generations")))
mux.Handle("GET /api/v1/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("GET /api/v1/tasks/{taskID}/events", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
return server.recover(server.cors(mux))
}
func (s *Server) cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (s.cfg.CORSAllowedOrigin == "*" || strings.EqualFold(origin, s.cfg.CORSAllowedOrigin)) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Comfy-Api-Key")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
s.logger.Error("panic recovered", "error", err, "path", r.URL.Path)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}

View File

@ -0,0 +1,508 @@
package store
import (
"context"
"encoding/json"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct {
pool *pgxpool.Pool
}
func Connect(ctx context.Context, databaseURL string) (*Store, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return &Store{pool: pool}, nil
}
func (s *Store) Close() {
s.pool.Close()
}
func (s *Store) Ping(ctx context.Context) error {
return s.pool.Ping(ctx)
}
type Platform struct {
ID string `json:"id"`
Provider string `json:"provider"`
PlatformKey string `json:"platformKey"`
Name string `json:"name"`
BaseURL string `json:"baseUrl,omitempty"`
AuthType string `json:"authType"`
Status string `json:"status"`
Priority int `json:"priority"`
DefaultPricingMode string `json:"defaultPricingMode"`
DefaultDiscountFactor float64 `json:"defaultDiscountFactor"`
Config map[string]any `json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CreatePlatformInput struct {
Provider string `json:"provider"`
PlatformKey string `json:"platformKey"`
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
AuthType string `json:"authType"`
Credentials map[string]any `json:"credentials"`
Config map[string]any `json:"config"`
DefaultPricingMode string `json:"defaultPricingMode"`
DefaultDiscountFactor float64 `json:"defaultDiscountFactor"`
Priority int `json:"priority"`
}
type PlatformModel struct {
ID string `json:"id"`
PlatformID string `json:"platformId"`
BaseModelID string `json:"baseModelId,omitempty"`
Provider string `json:"provider,omitempty"`
PlatformName string `json:"platformName,omitempty"`
ModelName string `json:"modelName"`
ModelAlias string `json:"modelAlias,omitempty"`
ModelType string `json:"modelType"`
DisplayName string `json:"displayName"`
CapabilityOverride map[string]any `json:"capabilityOverride,omitempty"`
Capabilities map[string]any `json:"capabilities,omitempty"`
PricingMode string `json:"pricingMode"`
DiscountFactor float64 `json:"discountFactor,omitempty"`
BillingConfigOverride map[string]any `json:"billingConfigOverride,omitempty"`
BillingConfig map[string]any `json:"billingConfig,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CatalogProvider struct {
ID string `json:"id"`
ProviderKey string `json:"providerKey"`
DisplayName string `json:"displayName"`
ProviderType string `json:"providerType"`
CapabilitySchema map[string]any `json:"capabilitySchema,omitempty"`
DefaultRateLimitPolicy map[string]any `json:"defaultRateLimitPolicy,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type BaseModel struct {
ID string `json:"id"`
ProviderKey string `json:"providerKey"`
CanonicalModelKey string `json:"canonicalModelKey"`
ProviderModelName string `json:"providerModelName"`
ModelType string `json:"modelType"`
DisplayName string `json:"displayName"`
Capabilities map[string]any `json:"capabilities,omitempty"`
BaseBillingConfig map[string]any `json:"baseBillingConfig,omitempty"`
DefaultRateLimitPolicy map[string]any `json:"defaultRateLimitPolicy,omitempty"`
PricingVersion int `json:"pricingVersion"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PricingRule struct {
ID string `json:"id"`
ScopeType string `json:"scopeType"`
ScopeID string `json:"scopeId,omitempty"`
ResourceType string `json:"resourceType"`
Unit string `json:"unit"`
BasePrice float64 `json:"basePrice"`
Currency string `json:"currency"`
BaseWeight map[string]any `json:"baseWeight,omitempty"`
DynamicWeight map[string]any `json:"dynamicWeight,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type RateLimitWindow struct {
ScopeType string `json:"scopeType"`
ScopeKey string `json:"scopeKey"`
Metric string `json:"metric"`
WindowStart time.Time `json:"windowStart"`
LimitValue float64 `json:"limitValue"`
UsedValue float64 `json:"usedValue"`
ReservedValue float64 `json:"reservedValue"`
ResetAt time.Time `json:"resetAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CreateTaskInput struct {
Kind string `json:"kind"`
Model string `json:"model"`
Request map[string]any `json:"request"`
}
type GatewayTask struct {
ID string `json:"id"`
Kind string `json:"kind"`
UserID string `json:"userId"`
TenantID string `json:"tenantId,omitempty"`
Model string `json:"model"`
Request map[string]any `json:"request,omitempty"`
Status string `json:"status"`
Result map[string]any `json:"result,omitempty"`
Billings []any `json:"billings,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (s *Store) ListPlatforms(ctx context.Context) ([]Platform, error) {
rows, err := s.pool.Query(ctx, `
SELECT id::text, provider, platform_key, name, COALESCE(base_url, ''), auth_type, status, priority,
default_pricing_mode, default_discount_factor::float8, config, created_at, updated_at
FROM integration_platforms
ORDER BY priority ASC, created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
platforms := make([]Platform, 0)
for rows.Next() {
var platform Platform
var configBytes []byte
if err := rows.Scan(
&platform.ID,
&platform.Provider,
&platform.PlatformKey,
&platform.Name,
&platform.BaseURL,
&platform.AuthType,
&platform.Status,
&platform.Priority,
&platform.DefaultPricingMode,
&platform.DefaultDiscountFactor,
&configBytes,
&platform.CreatedAt,
&platform.UpdatedAt,
); err != nil {
return nil, err
}
platform.Config = decodeObject(configBytes)
platforms = append(platforms, platform)
}
return platforms, rows.Err()
}
func (s *Store) CreatePlatform(ctx context.Context, input CreatePlatformInput) (Platform, error) {
credentials, _ := json.Marshal(input.Credentials)
config, _ := json.Marshal(input.Config)
if input.DefaultPricingMode == "" {
input.DefaultPricingMode = "inherit_discount"
}
if input.DefaultDiscountFactor == 0 {
input.DefaultDiscountFactor = 1
}
if input.Priority == 0 {
input.Priority = 100
}
var platform Platform
var configBytes []byte
err := s.pool.QueryRow(ctx, `
INSERT INTO integration_platforms (provider, platform_key, name, base_url, auth_type, credentials, config, default_pricing_mode, default_discount_factor, priority)
VALUES ($1, COALESCE(NULLIF($2, ''), 'platform_' || replace(gen_random_uuid()::text, '-', '')), $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id::text, provider, platform_key, name, COALESCE(base_url, ''), auth_type, status, priority,
default_pricing_mode, default_discount_factor::float8, config, created_at, updated_at`,
input.Provider, input.PlatformKey, input.Name, input.BaseURL, input.AuthType, credentials, config, input.DefaultPricingMode, input.DefaultDiscountFactor, input.Priority,
).Scan(
&platform.ID,
&platform.Provider,
&platform.PlatformKey,
&platform.Name,
&platform.BaseURL,
&platform.AuthType,
&platform.Status,
&platform.Priority,
&platform.DefaultPricingMode,
&platform.DefaultDiscountFactor,
&configBytes,
&platform.CreatedAt,
&platform.UpdatedAt,
)
if err != nil {
return Platform{}, err
}
platform.Config = decodeObject(configBytes)
return platform, nil
}
func (s *Store) ListModels(ctx context.Context) ([]PlatformModel, error) {
rows, err := s.pool.Query(ctx, `
SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.provider, p.name,
m.model_name, COALESCE(m.model_alias, ''), m.model_type, m.display_name,
m.capability_override, m.capabilities, m.pricing_mode, COALESCE(m.discount_factor, 0)::float8,
m.billing_config_override, m.billing_config, m.enabled, m.created_at, m.updated_at
FROM platform_models m
JOIN integration_platforms p ON p.id = m.platform_id
ORDER BY m.model_type ASC, m.model_name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
models := make([]PlatformModel, 0)
for rows.Next() {
var model PlatformModel
var capabilityOverride []byte
var capabilities []byte
var billingConfigOverride []byte
var billingConfig []byte
if err := rows.Scan(
&model.ID,
&model.PlatformID,
&model.BaseModelID,
&model.Provider,
&model.PlatformName,
&model.ModelName,
&model.ModelAlias,
&model.ModelType,
&model.DisplayName,
&capabilityOverride,
&capabilities,
&model.PricingMode,
&model.DiscountFactor,
&billingConfigOverride,
&billingConfig,
&model.Enabled,
&model.CreatedAt,
&model.UpdatedAt,
); err != nil {
return nil, err
}
model.CapabilityOverride = decodeObject(capabilityOverride)
model.Capabilities = decodeObject(capabilities)
model.BillingConfigOverride = decodeObject(billingConfigOverride)
model.BillingConfig = decodeObject(billingConfig)
models = append(models, model)
}
return models, rows.Err()
}
func (s *Store) ListCatalogProviders(ctx context.Context) ([]CatalogProvider, error) {
rows, err := s.pool.Query(ctx, `
SELECT id::text, provider_key, display_name, provider_type, capability_schema,
default_rate_limit_policy, status, created_at, updated_at
FROM model_catalog_providers
ORDER BY provider_key ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]CatalogProvider, 0)
for rows.Next() {
var item CatalogProvider
var capabilitySchema []byte
var rateLimitPolicy []byte
if err := rows.Scan(
&item.ID,
&item.ProviderKey,
&item.DisplayName,
&item.ProviderType,
&capabilitySchema,
&rateLimitPolicy,
&item.Status,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, err
}
item.CapabilitySchema = decodeObject(capabilitySchema)
item.DefaultRateLimitPolicy = decodeObject(rateLimitPolicy)
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) ListBaseModels(ctx context.Context) ([]BaseModel, error) {
rows, err := s.pool.Query(ctx, `
SELECT id::text, provider_key, canonical_model_key, provider_model_name, model_type, display_name,
capabilities, base_billing_config, default_rate_limit_policy, pricing_version,
status, created_at, updated_at
FROM base_model_catalog
ORDER BY provider_key ASC, model_type ASC, canonical_model_key ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]BaseModel, 0)
for rows.Next() {
var item BaseModel
var capabilities []byte
var billingConfig []byte
var rateLimitPolicy []byte
if err := rows.Scan(
&item.ID,
&item.ProviderKey,
&item.CanonicalModelKey,
&item.ProviderModelName,
&item.ModelType,
&item.DisplayName,
&capabilities,
&billingConfig,
&rateLimitPolicy,
&item.PricingVersion,
&item.Status,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, err
}
item.Capabilities = decodeObject(capabilities)
item.BaseBillingConfig = decodeObject(billingConfig)
item.DefaultRateLimitPolicy = decodeObject(rateLimitPolicy)
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) ListPricingRules(ctx context.Context) ([]PricingRule, error) {
rows, err := s.pool.Query(ctx, `
SELECT id::text, scope_type, COALESCE(scope_id::text, ''), resource_type, unit,
base_price::float8, currency, base_weight, dynamic_weight, created_at, updated_at
FROM model_pricing_rules
ORDER BY scope_type ASC, resource_type ASC, created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]PricingRule, 0)
for rows.Next() {
var item PricingRule
var baseWeight []byte
var dynamicWeight []byte
if err := rows.Scan(
&item.ID,
&item.ScopeType,
&item.ScopeID,
&item.ResourceType,
&item.Unit,
&item.BasePrice,
&item.Currency,
&baseWeight,
&dynamicWeight,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, err
}
item.BaseWeight = decodeObject(baseWeight)
item.DynamicWeight = decodeObject(dynamicWeight)
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) ListRateLimitWindows(ctx context.Context) ([]RateLimitWindow, error) {
rows, err := s.pool.Query(ctx, `
SELECT scope_type, scope_key, metric, window_start, limit_value::float8, used_value::float8,
reserved_value::float8, reset_at, updated_at
FROM gateway_rate_limit_counters
WHERE reset_at >= now() - interval '5 minutes'
ORDER BY window_start DESC, scope_type ASC, scope_key ASC, metric ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]RateLimitWindow, 0)
for rows.Next() {
var item RateLimitWindow
if err := rows.Scan(
&item.ScopeType,
&item.ScopeKey,
&item.Metric,
&item.WindowStart,
&item.LimitValue,
&item.UsedValue,
&item.ReservedValue,
&item.ResetAt,
&item.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) CreateTask(ctx context.Context, input CreateTaskInput, user *auth.User) (GatewayTask, error) {
requestBody, _ := json.Marshal(input.Request)
var task GatewayTask
var requestBytes []byte
var resultBytes []byte
var billingsBytes []byte
err := s.pool.QueryRow(ctx, `
INSERT INTO gateway_tasks (kind, user_id, tenant_id, model, request, status)
VALUES ($1, $2, NULLIF($3, ''), $4, $5, 'queued')
RETURNING id::text, kind, user_id, COALESCE(tenant_id, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at`,
input.Kind, user.ID, user.TenantID, input.Model, requestBody,
).Scan(&task.ID, &task.Kind, &task.UserID, &task.TenantID, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
return GatewayTask{}, err
}
task.Request = decodeObject(requestBytes)
task.Result = decodeObject(resultBytes)
task.Billings = decodeArray(billingsBytes)
return task, nil
}
func (s *Store) GetTask(ctx context.Context, taskID string) (GatewayTask, error) {
var task GatewayTask
var requestBytes []byte
var resultBytes []byte
var billingsBytes []byte
err := s.pool.QueryRow(ctx, `
SELECT id::text, kind, user_id, COALESCE(tenant_id, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at
FROM gateway_tasks
WHERE id=$1`, taskID,
).Scan(&task.ID, &task.Kind, &task.UserID, &task.TenantID, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
return GatewayTask{}, err
}
task.Request = decodeObject(requestBytes)
task.Result = decodeObject(resultBytes)
task.Billings = decodeArray(billingsBytes)
return task, nil
}
func IsNotFound(err error) bool {
return err == pgx.ErrNoRows
}
func decodeObject(bytes []byte) map[string]any {
if len(bytes) == 0 {
return nil
}
var out map[string]any
if err := json.Unmarshal(bytes, &out); err != nil {
return nil
}
return out
}
func decodeArray(bytes []byte) []any {
if len(bytes) == 0 {
return nil
}
var out []any
if err := json.Unmarshal(bytes, &out); err != nil {
return nil
}
return out
}

View File

@ -0,0 +1,333 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS model_catalog_providers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
provider_key text NOT NULL UNIQUE,
display_name text NOT NULL,
provider_type text NOT NULL DEFAULT 'openai_compatible',
capability_schema jsonb NOT NULL DEFAULT '{}'::jsonb,
default_rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL DEFAULT 'active',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_model_catalog_provider_status
ON model_catalog_providers(status);
CREATE TABLE IF NOT EXISTS base_model_catalog (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id uuid REFERENCES model_catalog_providers(id) ON DELETE SET NULL,
provider_key text NOT NULL,
canonical_model_key text NOT NULL UNIQUE,
provider_model_name text NOT NULL,
model_type text NOT NULL,
display_name text NOT NULL,
capabilities jsonb NOT NULL DEFAULT '{}'::jsonb,
base_billing_config jsonb NOT NULL DEFAULT '{}'::jsonb,
default_rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
pricing_version integer NOT NULL DEFAULT 1,
status text NOT NULL DEFAULT 'active',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_base_model_catalog_provider
ON base_model_catalog(provider_key, model_type, status);
CREATE INDEX IF NOT EXISTS idx_base_model_catalog_capabilities
ON base_model_catalog USING gin(capabilities);
CREATE TABLE IF NOT EXISTS integration_platforms (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
provider text NOT NULL,
platform_key text NOT NULL UNIQUE DEFAULT ('platform_' || replace(gen_random_uuid()::text, '-', '')),
name text NOT NULL,
base_url text,
auth_type text NOT NULL DEFAULT 'bearer',
credentials jsonb NOT NULL DEFAULT '{}'::jsonb,
config jsonb NOT NULL DEFAULT '{}'::jsonb,
default_pricing_mode text NOT NULL DEFAULT 'inherit_discount',
default_discount_factor numeric NOT NULL DEFAULT 1,
retry_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
priority integer NOT NULL DEFAULT 100,
dynamic_priority integer,
status text NOT NULL DEFAULT 'enabled',
disabled_reason text,
cooldown_until timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_integration_platforms_provider_status
ON integration_platforms(provider, status);
CREATE INDEX IF NOT EXISTS idx_integration_platforms_status_priority
ON integration_platforms(status, priority, dynamic_priority);
CREATE INDEX IF NOT EXISTS idx_integration_platforms_cooldown
ON integration_platforms(cooldown_until);
CREATE TABLE IF NOT EXISTS model_pricing_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scope_type text NOT NULL,
scope_id uuid,
resource_type text NOT NULL,
unit text NOT NULL,
base_price numeric NOT NULL,
currency text NOT NULL DEFAULT 'resource',
base_weight jsonb NOT NULL DEFAULT '{}'::jsonb,
dynamic_weight jsonb NOT NULL DEFAULT '{}'::jsonb,
effective_from timestamptz,
effective_to timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_model_pricing_scope
ON model_pricing_rules(scope_type, scope_id, resource_type);
CREATE INDEX IF NOT EXISTS idx_model_pricing_effective
ON model_pricing_rules(effective_from, effective_to);
CREATE TABLE IF NOT EXISTS platform_models (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
platform_id uuid NOT NULL REFERENCES integration_platforms(id) ON DELETE CASCADE,
base_model_id uuid REFERENCES base_model_catalog(id) ON DELETE SET NULL,
model_name text NOT NULL,
model_alias text,
model_type text NOT NULL,
display_name text NOT NULL DEFAULT '',
capability_override jsonb NOT NULL DEFAULT '{}'::jsonb,
capabilities jsonb NOT NULL DEFAULT '{}'::jsonb,
pricing_mode text NOT NULL DEFAULT 'inherit_discount',
discount_factor numeric,
billing_config_override jsonb NOT NULL DEFAULT '{}'::jsonb,
billing_config jsonb NOT NULL DEFAULT '{}'::jsonb,
permission_config jsonb NOT NULL DEFAULT '{}'::jsonb,
retry_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(platform_id, model_name, model_type)
);
CREATE INDEX IF NOT EXISTS idx_platform_models_base
ON platform_models(base_model_id);
CREATE INDEX IF NOT EXISTS idx_platform_models_lookup
ON platform_models(model_type, model_name, enabled);
CREATE INDEX IF NOT EXISTS idx_platform_models_alias
ON platform_models(model_alias);
CREATE INDEX IF NOT EXISTS idx_platform_models_capabilities
ON platform_models USING gin(capabilities);
CREATE TABLE IF NOT EXISTS gateway_tasks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
external_task_id text,
kind text NOT NULL,
run_mode text NOT NULL DEFAULT 'production',
user_id text NOT NULL,
tenant_id text,
api_key_id text,
model text NOT NULL,
model_type text,
request jsonb NOT NULL DEFAULT '{}'::jsonb,
normalized_request jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL DEFAULT 'queued',
queue_key text NOT NULL DEFAULT 'default',
priority integer NOT NULL DEFAULT 100,
idempotency_key text,
remote_task_id text,
remote_task_payload jsonb,
simulation_profile jsonb,
simulation_seed text,
locked_by text,
locked_at timestamptz,
heartbeat_at timestamptz,
next_run_at timestamptz NOT NULL DEFAULT now(),
attempt_count integer NOT NULL DEFAULT 0,
max_attempts integer NOT NULL DEFAULT 1,
result jsonb,
billings jsonb,
error text,
error_code text,
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_gateway_tasks_queue
ON gateway_tasks(status, next_run_at, priority, created_at);
CREATE INDEX IF NOT EXISTS idx_gateway_tasks_lease
ON gateway_tasks(status, heartbeat_at);
CREATE INDEX IF NOT EXISTS idx_gateway_tasks_user_created
ON gateway_tasks(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_gateway_tasks_external
ON gateway_tasks(external_task_id);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_tasks_idempotency
ON gateway_tasks(user_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS gateway_task_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task_id uuid NOT NULL REFERENCES gateway_tasks(id) ON DELETE CASCADE,
attempt_no integer NOT NULL,
platform_id uuid REFERENCES integration_platforms(id) ON DELETE SET NULL,
platform_model_id uuid REFERENCES platform_models(id) ON DELETE SET NULL,
client_id text,
queue_key text NOT NULL,
status text NOT NULL,
retryable boolean NOT NULL DEFAULT false,
simulated boolean NOT NULL DEFAULT false,
remote_task_id text,
request_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
response_snapshot jsonb,
error_code text,
error_message text,
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
UNIQUE(task_id, attempt_no)
);
CREATE INDEX IF NOT EXISTS idx_gateway_attempts_task
ON gateway_task_attempts(task_id);
CREATE INDEX IF NOT EXISTS idx_gateway_attempts_client
ON gateway_task_attempts(client_id, started_at DESC);
CREATE TABLE IF NOT EXISTS gateway_task_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task_id uuid NOT NULL REFERENCES gateway_tasks(id) ON DELETE CASCADE,
seq bigint NOT NULL,
event_type text NOT NULL,
status text,
phase text,
progress numeric,
message text,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
simulated boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(task_id, seq)
);
CREATE INDEX IF NOT EXISTS idx_gateway_events_task_created
ON gateway_task_events(task_id, created_at);
CREATE TABLE IF NOT EXISTS runtime_client_states (
client_id text PRIMARY KEY,
platform_id uuid REFERENCES integration_platforms(id) ON DELETE SET NULL,
provider text NOT NULL,
method_name text NOT NULL,
queue_key text NOT NULL,
running_count integer NOT NULL DEFAULT 0,
waiting_count integer NOT NULL DEFAULT 0,
limiter_ratio numeric NOT NULL DEFAULT 0,
cooldown_until timestamptz,
last_assigned_at timestamptz,
last_error text,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_runtime_client_queue
ON runtime_client_states(queue_key, cooldown_until);
CREATE INDEX IF NOT EXISTS idx_runtime_client_platform
ON runtime_client_states(platform_id);
CREATE TABLE IF NOT EXISTS gateway_upload_assets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task_id uuid REFERENCES gateway_tasks(id) ON DELETE SET NULL,
source text NOT NULL,
server_main_file_id text,
url text NOT NULL,
object_key text,
content_type text,
size bigint,
checksum text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_gateway_upload_task
ON gateway_upload_assets(task_id);
CREATE INDEX IF NOT EXISTS idx_gateway_upload_file
ON gateway_upload_assets(server_main_file_id);
CREATE TABLE IF NOT EXISTS gateway_retry_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scope_type text NOT NULL,
scope_key text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
policy jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(scope_type, scope_key)
);
CREATE TABLE IF NOT EXISTS gateway_rate_limit_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scope_type text NOT NULL,
scope_key text NOT NULL,
policy jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(scope_type, scope_key)
);
CREATE TABLE IF NOT EXISTS gateway_rate_limit_counters (
scope_type text NOT NULL,
scope_key text NOT NULL,
metric text NOT NULL,
window_start timestamptz NOT NULL,
limit_value numeric NOT NULL,
used_value numeric NOT NULL DEFAULT 0,
reserved_value numeric NOT NULL DEFAULT 0,
reset_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(scope_type, scope_key, metric, window_start)
);
CREATE TABLE IF NOT EXISTS gateway_concurrency_leases (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task_id uuid NOT NULL REFERENCES gateway_tasks(id) ON DELETE CASCADE,
attempt_id uuid REFERENCES gateway_task_attempts(id) ON DELETE SET NULL,
scope_type text NOT NULL,
scope_key text NOT NULL,
lease_value numeric NOT NULL DEFAULT 1,
acquired_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
released_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_concurrency_leases_active
ON gateway_concurrency_leases(scope_type, scope_key, released_at, expires_at);
CREATE INDEX IF NOT EXISTS idx_concurrency_leases_task
ON gateway_concurrency_leases(task_id);
CREATE TABLE IF NOT EXISTS settlement_outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task_id uuid NOT NULL REFERENCES gateway_tasks(id) ON DELETE CASCADE,
event_type text NOT NULL DEFAULT 'task.settlement.requested',
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(task_id, event_type)
);

39
apps/api/project.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "api",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/api",
"sourceRoot": "apps/api",
"projectType": "application",
"targets": {
"dev": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/api",
"command": "go run ./cmd/gateway"
}
},
"migrate": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/api",
"command": "go run ./cmd/migrate"
}
},
"test": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/coverage/apps/api"],
"options": {
"cwd": "apps/api",
"command": "go test ./..."
}
},
"build": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/dist/apps/api"],
"options": {
"cwd": "apps/api",
"command": "go build -o ../../dist/apps/api/easyai-ai-gateway ./cmd/gateway"
}
}
}
}

12
apps/web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EasyAI AI Gateway</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

24
apps/web/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "@easyai-ai-gateway/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5178",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0 --port 4178",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@easyai-ai-gateway/contracts": "workspace:*",
"@vitejs/plugin-react": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"vite": "^7.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.8.0"
}
}

38
apps/web/project.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "web",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/web",
"sourceRoot": "apps/web/src",
"projectType": "application",
"targets": {
"dev": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/web",
"command": "pnpm dev"
}
},
"build": {
"executor": "nx:run-commands",
"outputs": ["{projectRoot}/dist"],
"options": {
"cwd": "apps/web",
"command": "pnpm build"
}
},
"lint": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/web",
"command": "pnpm typecheck"
}
},
"test": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/web",
"command": "pnpm typecheck"
}
}
}
}

227
apps/web/src/App.tsx Normal file
View File

@ -0,0 +1,227 @@
import { useEffect, useMemo, useState } from 'react';
import type {
BaseModelCatalogItem,
CatalogProvider,
IntegrationPlatform,
PlatformModel,
PricingRule,
RateLimitWindow,
} from '@easyai-ai-gateway/contracts';
import {
getHealth,
listBaseModels,
listCatalogProviders,
listModels,
listPlatforms,
listPricingRules,
listRateLimitWindows,
type HealthResponse,
} from './api';
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
export function App() {
const [token, setToken] = useState('');
const [health, setHealth] = useState<HealthResponse | null>(null);
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
const [models, setModels] = useState<PlatformModel[]>([]);
const [providers, setProviders] = useState<CatalogProvider[]>([]);
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
const [state, setState] = useState<LoadState>('idle');
const [error, setError] = useState('');
useEffect(() => {
getHealth()
.then(setHealth)
.catch((err: Error) => setError(err.message));
}, []);
const stats = useMemo(() => {
const enabledPlatforms = platforms.filter((item) => item.status === 'enabled').length;
const enabledModels = models.filter((item) => item.enabled).length;
const activeProviders = providers.filter((item) => item.status === 'active').length;
const activeRateWindows = rateLimitWindows.filter((item) => item.resetAt >= new Date().toISOString()).length;
return [
{ label: '平台', value: platforms.length, tone: 'blue' },
{ label: '启用平台', value: enabledPlatforms, tone: 'green' },
{ label: '基准模型', value: baseModels.length, tone: 'violet' },
{ label: 'Provider', value: activeProviders || providers.length || enabledModels, tone: 'amber' },
{ label: '定价规则', value: pricingRules.length, tone: 'cyan' },
{ label: '限流窗口', value: activeRateWindows, tone: 'rose' },
];
}, [baseModels.length, models, platforms, pricingRules.length, providers, rateLimitWindows]);
async function refresh() {
setState('loading');
setError('');
try {
const [
platformResponse,
modelResponse,
providerResponse,
baseModelResponse,
pricingRuleResponse,
rateLimitWindowResponse,
] = await Promise.all([
listPlatforms(token),
listModels(token),
listCatalogProviders(token),
listBaseModels(token),
listPricingRules(token),
listRateLimitWindows(token),
]);
setPlatforms(platformResponse.items);
setModels(modelResponse.items);
setProviders(providerResponse.items);
setBaseModels(baseModelResponse.items);
setPricingRules(pricingRuleResponse.items);
setRateLimitWindows(rateLimitWindowResponse.items);
setState('ready');
} catch (err) {
setState('error');
setError(err instanceof Error ? err.message : '加载失败');
}
}
return (
<main className="page">
<header className="topbar">
<div>
<p className="eyebrow">EasyAI</p>
<h1>AI Gateway Console</h1>
</div>
<div className="health" data-ok={health?.ok === true}>
<span />
{health?.service ?? 'API 未连接'}
</div>
</header>
<section className="toolbar" aria-label="授权与刷新">
<label className="tokenField">
<span>Server Main JWT</span>
<input
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="粘贴 server-main access_token"
/>
</label>
<button type="button" onClick={refresh} disabled={!token || state === 'loading'}>
{state === 'loading' ? '加载中' : '刷新'}
</button>
</section>
{error && <div className="notice">{error}</div>}
<section className="metrics" aria-label="概览">
{stats.map((item) => (
<div className="metric" data-tone={item.tone} key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</section>
<section className="split">
<div className="panel">
<div className="panelHeader">
<h2></h2>
<span>{platforms.length}</span>
</div>
<div className="table" role="table">
<div className="row head" role="row">
<span>Provider</span>
<span></span>
<span></span>
<span></span>
</div>
{platforms.map((item) => (
<div className="row" role="row" key={item.id}>
<span>{item.provider}</span>
<span>{item.name}</span>
<span>{item.status}</span>
<span>{item.priority}</span>
</div>
))}
{!platforms.length && <p className="empty"></p>}
</div>
</div>
<div className="panel">
<div className="panelHeader">
<h2></h2>
<span>{models.length}</span>
</div>
<div className="table" role="table">
<div className="row head" role="row">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{models.map((item) => (
<div className="row" role="row" key={item.id}>
<span>{item.modelName}</span>
<span>{item.modelType}</span>
<span>{item.provider ?? item.platformName}</span>
<span>{item.enabled ? '是' : '否'}</span>
</div>
))}
{!models.length && <p className="empty"></p>}
</div>
</div>
</section>
<section className="split secondary">
<div className="panel">
<div className="panelHeader">
<h2></h2>
<span>{baseModels.length}</span>
</div>
<div className="table catalogTable" role="table">
<div className="row head" role="row">
<span>Provider</span>
<span></span>
<span></span>
<span></span>
</div>
{baseModels.map((item) => (
<div className="row" role="row" key={item.id}>
<span>{item.providerKey}</span>
<span>{item.canonicalModelKey}</span>
<span>{item.modelType}</span>
<span>{item.pricingVersion}</span>
</div>
))}
{!baseModels.length && <p className="empty"></p>}
</div>
</div>
<div className="panel">
<div className="panelHeader">
<h2>TPM/RPM </h2>
<span>{rateLimitWindows.length}</span>
</div>
<div className="table rateTable" role="table">
<div className="row head" role="row">
<span>Scope</span>
<span></span>
<span>使</span>
<span></span>
</div>
{rateLimitWindows.map((item) => (
<div className="row" role="row" key={`${item.scopeType}:${item.scopeKey}:${item.metric}:${item.windowStart}`}>
<span>{item.scopeKey}</span>
<span>{item.metric}</span>
<span>{item.usedValue}/{item.limitValue}</span>
<span>{item.reservedValue}</span>
</div>
))}
{!rateLimitWindows.length && <p className="empty"></p>}
</div>
</div>
</section>
</main>
);
}

60
apps/web/src/api.ts Normal file
View File

@ -0,0 +1,60 @@
import type {
BaseModelCatalogItem,
CatalogProvider,
IntegrationPlatform,
ListResponse,
PlatformModel,
PricingRule,
RateLimitWindow,
} from '@easyai-ai-gateway/contracts';
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
export interface HealthResponse {
ok: boolean;
service: string;
env: string;
}
export async function getHealth(): Promise<HealthResponse> {
return request<HealthResponse>('/healthz', { auth: false });
}
export async function listPlatforms(token: string): Promise<ListResponse<IntegrationPlatform>> {
return request<ListResponse<IntegrationPlatform>>('/api/v1/platforms', { token });
}
export async function listModels(token: string): Promise<ListResponse<PlatformModel>> {
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
}
export async function listCatalogProviders(token: string): Promise<ListResponse<CatalogProvider>> {
return request<ListResponse<CatalogProvider>>('/api/v1/catalog/providers', { token });
}
export async function listBaseModels(token: string): Promise<ListResponse<BaseModelCatalogItem>> {
return request<ListResponse<BaseModelCatalogItem>>('/api/v1/catalog/base-models', { token });
}
export async function listPricingRules(token: string): Promise<ListResponse<PricingRule>> {
return request<ListResponse<PricingRule>>('/api/v1/pricing/rules', { token });
}
export async function listRateLimitWindows(token: string): Promise<ListResponse<RateLimitWindow>> {
return request<ListResponse<RateLimitWindow>>('/api/v1/runtime/rate-limit-windows', { token });
}
async function request<T>(path: string, options: { token?: string; auth?: boolean } = {}): Promise<T> {
const headers: Record<string, string> = {};
if (options.auth !== false && options.token) {
headers.Authorization = `Bearer ${options.token}`;
}
const response = await fetch(`${API_BASE}${path}`, {
headers,
});
if (!response.ok) {
const body = await response.text();
throw new Error(body || `Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}

10
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

286
apps/web/src/styles.css Normal file
View File

@ -0,0 +1,286 @@
:root {
color: #172033;
background: #f5f7fb;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input {
font: inherit;
}
.page {
width: min(1180px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 48px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 4px;
color: #667085;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 30px;
line-height: 1.2;
}
h2 {
font-size: 18px;
}
.health {
display: inline-flex;
align-items: center;
gap: 8px;
color: #667085;
font-size: 14px;
}
.health span {
width: 9px;
height: 9px;
border-radius: 50%;
background: #c43f3f;
}
.health[data-ok="true"] span {
background: #1b8a5a;
}
.toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: end;
padding: 16px;
margin-bottom: 18px;
border: 1px solid #dde3ee;
border-radius: 8px;
background: #ffffff;
}
.tokenField {
display: grid;
gap: 8px;
color: #4a5568;
font-size: 13px;
font-weight: 700;
}
.tokenField input {
width: 100%;
min-height: 42px;
padding: 0 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
color: #172033;
outline: none;
}
.tokenField input:focus {
border-color: #2b6cb0;
box-shadow: 0 0 0 3px rgba(43, 108, 176, 0.14);
}
button {
min-height: 42px;
padding: 0 18px;
border: 0;
border-radius: 6px;
background: #214e8a;
color: #ffffff;
font-weight: 700;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.notice {
padding: 12px 14px;
margin-bottom: 18px;
border: 1px solid #f0b8b8;
border-radius: 8px;
background: #fff1f1;
color: #9b2c2c;
font-size: 14px;
}
.metrics {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.metric {
min-height: 96px;
padding: 16px;
border: 1px solid #dde3ee;
border-radius: 8px;
background: #ffffff;
}
.metric span {
color: #667085;
font-size: 13px;
font-weight: 700;
}
.metric strong {
display: block;
margin-top: 10px;
font-size: 30px;
}
.metric[data-tone="blue"] {
border-top: 3px solid #2b6cb0;
}
.metric[data-tone="green"] {
border-top: 3px solid #1b8a5a;
}
.metric[data-tone="violet"] {
border-top: 3px solid #6b46c1;
}
.metric[data-tone="amber"] {
border-top: 3px solid #b7791f;
}
.metric[data-tone="cyan"] {
border-top: 3px solid #087f8c;
}
.metric[data-tone="rose"] {
border-top: 3px solid #b8325f;
}
.split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.split.secondary {
margin-top: 18px;
}
.panel {
overflow: hidden;
border: 1px solid #dde3ee;
border-radius: 8px;
background: #ffffff;
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #e7ecf4;
}
.panelHeader span {
color: #667085;
font-size: 13px;
font-weight: 700;
}
.table {
display: grid;
}
.row {
display: grid;
grid-template-columns: 1.2fr 1.2fr 0.8fr 0.6fr;
gap: 12px;
min-height: 46px;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid #edf1f7;
color: #2d3748;
font-size: 14px;
}
.row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row.head {
min-height: 38px;
background: #f8fafc;
color: #667085;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.empty {
padding: 22px 16px;
color: #667085;
font-size: 14px;
}
@media (max-width: 860px) {
.topbar,
.toolbar {
grid-template-columns: 1fr;
}
.topbar {
align-items: flex-start;
flex-direction: column;
}
.metrics,
.split {
grid-template-columns: 1fr;
}
.row {
grid-template-columns: 1fr 0.8fr;
padding: 10px 16px;
}
}
@media (min-width: 861px) and (max-width: 1180px) {
.metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

21
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

9
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,9 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
server: {
port: 5178,
},
});

1584
docs/design.md Normal file

File diff suppressed because it is too large Load Diff

49
docs/migration-plan.md Normal file
View File

@ -0,0 +1,49 @@
# integration-platform 迁移实施计划
## 第 1 周:基础设施
- 在 Agent memory 的 `easyai-pgvector` 实例上建立独立数据库 `easyai_ai_gateway` 和 AI Gateway 表,不直接使用 `easyai_memory` 记忆库。正式 EasyAI compose 默认账号为 `easyai` / `easyai2025`
- 完成 JWT / API Key 授权验证。
- 完成基准 provider、基准模型库、平台与模型管理 API。
- 完成基准定价、平台默认折扣、平台模型覆盖的 schema。
- React 控制台接入平台、基准模型、TPM/RPM 限流窗口列表。
## 第 2 周:路由行为复刻
- 从旧代码抽取以下行为测试:
- 同名模型平台权限过滤。
- `assignClientsByModelName` 候选排序。
- `assignClientsByProviderMethod` provider-level 负载均衡。
- estimated billing 使用真实候选集。
- 建立 TPM/RPM/并发限流 fixtures覆盖预占、释放、失败切换重新计数。
- Go 侧实现 router并用 fixtures 对齐旧行为。
## 第 3 周:核心 provider
- 先迁 OpenAI-compatible / Universal。
- 再迁生图、生视频主 provider。
- 每个 provider 建 contract test。
## 第 4 周:任务链路
- 实现队列、任务状态、SSE 进度。
- 实现 TPM/RPM 一分钟窗口计数和并发 lease 恢复。
- 打通 Chat、生图、生视频端到端。
- 生成结算事件,接入 server-main 幂等扣费。
## 第 5 周:切流
- server-main `OpenaiService` 加 Gateway client。
- 开启 shadow / dry-run 比对。
- 前端增加 `VITE_GATEWAY_API_BASE_URL`
- 灰度切流,观察任务成功率、平均排队、扣费一致性。
## 风险控制
- 不做 first-match 回退,所有候选选择都要有行为测试。
- API Key 不在 Gateway 落库。
- OSS 密钥不进入 Gateway文件统一调用 server-main 开放上传接口。
- 平台模型没有自定义价格时必须 follow 基准模型,不能隐式按 0 计费。
- estimated billing 与真实结算必须使用同一个 effective pricing resolver。
- 结算事件必须幂等和可重试。
- 任务推送与余额/历史推送拆分,避免重新耦合回 server-main。

View File

@ -0,0 +1,87 @@
# server-main 对接清单
## 1. 需要在 server-main 增加的内部接口
### 1.1 API Key 校验
```http
POST /internal/platform/auth/verify-api-key
Authorization: Bearer ${SERVER_MAIN_INTERNAL_TOKEN}
Content-Type: application/json
{ "apiKey": "sk-..." }
```
返回:
```json
{
"sub": "user-id",
"username": "demo",
"role": ["user"],
"tenantId": null,
"apiKeyId": "key-id",
"apiKeySecret": "sk-...",
"apiKeyName": "production-key"
}
```
### 1.2 文件上传
```http
POST /v1/files/upload
Authorization: Bearer ${USER_JWT_OR_SK}
Content-Type: multipart/form-data
file=@result.png
```
AI Gateway 不维护独立 OSS 配置,也不向 `server-main` 申请预签名。需要上传本地中间产物、provider 临时 URL 转存、base64 解码结果时,统一组装 multipart 请求调用主服务开放上传接口,并记录主服务返回的 file id / URL / object key。
### 1.3 结算事件
```http
POST /internal/platform/settlements
Authorization: Bearer ${SERVER_MAIN_INTERNAL_TOKEN}
Content-Type: application/json
Idempotency-Key: ${eventId}
```
结算事件中的 `billings` 由 AI Gateway 根据基准模型库、平台折扣、平台模型覆盖后的 effective pricing 计算。`server-main` 仍负责余额、资源包、账单锁和消费流水,不重新推导模型价格,只按幂等事件扣费。
## 2. server-main OpenaiService 薄门面
保留现有对内方法签名,内部新增 `AiGatewayClient`
- `createChatCompletion`
- `generateImage`
- `editImage`
- `generateVideo`
- `createEmbedding`
- `estimateBilling`
切流开关:
```env
AI_GATEWAY_ENABLED=true
AI_GATEWAY_BASE_URL=http://easyai-ai-gateway:8088
AI_GATEWAY_INTERNAL_TOKEN=change-me
```
## 3. 迁移期双写与比对
高风险接口可短期 shadow
1. 主路径仍走旧实现。
2. 异步把同一请求投递到 Gateway dry-run。
3. 比对候选平台、TPM/RPM/并发限流决策、预估扣费、参数预处理结果。
4. 结果稳定后切主路径。
## 4. 不迁移项
- `refresh_token` 签发和刷新。
- 用户余额查询。
- 用户 API Key 的创建、撤销、列表。
- 账单锁、扣费流水。
- OSS/COS/S3 上传配置和实际文件落库。
- 对话与绘图历史最终落库。

5
go.work Normal file
View File

@ -0,0 +1,5 @@
go 1.23
use (
./apps/api
)

16
go.work.sum Normal file
View File

@ -0,0 +1,16 @@
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=

19
nx.json Normal file
View File

@ -0,0 +1,19 @@
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": ["default"],
"sharedGlobals": []
},
"targetDefaults": {
"build": {
"cache": true
},
"test": {
"cache": true
},
"lint": {
"cache": true
}
}
}

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "easyai-ai-gateway",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.18.1",
"scripts": {
"dev": "scripts/dev.sh",
"build": "nx run-many -t build -p api web",
"test": "nx run-many -t test -p api web",
"lint": "nx run-many -t lint -p web contracts",
"db:create": "scripts/create-database.sh",
"migrate": "nx run api:migrate"
},
"devDependencies": {
"@nx/vite": "^21.0.0",
"@vitejs/plugin-react": "^5.0.0",
"nx": "^21.0.0",
"typescript": "^5.8.0",
"vite": "^7.0.0"
}
}

View File

@ -0,0 +1,17 @@
{
"name": "@easyai-ai-gateway/contracts",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.8.0"
}
}

View File

@ -0,0 +1,23 @@
{
"name": "contracts",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "packages/contracts",
"sourceRoot": "packages/contracts/src",
"projectType": "library",
"targets": {
"lint": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/contracts",
"command": "tsc --noEmit"
}
},
"build": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/contracts",
"command": "tsc --noEmit"
}
}
}
}

View File

@ -0,0 +1,163 @@
export type Permission = 'public' | 'basic' | 'creat' | 'power' | 'manager';
export interface AuthUser {
sub: string;
username: string;
role?: string[];
tenantId?: string | null;
sso_id?: string;
apiKeyId?: string;
apiKeySecret?: string;
apiKeyName?: string;
}
export interface IntegrationPlatform {
id: string;
provider: string;
platformKey: string;
name: string;
baseUrl?: string;
authType: string;
status: 'enabled' | 'disabled' | string;
priority: number;
defaultPricingMode: PricingMode;
defaultDiscountFactor: number;
config?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
export type PricingMode = 'inherit' | 'inherit_discount' | 'custom';
export type RateLimitMetric =
| 'tpm_total'
| 'tpm_input'
| 'tpm_output'
| 'rpm'
| 'concurrent'
| 'queue_size';
export interface CatalogProvider {
id: string;
providerKey: string;
displayName: string;
providerType: string;
capabilitySchema?: Record<string, unknown>;
defaultRateLimitPolicy?: RateLimitPolicy;
status: 'active' | 'deprecated' | 'hidden' | string;
createdAt: string;
updatedAt: string;
}
export interface BaseModelCatalogItem {
id: string;
providerKey: string;
canonicalModelKey: string;
providerModelName: string;
modelType: 'chat' | 'image' | 'video' | 'audio' | 'embedding' | 'music' | 'digital_human' | 'model_3d' | string;
displayName: string;
capabilities?: Record<string, unknown>;
baseBillingConfig?: BillingConfig;
defaultRateLimitPolicy?: RateLimitPolicy;
pricingVersion: number;
status: 'active' | 'deprecated' | 'hidden' | string;
createdAt: string;
updatedAt: string;
}
export interface PricingRule {
id: string;
scopeType: 'base_model' | 'platform' | 'platform_model' | string;
scopeId?: string;
resourceType:
| 'text_input'
| 'text_output'
| 'text_total'
| 'image'
| 'video'
| 'audio'
| 'music'
| 'digital_human'
| 'model'
| string;
unit: '1k_tokens' | 'image' | '5s' | 'second' | 'character_1k' | 'item' | string;
basePrice: number;
currency: 'resource' | 'credit' | 'cny' | 'usd' | string;
baseWeight?: Record<string, unknown>;
dynamicWeight?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
export interface RateLimitRule {
metric: RateLimitMetric;
limit: number;
windowSeconds?: number;
leaseTtlSeconds?: number;
consume?: 'fixed_window' | 'reserve_then_reconcile' | string;
}
export interface RateLimitPolicy {
rules?: RateLimitRule[];
[key: string]: unknown;
}
export interface BillingConfig {
resourceType?: string;
basePrice?: number;
baseWeight?: Record<string, unknown>;
dynamicWeight?: Record<string, unknown>;
[key: string]: unknown;
}
export interface PlatformModel {
id: string;
platformId: string;
baseModelId?: string;
provider?: string;
platformName?: string;
modelName: string;
modelAlias?: string;
modelType: 'chat' | 'image' | 'video' | 'audio' | 'embedding' | string;
displayName: string;
capabilityOverride?: Record<string, unknown>;
capabilities?: Record<string, unknown>;
pricingMode: PricingMode;
discountFactor?: number;
billingConfigOverride?: BillingConfig;
billingConfig?: BillingConfig;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface RateLimitWindow {
scopeType: string;
scopeKey: string;
metric: RateLimitMetric | string;
windowStart: string;
limitValue: number;
usedValue: number;
reservedValue: number;
resetAt: string;
updatedAt: string;
}
export interface GatewayTask {
id: string;
kind: string;
userId: string;
tenantId?: string;
model: string;
request?: Record<string, unknown>;
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | string;
result?: Record<string, unknown>;
billings?: unknown[];
error?: string;
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
items: T[];
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*.ts"]
}

4172
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- apps/web
- packages/*

22
scripts/create-database.sh Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
CONTAINER="${AI_GATEWAY_PG_CONTAINER:-easyai-pgvector}"
PGUSER="${AI_GATEWAY_PG_USER:-easyai}"
DB_NAME="${AI_GATEWAY_DATABASE_NAME:-easyai_ai_gateway}"
exists="$(
docker exec "$CONTAINER" \
psql -U "$PGUSER" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" \
| tr -d '[:space:]'
)"
if [[ "$exists" == "1" ]]; then
echo "[ai-gateway] database already exists: ${DB_NAME}"
exit 0
fi
docker exec "$CONTAINER" \
psql -U "$PGUSER" -d postgres -c "CREATE DATABASE \"${DB_NAME}\""
echo "[ai-gateway] database created: ${DB_NAME}"

13
scripts/dev.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
export AI_GATEWAY_PG_CONTAINER="${AI_GATEWAY_PG_CONTAINER:-easyai-pgvector}"
export AI_GATEWAY_PG_USER="${AI_GATEWAY_PG_USER:-easyai}"
export AI_GATEWAY_DATABASE_NAME="${AI_GATEWAY_DATABASE_NAME:-easyai_ai_gateway}"
export AI_GATEWAY_DATABASE_URL="${AI_GATEWAY_DATABASE_URL:-postgresql://easyai:easyai2025@localhost:5432/easyai_ai_gateway?sslmode=disable}"
echo "[ai-gateway] using database: ${AI_GATEWAY_DATABASE_URL}"
scripts/create-database.sh
pnpm nx run api:migrate
exec pnpm nx run-many -t dev -p api web --parallel=2