iop/apps/edge/internal/edgecmd/smoke_openai.go

248 lines
8.1 KiB
Go

package edgecmd
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/spf13/cobra"
)
func smokeCmd() *cobra.Command {
c := &cobra.Command{
Use: "smoke",
Short: "Run verification and smoke tests",
Long: `Run diagnostic smoke tests to verify the connectivity and functionality of IOP Edge components.
Typical local flow:
1. iop-edge config init
2. iop-edge node register <node-id>
3. iop-edge serve
4. iop-edge smoke openai --model <model>`,
Example: ` iop-edge smoke openai --model gemma4:26b
iop-edge smoke openai --model gemma4:26b --base-url http://127.0.0.1:8080`,
}
c.AddCommand(smokeOpenAICmd())
return c
}
var (
smokeModel string
smokeBaseURL string
smokePrompt string
smokeTimeout string
)
type openAIModel struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
}
type openAIModelsResponse struct {
Object string `json:"object"`
Data []openAIModel `json:"data"`
}
type chatCompletionMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatCompletionChoice struct {
Index int `json:"index"`
Message chatCompletionMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type chatCompletionUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type chatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []chatCompletionChoice `json:"choices"`
Usage *chatCompletionUsage `json:"usage"`
}
func resolveSmokeBaseURL(cmd *cobra.Command) (string, error) {
if smokeBaseURL != "" {
return strings.TrimSuffix(smokeBaseURL, "/"), nil
}
cfg, _, err := LoadEdgeConfig(cmd)
if err != nil {
return "", fmt.Errorf("failed to load edge config to resolve base URL (pass --base-url to override): %w", err)
}
listen := cfg.OpenAI.Listen
if listen == "" {
listen = "127.0.0.1:8080"
}
host, port, err := net.SplitHostPort(listen)
if err != nil {
return "http://127.0.0.1:8080", nil
}
if host == "0.0.0.0" || host == "" {
host = "127.0.0.1"
}
return "http://" + net.JoinHostPort(host, port), nil
}
func smokeOpenAICmd() *cobra.Command {
c := &cobra.Command{
Use: "openai",
Short: "Perform OpenAI-compatible endpoint smoke test",
Long: `Validate the IOP Edge OpenAI-compatible HTTP API surface.
It runs a three-step diagnostics check:
1. Checks GET /healthz (server status).
2. Checks GET /v1/models (advertised model compatibility).
3. Checks POST /v1/chat/completions (inference request processing).
If --base-url is not provided, it auto-discovers the OpenAI URL from the effective config's listen address.`,
Example: ` iop-edge smoke openai --model gemma4:26b
iop-edge smoke openai --model gemma4:26b --base-url http://127.0.0.1:8080 --prompt "say hello" --timeout 15s`,
RunE: func(cmd *cobra.Command, _ []string) error {
baseURL, err := resolveSmokeBaseURL(cmd)
if err != nil {
return err
}
timeoutDur := 10 * time.Second
if smokeTimeout != "" {
d, err := time.ParseDuration(smokeTimeout)
if err != nil {
return fmt.Errorf("invalid timeout duration: %w", err)
}
timeoutDur = d
}
client := &http.Client{
Timeout: timeoutDur,
}
fmt.Fprintf(cmd.OutOrStdout(), "IOP Edge OpenAI Smoke Test\n")
fmt.Fprintf(cmd.OutOrStdout(), "========================================\n")
fmt.Fprintf(cmd.OutOrStdout(), "Target Endpoint: %s\n", baseURL)
fmt.Fprintf(cmd.OutOrStdout(), "Target Model: %s\n", smokeModel)
fmt.Fprintf(cmd.OutOrStdout(), "Timeout: %s\n\n", timeoutDur)
fmt.Fprintf(cmd.OutOrStdout(), "Step 1: Checking /healthz ... ")
healthURL := baseURL + "/healthz"
resp, err := client.Get(healthURL)
if err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("GET %s failed: %w (make sure the edge server is running)", healthURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("GET %s returned non-200 status %d: %s", healthURL, resp.StatusCode, string(body))
}
var healthData map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&healthData); err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("failed to parse /healthz JSON response: %w", err)
}
fmt.Fprintln(cmd.OutOrStdout(), "[OK]")
fmt.Fprintf(cmd.OutOrStdout(), "Step 2: Checking /v1/models ... ")
modelsURL := baseURL + "/v1/models"
resp, err = client.Get(modelsURL)
if err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("GET %s failed: %w", modelsURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("GET %s returned non-200 status %d: %s", modelsURL, resp.StatusCode, string(body))
}
var modelsResp openAIModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&modelsResp); err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("failed to parse /v1/models JSON response: %w", err)
}
modelIDs := make([]string, 0, len(modelsResp.Data))
modelMatched := false
for _, m := range modelsResp.Data {
modelIDs = append(modelIDs, m.ID)
if m.ID == smokeModel {
modelMatched = true
}
}
fmt.Fprintf(cmd.OutOrStdout(), "[OK] (found models: %s)\n", strings.Join(modelIDs, ", "))
if !modelMatched {
fmt.Fprintf(cmd.OutOrStdout(), "WARNING: Target model %q is not advertised by /v1/models (advertised: %s)\n", smokeModel, strings.Join(modelIDs, ", "))
}
fmt.Fprintf(cmd.OutOrStdout(), "Step 3: Checking /v1/chat/completions ... ")
prompt := smokePrompt
if prompt == "" {
prompt = "ping"
}
reqPayload := map[string]interface{}{
"model": smokeModel,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
"stream": false,
}
payloadBytes, err := json.Marshal(reqPayload)
if err != nil {
return fmt.Errorf("failed to marshal request payload: %w", err)
}
chatURL := baseURL + "/v1/chat/completions"
resp, err = client.Post(chatURL, "application/json", bytes.NewReader(payloadBytes))
if err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("POST %s failed: %w (make sure active nodes are online)", chatURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("POST %s returned non-200 status %d: %s", chatURL, resp.StatusCode, string(body))
}
var chatResp chatCompletionResponse
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("failed to parse /v1/chat/completions JSON response: %w", err)
}
if len(chatResp.Choices) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("POST %s returned empty choices", chatURL)
}
content := chatResp.Choices[0].Message.Content
if strings.TrimSpace(content) == "" {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("POST %s returned empty assistant message content", chatURL)
}
fmt.Fprintf(cmd.OutOrStdout(), "[OK]\n")
fmt.Fprintf(cmd.OutOrStdout(), "Response Content: %q\n\n", content)
fmt.Fprintf(cmd.OutOrStdout(), "IOP Edge OpenAI Smoke Test SUCCESS!\n")
return nil
},
}
c.Flags().StringVar(&smokeModel, "model", "", "model identifier to check (required)")
c.Flags().StringVar(&smokeBaseURL, "base-url", "", "override OpenAI base URL (e.g. http://127.0.0.1:8080)")
c.Flags().StringVar(&smokePrompt, "prompt", "ping", "prompt text to send")
c.Flags().StringVar(&smokeTimeout, "timeout", "10s", "client timeout duration")
c.MarkFlagRequired("model")
return c
}