feat(openai): think 컨트롤 필드 전달과 include_reasoning 옵션을 지원한다

node 어댑터에서 think, reasoning_effort, thinking_token_budget 필드를
vllm, lemonade 등 제공자별로 변환하여 전달하도록 변경한다.
vllm은 chat_template_kwargs로, lemonade는 직접 필드로 전달한다.
에드지는 include_reasoning=false일 때 reasoning_content를
스트림과 비스트림 응답에서 모두 삭제하도록 변경한다.
관련 테스트를 추가한다.
This commit is contained in:
toki 2026-07-03 15:05:39 +09:00
parent 3e1248d584
commit 37bab752d0
10 changed files with 655 additions and 652 deletions

View file

@ -1,105 +0,0 @@
<!-- task=m-openai-compatible-think-control/02+01_node_provider_mapping plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Start only after predecessor `01_edge_request_contract` has `complete.log`.
> Fill implementation-owned sections, then stop with active files in place and report ready for review. Finalization is review-agent-only.
## 개요
date=2026-07-03
task=m-openai-compatible-think-control/02+01_node_provider_mapping, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md`
- Task ids:
- `provider-mapping`: Edge run input과 Node `openai_compat` adapter가 요청별 think 설정을 provider별 top-level field 또는 내부 `chat_template_kwargs.enable_thinking`으로 매핑한다.
- `default-compat`: think 관련 필드가 생략된 기존 요청은 현재 provider 기본 thinking 동작을 유지한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 구현 에이전트는 종결 절차를 실행하지 않는다.
실제 provider body mapping, omitted-field absence, predecessor dependency 준수, 검증 출력 일치를 확인한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Provider-Specific Think Body Mapping | [ ] |
| [API-2] Default Compatibility Regression | [ ] |
## 구현 체크리스트
- [ ] `01_edge_request_contract` predecessor `complete.log`를 확인한 뒤 시작한다.
- [ ] `openai_compat` adapter request body builder가 provider label을 받아 think-control input을 provider별 body로 매핑하도록 바꾼다. 검증: vLLM Qwen3, vLLM-MLX, Lemonade mock이 같은 요청 플래그에 대해 기대 request body를 받는다.
- [ ] omitted think-control field가 `think`, `reasoning_effort`, `thinking_token_budget`, `chat_template_kwargs`를 만들지 않는 회귀 테스트를 추가한다. 검증: 기존 chat/tool/provider pool targeted tests가 통과한다.
- [ ] `go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] active plan/review 파일을 `.log`로 아카이브한다.
- [ ] PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고하고 roadmap을 직접 수정하지 않는다.
- [ ] WARN/FAIL이면 다음 active plan/review 파일 또는 정당한 `USER_REVIEW.md`를 작성한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- vLLM/vLLM-MLX body가 raw Edge wrapper 없이 adapter 내부 `chat_template_kwargs`를 생성하는지 확인한다.
- Lemonade mapping과 omitted default absence가 별도 테스트되는지 확인한다.
- `tools`/`tool_choice` fallback behavior가 깨지지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```bash
$ go test -count=1 ./apps/node/internal/adapters/openai_compat
(output)
```
### API-2 중간 검증
```bash
$ go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai
(output)
```
### 최종 검증
```bash
$ go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**

View file

@ -1,217 +0,0 @@
<!-- task=m-openai-compatible-think-control/02+01_node_provider_mapping plan=0 tag=API -->
# Plan - API Node Provider Mapping
## 이 파일을 읽는 구현 에이전트에게
`01_edge_request_contract``complete.log`가 있어야 이 plan을 구현한다. 구현 완료는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성까지다. Milestone lock decision이 막으면 review stub의 `사용자 리뷰 요청`에만 기록하고, 직접 사용자에게 질문하거나 archive/complete 파일을 만들지 않는다.
## 배경
Edge가 공개 think-control 필드를 내부 input map으로 전달해도 Node adapter가 provider별 request body로 매핑하지 않으면 실제 generation 제어가 동작하지 않는다. 현재 `openai_compat` adapter는 `think`를 단순 top-level passthrough하고, provider label별 `chat_template_kwargs.enable_thinking` 매핑이 없다. 이 split은 vLLM, vLLM-MLX, Lemonade mock request body를 고정하고 omitted field 회귀를 같이 닫는다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 Milestone lock decision이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 환경/secret/provider 접근 실패는 사용자 리뷰가 아니라 검증 blocker 또는 follow-up이다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md`
- Task ids:
- `provider-mapping`: Edge run input과 Node `openai_compat` adapter가 요청별 think 설정을 provider별 top-level field 또는 내부 `chat_template_kwargs.enable_thinking`으로 매핑한다.
- `default-compat`: think 관련 필드가 생략된 기존 요청은 현재 provider 기본 thinking 동작을 유지한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md`
- `agent-roadmap/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/edge-smoke.md`
- `apps/node/internal/adapters/openai_compat/openai_compat.go`
- `apps/node/internal/adapters/openai_compat/openai_compat_test.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/server_test.go`
### SDD 기준
- SDD: `agent-roadmap/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md`
- 상태: `[승인됨]`, SDD 잠금: `해제`, 사용자 리뷰: 없음
- Acceptance Scenario: S02 -> `provider-mapping`, S04 -> `default-compat`
- Evidence Map: S02 requires Node request body unit tests with provider mocks; S04 requires existing chat/tool/provider-pool regression evidence.
- 이 plan은 provider body mock tests를 completion evidence로 삼는다. External three-provider smoke is excluded to `05+01,02,03,04_dev_smoke`.
### 테스트 환경 규칙
- test_env: `local`
- 읽은 env/profile: `agent-test/local/rules.md`, `agent-test/local/node-smoke.md`, `agent-test/local/edge-smoke.md`
- 적용 명령: `go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai`
- baseline 확인: `go test -count=1 ./apps/edge/internal/openai ./apps/node/internal/adapters/openai_compat` PASS
- 외부 provider preflight: 이 split은 mock HTTP server만 사용하므로 없음.
### 테스트 커버리지 공백
- `think=false` provider mapping: current tests do not assert `chat_template_kwargs.enable_thinking=false` for vLLM/vLLM-MLX or top-level Lemonade mapping.
- `think=true` and `reasoning_effort=none`: no provider body test.
- Omitted field default compatibility: existing tests check generic options/tool passthrough but not absence of `think`/`chat_template_kwargs`.
### 심볼 참조
- renamed/removed symbol: none. If `buildRequestBody` signature changes, update its call at `apps/node/internal/adapters/openai_compat/openai_compat.go:131` and tests in `apps/node/internal/adapters/openai_compat/openai_compat_test.go`.
### 분할 판단
- shared task group: `agent-task/m-openai-compatible-think-control/`
- current subtask: `02+01_node_provider_mapping`
- dependency from directory name: predecessor `01_edge_request_contract`
- predecessor completion: missing in active and archive lookup. Implementation must wait for `agent-task/m-openai-compatible-think-control/01_edge_request_contract/complete.log` or matching archive complete log.
- split reason: provider mapping touches Node adapter and mock provider semantics; Edge response visibility and docs have separate ownership/risk.
### 범위 결정 근거
- Edge request decoder is excluded except for running its targeted tests after dependency completion.
- Edge response `reasoning_content` filtering is excluded to `03+01_edge_reasoning_visibility`.
- Real dev-runtime provider validation is excluded to `05+01,02,03,04_dev_smoke`.
### 빌드 등급
- `local-G05`: bounded Node adapter change, but provider-specific compatibility matrix and default-preservation tests raise risk.
## 구현 체크리스트
- [ ] `01_edge_request_contract` predecessor `complete.log`를 확인한 뒤 시작한다.
- [ ] `openai_compat` adapter request body builder가 provider label을 받아 think-control input을 provider별 body로 매핑하도록 바꾼다. 검증: vLLM Qwen3, vLLM-MLX, Lemonade mock이 같은 요청 플래그에 대해 기대 request body를 받는다.
- [ ] omitted think-control field가 `think`, `reasoning_effort`, `thinking_token_budget`, `chat_template_kwargs`를 만들지 않는 회귀 테스트를 추가한다. 검증: 기존 chat/tool/provider pool targeted tests가 통과한다.
- [ ] `go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- `02+01_node_provider_mapping` decodes to `depends_on=[01]`.
- Do not implement until predecessor `01_edge_request_contract` has a `complete.log`.
### [API-1] Provider-Specific Think Body Mapping
#### 문제
- [apps/node/internal/adapters/openai_compat/openai_compat.go](/config/workspace/iop/apps/node/internal/adapters/openai_compat/openai_compat.go:36)의 adapter는 `provider` label을 갖고 있지만 body builder가 이를 사용하지 않는다.
- [apps/node/internal/adapters/openai_compat/openai_compat.go](/config/workspace/iop/apps/node/internal/adapters/openai_compat/openai_compat.go:440)의 `buildRequestBody``think`만 top-level passthrough한다.
Before:
```go
// apps/node/internal/adapters/openai_compat/openai_compat.go:440
func buildRequestBody(model string, messages []chatMessage, input map[string]any) map[string]any {
body := make(map[string]any)
...
for _, key := range []string{"tools", "tool_choice", "format", "think", "keep_alive"} {
if v, ok := input[key]; ok {
body[key] = v
}
}
body["model"] = model
body["messages"] = messages
body["stream"] = true
return body
}
```
After:
```go
// apps/node/internal/adapters/openai_compat/openai_compat.go
reqBody, err := a.buildRequestBody(model, messages, spec.Input)
if err != nil {
return err
}
```
Provider mapping baseline:
| Provider label | `think=false` / `reasoning_effort=none` | `think=true` | Budget/effort |
|---|---|---|---|
| `vllm`, `vllm-mlx` | `chat_template_kwargs.enable_thinking=false` | `chat_template_kwargs.enable_thinking=true` | place supported budget/effort under `chat_template_kwargs`; unsupported values return adapter error with `unsupported think control` |
| `lemonade` | top-level `think=false` | top-level `think=true` | top-level `reasoning_effort` / `thinking_token_budget` if present |
| empty/unknown | keep current top-level `think` passthrough for compatibility | same | top-level passthrough for explicit fields |
#### 해결 방법
- Convert `buildRequestBody` into an adapter method or pass provider label explicitly.
- Merge `chat_template_kwargs` only inside the adapter; do not expose raw wrapper at Edge.
- Preserve `tools`, `tool_choice`, `format`, `keep_alive` existing behavior unless tests show a regression.
- Return a clear adapter error before HTTP for explicitly unsupported provider/value combinations.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/adapters/openai_compat/openai_compat.go`: provider-aware body builder and helper functions.
- [ ] `apps/node/internal/adapters/openai_compat/openai_compat_test.go`: provider body assertions for vLLM, vLLM-MLX, Lemonade, omitted default.
#### 테스트 작성
- Add `TestOpenAICompatExecuteMapsThinkFalseForVLLMChatTemplateKwargs`.
- Add `TestOpenAICompatExecuteMapsThinkFalseForVLLMMLXChatTemplateKwargs`.
- Add `TestOpenAICompatExecuteMapsThinkFalseForLemonadeTopLevel`.
- Add `TestOpenAICompatExecuteOmitsThinkControlWhenRequestOmitsIt`.
- Add/extend tests for `reasoning_effort=none`, `think=true`, and explicit unsupported budget/effort behavior.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/adapters/openai_compat
```
Expected: PASS.
### [API-2] Default Compatibility Regression
#### 문제
SDD S04 requires omitted think fields to preserve existing provider default thinking behavior. Current body tests do not assert that omitted requests avoid `think` and `chat_template_kwargs`.
#### 해결 방법
- In the omitted-field test, decode the provider request body and assert absence of:
- `think`
- `reasoning_effort`
- `thinking_token_budget`
- `chat_template_kwargs`
- Keep existing options/tool tests passing.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/adapters/openai_compat/openai_compat_test.go`: absence assertions.
#### 테스트 작성
- Covered by `TestOpenAICompatExecuteOmitsThinkControlWhenRequestOmitsIt`.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai
```
Expected: PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/adapters/openai_compat/openai_compat.go` | API-1 |
| `apps/node/internal/adapters/openai_compat/openai_compat_test.go` | API-1, API-2 |
## 최종 검증
```bash
go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai
```
Expected: PASS. Fresh execution is required; do not rely on Go test cache.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -1,104 +0,0 @@
<!-- task=m-openai-compatible-think-control/03+01_edge_reasoning_visibility plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> Start only after predecessor `01_edge_request_contract` has `complete.log`.
> Fill all implementation-owned sections and verification output, then stop with active files in place. Finalization is review-agent-only.
## 개요
date=2026-07-03
task=m-openai-compatible-think-control/03+01_edge_reasoning_visibility, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md`
- Task ids:
- `reasoning-visibility`: `include_reasoning=false`가 provider reasoning 생성 여부와 별개로 OpenAI-compatible 응답의 `reasoning_content` 노출을 억제한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 구현 에이전트는 종결 절차를 실행하지 않는다.
stream/non-stream suppression, default exposure regression, tool/content preservation, 검증 출력 일치를 확인한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Non-Streaming Reasoning Suppression | [ ] |
| [API-2] Streaming Reasoning Suppression | [ ] |
## 구현 체크리스트
- [ ] `01_edge_request_contract` predecessor `complete.log`를 확인한 뒤 시작한다.
- [ ] non-streaming completion에서 `include_reasoning=false`이면 `message.reasoning_content`를 비우되 `content`, `tool_calls`, `finish_reason`, usage는 유지한다. 검증: non-stream 응답에서 reasoning field가 빠지고 content/tool output은 유지된다.
- [ ] live SSE stream에서 `include_reasoning=false`이면 `delta.reasoning_content` chunk를 쓰지 않되 content/tool chunks와 `[DONE]`은 유지한다. 검증: stream 응답에서 reasoning delta가 빠지고 content/tool output은 유지된다.
- [ ] default/omitted `include_reasoning`은 기존 reasoning exposure tests를 유지한다.
- [ ] `go test -count=1 ./apps/edge/internal/openai`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] active plan/review 파일을 `.log`로 아카이브한다.
- [ ] PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고하고 roadmap을 직접 수정하지 않는다.
- [ ] WARN/FAIL이면 다음 active plan/review 파일 또는 정당한 `USER_REVIEW.md`를 작성한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `include_reasoning=false`가 response-only filter인지 확인한다.
- content/tool output과 `[DONE]`이 suppression 중에도 유지되는지 확인한다.
- omitted `include_reasoning`의 기존 reasoning exposure tests가 계속 통과하는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/openai
(output)
```
### API-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/openai
(output)
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/internal/openai
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**

View file

@ -1,219 +0,0 @@
<!-- task=m-openai-compatible-think-control/03+01_edge_reasoning_visibility plan=0 tag=API -->
# Plan - API Edge Reasoning Visibility
## 이 파일을 읽는 구현 에이전트에게
`01_edge_request_contract``complete.log`가 있어야 이 plan을 구현한다. 구현 완료는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 검증 출력으로 채운 상태다. review finalization, archive, `complete.log` 작성은 code-review 전용이다.
## 배경
SDD는 `include_reasoning=false`를 generation control이 아니라 OpenAI-compatible response exposure control로 정의했다. 현재 Edge는 non-streaming `message.reasoning_content`와 live SSE `delta.reasoning_content`를 기본으로 노출한다. 요청자가 reasoning 생성을 유지하면서 노출만 숨길 수 있도록 response writer 단에서 필터를 추가해야 한다.
## 사용자 리뷰 요청 흐름
선택된 Milestone lock decision이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 외부 환경이나 검증 evidence 공백은 사용자 리뷰가 아니라 blocker/follow-up으로 기록한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md`
- Task ids:
- `reasoning-visibility`: `include_reasoning=false`가 provider reasoning 생성 여부와 별개로 OpenAI-compatible 응답의 `reasoning_content` 노출을 억제한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md`
- `agent-roadmap/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/server_test.go`
### SDD 기준
- SDD: `agent-roadmap/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md`
- 상태: `[승인됨]`, SDD 잠금: `해제`, 사용자 리뷰: 없음
- Acceptance Scenario: S03 -> `reasoning-visibility`
- Evidence Map: S03 requires stream/non-stream response filtering tests and `Roadmap Completion` evidence for `reasoning-visibility`.
### 테스트 환경 규칙
- test_env: `local`
- 읽은 env/profile: `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`
- 적용 명령: `go test -count=1 ./apps/edge/internal/openai`
- baseline 확인: `go test -count=1 ./apps/edge/internal/openai ./apps/node/internal/adapters/openai_compat` PASS
- 외부 runner preflight: 이 split은 current checkout unit test만 필요하므로 없음.
### 테스트 커버리지 공백
- Current tests assert default reasoning exposure:
- non-streaming `TestChatCompletionsReturnsReasoningContentSeparately`
- streaming `TestChatCompletionsStreamsReasoningSSE`
- No tests assert `include_reasoning=false` suppression while preserving content/tool output.
- Strict-output tests suppress reasoning for a different policy and must not be reused as this feature's evidence.
### 심볼 참조
- renamed/removed symbol: none.
### 분할 판단
- current subtask: `03+01_edge_reasoning_visibility`
- dependency from directory name: predecessor `01_edge_request_contract`
- predecessor completion: missing in active and archive lookup. Implementation must wait for `01_edge_request_contract/complete.log`.
- split reason: response filtering is Edge-only but depends on the request field introduced by 01; Node provider body mapping remains separate.
### 범위 결정 근거
- Request decoder validation is excluded to predecessor 01.
- Provider-side generation control is excluded to sibling 02.
- Contract docs and dev smoke are excluded to later dependent subtasks.
### 빌드 등급
- `local-G04`: bounded Edge stream/non-stream filtering with deterministic tests, but SSE behavior and tool output preservation add moderate risk.
## 구현 체크리스트
- [ ] `01_edge_request_contract` predecessor `complete.log`를 확인한 뒤 시작한다.
- [ ] non-streaming completion에서 `include_reasoning=false`이면 `message.reasoning_content`를 비우되 `content`, `tool_calls`, `finish_reason`, usage는 유지한다. 검증: non-stream 응답에서 reasoning field가 빠지고 content/tool output은 유지된다.
- [ ] live SSE stream에서 `include_reasoning=false`이면 `delta.reasoning_content` chunk를 쓰지 않되 content/tool chunks와 `[DONE]`은 유지한다. 검증: stream 응답에서 reasoning delta가 빠지고 content/tool output은 유지된다.
- [ ] default/omitted `include_reasoning`은 기존 reasoning exposure tests를 유지한다.
- [ ] `go test -count=1 ./apps/edge/internal/openai`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- `03+01_edge_reasoning_visibility` decodes to `depends_on=[01]`.
- Do not implement until predecessor `01_edge_request_contract` has a `complete.log`.
### [API-1] Non-Streaming Reasoning Suppression
#### 문제
[apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:249)의 collection path always builds `chatMessage{ReasoningContent: reasoning}` after normalization. No request-level visibility switch is applied.
Before:
```go
// apps/edge/internal/openai/chat_handler.go:249
text, reasoning, finishReason, nativeToolCalls, usage, textToolFallback, err := collectRunResult(ctx, handle.Stream(), handle.WaitTimeout())
...
message := chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning}
```
After:
```go
// apply after normalization, before response serialization
if !req.includeReasoning() {
message.ReasoningContent = ""
}
```
#### 해결 방법
- Add a small helper such as `req.includeReasoning()` that defaults to true.
- Filter only response exposure. Do not alter collected reasoning logs or provider generation input.
- Ensure tool call synthesis still sees content and does not depend on reasoning.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/openai/types.go`: include helper if not already added by 01.
- [ ] `apps/edge/internal/openai/chat_handler.go`: non-streaming response filter.
- [ ] `apps/edge/internal/openai/server_test.go`: non-stream suppression tests.
#### 테스트 작성
- Add `TestChatCompletionsSuppressesReasoningContentWhenExcluded`.
- Add a tool-call preserving case if existing native/text tool tests make this cheap; otherwise include content preservation in the suppression test.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/openai
```
Expected: PASS.
### [API-2] Streaming Reasoning Suppression
#### 문제
- [apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:65) writes every reasoning delta to SSE.
- [apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:167) only suppresses reasoning for strict-output, not request visibility.
Before:
```go
// apps/edge/internal/openai/stream.go:167
case "reasoning_delta":
if event.GetDelta() == "" {
continue
}
reasoningBuilder.WriteString(event.GetDelta())
if outputPolicy.Strict {
continue
}
writeTracedReasoningDelta(event.GetDelta())
```
After:
```go
if outputPolicy.Strict || !req.includeReasoning() {
continue
}
writeTracedReasoningDelta(event.GetDelta())
```
#### 해결 방법
- Suppress live SSE reasoning chunks when `include_reasoning=false`.
- Keep `reasoningBuilder` for logs and diagnostics.
- Buffered strict stream currently emits only content/tool output; verify it still does not leak reasoning and add a non-strict live SSE test.
#### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/openai/stream.go`: live SSE reasoning filter.
- [ ] `apps/edge/internal/openai/server_test.go`: streaming suppression tests.
#### 테스트 작성
- Add `TestChatCompletionsStreamSuppressesReasoningWhenExcluded` asserting no `reasoning_content`, content chunk present, `[DONE]` present.
- Keep `TestChatCompletionsStreamsReasoningSSE` passing for omitted `include_reasoning`.
#### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/openai
```
Expected: PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/types.go` | API-1 |
| `apps/edge/internal/openai/chat_handler.go` | API-1 |
| `apps/edge/internal/openai/stream.go` | API-2 |
| `apps/edge/internal/openai/server_test.go` | API-1, API-2 |
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/openai
```
Expected: PASS. Fresh execution is required; do not rely on Go test cache.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -284,6 +284,9 @@ 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() {
message.ReasoningContent = ""
}
var toolCalls []any
toolCallOrigin := ""
if len(nativeToolCalls) > 0 {

View file

@ -1683,6 +1683,42 @@ func TestChatCompletionsReturnsReasoningContentSeparately(t *testing.T) {
}
}
func TestChatCompletionsSuppressesReasoningContentWhenExcluded(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 5)}
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking "}
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "more"}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer"}
fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 2, OutputTokens: 2}}
srv := NewServer(config.EdgeOpenAIConf{
Adapter: "ollama",
Target: "llama-fixed",
}, 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 != "answer" {
t.Fatalf("content: got %q, expected \"answer\"", 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"}
@ -1708,6 +1744,35 @@ func TestChatCompletionsStreamsReasoningSSE(t *testing.T) {
}
}
func TestChatCompletionsStreamSuppressesReasoningWhenExcluded(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think"}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"}
fake.events <- &iop.RunEvent{Type: "complete"}
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") {
t.Fatalf("unexpected reasoning_content in SSE body when excluded:\n%s", body)
}
if !strings.Contains(body, `"content":"hi"`) || !strings.Contains(body, "data: [DONE]") {
t.Fatalf("unexpected SSE body, missing content or DONE:\n%s", body)
}
}
func TestChatCompletionsStrictOutputNormalizesXMLStyleAgentResponse(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "---\n<thinking>hidden</thinking>\n\n<attempt_completion>\n<result>ok</result>\n</attempt completion>\n```\ntrailing noise"}

View file

@ -169,7 +169,7 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
continue
}
reasoningBuilder.WriteString(event.GetDelta())
if outputPolicy.Strict {
if outputPolicy.Strict || !req.includeReasoning() {
continue
}
writeTracedReasoningDelta(event.GetDelta())

View file

@ -124,6 +124,13 @@ func (req chatCompletionRequest) runInput(prompt string, messages []chatMessage,
return input
}
func (req chatCompletionRequest) includeReasoning() bool {
if req.IncludeReasoning != nil {
return *req.IncludeReasoning
}
return true
}
func shouldForwardProviderToolChoice(toolChoice any) bool {
if toolChoice == nil {
return false

View file

@ -128,7 +128,11 @@ func (a *Adapter) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink
return err
}
reqBody := buildRequestBody(model, messages, spec.Input)
reqBody, err := a.buildRequestBody(model, messages, spec.Input)
if err != nil {
_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat build request body failed: %v", err))
return fmt.Errorf("openai_compat adapter: build request body: %w", err)
}
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("openai_compat adapter: marshal request: %w", err)
@ -437,7 +441,7 @@ func (a *Adapter) doChatCompletion(ctx context.Context, body []byte) (*http.Resp
return a.client.Do(req)
}
func buildRequestBody(model string, messages []chatMessage, input map[string]any) map[string]any {
func (a *Adapter) buildRequestBody(model string, messages []chatMessage, input map[string]any) (map[string]any, error) {
body := make(map[string]any)
// Copy caller options as top-level OpenAI-compatible request fields first so
// adapter-owned fields below always win over them.
@ -446,8 +450,8 @@ func buildRequestBody(model string, messages []chatMessage, input map[string]any
body[k] = v
}
}
// Pass through optional OpenAI-compatible fields when present in the input.
for _, key := range []string{"tools", "tool_choice", "format", "think", "keep_alive"} {
// Pass through optional OpenAI-compatible fields when present in the input (except think).
for _, key := range []string{"tools", "tool_choice", "format", "keep_alive"} {
if v, ok := input[key]; ok {
body[key] = v
}
@ -455,7 +459,110 @@ func buildRequestBody(model string, messages []chatMessage, input map[string]any
body["model"] = model
body["messages"] = messages
body["stream"] = true
return body
// Extract think-control fields
hasThink := false
var thinkVal bool
if v, ok := input["think"]; ok {
if bv, ok := v.(bool); ok {
hasThink = true
thinkVal = bv
}
}
hasReasoningEffort := false
var reasoningEffortVal string
if v, ok := input["reasoning_effort"]; ok {
if sv, ok := v.(string); ok {
hasReasoningEffort = true
reasoningEffortVal = sv
}
}
hasBudget := false
var budgetVal any
if v, ok := input["thinking_token_budget"]; ok {
hasBudget = true
budgetVal = v
}
isVllm := a.provider == "vllm" || a.provider == "vllm-mlx"
if isVllm {
if hasReasoningEffort && reasoningEffortVal != "" && reasoningEffortVal != "none" {
return nil, fmt.Errorf("unsupported think control: reasoning_effort %q is not supported by %s", reasoningEffortVal, a.provider)
}
if hasBudget {
var budgetInt int64
valid := false
switch bv := budgetVal.(type) {
case int:
budgetInt = int64(bv)
valid = true
case int64:
budgetInt = bv
valid = true
case float64:
if bv == float64(int64(bv)) {
budgetInt = int64(bv)
valid = true
}
}
if !valid || budgetInt < 0 {
return nil, fmt.Errorf("unsupported think control: invalid thinking_token_budget %v", budgetVal)
}
}
if hasThink || (hasReasoningEffort && reasoningEffortVal == "none") || hasBudget {
var chatTemplateKwargs map[string]any
if ctk, ok := body["chat_template_kwargs"].(map[string]any); ok {
chatTemplateKwargs = make(map[string]any)
for k, v := range ctk {
chatTemplateKwargs[k] = v
}
} else {
chatTemplateKwargs = make(map[string]any)
}
if (hasThink && !thinkVal) || (hasReasoningEffort && reasoningEffortVal == "none") {
chatTemplateKwargs["enable_thinking"] = false
} else if (hasThink && thinkVal) || hasBudget {
chatTemplateKwargs["enable_thinking"] = true
}
if hasBudget {
chatTemplateKwargs["thinking_token_budget"] = budgetVal
}
body["chat_template_kwargs"] = chatTemplateKwargs
}
} else if a.provider == "lemonade" {
if (hasThink && !thinkVal) || (hasReasoningEffort && reasoningEffortVal == "none") {
body["think"] = false
} else if hasThink && thinkVal {
body["think"] = true
}
if hasReasoningEffort && reasoningEffortVal != "none" {
body["reasoning_effort"] = reasoningEffortVal
}
if hasBudget {
body["thinking_token_budget"] = budgetVal
}
} else {
// empty/unknown provider
if hasThink {
body["think"] = thinkVal
}
if hasReasoningEffort {
body["reasoning_effort"] = reasoningEffortVal
}
if hasBudget {
body["thinking_token_budget"] = budgetVal
}
}
return body, nil
}
func retryBodyWithForcedSingleTool(body map[string]any, errorBody string) (map[string]any, bool) {

View file

@ -158,7 +158,6 @@ func TestOpenAICompatProbeProviderAvailability(t *testing.T) {
}
})
}
func TestOpenAICompatExecuteStreamsDeltasAndFinishReason(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
@ -616,3 +615,470 @@ func TestOpenAICompatJoinOpenAIPathNoDuplicateV1(t *testing.T) {
}
}
}
func TestOpenAICompatExecuteMapsThinkFalseForVLLMChatTemplateKwargs(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`)
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Provider: "vllm",
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-vllm",
Target: "qwen-model",
Input: map[string]any{
"prompt": "hello",
"think": false,
},
}, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
ctk, ok := body["chat_template_kwargs"].(map[string]any)
if !ok {
t.Fatalf("expected chat_template_kwargs to be a map, got %T", body["chat_template_kwargs"])
}
if ctk["enable_thinking"] != false {
t.Fatalf("expected enable_thinking to be false, got %v", ctk["enable_thinking"])
}
}
func TestOpenAICompatExecuteMapsThinkFalseForVLLMMLXChatTemplateKwargs(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`)
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Provider: "vllm-mlx",
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-vllm-mlx",
Target: "qwen-model",
Input: map[string]any{
"prompt": "hello",
"think": false,
},
}, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
ctk, ok := body["chat_template_kwargs"].(map[string]any)
if !ok {
t.Fatalf("expected chat_template_kwargs to be a map, got %T", body["chat_template_kwargs"])
}
if ctk["enable_thinking"] != false {
t.Fatalf("expected enable_thinking to be false, got %v", ctk["enable_thinking"])
}
}
func TestOpenAICompatExecuteMapsThinkFalseForLemonadeTopLevel(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`)
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Provider: "lemonade",
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-lemonade",
Target: "lemonade-model",
Input: map[string]any{
"prompt": "hello",
"think": false,
},
}, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
if body["think"] != false {
t.Fatalf("expected think to be false, got %v", body["think"])
}
}
func TestOpenAICompatExecuteOmitsThinkControlWhenRequestOmitsIt(t *testing.T) {
for _, provider := range []string{"vllm", "vllm-mlx", "lemonade", ""} {
t.Run(provider, func(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`)
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Provider: provider,
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-omit",
Target: "qwen-model",
Input: map[string]any{
"prompt": "hello",
},
}, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
for _, key := range []string{"think", "reasoning_effort", "thinking_token_budget", "chat_template_kwargs"} {
if _, ok := body[key]; ok {
t.Fatalf("expected key %q to be omitted for provider %q", key, provider)
}
}
})
}
}
func TestOpenAICompatExecuteVLLMUnsupportedReasoningEffort(t *testing.T) {
adapter := New(config.OpenAICompatConf{
Provider: "vllm",
Endpoint: "http://localhost:8000",
}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-unsupported",
Target: "qwen-model",
Input: map[string]any{
"prompt": "hello",
"reasoning_effort": "low",
},
}, sink)
if err == nil {
t.Fatalf("expected error for unsupported reasoning_effort, got nil")
}
if !strings.Contains(err.Error(), "unsupported think control") {
t.Fatalf("expected error message to contain 'unsupported think control', got: %v", err)
}
}
func TestOpenAICompatExecuteVLLMUnsupportedNegativeBudget(t *testing.T) {
adapter := New(config.OpenAICompatConf{
Provider: "vllm",
Endpoint: "http://localhost:8000",
}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-unsupported",
Target: "qwen-model",
Input: map[string]any{
"prompt": "hello",
"thinking_token_budget": -10,
},
}, sink)
if err == nil {
t.Fatalf("expected error for negative budget, got nil")
}
if !strings.Contains(err.Error(), "unsupported think control") {
t.Fatalf("expected error message to contain 'unsupported think control', got: %v", err)
}
}
func TestOpenAICompatExecuteMapsReasoningEffortNone(t *testing.T) {
t.Run("vllm", func(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`)
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Provider: "vllm",
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-vllm-none",
Target: "qwen-model",
Input: map[string]any{
"prompt": "hello",
"reasoning_effort": "none",
},
}, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
ctk, ok := body["chat_template_kwargs"].(map[string]any)
if !ok {
t.Fatalf("expected chat_template_kwargs to be a map, got %T", body["chat_template_kwargs"])
}
if ctk["enable_thinking"] != false {
t.Fatalf("expected enable_thinking to be false, got %v", ctk["enable_thinking"])
}
})
t.Run("lemonade", func(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`)
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Provider: "lemonade",
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-lemonade-none",
Target: "lemonade-model",
Input: map[string]any{
"prompt": "hello",
"reasoning_effort": "none",
},
}, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
if body["think"] != false {
t.Fatalf("expected think to be false, got %v", body["think"])
}
if _, ok := body["reasoning_effort"]; ok {
t.Fatalf("expected reasoning_effort to be omitted, got %v", body["reasoning_effort"])
}
})
}
func TestOpenAICompatExecuteMapsThinkTrueForVLLMChatTemplateKwargs(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`)
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Provider: "vllm",
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-vllm",
Target: "qwen-model",
Input: map[string]any{
"prompt": "hello",
"think": true,
},
}, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
ctk, ok := body["chat_template_kwargs"].(map[string]any)
if !ok {
t.Fatalf("expected chat_template_kwargs to be a map, got %T", body["chat_template_kwargs"])
}
if ctk["enable_thinking"] != true {
t.Fatalf("expected enable_thinking to be true, got %v", ctk["enable_thinking"])
}
}
func TestOpenAICompatExecuteMapsThinkTrueForVLLMMLXChatTemplateKwargs(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`)
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Provider: "vllm-mlx",
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-vllm-mlx",
Target: "qwen-model",
Input: map[string]any{
"prompt": "hello",
"think": true,
},
}, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
ctk, ok := body["chat_template_kwargs"].(map[string]any)
if !ok {
t.Fatalf("expected chat_template_kwargs to be a map, got %T", body["chat_template_kwargs"])
}
if ctk["enable_thinking"] != true {
t.Fatalf("expected enable_thinking to be true, got %v", ctk["enable_thinking"])
}
}
func TestOpenAICompatExecuteMapsThinkTrueForLemonadeTopLevel(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`)
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Provider: "lemonade",
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-lemonade",
Target: "lemonade-model",
Input: map[string]any{
"prompt": "hello",
"think": true,
"thinking_token_budget": 1024,
},
}, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
if body["think"] != true {
t.Fatalf("expected think to be true, got %v", body["think"])
}
if body["thinking_token_budget"] != float64(1024) {
t.Fatalf("expected thinking_token_budget to be float64 1024, got %v", body["thinking_token_budget"])
}
}
func TestOpenAICompatExecuteMapsThinkingTokenBudgetForVLLMChatTemplateKwargs(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`)
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := New(config.OpenAICompatConf{
Provider: "vllm",
Endpoint: server.URL,
}, zap.NewNop())
sink := &fakeSink{}
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-vllm-budget",
Target: "qwen-model",
Input: map[string]any{
"prompt": "hello",
"thinking_token_budget": 2048,
},
}, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
if _, ok := body["thinking_token_budget"]; ok {
t.Fatalf("expected top-level thinking_token_budget to be omitted for vllm, got %v", body["thinking_token_budget"])
}
ctk, ok := body["chat_template_kwargs"].(map[string]any)
if !ok {
t.Fatalf("expected chat_template_kwargs to be a map, got %T", body["chat_template_kwargs"])
}
if ctk["enable_thinking"] != true {
t.Fatalf("expected enable_thinking to be true for budget-only request, got %v", ctk["enable_thinking"])
}
if ctk["thinking_token_budget"] != float64(2048) {
t.Fatalf("expected thinking_token_budget to be 2048, got %v", ctk["thinking_token_budget"])
}
}
func TestOpenAICompatExecuteVLLMUnsupportedInvalidBudget(t *testing.T) {
adapter := New(config.OpenAICompatConf{
Provider: "vllm",
Endpoint: "http://localhost:8000",
}, zap.NewNop())
sink := &fakeSink{}
invalidBudgets := []any{"invalid", -5, 12.34}
for _, val := range invalidBudgets {
err := adapter.Execute(context.Background(), runtime.ExecutionSpec{
RunID: "run-vllm-invalid-budget",
Target: "qwen-model",
Input: map[string]any{
"prompt": "hello",
"thinking_token_budget": val,
},
}, sink)
if err == nil {
t.Fatalf("expected error for budget val %v, got nil", val)
}
if !strings.Contains(err.Error(), "unsupported think control") {
t.Fatalf("expected error message to contain 'unsupported think control' for val %v, got: %v", val, err)
}
}
}