Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,8 @@ docker run --rm -p 8080:8080 --env-file .env gomodel

| Endpoint | Method | Description |
| --------------------- | ------ | ----------------------------------------------- |
| `/health` | GET | Health check |
| `/health` | GET | Liveness check (always 200 while the process serves) |
| `/health/ready` | GET | Readiness check: pings storage (503 if down) and Redis cache (degraded, still 200) |
| `/metrics` | GET | Prometheus metrics (experimental, when enabled) |
| `/swagger/index.html` | GET | Swagger UI (when enabled) |

Expand Down
90 changes: 89 additions & 1 deletion cmd/gomodel/docs/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 12 additions & 2 deletions cmd/gomodel/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,31 @@ import (
"time"
)

const defaultHealthTimeout = 2 * time.Second
const (
defaultHealthTimeout = 2 * time.Second
// defaultReadyTimeout is larger than the server's per-probe readinessProbeTimeout
// so a slow dependency yields a clean not_ready/degraded response instead of
// the client cutting the connection first.
defaultReadyTimeout = 4 * time.Second
)

type cliOptions struct {
Version bool
Health bool
HealthTimeout time.Duration
Ready bool
ReadyTimeout time.Duration
}

func parseCLI(args []string, output io.Writer) (cliOptions, error) {
var opts cliOptions
flags := flag.NewFlagSet("gomodel", flag.ContinueOnError)
flags.SetOutput(output)
flags.BoolVar(&opts.Version, "version", false, "Print version information")
flags.BoolVar(&opts.Health, "health", false, "Check the local GoModel health endpoint and exit")
flags.BoolVar(&opts.Health, "health", false, "Check the local GoModel health (liveness) endpoint and exit")
flags.DurationVar(&opts.HealthTimeout, "health-timeout", defaultHealthTimeout, "Timeout for --health")
flags.BoolVar(&opts.Ready, "ready", false, "Check the local GoModel readiness endpoint and exit")
flags.DurationVar(&opts.ReadyTimeout, "ready-timeout", defaultReadyTimeout, "Timeout for --ready")
if err := flags.Parse(args); err != nil {
return opts, err
}
Expand Down
52 changes: 51 additions & 1 deletion cmd/gomodel/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,26 @@ func runHealthProbe(timeout time.Duration) error {
return checkHealthEndpoint(context.Background(), client, endpoint)
}

func runReadyProbe(timeout time.Duration) error {
result, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
}

endpoint := probeURL(result.Config.Server, "/health/ready")
if timeout <= 0 {
timeout = defaultReadyTimeout
}

client := &http.Client{Timeout: timeout}
return checkReadyEndpoint(context.Background(), client, endpoint)
}

func healthProbeURL(server config.ServerConfig) string {
return probeURL(server, "/health")
}

func probeURL(server config.ServerConfig, path string) string {
port := strings.TrimSpace(server.Port)
if port == "" {
port = "8080"
Expand All @@ -38,7 +57,7 @@ func healthProbeURL(server config.ServerConfig) string {
u := url.URL{
Scheme: "http",
Host: net.JoinHostPort("127.0.0.1", port),
Path: config.JoinBasePath(server.BasePath, "/health"),
Path: config.JoinBasePath(server.BasePath, path),
}
return u.String()
}
Expand Down Expand Up @@ -70,3 +89,34 @@ func checkHealthEndpoint(ctx context.Context, client *http.Client, endpoint stri
}
return nil
}

// checkReadyEndpoint treats both "ready" and "degraded" as serviceable (exit 0)
// and only fails on "not_ready" (HTTP 503) — a degraded gateway still handles
// traffic, so it should remain in rotation.
func checkReadyEndpoint(ctx context.Context, client *http.Client, endpoint string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("build readiness request: %w", err)
}

resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request readiness endpoint: %w", err)
}
defer resp.Body.Close()

var body struct {
Status string `json:"status"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1024)).Decode(&body); err != nil {
return fmt.Errorf("decode readiness response: %w", err)
}

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("readiness endpoint returned HTTP %d (status %q)", resp.StatusCode, body.Status)
}
if body.Status != "ready" && body.Status != "degraded" {
return fmt.Errorf("readiness status is %q", body.Status)
}
return nil
}
10 changes: 9 additions & 1 deletion cmd/gomodel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func startApplication(application lifecycleApp, addr string) error {

// @title GoModel API
// @version 1.0
// @description AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, OpenRouter, DeepSeek, Z.ai, xAI, MiniMax, Xiaomi MiMo, Oracle, Ollama, Bailian). Drop-in OpenAI-compatible API.
// @description AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, OpenRouter, DeepSeek, Z.ai, xAI, MiniMax, Xiaomi MiMo, OpenCode Go, Oracle, Ollama, Bailian). Drop-in OpenAI-compatible API.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep the Swagger provider description from drifting.

Line 80 lists providers but omits providers registered in this same file (e.g., Azure, Bedrock, Vertex, vLLM), so the generated API description can be misleading. Prefer a non-exhaustive description or keep the list fully synchronized.

Proposed fix
-// `@description`    AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, OpenRouter, DeepSeek, Z.ai, xAI, MiniMax, Xiaomi MiMo, OpenCode Go, Oracle, Ollama, Bailian). Drop-in OpenAI-compatible API.
+// `@description`    AI gateway routing requests to multiple LLM providers. Drop-in OpenAI-compatible API.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// @description AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, OpenRouter, DeepSeek, Z.ai, xAI, MiniMax, Xiaomi MiMo, OpenCode Go, Oracle, Ollama, Bailian). Drop-in OpenAI-compatible API.
// `@description` AI gateway routing requests to multiple LLM providers. Drop-in OpenAI-compatible API.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/gomodel/main.go` at line 80, The Swagger description at line 80 in
cmd/gomodel/main.go lists specific LLM providers but omits providers that are
actually registered in the same file, such as Azure, Bedrock, Vertex, and vLLM.
This causes a mismatch between the documented and actual supported providers.
Either update the description to be non-exhaustive by removing the explicit
provider list and keeping it generic, or fully synchronize the description to
include all registered providers (Azure, Bedrock, Vertex, vLLM along with the
currently listed ones) to ensure the generated API documentation remains
accurate and maintainable.

// @BasePath /
// @schemes http
// @securityDefinitions.apikey BearerAuth
Expand All @@ -104,6 +104,14 @@ func main() {
os.Exit(0)
}

if opts.Ready {
if err := runReadyProbe(opts.ReadyTimeout); err != nil {
fmt.Fprintf(os.Stderr, "readiness check failed: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}

if err := configureLogging(os.Stderr); err != nil {
fmt.Fprintf(os.Stderr, "failed to configure logging: %v\n", err)
os.Exit(1)
Expand Down
104 changes: 104 additions & 0 deletions cmd/gomodel/ready_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package main

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"gomodel/config"
)

func TestReadyProbeURL(t *testing.T) {
tests := []struct {
name string
server config.ServerConfig
expected string
}{
{
name: "default base path",
server: config.ServerConfig{Port: "8080", BasePath: "/"},
expected: "http://127.0.0.1:8080/health/ready",
},
{
name: "prefixed base path",
server: config.ServerConfig{Port: "9090", BasePath: "/g"},
expected: "http://127.0.0.1:9090/g/health/ready",
},
{
name: "empty port falls back to default",
server: config.ServerConfig{BasePath: "/"},
expected: "http://127.0.0.1:8080/health/ready",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := probeURL(tt.server, "/health/ready")
if got != tt.expected {
t.Fatalf("probeURL() = %q, want %q", got, tt.expected)
}
})
}
}

func TestCheckReadyEndpoint(t *testing.T) {
tests := []struct {
name string
statusCode int
body string
wantErr string
}{
{
name: "ready",
statusCode: http.StatusOK,
body: `{"status":"ready"}`,
},
{
name: "degraded still serviceable",
statusCode: http.StatusOK,
body: `{"status":"degraded"}`,
},
{
name: "not ready",
statusCode: http.StatusServiceUnavailable,
body: `{"status":"not_ready"}`,
wantErr: "HTTP 503",
},
{
name: "bad response body",
statusCode: http.StatusOK,
body: `not json`,
wantErr: "decode readiness response",
},
{
name: "unexpected status with 200",
statusCode: http.StatusOK,
body: `{"status":"starting"}`,
wantErr: `readiness status is "starting"`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tt.statusCode)
_, _ = fmt.Fprint(w, tt.body)
}))
defer server.Close()

err := checkReadyEndpoint(context.Background(), server.Client(), server.URL)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("checkReadyEndpoint() error = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("checkReadyEndpoint() error = %v, want substring %q", err, tt.wantErr)
}
})
}
}
Loading