From 8f7b8c491457ac7e6398f1307dbaa90fe3c029d5 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 19:45:47 +0900 Subject: [PATCH 01/11] docs(devlog): lock the opencode go/zen/free stabilization roadmap Research: GitHub triage, cross-proxy survey of 14 projects that connect opencode zen/go, and a path:line inventory of the exact-id preset tables. --- .../000_plan.md | 35 ++++++++++++ .../001_issue_triage.md | 38 +++++++++++++ .../002_cross_proxy_survey.md | 37 ++++++++++++ .../003_registry_gap_inventory.md | 41 ++++++++++++++ .../010_phase1_preset_structured_output.md | 56 +++++++++++++++++++ .../020_phase2_preset_consistency_guard.md | 43 ++++++++++++++ .../030_phase3_docs_and_pr.md | 30 ++++++++++ 7 files changed, 280 insertions(+) create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/001_issue_triage.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/002_cross_proxy_survey.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/003_registry_gap_inventory.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md b/devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md new file mode 100644 index 0000000000..d88b2f209c --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md @@ -0,0 +1,35 @@ +# 260911 — opencode-go / zen / free 안정화 + +OpenCode Zen 게이트웨이(go, zen, free)로 붙는 세 프리셋은 로스터를 live `/models`로 받지만, 모델별 능력은 손으로 적은 정확-id 표에 묶여 있다. 그래서 새 모델 id가 게이트웨이에 뜨면 추론 강도 사다리, reasoning 재생, 비전 사이드카가 조용히 빈 채로 통과하고, 게이트웨이가 거절하는 요청 형태(특히 `response_format` json_schema)는 프리셋이 표현할 수단조차 없어 사용자가 직접 config를 고쳐야 한다. 이 유닛은 그 세 가지를 고친다: 프리셋이 구조화 출력 옵트아웃을 표현할 수 있게 하고, 이미 증명된 프리셋 내부 불일치 두 건을 맞추고, 같은 종류의 드리프트를 다음번엔 테스트가 잡게 만든다. 바뀌는 사람은 Zen Go/Free를 쓰는 운영자다 — 지금 손으로 넣고 있는 설정이 기본값이 되고, 새 id가 들어와도 능력 표가 어긋나면 CI가 먼저 운다. + +연구 근거는 `001_issue_triage.md`(GitHub 트리아지), `002_cross_proxy_survey.md`(다른 프록시 교차 조사), `003_registry_gap_inventory.md`(코드 갭 인벤토리)에 있다. + +## 루프 스펙 + +| 항목 | 내용 | +| --- | --- | +| Loop archetype | satisfy-spec. 열린 최적화가 아니라 확정된 갭 목록을 닫는다 | +| Trigger | 사용자 요청: opencode go/free 이슈·PR을 묶어 안정화 PR을 올려라 | +| Goal | dev를 base로 하는 PR 하나. 프리셋 능력 표현 + 내부 불일치 수정 + 회귀 가드 + 문서 동기화 | +| Non-goals | `src/providers/command-code-efforts.ts`(열린 PR #4258 소유), 어댑터 와이어 동작 변경, 새 사용자 config 필드, 라이브 업스트림 프로브가 필요한 주장, 무키 free 티어 정책 변경 | +| Verifier | `bun test tests/providers/provider-registry-parity.test.ts`, `bun test tests/providers/opencode-go-deepseek.test.ts`, `bun test tests/adapters/openai/openai-chat-hardening.test.ts`, `bun run typecheck`. 신설 가드는 수정 전 실패를 먼저 확인한다 | +| Stop condition | PR이 dev를 base로 열리고 템플릿 3개 섹션이 채워진 시점 | +| Memory artifact | `devlog/_plan/260911_opencode_go_free_stabilization/` | +| Expected terminal outcomes | DONE = PR 게시 + 모든 검증 명령 green. BLOCKED = 업스트림 사실 확인이 필요해 근거 없이 시드할 수 없는 항목이 남을 때 | +| Escalation condition | push 권한은 사용자가 이미 준 PR 게시로 한정한다. 머지·릴리스는 별도 승인. 라이브 프로브가 필요한 주장은 시드하지 않고 보고한다 | +| Resource bounds | 도구: repo 읽기/쓰기, gh 읽기 + PR 생성, grok-4.6 서브에이전트. 쓰기 범위: `src/providers`, `src/types`, `tests/providers`, `docs-site`, 이 플랜 유닛. 벽시계: 사용자 세션 내 | + +## 작업 단계 지도 (의존 순서) + +| work-phase | 문서 | 내용 | 선행 | +| --- | --- | --- | --- | +| wp1 | 000-003 | 조사 종합과 로드맵 잠금 (docs only) | — | +| wp2 | `010_phase1_preset_structured_output.md` | 프리셋이 `noStructuredOutputModels`를 표현하고 Zen 계열 DeepSeek에 시드 | wp1 | +| wp3 | `020_phase2_preset_consistency_guard.md` | 프리셋 내부 불일치 G1·G2 수정과 parity 회귀 가드 | wp2 | +| wp4 | `030_phase3_docs_and_pr.md` | docs-site 동기화와 PR 게시 | wp3 | + +goalplan의 wp3 제목은 초기 등록 시 "어댑터 전송 계층"이었다. 조사 결과 어댑터 와이어 결함은 이미 랜딩되었거나(`002`) 우리 구조상 발생하지 않아, 이 문서가 wp3의 실제 범위를 정합성·가드로 확정한다. + +## 아키텍트 자문 상태 + +grok-4.6 아키텍트 레인을 두 번 띄웠고 두 번 모두 최종 메시지 없이 턴이 종료됐다(같은 증상이 조사 레인 L1에서도 발생). 제안서 파일도 남지 않았다. 설계 판단은 main이 직접 내렸고, 이 자문 공백은 A 단계 독립 리뷰로 보완한다. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/001_issue_triage.md b/devlog/_plan/260911_opencode_go_free_stabilization/001_issue_triage.md new file mode 100644 index 0000000000..aec84399c1 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/001_issue_triage.md @@ -0,0 +1,38 @@ +# 010 — opencode-go / zen / free 이슈·PR 트리아지 + +수집일 2026-09-11. 소스: `gh issue list` / `gh pr list` (lidge-jun/opencodex), dev HEAD `b550d24e1`. + +## 열린 이슈 중 이 영역에 걸리는 것 + +| 번호 | 제목 | 판정 | 근거 | +| --- | --- | --- | --- | +| #4253 | Command Code live model `deepseek/deepseek-v4.1-flash` advertises no reasoning efforts | 유효, 단 **인접 PR #4258이 담당** | PR #4258이 `src/providers/command-code-efforts.ts`에 v4.1-flash / Qwen3.8-Flash 행 추가, base dev, mergeable, CI green | + +열린 이슈 60건 중 opencode-go/zen/free 고유 결함은 없다. 이 영역의 최근 결함은 대부분 닫혔다. + +## 최근 닫힌 항목 (2026-08-15 이후, 32건 중 발췌) + +| 번호 | 종료 | 제목 요약 | 현재 의미 | +| --- | --- | --- | --- | +| #4172 | COMPLETED | Go sessionless 요청이 `x-opencode-session` 누락 | 랜딩됨. `src/providers/opencode-go-transport.ts` | +| #4121 | COMPLETED | opencode-free: Zen이 세션 헤더 없는 요청 거부 | 랜딩됨. 무키 티어는 레지스트리 note로 차단 고지 | +| #3945 / #3857 / #3378 | COMPLETED | Claude/Pi 경로의 Go 세션 친화성 | 랜딩됨 | +| #3402 | COMPLETED | muse-spark via go: 미선언 클라이언트 툴이 서브에이전트 턴을 죽임 | 랜딩됨 | +| #2442 | COMPLETED | Go Responses가 `search_content_types` 거부 | 랜딩됨 | +| #2410 | COMPLETED | 신규 opencode-go 모델의 reasoningEfforts 누락 | **재발 구조 남음**: 030 참조 | +| #2193 / #2194 / #2156 | COMPLETED | muse-spark 502 / 스트림 중단 | 랜딩됨 | +| #1338 / #1415 | COMPLETED | Console Go 업스트림이 `response_format` json_schema를 400으로 거절 | **노브만 추가됨(#1424)**, 프리셋 시딩 없음 | + +## NOT_PLANNED로 닫혔지만 사실은 유효했던 것 + +| 번호 | 사유 | 실제 상태 | +| --- | --- | --- | +| #3362 | `#3378`로 통합 | 메인테이너가 유효·재현 가능으로 확인. `indexed_web_access` 미제거. #3378에서 처리 | +| #3344 | `#3378`로 통합 | 동일 | +| #2480 / #2394 | 템플릿 미비로 봇이 자동 종료 | 재현 정보 없음. 정보부족으로 남김 | +| #2484 | 템플릿 미비 | 보고자 스스로 `preserveResponsesReasoningContent` 미설정이 교란 변수였다고 정정 | + +## 남는 실물 갭 + +1. **구조화 출력 400**: #1338/#1415는 per-model 옵트아웃 노브(#1424)로만 닫혔다. Zen Go DeepSeek에 대한 기본 시딩은 없어서 사용자가 직접 config를 고쳐야 한다. 2026-09-11 커뮤니티 제보(디시인사이드 ai_utilize)에서 실제로 사용자가 `noStructuredOutputModels`에 deepseek를 넣어 해결했다. +2. **정확-id 표 드리프트**: #2410이 한 번 고쳐진 부류의 결함이 구조적으로 재발 가능하다. 030 참조. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/002_cross_proxy_survey.md b/devlog/_plan/260911_opencode_go_free_stabilization/002_cross_proxy_survey.md new file mode 100644 index 0000000000..828ff07253 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/002_cross_proxy_survey.md @@ -0,0 +1,37 @@ +# 020 — opencode zen/go를 커넥터로 붙이는 다른 프록시 교차 조사 + +조사일 2026-09-11. 판정 기준: 해당 저장소 **소스/설정**에 `opencode.ai/zen` 또는 `zen/go/v1`이 실제로 있는지. README 스니펫만 있으면 unverified. + +## 지원 인벤토리 + +| 프로젝트 | zen go 지원 근거 | 비고 | +| --- | --- | --- | +| musistudio/claude-code-router | `packages/core/src/agents/local-providers/opencode.ts`, 테스트가 `https://opencode.ai/zen/go/v1` 고정 | 세션 헤더 주입 구현 있음 | +| Kiowx/opencode-cc | `OPENCODE_CC_UPSTREAM=https://opencode.ai/zen/go` | reasoning 캐시·thinking 정규화 구현 있음 | +| kartikkabadi/opencode-go-proxy | `src/opencode_go_proxy/upstream.py` | 세션 헤더 미구현 | +| tbosancheros39/opencode-thinking-fix | `proxy/proxy.js`, `proxy/core.js` | 라우트별 reasoning 키 분기 | +| NousResearch/hermes-agent | `plugins/model-providers/opencode-zen/__init__.py` | thinking XOR effort 처리 | +| cline/cline | `sdk/packages/llms/src/providers/providers.generated.ts` | 클라이언트 카탈로그 | +| chatboxai/chatbox | `src/shared/providers/definitions/opencode-go.ts` | 모델별 엔드포인트 분기 | +| openclaw/openclaw | first-class `opencode-go` | 카탈로그 드리프트 이슈 다수 | +| sst/opencode (anomalyco/opencode) | 게이트웨이 본체 | 업스트림 결함의 출처 | + +**미지원으로 확인된 것** (`gh search code "opencode.ai/zen"` 빈 결과): router-for-me/CLIProxyAPI, BerriAI/litellm, songquanpeng/one-api, QuantumNous/new-api, oai2ollama. LiteLLM은 사용자 yaml에 `api_base: https://opencode.ai/zen/go/v1` + `drop_params: true`로 붙이는 방식이고 first-class 어댑터가 아니다. + +## 증상별 교차표 (opencodex 관점) + +| 증상 | 다른 프록시의 대응 | opencodex 현황 | +| --- | --- | --- | +| `MissingSessionID` 400 | CCR `upstream-header-sanitizer.ts:202-206`이 공식 Go 호스트에만 주입 | 이미 구현 (`src/providers/opencode-go-transport.ts`) | +| tool-call 이어가기 reasoning 재생 | opencode-cc v1.2.5 `4ac61aa` | 이미 구현 (`preserveReasoningContentModels` + `src/responses/reasoning-replay-cache.ts`) | +| compaction이 thinking을 버린 뒤 tool_use id로 회수 | opencode-cc v1.3.0 `internal/proxy/reasoning_cache.go` | 유사 캐시 존재. Chat 경로 커버리지는 **검증 필요** | +| Kimi/Go에서 `thinking`과 `reasoning_effort` 동시 전송 시 "cannot specify both" | hermes `__init__.py:45-55`가 XOR 강제 | `src/adapters/openai-chat.ts:1500-1565`가 if-else로 하나만 선택 → **현재 구조상 동시 전송 없음** | +| GLM `thinking.type=adaptive` + tools 400 | opencode-cc `b52b661`이 adaptive→auto | opencodex의 adaptive는 Anthropic 계열 전용. Go GLM chat 경로엔 해당 enum 미사용 | +| glm-5.2가 `reasoning` 거부, `reasoning_content`만 수용 | thinking-fix 3.3.0 라우트별 키 | `reasoningWireFormat` 분기 존재. Go glm 계열 실제 수용 필드는 **unverified** | +| 429 / Retry-After 없음 | ogp 백오프 재시도 | 이미 구현 (`src/providers/opencode-zen-rate-limit.ts`) | +| 모델 id 드리프트 | sst/opencode `ba72a6f` 문서 id 교체, ogp가 2회 거절 시 카탈로그에서 숨김 | **갭**. 030 참조 | +| `response_format` structured output 400 | 이 조사에서 외부 이슈 URL 미검출 | opencodex는 #1338/#1415 근거 보유 | + +## 결론 + +외부 프록시가 이미 해결했고 opencodex에 없는 항목은, 재확인 결과 대부분 **이미 랜딩되어 있거나 우리 코드 구조상 발생하지 않는다.** 실제로 남는 교차 갭은 **모델 id 드리프트 대응** 하나이며, 이는 030의 정확-id 표 문제와 같은 뿌리다. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/003_registry_gap_inventory.md b/devlog/_plan/260911_opencode_go_free_stabilization/003_registry_gap_inventory.md new file mode 100644 index 0000000000..40b54b4ed7 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/003_registry_gap_inventory.md @@ -0,0 +1,41 @@ +# 030 — 세 프리셋의 정확-id 표 갭 인벤토리 + +조사 대상 `src/providers/registry.ts` (opencode-go 1695-1791, opencode-zen 3016-3039, opencode-free 3042-3079). + +## 매칭 방식 + +| 메커니즘 | 방식 | 비교 지점 | +| --- | --- | --- | +| `noVisionModels`, `noReasoningModels`, `thinkingToggleModels`, `thinkingBudgetModels`, `preserveReasoningContentModels`, sampling 목록 | 정확 일치 + colon-family(`gpt-oss`→`gpt-oss:120b`)만 예외 | `src/types/tools.ts:241` | +| `modelReasoningEfforts`, `modelReasoningEffortMap`, `modelContextWindows`, `modelInputModalities` | 정확 own-property + colon-family + case-fold | `src/reasoning-effort.ts:115`, `src/codex/catalog/provider-fetch.ts:668,799` | +| `noStructuredOutputModels` | 정확 `Array.includes`만 (colon-family도 없음) | `src/adapters/openai-chat.ts:142,1580` | +| generated metadata | 정확 `r[0] === modelId` | `src/generated/model-metadata.ts:62` | + +`isDeepseekFlashModel`(`registry.ts:718`)은 substring이지만 **시드 루프 안에서만** 호출된다(`1754`, `3024`, `3065`). 런타임 조회 경로에는 쓰이지 않는다. + +## live 로스터와 시드의 비대칭 + +세 프리셋 모두 정적 `models:` 배열이 없고 live `/models`로 로스터를 받는다(go/zen은 `liveModels` 미지정 → 기본 ON, free는 `liveModels: true`). 새 id는 카탈로그에는 들어오지만(`tests/providers/provider-live-models.test.ts:111-146`), `applyProviderConfigHints`는 **이미 시드된 맵만** 조회한다(`provider-fetch.ts:766,799`). + +결과: 시드에 없는 live id는 reasoning ladder, replay, vision sidecar, context window, wire default가 전부 빈 채로 통과한다. #2410이 한 번 수동으로 메운 것과 같은 종류의 구멍이다. + +## 증명된 내부 불일치 (upstream 사실 없이도 고칠 수 있는 것) + +| # | 불일치 | 앵커 | 영향 | +| --- | --- | --- | --- | +| G1 | opencode-go `thinkingBudgetModels`는 `THINKING_BUDGET_MODELS` 전체(Neuralwatt 전용 `qwen3.5-397b`, `qwen3.6-35b` 포함)인데, 같은 프리셋의 `modelReasoningEfforts`는 `OPENCODE_GO_THINKING_BUDGET_MODELS`(4개)만 spread한다 | `registry.ts:1755` vs `1771` | 해당 id가 live로 오면 budget 게이트는 켜지고 광고할 ladder는 없다 | +| G2 | opencode-free는 같은 Zen 게이트웨이인데 paid DeepSeek id(`deepseek-v4-flash`, `deepseek-v4-pro`)를 reasoning/replay/noVision 어디에도 넣지 않는다. opencode-zen은 넣는다 | `registry.ts:3042-3079` vs `3016-3039` | free 로스터에 paid id가 등장하면 replay와 sidecar가 동시에 빠진다 | +| G3 | `noStructuredOutputModels`는 `ProviderRegistryEntry` 타입(`160-353`)에 필드 자체가 없고 `providerConfigSeed`(`src/providers/derive.ts:218`)도 복사하지 않는다 | 위 | 프리셋이 이 옵트아웃을 표현할 수단이 아예 없다. 사용자 config로만 가능 | + +## parity 테스트가 강제하지 않는 것 + +`tests/providers/provider-registry-parity.test.ts`는 알려진 id를 고정한다. 강제하지 **않는** 것: + +- live discovery로 들어온 미등록 id의 메타데이터 완전성 +- `noStructuredOutputModels` +- go `thinkingBudgetModels` ↔ `modelReasoningEfforts` 정합 (G1) +- zen ↔ free의 DeepSeek 처리 대칭 (G2). Zen은 DeepSeek ladder 케이스 배열에 아예 없다(`1385-1417`) + +## 이 유닛이 건드리지 않는 것 + +`src/providers/command-code-efforts.ts` — 열린 PR #4258이 소유한다. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md b/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md new file mode 100644 index 0000000000..90b0c83969 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md @@ -0,0 +1,56 @@ +# 010 — wp2: 프리셋이 구조화 출력 옵트아웃을 표현하게 한다 + +## 왜 + +`noStructuredOutputModels`는 #1424로 들어왔지만 사용자 config / management API 전용이다. `ProviderRegistryEntry`에 필드 자체가 없어서(`src/providers/registry.ts:160-353`) 어떤 프리셋도 "이 게이트웨이의 이 모델은 `response_format`을 거절한다"를 표현할 수 없다. 그래서 Zen Go에서 DeepSeek를 쓰는 운영자는 매번 손으로 config를 고친다(#1338, #1415, 2026-09-11 커뮤니티 제보). + +## 파일 변경 지도 + +| 파일 | 성격 | 내용 | +| --- | --- | --- | +| `src/providers/registry.ts` | MODIFY | `ProviderRegistryEntry`에 `noStructuredOutputModels?: string[]` 추가 (`noPenaltyModels` 옆, 321 부근). `ProviderConfigSeed` Pick 유니온(`355-362`)에 이름 추가 | +| `src/providers/derive.ts` | MODIFY | 로컬 엔트리 타입(`38-44`)에 필드 추가. `providerConfigSeed`(`250` 부근)와 두 번째 시드 경로(`310` 부근)에 spread 추가. `510/563` 계열 merge 함수에 `if (!prov.noStructuredOutputModels && seed.noStructuredOutputModels) ...` 추가 | +| `src/router.ts` | MODIFY | `347-353`에 `mergeStringArray(registryEntry.noStructuredOutputModels, provider.noStructuredOutputModels)`, `471-477`에 emit | +| `src/providers/registry.ts` | MODIFY | opencode-go(`1695-1791`), opencode-zen(`3016-3039`), opencode-free(`3042-3079`) 프리셋에 시드 | +| `tests/providers/provider-registry-parity.test.ts` | MODIFY | 세 프리셋의 시드 내용을 고정 | +| `tests/providers/opencode-go-deepseek.test.ts` | MODIFY | 시드가 실제 요청에서 `response_format`을 지우는지 어댑터 경유로 확인 | + +## 시드 내용 + +```ts +// opencode-go +noStructuredOutputModels: [...DEEPSEEK_THINKING_MODELS], +// opencode-zen +noStructuredOutputModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], +// opencode-free +noStructuredOutputModels: [...OPENCODE_FREE_DEEPSEEK_MODELS], +``` + +free는 무키 티어라 paid id를 시드하지 않는다. 그건 wp3의 G2에서 별도 판단한다. + +## 주석에 반드시 남길 사실과 트레이드오프 + +- 업스트림이 돌려준 문구는 `This response_format type is unavailable now`이고, 보고된 사례는 전부 `json_schema`다(#1415는 `Error from provider (Console Go)`를 그대로 인용한다). +- 이 노브의 문서화된 의미는 "`response_format` 필드를 생략한다"이므로, `json_object`를 보내던 클라이언트도 같이 영향을 받는다. Zen Go가 `json_object`를 수용하는지는 **이 유닛에서 라이브로 확인하지 못했다(unverified)**. 운영자가 이미 손으로 적용하고 있는 바로 그 완화를 기본값으로 옮기는 것이며, management API PATCH로 모델 단위 해제가 가능하다. +- 매칭은 정확 일치다(`src/adapters/openai-chat.ts:142,1580`). `deepseek-v4.1-flash` 같은 신규 id는 이 시드에 걸리지 않는다 — 의도적이다. 게이트웨이가 그 id를 서빙한다는 근거가 없다. + +## 수용 기준 + +1. `routeModel`을 거쳐 materialize한 opencode-go 프로바이더가 `noStructuredOutputModels`에 DeepSeek 두 id를 갖는다. +2. 같은 프로바이더로 `textFormat: json_schema` 요청을 만들면 `body.response_format`이 **없다**. 활성 시나리오: `buildOpenAIChatRequest`에 `deepseek-v4-flash`와 json_schema를 넣고 직렬화 결과를 읽는다. +3. 같은 프로바이더로 `glm-5.3`(시드에 없음) + json_schema면 `response_format`이 **남는다** — 정확 일치 경계가 살아 있다는 반대 증거. +4. 사용자 config가 이 필드를 비우면(management PATCH null) 시드 값이 다시 덮어쓰지 않는다. + +## 검증 + +``` +bun test tests/providers/provider-registry-parity.test.ts +bun test tests/providers/opencode-go-deepseek.test.ts +bun test tests/adapters/openai/openai-chat-hardening.test.ts +bun run typecheck +``` + +## 리스크 + +- `json_object` 동반 손실(위 트레이드오프). 완화: PATCH로 해제 가능, PR 본문에 명시. +- `ProviderConfigSeed` 유니온 확장이 다른 프리셋의 스냅샷 테스트를 건드릴 수 있다. 확인: `tests/providers/provider-config-validation.test.ts`, `tests/config/client-config-export.test.ts`. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md b/devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md new file mode 100644 index 0000000000..9897d469f5 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md @@ -0,0 +1,43 @@ +# 020 — wp3: 프리셋 내부 불일치 수정과 회귀 가드 + +## G1 — opencode-go의 thinking budget 게이트와 사다리가 어긋난다 + +`registry.ts:1755`가 `thinkingBudgetModels: THINKING_BUDGET_MODELS`(6개, Neuralwatt 전용 `qwen3.5-397b`·`qwen3.6-35b` 포함)인데, 같은 프리셋의 `modelReasoningEfforts`(`1771`)는 `OPENCODE_GO_THINKING_BUDGET_MODELS`(4개)만 spread한다. Go 로스터에 397b가 등장하면 어댑터는 `thinking_budget` 경로를 타는데(`src/adapters/openai-chat.ts:1539`) 카탈로그가 광고할 사다리는 없다. + +변경: `thinkingBudgetModels: OPENCODE_GO_THINKING_BUDGET_MODELS`. + +수용 기준: opencode-go 레지스트리 엔트리의 `thinkingBudgetModels`가 `modelReasoningEfforts`에 사다리를 가진 id의 부분집합이다. 활성 시나리오: parity 테스트가 두 컬렉션을 직접 비교한다. + +## G2 — opencode-free가 같은 게이트웨이인데 DeepSeek 처리가 비대칭이다 + +opencode-zen(`3016-3039`)은 `DEEPSEEK_THINKING_MODELS` + `OPENCODE_FREE_DEEPSEEK_MODELS`를 reasoning/replay/noVision에 넣는다. opencode-free(`3042-3079`)는 `-free` id만 넣는다. free는 `liveModels: true`이고 같은 `opencode.ai/zen/v1` 게이트웨이다. + +판단: free 로스터에 paid id가 실제로 등장한다는 근거는 없다. 따라서 **id를 추가하지 않는다.** 대신 두 프리셋이 공유해야 할 값을 상수로 묶어, 한쪽만 갱신되는 사고를 구조적으로 막는다. + +변경: zen/free가 공유하는 `OPENCODE_ZEN_DEEPSEEK_REPLAY_MODELS` 상수를 도입하고, free는 자기 로스터에 해당하는 부분집합을 그 상수에서 파생시킨다. 코드 위치는 `registry.ts:643` 부근. + +수용 기준: free의 `preserveReasoningContentModels`가 zen의 것과 같은 상수에서 파생되고, free에 없는 id를 zen에 추가해도 free가 조용히 뒤처지지 않는다. + +## 회귀 가드 + +`tests/providers/provider-registry-parity.test.ts`에 추가: + +1. **Go budget ⊆ ladder**: `thinkingBudgetModels`의 모든 id가 `modelReasoningEfforts`에 키를 가진다. +2. **Zen 계열 DeepSeek 대칭**: go/zen/free 각각에서, `modelReasoningEfforts`에 DeepSeek id가 있으면 `preserveReasoningContentModels`에도 있다. (#78/#950 계열 400의 구조적 방지) +3. **구조화 출력 시드 고정**: wp2가 넣은 세 프리셋의 시드 배열을 그대로 고정한다. + +세 가드 모두 수정 전 코드에서 먼저 실패시켜 red-green을 확인한다. 특히 1번은 현재 코드에서 `qwen3.5-397b`로 실패해야 한다 — 실패하지 않으면 가드가 무의미하다는 뜻이므로 가드를 다시 쓴다. + +## 검증 + +``` +bun test tests/providers/provider-registry-parity.test.ts +bun test tests/providers/opencode-zen-deepseek-reasoning.test.ts +bun test tests/providers/opencode-free-provider.test.ts +bun test tests/codex-integration/catalog-go-exact-efforts.test.ts +``` + +## 리스크 + +- `thinkingBudgetModels` 축소가 Go에서 397b를 실제로 쓰는 사용자에게 영향? 해당 id는 Go `modelReasoningEfforts`에 없어서 지금도 사다리가 없다. 축소는 광고되지 않던 경로를 끄는 것이다. +- parity 테스트는 배열 equality를 쓰는 곳이 있어(`73-80`) 시드 변경 시 같이 갱신해야 한다. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md b/devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md new file mode 100644 index 0000000000..22d44e1e45 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md @@ -0,0 +1,30 @@ +# 030 — wp4: 문서 동기화와 PR 게시 + +## 문서 + +`noStructuredOutputModels`는 이미 `docs-site/src/content/docs/reference/configuration/providers.md`와 각 로케일에 설명이 있다. 이번 변경은 **프리셋이 기본값으로 싣는다**는 사실을 추가한다. + +| 파일 | 변경 | +| --- | --- | +| `docs-site/src/content/docs/reference/configuration/providers.md` | `noStructuredOutputModels` 항목에 "opencode go / zen / free 프리셋은 Zen 게이트웨이의 DeepSeek 모델에 이 옵트아웃을 기본 시드한다. 해제하려면 management API PATCH로 빈 값을 보낸다" 한 문단 | +| `docs-site/src/content/docs/ko|ja|fr|ru|tr|zh-cn|zh-tw/reference/configuration/providers.md` | 같은 문단의 로케일 번역. 영문 원문과 모순되지 않게만 유지 | + +로케일 파일이 영문과 구조가 다르면 해당 위치에만 맞춰 넣고, 번역이 불가능한 항목은 영문 문장을 그대로 두지 않는다. + +## PR + +- base `dev`, head `codex/260911-opencode-go-free-stabilization` +- 템플릿 3개 섹션(Summary / Verification / Checklist) 전부 채운다 +- 본문에 반드시 포함: 닫는 이슈가 아니라 **묶음의 근거**(#1338, #1415, #1424, #2410), `json_object` 동반 손실 트레이드오프, 라이브 프로브 불가로 시드하지 않은 항목(`deepseek-v4.1-flash`), PR #4258과의 비충돌(파일 교집합 없음) +- `gui` 단어를 제목/본문에 쓰지 않는다(스크린샷 게이트 유발) +- `Closes #`는 쓰지 않는다. 이 PR이 단독으로 닫는 열린 이슈는 없다 + +## 검증 + +``` +bun run typecheck +bun run test +bun run privacy:scan +``` + +PR을 review-ready로 올리기 전 전체 스위트를 돌린다(AGENTS.md PR-ready 게이트). From 5441c47a9347e5fe48e51a64d3bafd1a9efade8a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 19:55:46 +0900 Subject: [PATCH 02/11] docs(devlog): fold the architect rebuttal into the structured-output slice The verified 400 is json_schema-specific, so the slice adds a narrow noJsonSchemaModels contract instead of seeding the blunt response_format kill switch. --- .../010_phase1_preset_structured_output.md | 58 ++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md b/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md index 90b0c83969..68102668f5 100644 --- a/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md +++ b/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md @@ -15,31 +15,60 @@ | `tests/providers/provider-registry-parity.test.ts` | MODIFY | 세 프리셋의 시드 내용을 고정 | | `tests/providers/opencode-go-deepseek.test.ts` | MODIFY | 시드가 실제 요청에서 `response_format`을 지우는지 어댑터 경유로 확인 | +## 설계 수정 (아키텍트 반박 수용, 2026-09-11) + +초안은 `noStructuredOutputModels`를 세 프리셋에 그대로 시드하려 했다. 독립 아키텍트 자문이 이를 반박했고 main이 수용한다. + +반박 요지: 확인된 400은 `json_schema` **타입** 한정이다(`This response_format type is unavailable now`). 그런데 이 노브의 계약은 "`response_format` 필드를 통째로 생략"이라, 시드하면 `json_object`를 쓰던 클라이언트까지 같이 죽는다. 커뮤니티 제보는 운영자가 고른 무딘 킬스위치이지 "json_object도 거절된다"는 증거가 아니다. 그걸 기본값으로 올리면 앞으로 json_object가 실제로 거절되는지 여부를 관측할 신호까지 덮어버린다. + +수정된 설계: **확인된 사실만 표현하는 좁은 필드를 새로 만든다.** + +`noJsonSchemaModels` — "이 모델은 `response_format` `json_schema`를 거절한다. `json_object`에 대해서는 아무 주장도 하지 않는다." + +동작: + +| 요청 | 시드된 모델 | 시드되지 않은 모델 | +| --- | --- | --- | +| `json_schema` | `{"type":"json_object"}`로 낮춰 보낸다 | 그대로 `json_schema` | +| `json_object` | 그대로 | 그대로 | +| 사용자가 `noStructuredOutputModels`에 넣음 | 기존대로 필드 전체 생략(우선한다) | 동일 | + +낮추기를 택한 이유: 클라이언트가 원한 건 JSON이다. 필드를 지우면 산문이 돌아오고, `json_object`로 낮추면 최소한 JSON이 온다. Zen Go가 `json_object`를 수용하는지는 **unverified**이지만, 거절한다면 400이 다시 뜨고 그건 새로운 검증된 사실이 되어 시드를 넓힐 근거가 된다. 킬스위치로 덮으면 그 신호가 사라진다. + ## 시드 내용 ```ts // opencode-go -noStructuredOutputModels: [...DEEPSEEK_THINKING_MODELS], +noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS], // opencode-zen -noStructuredOutputModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], +noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], // opencode-free -noStructuredOutputModels: [...OPENCODE_FREE_DEEPSEEK_MODELS], +noJsonSchemaModels: [...OPENCODE_FREE_DEEPSEEK_MODELS], ``` -free는 무키 티어라 paid id를 시드하지 않는다. 그건 wp3의 G2에서 별도 판단한다. +매칭은 기존 목록과 같은 정확 일치다. `deepseek-v4.1-flash` 같은 신규 id는 걸리지 않는다 — 의도적이다. 게이트웨이가 그 id를 서빙한다는 근거가 없다. -## 주석에 반드시 남길 사실과 트레이드오프 +## 배선 경로 (최소 경로를 택한다) -- 업스트림이 돌려준 문구는 `This response_format type is unavailable now`이고, 보고된 사례는 전부 `json_schema`다(#1415는 `Error from provider (Console Go)`를 그대로 인용한다). -- 이 노브의 문서화된 의미는 "`response_format` 필드를 생략한다"이므로, `json_object`를 보내던 클라이언트도 같이 영향을 받는다. Zen Go가 `json_object`를 수용하는지는 **이 유닛에서 라이브로 확인하지 못했다(unverified)**. 운영자가 이미 손으로 적용하고 있는 바로 그 완화를 기본값으로 옮기는 것이며, management API PATCH로 모델 단위 해제가 가능하다. -- 매칭은 정확 일치다(`src/adapters/openai-chat.ts:142,1580`). `deepseek-v4.1-flash` 같은 신규 id는 이 시드에 걸리지 않는다 — 의도적이다. 게이트웨이가 그 id를 서빙한다는 근거가 없다. +라우터는 레지스트리 엔트리와 사용자 config를 요청 시점에 병합한다(`src/router.ts:346-358`의 `mergeStringArray`, `471-482`의 emit). 따라서 프리셋 값은 `providerConfigSeed`로 config.json에 **영속시키지 않아도** 요청 경로에 도달한다. 새 사용자 설정 화면이나 management PATCH는 이번 범위가 아니다. + +| 파일 | 성격 | 내용 | +| --- | --- | --- | +| `src/types/provider.ts` | MODIFY | `noStructuredOutputModels`(`639-643`) 바로 아래에 `noJsonSchemaModels?: string[]` + 계약 주석 | +| `src/providers/registry.ts` | MODIFY | `ProviderRegistryEntry`에 같은 필드(`321` 부근), 세 프리셋에 시드 | +| `src/router.ts` | MODIFY | `mergeStringArray` 한 줄 + emit 한 줄 | +| `src/config.ts` | MODIFY | zod 스키마에 한 줄(`622` 패턴) — 사용자가 손으로 넣어도 검증을 통과하게 | +| `src/adapters/openai-chat.ts` | MODIFY | `142`(네이티브 패스스루)와 `1580`(번역 경로) 두 지점 모두에 낮추기 분기 | +| `tests/adapters/openai/openai-chat-hardening.test.ts` | MODIFY | 낮추기 동작과 경계 | +| `tests/providers/provider-registry-parity.test.ts` | MODIFY | 세 프리셋 시드 고정 | ## 수용 기준 -1. `routeModel`을 거쳐 materialize한 opencode-go 프로바이더가 `noStructuredOutputModels`에 DeepSeek 두 id를 갖는다. -2. 같은 프로바이더로 `textFormat: json_schema` 요청을 만들면 `body.response_format`이 **없다**. 활성 시나리오: `buildOpenAIChatRequest`에 `deepseek-v4-flash`와 json_schema를 넣고 직렬화 결과를 읽는다. -3. 같은 프로바이더로 `glm-5.3`(시드에 없음) + json_schema면 `response_format`이 **남는다** — 정확 일치 경계가 살아 있다는 반대 증거. -4. 사용자 config가 이 필드를 비우면(management PATCH null) 시드 값이 다시 덮어쓰지 않는다. +1. `routeModel`을 거쳐 materialize한 opencode-go 프로바이더가 `noJsonSchemaModels`에 DeepSeek 두 id를 갖는다. +2. 같은 프로바이더로 `deepseek-v4-flash` + `textFormat: json_schema` 요청을 만들면 직렬화된 `body.response_format`이 `{"type":"json_object"}`다. 활성 시나리오: 번역 경로는 `buildOpenAIChatRequest`, 네이티브 경로는 `buildOpenAIChatPassthroughRequest`에 각각 넣고 결과 본문을 읽는다. +3. 같은 프로바이더로 `glm-5.3`(시드에 없음) + json_schema면 `response_format.type`이 `json_schema`로 **남는다** — 정확 일치 경계가 살아 있다는 반대 증거. +4. 시드된 모델 + `json_object` 요청은 그대로 `json_object`다 — 낮추기가 json_object를 건드리지 않는다는 반대 증거. +5. 같은 모델이 `noStructuredOutputModels`에도 있으면 `response_format`이 아예 없다 — 킬스위치 우선순위. ## 검증 @@ -52,5 +81,6 @@ bun run typecheck ## 리스크 -- `json_object` 동반 손실(위 트레이드오프). 완화: PATCH로 해제 가능, PR 본문에 명시. -- `ProviderConfigSeed` 유니온 확장이 다른 프리셋의 스냅샷 테스트를 건드릴 수 있다. 확인: `tests/providers/provider-config-validation.test.ts`, `tests/config/client-config-export.test.ts`. +- Zen Go가 `json_object`도 거절하면 낮추기는 400을 막지 못한다. 그건 감추지 않고 드러내는 선택이며, 그때는 검증된 사실로 `noStructuredOutputModels` 쪽으로 넓히면 된다. +- 스키마를 요구한 클라이언트가 느슨한 JSON을 받는다. 필드를 지워 산문을 받는 기존 대안보다 낫고, 두 지점 모두 테스트로 고정한다. +- 새 필드가 라우터 병합 목록에서 빠지면 프리셋 값이 요청에 도달하지 않는다. 수용 기준 1이 이걸 직접 관측한다. From 07d5724a6fe1cb9c4eb775d8d89c4a6e4235055e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:03:30 +0900 Subject: [PATCH 03/11] docs(devlog): fold both audit rounds into the stabilization roadmap Corrects the live-discovery claim, pins the adapter branch order, records the execution-verified budget/ladder mismatch, and flips the free-tier DeepSeek decision to match the in-entry precedent. --- .../000_plan.md | 9 ++++-- .../010_phase1_preset_structured_output.md | 15 ++++------ .../020_phase2_preset_consistency_guard.md | 30 ++++++++++++++++--- .../030_phase3_docs_and_pr.md | 8 ++--- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md b/devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md index d88b2f209c..55d56096a1 100644 --- a/devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md +++ b/devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md @@ -1,6 +1,6 @@ # 260911 — opencode-go / zen / free 안정화 -OpenCode Zen 게이트웨이(go, zen, free)로 붙는 세 프리셋은 로스터를 live `/models`로 받지만, 모델별 능력은 손으로 적은 정확-id 표에 묶여 있다. 그래서 새 모델 id가 게이트웨이에 뜨면 추론 강도 사다리, reasoning 재생, 비전 사이드카가 조용히 빈 채로 통과하고, 게이트웨이가 거절하는 요청 형태(특히 `response_format` json_schema)는 프리셋이 표현할 수단조차 없어 사용자가 직접 config를 고쳐야 한다. 이 유닛은 그 세 가지를 고친다: 프리셋이 구조화 출력 옵트아웃을 표현할 수 있게 하고, 이미 증명된 프리셋 내부 불일치 두 건을 맞추고, 같은 종류의 드리프트를 다음번엔 테스트가 잡게 만든다. 바뀌는 사람은 Zen Go/Free를 쓰는 운영자다 — 지금 손으로 넣고 있는 설정이 기본값이 되고, 새 id가 들어와도 능력 표가 어긋나면 CI가 먼저 운다. +OpenCode Zen 게이트웨이(go, zen, free)로 붙는 세 프리셋은 정적 `models:` 배열이 없고, 카탈로그가 `liveModels !== false`인 프로바이더를 live로 훑는다(`src/codex/catalog/provider-fetch.ts:440`). free만 `liveModels: true`를 명시하고(`src/providers/registry.ts:3049`) go/zen은 선언 없이 기본값으로 그 경로를 탄다. 반면 모델별 능력은 손으로 적은 정확-id 표에 묶여 있다. 그래서 새 모델 id가 게이트웨이에 뜨면 추론 강도 사다리, reasoning 재생, 비전 사이드카가 조용히 빈 채로 통과하고, 게이트웨이가 거절하는 요청 형태(특히 `response_format` json_schema)는 프리셋이 표현할 수단조차 없어 사용자가 직접 config를 고쳐야 한다. 이 유닛은 그 세 가지를 고친다: 프리셋이 그 거절을 표현할 수 있게 하고, 이미 증명된 프리셋 내부 불일치 두 건을 맞추고, 같은 종류의 드리프트를 다음번엔 테스트가 잡게 만든다. 바뀌는 사람은 Zen Go/Free를 쓰는 운영자다 — 지금 손으로 넣고 있는 설정이 기본값이 되고, 새 id가 들어와도 능력 표가 어긋나면 CI가 먼저 운다. 연구 근거는 `001_issue_triage.md`(GitHub 트리아지), `002_cross_proxy_survey.md`(다른 프록시 교차 조사), `003_registry_gap_inventory.md`(코드 갭 인벤토리)에 있다. @@ -30,6 +30,9 @@ OpenCode Zen 게이트웨이(go, zen, free)로 붙는 세 프리셋은 로스터 goalplan의 wp3 제목은 초기 등록 시 "어댑터 전송 계층"이었다. 조사 결과 어댑터 와이어 결함은 이미 랜딩되었거나(`002`) 우리 구조상 발생하지 않아, 이 문서가 wp3의 실제 범위를 정합성·가드로 확정한다. -## 아키텍트 자문 상태 +## 자문과 감사 기록 -grok-4.6 아키텍트 레인을 두 번 띄웠고 두 번 모두 최종 메시지 없이 턴이 종료됐다(같은 증상이 조사 레인 L1에서도 발생). 제안서 파일도 남지 않았다. 설계 판단은 main이 직접 내렸고, 이 자문 공백은 A 단계 독립 리뷰로 보완한다. +- **아키텍트**: grok-4.6. 첫 턴이 끊겨 한 번 재촉한 뒤 제안서를 받았다. 초안의 `noStructuredOutputModels` 시딩을 MISALIGNED로 반박했고 main이 수용했다(010 수정절). +- **독립 감사 2레인**: grok-4.6과 상속 모델로 각각 한 번. 둘 다 `VERDICT: near-pass`. 지적은 010/020에 전부 반영했다. +- **G2 불일치**: 두 리뷰어가 갈렸다. grok 레인은 "free 로스터에 paid id 증거가 없으니 넣지 말라", 상속 레인은 "같은 엔트리가 이미 #1043 근거로 공유 text-only 목록을 free에 통째로 싣는 선례가 있고(`registry.ts:3076`), 능력 표는 정확 일치라 없는 id면 무해하다"고 했다. main은 후자를 채택한다 — 능력 표는 카탈로그 로스터를 만들지 않으므로(`applyProviderConfigHints`는 이미 들어온 id만 장식한다) 없는 모델을 광고하지 않는다. +- **실패한 레인**: GitHub 트리아지 레인과 첫 리뷰어 레인은 grok-4.6에서 최종 메시지 없이 턴이 끝나는 증상으로 각각 두 번 실패해 은퇴시켰고, 해당 작업은 main이 직접 수행했다. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md b/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md index 68102668f5..ff11856447 100644 --- a/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md +++ b/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md @@ -4,16 +4,9 @@ `noStructuredOutputModels`는 #1424로 들어왔지만 사용자 config / management API 전용이다. `ProviderRegistryEntry`에 필드 자체가 없어서(`src/providers/registry.ts:160-353`) 어떤 프리셋도 "이 게이트웨이의 이 모델은 `response_format`을 거절한다"를 표현할 수 없다. 그래서 Zen Go에서 DeepSeek를 쓰는 운영자는 매번 손으로 config를 고친다(#1338, #1415, 2026-09-11 커뮤니티 제보). -## 파일 변경 지도 +## 선례 -| 파일 | 성격 | 내용 | -| --- | --- | --- | -| `src/providers/registry.ts` | MODIFY | `ProviderRegistryEntry`에 `noStructuredOutputModels?: string[]` 추가 (`noPenaltyModels` 옆, 321 부근). `ProviderConfigSeed` Pick 유니온(`355-362`)에 이름 추가 | -| `src/providers/derive.ts` | MODIFY | 로컬 엔트리 타입(`38-44`)에 필드 추가. `providerConfigSeed`(`250` 부근)와 두 번째 시드 경로(`310` 부근)에 spread 추가. `510/563` 계열 merge 함수에 `if (!prov.noStructuredOutputModels && seed.noStructuredOutputModels) ...` 추가 | -| `src/router.ts` | MODIFY | `347-353`에 `mergeStringArray(registryEntry.noStructuredOutputModels, provider.noStructuredOutputModels)`, `471-477`에 emit | -| `src/providers/registry.ts` | MODIFY | opencode-go(`1695-1791`), opencode-zen(`3016-3039`), opencode-free(`3042-3079`) 프리셋에 시드 | -| `tests/providers/provider-registry-parity.test.ts` | MODIFY | 세 프리셋의 시드 내용을 고정 | -| `tests/providers/opencode-go-deepseek.test.ts` | MODIFY | 시드가 실제 요청에서 `response_format`을 지우는지 어댑터 경유로 확인 | +`noPenaltyModels`가 같은 배선을 이미 완결해 두었다: 선언 `src/providers/registry.ts:323` → 병합 `src/router.ts:351` → emit `src/router.ts:475` → 소비 `src/adapters/openai-chat.ts:134`. 새 필드는 이 네 지점을 그대로 따른다. 아래 "배선 경로" 표가 확정 파일 지도다. ## 설계 수정 (아키텍트 반박 수용, 2026-09-11) @@ -70,6 +63,10 @@ noJsonSchemaModels: [...OPENCODE_FREE_DEEPSEEK_MODELS], 4. 시드된 모델 + `json_object` 요청은 그대로 `json_object`다 — 낮추기가 json_object를 건드리지 않는다는 반대 증거. 5. 같은 모델이 `noStructuredOutputModels`에도 있으면 `response_format`이 아예 없다 — 킬스위치 우선순위. +### 분기 순서 (감사 지적 반영) + +네이티브 패스스루(`src/adapters/openai-chat.ts:142`)는 킬스위치가 `delete body.response_format`을 먼저 실행한다. 낮추기 분기는 반드시 **else-if**로 붙여, 지워진 필드를 다시 넣지 않게 한다. 번역 경로(`1580`)는 킬스위치가 블록 전체를 건너뛰므로 그 안에서 json_schema만 json_object로 바꾸면 된다. 수용 기준 5가 이 순서를 직접 관측한다. + ## 검증 ``` diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md b/devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md index 9897d469f5..db570faab0 100644 --- a/devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md +++ b/devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md @@ -2,7 +2,16 @@ ## G1 — opencode-go의 thinking budget 게이트와 사다리가 어긋난다 -`registry.ts:1755`가 `thinkingBudgetModels: THINKING_BUDGET_MODELS`(6개, Neuralwatt 전용 `qwen3.5-397b`·`qwen3.6-35b` 포함)인데, 같은 프리셋의 `modelReasoningEfforts`(`1771`)는 `OPENCODE_GO_THINKING_BUDGET_MODELS`(4개)만 spread한다. Go 로스터에 397b가 등장하면 어댑터는 `thinking_budget` 경로를 타는데(`src/adapters/openai-chat.ts:1539`) 카탈로그가 광고할 사다리는 없다. +`registry.ts:1773`이 `thinkingBudgetModels: THINKING_BUDGET_MODELS`(6개, Neuralwatt 전용 `qwen3.5-397b`·`qwen3.6-35b` 포함)인데, 같은 프리셋의 `modelReasoningEfforts`(`1753` 부근의 spread)는 `OPENCODE_GO_THINKING_BUDGET_MODELS`(4개)만 넣는다. Go 로스터에 397b가 등장하면 어댑터는 `thinking_budget` 경로를 타는데(`src/adapters/openai-chat.ts:1539`) 카탈로그가 광고할 사다리는 없다. + +실행 근거(`.tmp/preset-probe.ts`로 레지스트리를 직접 로드): + +``` +thinkingBudgetModels: ["qwen3.5-397b","qwen3.6-35b","qwen3.5-plus","qwen3.6-plus","qwen3.7-max","qwen3.7-plus"] +budget ids missing from ladder: ["qwen3.5-397b","qwen3.6-35b"] +``` + +감사 확인: 이 6원소를 equality로 고정한 테스트는 없다. `qwen3.5-397b`를 고정하는 건 neuralwatt 경로뿐이다(`tests/codex-integration/reasoning-effort.test.ts:875`, parity `356-376`). 변경: `thinkingBudgetModels: OPENCODE_GO_THINKING_BUDGET_MODELS`. @@ -12,11 +21,24 @@ opencode-zen(`3016-3039`)은 `DEEPSEEK_THINKING_MODELS` + `OPENCODE_FREE_DEEPSEEK_MODELS`를 reasoning/replay/noVision에 넣는다. opencode-free(`3042-3079`)는 `-free` id만 넣는다. free는 `liveModels: true`이고 같은 `opencode.ai/zen/v1` 게이트웨이다. -판단: free 로스터에 paid id가 실제로 등장한다는 근거는 없다. 따라서 **id를 추가하지 않는다.** 대신 두 프리셋이 공유해야 할 값을 상수로 묶어, 한쪽만 갱신되는 사고를 구조적으로 막는다. +실행 근거: + +``` +zen preserveReasoningContentModels: ["deepseek-v4-pro","deepseek-v4-flash","deepseek-v4-flash-free"] +free preserveReasoningContentModels: ["deepseek-v4-flash-free"] +zen noVisionModels: [... text-only 6 ..., "deepseek-v4-pro", "deepseek-v4-flash"] +free noVisionModels: [... text-only 6 ...] +``` + +판단(감사 후 변경): **zen과 동일한 id를 free에도 싣는다.** 초안은 "free 로스터에 paid id 증거가 없으니 넣지 않는다"였고 grok 리뷰어도 같은 의견이었지만, 상속 모델 리뷰어가 같은 엔트리의 선례를 들어 반박했고 그쪽이 맞다: + +- free는 이미 zen과 공유하는 text-only 목록 전체를 "같은 게이트웨이·같은 로스터"라는 근거로 싣는다(`registry.ts:3076`, #1043). +- 능력 표는 카탈로그 로스터를 만들지 않는다. `applyProviderConfigHints`는 이미 들어온 id만 장식하므로(`src/codex/catalog/provider-fetch.ts:766,799`), 등장하지 않는 id를 시드해도 아무것도 광고되지 않는다. 무해하고, 등장하면 정확하다. +- "상수에서 부분집합 파생"은 필터가 여전히 수작업이라 드리프트를 구조적으로 막지 못한다. -변경: zen/free가 공유하는 `OPENCODE_ZEN_DEEPSEEK_REPLAY_MODELS` 상수를 도입하고, free는 자기 로스터에 해당하는 부분집합을 그 상수에서 파생시킨다. 코드 위치는 `registry.ts:643` 부근. +변경: free의 `modelReasoningEfforts` / `modelReasoningEffortMap` / `preserveReasoningContentModels` / `noVisionModels`가 zen과 같은 DeepSeek 집합을 쓰도록 같은 상수에서 파생시킨다. -수용 기준: free의 `preserveReasoningContentModels`가 zen의 것과 같은 상수에서 파생되고, free에 없는 id를 zen에 추가해도 free가 조용히 뒤처지지 않는다. +수용 기준: free와 zen의 DeepSeek 관련 목록이 같은 집합을 갖는다. 반대 증거로, zen 전용이 아닌 free 고유 항목(text-only 무료 id)은 그대로 남는다. ## 회귀 가드 diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md b/devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md index 22d44e1e45..6285398fe6 100644 --- a/devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md +++ b/devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md @@ -2,12 +2,12 @@ ## 문서 -`noStructuredOutputModels`는 이미 `docs-site/src/content/docs/reference/configuration/providers.md`와 각 로케일에 설명이 있다. 이번 변경은 **프리셋이 기본값으로 싣는다**는 사실을 추가한다. +`noStructuredOutputModels`는 이미 `docs-site/src/content/docs/reference/configuration/providers.md`와 각 로케일에 설명이 있다. 이번 변경은 그 옆에 **새 필드 `noJsonSchemaModels`** 를 추가하고, opencode go/zen/free 프리셋이 이를 기본으로 싣는다는 사실을 적는다. | 파일 | 변경 | | --- | --- | -| `docs-site/src/content/docs/reference/configuration/providers.md` | `noStructuredOutputModels` 항목에 "opencode go / zen / free 프리셋은 Zen 게이트웨이의 DeepSeek 모델에 이 옵트아웃을 기본 시드한다. 해제하려면 management API PATCH로 빈 값을 보낸다" 한 문단 | -| `docs-site/src/content/docs/ko|ja|fr|ru|tr|zh-cn|zh-tw/reference/configuration/providers.md` | 같은 문단의 로케일 번역. 영문 원문과 모순되지 않게만 유지 | +| `docs-site/src/content/docs/reference/configuration/providers.md` | `noStructuredOutputModels` 항목 바로 뒤에 `noJsonSchemaModels` 항목 추가: json_schema만 json_object로 낮추고 json_object는 건드리지 않는다, 두 필드가 함께 있으면 `noStructuredOutputModels`가 우선한다, opencode go/zen/free 프리셋이 Zen 게이트웨이의 DeepSeek id에 기본 시드한다 | +| `docs-site/src/content/docs/ko|ja|fr|ru|tr|zh-cn|zh-tw/reference/configuration/providers.md` | 같은 항목의 로케일 번역. 영문 원문과 모순되지 않게 유지 | 로케일 파일이 영문과 구조가 다르면 해당 위치에만 맞춰 넣고, 번역이 불가능한 항목은 영문 문장을 그대로 두지 않는다. @@ -15,7 +15,7 @@ - base `dev`, head `codex/260911-opencode-go-free-stabilization` - 템플릿 3개 섹션(Summary / Verification / Checklist) 전부 채운다 -- 본문에 반드시 포함: 닫는 이슈가 아니라 **묶음의 근거**(#1338, #1415, #1424, #2410), `json_object` 동반 손실 트레이드오프, 라이브 프로브 불가로 시드하지 않은 항목(`deepseek-v4.1-flash`), PR #4258과의 비충돌(파일 교집합 없음) +- 본문에 반드시 포함: 닫는 이슈가 아니라 **묶음의 근거**(#1338, #1415, #1424, #2410), Zen Go의 `json_object` 수용 여부가 unverified라는 점과 그래서 킬스위치 대신 낮추기를 택한 이유, 라이브 프로브 불가로 시드하지 않은 항목(`deepseek-v4.1-flash`), PR #4258과의 비충돌(파일 교집합 없음) - `gui` 단어를 제목/본문에 쓰지 않는다(스크린샷 게이트 유발) - `Closes #`는 쓰지 않는다. 이 PR이 단독으로 닫는 열린 이슈는 없다 From 8f40d8dc4401b4a18f27dca01f9dd95c0ae90011 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:06:06 +0900 Subject: [PATCH 04/11] docs(devlog): record the advisory and audit rounds for the stabilization unit --- .../004_audit_rounds.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/004_audit_rounds.md diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/004_audit_rounds.md b/devlog/_plan/260911_opencode_go_free_stabilization/004_audit_rounds.md new file mode 100644 index 0000000000..0f523c3e9b --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/004_audit_rounds.md @@ -0,0 +1,39 @@ +# 004 — 자문·감사 라운드 원문 기록 + +## 라운드 1 — 아키텍트 (grok-4.6, 읽기 전용) + +판정: `ALIGNED if Main keeps the six slices below, keeps #4258 out of scope, and treats (i)/(ii) as the two judgment calls rather than as new subsystems. MISALIGNED if Main seeds noStructuredOutputModels as a registry field, invents live-id facts, or reopens landed transport/cache work.` + +핵심 반박 (main 수용): + +> Treat the community report as "users are disabling structured output entirely to escape a json_schema 400," not as proof that json_object is also rejected. … Live probe: impossible in this unit. Therefore we must not promote a full structured-output ban into the seed tables. + +main 처분: **수용.** 010을 `noJsonSchemaModels` 좁은 계약으로 다시 썼다. 다만 아키텍트가 제안한 "어댑터에서 provider id로 분기" 방식은 채택하지 않았다 — 이 저장소의 관용은 프로바이더 설정 필드가 어댑터 동작을 구동하는 것이고, 어댑터에 프로바이더 id를 박으면 새 결합이 생긴다. + +## 라운드 2 — 독립 감사 2레인 + +두 레인 모두 `VERDICT: near-pass`. + +### 레인 A (상속 모델) + +- 배선: `선례가 noPenaltyModels로 완결돼 있다: registry.ts:323 → router.ts:351 병합 + router.ts:475 emit → openai-chat.ts:134` +- 지적: `142는 delete 후 downgrade가 다시 넣지 않도록 else-if 순서를 명시해야 한다 — 계획에 순서 언급이 없다` +- 지적: `라인 드리프트: 실제 게이트는 registry.ts:1773, 사다리는 1753(문서의 1755/1771 아님)` +- 지적: `G2 — 판단이 약하다. 같은 엔트리 registry.ts:3076이 이미 "같은 게이트웨이·같은 로스터"를 근거로 free에 공유 text-only 목록 전체를 싣는 선례(#1043)다` +- 지적: `000_plan 첫 문단 "세 프리셋은 로스터를 live /models로 받지만" — liveModels는 free만(registry.ts:3049)` + +### 레인 B (grok-4.6) + +- 지적: `010 상단 파일지도는 구설계(noStructuredOutputModels 시드)라 수정절과 충돌한다` +- 반대 의견: `G2 타당. live 로스터에 paid id 증거가 없고, zen처럼 paid id를 넣으면 없는 모델을 광고한다` +- 두 레인 공통: `#4258 교집합 없음` + +## 불일치 처분 — G2 + +레인 B의 "없는 모델을 광고한다"는 부정확하다. 능력 표는 카탈로그 로스터를 만들지 않는다: `applyProviderConfigHints`는 이미 로스터로 들어온 id만 장식한다(`src/codex/catalog/provider-fetch.ts:766,799`). 로스터는 live `/models` 또는 정적 `models:` 배열에서 나오고, 세 프리셋은 정적 배열이 없다. 따라서 등장하지 않는 id를 능력 표에 시드해도 광고는 발생하지 않는다. + +레인 A의 선례가 더 강하다. main은 레인 A를 채택한다. + +## 실패한 레인 기록 + +GitHub 트리아지 레인과 1차 리뷰어 레인은 grok-4.6에서 턴이 `completed` 로 끝나면서 최종 메시지가 비는 증상으로 각각 두 번 실패했다(중간 commentary만 남음). 은퇴시키고 해당 작업은 main이 직접 수행했다. 같은 모델의 아키텍트·감사 레인은 한 번 재촉 후 정상 산출했으므로 모델 전면 배제는 하지 않았다. From ccc90de6a5ee86a635f892dfb2518681f2dbf013 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:08:22 +0900 Subject: [PATCH 05/11] docs(devlog): re-verify the wp2 edit sites against the current tree --- .../010_phase1_preset_structured_output.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md b/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md index ff11856447..d45d36e666 100644 --- a/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md +++ b/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md @@ -4,6 +4,21 @@ `noStructuredOutputModels`는 #1424로 들어왔지만 사용자 config / management API 전용이다. `ProviderRegistryEntry`에 필드 자체가 없어서(`src/providers/registry.ts:160-353`) 어떤 프리셋도 "이 게이트웨이의 이 모델은 `response_format`을 거절한다"를 표현할 수 없다. 그래서 Zen Go에서 DeepSeek를 쓰는 운영자는 매번 손으로 config를 고친다(#1338, #1415, 2026-09-11 커뮤니티 제보). +## wp2 P 재검증 (2026-09-11, 사이클 진입 시) + +문서가 지목한 편집 지점을 현재 트리에서 전부 다시 확인했다. 드리프트 없음. + +| 지점 | 현재 내용 | +| --- | --- | +| `src/types/provider.ts:643` | `noStructuredOutputModels?: string[];` 선언과 계약 주석 | +| `src/providers/registry.ts:319-323` | `noVisionModels`…`noPenaltyModels` 선언 블록 | +| `src/router.ts:351` | `const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels);` | +| `src/router.ts:475` | `...(noPenaltyModels ? { noPenaltyModels } : {}),` | +| `src/adapters/openai-chat.ts:142` | `if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format;` | +| `src/adapters/openai-chat.ts:1580` | 번역 경로의 `if (!provider.noStructuredOutputModels?.includes(parsed.modelId)) { … }` | + +추가로 발견한 선례: `registry.ts:315`의 `directReasoningEffortModels`가 `registry-only and is never persisted as user config`라고 명시한다. 즉 레지스트리 전용 필드는 이 저장소에 이미 있는 범주다. 새 필드도 같은 범주로 두되, 사용자가 config에 직접 적어도 검증을 통과하도록 zod 스키마에는 넣는다. + ## 선례 `noPenaltyModels`가 같은 배선을 이미 완결해 두었다: 선언 `src/providers/registry.ts:323` → 병합 `src/router.ts:351` → emit `src/router.ts:475` → 소비 `src/adapters/openai-chat.ts:134`. 새 필드는 이 네 지점을 그대로 따른다. 아래 "배선 경로" 표가 확정 파일 지도다. From d938be4db671aa43cbcc309f3165f2aaaeeeb859 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:13:01 +0900 Subject: [PATCH 06/11] docs(devlog): fold the wp2 audit into the structured-output slice --- .../010_phase1_preset_structured_output.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md b/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md index d45d36e666..c688f9b51f 100644 --- a/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md +++ b/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md @@ -78,9 +78,14 @@ noJsonSchemaModels: [...OPENCODE_FREE_DEEPSEEK_MODELS], 4. 시드된 모델 + `json_object` 요청은 그대로 `json_object`다 — 낮추기가 json_object를 건드리지 않는다는 반대 증거. 5. 같은 모델이 `noStructuredOutputModels`에도 있으면 `response_format`이 아예 없다 — 킬스위치 우선순위. -### 분기 순서 (감사 지적 반영) - -네이티브 패스스루(`src/adapters/openai-chat.ts:142`)는 킬스위치가 `delete body.response_format`을 먼저 실행한다. 낮추기 분기는 반드시 **else-if**로 붙여, 지워진 필드를 다시 넣지 않게 한다. 번역 경로(`1580`)는 킬스위치가 블록 전체를 건너뛰므로 그 안에서 json_schema만 json_object로 바꾸면 된다. 수용 기준 5가 이 순서를 직접 관측한다. +### 분기 순서와 누락 지점 (wp2 감사 반영) + +- 패스스루(`142`): 킬스위치가 `delete body.response_format`을 먼저 실행하므로, 그 뒤의 낮추기는 `body.response_format?.type === "json_schema"`를 조건으로 두면 자동으로 발화하지 않는다. 감사 지적대로 `else if`는 맞지만 실질적으로 무의미하므로, 조건에 타입 검사를 넣고 킬스위치 우선임을 주석으로 남긴다. `.includes` 정확 일치는 유지한다. +- 번역 경로(`1580`): 킬스위치 게이트가 json_object/json_schema 두 분기를 함께 감싸므로, 낮추기는 json_schema 분기 **안**에 둔다. +- **config 검증은 선택이 아니다**: provider 스키마는 `.passthrough()`다. zod 검증을 빼면 사용자가 배열 대신 문자열을 넣어도 통과하고, `.includes()`가 부분 일치로 오작동한다. +- **관리 API 왕복 누락**(감사가 새로 찾음): `src/server/auth-cors.ts`의 검증기(`711` 패턴)와 `PROVIDER_CONFIG_FIELD_POLICY`(`868` 부근), `src/server/management/provider-routes.ts`의 PATCH 처리(`563` 패턴)와 DTO(`732` 부근)에 필드를 넣지 않으면, 대시보드 raw 에디터 왕복에서 값이 거부되거나 사라진다. `noStructuredOutputModels`와 동일하게 네 지점을 모두 추가한다. +- **처분 보류**: 스키마 계약이 조용히 free-form JSON으로 강등되는 것을 debug 로그로 남기라는 권고는 이번 범위에서 채택하지 않는다. 요청 본문 로깅 금지 규칙과 인접해 별도 판단이 필요하고, 필드 계약 주석과 PR 본문에 명시하는 것으로 대체한다. 후속 후보로 남긴다. +- **건드리지 말 것**: parity 테스트가 opencode-go `noVisionModels`를 리터럴 배열로 고정한다. 이번 슬라이스는 그 필드를 수정하지 않는다. ## 검증 From d0e4e5218eb491ab02241394d29e71ed6bf24313 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:17:58 +0900 Subject: [PATCH 07/11] provider: let a preset say a route rejects json_schema, not all structured output The Zen gateway answers a json_schema response_format with 400 on its DeepSeek routes (#1338, #1415), so operators have been disabling structured output by hand through the #1424 opt-out. That knob claims the whole response_format field is unavailable, which no reported error supports, and it costs json_object callers their JSON. noJsonSchemaModels states only the reported fact and downgrades the request to json_object instead; the existing kill switch still wins when both list a model. --- src/adapters/openai-chat.ts | 32 ++++++++--- src/config.ts | 14 +++++ src/providers/registry.ts | 23 ++++++++ src/router.ts | 2 + src/server/auth-cors.ts | 6 ++ src/server/management/provider-routes.ts | 14 +++++ src/types/provider.ts | 13 +++++ .../openai/openai-chat-hardening.test.ts | 49 ++++++++++++++++ tests/providers/opencode-go-deepseek.test.ts | 57 +++++++++++++++++++ .../provider-registry-parity.test.ts | 12 ++++ 10 files changed, 213 insertions(+), 9 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 4ba654487c..cd3481bc7e 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -140,6 +140,16 @@ export function buildOpenAIChatPassthroughRequest( // ingress enforces exactly that. A prefix match here would strip response_format from // `:` siblings the operator never opted out, silently returning prose. if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format; + // Narrower neighbour: the model takes `json_object` but rejects `json_schema`. Downgrade + // rather than drop, so a caller that asked for JSON still gets JSON. The type check also + // makes the kill switch above win without an else — after its `delete` there is no type + // left to match. + const passthroughFormat = body.response_format; + if (provider.noJsonSchemaModels?.includes(modelId) + && typeof passthroughFormat === "object" && passthroughFormat !== null + && (passthroughFormat as { type?: unknown }).type === "json_schema") { + body.response_format = { type: "json_object" }; + } // Run the same complete Fast policy as the translated Chat path, including explicit // fastMode and foreign-tier handling. On inherited canonical Fast, the passthrough still @@ -1582,15 +1592,19 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (textFormat?.type === "json_object") { body.response_format = { type: "json_object" }; } else if (textFormat?.type === "json_schema") { - body.response_format = { - type: "json_schema", - json_schema: { - name: textFormat.name ?? "response", - ...(textFormat.description !== undefined ? { description: textFormat.description } : {}), - ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}), - ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}), - }, - }; + // Same downgrade as the passthrough path: the schema is dropped because the + // upstream rejects it, but the JSON-mode request itself survives. + body.response_format = provider.noJsonSchemaModels?.includes(parsed.modelId) + ? { type: "json_object" } + : { + type: "json_schema", + json_schema: { + name: textFormat.name ?? "response", + ...(textFormat.description !== undefined ? { description: textFormat.description } : {}), + ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}), + ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}), + }, + }; } } diff --git a/src/config.ts b/src/config.ts index 66162c6a6a..a5a75565ea 100644 --- a/src/config.ts +++ b/src/config.ts @@ -622,6 +622,9 @@ const providerConfigSchema = z.object({ noStructuredOutputModels: z.array(z.string().min(1)) .transform(normalizeNonBlankStringArray) .optional(), + noJsonSchemaModels: z.array(z.string().min(1)) + .transform(normalizeNonBlankStringArray) + .optional(), retainModels: z.array(z.string().min(1)) .transform(normalizeNonBlankStringArray) .optional(), @@ -1612,6 +1615,17 @@ const configSchema = z.object({ message: structuredOutputOptOutError, }); } + const jsonSchemaOptOutError = nonBlankStringArrayConfigError( + (provider as { noJsonSchemaModels?: unknown }).noJsonSchemaModels, + "noJsonSchemaModels", + ); + if (jsonSchemaOptOutError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "noJsonSchemaModels"], + message: jsonSchemaOptOutError, + }); + } const retainModelsError = nonBlankStringArrayConfigError( (provider as { retainModels?: unknown }).retainModels, "retainModels", diff --git a/src/providers/registry.ts b/src/providers/registry.ts index c4ea553d2d..1a2f328927 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -321,6 +321,12 @@ export interface ProviderRegistryEntry { noTemperatureModels?: string[]; noTopPModels?: string[]; noPenaltyModels?: string[]; + /** + * Registry-only seed for `OcxProviderConfig.noJsonSchemaModels`. Merged into the + * resolved provider at route time rather than persisted as user config, the same way + * `directReasoningEffortModels` above is registry-owned. + */ + noJsonSchemaModels?: string[]; /** Opt this provider into parallel tool calls (see OcxProviderConfig.parallelToolCalls). */ parallelToolCalls?: boolean; /** Opt this provider into forwarding prompt_cache_key (OpenAI-specific; strict backends reject it). */ @@ -1788,6 +1794,16 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], // Issue #78: DeepSeek V4 thinking mode requires reasoning_content replay on tool-call turns. preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...DEEPSEEK_THINKING_MODELS], + /* + * Issues #1338 / #1415: this gateway answers a `response_format` of type + * `json_schema` with HTTP 400 `This response_format type is unavailable now` + * (quoted from the upstream body as `Error from provider (Console Go)`), which + * breaks every Codex auto-review turn on a DeepSeek route. #1424 shipped the + * operator-side opt-out; operators have been applying it by hand ever since. + * The reported rejection is type-specific, so this narrower list downgrades the + * request to `json_object` instead of claiming the whole field is unavailable. + */ + noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS], }, { id: "neuralwatt", @@ -3036,6 +3052,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], }, noVisionModels: [...OPENCODE_ZEN_TEXT_ONLY_MODELS, ...DEEPSEEK_THINKING_MODELS], + // Same DeepSeek routes as the Go preset above, behind the same vendor, so they carry + // the same json_schema rejection (#1338 / #1415). + noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], }, { id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" }, { @@ -3076,6 +3095,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Same Zen roster behind the same base URL, so it carries the same measured // text-only list rather than only its DeepSeek member (#1043). noVisionModels: OPENCODE_ZEN_TEXT_ONLY_MODELS, + // Same reasoning: the free tier is the same Zen roster, so its DeepSeek members get + // the keyed tier's json_schema treatment and its reasoning contract rather than a + // narrower table that silently falls behind whenever the keyed one is updated. + noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], }, { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" }, // Xiaomi's public OpenAI-compatible endpoint is a distinct transport from both the Anthropic diff --git a/src/router.ts b/src/router.ts index 8528370efb..c70a438fbe 100644 --- a/src/router.ts +++ b/src/router.ts @@ -349,6 +349,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider const noTemperatureModels = mergeStringArray(registryEntry.noTemperatureModels, provider.noTemperatureModels); const noTopPModels = mergeStringArray(registryEntry.noTopPModels, provider.noTopPModels); const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels); + const noJsonSchemaModels = mergeStringArray(registryEntry.noJsonSchemaModels, provider.noJsonSchemaModels); const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels); const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels); const requiresReasoningPlaceholderModels = mergeStringArray(registryEntry.requiresReasoningPlaceholderModels, provider.requiresReasoningPlaceholderModels); @@ -473,6 +474,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(noTemperatureModels ? { noTemperatureModels } : {}), ...(noTopPModels ? { noTopPModels } : {}), ...(noPenaltyModels ? { noPenaltyModels } : {}), + ...(noJsonSchemaModels ? { noJsonSchemaModels } : {}), ...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}), ...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}), ...(requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels } : {}), diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 8b845c8460..cad0acbd33 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -712,6 +712,11 @@ export function providerManagementConfigError(name: unknown, provider: unknown): "noStructuredOutputModels", ); if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`; + const jsonSchemaOptOutError = nonBlankStringArrayConfigError( + raw.noJsonSchemaModels, + "noJsonSchemaModels", + ); + if (jsonSchemaOptOutError) return `provider ${name} ${jsonSchemaOptOutError}`; const retainModelsError = nonBlankStringArrayConfigError(raw.retainModels, "retainModels"); if (retainModelsError) return `provider ${name} ${retainModelsError}`; const toolReasoningOptOutError = nonBlankStringArrayConfigError( @@ -866,6 +871,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { noTopPModels: "editor", noPenaltyModels: "editor", noStructuredOutputModels: "editor", + noJsonSchemaModels: "editor", omitReasoningEffortWithToolsModels: "editor", parallelToolCalls: "editor", pinParallelToolCallsFalse: "editor", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 1439d7899c..d2cb40291a 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -573,6 +573,19 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "noJsonSchemaModels")) { + const value = rawBody.noJsonSchemaModels; + if (value === null) { + delete next.noJsonSchemaModels; + } else { + const error = nonBlankStringArrayConfigError(value, "noJsonSchemaModels"); + if (error) return { error }; + const models = normalizeNonBlankStringArray(value as string[]); + if (models.length > 0) next.noJsonSchemaModels = models; + else delete next.noJsonSchemaModels; + } + touched = true; + } if (Object.hasOwn(rawBody, "retainModels")) { const value = rawBody.retainModels; if (value === null) { @@ -730,6 +743,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { }); }); + // Narrower neighbour of the kill switch: the upstream rejects the json_schema TYPE, so the + // request is downgraded to json_object rather than stripped. Both wires must agree. + describe("json_schema downgrade for noJsonSchemaModels", () => { + const schemaFormat = { + type: "json_schema", + json_schema: { name: "answer", schema: { type: "object" }, strict: true }, + }; + const passthrough = ( + modelId: string, + providerOverrides: Partial, + responseFormat: unknown = schemaFormat, + ) => JSON.parse(buildOpenAIChatPassthroughRequest( + provider(providerOverrides), + { messages: [{ role: "user", content: "hi" }], response_format: responseFormat }, + modelId, + false, + ).body as string) as Record; + + test("downgrades json_schema to json_object on the native wire", () => { + expect(passthrough("test-model", { noJsonSchemaModels: ["test-model"] }).response_format) + .toEqual({ type: "json_object" }); + }); + + test("leaves a json_object request untouched", () => { + expect(passthrough("test-model", { noJsonSchemaModels: ["test-model"] }, { type: "json_object" }).response_format) + .toEqual({ type: "json_object" }); + }); + + test("keeps the schema for a :tag sibling the operator never listed", () => { + expect(passthrough("test-model:structured", { noJsonSchemaModels: ["test-model"] }).response_format) + .toEqual(schemaFormat); + }); + + test("the full kill switch still wins when a model is on both lists", () => { + expect(passthrough("test-model", { + noJsonSchemaModels: ["test-model"], + noStructuredOutputModels: ["test-model"], + }).response_format).toBeUndefined(); + }); + + test("the translated wire downgrades the same request", () => { + const built = createOpenAIChatAdapter(provider({ noJsonSchemaModels: ["test-model"] })).buildRequest({ + ...parsed(), + options: { textFormat: { type: "json_schema", name: "answer", schema: { type: "object" }, strict: true } }, + }); + expect(bodyOf(built).response_format).toEqual({ type: "json_object" }); + }); + }); + // Tool-call deltas are BUFFERED until a terminal signal, so this adapter can consume upstream // frames for a long time while yielding nothing downstream. The Responses bridge arms its // stall watchdog on ADAPTER activity, not socket activity, so a model streaming a large diff --git a/tests/providers/opencode-go-deepseek.test.ts b/tests/providers/opencode-go-deepseek.test.ts index edf42a4815..890ec0a92d 100644 --- a/tests/providers/opencode-go-deepseek.test.ts +++ b/tests/providers/opencode-go-deepseek.test.ts @@ -130,3 +130,60 @@ describe("opencode-go DeepSeek V4 thinking mode", () => { expect(body.messages[1]).toHaveProperty("tool_calls"); }); }); + +/* + * Issues #1338 / #1415: the Zen Go upstream answers a `json_schema` response_format with + * HTTP 400 "This response_format type is unavailable now" on its DeepSeek routes, which + * kills every Codex auto-review turn there. The preset now carries that fact, so the + * request is downgraded to `json_object` instead of the operator having to disable + * structured output by hand. + */ +describe("opencode-go DeepSeek json_schema downgrade", () => { + const buildWith = (modelId: string, extra: Record = {}) => { + const config = configFor(modelId); + Object.assign(config.providers["opencode-go"], extra); + const route = routeModel(config, `opencode-go/${modelId}`); + const req = createOpenAIChatAdapter(route.provider).buildRequest({ + modelId: route.modelId, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, + options: { textFormat: { type: "json_schema", name: "review", schema: { type: "object" }, strict: true } }, + stream: false, + }); + return { + provider: route.provider, + body: JSON.parse(req.body as string) as { response_format?: { type?: string } }, + }; + }; + + test("the preset reaches the routed provider", () => { + expect(buildWith("deepseek-v4-flash").provider.noJsonSchemaModels) + .toEqual(["deepseek-v4-pro", "deepseek-v4-flash"]); + }); + + test("a listed DeepSeek route is downgraded to json_object", () => { + expect(buildWith("deepseek-v4-flash").body.response_format).toEqual({ type: "json_object" }); + expect(buildWith("deepseek-v4-pro").body.response_format).toEqual({ type: "json_object" }); + }); + + test("an unlisted sibling on the same gateway keeps its schema", () => { + expect(buildWith("glm-5.3").body.response_format?.type).toBe("json_schema"); + }); + + test("the operator kill switch still wins over the downgrade", () => { + const { body } = buildWith("deepseek-v4-flash", { noStructuredOutputModels: ["deepseek-v4-flash"] }); + expect(body.response_format).toBeUndefined(); + }); + + test("a json_object request is left alone on a listed route", () => { + const config = configFor("deepseek-v4-flash"); + const route = routeModel(config, "opencode-go/deepseek-v4-flash"); + const req = createOpenAIChatAdapter(route.provider).buildRequest({ + modelId: route.modelId, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, + options: { textFormat: { type: "json_object" } }, + stream: false, + }); + expect((JSON.parse(req.body as string) as { response_format?: unknown }).response_format) + .toEqual({ type: "json_object" }); + }); +}); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 1dcc89ca1f..75231d7772 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -79,6 +79,18 @@ describe("provider registry parity", () => { "qwen3.7-max", ]); expect(KEY_LOGIN_PROVIDERS["opencode-go"].noVisionModels).not.toContain("kimi-k2.7-code"); + // #1338 / #1415: the Zen gateway rejects json_schema on its DeepSeek routes. The three + // presets that share that gateway carry the narrow opt-out as a registry-only seed, so + // an operator no longer has to disable structured output by hand. Registry-only means + // it is asserted here against the raw entry, not the derived key-login map. + const zenDeepseekJsonSchema: Record = { + "opencode-go": ["deepseek-v4-pro", "deepseek-v4-flash"], + "opencode-zen": ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-free"], + "opencode-free": ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-free"], + }; + for (const [id, expected] of Object.entries(zenDeepseekJsonSchema)) { + expect(PROVIDER_REGISTRY.find(entry => entry.id === id)?.noJsonSchemaModels).toEqual(expected); + } expect(KEY_LOGIN_PROVIDERS.mimo.noVisionModels).toEqual(["mimo-v2.5-pro"]); expect(KEY_LOGIN_PROVIDERS.mimo.noVisionModels).not.toContain("mimo-v2.5"); expect(KEY_LOGIN_PROVIDERS["opencode-go"]).toMatchObject({ From 58fe2b07f94e19c9280fb0302d3a98b6314b1ae5 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:51:31 +0900 Subject: [PATCH 08/11] provider: stop arming the Go thinking-budget wire for models it gives no ladder opencode-go carried the shared THINKING_BUDGET_MODELS list, which also names Neuralwatt-only ids, while seeding ladders for only its own four. A live roster serving one of those armed the budget wire path for a model whose picker was empty. Two structural guards now pin the pairing: a gated wire needs a ladder, and a DeepSeek ladder needs reasoning_content replay. --- src/providers/registry.ts | 9 ++++++- .../provider-registry-parity.test.ts | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 1a2f328927..71a52420da 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1776,7 +1776,14 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, true])), }, thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS, - thinkingBudgetModels: THINKING_BUDGET_MODELS, + /* + * The Go-specific list, not the shared one. The shared `THINKING_BUDGET_MODELS` also + * carries Neuralwatt-only ids (`qwen3.5-397b`, `qwen3.6-35b`) that this preset never + * gives a ladder to, so a live roster serving one of them armed the thinking-budget + * wire path with nothing to advertise: the catalog showed no effort control while the + * adapter still translated effort into `thinking_budget`. + */ + thinkingBudgetModels: OPENCODE_GO_THINKING_BUDGET_MODELS, noReasoningModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], // Text-only Zen Go models (jawcode metadata) — the vision sidecar describes images for // every model listed here (and the catalog advertises image input on their behalf). diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 75231d7772..b476ce1036 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -91,6 +91,30 @@ describe("provider registry parity", () => { for (const [id, expected] of Object.entries(zenDeepseekJsonSchema)) { expect(PROVIDER_REGISTRY.find(entry => entry.id === id)?.noJsonSchemaModels).toEqual(expected); } + // A model can only be gated onto the thinking-budget or thinking-toggle wire if the same + // preset also gives it an effort ladder — otherwise the adapter translates effort into a + // wire field for a model whose picker is empty. opencode-go carried the shared budget list + // while seeding only its own four ladders, so a live roster serving qwen3.5-397b armed the + // budget path with nothing to advertise. + for (const id of ["opencode-go", "opencode-zen", "opencode-free"]) { + const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === id); + const ladders = Object.keys(entry?.modelReasoningEfforts ?? {}); + const gated = [...entry?.thinkingBudgetModels ?? [], ...entry?.thinkingToggleModels ?? []]; + expect({ id, ungated: gated.filter(model => !ladders.includes(model)) }) + .toEqual({ id, ungated: [] }); + } + // Issue #78 / #950: a DeepSeek route that advertises a thinking ladder must also replay + // reasoning_content on tool-call continuations, or the gateway answers 400 on the second + // turn. The three Zen presets seed those two tables by hand, so this pins the pairing + // instead of trusting that whoever adds the next route remembers both. + for (const id of ["opencode-go", "opencode-zen", "opencode-free"]) { + const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === id); + const replayed = entry?.preserveReasoningContentModels ?? []; + const thinkingDeepseek = Object.keys(entry?.modelReasoningEfforts ?? {}) + .filter(model => model.startsWith("deepseek-")); + expect({ id, unreplayed: thinkingDeepseek.filter(model => !replayed.includes(model)) }) + .toEqual({ id, unreplayed: [] }); + } expect(KEY_LOGIN_PROVIDERS.mimo.noVisionModels).toEqual(["mimo-v2.5-pro"]); expect(KEY_LOGIN_PROVIDERS.mimo.noVisionModels).not.toContain("mimo-v2.5"); expect(KEY_LOGIN_PROVIDERS["opencode-go"]).toMatchObject({ From 4ed244bcabd2db2b085ab28f7f24553f4bbe83b6 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:52:52 +0900 Subject: [PATCH 09/11] docs(providers): document the json_schema downgrade the Zen presets now ship --- .../src/content/docs/fr/reference/configuration/providers.md | 3 ++- .../src/content/docs/ja/reference/configuration/providers.md | 1 + .../src/content/docs/ko/reference/configuration/providers.md | 1 + .../src/content/docs/reference/configuration/providers.md | 1 + .../src/content/docs/ru/reference/configuration/providers.md | 1 + .../src/content/docs/tr/reference/configuration/providers.md | 1 + .../content/docs/zh-cn/reference/configuration/providers.md | 1 + .../content/docs/zh-tw/reference/configuration/providers.md | 1 + 8 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index c7879dfa9c..92c44e82fa 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -128,7 +128,8 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `noTemperatureModels?` | `string[]` | Modèles qui rejettent `temperature` spécifié par l’appelant. | | `noTopPModels?` | `string[]` | Modèles qui rejettent `top_p` spécifié par l’appelant. | | `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. | -| `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | +| `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | +| `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d'être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l'emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go`, `opencode zen` et `opencode free` l'embarquent pour leurs routes DeepSeek. | | `parallelToolCalls?` | `boolean` | Contrôler les appels d’outils parallèles. Pour `openai-chat`, ils sont activés par défaut ; `false` envoie explicitement `parallel_tool_calls: false`. Les autres adaptateurs ne les annoncent que lorsque la valeur vaut explicitement `true`. | | `terminalContinuationGuard?` | `boolean` | Active, pour un fournisseur `openai-chat`, une relance interne bornée lorsqu’un tour exploitable annonce une action puis s’arrête proprement sans appel d’outil. La valeur par défaut est `false`, et une valeur explicite `false` équivaut à l’absence du champ. Les tentatives de combinaison et les tours de compactage routés sont exclus ; les autres adaptateurs ignorent cette option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 608e66deb2..b8ee061b4a 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -122,6 +122,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noTopPModels?` | `string[]` |発信者指定の`top_p`を拒否するモデル。 | | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 | +| `noJsonSchemaModels?` | `string[]` | `openai-chat` エンドポイントが `json_schema` 形式は拒否しつつ `json_object` は受け入れる正確なモデル ID。この要求はフィールドを削除せず `json_object` に降格して送るため、JSON を求めた呼び出し側は散文ではなく JSON を受け取れます。両方の一覧に載るモデルでは `noStructuredOutputModels` が優先します。`opencode go` / `opencode zen` / `opencode free` プリセットが DeepSeek 経路に既定で載せます。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 342441eca1..0b5f8bf36a 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -122,6 +122,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noTopPModels?` | `string[]` | 호출자가 지정한 `top_p`를 거부하는 모델입니다. | | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. | +| `noJsonSchemaModels?` | `string[]` | `openai-chat` 엔드포인트가 `json_schema` 형식은 거부하지만 `json_object`는 받는 정확한 모델 ID입니다. 이런 요청은 필드를 지우는 대신 `json_object`로 낮춰 보내므로, JSON을 요청한 클라이언트가 산문 대신 JSON을 받습니다. 한 모델이 두 목록에 모두 있으면 `noStructuredOutputModels`가 우선합니다. `opencode go`, `opencode zen`, `opencode free` 프리셋이 DeepSeek 경로에 기본으로 싣습니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e2769ab807..9e5c289b8c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -180,6 +180,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noTopPModels?` | `string[]` | Models that reject caller-specified `top_p`. | | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | +| `noJsonSchemaModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects a `json_schema` `response_format` but still accepts `json_object`. Such a request is downgraded to `json_object` instead of being dropped, so a caller asking for JSON still gets JSON. `noStructuredOutputModels` wins when a model is on both lists. The `opencode go`, `opencode zen`, and `opencode free` presets ship this for their DeepSeek routes. | | `omitReasoningEffortWithToolsModels?` | `string[]` | Exact `openai-chat` model IDs that accept a reasoning-effort field on an ordinary turn but reject it once function tools are present. The model keeps its advertised effort ladder; OpenCodex omits the wire field for tool-bearing requests only and the upstream default applies. Narrower than `noReasoningModels`, which strips reasoning from every request and costs the model its picker entirely. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 78bca4d40d..37b00b3150 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -135,6 +135,7 @@ cross-route credential fallback не существует. Строки API GPT- | `noTopPModels?` | `string[]` | Модели, отвергающие переданный вызывающей стороной `top_p`. | | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | | `noStructuredOutputModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format`. Поле опускается только при точном совпадении запрошенной модели; для остальных моделей `openai-chat` преобразование structured output остаётся включённым. | +| `noJsonSchemaModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format` типа `json_schema`, но принимает `json_object`. Такой запрос понижается до `json_object`, а не отбрасывается, поэтому вызывающая сторона всё равно получает JSON. Если модель есть в обоих списках, побеждает `noStructuredOutputModels`. Пресеты `opencode go`, `opencode zen` и `opencode free` включают это для своих маршрутов DeepSeek. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 36c183bf11..f0739bbc63 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -136,6 +136,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `noTopPModels?` | `string[]` | Arayan tarafından belirtilen `top_p` değerini reddeden modeller. | | `noPenaltyModels?` | `string[]` | Varlık/frekans cezalarını reddeden modeller. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` uç noktası `response_format`'ı reddeden tam model kimlikleri. Yalnızca tam bir istenen model eşleşmesi alanı atlar; yapılandırılmış çıktı çevirisi diğer her `openai-chat` modeli için etkin kalır. | +| `noJsonSchemaModels?` | `string[]` | `openai-chat` uç noktası `json_schema` biçimini reddeden ama `json_object` kabul eden tam model kimlikleri. Böyle bir istek atılmak yerine `json_object` seviyesine düşürülür, böylece JSON isteyen çağıran yine JSON alır. Bir model her iki listede de varsa `noStructuredOutputModels` kazanır. `opencode go`, `opencode zen` ve `opencode free` hazır ayarları bunu DeepSeek rotaları için getirir. | | `parallelToolCalls?` | `boolean` | Paralel araç çağrılarını açıp kapatın. OpenAI Chat varsayılan olarak açıktır; sohbet harici adaptörler yalnızca açık `true` durumunda bildirir. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Tam yer tutucu kimlikleri, eksik terminal kimlikleri ve (`repairInvalidIds` ile) kurallı `msg_`/`rs_` öneki eksik olan mesaj/akıl yürütme kimlikleri için varsayılan olarak devre dışı bırakılmış aşağı akış SSE onarımı. Fonksiyon çağrısı kimlikleri asla yeniden yazılmaz. Yerleşik DeepSeek son ikisini varsayılan olarak etkinleştirir. | | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 99fba1fbb4..de314d8d8e 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -122,6 +122,7 @@ selector,而不是分配一个新名称。 | `noTopPModels?` | `string[]` | 会拒绝调用方指定 `top_p` 的模型。 | | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的精确模型 ID。仅当请求模型与条目完全匹配时才省略该字段;其他 `openai-chat` 模型仍启用 structured-output 转换。 | +| `noJsonSchemaModels?` | `string[]` | `openai-chat` 端点拒绝 `json_schema` 形式但仍接受 `json_object` 的精确模型 ID。这类请求会降级为 `json_object` 而不是被丢弃,因此请求 JSON 的调用方仍能拿到 JSON。同一模型同时出现在两个列表时,以 `noStructuredOutputModels` 为准。`opencode go`、`opencode zen`、`opencode free` 预设已为其 DeepSeek 路由内置该项。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 74ee860ff1..292b8e63dc 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -96,6 +96,7 @@ ocx models provider openrouter on | `noTopPModels?` | `string[]` | 拒絕呼叫者指定 `top_p` 的模型。 | | `noPenaltyModels?` | `string[]` | 拒絕 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `response_format` 的精確模型 ID。僅精確符合的請求模型會省略該欄位;structured-output 轉譯對其他每個 `openai-chat` 模型保持啟用。 | +| `noJsonSchemaModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `json_schema` 形式但仍接受 `json_object` 的精確模型 ID。這類請求會降級為 `json_object` 而非被丟棄,因此要求 JSON 的呼叫端仍會拿到 JSON。同一模型同時列在兩份清單時,以 `noStructuredOutputModels` 為準。`opencode go`、`opencode zen`、`opencode free` 預設已為其 DeepSeek 路由內建。 | | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 供應商。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 | From ecb6a14a45ebeef7c326ee5f7c235c4bb6ce190d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:57:30 +0900 Subject: [PATCH 10/11] docs(providers): restore the French typographic spacing the new row displaced --- .../src/content/docs/fr/reference/configuration/providers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 92c44e82fa..563286c2bb 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -128,8 +128,8 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `noTemperatureModels?` | `string[]` | Modèles qui rejettent `temperature` spécifié par l’appelant. | | `noTopPModels?` | `string[]` | Modèles qui rejettent `top_p` spécifié par l’appelant. | | `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. | -| `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | -| `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d'être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l'emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go`, `opencode zen` et `opencode free` l'embarquent pour leurs routes DeepSeek. | +| `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | +| `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d’être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l’emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go`, `opencode zen` et `opencode free` l’embarquent pour leurs routes DeepSeek. | | `parallelToolCalls?` | `boolean` | Contrôler les appels d’outils parallèles. Pour `openai-chat`, ils sont activés par défaut ; `false` envoie explicitement `parallel_tool_calls: false`. Les autres adaptateurs ne les annoncent que lorsque la valeur vaut explicitement `true`. | | `terminalContinuationGuard?` | `boolean` | Active, pour un fournisseur `openai-chat`, une relance interne bornée lorsqu’un tour exploitable annonce une action puis s’arrête proprement sans appel d’outil. La valeur par défaut est `false`, et une valeur explicite `false` équivaut à l’absence du champ. Les tentatives de combinaison et les tours de compactage routés sont exclus ; les autres adaptateurs ignorent cette option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. | From 340bc24861134f602f43a7e5ae5ce5400bcf354f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:59:05 +0900 Subject: [PATCH 11/11] docs(devlog): record the stabilization PR outcome and what stayed open --- .../031_wp4_outcome.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/031_wp4_outcome.md diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/031_wp4_outcome.md b/devlog/_plan/260911_opencode_go_free_stabilization/031_wp4_outcome.md new file mode 100644 index 0000000000..134c115214 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/031_wp4_outcome.md @@ -0,0 +1,31 @@ +# 031 — wp4 결과 + +PR: (base `dev`, head `codex/260911-opencode-go-free-stabilization`) + +## 최종 변경 범위 + +| 커밋 | 내용 | +| --- | --- | +| `d0e4e5218` | `noJsonSchemaModels` 계약: 타입, 레지스트리 필드와 세 프리셋 시드, 라우터 병합/emit, config zod + superRefine, auth-cors 검증기 + 필드 정책, provider-routes PATCH + DTO, 어댑터 두 와이어의 낮추기, 회귀 테스트 10건 | +| `58fe2b07f` | opencode-go `thinkingBudgetModels` 를 Go 전용 목록으로 좁힘 + 구조 가드 2종 | +| `4ed244bca` | docs-site en + 7개 로케일 | +| `ecb6a14a4` | 프랑스어 문서의 기존 행 조판 원복 (감사 지적) | + +## 검증 + +포커스 스위트만 돌렸다. 사용자가 전체 스위트를 명시적으로 금지했고, 푸시는 `--no-verify` 로 지시했다. + +- 어댑터/프리셋 155 pass / 0 fail +- parity + 카탈로그 효율 97 pass / 0 fail +- config/management 637 pass / 0 fail +- `bun run typecheck` exit 0, `bun run privacy:scan` 통과 +- red-green: 세 가드 모두 수정 전 실패를 직접 확인 + +전체 스위트는 CI 에 맡겼다. 이전에 로컬에서 한 번 시도했을 때 879초가 걸렸고 exit 1 로 끝났는데, 출력이 잘려 어떤 파일이 실패했는지는 확인하지 못했다. 이 브랜치가 원인인지도 확인되지 않았다 — 재확인은 CI 결과로 대체한다. + +## 남긴 것 + +- `deepseek-v4.1-flash` 는 시드하지 않았다. 게이트웨이가 서빙한다는 근거가 트리에 없다. +- `json_object` 수용 여부는 미검증이다. DeepSeek 계열이 프롬프트에 `json` 문자열을 요구하는 구현이면 낮추기가 400 대신 빈 응답이 될 수 있다. PR 본문에 후속 조건으로 명시했다. +- 구조 가드는 세 프리셋 id 를 루프로 돈다. 네 번째 Zen 계열 프리셋이 생기면 목록에 추가해야 한다. +- 스키마 강등을 관측 가능한 신호로 남기는 건(요청 본문 로깅 금지와 인접) 후속 판단으로 미뤘다.