diff --git a/agent-contract/inner/edge-config-runtime-refresh.md b/agent-contract/inner/edge-config-runtime-refresh.md
index 7b2f48a..0e9fbc0 100644
--- a/agent-contract/inner/edge-config-runtime-refresh.md
+++ b/agent-contract/inner/edge-config-runtime-refresh.md
@@ -30,7 +30,7 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c
## 핵심 규칙
- `openai.model_routes[]`는 외부 OpenAI-compatible `model` id를 내부 `adapter + target` route로 매핑하는 compatibility catalog다.
-- `models[]`는 provider pool 방향의 canonical routing key이며 `nodes[].providers[].id`를 참조한다.
+- `models[]`는 provider pool 방향의 canonical routing key이며 `nodes[].providers[].id`를 참조한다. `default_max_tokens`, `min_max_tokens`, `default_thinking_token_budget`은 OpenAI-compatible 요청을 내부 실행으로 넘기기 전에 적용하는 모델 단위 generation policy다.
- `nodes[].providers[]`는 Node 아래 resource/provider catalog다. `category`는 `api`, `cli`, `local_inference` resource kind를 나타낸다.
- `nodes[].providers[].id`는 전체 Edge config 안에서 중복되면 안 된다.
- `nodes[].providers[].adapter`는 같은 Node 안의 enabled adapter instance key를 참조해야 한다. Exact instance key를 우선하고, legacy type-name route는 같은 type의 enabled instance가 정확히 하나일 때만 허용한다. `category: cli` resource는 enabled CLI adapter가 필요하다.
@@ -42,7 +42,7 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c
## refresh 분류 기준
-- live apply 가능: provider capacity, provider priority, provider max queue, provider queue timeout, provider `enabled` toggle, `models[]` display/provider mapping, legacy node runtime concurrency metadata.
+- live apply 가능: provider capacity, provider priority, provider max queue, provider queue timeout, provider `enabled` toggle, `models[]` display/provider/generation policy mapping, legacy node runtime concurrency metadata.
- restart required: Edge identity/listen/bootstrap/logging/metrics/console/control-plane/openai/a2a listener config, node 추가/삭제, node token/alias/agent kind, adapter 설정, provider type/category/adapter/models/health/lifecycle capability, provider-first execution fields(`provider`, `endpoint`, `base_url`, `headers`, `command`, `args`, `env`, `mode`, `resume_args`, `output_format`, `context_size`, `request_timeout_ms`) 변경.
- rejected: candidate config load/validate 실패, invalid refresh mode, apply failure.
diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md
index 9ce7d0a..deac311 100644
--- a/agent-contract/outer/openai-compatible-api.md
+++ b/agent-contract/outer/openai-compatible-api.md
@@ -196,6 +196,12 @@ Provider별 think-control 정책:
- `reasoning_effort=low|medium|high` -> `unsupported think control` 오류 반환
- Unknown / 기타 provider: 요청 field를 그대로 전달하되, provider가 지원하지 않는 값은 backend 또는 adapter error가 될 수 있다.
+Provider pool model catalog의 `models[]` entry가 generation policy를 제공하면 Edge는 요청을 내부 실행으로 넘기기 전에 다음 값을 보정한다.
+
+- `default_max_tokens`: caller가 출력 token limit을 생략했을 때 `max_tokens` 또는 `max_output_tokens`로 주입한다.
+- `min_max_tokens`: caller가 너무 작은 출력 token limit을 보냈을 때 해당 값까지 올린다. caller 값이 더 크면 보존한다.
+- `default_thinking_token_budget`: caller가 `thinking_token_budget`을 생략했고 strict output이 reasoning을 금지하지 않을 때 내부 실행 입력에 주입한다.
+
Conflict 정책:
- `reasoning_effort`가 비어 있거나 `none|low|medium|high` 외 값이면 400 에러.
diff --git a/agent-test/dev/edge-smoke.md b/agent-test/dev/edge-smoke.md
index 0a96988..7820ff0 100644
--- a/agent-test/dev/edge-smoke.md
+++ b/agent-test/dev/edge-smoke.md
@@ -51,7 +51,7 @@ dev-runtime provider pool과 3-node 연결 상태를 점검할 때는 `agent-tes
- bootstrap HTTP: `http://toki-labs.com:18082`
- Edge OpenAI-compatible base URL: `http://toki-labs.com:18083/v1`
- Edge-Node TCP transport: `toki-labs.com:18084`
-- model alias: `qwen3.6:35b`
+- model alias: `ornith:35b`
노드 후보:
@@ -61,31 +61,32 @@ dev-runtime provider pool과 3-node 연결 상태를 점검할 때는 `agent-tes
- workspace: `/Users/toki/agent-work/iop-workspace/nomadcode`
- MLX vLLM provider: `mac-mlx-vllm`
- provider endpoint: `http://127.0.0.1:8002/v1`
- - served model: `mlx-community/Qwen3.6-35B-A3B-4bit`
- - capacity baseline: `3`
+ - served model: `mlx-community/Ornith-1.0-35B-4bit`
+ - capacity baseline: `2`
- workdir: `/Users/toki/agent-work/iop-mlx-vllm`
- - runtime baseline: `vllm-mlx`, `--max-num-seqs 3`, `--max-kv-size 262144`, `--max-request-tokens 262144`, `--use-paged-cache --paged-cache-block-size 64 --max-cache-blocks 8192`
- - KV policy: per-call window bound `262144`, total paged KV budget `524288` tokens, equivalent to two full context windows
+ - runtime baseline: `vllm-mlx`, `--max-num-seqs 2`, `--max-kv-size 262144`, `--max-request-tokens 262144`, `--use-paged-cache --paged-cache-block-size 64 --max-cache-blocks 4096`
+ - KV policy: per-call window bound `262144`, one full context-size setting
- GX10 vLLM node: `gx10-vllm-node` / `gx10-vllm`
- SSH/user: `ssh toki@192.168.0.91`
- provider endpoint: `http://192.168.0.91:8001/v1`
- - served model: `nvidia/Qwen3.6-35B-A3B-NVFP4`
+ - served model: `sakamakismile/Ornith-1.0-35B-NVFP4`
- capacity baseline: `4`
+ - runtime baseline: `vllm`, `--max-model-len 262144`, `--max-num-seqs 4`, `--gpu-memory-utilization 0.30`
- workspace: `/home/toki/iop-gx10-vllm`
- OneXPlayer Lemonade node: `onexplayer-lemonade-node` / `onexplayer-lemonade`
- SSH/user: `ssh r0bin@192.168.0.59`
- 접속 기준: 현재 작업 호스트에서 직접 SSH
- provider endpoint: `http://192.168.0.59:13305/v1`
- - served model: `Qwen3.6-35B-A3B-MTP-GGUF`
+ - served model: `Ornith-1.0-35B-GGUF-Q4_K_M`
- 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`
+ - load baseline: backend `vulkan`, ctx size `524288`, `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는 `Ornith-1.0-35B-GGUF-Q4_K_M` artifact를 사용하되 runtime MTP speculative decoding은 켜지 않는다. dev long-context baseline은 `/v1/load`에 `llamacpp_backend=vulkan`, `ctx_size=524288`, `--spec-type none -np 3 -cb -fa on -b 4096 -ub 1024`를 `save_options=true`로 저장한 상태다. llama.cpp backend의 `/slots`에서는 총 ctx `524288`이 slot 3개로 나뉘어 2026-07-04 기준 slot별 `n_ctx=174848`로 보인다.
OneXPlayer Lemonade Node는 원격 runner나 Edge host에서 다시 SSH하거나 proxy process로 띄우지 않는다. 현재 작업 호스트에서 OneXPlayer Windows host에 `ssh r0bin@192.168.0.59`로 직접 접속한 뒤 generated PowerShell bootstrap을 실행한다.
-mac-mlx-vllm provider는 mac-codex-node 소속 resource로, Edge host와 같은 macOS host에서 vllm-mlx process로 실행한다. vllm-mlx API는 외부에 직접 노출하지 않고 `127.0.0.1:8002`에 bind하며, Edge OpenAI-compatible adapter가 local bearer header로 호출한다. 운영 파일은 `/Users/toki/agent-work/iop-mlx-vllm/vllm-mlx.pid`, `logs/vllm-mlx.stdout.log`, `logs/vllm-mlx.stderr.log`를 기준으로 한다. Docker와 macOS 여유 메모리를 고려해 capacity는 `3`을 기본선으로 유지한다.
+mac-mlx-vllm provider는 mac-codex-node 소속 resource로, Edge host와 같은 macOS host에서 vllm-mlx process로 실행한다. vllm-mlx API는 외부에 직접 노출하지 않고 `127.0.0.1:8002`에 bind하며, Edge OpenAI-compatible adapter가 local bearer header로 호출한다. 운영 파일은 `/Users/toki/agent-work/iop-mlx-vllm/vllm-mlx.pid`, `logs/vllm-mlx.stdout.log`, `logs/vllm-mlx.stderr.log`를 기준으로 한다. Docker와 macOS 여유 메모리를 고려해 capacity는 `2`를 기본선으로 유지한다.
## 명령
@@ -115,10 +116,10 @@ mac-mlx-vllm provider는 mac-codex-node 소속 resource로, Edge host와 같은
- node 로컬 `[node-message]` payload 라인 목록과 edge `[node-*-message]` payload 라인 목록이 run별로 내용/순서까지 동일해야 한다.
- edge complete는 같은 run의 마지막 `[node-*-message]` 이후에만 정상이다.
- OpenAI-compatible smoke에서 `/healthz`, `/v1/models`, `/v1/responses`가 기대 상태로 응답한다.
-- dev-runtime capacity smoke는 `/v1/responses`와 `/v1/chat/completions` 각각에 provider capacity 총합 + 1개 동시 요청을 보내고, Control Plane status의 `provider_snapshots`에서 총 `in_flight`가 capacity 총합에 도달하며 `queued`가 1 이상 잡히는지 확인한다. 현재 `gx10-vllm=4`, `onexplayer-lemonade=3`, `mac-mlx-vllm=3`이면 endpoint별 11개 동시 요청에서 총 `in_flight=10`, `queued>=1` 관측을 기준으로 한다. 13개 동시 확장 smoke에서는 총 `in_flight=10`, `queued>=3` 관측을 기대한다.
+- dev-runtime capacity smoke는 `/v1/responses`와 `/v1/chat/completions` 각각에 provider capacity 총합 + 1개 동시 요청을 보내고, Control Plane status의 `provider_snapshots`에서 총 `in_flight`가 capacity 총합에 도달하며 `queued`가 1 이상 잡히는지 확인한다. 현재 `gx10-vllm=4`, `onexplayer-lemonade=3`, `mac-mlx-vllm=2`이면 endpoint별 10개 동시 요청에서 총 `in_flight=9`, `queued>=1` 관측을 기준으로 한다. 13개 동시 확장 smoke에서는 총 `in_flight=9`, `queued>=4` 관측을 기대한다.
- capacity smoke 완료 후 대상 provider의 `in_flight=0`, `queued=0` 회복을 확인한다.
- 2026-06-24 dev 13-way 확장 smoke의 관측 결과는 `/v1/chat/completions` 13개 요청 성공, peak `in_flight=10`, 실제 queue 대기 `3`개, 최종 회복 `in_flight=0`, `queued=0`이다. 초기 peak는 `gx10-vllm=4`, `onexplayer-lemonade=3`, `mac-mlx-vllm=3`으로 채워졌고, 대기 요청은 먼저 slot이 빈 provider로 dispatch될 수 있다.
-- Qwen 계열 provider-pool smoke는 thinking/reasoning 텍스트가 포함될 수 있다. 추론 출력 자체를 실패로 보지 말고 HTTP 성공, model alias, final marker 포함 여부, provider node log/run count 증가로 판정한다. 응답 전체가 특정 token과 정확히 같은지 비교하는 strict exact-match는 이 profile의 기본 판정으로 쓰지 않는다.
+- Ornith provider-pool smoke는 thinking/reasoning 텍스트가 포함될 수 있다. 추론 출력 자체를 실패로 보지 말고 HTTP 성공, model alias, final marker 포함 여부, provider node log/run count 증가로 판정한다. 응답 전체가 특정 token과 정확히 같은지 비교하는 strict exact-match는 이 profile의 기본 판정으로 쓰지 않는다.
- bootstrap 사용자 명령은 완성된 URL과 positional token 하나만 포함한다.
## 기준 출력 예시
diff --git a/agent-test/dev/inventory.yaml b/agent-test/dev/inventory.yaml
index 9dd15b8..af636aa 100644
--- a/agent-test/dev/inventory.yaml
+++ b/agent-test/dev/inventory.yaml
@@ -1,6 +1,6 @@
test_env: dev
profile: dev-runtime-provider-pool
-last_updated_at: "2026-06-28"
+last_updated_at: "2026-07-03"
source:
remote_runner:
@@ -31,17 +31,21 @@ build:
node_windows_amd64: build/dev-runtime/bin/iop-node-windows-amd64.exe
model:
- alias: qwen3.6:35b
- provider_capacity_total: 10
+ alias: ornith:35b
+ provider_capacity_total: 9
+ context_window: 262144
+ default_max_tokens: 32768
+ min_max_tokens: 32768
+ default_thinking_token_budget: 8192
capacity_smoke:
endpoints:
- /v1/responses
- /v1/chat/completions
concurrent_requests: capacity_plus_one
- expected_total_in_flight: 10
+ expected_total_in_flight: 9
expected_min_queued: 1
extended_concurrent_requests: 13
- extended_expected_min_queued: 3
+ extended_expected_min_queued: 4
prompt_policy: long_reasoning_allowed
exact_output_match: false
@@ -59,8 +63,8 @@ nodes:
- id: mac-mlx-vllm
type: vllm-mlx
endpoint: http://127.0.0.1:8002/v1
- served_model: mlx-community/Qwen3.6-35B-A3B-4bit
- capacity: 3
+ served_model: mlx-community/Ornith-1.0-35B-4bit
+ capacity: 2
runtime:
api_key_policy: local_bearer_from_edge_yaml
workdir: /Users/toki/agent-work/iop-mlx-vllm
@@ -73,24 +77,16 @@ nodes:
pid_file: /Users/toki/agent-work/iop-mlx-vllm/vllm-mlx.pid
stdout_log: /Users/toki/agent-work/iop-mlx-vllm/logs/vllm-mlx.stdout.log
stderr_log: /Users/toki/agent-work/iop-mlx-vllm/logs/vllm-mlx.stderr.log
- max_num_seqs: 3
+ max_num_seqs: 2
max_kv_size: 262144
max_request_tokens: 262144
+ default_max_tokens: 32768
+ default_thinking_token_budget: 8192
paged_cache_block_size: 64
- max_cache_blocks: 8192
- total_kv_tokens: 524288
+ max_cache_blocks: 4096
+ total_kv_tokens: 262144
default_chat_template_kwargs:
- enable_thinking: false
- observed_direct_throughput_2026_06_24:
- max_tokens: 192
- completion_token_basis: true
- concurrency:
- 1:
- total_tok_s: 59.66
- 2:
- total_tok_s: 87.31
- 3:
- total_tok_s: 98.13
+ enable_thinking: true
- id: gx10-vllm-node
alias: gx10-vllm
role: vllm-provider
@@ -101,8 +97,14 @@ nodes:
id: gx10-vllm
type: vllm
endpoint: http://192.168.0.91:8001/v1
- served_model: nvidia/Qwen3.6-35B-A3B-NVFP4
+ served_model: sakamakismile/Ornith-1.0-35B-NVFP4
capacity: 4
+ runtime:
+ max_model_len: 262144
+ max_num_seqs: 4
+ gpu_memory_utilization: 0.30
+ default_chat_template_kwargs:
+ enable_thinking: true
- id: onexplayer-lemonade-node
alias: onexplayer-lemonade
role: lemonade-provider
@@ -114,13 +116,14 @@ nodes:
id: onexplayer-lemonade
type: lemonade
endpoint: http://192.168.0.59:13305/v1
- served_model: Qwen3.6-35B-A3B-MTP-GGUF
+ served_model: Ornith-1.0-35B-GGUF-Q4_K_M
capacity: 3
load:
endpoint: http://192.168.0.59:13305/v1/load
backend: vulkan
- ctx_size: 786432
+ ctx_size: 524288
llamacpp_args: "--spec-type none -np 3 -cb -fa on -b 4096 -ub 1024"
+ observed_slot_n_ctx: 174848
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 0ee52ea..ea43255 100644
--- a/agent-test/dev/node-smoke.md
+++ b/agent-test/dev/node-smoke.md
@@ -50,31 +50,32 @@ dev-runtime의 실제 3-node 연결을 점검할 때는 원격 runner `ssh toki@
- workspace: `/Users/toki/agent-work/iop-workspace/nomadcode`
- MLX vLLM provider: `mac-mlx-vllm`
- provider endpoint: `http://127.0.0.1:8002/v1`
- - served model: `mlx-community/Qwen3.6-35B-A3B-4bit`
- - capacity baseline: `3`
+ - served model: `mlx-community/Ornith-1.0-35B-4bit`
+ - capacity baseline: `2`
- workdir: `/Users/toki/agent-work/iop-mlx-vllm`
- - runtime baseline: `vllm-mlx`, `--max-num-seqs 3`, `--max-kv-size 262144`, `--max-request-tokens 262144`, `--use-paged-cache --paged-cache-block-size 64 --max-cache-blocks 8192`
- - KV policy: per-call window bound `262144`, total paged KV budget `524288` tokens, equivalent to two full context windows
+ - runtime baseline: `vllm-mlx`, `--max-num-seqs 2`, `--max-kv-size 262144`, `--max-request-tokens 262144`, `--use-paged-cache --paged-cache-block-size 64 --max-cache-blocks 4096`
+ - KV policy: per-call window bound `262144`, one full context-size setting
- GX10 vLLM node: `gx10-vllm-node` / `gx10-vllm`
- SSH/user: `ssh toki@192.168.0.91`
- provider endpoint: `http://192.168.0.91:8001/v1`
- - served model: `nvidia/Qwen3.6-35B-A3B-NVFP4`
+ - served model: `sakamakismile/Ornith-1.0-35B-NVFP4`
- capacity baseline: `4`
+ - runtime baseline: `vllm`, `--max-model-len 262144`, `--max-num-seqs 4`, `--gpu-memory-utilization 0.30`
- workspace: `/home/toki/iop-gx10-vllm`
- OneXPlayer Lemonade node: `onexplayer-lemonade-node` / `onexplayer-lemonade`
- SSH/user: `ssh r0bin@192.168.0.59`
- 접속 기준: 현재 작업 호스트에서 직접 SSH
- provider endpoint: `http://192.168.0.59:13305/v1`
- - served model: `Qwen3.6-35B-A3B-MTP-GGUF`
+ - served model: `Ornith-1.0-35B-GGUF-Q4_K_M`
- 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`
+ - load baseline: backend `vulkan`, ctx size `524288`, `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`로 보여야 한다.
+OneXPlayer Lemonade는 `Ornith-1.0-35B-GGUF-Q4_K_M` artifact를 사용하되 runtime MTP speculative decoding은 끈 상태를 dev 기준으로 삼는다. Node 검증 전 `/v1/load`의 `recipe_options`가 Vulkan, `ctx_size=524288`, `--spec-type none -np 3 -cb -fa on -b 4096 -ub 1024`를 포함하는지 확인한다. backend `/slots`에서는 총 ctx `524288`이 slot 3개로 나뉘어 2026-07-04 기준 slot별 `n_ctx=174848`로 보인다.
GX10은 Linux/ARM64 bootstrap, OneXPlayer는 Windows native PowerShell bootstrap을 기본으로 한다. OneXPlayer는 현재 작업 호스트에서 직접 접속해 세팅하며, 원격 runner나 Edge host에서 `node-onexplayer-lemonade.yaml`로 proxy 실행하지 않는다.
-mac-mlx-vllm provider는 mac-codex-node 소속 resource로, Edge host와 같은 macOS host에서 vllm-mlx process로 실행한다. vllm-mlx API는 외부에 직접 노출하지 않고 `127.0.0.1:8002`에 bind하며, Edge의 OpenAI-compatible adapter가 local bearer header로 호출한다. 운영 확인은 `/Users/toki/agent-work/iop-mlx-vllm/vllm-mlx.pid`, `logs/vllm-mlx.stdout.log`, `logs/vllm-mlx.stderr.log`를 기준으로 한다. Docker와 macOS 여유 메모리를 고려해 capacity는 `3`을 기본선으로 유지한다.
+mac-mlx-vllm provider는 mac-codex-node 소속 resource로, Edge host와 같은 macOS host에서 vllm-mlx process로 실행한다. vllm-mlx API는 외부에 직접 노출하지 않고 `127.0.0.1:8002`에 bind하며, Edge의 OpenAI-compatible adapter가 local bearer header로 호출한다. 운영 확인은 `/Users/toki/agent-work/iop-mlx-vllm/vllm-mlx.pid`, `logs/vllm-mlx.stdout.log`, `logs/vllm-mlx.stderr.log`를 기준으로 한다. Docker와 macOS 여유 메모리를 고려해 capacity는 `2`를 기본선으로 유지한다.
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/configrefresh/classify.go b/apps/edge/internal/configrefresh/classify.go
index 2da4982..ab9365d 100644
--- a/apps/edge/internal/configrefresh/classify.go
+++ b/apps/edge/internal/configrefresh/classify.go
@@ -373,6 +373,9 @@ func Classify(current, candidate *config.EdgeConfig) Result {
continue
}
appendIfChanged(&changes, fmt.Sprintf("models[%q].display_name", modelID), StatusApplied, cur.DisplayName, next.DisplayName)
+ appendIfChanged(&changes, fmt.Sprintf("models[%q].default_max_tokens", modelID), StatusApplied, cur.DefaultMaxTokens, next.DefaultMaxTokens)
+ appendIfChanged(&changes, fmt.Sprintf("models[%q].min_max_tokens", modelID), StatusApplied, cur.MinMaxTokens, next.MinMaxTokens)
+ appendIfChanged(&changes, fmt.Sprintf("models[%q].default_thinking_token_budget", modelID), StatusApplied, cur.DefaultThinkingTokenBudget, next.DefaultThinkingTokenBudget)
appendDeepIfChanged(&changes, fmt.Sprintf("models[%q].providers", modelID), StatusApplied, cur.Providers, next.Providers)
}
for modelID := range candidateModels {
diff --git a/apps/edge/internal/configrefresh/classify_test.go b/apps/edge/internal/configrefresh/classify_test.go
index 995ff1d..cd884d1 100644
--- a/apps/edge/internal/configrefresh/classify_test.go
+++ b/apps/edge/internal/configrefresh/classify_test.go
@@ -254,6 +254,8 @@ nodes:
models:
- id: "qwen3.6:35b"
display_name: "Qwen"
+ default_max_tokens: 8192
+ min_max_tokens: 8192
providers:
prov-a: "llama3.1"
`
@@ -283,6 +285,9 @@ nodes:
models:
- id: "qwen3.6:35b"
display_name: "Qwen"
+ default_max_tokens: 32768
+ min_max_tokens: 32768
+ default_thinking_token_budget: 8192
providers:
prov-b: "llama3.1"
`
@@ -302,14 +307,21 @@ models:
if result.Status != configrefresh.StatusApplied {
t.Fatalf("expected status=%q, got %q changes=%+v", configrefresh.StatusApplied, result.Status, result.Changes)
}
- found := false
+ found := map[string]bool{}
for _, change := range result.Changes {
- if change.Path == `models["qwen3.6:35b"].providers` && change.Class == configrefresh.StatusApplied {
- found = true
+ if change.Class == configrefresh.StatusApplied {
+ found[change.Path] = true
}
}
- if !found {
- t.Fatalf("model catalog providers change not found in %+v", result.Changes)
+ for _, path := range []string{
+ `models["qwen3.6:35b"].default_max_tokens`,
+ `models["qwen3.6:35b"].min_max_tokens`,
+ `models["qwen3.6:35b"].default_thinking_token_budget`,
+ `models["qwen3.6:35b"].providers`,
+ } {
+ if !found[path] {
+ t.Fatalf("model catalog change %s not found in %+v", path, result.Changes)
+ }
}
}
diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go
index 62b782f..861f408 100644
--- a/apps/edge/internal/openai/chat_handler.go
+++ b/apps/edge/internal/openai/chat_handler.go
@@ -49,6 +49,9 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
return
}
outputPolicy := s.resolveOutputPolicy(basePrompt)
+ if catalogEntry := s.findProviderPoolEntry(req.Model); catalogEntry != nil {
+ applyModelCatalogGenerationPolicyToChat(&req, *catalogEntry, outputPolicy.Strict)
+ }
messages := req.Messages
if instruction := strictOutputContractInstruction(outputPolicy); instruction != "" {
messages = prependSystemMessage(messages, instruction)
@@ -155,6 +158,63 @@ func validateThinkControl(req *chatCompletionRequest) error {
return nil
}
+func applyModelCatalogGenerationPolicyToChat(req *chatCompletionRequest, entry config.ModelCatalogEntry, strictOutput bool) {
+ if req == nil {
+ return
+ }
+ applyOutputTokenPolicy(&req.MaxTokens, req.MaxCompletionTokens, entry.DefaultMaxTokens, entry.MinMaxTokens)
+ if entry.DefaultThinkingTokenBudget > 0 && req.ThinkingTokenBudget == nil && chatRequestAllowsDefaultThinkingBudget(*req, strictOutput) {
+ req.ThinkingTokenBudget = intPtr(entry.DefaultThinkingTokenBudget)
+ }
+}
+
+func applyModelCatalogGenerationPolicyToResponses(req *responsesRequest, entry config.ModelCatalogEntry) {
+ if req == nil {
+ return
+ }
+ applyOutputTokenPolicy(&req.MaxOutputTokens, nil, entry.DefaultMaxTokens, entry.MinMaxTokens)
+}
+
+func applyOutputTokenPolicy(primary **int, fallback *int, defaultTokens, minTokens int) {
+ if primary == nil {
+ return
+ }
+ current := 0
+ if *primary != nil {
+ current = **primary
+ } else if fallback != nil {
+ current = *fallback
+ }
+
+ next := current
+ if next == 0 && defaultTokens > 0 {
+ next = defaultTokens
+ }
+ if minTokens > 0 && (next == 0 || next < minTokens) {
+ next = minTokens
+ }
+ if next > 0 && next != current {
+ *primary = intPtr(next)
+ }
+}
+
+func chatRequestAllowsDefaultThinkingBudget(req chatCompletionRequest, strictOutput bool) bool {
+ if strictOutput && (req.Think == nil || !*req.Think) {
+ return false
+ }
+ if req.Think != nil && !*req.Think {
+ return false
+ }
+ if req.ReasoningEffort != nil && *req.ReasoningEffort == "none" {
+ return false
+ }
+ return true
+}
+
+func intPtr(v int) *int {
+ return &v
+}
+
func chatRunMetadata(runMeta map[string]string, req chatCompletionRequest, outputPolicy strictOutputPolicy) map[string]string {
if runMeta == nil {
runMeta = make(map[string]string)
@@ -306,18 +366,33 @@ func collectChatCompletionOutput(ctx context.Context, req chatCompletionRequest,
finishReason = "tool_calls"
}
}
+ if len(message.ToolCalls) == 0 && strings.TrimSpace(message.Content) == "" && strings.TrimSpace(message.ReasoningContent) != "" {
+ message.Content = reasoningOnlyFallbackContent(message.ReasoningContent, finishReason)
+ }
return chatCompletionOutput{
message: message,
finishReason: finishReason,
usage: usage,
normalized: normalized,
- contentLen: len(text),
+ contentLen: len(message.Content),
reasoningLen: len(reasoning),
toolCalls: toolCalls,
toolCallOrigin: toolCallOrigin,
}, nil
}
+func reasoningOnlyFallbackContent(reasoning, finishReason string) string {
+ content := strings.TrimSpace(reasoning)
+ if content == "" {
+ return ""
+ }
+ reason := strings.TrimSpace(finishReason)
+ if reason == "" || reason == "stop" {
+ return content
+ }
+ return content + "\n\n[IOP notice: model finished before producing final assistant content; finish_reason=" + reason + "]"
+}
+
func (s *Server) resolveAdapter() string {
if s.cfg.Adapter != "" {
return s.cfg.Adapter
diff --git a/apps/edge/internal/openai/responses_handler.go b/apps/edge/internal/openai/responses_handler.go
index fd5cf57..84a6809 100644
--- a/apps/edge/internal/openai/responses_handler.go
+++ b/apps/edge/internal/openai/responses_handler.go
@@ -61,6 +61,13 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
+ var defaultThinkingTokenBudget int
+ if catalogEntry := s.findProviderPoolEntry(req.Model); catalogEntry != nil {
+ applyModelCatalogGenerationPolicyToResponses(&req, *catalogEntry)
+ if !outputPolicy.Strict && catalogEntry.DefaultThinkingTokenBudget > 0 {
+ defaultThinkingTokenBudget = catalogEntry.DefaultThinkingTokenBudget
+ }
+ }
runMeta["openai_model"] = req.Model
runMeta["openai_stream"] = fmt.Sprintf("%t", req.Stream)
@@ -68,6 +75,8 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
input := map[string]any{"prompt": prompt}
if outputPolicy.Strict {
input["think"] = false
+ } else if defaultThinkingTokenBudget > 0 {
+ input["thinking_token_budget"] = defaultThinkingTokenBudget
}
if options := req.providerOptions(); len(options) > 0 {
input["options"] = options
diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go
index 5403d45..54e7884 100644
--- a/apps/edge/internal/openai/server_test.go
+++ b/apps/edge/internal/openai/server_test.go
@@ -1683,6 +1683,37 @@ func TestChatCompletionsReturnsReasoningContentSeparately(t *testing.T) {
}
}
+func TestChatCompletionsFallsBackToReasoningWhenContentEmpty(t *testing.T) {
+ fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
+ fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking "}
+ fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "only"}
+ fake.events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{"finish_reason": "length"}}
+
+ srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
+ "model":"client-model",
+ "messages":[{"role":"user","content":"hi"}]
+ }`))
+ w := httptest.NewRecorder()
+
+ srv.handleChatCompletions(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
+ }
+ var resp chatCompletionResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ content := resp.Choices[0].Message.Content
+ if !strings.Contains(content, "thinking only") || !strings.Contains(content, "finish_reason=length") {
+ t.Fatalf("content fallback did not preserve reasoning and finish reason: %q", content)
+ }
+ if resp.Choices[0].Message.ReasoningContent != "thinking only" {
+ t.Fatalf("reasoning_content: got %q, expected \"thinking only\"", resp.Choices[0].Message.ReasoningContent)
+ }
+}
+
func TestChatCompletionsSuppressesReasoningContentWhenExcluded(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 5)}
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking "}
@@ -1719,6 +1750,37 @@ func TestChatCompletionsSuppressesReasoningContentWhenExcluded(t *testing.T) {
}
}
+func TestChatCompletionsDoesNotFallbackReasoningWhenExcluded(t *testing.T) {
+ fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
+ fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking "}
+ fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "only"}
+ fake.events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{"finish_reason": "length"}}
+
+ srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
+ "model":"client-model",
+ "messages":[{"role":"user","content":"hi"}],
+ "include_reasoning": false
+ }`))
+ w := httptest.NewRecorder()
+
+ srv.handleChatCompletions(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
+ }
+ var resp chatCompletionResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if resp.Choices[0].Message.Content != "" {
+ t.Fatalf("content should stay empty when reasoning is excluded, got %q", resp.Choices[0].Message.Content)
+ }
+ if resp.Choices[0].Message.ReasoningContent != "" {
+ t.Fatalf("reasoning_content: got %q, expected empty string due to include_reasoning=false", resp.Choices[0].Message.ReasoningContent)
+ }
+}
+
func TestChatCompletionsStreamsReasoningSSE(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think"}
@@ -1744,6 +1806,33 @@ func TestChatCompletionsStreamsReasoningSSE(t *testing.T) {
}
}
+func TestChatCompletionsStreamFallsBackToReasoningWhenContentEmpty(t *testing.T) {
+ fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
+ fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think only"}
+ fake.events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{"finish_reason": "length"}}
+
+ srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
+ "model":"stream-model",
+ "stream":true,
+ "messages":[{"role":"user","content":"hi"}]
+ }`))
+ w := httptest.NewRecorder()
+
+ srv.handleChatCompletions(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
+ }
+ body := w.Body.String()
+ if !strings.Contains(body, `"reasoning_content":"think only"`) ||
+ !strings.Contains(body, `"content":"think only`) ||
+ !strings.Contains(body, "finish_reason=length") ||
+ !strings.Contains(body, "data: [DONE]") {
+ t.Fatalf("unexpected SSE body:\n%s", body)
+ }
+}
+
func TestChatCompletionsStreamSuppressesReasoningWhenExcluded(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think"}
@@ -1773,6 +1862,34 @@ func TestChatCompletionsStreamSuppressesReasoningWhenExcluded(t *testing.T) {
}
}
+func TestChatCompletionsStreamDoesNotFallbackReasoningWhenExcluded(t *testing.T) {
+ fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
+ fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think only"}
+ fake.events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{"finish_reason": "length"}}
+
+ srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
+ "model":"stream-model",
+ "stream":true,
+ "messages":[{"role":"user","content":"hi"}],
+ "include_reasoning": false
+ }`))
+ w := httptest.NewRecorder()
+
+ srv.handleChatCompletions(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
+ }
+ body := w.Body.String()
+ if strings.Contains(body, "reasoning_content") || strings.Contains(body, "think only") {
+ t.Fatalf("unexpected reasoning fallback in SSE body when excluded:\n%s", body)
+ }
+ if !strings.Contains(body, "data: [DONE]") {
+ t.Fatalf("unexpected SSE body, missing DONE:\n%s", body)
+ }
+}
+
func TestChatCompletionsStrictOutputNormalizesXMLStyleAgentResponse(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "---\nhidden\n\n\nok\n\n```\ntrailing noise"}
@@ -3327,6 +3444,83 @@ func TestChatCompletionsProviderPoolDispatch(t *testing.T) {
}
}
+func TestChatCompletionsProviderPoolAppliesGenerationPolicy(t *testing.T) {
+ fake := &fakeRunService{events: bufferedRunEvents(
+ &iop.RunEvent{Type: "delta", Delta: "ok"},
+ &iop.RunEvent{Type: "complete"},
+ )}
+ catalog := []config.ModelCatalogEntry{{
+ ID: "ornith:35b",
+ DefaultMaxTokens: 32768,
+ MinMaxTokens: 32768,
+ DefaultThinkingTokenBudget: 8192,
+ Providers: map[string]string{"prov-vllm": "Ornith-1.0-35B"},
+ }}
+ srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
+ srv.SetModelCatalog(catalog)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
+ "model":"ornith:35b",
+ "messages":[{"role":"user","content":"hello"}],
+ "max_tokens":4096
+ }`))
+ w := httptest.NewRecorder()
+ srv.handleChatCompletions(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
+ }
+ options, ok := fake.req.Input["options"].(map[string]any)
+ if !ok {
+ t.Fatalf("options not passed: %+v", fake.req.Input)
+ }
+ if options["max_tokens"].(int) != 32768 {
+ t.Fatalf("max_tokens policy not applied: %+v", options)
+ }
+ if fake.req.Input["thinking_token_budget"].(int) != 8192 {
+ t.Fatalf("thinking_token_budget policy not applied: %+v", fake.req.Input)
+ }
+}
+
+func TestChatCompletionsProviderPoolPreservesLargerGenerationPolicyValues(t *testing.T) {
+ fake := &fakeRunService{events: bufferedRunEvents(
+ &iop.RunEvent{Type: "delta", Delta: "ok"},
+ &iop.RunEvent{Type: "complete"},
+ )}
+ catalog := []config.ModelCatalogEntry{{
+ ID: "ornith:35b",
+ DefaultMaxTokens: 32768,
+ MinMaxTokens: 32768,
+ DefaultThinkingTokenBudget: 8192,
+ Providers: map[string]string{"prov-vllm": "Ornith-1.0-35B"},
+ }}
+ srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
+ srv.SetModelCatalog(catalog)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
+ "model":"ornith:35b",
+ "messages":[{"role":"user","content":"hello"}],
+ "max_tokens":40000,
+ "thinking_token_budget":2048
+ }`))
+ w := httptest.NewRecorder()
+ srv.handleChatCompletions(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
+ }
+ options, ok := fake.req.Input["options"].(map[string]any)
+ if !ok {
+ t.Fatalf("options not passed: %+v", fake.req.Input)
+ }
+ if options["max_tokens"].(int) != 40000 {
+ t.Fatalf("larger max_tokens should be preserved: %+v", options)
+ }
+ if fake.req.Input["thinking_token_budget"].(int) != 2048 {
+ t.Fatalf("explicit thinking_token_budget should be preserved: %+v", fake.req.Input)
+ }
+}
+
// TestChatCompletionsLegacyRouteSetsProviderPoolFalse verifies that when the
// server is configured with Adapter + Target (no catalog), the dispatched
// SubmitRunRequest has ProviderPool=false so the service uses direct dispatch
@@ -3396,6 +3590,44 @@ func TestResponsesProviderPoolDispatch(t *testing.T) {
}
}
+func TestResponsesProviderPoolAppliesGenerationPolicy(t *testing.T) {
+ fake := &fakeRunService{events: bufferedRunEvents(
+ &iop.RunEvent{Type: "delta", Delta: "ok"},
+ &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}},
+ )}
+ catalog := []config.ModelCatalogEntry{{
+ ID: "ornith:35b",
+ DefaultMaxTokens: 32768,
+ MinMaxTokens: 32768,
+ DefaultThinkingTokenBudget: 8192,
+ Providers: map[string]string{"prov-vllm": "Ornith-1.0-35B"},
+ }}
+ srv := NewServer(config.EdgeOpenAIConf{TimeoutSec: 5}, fake, nil)
+ srv.SetModelCatalog(catalog)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
+ "model":"ornith:35b",
+ "input":"hello",
+ "max_output_tokens":4096
+ }`))
+ w := httptest.NewRecorder()
+ srv.handleResponses(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
+ }
+ options, ok := fake.req.Input["options"].(map[string]any)
+ if !ok {
+ t.Fatalf("options not passed: %+v", fake.req.Input)
+ }
+ if options["max_tokens"].(int) != 32768 {
+ t.Fatalf("max_output_tokens policy not applied: %+v", options)
+ }
+ if fake.req.Input["thinking_token_budget"].(int) != 8192 {
+ t.Fatalf("thinking_token_budget policy not applied: %+v", fake.req.Input)
+ }
+}
+
// TestChatCompletionsProviderPoolFallsBackToLegacyRoute verifies that when the
// request model does not match the catalog, the legacy model_routes path is used.
func TestChatCompletionsProviderPoolFallsBackToLegacyRoute(t *testing.T) {
diff --git a/apps/edge/internal/openai/stream.go b/apps/edge/internal/openai/stream.go
index b618e63..357dd68 100644
--- a/apps/edge/internal/openai/stream.go
+++ b/apps/edge/internal/openai/stream.go
@@ -226,6 +226,16 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
writeTracedContentDelta(pending, pending, false)
}
}
+ if !outputPolicy.Strict &&
+ req.includeReasoning() &&
+ strings.TrimSpace(contentBuilder.String()) == "" &&
+ strings.TrimSpace(emittedContent.String()) == "" &&
+ strings.TrimSpace(reasoningBuilder.String()) != "" &&
+ len(nativeToolCalls) == 0 {
+ fallback := reasoningOnlyFallbackContent(reasoningBuilder.String(), finishReason)
+ emittedContent.WriteString(fallback)
+ writeTracedContentDelta(fallback, reasoningBuilder.String(), false)
+ }
if traceStream {
s.logger.Info("openai chat completion stream output done",
zap.String("run_id", handle.Dispatch().RunID),
diff --git a/docs/edge-local-dev-guide.md b/docs/edge-local-dev-guide.md
index 71dbad4..dbfe1b0 100644
--- a/docs/edge-local-dev-guide.md
+++ b/docs/edge-local-dev-guide.md
@@ -151,48 +151,48 @@ GOOS=windows GOARCH=amd64 go build -trimpath -o build/dev-runtime/bin/iop-node-w
기준 provider pool:
-- model alias: `qwen3.6:35b`
+- model alias: `ornith:35b`
- provider candidate: `gx10-vllm` on `gx10-vllm-node`, capacity `4`
- provider candidate: `onexplayer-lemonade` on `onexplayer-lemonade-node`, capacity `3`
-- provider candidate: `mac-mlx-vllm` on `mac-codex-node`, capacity `3`
+- provider candidate: `mac-mlx-vllm` on `mac-codex-node`, capacity `2`
+
+GX10 vLLM은 `sakamakismile/Ornith-1.0-35B-NVFP4`를 Docker container `iop-vllm-ornith`로 띄운다. dev 기준 endpoint는 `http://192.168.0.91:8001/v1`, runtime baseline은 `--max-model-len 262144`, `--max-num-seqs 4`, `--gpu-memory-utilization 0.30`이다.
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`로 적용한다.
+OneXPlayer Lemonade는 `Ornith-1.0-35B-GGUF-Q4_K_M` artifact를 사용하되 runtime MTP speculative decoding은 끈다. dev long-context 기준 load 설정은 Vulkan backend, `ctx_size=524288`, `llamacpp_args="--spec-type none -np 3 -cb -fa on -b 4096 -ub 1024"`로 고정한다. llama.cpp server 기준 `ctx_size`는 전체 KV/context 예산이고 `-np`는 server slot 수이므로, 이 설정은 총 context 예산을 full `262144` window 2개분으로 제한하고 slot 3개를 둔다. 2026-07-04 관측 기준 `/slots`는 slot별 `n_ctx=174848`을 반환한다. 재시작 뒤에도 유지되도록 `/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}'
+ -d '{"model_name":"Ornith-1.0-35B-GGUF-Q4_K_M","ctx_size":524288,"llamacpp_backend":"vulkan","llamacpp_args":"--spec-type none -np 3 -cb -fa on -b 4096 -ub 1024","save_options":true}'
```
-Mac MLX provider는 Edge host 자신에서 `vllm-mlx`로 띄운다. dev 기준 endpoint는 `http://127.0.0.1:8002/v1`, served model은 `mlx-community/Qwen3.6-35B-A3B-4bit`, provider capacity는 `3`이다. 작업 경로는 `/Users/toki/agent-work/iop-mlx-vllm`, Python 환경은 `.venv`의 Homebrew Python 3.12, 모델 cache는 `hf-cache`를 사용한다. 확인된 package baseline은 `vllm-mlx 0.3.0`, `mlx 0.31.2`, `mlx-lm 0.31.3`이다.
+Mac MLX provider는 Edge host 자신에서 `vllm-mlx`로 띄운다. dev 기준 endpoint는 `http://127.0.0.1:8002/v1`, served model은 `mlx-community/Ornith-1.0-35B-4bit`, provider capacity는 `2`이다. 작업 경로는 `/Users/toki/agent-work/iop-mlx-vllm`, Python 환경은 `.venv`의 Homebrew Python 3.12, 모델 cache는 `hf-cache`를 사용한다. 확인된 package baseline은 `vllm-mlx 0.3.0`, `mlx 0.31.2`, `mlx-lm 0.31.3`이다.
-vllm-mlx process는 `--max-num-seqs 3`, `--max-kv-size 262144`, `--max-request-tokens 262144`, `--use-paged-cache --paged-cache-block-size 64 --max-cache-blocks 8192`로 고정한다. 이 설정은 각 호출 window bound를 `262144`로 두고, 전체 paged KV 예산은 `524288` tokens, 즉 full context window 2개분으로 제한한다. Mac host는 Docker와 다른 dev process도 함께 돌리므로 provider capacity를 `3`보다 높이지 않는 것을 기본선으로 둔다.
+vllm-mlx process는 `--max-num-seqs 2`, `--max-kv-size 262144`, `--max-request-tokens 262144`, `--use-paged-cache --paged-cache-block-size 64 --max-cache-blocks 4096`로 고정한다. 이 설정은 각 호출 window bound를 `262144`로 두고, Mac host는 Docker와 다른 dev process도 함께 돌리므로 provider capacity를 `2`보다 높이지 않는 것을 기본선으로 둔다.
```bash
cd /Users/toki/agent-work/iop-mlx-vllm
export HF_HOME="$PWD/hf-cache"
export IOP_MLX_API_KEY=""
-nohup .venv/bin/vllm-mlx serve mlx-community/Qwen3.6-35B-A3B-4bit \
- --served-model-name mlx-community/Qwen3.6-35B-A3B-4bit \
+nohup .venv/bin/vllm-mlx serve mlx-community/Ornith-1.0-35B-4bit \
+ --offline \
--host 127.0.0.1 \
--port 8002 \
--api-key "$IOP_MLX_API_KEY" \
- --max-num-seqs 3 \
+ --max-num-seqs 2 \
--max-kv-size 262144 \
--max-request-tokens 262144 \
--max-tokens 32768 \
- --continuous-batching \
--use-paged-cache \
--paged-cache-block-size 64 \
- --max-cache-blocks 8192 \
- --cache-memory-percent 0.20 \
- --gpu-memory-utilization 0.85 \
- --chunked-prefill-tokens 4096 \
- --enable-metrics \
- --timeout 300 \
- --default-chat-template-kwargs '{"enable_thinking": false}' \
+ --max-cache-blocks 4096 \
+ --enable-auto-tool-choice \
+ --tool-call-parser qwen \
+ --reasoning-parser qwen3 \
+ --default-chat-template-kwargs '{"enable_thinking": true}' \
+ --mllm \
> logs/vllm-mlx.stdout.log 2> logs/vllm-mlx.stderr.log &
echo $! > vllm-mlx.pid
```
@@ -226,14 +226,14 @@ 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`, `mac-mlx-vllm=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`, `mac-mlx-vllm=3` 기준이면 endpoint별 11개 동시 요청에서 Control Plane status의 provider snapshot이 총 `in_flight=10`, `queued>=1`을 한 번 이상 보여야 한다. 13개 동시 확장 smoke에서는 `in_flight=10`, `queued>=3`을 기대한다.
+5. capacity를 `gx10-vllm=4`, `onexplayer-lemonade=3`, `mac-mlx-vllm=2`로 맞춘 후보 config를 refresh apply하고 Edge process가 유지되는지 확인한다. Node adapter capacity 변경이 `restart_required`로 반환되면 Edge process를 새 config로 재시작한 뒤 Node reconnect 상태를 확인한다.
+6. `ornith:35b`로 `/v1/responses`와 `/v1/chat/completions` 각각에 provider capacity 총합 + 1개 동시 요청을 보낸다.
+7. capacity `gx10-vllm=4`, `onexplayer-lemonade=3`, `mac-mlx-vllm=2` 기준이면 endpoint별 10개 동시 요청에서 Control Plane status의 provider snapshot이 총 `in_flight=9`, `queued>=1`을 한 번 이상 보여야 한다. 13개 동시 확장 smoke에서는 `in_flight=9`, `queued>=4`를 기대한다.
8. 요청 완료 후 provider snapshot이 `in_flight=0`, `queued=0`으로 회복되는지 확인한다.
2026-06-24 dev 13-way 확장 smoke에서는 `/v1/chat/completions` 13개 요청이 모두 성공했고 peak `in_flight=10`, 실제 queue 대기 `3`개가 관측되었다. 초기 peak는 `gx10-vllm=4`, `onexplayer-lemonade=3`, `mac-mlx-vllm=3`으로 꽉 찼고, 초과 대기분은 slot이 먼저 빈 provider로 dispatch되었다. 같은 run에서 최종 처리 건수는 GX10 4건, OneXPlayer 3건, Mac MLX 6건이었다.
-Qwen 계열 모델은 thinking/reasoning 텍스트를 포함해 응답할 수 있다. 이 dev smoke에서는 thinking 출력을 실패로 보지 않고, HTTP 성공, final marker 포함, provider log/run count 증가를 기준으로 판정한다.
+Ornith 계열 모델은 thinking/reasoning 텍스트를 포함해 응답할 수 있다. 이 dev smoke에서는 thinking 출력을 실패로 보지 않고, HTTP 성공, final marker 포함, provider log/run count 증가를 기준으로 판정한다.
### Request-level think-control smoke
@@ -250,7 +250,7 @@ Qwen 계열 모델은 thinking/reasoning 텍스트를 포함해 응답할 수
curl -fsS http://toki-labs.com:18083/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer ' \
- -d '{"model":"qwen3.6:35b","messages":[{"role":"user","content":"hello"}],"think":false}'
+ -d '{"model":"ornith:35b","messages":[{"role":"user","content":"hello"}],"think":false}'
```
예시 (thinking 생성은 허용하고 response reasoning_content만 숨김):
@@ -259,7 +259,7 @@ curl -fsS http://toki-labs.com:18083/v1/chat/completions \
curl -fsS http://toki-labs.com:18083/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer ' \
- -d '{"model":"qwen3.6:35b","messages":[{"role":"user","content":"hello"}],"include_reasoning":false}'
+ -d '{"model":"ornith:35b","messages":[{"role":"user","content":"hello"}],"include_reasoning":false}'
```
예시 (budget-only):
@@ -268,7 +268,7 @@ curl -fsS http://toki-labs.com:18083/v1/chat/completions \
curl -fsS http://toki-labs.com:18083/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer ' \
- -d '{"model":"qwen3.6:35b","messages":[{"role":"user","content":"hello"}],"thinking_token_budget":512}'
+ -d '{"model":"ornith:35b","messages":[{"role":"user","content":"hello"}],"thinking_token_budget":512}'
```
`reasoning_effort=low` 같은 effort smoke는 현재 dev-runtime provider 매핑에서 unsupported error를 기대한다. provider pool에 vLLM/vLLM-MLX/Lemonade가 섞여 있으면 어떤 provider로 라우팅되더라도 성공 stream으로 판정하지 않는다.
@@ -277,7 +277,7 @@ curl -fsS http://toki-labs.com:18083/v1/chat/completions \
## 7. 기본 Smoke
-아래는 dev-runtime provider pool이 아니라 기본 field/local OpenAI-compatible profile 기준 예시다. dev-runtime provider pool 검증은 위의 `18083`/`qwen3.6:35b` 기준을 따른다.
+아래는 dev-runtime provider pool이 아니라 기본 field/local OpenAI-compatible profile 기준 예시다. dev-runtime provider pool 검증은 위의 `18083`/`ornith:35b` 기준을 따른다.
```bash
curl -fsS http://toki-labs.com:18081/v1/models
diff --git a/packages/go/config/config.go b/packages/go/config/config.go
index 27a421b..f05a65f 100644
--- a/packages/go/config/config.go
+++ b/packages/go/config/config.go
@@ -261,6 +261,15 @@ type ModelCatalogEntry struct {
ID string `mapstructure:"id" yaml:"id"`
// DisplayName is a human-readable name shown in operation dashboards.
DisplayName string `mapstructure:"display_name" yaml:"display_name,omitempty"`
+ // DefaultMaxTokens is injected when OpenAI-compatible requests omit an
+ // output-token limit for this model group.
+ DefaultMaxTokens int `mapstructure:"default_max_tokens" yaml:"default_max_tokens,omitempty"`
+ // MinMaxTokens raises too-small OpenAI-compatible output-token limits for
+ // this model group.
+ MinMaxTokens int `mapstructure:"min_max_tokens" yaml:"min_max_tokens,omitempty"`
+ // DefaultThinkingTokenBudget bounds reasoning tokens for think-capable
+ // providers when callers do not explicitly set a budget.
+ DefaultThinkingTokenBudget int `mapstructure:"default_thinking_token_budget" yaml:"default_thinking_token_budget,omitempty"`
// Providers maps provider id to the concrete served model name that the
// provider actually exposes. Keys must match nodes[].providers[].id.
Providers map[string]string `mapstructure:"providers" yaml:"providers"`
@@ -291,6 +300,18 @@ func (e ModelCatalogEntry) Validate(resolvedProviderIDs map[string]struct{}, ser
if id == "" {
return fmt.Errorf("models[].id must not be empty")
}
+ if e.DefaultMaxTokens < 0 {
+ return fmt.Errorf("models[%q].default_max_tokens must be non-negative", id)
+ }
+ if e.MinMaxTokens < 0 {
+ return fmt.Errorf("models[%q].min_max_tokens must be non-negative", id)
+ }
+ if e.DefaultThinkingTokenBudget < 0 {
+ return fmt.Errorf("models[%q].default_thinking_token_budget must be non-negative", id)
+ }
+ if e.DefaultMaxTokens > 0 && e.MinMaxTokens > 0 && e.DefaultMaxTokens < e.MinMaxTokens {
+ return fmt.Errorf("models[%q].default_max_tokens must be greater than or equal to min_max_tokens", id)
+ }
if len(e.Providers) == 0 {
return fmt.Errorf("models[%q].providers must not be empty", id)
}
diff --git a/packages/go/config/config_test.go b/packages/go/config/config_test.go
index fd518b9..dc8e823 100644
--- a/packages/go/config/config_test.go
+++ b/packages/go/config/config_test.go
@@ -1802,6 +1802,9 @@ server:
models:
- id: "qwen3.6:35b"
display_name: "Qwen 3.6 35B"
+ default_max_tokens: 32768
+ min_max_tokens: 32768
+ default_thinking_token_budget: 8192
providers:
vllm-gpu: "nvidia/Qwen3.6-35B-A3B-NVFP4"
ollama-local: "qwen3.6:35b"
@@ -1839,6 +1842,15 @@ nodes:
if m.DisplayName != "Qwen 3.6 35B" {
t.Errorf("models[0].DisplayName = %q, want %q", m.DisplayName, "Qwen 3.6 35B")
}
+ if m.DefaultMaxTokens != 32768 {
+ t.Errorf("models[0].DefaultMaxTokens = %d, want 32768", m.DefaultMaxTokens)
+ }
+ if m.MinMaxTokens != 32768 {
+ t.Errorf("models[0].MinMaxTokens = %d, want 32768", m.MinMaxTokens)
+ }
+ if m.DefaultThinkingTokenBudget != 8192 {
+ t.Errorf("models[0].DefaultThinkingTokenBudget = %d, want 8192", m.DefaultThinkingTokenBudget)
+ }
if len(m.Providers) != 2 {
t.Fatalf("expected 2 providers, got %d", len(m.Providers))
}
@@ -2251,8 +2263,11 @@ func TestModelCatalogEntry_Validate(t *testing.T) {
"ollama-loc": {"qwen3.6:35b": {}},
}
e := config.ModelCatalogEntry{
- ID: "qwen3.6:35b",
- DisplayName: "Qwen 3.6 35B",
+ ID: "qwen3.6:35b",
+ DisplayName: "Qwen 3.6 35B",
+ DefaultMaxTokens: 32768,
+ MinMaxTokens: 32768,
+ DefaultThinkingTokenBudget: 8192,
Providers: map[string]string{
"vllm-gpu": "nvidia/Qwen3.6-35B",
"ollama-loc": "qwen3.6:35b",
@@ -2299,6 +2314,52 @@ func TestModelCatalogEntry_Validate(t *testing.T) {
if err := e4.Validate(providerIDs, serveModels); err == nil {
t.Fatal("expected error for unresolved provider")
}
+
+ // Negative and inconsistent generation policy
+ for _, tc := range []struct {
+ name string
+ entry config.ModelCatalogEntry
+ }{
+ {
+ name: "negative default max tokens",
+ entry: config.ModelCatalogEntry{
+ ID: "test",
+ DefaultMaxTokens: -1,
+ Providers: map[string]string{"vllm-gpu": "m"},
+ },
+ },
+ {
+ name: "negative min max tokens",
+ entry: config.ModelCatalogEntry{
+ ID: "test",
+ MinMaxTokens: -1,
+ Providers: map[string]string{"vllm-gpu": "m"},
+ },
+ },
+ {
+ name: "negative thinking budget",
+ entry: config.ModelCatalogEntry{
+ ID: "test",
+ DefaultThinkingTokenBudget: -1,
+ Providers: map[string]string{"vllm-gpu": "m"},
+ },
+ },
+ {
+ name: "default below min",
+ entry: config.ModelCatalogEntry{
+ ID: "test",
+ DefaultMaxTokens: 8,
+ MinMaxTokens: 16,
+ Providers: map[string]string{"vllm-gpu": "m"},
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := tc.entry.Validate(providerIDs, nil); err == nil {
+ t.Fatal("expected error for invalid generation policy")
+ }
+ })
+ }
}
func TestNodeProviderConf_Validate(t *testing.T) {