Initial project scaffold
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
6323e70e49
25
.env.example
Normal file
25
.env.example
Normal 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
12
.gitignore
vendored
Normal 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
60
README.md
Normal 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 + TSX,UI 体系按 `shadcn-ui` / Radix / Tailwind 方向沉淀,先提供运维控制台骨架。
|
||||
- Monorepo:Nx 负责任务编排,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)。
|
||||
56
apps/api/cmd/gateway/main.go
Normal file
56
apps/api/cmd/gateway/main.go
Normal 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")
|
||||
}
|
||||
85
apps/api/cmd/migrate/main.go
Normal file
85
apps/api/cmd/migrate/main.go
Normal 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
8
apps/api/go.mod
Normal 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
|
||||
)
|
||||
222
apps/api/internal/auth/auth.go
Normal file
222
apps/api/internal/auth/auth.go
Normal 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
|
||||
}
|
||||
}
|
||||
99
apps/api/internal/config/config.go
Normal file
99
apps/api/internal/config/config.go
Normal 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
|
||||
}
|
||||
}
|
||||
218
apps/api/internal/httpapi/handlers.go
Normal file
218
apps/api/internal/httpapi/handlers.go
Normal 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",
|
||||
})
|
||||
}
|
||||
}
|
||||
28
apps/api/internal/httpapi/response.go
Normal file
28
apps/api/internal/httpapi/response.go
Normal 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)
|
||||
}
|
||||
78
apps/api/internal/httpapi/server.go
Normal file
78
apps/api/internal/httpapi/server.go
Normal 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)
|
||||
})
|
||||
}
|
||||
508
apps/api/internal/store/postgres.go
Normal file
508
apps/api/internal/store/postgres.go
Normal 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
|
||||
}
|
||||
333
apps/api/migrations/0001_init.sql
Normal file
333
apps/api/migrations/0001_init.sql
Normal 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
39
apps/api/project.json
Normal 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
12
apps/web/index.html
Normal 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
24
apps/web/package.json
Normal 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
38
apps/web/project.json
Normal 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
227
apps/web/src/App.tsx
Normal 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
60
apps/web/src/api.ts
Normal 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
10
apps/web/src/main.tsx
Normal 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
286
apps/web/src/styles.css
Normal 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
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
apps/web/tsconfig.json
Normal file
21
apps/web/tsconfig.json
Normal 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
9
apps/web/vite.config.ts
Normal 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
1584
docs/design.md
Normal file
File diff suppressed because it is too large
Load Diff
49
docs/migration-plan.md
Normal file
49
docs/migration-plan.md
Normal 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。
|
||||
87
docs/server-main-integration.md
Normal file
87
docs/server-main-integration.md
Normal 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 上传配置和实际文件落库。
|
||||
- 对话与绘图历史最终落库。
|
||||
16
go.work.sum
Normal file
16
go.work.sum
Normal 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
19
nx.json
Normal 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
21
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
packages/contracts/package.json
Normal file
17
packages/contracts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
packages/contracts/project.json
Normal file
23
packages/contracts/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
163
packages/contracts/src/index.ts
Normal file
163
packages/contracts/src/index.ts
Normal 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[];
|
||||
}
|
||||
11
packages/contracts/tsconfig.json
Normal file
11
packages/contracts/tsconfig.json
Normal 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
4172
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- apps/web
|
||||
- packages/*
|
||||
22
scripts/create-database.sh
Executable file
22
scripts/create-database.sh
Executable 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
13
scripts/dev.sh
Executable 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
|
||||
Loading…
Reference in New Issue
Block a user