diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/000_plan.md b/devlog/_plan/260912_zcode_protocol_and_catalog/000_plan.md new file mode 100644 index 0000000000..18f2236231 --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/000_plan.md @@ -0,0 +1,45 @@ +# 000 — ZCode 프로토콜 정렬 + GLM-5.3 모달리티 정정 + +## 목표 + +ZCode 연동에서 확인된 세 가지 결함을 1차 근거와 함께 고친다. + +1. ocx가 ZCode에 내보내는 프로바이더 블록이 `kind: "openai-compatible"`(Chat Completions)로 붙는다. + ocx는 Responses-우선 프록시라 Chat 인바운드는 Responses로 번역된 뒤 다시 Chat으로 역번역된다. + ZCode는 `kind: "openai"`로 `{baseURL}/responses`를 직접 호출할 수 있으므로 번역을 0회로 줄인다. +2. `zai`와 `zhipu-bigmodel-coding` 행이 `glm-5.3-flash`의 입력 모달리티를 음수 선언 + (`noVisionModels` 제외)으로만 다뤄서, 클라이언트 export 피커에 네이티브 VLM이 text-only로 나간다. +3. `zai` 행이 Chat Completions 한 갈래에 고정되어 있다. Z.AI 는 같은 키로 Responses 도 서빙하고 + (`https://api.z.ai/api/v1`) 실사용에서 Chat 경로가 불안정하다. Responses 를 기본으로 돌리고 + Chat 은 opt-in 으로 남긴다. + +## 제약 + +- 로컬 상태(`~/.opencodex`, `~/.zcode`, `zcode-ocx-sidecar`)는 건드리지 않는다. 사용자 지시. +- `zai` 행은 제자리에서 Responses 로 전환한다. 별도 행을 추가하지 않는다 — 사용자 결정 + ("다 통합하고 chat optin 으로, 기존 사용자도 response 전환"). 초안에 있던 로스터 손실 우려는 + 실측으로 반증됐다: glm-5.2 / glm-5.1 / glm-5 / glm-4.6 / glm-5-turbo 전부 Responses 에서 200 이다 + (030 라이브 표). 030 이 wp4 의 SSOT 다. +- 프로토콜 전환은 조용히 일어나므로 릴리스 노트에 적는다. Chat 전용 키를 가진 사용자는 + `modelAdapters` 로 모델마다 `openai-chat` 을 지정해야 한다. 마이그레이션 코드는 넣지 않는다. +- wp2 와 wp3 는 서로 독립이다. wp4 는 wp3 가 넣는 `ZAI_GLM_5X_INPUT_MODALITIES` 상수에 의존하므로 + wp3 가 `dev` 에 들어간 뒤에 올린다. 각 수정은 자기 이슈를 닫는 PR 로 가고 베이스는 `dev` 다. + +## 작업 단계 + +| work-phase | 내용 | 이슈 | 문서 | +|---|---|---|---| +| wp1 | 조사 + 로드맵 + 이슈 3건 등록 (docs-only) | — | 000-003 | +| wp2 | ZCode export를 `kind: "openai"`(Responses)로 | [#4295](https://github.com/lidge-jun/opencodex/issues/4295) | 010 | +| wp3 | `glm-5.3-flash` 양수 모달리티 선언 | [#4296](https://github.com/lidge-jun/opencodex/issues/4296) | 020 | +| wp4 | `zai` 를 Responses 기본으로 전환 + `chatCompletionsPath` 로 Chat opt-in | [#4297](https://github.com/lidge-jun/opencodex/issues/4297) | 030 | + +wp2 는 독립이다. wp4 는 wp3 뒤에 온다(위 제약). + +## 검증 + +- `bun run typecheck` +- `bun test tests/providers/zcode-client.test.ts tests/config/client-config-export.test.ts` +- `bun test tests/providers/provider-registry-parity.test.ts` +- `bun run structure:check` (structure/clients/integrations.md 소유 영역 변경 시) +- PR-ready 게이트로 `bun run test` diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/001_research_zai_model_facts.md b/devlog/_plan/260912_zcode_protocol_and_catalog/001_research_zai_model_facts.md new file mode 100644 index 0000000000..359a102743 --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/001_research_zai_model_facts.md @@ -0,0 +1,61 @@ +# 001 — Z.AI GLM-5.3 계열 1차 근거 (Aside 세션 조사) + +수집 경로: `aside exec --permission full-access` (CLI 1.26.902, 세션 `cye9q0tV093bZeFJ`), 2026-09-12. +아래 인용은 Aside 에이전트가 실제로 연 공식 문서 페이지에서 그대로 가져온 문장이다. + +## 입력 모달리티 + +| 모델 | 입력 모달리티 | 컨텍스트 | 최대 출력 | reasoning 사다리 | 출처 | +|---|---|---|---|---|---| +| GLM-5.3 | text only | 1M | 128K | low / high / max (비활성화 불가) | https://docs.z.ai/guides/llm/glm-5.3 | +| GLM-5.3-Flash | video / image / text / file | 1M | 128K | low / high / max (비활성화 불가) | https://docs.z.ai/guides/vlm/glm-5.3-flash | + +verbatim: + +> "GLM-5.3 currently supports text-only inputs, with a 1M-token context window and a maximum output length of 128K tokens." +> — https://docs.z.ai/guides/llm/glm-5.3 + +> "GLM-5.3 目前仅支持处理文本模态信息,支持 1M 上下文窗口,最大输出 Tokens 为 128K。" +> — https://docs.bigmodel.cn/cn/guide/models/text/glm-5.3 + +> "GLM-5.3-Flash is the first native multimodal model in the GLM-5 series" +> "Input Modality: Video / Image / Text / File" +> — https://docs.z.ai/guides/vlm/glm-5.3-flash + +> "GLM-5.3 is a text-only model, so uncheck Support Images; GLM-5.3-FLASH is a multimodal model, so Support Images can be checked" +> — https://docs.z.ai/devpack/latest-model + +이미지 입력 전달 방식: + +> "Image Parameters: Add a content block with type: image_url to messages[].content[], and pass the image URL (recommended) or a Base64 Data URL through image_url.url." +> — https://docs.z.ai/guides/vlm/glm-5.3-flash + +## reasoning effort + +> "reasoning_effort: Controls the degree of reasoning within the thought chain... Available values: max (default and recommended, deep inference), high (enhanced inference), low (mild inference, only supported by GLM-5.3 and GLM-5.3-FLASH)" +> "For GLM-5.3 and GLM-5.3-FLASH, only max, high and low are supported. Any other input will result in an error." +> — https://docs.z.ai/guides/capabilities/thinking + +리포지토리의 `ZAI_GLM_53_REASONING_EFFORTS = ["low", "high", "max"]` 와 일치한다. + +## 프로토콜 엔드포인트 (세 갈래) + +> "| Protocol | Base URL | +> | OpenAI Chat Completion Protocol | https://api.z.ai/api/coding/paas/v4 | +> | OpenAI Response Protocol | https://api.z.ai/api/v1 | +> | Anthropic Message Protocol | https://api.z.ai/api/anthropic |" +> — https://docs.z.ai/guides/llm/glm-5.3 + +> "Claude Code / Goose (Anthropic-compatible): https://api.z.ai/api/anthropic +> Codex: https://api.z.ai/api/v1 +> Other OpenAI-compatible tools: https://api.z.ai/api/coding/paas/v4" +> — https://docs.z.ai/devpack/latest-model + +상충하는 단서 하나 (해결 필요): + +> "If you have previously subscribed to a GLM Coding Plan, including an expired subscription, you can currently access the model API only through the OpenAI Chat Completion-compatible protocol." +> — https://docs.z.ai/guides/llm/glm-5.3 + +즉 Coding Plan 구독 이력이 있는 키는 Responses 엔드포인트에서 거절될 수 있다. Responses 전환을 +제안하기 전에 실제 키로 확인이 필요하다. + diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/002_research_zcode_bundle_schema.md b/devlog/_plan/260912_zcode_protocol_and_catalog/002_research_zcode_bundle_schema.md new file mode 100644 index 0000000000..94aa236ca4 --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/002_research_zcode_bundle_schema.md @@ -0,0 +1,87 @@ +# 002 — ZCode 3.11.2 번들 스키마 (app.asar 추출) + +조사 대상: `/Applications/ZCode.app/Contents/Resources/app.asar` (307MB, `rg -a`로 추출). +버전 확인: 실행 로그 `[arms] electron initialized env=prod version=3.11.2`. +리포지토리 주석은 3.7.7 / 3.8.1 기준이라 3.11.2 재확인이 필요했다. + +## kind enum — 정확히 3값 + +```js +bt=t.enum(["anthropic","openai","openai-compatible"]) +mh=t.enum(["anthropic-messages","openai-chat-completions","openai-responses"]) +``` + +`mh`는 내부 `apiFormat` 표현이고 `bt`가 사용자 config의 `kind`다. + +## kind → 요청 경로 + +```js +function fL(e){switch(e){case"anthropic":return"/v1/messages";case"openai":return"/responses";case"openai-compatible":return"/chat/completions"}} +s(fL,"getDefaultModelProviderEndpointPathForKind") + +function IHe(e,t){let n=t.replace(/\/+$/,"");switch(e){case"anthropic":return`${n}/v1/messages`;case"openai_chat":return`${n}/chat/completions`;case"openai_responses":return`${n}/responses`;case"gemini":return n}} +s(IHe,"buildConnectivityRequestUrl") +``` + +baseURL 정규화는 kind별 접미사를 자동으로 떼어낸다: + +```js +function lp(e,o){let r={anthropic:["/v1/messages","/messages"],openai:["/responses"],"openai-compatible":["/chat/completions"]},...} +s(lp,"normalizeModelProviderBaseUrlForKind") +``` + +따라서 `kind:"openai"` + `baseURL:"http://127.0.0.1:10100/v1"` → `POST http://127.0.0.1:10100/v1/responses`. +ocx는 그 경로를 실제로 서빙한다(`src/server/index.ts:1994`). + +## reasoning 필드 + +사용자 config 모델 엔트리는 `variants`/`defaultVariant` 형태다: + +```js +XWe=Q.object({enabled:Q.boolean().optional(),variants:Q.array(Q.string().min(1)).optional(),defaultVariant:Q.string().min(1).optional(),aliases:Q.record(Q.string(),Q.string()).optional()}).passthrough() +``` + +내부 카탈로그는 `levels`/`defaultLevel`이고 양방향 변환기(`openCodeReasoningToModelReasoning`)가 있다. +즉 현재 export가 쓰는 `variants`/`defaultVariant`는 kind를 바꿔도 그대로 유효하다. + +wire 변환은 kind마다 다르다: + +| kind | 요청 필드 | +|---|---| +| openai-compatible | `reasoning_effort` | +| openai | `reasoning: { effort }` | +| anthropic | `output_config: { effort }` (+ 선택 `thinking`) | + +`kind:"openai"`가 보내는 `reasoning.effort`는 ocx `/v1/responses`가 네이티브로 읽는 필드다. + +## 모달리티 + +```js +Zse=Q.enum(["text","image","video","audio","pdf"]) +modalities:Q.object({input:Q.array(Zse).optional(),output:Q.array(Zse).optional()}).optional() +``` + +kind별 제한이 없고, `image`가 있으면 `supportsImages` 케파빌리티로 투영된다: + +```js +w.supportsImages=y.modalities.input.includes("image") +``` + +## options / apiKeyRequired + +`options`는 `Q.record(Q.string(),Q.unknown())` 자유형이고, `apiKeyRequired:false`면 크리덴셜 요구를 +건너뛴다: + +```js +function yA(e){if(e.apiKeyRequired===!1)return!0;...}s(yA,"hasRuntimeCredential") +``` + +## anthropic kind의 추가 요구사항 — 없음 + +```js +function rje(e){return e.kind?e.kind:...}s(rje,"resolveOpenCodeProviderDefaultKind") +``` + +명시된 `kind`가 최우선이고 `defaultKind`/`apiFormat`/`providerMappings`는 전부 optional 폴백이다. +세 kind 어느 쪽으로 내보내도 추가 필드는 필요 없다. + diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/003_research_ocx_internals.md b/devlog/_plan/260912_zcode_protocol_and_catalog/003_research_ocx_internals.md new file mode 100644 index 0000000000..0307a1ea56 --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/003_research_ocx_internals.md @@ -0,0 +1,75 @@ +# 003 — ocx 내부 경로 (서브에이전트 3레인 조사) + +## 인바운드 라우트 + +| 경로 | 등록 | +|---|---| +| `POST /v1/responses` | `src/server/index.ts:1994` | +| `POST /v1/messages` | `src/server/index.ts:2065` | +| `POST /v1/messages/count_tokens` | `src/server/index.ts:2047` | +| `POST /v1/chat/completions` | `src/server/index.ts:2097` | + +Anthropic과 Chat 인바운드는 둘 다 본문을 Responses 형태로 번역해 내부적으로 `handleResponses`로 +리플레이한다(`src/server/claude-messages.ts:2-7`, `src/server/chat-completions.ts:2-4`, +`claude-messages.ts:900`의 `new Request("http://localhost/v1/responses", ...)`). +Responses 인바운드만 번역이 0회다. + +ocx 자신도 ZCode를 Responses 클라이언트로 이미 인지하고 있다: + +> "Generic Responses-API clients (AI-SDK apps such as ZCode) omit `store`" +> — `tests/responses/responses-inbound-store-default.test.ts:2` + +## 업스트림 와이어 결정 + +인바운드가 아니라 라우트된 프로바이더의 `adapter`가 결정한다 +(`src/server/adapter-resolve.ts:13-15`, 하드핀 → 모델별 오버라이드 → 레지스트리 기본 → `provider.adapter`). +`zai`는 `adapter: "openai-chat"`이므로 어떤 인바운드로 들어와도 업스트림은 Chat Completions다. + +## 클라이언트 export 프로토콜 지형 + +| 클라이언트 | 프로토콜 | baseURL 규칙 | +|---|---|---| +| zcode | `kind:"openai-compatible"` → chat | base + `/v1` | +| mcode | `api:"anthropic-messages"` | base에서 `/v1` 제거 | +| dsh | `api:"openai-responses"` | base 그대로(`/v1` 포함) | +| omp / raycast | chat completions | base 그대로 | + +즉 Responses로 붙는 클라이언트(dsh)와 Anthropic으로 붙는 클라이언트(mcode) 선례가 둘 다 있다. + +## ZCode 소유권 정책 + +`src/integrations/ownership-policy.ts:66-84`가 refreshable로 인정하는 경로는 +`models..reasoning`, `models..limit.output`, (권위 컨텍스트 부재 시) `models..limit.context` 뿐이다. +`kind`는 보호 필드다: + +> "Provider identity and connection fields (`name`, `kind`, `enabled`, `source`, and every `options` member), model membership, model names, modalities, and authoritative context limits remain protected. Changing any of them stays `conflict / foreign-edit`." +> — `structure/clients/integrations.md:112-114` + +이건 사용자 편집에 대한 규칙이다. ocx가 생성 계약 자체를 바꾸면 desired contribution이 달라지므로 +기존 기록과 대조해 refresh 경로를 타야 한다. wp2에서 마이그레이션 동작을 반드시 확인한다. + +## 모달리티 전파 경로 + +`registry.ts` → `configuredInputModalities`(`src/codex/catalog/provider-fetch.ts:674-677`) +→ vision sidecar 보정(`provider-fetch.ts:787-798`) → 카탈로그 `input_modalities` +→ `inputModalitiesForClient`(`src/clients/config-export/model-metadata.ts:61-72`) → 각 클라이언트 export. + +`zai` / `zhipu-bigmodel-coding` 행은 `modelInputModalities`를 아예 선언하지 않고 +`noVisionModels`(음수 선언)만 쓴다. 그래서 `glm-5.3-flash`는 sidecar 우회는 면하지만 +양수 선언이 없어 export 피커에서 `["text"]` 플로어로 떨어진다. + +`zhipu-bigmodel-responses` 행은 반대로 양수 선언을 갖는다 +(`modelInputModalities: { "glm-5.3": ["text"], "glm-5.3-flash": ["text", "image"], "glm-5-turbo": ["text"] }`). + +## 상류 권위 카탈로그 (라이브 확인, 2026-09-12) + +`GET https://api.z.ai/api/v1/models` → 200, Codex 형식 카탈로그: + +```json +{"slug": "glm-5.3", "input_modalities": ["text"], "context_window": 1048576, "default_reasoning_level": "max"} +{"slug": "glm-5.3-flash", "input_modalities": ["text", "image"], "context_window": 1048576, "default_reasoning_level": "max"} +``` + +전체 응답은 `evidence/zai-responses-models.json`. `POST https://api.z.ai/api/v1/responses`도 200을 반환했으므로 +"Coding Plan 구독 이력 키는 Chat만 가능"이라는 문서 문장은 이 키에 적용되지 않는다. + diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md b/devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md new file mode 100644 index 0000000000..ba57892c69 --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md @@ -0,0 +1,82 @@ +# 010 — wp2 / ISSUE-1: ZCode export를 Responses kind로 + +## 결함 + +src/clients/config-export/zcode.ts 가 kind "openai-compatible" 을 내보낸다. ZCode 는 그 kind 에서 +{baseURL}/chat/completions 를 호출하고, ocx 의 Chat 인바운드는 본문을 Responses 로 번역해 +handleResponses 로 리플레이한 뒤 응답을 다시 Chat SSE 로 역번역한다. 왕복 2회 번역이고 그 과정에서 +tool-call delta 와 reasoning 블록이 형태를 바꾼다. + +ZCode 는 kind "openai" 로 {baseURL}/responses 를 직접 호출한다(002 문서의 fL / IHe 인용). +ocx 는 POST /v1/responses 를 네이티브로 서빙한다(src/server/index.ts:1994). 번역 0회. + +## 변경 + +### MODIFY src/clients/config-export/zcode.ts + +다섯 지점이다. 타입 리터럴(33행), 빌더 값(94행), 그리고 세 주석 블록(10-11행, 49-54행, 76-78행). + + - kind: "openai-compatible"; + + kind: "openai"; + + - kind: "openai-compatible", + + kind: "openai", + +주석은 3.11.2 번들에서 재추출한 사실로 갱신한다: getDefaultModelProviderEndpointPathForKind 가 +anthropic 을 /v1/messages, openai 를 /responses, openai-compatible 을 /chat/completions 로 보낸다는 +것과, Responses 가 프록시의 네이티브 인바운드라 이전 배선이 턴마다 번역 두 번을 냈다는 것. + +baseURL 은 그대로 ctx.baseUrl 에서 /v1 을 떼고 다시 "/v1" 을 붙인 값이다. ZCode 의 +normalizeModelProviderBaseUrlForKind 는 openai kind 에서 /responses 접미사만 떼므로 /v1 은 보존되고 +최종 URL 은 http://127.0.0.1:/v1/responses 가 된다. + +reasoning 블록(enabled / variants / defaultVariant)은 kind 와 무관하게 같은 스키마다. openai kind 에서는 +선택된 variant 가 reasoning.effort 로 나가고, 그건 ocx /v1/responses 가 네이티브로 읽는 필드다. + +76-78행 주석이 "ZCode forwards the selected variant as reasoning_effort" 라고 말하는데 그건 +openai-compatible kind 의 wire 필드다. openai kind 는 reasoning.effort 로 보낸다(002 문서의 +withOpenAiResponsesThoughtLevel 인용). 동작은 스키마가 같아 그대로지만 주석은 틀리므로 함께 고친다. + +### MODIFY tests/providers/zcode-client.test.ts + + - expect(provider.kind).toBe("openai-compatible"); + + expect(provider.kind).toBe("openai"); + +options 기대값(baseURL http://127.0.0.1:10100/v1)은 바뀌지 않는다. 같은 describe 에 회귀 테스트를 +하나 추가해, ZCode 가 openai kind 에서 조립하는 최종 URL 이 프록시가 실제로 서빙하는 경로와 +일치한다는 것을 고정한다. + + test("the exported kind resolves to the proxy's native Responses route", () => { + const document = buildClientConfig("zcode", context()) as ZcodeGeneratedConfig; + const provider = document.provider[OPENCODE_PROVIDER_ID]!; + // ZCode 3.11.2 getDefaultModelProviderEndpointPathForKind: openai -> "/responses". + expect(provider.kind).toBe("openai"); + expect(provider.options.baseURL + "/responses").toBe("http://127.0.0.1:10100/v1/responses"); + }); + +### MODIFY tests/config/client-config-export.test.ts + +123행 직렬화 바이트 고정값에서 "kind":"openai-compatible" 을 "kind":"openai" 로 바꾼다. +나머지 필드 순서와 값은 동일하다. + +## 마이그레이션 — 기대 결과는 stale -> rewrite + +감사에서 확정됐다. kind 는 refreshable 경로가 아니지만(ownership-policy.ts:66-84), 사용자가 파일을 +손대지 않았다면 recordedBlockIsOwned 가 기존 지문으로 true 를 돌려주고(integrations/state.ts:213) +desired 지문만 달라져 상태가 stale 이 된다(state.ts:411). JSON 클라이언트인 zcode 는 stale refresh 에서 +프래그먼트를 다시 쓴다. 즉 미수정 설치는 자동으로 따라온다. + +--overwrite-conflict 는 사용자가 kind 나 options 를 직접 고쳐 이미 foreign-edit 인 경우에만 필요하다. +tests/clients/integrations-writer.test.ts:525 의 conflict 케이스는 사용자가 baseURL 을 편집한 상황이지 +ocx 가 kind 를 바꾸는 상황이 아니다. + +회귀 테스트는 구성 가능한 쪽으로 넣는다. 이전 빌드가 쓴 기록(옛 지문)을 이 하네스에서 만들 수 없어 +"옛 기록 + 새 계약 -> stale" 은 직접 재현할 수 없다. 그 경로는 코드로만 확인된다 +(state.ts:213 recordedBlockIsOwned, state.ts:405-411 stale 분류, writer.ts:390-391 재작성). +대신 보완 관계인 보호 쪽을 고정한다: 사용자가 kind 를 손으로 되돌리면 여전히 conflict / foreign-edit 이고 +apply 가 거부된다. baseURL 편집에만 있던 보호를 kind 에도 명시적으로 건다. + +## 검증 + + bun test tests/providers/zcode-client.test.ts tests/config/client-config-export.test.ts tests/clients/integrations-writer.test.ts + bun run typecheck diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/020_wp3_glm53_flash_modalities.md b/devlog/_plan/260912_zcode_protocol_and_catalog/020_wp3_glm53_flash_modalities.md new file mode 100644 index 0000000000..619742aeaa --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/020_wp3_glm53_flash_modalities.md @@ -0,0 +1,70 @@ +# 020 — wp3 / ISSUE-2: glm-5.3-flash 입력 모달리티 양수 선언 + +## 결함 + +glm-5.3 은 text-only 이고 glm-5.3-flash 는 네이티브 VLM 이다(001 문서, 그리고 상류 +GET https://api.z.ai/api/v1/models 의 input_modalities). + +zai 와 zhipu-bigmodel-coding 행은 이 사실을 noVisionModels 음수 선언으로만 표현한다. +ZAI_GLM_5X_SIDECAR_VISION_MODELS 가 flash 를 제외하므로 vision sidecar 우회는 막히지만, +modelInputModalities 가 없어 configuredInputModalities 가 undefined 를 돌려주고 카탈로그가 +["text"] 플로어로 떨어진다. 결과적으로 클라이언트 export(ZCode / Pi / OMP)의 모델 피커에 +네이티브 VLM 이 text-only 로 실리고 이미지 첨부가 막힌다. + +zhipu-bigmodel-responses 행은 이미 양수로 선언한다. 같은 모델인데 행마다 다르게 표현된 상태다. + +## 변경 + +### MODIFY src/providers/registry.ts + +ZAI_GLM_5X_SIDECAR_VISION_MODELS 정의 바로 아래에 공유 상수를 추가한다. + + const ZAI_GLM_5X_INPUT_MODALITIES: Record = { + ...Object.fromEntries(ZAI_GLM_5X_SIDECAR_VISION_MODELS.map(id => [id, ["text"]])), + "glm-5.3-flash": ["text", "image"], + }; + +주석으로 남길 근거: noVisionModels 는 음수 진술이라 sidecar 우회만 막고 카탈로그에 모델이 무엇을 +읽을 수 있는지 말해주지 않는다는 것, 그리고 권위 출처가 GET https://api.z.ai/api/v1/models 의 +input_modalities(["text"] vs ["text","image"], evidence/zai-responses-models.json 에 캡처)와 +docs.z.ai/devpack/latest-model 의 산문이라는 것. + +zai 행과 zhipu-bigmodel-coding 행 각각에 한 줄씩 추가한다. + + noVisionModels: ZAI_GLM_5X_SIDECAR_VISION_MODELS, + + modelInputModalities: ZAI_GLM_5X_INPUT_MODALITIES, + modelReasoningEfforts: ZAI_GLM_5X_REASONING_EFFORTS, + +glm-4.6 은 두 행의 models 에 있지만 5.x 가족이 아니라 이 맵에 없다. 선언이 없으면 기존 폴백 동작이 +유지되므로 의도적으로 건드리지 않는다. + +### MODIFY tests/providers/provider-registry-parity.test.ts + +기존 전역 assertion 은 "flash 선언이 있으면 image 를 포함해야 한다"는 조건부다. 이제 Chat 행에서도 +선언이 존재해야 하므로 고정 기대값을 추가한다. + + test("the Chat-path Z.AI rows declare glm-5.3-flash as multimodal, not just out of the sidecar list", () => { + for (const id of ["zai", "zhipu-bigmodel-coding"] as const) { + const row = PROVIDER_REGISTRY.find(entry => entry.id === id); + expect(row?.modelInputModalities?.["glm-5.3-flash"]).toEqual(["text", "image"]); + expect(row?.modelInputModalities?.["glm-5.3"]).toEqual(["text"]); + expect(row?.noVisionModels ?? []).not.toContain("glm-5.3-flash"); + } + }); + +## 범위 밖 + +src/generated/model-metadata.ts 와 scripts/model-metadata.source.json 의 zai 번들에는 glm-5.3-flash +행 자체가 없다. 그 파일은 vendored 스냅샷 + 생성물이고 tests/codex-integration/model-metadata-sync.test.ts +가 바이트 동기화를 강제한다. 스냅샷 갱신은 별도의 의도적 커밋이므로 이 PR 에 섞지 않는다. +레지스트리 선언이 폴백보다 우선하므로 이 결함은 레지스트리 한 곳에서 닫힌다. + +상류가 말하는 Flash 의 입력은 Video / Image / Text / File 이지만 이 변경은 image 까지만 선언한다. +ocx 의 내부 모달리티 어휘는 text / image / audio 이고 ZCode·Pi export 어휘는 text / image 뿐이라 +(src/clients/config-export/model-metadata.ts:56-58) video 와 file 은 표현할 자리가 없다. +피커 결함은 image 선언만으로 닫힌다. video / file 은 명시적으로 범위 밖이다. + +## 검증 + + bun test tests/providers/provider-registry-parity.test.ts tests/codex-integration/catalog-vision-sidecar-modalities.test.ts + bun run typecheck diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md b/devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md new file mode 100644 index 0000000000..817993dc98 --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md @@ -0,0 +1,179 @@ +# 030 — wp4 / ISSUE-3: Z.AI 를 Responses 기본으로 통합하고 Chat 을 opt-in 으로 + +## 결함 + +Z.AI 는 같은 키로 세 프로토콜을 서빙한다(001 문서 인용). + + OpenAI Chat Completion https://api.z.ai/api/coding/paas/v4 + OpenAI Response https://api.z.ai/api/v1 + Anthropic Message https://api.z.ai/api/anthropic + +ocx 의 zai 행은 Chat 한 갈래에 고정되어 있다(adapter openai-chat). Chat 경로는 실사용에서 불안정하고, +Z.AI 자신의 devpack 안내도 Codex 계열 클라이언트에 Responses 엔드포인트를 지정한다("Codex: +https://api.z.ai/api/v1"). 국내판 Responses 행(zhipu-bigmodel-responses)만 있고 국제판이 없다. + +## 라이브 확인 (2026-09-12, 사용자 키) + + GET https://api.z.ai/api/v1/models -> 200 (Codex 형식, slug/input_modalities) + POST https://api.z.ai/api/v1/responses glm-5.3 -> 200 + POST https://api.z.ai/api/v1/responses glm-5.3-flash -> 200 + POST https://api.z.ai/api/v1/responses glm-5.2 -> 200 + POST https://api.z.ai/api/v1/responses glm-5.1 -> 200 + POST https://api.z.ai/api/v1/responses glm-5 -> 200 + POST https://api.z.ai/api/v1/responses glm-4.6 -> 200 + POST https://api.z.ai/api/v1/responses glm-5-turbo -> 200 + POST https://api.z.ai/api/v1/chat/completions glm-5.3 -> 403 model_access_denied + POST https://api.z.ai/api/coding/paas/v4/chat/completions glm-5.3 -> 200 + +두 가지가 확정된다. Responses 엔드포인트가 로스터 전체를 받으므로 전환은 모델 손실이 없다. +그리고 두 와이어는 서로 다른 경로 접두를 쓰므로 한 baseUrl 로는 둘 다 맞출 수 없다. + +문서의 "Coding Plan 구독 이력 키는 Chat 으로만 접근 가능"이라는 문장은 이 키에 해당하지 않는다. + +## 설계 + +한 행으로 통합한다. Responses 가 기본이고 Chat 은 opt-in 이며, Chat 이 받지 않는 모델은 +레지스트리가 Responses 로 고정한다. xAI 행과 방향이 같지는 않다 — 거기는 provider-wide Chat 에 +일부 모델만 modelWireDefaults 로 Responses 를 씌운다. 여기서는 반대로 provider-wide Responses 에 +Chat 을 opt-in 으로 둔다. 빌려오는 것은 modelWireDefaults 로 특정 모델의 와이어를 못박는 부분뿐이다. + +막히는 지점은 하나다. resolveWireProtocolOverride 는 adapter 만 바꾸고 baseUrl 은 그대로 둔다 +(src/server/adapter-resolve.ts:26-47). openai-chat 어댑터는 openaiChatCompletionsUrl(provider.baseUrl) +로 URL 을 만들고(src/adapters/openai-chat.ts:99), openai-responses 어댑터만 provider.responsesPath +라는 상대 경로 오버라이드를 갖는다(src/adapters/openai-responses.ts:2357-2362). +즉 Responses 쪽에는 이미 경로 오버라이드가 있고 Chat 쪽에만 없다. + +그래서 responsesPath 의 대칭짝을 만든다. + +### NEW FIELD chatCompletionsPath + +MODIFY src/types/provider.ts — responsesPath 선언 바로 아래. + + /** + * Relative send path for the openai-chat wire, mirroring responsesPath. + * Absent keeps openaiChatCompletionsUrl(baseUrl). Needed when one upstream serves + * Chat Completions and Responses under different path prefixes, so a per-model wire + * override cannot reach the right endpoint by swapping the adapter alone. + */ + chatCompletionsPath?: string; + +MODIFY src/config.ts — responsesPath 검증과 같은 규칙을 재사용한다: 스킴 없는 상대 경로, "/" 로 시작, +쿼리/프래그먼트 금지. providerResponsesPathConfigError 를 경로 이름만 받는 공용 함수로 일반화하고 +두 필드에 각각 적용한다. + +MODIFY src/adapters/openai-chat.ts — URL 조립을 responses 쪽과 같은 모양으로 바꾼다. + + - return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential }; + + const url = provider.chatCompletionsPath === undefined + + ? openaiChatCompletionsUrl(provider.baseUrl) + + : provider.baseUrl.replace(/\/$/, "") + provider.chatCompletionsPath; + + return { url, headers, hasCredential }; + +responsesPath 가 실제로 흐르는 경로 전체를 대칭으로 따라가야 한다. 감사에서 확인된 지점이다. +빠뜨리면 typecheck 가 즉시 깨지거나(auth-cors 의 satisfies Record) +런타임에 필드가 사라져 Chat opt-in 이 https://api.z.ai/chat/completions 로 나간다. + + src/providers/registry.ts:231 ProviderRegistryEntry 에 필드 선언 + src/providers/registry.ts:363 ProviderConfigSeed Pick 목록 + src/providers/derive.ts:18, :73 DerivedKeyLoginProvider / DerivedProviderPreset + src/providers/derive.ts:224, :259 providerConfigSeed (복사가 두 군데다) + src/providers/derive.ts:296 deriveKeyLoginMap + src/providers/derive.ts:480, :523 enrichProviderFromRegistry + src/providers/derive.ts:604 entryToPreset + src/router.ts:377-378 fill-if-absent 시딩 + src/config.ts zod 스키마 + 경로 검증(responsesPath 규칙 재사용) + src/server/auth-cors.ts:801 필드 권한 맵에 "editor" + gui/src/provider-payload.ts:5, :74, :89-90 + gui/src/components/provider-catalog/provider-presets.ts:18 + gui/src/components/AddProviderModal.tsx:151 + tests/server/config.test.ts:1476 허용/거절 검증 3건의 대칭 + +openai-chat 쪽 URL 조립은 openAIChatTransport 한 곳(src/adapters/openai-chat.ts:99)이면 된다. +116행과 1460행은 그 함수를 탄다. + +### NEW modelSuffixBracketStrip 을 Responses 어댑터에도 적용 + +감사와 사전 조사가 일치한다. 이 플래그는 openai-chat.ts:119, :743, :1466 과 ollama-native.ts:214 에만 +있고 openai-responses.ts 에는 매치가 0건이다. zai 로스터는 glm-5.3[1m] 과 glm-5.2[1m] 를 포함하고, +상류 실측에서 괄호 id 는 400 model_not_found 였다. 지금 상태로 Responses 를 기본으로 돌리면 +두 별칭이 기본 경로에서 죽는다. + +해결책은 둘이다. Responses buildRequest 의 wire model 에 스트립을 넣거나, 로스터에서 별칭을 뺀다. +후자는 zai/glm-5.3[1m] 을 고른 기존 사용자 선택을 깨고 parity 테스트가 고정한 별칭 메타데이터 +(provider-registry-parity.test.ts:462, :490-504)까지 무너뜨린다. 결함 크기에 비해 파괴가 크다. +전자를 택한다: provider.modelSuffixBracketStrip 이 true 일 때만 wire model 을 정규화하고 +카탈로그 slug 는 그대로 둔다. openai-chat 이 이미 하는 것과 같은 동작이다. +tests/adapters/openai/openai-chat-model-suffix.test.ts 의 Responses 대칭 테스트를 추가한다. + +### MODIFY zai 행 + + id: "zai", label: "Z.AI — GLM Coding Plan", + baseUrl: "https://api.z.ai", + adapter: "openai-responses", + responsesPath: "/api/v1/responses", + chatCompletionsPath: "/api/coding/paas/v4/chat/completions", + +models 로스터는 유지한다. modelContextWindows 의 5.3 가족은 상류 카탈로그가 말하는 1_048_576 으로 +맞춘다(현재 1_000_000, 국내 Responses 행은 이미 1_048_576). modelInputModalities 는 020 에서 넣은 +ZAI_GLM_5X_INPUT_MODALITIES 를 그대로 쓴다. preserveResponsesReasoningContent 를 켜고, +Chat 전용이던 preserveReasoningContentModels 는 유지한다(opt-in 한 사용자가 여전히 Chat 을 탄다). + +### Chat opt-in 과 Responses 고정 + +opt-in 은 기존 수단을 그대로 쓴다: 사용자가 modelAdapters 에 "openai-chat" 을 적으면 +resolveWireProtocolOverride 가 어댑터를 바꾸고, 새 chatCompletionsPath 가 올바른 경로로 보낸다. + +Chat 이 받지 않는 모델은 레지스트리가 Responses 로 고정한다. B 단계에서 coding/paas/v4 chat 경로에 +로스터 전체를 실제로 던져 어떤 모델이 400/403 을 내는지 확인하고, 해당 모델만 modelWireDefaults 에 +wire "openai-responses" 와 inbound ["responses", "chat", "anthropic"] 로 선언한다. +grok-4.20-multi-agent 행의 주석이 같은 상황을 같은 방식으로 처리한 선례다. + +### 감사에서 정리된 사항 + +- routedProviderConfig 는 매 요청 저장 설정을 레지스트리 값으로 덮는다(src/router.ts:375). 호스트가 + api.z.ai 로 같으므로 키가 다른 호스트로 가지 않는다. quota 매핑도 https://api.z.ai 와 /api/v1 을 + 이미 허용한다(src/providers/quota.ts:348-356). +- liveModels 는 켜지 않는다. 상류가 Codex 형식(models[] + slug)을 돌려주는데 ocx 라이브 발견은 + OpenAI /models(data[] + id) 계약을 기대한다. 확인되지 않은 라이브 주장은 빈 피커를 만든다. +- free-directory 의 glm id 는 별개다(src/providers/free-directory.ts:112). 계속 + https://api.z.ai/api/coding/paas/v4 + openai-chat 에 남고 zai 전환을 따라가지 않는다. +- structure 문서 의무: src/adapters/ 와 src/config.ts 와 src/providers/ 가 소유 문서를 갖는다. + structure/transports/responses.md:253 의 responsesPath 서술에 chatCompletionsPath 대칭 문장을 넣고 + bun run structure:check 를 wp4 검증에 포함한다. +- 이 변경은 020 이 넣는 ZAI_GLM_5X_INPUT_MODALITIES 상수를 쓴다. wp3 가 dev 에 들어간 뒤 올린다. + +## 테스트 + +갱신이 필요한 기존 고정 테스트. 감사가 열거한 목록이다. + + tests/providers/provider-registry-parity.test.ts:281, :313, :462, :512 + (:462 는 modelContextWindows 를 1_000_000 으로 고정한다 -> 1_048_576) + tests/providers/zhipu-bigmodel-provider.test.ts:86 + (glm free-directory 는 coding/paas/v4 에 남는다. zai 와 섞지 말 것) + tests/adapters/openai/openai-chat-model-suffix.test.ts:31 + quota / catalog / reasoning 테스트 중 zai Chat URL 을 fixture 로 쓰는 것들 + +MODIFY tests/providers/provider-registry-parity.test.ts + + test("the Z.AI row defaults to Responses and keeps Chat reachable as an opt-in", () => { + const row = PROVIDER_REGISTRY.find(entry => entry.id === "zai"); + expect(row?.adapter).toBe("openai-responses"); + expect(row?.baseUrl).toBe("https://api.z.ai"); + expect(row?.responsesPath).toBe("/api/v1/responses"); + expect(row?.chatCompletionsPath).toBe("/api/coding/paas/v4/chat/completions"); + }); + +NEW tests/adapters/openai/openai-chat-path-override.test.ts — chatCompletionsPath 가 있을 때와 +없을 때의 최종 URL 을 고정하고, modelAdapters 로 openai-chat 을 opt-in 한 zai 라우트가 +coding/paas/v4 경로로 나가는지 end-to-end 로 확인한다. + +경로가 tests/adapters 루트가 아니라 tests/adapters/openai 인 이유는 layout 규칙이다: +scripts/test-layout/layout.json 의 자식 규칙이 ^(?:openai)- 를 adapters/openai 로 보낸다. +AGENTS.md 대로 새 파일은 layout.json 의 explicit 과 tests/fixtures/test-layout-expected.json 양쪽에 +"openai-chat-path-override.test.ts": "adapters/openai" 를 등록해야 한다. + +## 검증 + + bun test tests/providers/provider-registry-parity.test.ts tests/adapters + bun run typecheck + bun run test diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json b/devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json new file mode 100644 index 0000000000..9f5f462daf --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json @@ -0,0 +1,115 @@ +{ + "models": [ + { + "apply_patch_tool_type": "freeform", + "base_instructions": "", + "context_window": 1048576, + "default_reasoning_level": "max", + "default_reasoning_summary": "none", + "description": "Z.ai's latest flagship model", + "display_name": "glm-5.3", + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": [ + "text" + ], + "max_context_window": 1048576, + "priority": 0, + "shell_type": "shell_command", + "slug": "glm-5.3", + "support_verbosity": false, + "supported_in_api": true, + "supported_reasoning_levels": [ + { + "description": "Light reasoning", + "effort": "low" + }, + { + "description": "Enhanced reasoning", + "effort": "high" + }, + { + "description": "Deep reasoning", + "effort": "max" + } + ], + "supports_parallel_tool_calls": true, + "supports_reasoning_summaries": true, + "truncation_policy": { + "limit": 10000, + "mode": "bytes" + }, + "visibility": "list" + }, + { + "apply_patch_tool_type": "freeform", + "base_instructions": "", + "context_window": 1048576, + "default_reasoning_level": "max", + "default_reasoning_summary": "none", + "description": "Fast multimodal coding model", + "display_name": "glm-5.3-flash", + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": [ + "text", + "image" + ], + "max_context_window": 1048576, + "priority": 1, + "shell_type": "shell_command", + "slug": "glm-5.3-flash", + "support_verbosity": false, + "supported_in_api": true, + "supported_reasoning_levels": [ + { + "description": "Light reasoning", + "effort": "low" + }, + { + "description": "Enhanced reasoning", + "effort": "high" + }, + { + "description": "Deep reasoning", + "effort": "max" + } + ], + "supports_parallel_tool_calls": true, + "supports_reasoning_summaries": true, + "truncation_policy": { + "limit": 10000, + "mode": "bytes" + }, + "visibility": "list" + }, + { + "apply_patch_tool_type": "freeform", + "base_instructions": "", + "context_window": 204800, + "default_reasoning_level": "max", + "default_reasoning_summary": "none", + "description": "Agent-optimized model", + "display_name": "glm-5-turbo", + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": [ + "text" + ], + "max_context_window": 204800, + "priority": 2, + "shell_type": "shell_command", + "slug": "glm-5-turbo", + "support_verbosity": false, + "supported_in_api": true, + "supported_reasoning_levels": [], + "supports_parallel_tool_calls": true, + "supports_reasoning_summaries": true, + "truncation_policy": { + "limit": 10000, + "mode": "bytes" + }, + "visibility": "list" + } + ] +} \ No newline at end of file diff --git a/src/clients/config-export/zcode.ts b/src/clients/config-export/zcode.ts index d8ec37ff1a..edd200beba 100644 --- a/src/clients/config-export/zcode.ts +++ b/src/clients/config-export/zcode.ts @@ -6,9 +6,18 @@ import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants" /** - * ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated - * live against ZCode 3.7.7 / 3.8.1). `kind: "openai-compatible"` selects the - * OpenAI Chat Completions protocol, which the proxy serves at `/v1/chat/completions`. + * ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated live + * against ZCode 3.7.7 / 3.8.1 and re-extracted from 3.11.2's bundle). + * `kind: "openai"` selects the OpenAI Responses protocol, which the proxy serves at + * `/v1/responses`. ZCode's own dispatch is the authority: its + * `getDefaultModelProviderEndpointPathForKind` maps `anthropic` to `/v1/messages`, + * `openai` to `/responses`, and `openai-compatible` to `/chat/completions`. + * + * Responses is the only surface the proxy speaks natively. The Chat and Anthropic + * inbounds translate their body into a Responses shape and replay it through + * `handleResponses`, then translate the stream back, so the previous + * `openai-compatible` wiring paid two translations per turn and reshaped tool-call + * deltas and reasoning blocks on the way through. * `apiKeyRequired` keeps ZCode's UI from prompting for a key it does not need on * loopback; the serialized key is always the non-secret loopback placeholder. */ @@ -30,7 +39,7 @@ export interface ZcodeModelEntry { export interface ZcodeProviderBlock { name: "OpenCodex"; - kind: "openai-compatible"; + kind: "openai"; enabled: true; source: "custom"; options: { @@ -46,11 +55,12 @@ export interface ZcodeGeneratedConfig { } /** - * ZCode dials the OpenAI Chat Completions surface (`openai-compatible`), which - * appends `/chat/completions` to `baseURL`. We supply `baseURL` with the `/v1` - * suffix so requests land on `/v1/chat/completions`. Model ids are the proxy's canonical - * `provider/id` selectors, which `/v1/chat/completions` resolves directly. Context - * limits follow the authoritative-window rule: a model without one ships + * ZCode dials the OpenAI Responses surface (`openai`), which appends `/responses` to + * `baseURL`. We supply `baseURL` with the `/v1` suffix so requests land on + * `/v1/responses`; ZCode's `normalizeModelProviderBaseUrlForKind` strips only the + * `/responses` suffix for this kind, so the `/v1` root survives. Model ids are the + * proxy's canonical `provider/id` selectors, which `/v1/responses` resolves directly. + * Context limits follow the authoritative-window rule: a model without one ships * without `limit` rather than guessing. Modalities are ZCode's observed * `text`-floor vocabulary; image-capable rows advertise image input. */ @@ -73,8 +83,9 @@ export function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig entry.limit = { context }; } // `none` is a Codex omit-sentinel, not a ZCode picker option. Keep catalog - // `ultra` when present: ZCode forwards the selected variant as - // `reasoning_effort`. Set `defaultVariant` only when it survives that filter. + // `ultra` when present: ZCode forwards the selected variant to the wire field its + // kind uses — `reasoning.effort` on `openai`, which is what `/v1/responses` reads + // natively. Set `defaultVariant` only when it survives that filter. const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts) ?.filter(effort => effort !== "none"); if (efforts && efforts.length > 0) { @@ -91,7 +102,7 @@ export function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig provider: { [OPENCODE_PROVIDER_ID]: { name: "OpenCodex", - kind: "openai-compatible", + kind: "openai", enabled: true, source: "custom", options: { diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts index c80c28481d..38c2498c32 100644 --- a/tests/clients/integrations-writer.test.ts +++ b/tests/clients/integrations-writer.test.ts @@ -549,6 +549,29 @@ describe("apply", () => { if (!result.ok) expect(result.reason).toBe("conflict"); }); + test("a hand-edited ZCode provider kind stays a hard conflict (#4295)", () => { + // The export moved from `openai-compatible` to `openai` so ZCode dials the proxy's + // native Responses route. `kind` is not a refreshable path, so a user who sets it + // back by hand must keep owning that decision instead of having it silently + // rewritten — the same protection `options` already has above. + const configPath = installZcode(); + const request = input({ clientId: "zcode" }); + expect(applyIntegration(request).ok).toBe(true); + + const document = JSON.parse(readFileSync(configPath, "utf8")) as { + provider: Record; + }; + expect(document.provider.opencodex!.kind).toBe("openai"); + document.provider.opencodex!.kind = "openai-compatible"; + writeFileSync(configPath, `${JSON.stringify(document, null, 2)}\n`); + + const status = readIntegrationState(request); + expect(status).toMatchObject({ state: "conflict", reason: "foreign-edit" }); + const result = applyIntegration(request); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("conflict"); + }); + test("a malformed recorded ZCode policy cannot widen refreshable drift (#2389)", () => { const configPath = installZcode(); const request = input({ clientId: "zcode" }); diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 5524b1c338..0dc53c31cd 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -120,7 +120,7 @@ describe("split config-export public facade", () => { ["omp", ["providers", "opencodex"], '{"providers":{"opencodex":{"baseUrl":"http://127.0.0.1:10100/v1","api":"openai-completions","apiKey":"opencodex-loopback","models":[{"id":"test/known","name":"known (test)","input":["text","image"],"contextWindow":8192,"maxTokens":8192,"reasoning":true,"thinking":{"mode":"effort","efforts":["high"]}}]}}}'], ["dsh", ["llm-pi-ai", "providers", "opencodex"], '{"llm-pi-ai":{"providers":{"opencodex":{"displayName":"OpenCodex","api":"openai-responses","baseURL":"http://127.0.0.1:10100/v1","headers":{"Authorization":"Bearer ocx_data_dsh"},"models":[{"id":"test/known","name":"known (test)","input":["text","image"],"contextWindow":8192,"reasoningEfforts":{"high":"high"}}]}}}}'], ["mcode", ["custom_provider", "opencodex"], '{"custom_provider":{"opencodex":{"name":"OpenCodex","kind":"custom","enabled":true,"api":"anthropic-messages","options":{"apiKey":"opencodex-loopback","baseURL":"http://127.0.0.1:10100","authMode":"api-key"},"models":{"test/known":{"limit":{"context":8192},"thinking":{"effortOptions":["high"]}}}}}}'], - ["zcode", ["provider", "opencodex"], '{"provider":{"opencodex":{"name":"OpenCodex","kind":"openai-compatible","enabled":true,"source":"custom","options":{"apiKey":"opencodex-loopback","baseURL":"http://127.0.0.1:10100/v1","apiKeyRequired":true},"models":{"test/known":{"name":"known (test)","modalities":{"input":["text","image"],"output":["text"]},"limit":{"context":8192},"reasoning":{"enabled":true,"variants":["high"]}}}}}}'], + ["zcode", ["provider", "opencodex"], '{"provider":{"opencodex":{"name":"OpenCodex","kind":"openai","enabled":true,"source":"custom","options":{"apiKey":"opencodex-loopback","baseURL":"http://127.0.0.1:10100/v1","apiKeyRequired":true},"models":{"test/known":{"name":"known (test)","modalities":{"input":["text","image"],"output":["text"]},"limit":{"context":8192},"reasoning":{"enabled":true,"variants":["high"]}}}}}}'], ] as const; for (const [id, path, expectedBytes] of cases) { const built = buildClientConfigText(id, context); diff --git a/tests/providers/zcode-client.test.ts b/tests/providers/zcode-client.test.ts index 92ff210627..3d4047f791 100644 --- a/tests/providers/zcode-client.test.ts +++ b/tests/providers/zcode-client.test.ts @@ -43,7 +43,7 @@ describe("ZCode client config", () => { expect(Object.keys(document)).toEqual(["provider"]); const provider = document.provider[OPENCODE_PROVIDER_ID]!; expect(provider.name).toBe("OpenCodex"); - expect(provider.kind).toBe("openai-compatible"); + expect(provider.kind).toBe("openai"); expect(provider.enabled).toBe(true); expect(provider.source).toBe("custom"); expect(provider.options).toEqual({ @@ -53,6 +53,17 @@ describe("ZCode client config", () => { }); }); + test("the exported kind resolves to the proxy's native Responses route", () => { + const document = buildClientConfig("zcode", context()) as ZcodeGeneratedConfig; + const provider = document.provider[OPENCODE_PROVIDER_ID]!; + // ZCode 3.11.2 getDefaultModelProviderEndpointPathForKind sends `openai` to + // `/responses` and normalizeModelProviderBaseUrlForKind strips only that suffix, + // so the `/v1` root we serialize survives and the turn lands on the route + // src/server/index.ts registers as POST /v1/responses. + expect(provider.kind).toBe("openai"); + expect(`${provider.options.baseURL}/responses`).toBe("http://127.0.0.1:10100/v1/responses"); + }); + test("models carry authoritative limits, text-floor modalities, and drop audio-only rows", () => { const document = buildClientConfig("zcode", context()) as ZcodeGeneratedConfig; const models = document.provider[OPENCODE_PROVIDER_ID]!.models;