feat: edge server-side streaming refactoring and contract/docs update
This commit is contained in:
parent
82ad78754e
commit
9b4c6ac644
6 changed files with 59 additions and 325 deletions
|
|
@ -12,48 +12,10 @@ type LoadedRule = {
|
|||
content: string;
|
||||
};
|
||||
|
||||
type ProviderMessage = {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ProviderPayload = {
|
||||
model?: unknown;
|
||||
messages?: ProviderMessage[];
|
||||
temperature?: unknown;
|
||||
top_p?: unknown;
|
||||
seed?: unknown;
|
||||
think?: unknown;
|
||||
reasoning_effort?: unknown;
|
||||
thinking_token_budget?: unknown;
|
||||
include_reasoning?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type PiMessage = {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const commonRules: RuleSpec[] = [
|
||||
{ relativePath: "agent-ops/rules/common/rules.md" },
|
||||
];
|
||||
|
||||
const responseLanguagePolicy = `## IOP Response Language
|
||||
|
||||
사용자에게 보이는 최종 답변과 설명의 자연어 문장은 반드시 한국어로 작성한다.
|
||||
사용자가 명시적으로 다른 언어를 요청한 경우에만 해당 언어를 사용한다.
|
||||
코드, 명령어, 파일 경로, 식별자는 원문을 유지한다.`;
|
||||
|
||||
const responseLanguageUserPrefix = `[IOP 응답 언어: 이번 요청에서 사용자가 다른 언어를 명시하지 않았다면 최종 답변의 자연어 문장은 반드시 한국어로 작성한다. 코드, 명령어, 파일 경로, 식별자는 원문을 유지한다.]`;
|
||||
|
||||
const responseLanguageUserSuffix = `[응답 전 확인: 자연어 설명이 한국어인지 확인하고, 한국어가 아니면 한국어로 다시 작성한다.]`;
|
||||
|
||||
const defaultThinkingTokenBudget = 8192;
|
||||
const nonKoreanCJKPattern = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff]/;
|
||||
|
||||
const commonRequiredSessionRules: RuleSpec[] = [
|
||||
{ relativePath: "agent-ops/rules/project/rules.md" },
|
||||
{ relativePath: "agent-ops/rules/private/rules.md" },
|
||||
|
|
@ -127,241 +89,6 @@ ${rule.content}
|
|||
${blocks}`;
|
||||
}
|
||||
|
||||
function contentHasPolicy(content: unknown): boolean {
|
||||
if (typeof content === "string") {
|
||||
return content.includes("## IOP Response Language");
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.some((item) => {
|
||||
if (typeof item === "string") {
|
||||
return item.includes("## IOP Response Language");
|
||||
}
|
||||
if (item && typeof item === "object" && "text" in item) {
|
||||
return String((item as { text?: unknown }).text ?? "").includes("## IOP Response Language");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function contentHasUserPolicy(content: unknown): boolean {
|
||||
return contentAsText(content).includes(responseLanguageUserPrefix);
|
||||
}
|
||||
|
||||
function appendSystemPolicyToContent(content: unknown): unknown {
|
||||
if (typeof content === "string") {
|
||||
return `${content}
|
||||
|
||||
${responseLanguagePolicy}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return [
|
||||
...content,
|
||||
{
|
||||
type: "text",
|
||||
text: responseLanguagePolicy,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return responseLanguagePolicy;
|
||||
}
|
||||
|
||||
function wrapUserPolicyAroundContent(content: unknown): unknown {
|
||||
if (typeof content === "string") {
|
||||
return `${responseLanguageUserPrefix}
|
||||
|
||||
${content}
|
||||
|
||||
${responseLanguageUserSuffix}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: responseLanguageUserPrefix,
|
||||
},
|
||||
...content,
|
||||
{
|
||||
type: "text",
|
||||
text: responseLanguageUserSuffix,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return `${responseLanguageUserPrefix}
|
||||
|
||||
${responseLanguageUserSuffix}`;
|
||||
}
|
||||
|
||||
function contentAsText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
if (item && typeof item === "object" && "text" in item) {
|
||||
return String((item as { text?: unknown }).text ?? "");
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function visibleMessageText(message: PiMessage): string {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
return content
|
||||
.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
if (!item || typeof item !== "object") {
|
||||
return "";
|
||||
}
|
||||
const type = String((item as { type?: unknown }).type ?? "");
|
||||
if (type !== "text" || !("text" in item)) {
|
||||
return "";
|
||||
}
|
||||
return String((item as { text?: unknown }).text ?? "");
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function messageHasToolCall(message: PiMessage): boolean {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return false;
|
||||
}
|
||||
return message.content.some((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return false;
|
||||
}
|
||||
const type = String((item as { type?: unknown }).type ?? "");
|
||||
return type === "toolCall" || type === "tool_call";
|
||||
});
|
||||
}
|
||||
|
||||
function replaceMessageText(message: PiMessage, text: string): PiMessage {
|
||||
return {
|
||||
...message,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function applyIopOrnithDefaults(payload: ProviderPayload): boolean {
|
||||
if (payload.model !== "ornith:35b") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
if (payload.temperature === undefined) {
|
||||
payload.temperature = 0;
|
||||
changed = true;
|
||||
}
|
||||
if (payload.top_p === undefined) {
|
||||
payload.top_p = 1;
|
||||
changed = true;
|
||||
}
|
||||
if (payload.seed === undefined) {
|
||||
payload.seed = 1;
|
||||
changed = true;
|
||||
}
|
||||
if (payload.think === undefined && payload.reasoning_effort !== "none") {
|
||||
payload.think = true;
|
||||
changed = true;
|
||||
}
|
||||
if (
|
||||
payload.thinking_token_budget === undefined &&
|
||||
payload.think !== false &&
|
||||
payload.reasoning_effort !== "none"
|
||||
) {
|
||||
payload.thinking_token_budget = defaultThinkingTokenBudget;
|
||||
changed = true;
|
||||
}
|
||||
if (payload.include_reasoning === undefined) {
|
||||
payload.include_reasoning = false;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function injectResponseLanguagePolicy(payload: ProviderPayload): ProviderPayload | undefined {
|
||||
const messages = payload.messages;
|
||||
if (!Array.isArray(messages)) {
|
||||
const changed = applyIopOrnithDefaults(payload);
|
||||
return changed ? payload : undefined;
|
||||
}
|
||||
|
||||
const systemIndex = messages.findIndex((message) => message.role === "system" || message.role === "developer");
|
||||
const userIndex = messages.findLastIndex((message) => {
|
||||
if (message.role !== "user") {
|
||||
return false;
|
||||
}
|
||||
return !contentAsText(message.content).trimStart().startsWith("<tool_response>");
|
||||
});
|
||||
const needsSystemPolicy = systemIndex < 0 || !contentHasPolicy(messages[systemIndex].content);
|
||||
const needsUserPolicy = userIndex >= 0 && !contentHasUserPolicy(messages[userIndex].content);
|
||||
|
||||
if (!needsSystemPolicy && !needsUserPolicy) {
|
||||
const changed = applyIopOrnithDefaults(payload);
|
||||
return changed ? payload : undefined;
|
||||
}
|
||||
|
||||
const nextMessages = [...messages];
|
||||
let nextUserIndex = userIndex;
|
||||
if (needsSystemPolicy && systemIndex >= 0) {
|
||||
const systemMessage = nextMessages[systemIndex];
|
||||
nextMessages[systemIndex] = {
|
||||
...systemMessage,
|
||||
content: appendSystemPolicyToContent(systemMessage.content),
|
||||
};
|
||||
} else if (needsSystemPolicy) {
|
||||
nextMessages.unshift({
|
||||
role: "system",
|
||||
content: responseLanguagePolicy,
|
||||
});
|
||||
if (nextUserIndex >= 0) {
|
||||
nextUserIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUserPolicy) {
|
||||
const userMessage = nextMessages[nextUserIndex];
|
||||
nextMessages[nextUserIndex] = {
|
||||
...userMessage,
|
||||
content: wrapUserPolicyAroundContent(userMessage.content),
|
||||
};
|
||||
}
|
||||
|
||||
const nextPayload: ProviderPayload = {
|
||||
...payload,
|
||||
messages: nextMessages,
|
||||
};
|
||||
applyIopOrnithDefaults(nextPayload);
|
||||
|
||||
return nextPayload;
|
||||
}
|
||||
|
||||
export default function iopRuleLoader(pi: ExtensionAPI) {
|
||||
let projectRoot = "";
|
||||
let loadedRules: LoadedRule[] = [];
|
||||
|
|
@ -393,27 +120,4 @@ export default function iopRuleLoader(pi: ExtensionAPI) {
|
|||
${buildRulesPrompt(projectRoot, loadedRules)}`,
|
||||
};
|
||||
});
|
||||
|
||||
pi.on("before_provider_request", (event) => {
|
||||
return injectResponseLanguagePolicy(event.payload as ProviderPayload);
|
||||
});
|
||||
|
||||
pi.on("message_end", async (event) => {
|
||||
const message = event.message as PiMessage;
|
||||
if (message.role !== "assistant" || messageHasToolCall(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = visibleMessageText(message);
|
||||
if (!text || !nonKoreanCJKPattern.test(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
message: replaceMessageText(
|
||||
message,
|
||||
"응답 언어 정책에 맞지 않는 출력이 감지되어 답변을 차단했습니다. 같은 요청을 새로 실행하거나 한국어 응답 안정성이 더 높은 모델로 전환해 다시 실행해야 합니다.",
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,6 +177,13 @@ Think 제어 field:
|
|||
- `thinking_token_budget` (int, optional): thinking token budget. 0 이상이어야 한다.
|
||||
- `include_reasoning` (bool, optional): OpenAI-compatible 응답에서 `reasoning_content` 노출 여부. 생략하거나 `true`이면 provider reasoning delta/message를 노출할 수 있고, `false`이면 provider가 reasoning을 생성해도 response의 `reasoning_content`를 제거한다.
|
||||
|
||||
Reasoning-only 완료 처리:
|
||||
|
||||
- provider가 reasoning은 생성했지만 최종 assistant `content`와 `tool_calls` 없이 완료하면 Edge는 성공 응답을 빈 content로 끝내지 않는다.
|
||||
- `include_reasoning` 생략 또는 `true`인 요청은 기존 reasoning 본문을 `reasoning_content`에 유지하고, `content`가 비어 있으면 reasoning 본문을 fallback content로도 반환한다. `finish_reason`이 `stop`이 아니면 fallback content 뒤에 IOP notice를 붙인다.
|
||||
- `include_reasoning=false`인 요청은 reasoning 본문을 노출하지 않는다. 대신 `content`에 IOP notice를 넣어 "reasoning was hidden" 상태와 `finish_reason`을 알린다.
|
||||
- streaming 응답도 같은 정책을 따른다. reasoning-only 완료 시 최종 finish chunk와 `[DONE]` 전에 fallback 또는 hidden-reasoning notice를 `content` delta로 한 번 전송한다.
|
||||
|
||||
Provider별 think-control 정책:
|
||||
|
||||
- `vLLM`:
|
||||
|
|
|
|||
|
|
@ -344,7 +344,8 @@ func collectChatCompletionOutput(ctx context.Context, req chatCompletionRequest,
|
|||
}
|
||||
text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning)
|
||||
message := chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning}
|
||||
if !req.includeReasoning() {
|
||||
includeReasoning := req.includeReasoning()
|
||||
if !includeReasoning {
|
||||
message.ReasoningContent = ""
|
||||
}
|
||||
var toolCalls []any
|
||||
|
|
@ -366,8 +367,12 @@ 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)
|
||||
if len(message.ToolCalls) == 0 && strings.TrimSpace(message.Content) == "" && strings.TrimSpace(reasoning) != "" {
|
||||
if includeReasoning {
|
||||
message.Content = reasoningOnlyFallbackContent(reasoning, finishReason)
|
||||
} else {
|
||||
message.Content = hiddenReasoningFallbackContent(finishReason)
|
||||
}
|
||||
}
|
||||
return chatCompletionOutput{
|
||||
message: message,
|
||||
|
|
@ -393,6 +398,14 @@ func reasoningOnlyFallbackContent(reasoning, finishReason string) string {
|
|||
return content + "\n\n[IOP notice: model finished before producing final assistant content; finish_reason=" + reason + "]"
|
||||
}
|
||||
|
||||
func hiddenReasoningFallbackContent(finishReason string) string {
|
||||
reason := strings.TrimSpace(finishReason)
|
||||
if reason == "" {
|
||||
reason = "unknown"
|
||||
}
|
||||
return "[IOP notice: model produced reasoning but no final assistant content; reasoning was hidden by include_reasoning=false; finish_reason=" + reason + "]"
|
||||
}
|
||||
|
||||
func (s *Server) resolveAdapter() string {
|
||||
if s.cfg.Adapter != "" {
|
||||
return s.cfg.Adapter
|
||||
|
|
|
|||
|
|
@ -1750,7 +1750,7 @@ func TestChatCompletionsSuppressesReasoningContentWhenExcluded(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsDoesNotFallbackReasoningWhenExcluded(t *testing.T) {
|
||||
func TestChatCompletionsReportsHiddenReasoningWhenExcludedAndContentEmpty(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"}
|
||||
|
|
@ -1773,8 +1773,15 @@ func TestChatCompletionsDoesNotFallbackReasoningWhenExcluded(t *testing.T) {
|
|||
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)
|
||||
content := resp.Choices[0].Message.Content
|
||||
if content == "" {
|
||||
t.Fatalf("content should report hidden reasoning instead of returning empty success")
|
||||
}
|
||||
if strings.Contains(content, "thinking only") {
|
||||
t.Fatalf("hidden reasoning fallback leaked reasoning content: %q", content)
|
||||
}
|
||||
if !strings.Contains(content, "reasoning was hidden by include_reasoning=false") || !strings.Contains(content, "finish_reason=length") {
|
||||
t.Fatalf("hidden reasoning fallback did not report cause and finish reason: %q", 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)
|
||||
|
|
@ -1862,7 +1869,7 @@ func TestChatCompletionsStreamSuppressesReasoningWhenExcluded(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamDoesNotFallbackReasoningWhenExcluded(t *testing.T) {
|
||||
func TestChatCompletionsStreamReportsHiddenReasoningWhenExcludedAndContentEmpty(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"}}
|
||||
|
|
@ -1885,8 +1892,10 @@ func TestChatCompletionsStreamDoesNotFallbackReasoningWhenExcluded(t *testing.T)
|
|||
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)
|
||||
if !strings.Contains(body, "reasoning was hidden by include_reasoning=false") ||
|
||||
!strings.Contains(body, "finish_reason=length") ||
|
||||
!strings.Contains(body, "data: [DONE]") {
|
||||
t.Fatalf("unexpected SSE body, missing hidden reasoning report or DONE:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -227,12 +227,14 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
|
|||
}
|
||||
}
|
||||
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)
|
||||
fallback := hiddenReasoningFallbackContent(finishReason)
|
||||
if req.includeReasoning() {
|
||||
fallback = reasoningOnlyFallbackContent(reasoningBuilder.String(), finishReason)
|
||||
}
|
||||
emittedContent.WriteString(fallback)
|
||||
writeTracedContentDelta(fallback, reasoningBuilder.String(), false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,40 +151,39 @@ GOOS=windows GOARCH=amd64 go build -trimpath -o build/dev-runtime/bin/iop-node-w
|
|||
|
||||
기준 provider pool:
|
||||
|
||||
- model alias: `ornith:35b`
|
||||
- model alias: `qwen3.6: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 `2`
|
||||
- provider candidate: `mac-mlx-vllm` on `mac-codex-node`, capacity `3`
|
||||
|
||||
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`이다.
|
||||
GX10 vLLM은 `nvidia/Qwen3.6-35B-A3B-NVFP4`를 Docker container `iop-vllm-qwen36-prev-20260704063128`로 띄운다. dev 기준 endpoint는 `http://192.168.0.91:8001/v1`, runtime baseline은 `--max-model-len 262144`, `--max-num-seqs 4`, `--gpu-memory-utilization 0.30`, `--reasoning-parser qwen3`, `--default-chat-template-kwargs '{"enable_thinking":true}'`이다.
|
||||
|
||||
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는 `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`로 적용한다.
|
||||
OneXPlayer Lemonade는 `Qwen3.6-35B-A3B-MTP-GGUF` 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":"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}'
|
||||
-d '{"model_name":"Qwen3.6-35B-A3B-MTP-GGUF","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/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`이다.
|
||||
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`이다.
|
||||
|
||||
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`보다 높이지 않는 것을 기본선으로 둔다.
|
||||
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 4096`로 고정한다. 이 설정은 각 호출 window bound를 `262144`로 두고, provider capacity는 `3`으로 맞춘다.
|
||||
|
||||
```bash
|
||||
cd /Users/toki/agent-work/iop-mlx-vllm
|
||||
export HF_HOME="$PWD/hf-cache"
|
||||
export IOP_MLX_API_KEY="<edge.yaml local bearer token>"
|
||||
nohup .venv/bin/vllm-mlx serve mlx-community/Ornith-1.0-35B-4bit \
|
||||
nohup .venv/bin/vllm-mlx serve mlx-community/Qwen3.6-35B-A3B-4bit \
|
||||
--offline \
|
||||
--host 127.0.0.1 \
|
||||
--port 8002 \
|
||||
--api-key "$IOP_MLX_API_KEY" \
|
||||
--max-num-seqs 2 \
|
||||
--max-num-seqs 3 \
|
||||
--max-kv-size 262144 \
|
||||
--max-request-tokens 262144 \
|
||||
--max-tokens 32768 \
|
||||
--use-paged-cache \
|
||||
--paged-cache-block-size 64 \
|
||||
--max-cache-blocks 4096 \
|
||||
|
|
@ -226,14 +225,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=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`를 기대한다.
|
||||
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별 10개 동시 요청에서 Control Plane status의 provider snapshot이 총 `in_flight=10`, `queued=0`으로 처리되어야 한다. 11개 동시 요청에서는 `in_flight=10`, `queued>=1`, 13개 동시 확장 smoke에서는 `in_flight=10`, `queued>=3`을 기대한다.
|
||||
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건이었다.
|
||||
|
||||
Ornith 계열 모델은 thinking/reasoning 텍스트를 포함해 응답할 수 있다. 이 dev smoke에서는 thinking 출력을 실패로 보지 않고, HTTP 성공, final marker 포함, provider log/run count 증가를 기준으로 판정한다.
|
||||
Qwen 계열 thinking 모델은 reasoning 텍스트를 포함해 응답할 수 있다. 이 dev smoke에서는 thinking 출력을 실패로 보지 않고, HTTP 성공, final marker 포함, provider log/run count 증가를 기준으로 판정한다.
|
||||
|
||||
### Request-level think-control smoke
|
||||
|
||||
|
|
@ -250,7 +249,7 @@ Ornith 계열 모델은 thinking/reasoning 텍스트를 포함해 응답할 수
|
|||
curl -fsS http://toki-labs.com:18083/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <token>' \
|
||||
-d '{"model":"ornith:35b","messages":[{"role":"user","content":"hello"}],"think":false}'
|
||||
-d '{"model":"qwen3.6:35b","messages":[{"role":"user","content":"hello"}],"think":false}'
|
||||
```
|
||||
|
||||
예시 (thinking 생성은 허용하고 response reasoning_content만 숨김):
|
||||
|
|
@ -259,7 +258,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 <token>' \
|
||||
-d '{"model":"ornith:35b","messages":[{"role":"user","content":"hello"}],"include_reasoning":false}'
|
||||
-d '{"model":"qwen3.6:35b","messages":[{"role":"user","content":"hello"}],"include_reasoning":false}'
|
||||
```
|
||||
|
||||
예시 (budget-only):
|
||||
|
|
@ -268,7 +267,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 <token>' \
|
||||
-d '{"model":"ornith:35b","messages":[{"role":"user","content":"hello"}],"thinking_token_budget":512}'
|
||||
-d '{"model":"qwen3.6: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 +276,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`/`ornith:35b` 기준을 따른다.
|
||||
아래는 dev-runtime provider pool이 아니라 기본 field/local OpenAI-compatible profile 기준 예시다. dev-runtime provider pool 검증은 위의 `18083`/`qwen3.6:35b` 기준을 따른다.
|
||||
|
||||
```bash
|
||||
curl -fsS http://toki-labs.com:18081/v1/models
|
||||
|
|
|
|||
Loading…
Reference in a new issue