Stream Gate 준비 실패에서도 요청 terminal 관측 계약을 지키고, direct provider identity 마이그레이션 누락으로 기존 검증과 운영 설정이 깨지지 않게 한다.
193 lines
4.6 KiB
Go
193 lines
4.6 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"testing"
|
|
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
func newTestConfig() *config.EdgeConfig {
|
|
return &config.EdgeConfig{
|
|
Server: config.EdgeServerConf{Listen: "127.0.0.1:0"},
|
|
Logging: config.LoggingConf{Level: "error"},
|
|
Metrics: config.MetricsConf{Port: 0},
|
|
Nodes: []config.NodeDefinition{
|
|
{ID: "node-1", Alias: "alias-1", Token: "tok-1"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func serveNormalizedLogPath() string {
|
|
if exe, err := os.Executable(); err == nil {
|
|
return filepath.Join(filepath.Dir(exe), "logs", "edge.log")
|
|
}
|
|
if wd, err := os.Getwd(); err == nil {
|
|
return filepath.Join(wd, "logs", "edge.log")
|
|
}
|
|
return "logs/edge.log"
|
|
}
|
|
|
|
func serveNormalizedArtifactDir(dir string) string {
|
|
if dir == "" {
|
|
dir = "artifacts"
|
|
}
|
|
if filepath.IsAbs(dir) {
|
|
return dir
|
|
}
|
|
bd := binaryDirForTest()
|
|
return filepath.Join(bd, dir)
|
|
}
|
|
|
|
func binaryDirForTest() string {
|
|
if exe, err := os.Executable(); err == nil {
|
|
return filepath.Dir(exe)
|
|
}
|
|
if wd, err := os.Getwd(); err == nil {
|
|
return wd
|
|
}
|
|
return "."
|
|
}
|
|
|
|
// loadServeNormalizedConfig loads an Edge config and applies the same
|
|
// normalization iop-edge serve performs, so refresh classification compares
|
|
// effective values.
|
|
func loadServeNormalizedConfig(t *testing.T, path string) *config.EdgeConfig {
|
|
t.Helper()
|
|
cfg, err := config.LoadEdge(path)
|
|
if err != nil {
|
|
t.Fatalf("load config %s: %v", path, err)
|
|
}
|
|
cfg.Logging.Path = serveNormalizedLogPath()
|
|
cfg.Bootstrap.ArtifactDir = serveNormalizedArtifactDir(cfg.Bootstrap.ArtifactDir)
|
|
return cfg
|
|
}
|
|
|
|
// providerPoolConfigYAML builds an Edge config with one provider-pool model and
|
|
// one node provider. The extra parameter lets callers inject a candidate
|
|
// variation (e.g. a different capacity or an invalid adapter block).
|
|
func providerPoolConfigYAML(serverAddr, openAIAddr, providerBlock string) string {
|
|
return fmt.Sprintf(`
|
|
server:
|
|
listen: %q
|
|
bootstrap:
|
|
listen: "0.0.0.0:18080"
|
|
artifact_dir: "artifacts"
|
|
logging:
|
|
level: "error"
|
|
refresh:
|
|
enabled: true
|
|
listen: "127.0.0.1:0"
|
|
openai:
|
|
enabled: true
|
|
listen: %q
|
|
provider_id: "test-provider"
|
|
adapter: "openai_compat"
|
|
target: ""
|
|
a2a:
|
|
listen: "0.0.0.0:8081"
|
|
metrics:
|
|
port: 0
|
|
models:
|
|
- id: "qwen3.6:35b"
|
|
display_name: "Qwen"
|
|
providers:
|
|
prov-a: "served-qwen"
|
|
nodes:
|
|
- id: "node-1"
|
|
alias: "n1"
|
|
token: "tok-1"
|
|
adapters:
|
|
%s
|
|
providers:
|
|
- id: "prov-a"
|
|
type: "vllm"
|
|
category: "api"
|
|
adapter: "vllm-gpu"
|
|
models: ["served-qwen"]
|
|
health: "available"
|
|
capacity: 2
|
|
max_queue: 4
|
|
queue_timeout_ms: 5000
|
|
`, serverAddr, openAIAddr, providerBlock)
|
|
}
|
|
|
|
const validAdapterBlock = ` openai_compat_instances:
|
|
- name: "vllm-gpu"
|
|
enabled: true
|
|
provider: "vllm"
|
|
endpoint: "http://127.0.0.1:8000/v1"`
|
|
|
|
// invalidAdapterBlock enables an openai_compat instance but leaves endpoint
|
|
// empty, which ValidateEdgeConfig rejects.
|
|
const invalidAdapterBlock = ` openai_compat_instances:
|
|
- name: "vllm-gpu"
|
|
enabled: true
|
|
provider: "vllm"
|
|
endpoint: ""`
|
|
|
|
// modelsResponse represents the JSON response from /v1/models.
|
|
type modelsResponse struct {
|
|
Object string `json:"object"`
|
|
Data []modelItem `json:"data"`
|
|
}
|
|
|
|
// modelItem represents a single model entry in the /v1/models response.
|
|
type modelItem struct {
|
|
ID string `json:"id"`
|
|
Object string `json:"object"`
|
|
Created int64 `json:"created"`
|
|
OwnedBy string `json:"owned_by"`
|
|
}
|
|
|
|
// modelIDs returns the sorted list of model IDs from /v1/models.
|
|
// This avoids flaky timestamp comparisons on the `created` field.
|
|
func modelIDs(t *testing.T, client *http.Client, openAIAddr string) []string {
|
|
t.Helper()
|
|
resp, err := client.Get("http://" + openAIAddr + "/v1/models")
|
|
if err != nil {
|
|
t.Fatalf("GET /v1/models: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /v1/models status=%d", resp.StatusCode)
|
|
}
|
|
var body modelsResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode /v1/models body: %v", err)
|
|
}
|
|
ids := make([]string, 0, len(body.Data))
|
|
for _, m := range body.Data {
|
|
ids = append(ids, m.ID)
|
|
}
|
|
sort.Strings(ids)
|
|
return ids
|
|
}
|
|
|
|
func containsString(s []string, want string) bool {
|
|
for _, v := range s {
|
|
if v == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func freeTCPAddr(t *testing.T) string {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen free tcp addr: %v", err)
|
|
}
|
|
addr := ln.Addr().String()
|
|
if err := ln.Close(); err != nil {
|
|
t.Fatalf("close free tcp addr listener: %v", err)
|
|
}
|
|
return addr
|
|
}
|