diff --git a/agent-contract/provided/openai-compatible-api.md b/agent-contract/provided/openai-compatible-api.md index 6925157..fffa253 100644 --- a/agent-contract/provided/openai-compatible-api.md +++ b/agent-contract/provided/openai-compatible-api.md @@ -13,6 +13,16 @@ IOP 내부 실행은 `adapter + target` 기준이며, OpenAI-compatible 경계에서는 호환성을 위해 `model`을 사용한다. IOP 고유 실행 문맥은 별도 `iop` wrapper field를 만들지 않고 OpenAI request의 `metadata`에 둔다. +## Auth + +Edge 설정의 `openai.bearer_token`이 비어 있지 않으면 OpenAI-compatible HTTP 표면은 다음 헤더를 요구한다. + +```http +Authorization: Bearer +``` + +토큰이 없거나 일치하지 않으면 `401 unauthorized` OpenAI-compatible error response를 반환한다. `openai.bearer_token`이 빈 값이면 auth를 적용하지 않는다. + ## Responses API Endpoint: diff --git a/agent-test/dev/edge-smoke.md b/agent-test/dev/edge-smoke.md index 14b13d7..adee6e8 100644 --- a/agent-test/dev/edge-smoke.md +++ b/agent-test/dev/edge-smoke.md @@ -71,8 +71,11 @@ dev-runtime provider pool과 3-node 연결 상태를 점검할 때는 `agent-tes - provider endpoint: `http://192.168.0.59:13305/v1` - served model: `Qwen3.6-35B-A3B-MTP-GGUF` - capacity baseline: `3` + - load baseline: backend `vulkan`, ctx size `786432`, `llamacpp_args="--spec-type none -np 3 -cb -fa on -b 4096 -ub 1024"`, `save_options=true` - workspace: `C:/Users/r0bin/iop-field` +OneXPlayer Lemonade는 `Qwen3.6-35B-A3B-MTP-GGUF` artifact를 사용하되 runtime MTP speculative decoding은 켜지 않는다. dev long-context baseline은 `/v1/load`에 `llamacpp_backend=vulkan`, `ctx_size=786432`, `--spec-type none -np 3 -cb -fa on -b 4096 -ub 1024`를 `save_options=true`로 저장한 상태다. llama.cpp backend의 `/slots`에서 slot 3개가 각각 `n_ctx=262144`로 보여야 한다. + OneXPlayer Lemonade Node는 원격 runner나 Edge host에서 다시 SSH하거나 proxy process로 띄우지 않는다. 현재 작업 호스트에서 OneXPlayer Windows host에 `ssh r0bin@192.168.0.59`로 직접 접속한 뒤 generated PowerShell bootstrap을 실행한다. ## 명령 diff --git a/agent-test/dev/inventory.yaml b/agent-test/dev/inventory.yaml index 3cea1ea..ccf58a3 100644 --- a/agent-test/dev/inventory.yaml +++ b/agent-test/dev/inventory.yaml @@ -77,4 +77,11 @@ nodes: endpoint: http://192.168.0.59:13305/v1 served_model: Qwen3.6-35B-A3B-MTP-GGUF capacity: 3 + load: + endpoint: http://192.168.0.59:13305/v1/load + backend: vulkan + ctx_size: 786432 + llamacpp_args: "--spec-type none -np 3 -cb -fa on -b 4096 -ub 1024" + save_options: true + mtp_runtime_policy: disabled windows_process_start: Win32_Process.Create diff --git a/agent-test/dev/node-smoke.md b/agent-test/dev/node-smoke.md index 988e45a..e0cd199 100644 --- a/agent-test/dev/node-smoke.md +++ b/agent-test/dev/node-smoke.md @@ -58,8 +58,11 @@ dev-runtime의 실제 3-node 연결을 점검할 때는 원격 runner `ssh toki@ - 접속 기준: 현재 작업 호스트에서 직접 SSH - provider endpoint: `http://192.168.0.59:13305/v1` - served model: `Qwen3.6-35B-A3B-MTP-GGUF` + - load baseline: backend `vulkan`, ctx size `786432`, `llamacpp_args="--spec-type none -np 3 -cb -fa on -b 4096 -ub 1024"`, `save_options=true` - workspace: `C:/Users/r0bin/iop-field` +OneXPlayer Lemonade는 `Qwen3.6-35B-A3B-MTP-GGUF` artifact를 사용하되 runtime MTP speculative decoding은 끈 상태를 dev 기준으로 삼는다. Node 검증 전 `/v1/load`의 `recipe_options`가 Vulkan, `ctx_size=786432`, `--spec-type none -np 3 -cb -fa on -b 4096 -ub 1024`를 포함하는지 확인한다. backend `/slots`에서는 slot 3개가 각각 `n_ctx=262144`로 보여야 한다. + GX10은 Linux/ARM64 bootstrap, OneXPlayer는 Windows native PowerShell bootstrap을 기본으로 한다. OneXPlayer는 현재 작업 호스트에서 직접 접속해 세팅하며, 원격 runner나 Edge host에서 `node-onexplayer-lemonade.yaml`로 proxy 실행하지 않는다. OneXPlayer에서 SSH 세션 안의 `Start-Process`로 `iop-node.exe`를 띄우면 SSH 세션 종료와 함께 process가 정리될 수 있다. dev 반복 배포에서는 `Win32_Process.Create` 또는 동등한 세션 독립 실행 방식으로 `C:/Users/r0bin/iop-field`에서 `iop-node.exe --config node.yaml serve`를 시작하고, WMI/process query와 `iop-node.log`의 `connected to edge` 로그로 유지 여부를 확인한다. diff --git a/apps/edge/internal/openai/routes.go b/apps/edge/internal/openai/routes.go index 0210c58..5424f2d 100644 --- a/apps/edge/internal/openai/routes.go +++ b/apps/edge/internal/openai/routes.go @@ -1,6 +1,7 @@ package openai import ( + "crypto/subtle" "net/http" "strings" "time" @@ -9,13 +10,27 @@ import ( func (s *Server) routes() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/healthz", s.handleHealthz) - mux.HandleFunc("/v1/models", s.handleModels) - mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions) - mux.HandleFunc("/v1/responses", s.handleResponses) - mux.HandleFunc("/api/", s.handleOllamaAPI) + mux.HandleFunc("/v1/models", s.withAuth(s.handleModels)) + mux.HandleFunc("/v1/chat/completions", s.withAuth(s.handleChatCompletions)) + mux.HandleFunc("/v1/responses", s.withAuth(s.handleResponses)) + mux.HandleFunc("/api/", s.withAuth(s.handleOllamaAPI)) return mux } +func (s *Server) withAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if strings.TrimSpace(s.cfg.BearerToken) != "" { + expected := "Bearer " + s.cfg.BearerToken + if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte(expected)) != 1 { + w.Header().Set("WWW-Authenticate", `Bearer realm="iop-openai"`) + writeError(w, http.StatusUnauthorized, "unauthorized", "unauthorized") + return + } + } + next(w, r) + } +} + func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 5e157d5..ae29fdc 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -21,6 +21,49 @@ type fakeRunService struct { events chan *iop.RunEvent } +func TestRoutesRequireBearerTokenWhenConfigured(t *testing.T) { + srv := NewServer(config.EdgeOpenAIConf{ + BearerToken: "secret-token", + Models: []string{"model-a"}, + }, &fakeRunService{}, nil) + + for _, tc := range []struct { + name string + auth string + wantStatus int + }{ + {name: "missing", wantStatus: http.StatusUnauthorized}, + {name: "wrong", auth: "Bearer wrong", wantStatus: http.StatusUnauthorized}, + {name: "valid", auth: "Bearer secret-token", wantStatus: http.StatusOK}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + if tc.auth != "" { + req.Header.Set("Authorization", tc.auth) + } + w := httptest.NewRecorder() + + srv.routes().ServeHTTP(w, req) + + if w.Code != tc.wantStatus { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + }) + } +} + +func TestHealthzDoesNotRequireBearerToken(t *testing.T) { + srv := NewServer(config.EdgeOpenAIConf{BearerToken: "secret-token"}, &fakeRunService{}, nil) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + w := httptest.NewRecorder() + + srv.routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } +} + func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) { s.req = req return &edgeservice.RunHandle{ diff --git a/apps/node/internal/node/node.go b/apps/node/internal/node/node.go index d3c7d41..51300a3 100644 --- a/apps/node/internal/node/node.go +++ b/apps/node/internal/node/node.go @@ -171,15 +171,30 @@ func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *i sessionID: normalizeSessionID(spec.SessionID), background: spec.Background, } + runSink := &terminalDeferringSink{inner: sink} run := func() error { - defer ticket.release() + released := false + releaseTicket := func() { + if !released { + ticket.release() + released = true + } + } + defer releaseTicket() defer cancel() defer n.runs.deregister(spec.RunID) defer close(h.done) - execErr := adapter.Execute(execCtx, spec, sink) + execErr := adapter.Execute(execCtx, spec, runSink) + releaseTicket() n.completeRun(spec, execErr) + if flushErr := runSink.Flush(context.Background()); flushErr != nil { + n.logger.Warn("session: flush terminal events", zap.String("run_id", spec.RunID), zap.Error(flushErr)) + if execErr == nil { + return flushErr + } + } return execErr } @@ -691,6 +706,48 @@ type noopSender struct{} func (noopSender) Send(proto.Message) error { return nil } +// terminalDeferringSink holds terminal events until Node-local admission has +// released its slot. Edge uses terminal run events to advance queued work, so +// emitting them before the local slot is free can over-dispatch back into Node. +type terminalDeferringSink struct { + inner runtime.EventSink + + mu sync.Mutex + deferring bool + deferred []runtime.RuntimeEvent +} + +func (s *terminalDeferringSink) Emit(ctx context.Context, event runtime.RuntimeEvent) error { + s.mu.Lock() + if s.deferring || isTerminalRuntimeEvent(event.Type) { + s.deferring = true + s.deferred = append(s.deferred, event) + s.mu.Unlock() + return nil + } + s.mu.Unlock() + return s.inner.Emit(ctx, event) +} + +func (s *terminalDeferringSink) Flush(ctx context.Context) error { + s.mu.Lock() + events := append([]runtime.RuntimeEvent(nil), s.deferred...) + s.deferred = nil + s.deferring = false + s.mu.Unlock() + + for _, event := range events { + if err := s.inner.Emit(ctx, event); err != nil { + return err + } + } + return nil +} + +func isTerminalRuntimeEvent(t runtime.EventType) bool { + return t == runtime.EventTypeComplete || t == runtime.EventTypeError || t == runtime.EventTypeCancelled +} + // sessionSink wraps a transport.Session to implement runtime.EventSink. type sessionSink struct { sess protoSender diff --git a/apps/node/internal/node/sink_test.go b/apps/node/internal/node/sink_test.go index 60036cf..abf58e4 100644 --- a/apps/node/internal/node/sink_test.go +++ b/apps/node/internal/node/sink_test.go @@ -15,12 +15,14 @@ import ( // mockSender is a minimal mock for protoSender. type mockSender struct { - sent *iop.RunEvent + sent *iop.RunEvent + sentEvents []*iop.RunEvent } func (m *mockSender) Send(msg proto.Message) error { if re, ok := msg.(*iop.RunEvent); ok { m.sent = re + m.sentEvents = append(m.sentEvents, re) } return nil } @@ -124,3 +126,65 @@ func TestSessionSinkPrintEventStreamsDeltaImmediately(t *testing.T) { t.Fatalf("expected node-message prefix once, got %q", got) } } + +func TestTerminalDeferringSinkFlushesTerminalEvents(t *testing.T) { + ms := &mockSender{} + var out bytes.Buffer + inner := &sessionSink{ + sess: ms, + out: &out, + nodeID: "test-node-123", + sessionID: "session-abc", + } + sink := &terminalDeferringSink{inner: inner} + + if err := sink.Emit(context.Background(), runtime.RuntimeEvent{ + RunID: "run-terminal", + Type: runtime.EventTypeStart, + Timestamp: time.Now(), + }); err != nil { + t.Fatalf("Emit start failed: %v", err) + } + if err := sink.Emit(context.Background(), runtime.RuntimeEvent{ + RunID: "run-terminal", + Type: runtime.EventTypeComplete, + Message: "done", + Timestamp: time.Now(), + }); err != nil { + t.Fatalf("Emit complete failed: %v", err) + } + if err := sink.Emit(context.Background(), runtime.RuntimeEvent{ + RunID: "run-terminal", + Type: runtime.EventTypeDelta, + Delta: "late", + Timestamp: time.Now(), + }); err != nil { + t.Fatalf("Emit late delta failed: %v", err) + } + + if len(ms.sentEvents) != 1 { + t.Fatalf("expected only start to be sent before flush, got %d events", len(ms.sentEvents)) + } + if got := ms.sentEvents[0].GetType(); got != string(runtime.EventTypeStart) { + t.Fatalf("first event type = %q, want start", got) + } + if strings.Contains(out.String(), "complete run_id=run-terminal") { + t.Fatalf("complete was printed before flush: %q", out.String()) + } + + if err := sink.Flush(context.Background()); err != nil { + t.Fatalf("Flush failed: %v", err) + } + if len(ms.sentEvents) != 3 { + t.Fatalf("expected start, complete, late delta after flush, got %d events", len(ms.sentEvents)) + } + if got := ms.sentEvents[1].GetType(); got != string(runtime.EventTypeComplete) { + t.Fatalf("second event type = %q, want complete", got) + } + if got := ms.sentEvents[2].GetType(); got != string(runtime.EventTypeDelta) { + t.Fatalf("third event type = %q, want delta", got) + } + if !strings.Contains(out.String(), "[node-event] complete run_id=run-terminal detail=\"done\"") { + t.Fatalf("complete was not printed after flush: %q", out.String()) + } +} diff --git a/configs/edge.yaml b/configs/edge.yaml index 066827e..e97dea4 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -55,6 +55,7 @@ a2a: openai: enabled: false listen: "0.0.0.0:18081" + bearer_token: "" node: "" adapter: "ollama" target: "" diff --git a/docs/edge-local-dev-guide.md b/docs/edge-local-dev-guide.md index 8a72028..a673745 100644 --- a/docs/edge-local-dev-guide.md +++ b/docs/edge-local-dev-guide.md @@ -125,6 +125,8 @@ GOOS=windows GOARCH=amd64 go build -trimpath -o build/dev-runtime/bin/iop-node-w - Edge OpenAI-compatible base URL: `http://toki-labs.com:18083/v1` - Edge-Node TCP transport: `toki-labs.com:18084` +외부로 노출되는 OpenAI-compatible API는 `openai.bearer_token`으로 보호한다. tracked 문서에는 실제 token 원문을 기록하지 않고, Cline 같은 외부 client에는 운영자가 전달한 API key를 `Authorization: Bearer ...` 형태로 설정한다. + 기준 provider pool: - model alias: `qwen3.6:35b` @@ -134,6 +136,14 @@ GOOS=windows GOARCH=amd64 go build -trimpath -o build/dev-runtime/bin/iop-node-w OneXPlayer Lemonade Node는 원격 runner나 Edge host에서 다시 SSH하거나 proxy process로 띄우지 않는다. 현재 작업 호스트에서 Windows host에 `ssh r0bin@192.168.0.59`로 직접 접속한 뒤, 그 host 안에서 generated PowerShell bootstrap 명령을 실행한다. 이때 Node 작업 경로는 `$HOME\iop-field`이며, Lemonade provider endpoint는 Windows host 로컬에서 접근 가능한 값을 기준으로 검증한다. +OneXPlayer Lemonade는 `Qwen3.6-35B-A3B-MTP-GGUF` artifact를 사용하되 runtime MTP speculative decoding은 끈다. dev long-context 기준 load 설정은 Vulkan backend, `ctx_size=786432`, `llamacpp_args="--spec-type none -np 3 -cb -fa on -b 4096 -ub 1024"`로 고정한다. llama.cpp server 기준 `ctx_size`는 전체 KV/context 예산이고 `-np`는 server slot 수이므로, 이 설정은 slot 3개가 각각 `n_ctx=262144`인 구조를 만든다. 2026-06-24 dev throughput 측정의 약 `95.9 tok/s` 관측은 `ctx_size=4096`, `-np 4` 기준이므로 이 long-context 설정과 직접 비교하지 않는다. 재시작 뒤에도 유지되도록 `/v1/load`는 `save_options=true`로 적용한다. + +```bash +curl -fsS http://192.168.0.59:13305/v1/load \ + -H 'Content-Type: application/json' \ + -d '{"model_name":"Qwen3.6-35B-A3B-MTP-GGUF","ctx_size":786432,"llamacpp_backend":"vulkan","llamacpp_args":"--spec-type none -np 3 -cb -fa on -b 4096 -ub 1024","save_options":true}' +``` + SSH 세션 안의 `Start-Process`는 세션 종료와 함께 `iop-node.exe`가 정리될 수 있다. 반복 배포에서는 Windows host에서 `Win32_Process.Create` 방식으로 세션 독립 실행한다. ```powershell @@ -161,7 +171,7 @@ Node host OS 재부팅은 필요하지 않다. Edge process 재시작이나 일 2. Edge host에서 `toki-labs.com:18084`의 established node TCP connection 수를 확인한다. 3. Edge process를 재시작하고 bootstrap 재실행 없이 3개 Node 연결이 회복되는지 확인한다. 4. `http://toki-labs.com:18083/healthz`와 `/v1/models`를 확인한다. -5. capacity를 `gx10-vllm=4`, `onexplayer-lemonade=3`로 맞춘 후보 config를 refresh apply하고 Edge process가 유지되는지 확인한다. +5. capacity를 `gx10-vllm=4`, `onexplayer-lemonade=3`로 맞춘 후보 config를 refresh apply하고 Edge process가 유지되는지 확인한다. Node adapter capacity 변경이 `restart_required`로 반환되면 Edge process를 새 config로 재시작한 뒤 Node reconnect 상태를 확인한다. 6. `qwen3.6:35b`로 `/v1/responses`와 `/v1/chat/completions` 각각에 provider capacity 총합 + 1개 동시 요청을 보낸다. 7. capacity `gx10-vllm=4`, `onexplayer-lemonade=3` 기준이면 endpoint별 8개 동시 요청에서 Control Plane status의 provider snapshot이 총 `in_flight=7`, `queued>=1`을 한 번 이상 보여야 한다. 8. 요청 완료 후 provider snapshot이 `in_flight=0`, `queued=0`으로 회복되는지 확인한다. diff --git a/packages/go/config/config.go b/packages/go/config/config.go index 2a920af..8ef4371 100644 --- a/packages/go/config/config.go +++ b/packages/go/config/config.go @@ -81,21 +81,21 @@ type EdgeInfo struct { // NodeDefinition is the edge-side record for a pre-registered node. type NodeDefinition struct { - ID string `mapstructure:"id" yaml:"id"` // stable node identity; if empty, a UUID v4 is auto-assigned (dev fallback only) - Alias string `mapstructure:"alias" yaml:"alias"` - Token string `mapstructure:"token" yaml:"token"` - AgentKind string `mapstructure:"agent_kind" yaml:"agent_kind"` // generic-node (default) - Adapters AdaptersConf `mapstructure:"adapters" yaml:"adapters"` + ID string `mapstructure:"id" yaml:"id"` // stable node identity; if empty, a UUID v4 is auto-assigned (dev fallback only) + Alias string `mapstructure:"alias" yaml:"alias"` + Token string `mapstructure:"token" yaml:"token"` + AgentKind string `mapstructure:"agent_kind" yaml:"agent_kind"` // generic-node (default) + Adapters AdaptersConf `mapstructure:"adapters" yaml:"adapters"` Providers []NodeProviderConf `mapstructure:"providers" yaml:"providers,omitempty"` - Runtime RuntimeConf `mapstructure:"runtime" yaml:"runtime"` + Runtime RuntimeConf `mapstructure:"runtime" yaml:"runtime"` } // Category represents the provider category. type Category string const ( - CategoryAPI Category = "api" - CategoryCLI Category = "cli" + CategoryAPI Category = "api" + CategoryCLI Category = "cli" CategoryLocalInference Category = "local_inference" ) @@ -266,6 +266,7 @@ func (e ModelCatalogEntry) Validate(resolvedProviderIDs map[string]struct{}, ser type EdgeOpenAIConf struct { Enabled bool `mapstructure:"enabled" yaml:"enabled"` Listen string `mapstructure:"listen" yaml:"listen"` + BearerToken string `mapstructure:"bearer_token" yaml:"bearer_token"` NodeRef string `mapstructure:"node" yaml:"node"` Adapter string `mapstructure:"adapter" yaml:"adapter"` Target string `mapstructure:"target" yaml:"target"` @@ -771,6 +772,7 @@ func setEdgeDefaults(v *viper.Viper) { v.SetDefault("bootstrap.artifact_dir", "artifacts") v.SetDefault("openai.enabled", false) v.SetDefault("openai.listen", "0.0.0.0:18081") + v.SetDefault("openai.bearer_token", "") v.SetDefault("openai.adapter", "ollama") v.SetDefault("openai.session_id", "openai") v.SetDefault("openai.timeout_sec", 120) diff --git a/packages/go/config/config_test.go b/packages/go/config/config_test.go index 8e58723..681ccf0 100644 --- a/packages/go/config/config_test.go +++ b/packages/go/config/config_test.go @@ -119,6 +119,9 @@ func TestLoadEdge_OpenAIDefaults(t *testing.T) { if cfg.OpenAI.Listen != "0.0.0.0:18081" { t.Fatalf("expected openai.listen default, got %q", cfg.OpenAI.Listen) } + if cfg.OpenAI.BearerToken != "" { + t.Fatalf("expected openai.bearer_token empty by default, got %q", cfg.OpenAI.BearerToken) + } if cfg.OpenAI.Adapter != "ollama" { t.Fatalf("expected openai.adapter=%q, got %q", "ollama", cfg.OpenAI.Adapter) } @@ -148,6 +151,7 @@ server: openai: enabled: true listen: "127.0.0.1:8088" + bearer_token: "secret-token" node: "node0" adapter: "ollama" target: "llama-test" @@ -172,6 +176,9 @@ openai: if cfg.OpenAI.Listen != "127.0.0.1:8088" || cfg.OpenAI.NodeRef != "node0" { t.Fatalf("unexpected openai routing config: %+v", cfg.OpenAI) } + if cfg.OpenAI.BearerToken != "secret-token" { + t.Fatalf("expected openai.bearer_token=%q, got %q", "secret-token", cfg.OpenAI.BearerToken) + } if cfg.OpenAI.Target != "llama-test" || len(cfg.OpenAI.Models) != 1 || cfg.OpenAI.Models[0] != "llama-test" { t.Fatalf("unexpected openai model config: %+v", cfg.OpenAI) } @@ -2267,10 +2274,10 @@ func TestNodeProviderConf_Validate(t *testing.T) { // Negative max_queue p7 := config.NodeProviderConf{ - ID: "p1", - Type: "vllm", - Category: config.CategoryAPI, - MaxQueue: -1, + ID: "p1", + Type: "vllm", + Category: config.CategoryAPI, + MaxQueue: -1, } if err := p7.Validate(); err == nil { t.Fatal("expected error for negative max_queue") @@ -2278,9 +2285,9 @@ func TestNodeProviderConf_Validate(t *testing.T) { // Negative queue_timeout_ms p8 := config.NodeProviderConf{ - ID: "p1", - Type: "vllm", - Category: config.CategoryAPI, + ID: "p1", + Type: "vllm", + Category: config.CategoryAPI, QueueTimeoutMS: -1, } if err := p8.Validate(); err == nil {