update: edge/node config and openapi contract changes

This commit is contained in:
toki 2026-06-24 22:03:09 +09:00
parent a47331f21d
commit 395c0b4708
12 changed files with 245 additions and 23 deletions

View file

@ -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 <token>
```
토큰이 없거나 일치하지 않으면 `401 unauthorized` OpenAI-compatible error response를 반환한다. `openai.bearer_token`이 빈 값이면 auth를 적용하지 않는다.
## Responses API
Endpoint:

View file

@ -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을 실행한다.
## 명령

View file

@ -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

View file

@ -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` 로그로 유지 여부를 확인한다.

View file

@ -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"})
}

View file

@ -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{

View file

@ -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

View file

@ -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())
}
}

View file

@ -55,6 +55,7 @@ a2a:
openai:
enabled: false
listen: "0.0.0.0:18081"
bearer_token: ""
node: ""
adapter: "ollama"
target: ""

View file

@ -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`으로 회복되는지 확인한다.

View file

@ -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)

View file

@ -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 {