From 526bfb31a70db0f2441ecde6786a0a99d4545c79 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 10 Jul 2026 07:19:14 +0900 Subject: [PATCH 1/5] chore: remove IOP_ROADMAP_STATUS.md --- IOP_ROADMAP_STATUS.md | 113 ------------------------------------------ 1 file changed, 113 deletions(-) delete mode 100644 IOP_ROADMAP_STATUS.md diff --git a/IOP_ROADMAP_STATUS.md b/IOP_ROADMAP_STATUS.md deleted file mode 100644 index 83958ea..0000000 --- a/IOP_ROADMAP_STATUS.md +++ /dev/null @@ -1,113 +0,0 @@ -# IOP 로드맵 현황 및 진행 계획 - -작성 기준: 2026-06-20 - -# 1. 월별 로드맵 - ---- - -## 5월 ~ 6월 중순: 핵심 실행 기반 구축 완료 - -| 구분 | 내용 | -| --- | --- | -| 목표 | Control Plane - Edge - Node 구조의 기본 실행 경로를 구축하고, OpenAI-compatible 입력 표면과 CLI Agent/Automation 실행 기반을 실제 운영 가능한 방향으로 정리합니다. | -| 예상 투입인원 | 1명 | -| 현재 구현 상태 | Edge-Node 소켓 실행 경로와 Node adapter execution 기반 완료
Ollama provider 기반 OpenAI-compatible chat completions 경로 안정화
Control Plane과 Flutter client를 통한 Edge/Node 운영 관찰 기반 구축
CLI Automation Runtime, persistent terminal, Codex/App Server 계열 실행 경로 정리
Domain Agent registry, bootstrap command, enrollment 경계 정리
OpenAI Responses input surface와 `metadata.workspace` 기반 workspace-bound agent execution contract 완료
Workspace port/env 표준화로 Control Plane, Edge, Node, Client, OpenAI-compatible, A2A, wire, metrics, DB/cache 포트 기준 정렬
Node 단일 연결에서 여러 target/provider/model candidate를 다루는 multi-target serving 기반 완료
Provider availability, capacity, admission queue snapshot 기반 완료
Lemonade provider의 모델 조회, non-streaming/streaming chat 경로 검증 완료
Edge model group queue scheduling과 model route queue 정책 정합화 완료 | -| 완료된 기능 | Edge-Node 실행 기반
Ollama 서빙 안정화 기반
Control Plane과 Client 운영 기반
CLI Automation Runtime 안정화
Domain Agent Registry와 Bootstrap Command 발급
OpenAI-compatible Responses 입력 표면
OpenAI Workspace Agent Execution Contract
Workspace port/env 표준화
Node multi-target serving 기반
Provider availability와 capacity queue 기반
Lemonade provider serving 검증
Edge model group queue scheduling MVP
Model route queue 정책 정합화 | - ---- - -## 6월 중순 ~ 7월: Edge model group queue와 추가 provider 검증 - -| 구분 | 내용 | -| --- | --- | -| 목표 | 같은 Edge 안에서 동일 `model` 요청을 model group queue로 묶고, 여러 Node 후보에 순차 dispatch하는 구조를 정리합니다. Lemonade 이후 vLLM과 SGLang provider 검증 결과를 반영해 1차 KPI 범위의 속도/디바이스 효율 축을 닫습니다. | -| 예상 투입인원 | 1명 | -| 현재 구현 상태 | Node-local admission/capacity 기반 완료
Edge model group queue scheduling과 같은 Edge 내부의 다중 Node 후보 순차 dispatch 기반 완료
OpenAI-compatible `model` route queue 정책 정합화 완료
vLLM provider serving validation 완료: provider boundary, DGX Spark vLLM container, config contract, 모델 조회/chat, streaming, split-host field smoke 검증 PASS 근거와 코드 레벨 완료 리뷰까지 정리하고 archive함
SGLang provider serving validation은 GX10 + Qwen3.6 35B NVFP4 field validation 결과 NVIDIA checkpoint 직접 로딩 실패와 vLLM 대비 낮은 throughput/KV 여유가 확인되어 현 조건에서 provider 채택을 폐기함
다음 활성 작업은 운영 관측 Phase의 `Model Alias Provider Pool과 Provider Catalog` 계획 마일스톤으로 전환됨 | -| 주요 작업 | 완료: OpenAI-compatible `model` 값을 queue group key로 사용하는 Edge-owned FIFO 정리
완료: Node 후보별 capacity, in-flight, queued snapshot을 기준으로 dispatch 흐름 정리
완료: Model route Queue 정책 원천 정합화
완료: vLLM OpenAI-compatible provider의 config contract, 모델 조회, chat, streaming, split-host field smoke 검증
정리: SGLang provider는 현 field 조건에서 채택하지 않고 Provider Catalog/Qualification 후속 판단 후보로 분리
다음: `models[]` 기반 provider pool과 read-only Provider Catalog 계약 구현 계획 | -| 완성 예정 기능 | vLLM provider 최소 serving 검증 완료
SGLang provider field 판단 결과 정리
Provider별 adapter/config/target/model 매핑 기준을 Provider Catalog 계약으로 승계 | - ---- - -## 7월 ~ 8월: 운영 관측, 사용량 추적, Provider Catalog 정리 - -| 구분 | 내용 | -| --- | --- | -| 목표 | IOP를 여러 Edge, Node, CLI Agent, local inference provider와 함께 운영하기 위해 필요한 사용자/토큰/사용량/로그/provider 상태 관찰 기준을 1차 feature MVP 단위로 정리합니다. | -| 예상 투입인원 | 1명 | -| 현재 구현 상태 | Edge, Node, Control Plane, Client의 기본 상태 관찰 경로 구축
Provider availability와 capacity snapshot 기반 완료
`Model Alias Provider Pool과 Provider Catalog`가 현재 활성 계획 마일스톤이며 SDD는 승인됨/잠금 해제 상태
사용자/토큰/사용량/로그 추적과 Provider-Device-Model Qualification 리포트는 후속 스케치 상태 | -| 주요 작업 | `models[]`를 OpenAI-compatible model key이자 provider pool canonical routing key로 정리
`nodes[].providers[]` 기반 provider 정의와 provider별 served model list 계약 정리
Edge-owned target rewrite와 `in_flight / capacity` load-ratio provider 선택 정책 구현 계획
API, CLI, local inference provider를 구분하는 read-only Provider Catalog 기준 정리
사용자 단위 API/CLI/local inference 사용량, token, request, execution 로그의 최소 수집 항목은 후속 MVP로 구체화 | -| 완성 예정 기능 | Model Alias Provider Pool과 Provider Catalog MVP
Provider Catalog와 로컬 디바이스 상태 관리 MVP
사용량, 토큰, 로그 운영 추적 MVP
운영자가 확인할 수 있는 provider/status/usage 최소 화면 또는 명령 표면 | - ---- - -## 8월 ~ 9월: CLI Agent 알림, 원격 작업 환경, Update Plane, 단계 호출 검증 MVP - -| 구분 | 내용 | -| --- | --- | -| 목표 | CLI Agent 사용량 limit 감지와 알림, 작업 자동 이어받기, workspace-bound execution 기반 원격 작업 환경을 feature MVP로 정리합니다. 동시에 Edge/Node 자체 업데이트 기반과 planner/generator/verifier 형태의 단계 호출/runtime schema 검증은 최소 실행 모드로 스케치합니다. | -| 예상 투입인원 | 1명 | -| 현재 구현 상태 | CLI runtime과 usage checker 계열 기반 마련
OpenAI Workspace Agent Execution Contract 완료
알림 자체는 `nexo`의 messaging/notification 기반을 활용할 수 있는 상태
socket/protocol 기반은 `proto-socket`의 안정화 결과를 활용할 수 있는 상태
CLI Agent 알림/자동 이어받기, 원격 작업 환경, Update Plane, 단계 호출 검증 MVP는 스케치/계획 상태이며 사용자 검토 후 구체화 | -| 주요 작업 | CLI Agent limit 도달 이벤트와 사용자 알림 trigger 기준 정리
자동 이어받기 허용 조건, 중단 조건, 사용자 승인 경계 정리
workspace-bound execution을 이용한 원격 코딩/유지보수 작업 환경의 최소 운영 흐름 정리
Control Plane release view, Edge/Node update protocol, host-local manager, rollout/recovery 정책 스케치
요청 의도 분석, 실제 작업, 검증/schema 강제, 오류 시 retry/fallback의 최소 단계 실행 흐름 정리
Nexo 알림 기반과 IOP 실행 이벤트의 연동 경계 정리 | -| 완성 예정 기능 | CLI Agent 사용량 알림 MVP
CLI Agent 자동 이어받기 MVP
원격 코딩/유지보수 작업 환경 MVP
Update Plane 안정 프로토콜과 자체 업데이트 기반 스케치
단계 호출과 runtime schema 검증 MVP | - ---- - -## 9월 ~ 10월: 1차 KPI 범위 통합 검증 및 안정화 - -| 구분 | 내용 | -| --- | --- | -| 목표 | 1차 KPI 범위에 포함된 feature MVP들을 통합하고, Control Plane - Edge - Node - Client - CLI Agent - provider 경로를 운영자가 확인 가능한 수준으로 안정화합니다. | -| 예상 투입인원 | 1명 | -| 주요 작업 | Edge model group queue와 provider serving 경로의 E2E smoke 정리
Usage/log/provider status/CLI notification 흐름의 통합 검증
Control Plane/Client 운영 표면에서 확인해야 할 최소 상태 정리
실패/재시도/중단/복구 시나리오 점검
1차 범위와 2차 보류 범위의 문서 경계 정리
운영 전환 전 known limitation과 후속 보완 항목 정리 | -| 완성 예정 기능 | 1차 KPI 범위 feature MVP 통합 검증 결과
운영자 확인용 usage/provider/queue/agent 상태 기준
E2E smoke와 회귀 확인 기준
10월 이후 고도화 후보와 2차 범위 분리 결과 | - ---- - -## 10월 이후: 2차 고도화 후보 - -| 구분 | 내용 | -| --- | --- | -| 목표 | 1차 KPI 범위의 feature MVP를 완료한 뒤, 장기 기억/RAG, advisor, context hook, 원격 터널링, oto scheduler/CI-CD 같은 고도화 항목을 별도 검토합니다. | -| 예상 투입인원 | 미정 | -| 주요 작업 | 장기 기억과 RAG update cycle 검토
Advisor와 local LLM context compression hook 검토
특정 Node CLI Agent 원격 터널링 POC 검토
oto 자동화 scheduler와 CI-CD 연동 검토
Cross-Edge/cloud fallback, 품질 평가 feedback 고도화 검토 | -| 완성 예정 기능 | 2차 고도화 후보별 검토 결과
1차 운영 결과를 반영한 우선순위 재정렬
구체화 가능한 항목의 별도 milestone 승격 | - ---- - -# 2. 단계별 산출물 - -| 구분      | 주요 산출물                                                                                                                           | -| ----------------| -----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| 5월 ~ 6월 중순 | Edge-Node 실행 기반, Ollama serving 안정화, Control Plane/Client 운영 기반, CLI Automation Runtime, Domain Agent registry/bootstrap, OpenAI Responses input surface, Workspace Agent Execution Contract, Workspace port/env 표준화, Lemonade provider 검증 결과 | -| 6월 중순 ~ 7월 | Edge model group queue scheduling과 model route queue 정책 정합화 결과, vLLM provider 검증 결과, SGLang provider field 판단 결과, provider별 adapter/config/target/model 매핑 기준                                                                    | -| 7월 ~ 8월   | Model Alias Provider Pool과 Provider Catalog MVP, 사용량/토큰/로그 추적 MVP 정의, 로컬 디바이스 provider 상태 관리 기준, 운영 확인 표면 초안                                                                        | -| 8월 ~ 9월   | CLI Agent 사용량 알림/자동 이어받기 MVP, 원격 작업 환경 MVP, Update Plane 안정 프로토콜/자체 업데이트 기반, 단계 호출과 runtime schema 검증 MVP, Nexo 알림 연동 경계                                                                      | -| 9월 ~ 10월   | 1차 KPI 범위 feature MVP 통합 검증 결과, E2E smoke 기준, 운영 전환 체크리스트, known limitation, 2차 범위 분리 결과                                                                       | -| 10월 이후   | 장기 기억/RAG, advisor/context hook, 원격 터널링, oto scheduler/CI-CD, cloud fallback/품질 평가 고도화 검토 결과                                                                        | - ---- - -# 3. 주요 리스크 및 고려사항 - -| 리스크 및 고려사항 | 내용 | -| --- | --- | -| Provider 실장비 검증 | vLLM, SGLang, vLLM-MLX 등 local inference provider는 adapter 구현보다 실제 장비와 런타임별 동작 검증이 일정 변수입니다. SGLang은 현 field 조건에서 채택하지 않기로 정리했으며, 1차 범위에서는 provider별 전체 최적화보다 최소 serving 경로와 상태 표현을 우선합니다. | -| 사용량/토큰/로그 정책 결정 | 사용자 단위 usage, token, log 추적은 구현보다 보존 범위, 노출 범위, 제한 정책 결정이 중요합니다. Billing, chargeback, 조직 IAM, 장기 retention은 1차 범위에서 완성하지 않고 후속 구체화로 둡니다. | -| CLI Agent 자동 이어받기 안전 경계 | 자동 이어받기는 편의성이 크지만, 무제한 재시도나 의도하지 않은 실행 지속으로 이어지면 위험합니다. 1차에서는 limit 감지, 알림, 명시된 조건에서의 이어받기만 MVP로 다룹니다. | -| Nexo와 Proto Socket 기반 활용 | 알림 기반은 `nexo`, socket/protocol 기반은 `proto-socket`의 거의 완료된 기반을 활용하는 전제로 봅니다. IOP에서는 새 인프라를 다시 만들기보다 실행 이벤트, 알림 trigger, protocol compatibility, 운영 표면 고정에 집중합니다. | -| 2차 범위 유입 방지 | 장기 기억/RAG, advisor, context compression hook, 원격 터널링, oto scheduler/CI-CD는 1차 KPI 범위 밖으로 분리합니다. 1차 기간에는 스케치와 잠금 상태로 유지하고, 실제 구현 계획은 별도 검토 후 승격합니다. | - ---- - -# 4. 보고용 요약 - -IOP는 현재 Control Plane - Edge - Node 기반의 실행 오케스트레이션 구조와 OpenAI-compatible 실행 표면을 상당 부분 구축한 상태입니다. Edge-Node 실행 경로, Ollama serving, Control Plane/Client 운영 기반, CLI Automation Runtime, Domain Agent registry/bootstrap, OpenAI Responses input surface, workspace-bound agent execution contract, Workspace port/env 표준화, Node multi-target serving, provider availability/capacity queue, Lemonade provider 검증, Edge model group queue scheduling, model route queue 정책 정합화까지 완료되었습니다. - -현재 vLLM provider serving validation은 완료 및 archive 처리되었습니다. SGLang provider serving validation은 field validation 결과를 반영해 현 조건에서는 provider 채택을 폐기했습니다. Edge model group queue scheduling과 model route queue 정책 정합화는 완료된 기반으로 보고, 다음 활성 마일스톤은 운영 관측 Phase의 `Model Alias Provider Pool과 Provider Catalog`입니다. - -이후 7월부터 8월까지는 `models[]` 기반 provider pool, Edge-owned target rewrite, load-ratio provider 선택, read-only Provider Catalog, 로컬 디바이스 provider 상태 관리를 정리합니다. 이 단계는 완성된 billing이나 enterprise IAM이 아니라, 운영자가 IOP의 사용량과 provider 상태를 확인할 수 있는 feature MVP를 목표로 합니다. 사용자/토큰/사용량/로그 추적과 Provider-Device-Model Qualification 리포트는 후속 마일스톤으로 이어집니다. - -8월부터 9월까지는 CLI Agent 사용량 limit 알림, 자동 이어받기, 원격 코딩/유지보수 작업 환경, Update Plane 자체 업데이트 기반, 단계 호출과 runtime schema 검증 MVP를 구체화합니다. 알림 기반은 `nexo`, socket/protocol 기반은 `proto-socket`의 거의 완료된 기반을 활용하고, IOP는 실행 이벤트와 운영 정책, 업데이트 수렴, E2E 흐름 고정에 집중합니다. - -9월부터 10월까지는 1차 KPI 범위에 포함된 feature MVP를 통합 검증하고 안정화합니다. 10월 이후에는 장기 기억/RAG, advisor, context hook, 원격 터널링, oto scheduler/CI-CD, cross-Edge/cloud fallback 같은 2차 고도화 후보를 별도로 검토합니다. From 6ab66277b856109e4edf43d3779368d482f2bc92 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 10 Jul 2026 07:40:02 +0900 Subject: [PATCH 2/5] docs: update dev-corp profile with sampling parameter extension --- agent-test/dev-corp/edge-smoke.md | 2 +- agent-test/dev-corp/inventory.yaml | 17 +++++- docs/dev-corp-openai-compatible-call-guide.md | 46 +++++++++++++++ docs/dev-corp-pi-settings-guide.md | 57 ++++++++++++++++++- 4 files changed, 118 insertions(+), 4 deletions(-) diff --git a/agent-test/dev-corp/edge-smoke.md b/agent-test/dev-corp/edge-smoke.md index 95cde38..30a4e1c 100644 --- a/agent-test/dev-corp/edge-smoke.md +++ b/agent-test/dev-corp/edge-smoke.md @@ -57,7 +57,7 @@ dev-corp provider pool과 3-node 연결 상태를 점검할 때는 `agent-test/d - Edge-Node TCP transport 기본 후보: `digitalplatform-iop.cloud:18087` - mac-mini/internal 후보는 dev-corp Edge 경로가 아니다. Node `edge_addr`는 `digitalplatform-iop.cloud:18087`이어야 한다. - model alias 후보: `gemma4:26b` -- host Pi dev-corp profile: `agent-test/dev-corp/inventory.yaml`의 `model.pi_agent_profile` 기준. 2026-07-09 현재 Pi 기본 provider/model/thinking level은 `dev-corp` / `gemma4:26b` / `high`이며, base URL은 public Edge route `http://digitalplatform-iop.cloud:18086/v1`이다. dev `qwen3.6:35b` Pi profile은 `agent-test/dev/inventory.yaml`에서 별도로 관리한다. +- host Pi dev-corp profile: `agent-test/dev-corp/inventory.yaml`의 `model.pi_agent_profile` 기준. 2026-07-10 현재 Pi 기본 provider/model/thinking level은 `dev-corp` / `gemma4:26b` / `high`이며, base URL은 public Edge route `http://digitalplatform-iop.cloud:18086/v1`이고 sampling parameter는 Pi extension으로 `temperature=0.2`, `top_p=0.95`를 주입한다. dev `qwen3.6:35b` Pi profile은 `agent-test/dev/inventory.yaml`에서 별도로 관리한다. 노드 후보: diff --git a/agent-test/dev-corp/inventory.yaml b/agent-test/dev-corp/inventory.yaml index 596737f..b16f29b 100644 --- a/agent-test/dev-corp/inventory.yaml +++ b/agent-test/dev-corp/inventory.yaml @@ -115,7 +115,7 @@ model: strict_output_behavior: provider_pool_catalog_default_overrides_strict_disable adapter_mapping: vllm_and_vllm_mlx_use_chat_template_kwargs_enable_thinking_true pi_agent_profile: - observed_at: "2026-07-09" + observed_at: "2026-07-10" config_scope: host_local_pi_agent config_dir: ~/.pi/agent install: @@ -124,6 +124,8 @@ model: binary: /opt/homebrew/bin/pi settings_file: ~/.pi/agent/settings.json models_file: ~/.pi/agent/models.json + extension_files: + - ~/.pi/agent/extensions/dev-corp-temperature.ts profile_namespace: dev_corp_gemma4_pi current_default_provider: dev-corp current_default_model: gemma4:26b @@ -132,6 +134,8 @@ model: current_default_api: openai-completions current_default_auth_header: true current_default_thinking_level: high + current_default_temperature: 0.2 + current_default_top_p: 0.95 hide_thinking_block: false http_idle_timeout_ms: 300000 retry_provider_timeout_ms: 300000 @@ -141,6 +145,12 @@ model: - dev-corp-direct/** - dev-corp-spark01/** - dev-corp/* + extensions: + - path: ~/.pi/agent/extensions/dev-corp-temperature.ts + registered_in_settings: extensions/dev-corp-temperature.ts + hook: before_provider_request + scope: dev-corp provider only + effect: inject temperature 0.2 and top_p 0.95 into provider request payload api_key_value_tracked: false route_policy: current dev-corp Pi default must use the public Edge route digitalplatform-iop.cloud. pi_model_parameters: @@ -152,6 +162,9 @@ model: max_tokens: 32768 reasoning: true thinking_level: high + request_temperature: 0.2 + request_top_p: 0.95 + sampling_parameter_source: before_provider_request extension dev-corp-temperature.ts expected_iop_thinking_token_budget: 1024 compat: supports_developer_role: false @@ -194,6 +207,8 @@ model: edge_context_window: 262144 pi_max_tokens: 32768 provider_default_max_tokens: 32768 + pi_temperature: 0.2 + pi_top_p: 0.95 pi_reasoning: true edge_reasoning_policy: bounded_thinking edge_default_thinking_token_budget: 1024 diff --git a/docs/dev-corp-openai-compatible-call-guide.md b/docs/dev-corp-openai-compatible-call-guide.md index c6d6c82..3f3b2af 100644 --- a/docs/dev-corp-openai-compatible-call-guide.md +++ b/docs/dev-corp-openai-compatible-call-guide.md @@ -14,6 +14,50 @@ dev-corp `gemma4:26b` 모델을 OpenAI-compatible 방식으로 직접 호출할 `/v1/responses`가 아니라 `/v1/chat/completions`를 사용한다. +## Pi 설정 + +현재 호스트의 Pi dev-corp profile은 `~/.pi/agent/settings.json`과 `~/.pi/agent/models.json`을 기준으로 한다. + +필수 기본값: + +| 항목 | 값 | +| --- | --- | +| `defaultProvider` | `dev-corp` | +| `defaultModel` | `gemma4:26b` | +| `defaultThinkingLevel` | `high` | +| `extensions` | `extensions/dev-corp-temperature.ts` | + +Pi `0.80.3`의 `models.json` provider/model schema에는 고정 sampling parameter 필드가 없다. dev-corp Pi 호출의 `temperature`와 `top_p`는 전역 extension `~/.pi/agent/extensions/dev-corp-temperature.ts`의 `before_provider_request` hook에서 적용한다. + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const DEV_CORP_PROVIDER = "dev-corp"; +const DEV_CORP_TEMPERATURE = 0.2; +const DEV_CORP_TOP_P = 0.95; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export default function (pi: ExtensionAPI) { + pi.on("before_provider_request", (event, ctx) => { + if (ctx.model?.provider !== DEV_CORP_PROVIDER) return; + if (!isRecord(event.payload)) return; + + return { + ...event.payload, + temperature: DEV_CORP_TEMPERATURE, + top_p: DEV_CORP_TOP_P, + }; + }); +} +``` + +이 hook은 provider가 `dev-corp`일 때만 요청 payload에 `temperature: 0.2`, `top_p: 0.95`를 추가한다. `dev-corp-direct`, `dev-corp-spark01` 같은 direct provider에는 적용하지 않는다. + +이미 실행 중인 Pi session에는 `/reload` 또는 재시작 후 반영한다. + ## 권장 요청 기본 권장 호출은 `stream:true`다. `think`, `reasoning_effort`, `thinking_token_budget`은 보내지 않는다. @@ -49,6 +93,8 @@ curl -fsS http://digitalplatform-iop.cloud:18086/v1/chat/completions \ | --- | --- | | `model` | `gemma4:26b` | | `stream` | 기본 `true`, 필요하면 `false` | +| `temperature` | Pi dev-corp profile은 extension으로 `0.2` 고정 | +| `top_p` | Pi dev-corp profile은 extension으로 `0.95` 고정 | | `think` | 생략 | | `reasoning_effort` | 생략 | | `thinking_token_budget` | 생략 | diff --git a/docs/dev-corp-pi-settings-guide.md b/docs/dev-corp-pi-settings-guide.md index f06d518..f0ac1cd 100644 --- a/docs/dev-corp-pi-settings-guide.md +++ b/docs/dev-corp-pi-settings-guide.md @@ -27,7 +27,7 @@ pi --version 디렉터리가 없으면 만든다. ```bash -mkdir -p ~/.pi/agent +mkdir -p ~/.pi/agent/extensions ``` ## settings.json @@ -48,12 +48,54 @@ mkdir -p ~/.pi/agent }, "enabledModels": [ "dev-corp/*" + ], + "extensions": [ + "extensions/dev-corp-temperature.ts" ] } ``` 기존 `theme`, `lastChangelogVersion` 같은 개인 설정은 유지해도 된다. +## sampling parameter extension + +Pi `0.80.3`의 `models.json` provider/model schema에는 고정 sampling parameter 필드가 없다. dev-corp Pi 호출의 `temperature`와 `top_p`는 전역 extension의 `before_provider_request` hook으로 적용한다. + +파일 위치: + +```text +~/.pi/agent/extensions/dev-corp-temperature.ts +``` + +내용: + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const DEV_CORP_PROVIDER = "dev-corp"; +const DEV_CORP_TEMPERATURE = 0.2; +const DEV_CORP_TOP_P = 0.95; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export default function (pi: ExtensionAPI) { + pi.on("before_provider_request", (event, ctx) => { + if (ctx.model?.provider !== DEV_CORP_PROVIDER) return; + if (!isRecord(event.payload)) return; + + return { + ...event.payload, + temperature: DEV_CORP_TEMPERATURE, + top_p: DEV_CORP_TOP_P, + }; + }); +} +``` + +이 extension은 provider가 `dev-corp`일 때만 요청 payload에 `temperature: 0.2`, `top_p: 0.95`를 추가한다. `dev-corp-direct`, `dev-corp-spark01` 같은 direct provider에는 적용하지 않는다. + ## models.json `apiKey`에는 실제 토큰을 넣는다. 토큰은 문서, 로그, 공유 화면에 남기지 않는다. @@ -101,14 +143,17 @@ mkdir -p ~/.pi/agent - `supportsReasoningEffort`는 `false`로 둔다. - `think=false`, `reasoning_effort=none`, `thinking_token_budget`을 강제로 추가하지 않는다. +- dev-corp Pi 호출 sampling parameter는 `~/.pi/agent/extensions/dev-corp-temperature.ts`로 `temperature: 0.2`, `top_p: 0.95`를 주입한다. - Pi는 `openai-completions` 기준으로 streaming 호출을 기본 사용한다. non-stream이 필요한 경우는 Pi가 아니라 일반 OpenAI-compatible 호출에서 `stream:false`로 선택한다. - reasoning 표시 여부는 Pi UI나 client 표시 정책에서 선택적으로 처리한다. +- 이미 실행 중인 Pi session에는 `/reload` 또는 재시작 후 extension 변경을 반영한다. ## 확인 ```bash pi --version -jq '{defaultProvider, defaultModel, defaultThinkingLevel, hideThinkingBlock}' ~/.pi/agent/settings.json +jq '{defaultProvider, defaultModel, defaultThinkingLevel, hideThinkingBlock, extensions}' ~/.pi/agent/settings.json +test -f ~/.pi/agent/extensions/dev-corp-temperature.ts jq '.providers["dev-corp"] | { baseUrl, api, @@ -128,6 +173,7 @@ jq '.providers["dev-corp"] | { - Pi 설치 - ~/.pi/agent/settings.json - ~/.pi/agent/models.json +- ~/.pi/agent/extensions/dev-corp-temperature.ts 수정 금지: - 이 저장소 코드와 설정 @@ -149,6 +195,12 @@ settings.json: - retry.provider.maxRetries: 0 - retry.provider.maxRetryDelayMs: 60000 - enabledModels: ["dev-corp/*"] +- extensions: ["extensions/dev-corp-temperature.ts"] + +sampling parameter extension: +- 파일: ~/.pi/agent/extensions/dev-corp-temperature.ts +- before_provider_request hook에서 ctx.model.provider가 dev-corp일 때만 payload.temperature를 0.2, payload.top_p를 0.95로 설정 +- dev-corp-direct, dev-corp-spark01에는 적용하지 않음 models.json: - providers.dev-corp.baseUrl: http://digitalplatform-iop.cloud:18086/v1 @@ -172,4 +224,5 @@ models.json: think=false, reasoning_effort=none, thinking_token_budget 설정은 추가하지 마. 검증 출력에는 apiKey 원문을 포함하지 말고 hasApiKey boolean만 보여줘. +이미 실행 중인 Pi session에는 /reload 또는 재시작이 필요하다고 안내해줘. ``` From 14c88c32aa225d9c63e7d50ec535d9b250882799 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 10 Jul 2026 07:51:10 +0900 Subject: [PATCH 3/5] =?UTF-8?q?docs(docs):=20dev-corp=20OpenAI-compatible?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20=EC=8B=9C=20=EB=B0=98=EB=B3=B5=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20loop=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev-corp-openai-compatible-call-guide.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/dev-corp-openai-compatible-call-guide.md b/docs/dev-corp-openai-compatible-call-guide.md index 3f3b2af..1d57d5f 100644 --- a/docs/dev-corp-openai-compatible-call-guide.md +++ b/docs/dev-corp-openai-compatible-call-guide.md @@ -61,6 +61,7 @@ export default function (pi: ExtensionAPI) { ## 권장 요청 기본 권장 호출은 `stream:true`다. `think`, `reasoning_effort`, `thinking_token_budget`은 보내지 않는다. +반복 출력 loop를 피하려면 최소 sampling parameter로 `temperature: 0.2`, `top_p: 0.95`를 함께 보낸다. ```bash curl -N http://digitalplatform-iop.cloud:18086/v1/chat/completions \ @@ -74,6 +75,8 @@ curl -N http://digitalplatform-iop.cloud:18086/v1/chat/completions \ "content": "간단히 응답해줘." } ], + "temperature": 0.2, + "top_p": 0.95, "stream": true }' ``` @@ -84,7 +87,7 @@ Streaming을 처리하지 않는 client에서는 선택적으로 `stream:false` curl -fsS http://digitalplatform-iop.cloud:18086/v1/chat/completions \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ - -d '{"model":"gemma4:26b","messages":[{"role":"user","content":"ok만 응답해줘."}],"stream":false}' + -d '{"model":"gemma4:26b","messages":[{"role":"user","content":"ok만 응답해줘."}],"temperature":0.2,"top_p":0.95,"stream":false}' ``` ## 파라미터 @@ -100,6 +103,12 @@ curl -fsS http://digitalplatform-iop.cloud:18086/v1/chat/completions \ | `thinking_token_budget` | 생략 | | `include_reasoning` | 숨김 보장용으로 쓰지 않음 | +## Repetition loop 조정 + +dev-corp `gemma4:26b` OpenAI-compatible 호출에서는 반복 출력 loop를 피하기 위한 최소 기준으로 `temperature: 0.2`, `top_p: 0.95`를 설정한다. 두 값을 생략해 provider 기본값에 맡기지 않는다. + +그래도 repetition loop가 발생하면 한 번에 여러 값을 크게 바꾸지 말고, `temperature`는 조금씩 올리고 `top_p`는 조금씩 낮추는 방향으로 조정한다. + ## Reasoning 표시 옵션 응답에 reasoning field가 포함될 수 있다. 표시하지 않는 UX가 필요하면 호출하는 client에서 아래 field를 렌더링 대상에서 제외하는 방식으로 처리할 수 있다. From ddcc4f42f85f67d5435750f0e971555ac7c6e2e8 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 10 Jul 2026 08:55:24 +0900 Subject: [PATCH 4/5] docs: update dev-corp openai compatible call guide and pi settings guide --- docs/dev-corp-openai-compatible-call-guide.md | 48 +------------------ docs/dev-corp-pi-settings-guide.md | 3 ++ 2 files changed, 5 insertions(+), 46 deletions(-) diff --git a/docs/dev-corp-openai-compatible-call-guide.md b/docs/dev-corp-openai-compatible-call-guide.md index 1d57d5f..3df68ca 100644 --- a/docs/dev-corp-openai-compatible-call-guide.md +++ b/docs/dev-corp-openai-compatible-call-guide.md @@ -14,50 +14,6 @@ dev-corp `gemma4:26b` 모델을 OpenAI-compatible 방식으로 직접 호출할 `/v1/responses`가 아니라 `/v1/chat/completions`를 사용한다. -## Pi 설정 - -현재 호스트의 Pi dev-corp profile은 `~/.pi/agent/settings.json`과 `~/.pi/agent/models.json`을 기준으로 한다. - -필수 기본값: - -| 항목 | 값 | -| --- | --- | -| `defaultProvider` | `dev-corp` | -| `defaultModel` | `gemma4:26b` | -| `defaultThinkingLevel` | `high` | -| `extensions` | `extensions/dev-corp-temperature.ts` | - -Pi `0.80.3`의 `models.json` provider/model schema에는 고정 sampling parameter 필드가 없다. dev-corp Pi 호출의 `temperature`와 `top_p`는 전역 extension `~/.pi/agent/extensions/dev-corp-temperature.ts`의 `before_provider_request` hook에서 적용한다. - -```typescript -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - -const DEV_CORP_PROVIDER = "dev-corp"; -const DEV_CORP_TEMPERATURE = 0.2; -const DEV_CORP_TOP_P = 0.95; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export default function (pi: ExtensionAPI) { - pi.on("before_provider_request", (event, ctx) => { - if (ctx.model?.provider !== DEV_CORP_PROVIDER) return; - if (!isRecord(event.payload)) return; - - return { - ...event.payload, - temperature: DEV_CORP_TEMPERATURE, - top_p: DEV_CORP_TOP_P, - }; - }); -} -``` - -이 hook은 provider가 `dev-corp`일 때만 요청 payload에 `temperature: 0.2`, `top_p: 0.95`를 추가한다. `dev-corp-direct`, `dev-corp-spark01` 같은 direct provider에는 적용하지 않는다. - -이미 실행 중인 Pi session에는 `/reload` 또는 재시작 후 반영한다. - ## 권장 요청 기본 권장 호출은 `stream:true`다. `think`, `reasoning_effort`, `thinking_token_budget`은 보내지 않는다. @@ -96,8 +52,8 @@ curl -fsS http://digitalplatform-iop.cloud:18086/v1/chat/completions \ | --- | --- | | `model` | `gemma4:26b` | | `stream` | 기본 `true`, 필요하면 `false` | -| `temperature` | Pi dev-corp profile은 extension으로 `0.2` 고정 | -| `top_p` | Pi dev-corp profile은 extension으로 `0.95` 고정 | +| `temperature` | 최소 기준 `0.2` | +| `top_p` | 최소 기준 `0.95` | | `think` | 생략 | | `reasoning_effort` | 생략 | | `thinking_token_budget` | 생략 | diff --git a/docs/dev-corp-pi-settings-guide.md b/docs/dev-corp-pi-settings-guide.md index f0ac1cd..a21e93d 100644 --- a/docs/dev-corp-pi-settings-guide.md +++ b/docs/dev-corp-pi-settings-guide.md @@ -96,6 +96,8 @@ export default function (pi: ExtensionAPI) { 이 extension은 provider가 `dev-corp`일 때만 요청 payload에 `temperature: 0.2`, `top_p: 0.95`를 추가한다. `dev-corp-direct`, `dev-corp-spark01` 같은 direct provider에는 적용하지 않는다. +반복 출력 loop를 피하기 위한 Pi dev-corp profile의 최소 기준은 `temperature: 0.2`, `top_p: 0.95`다. 그래도 repetition loop가 발생하면 extension의 `DEV_CORP_TEMPERATURE`는 조금씩 올리고 `DEV_CORP_TOP_P`는 조금씩 낮추는 방향으로 조정한다. + ## models.json `apiKey`에는 실제 토큰을 넣는다. 토큰은 문서, 로그, 공유 화면에 남기지 않는다. @@ -144,6 +146,7 @@ export default function (pi: ExtensionAPI) { - `supportsReasoningEffort`는 `false`로 둔다. - `think=false`, `reasoning_effort=none`, `thinking_token_budget`을 강제로 추가하지 않는다. - dev-corp Pi 호출 sampling parameter는 `~/.pi/agent/extensions/dev-corp-temperature.ts`로 `temperature: 0.2`, `top_p: 0.95`를 주입한다. +- repetition loop가 계속 발생하면 extension에서 temperature는 올리고 top_p는 낮추는 방향으로 조정한다. - Pi는 `openai-completions` 기준으로 streaming 호출을 기본 사용한다. non-stream이 필요한 경우는 Pi가 아니라 일반 OpenAI-compatible 호출에서 `stream:false`로 선택한다. - reasoning 표시 여부는 Pi UI나 client 표시 정책에서 선택적으로 처리한다. - 이미 실행 중인 Pi session에는 `/reload` 또는 재시작 후 extension 변경을 반영한다. From c58fbac0d553fdf0e351367f8287acab061f2626 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 10 Jul 2026 15:35:26 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(agent-client):=20Claude=20=EB=B0=8F=20?= =?UTF-8?q?Pi=20=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= =?UTF-8?q?=EC=99=80=20README=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent-client/claude/README.md | 30 + agent-client/claude/install.sh | 183 +++++++ agent-client/claude/iop-claude-gateway.py | 635 ++++++++++++++++++++++ agent-client/pi/README.md | 34 ++ agent-client/pi/install.sh | 222 ++++++++ 5 files changed, 1104 insertions(+) create mode 100644 agent-client/claude/README.md create mode 100755 agent-client/claude/install.sh create mode 100755 agent-client/claude/iop-claude-gateway.py create mode 100644 agent-client/pi/README.md create mode 100755 agent-client/pi/install.sh diff --git a/agent-client/claude/README.md b/agent-client/claude/README.md new file mode 100644 index 0000000..fc87d68 --- /dev/null +++ b/agent-client/claude/README.md @@ -0,0 +1,30 @@ +# Claude Agent Client + +기존 Seulgivibe Claude 설정이 되어 있는 macOS 환경에서 Claude TUI `/model`에 아래 모델을 함께 노출한다. + +```text +claude-seulgivibe-sonnet-4-5 +claude-seulgivibe-opus-4-8 +claude-seulgivibe-mythos-5 +claude-dev-corp-gemma4-26b +``` + +## 설치 + +```bash +cd agent-client/claude +./install.sh +``` + +설치 후 Claude TUI를 새로 열고 `/model`에서 모델을 선택한다. + +## 전제 + +- `~/.claude/anthropic_key.sh`가 이미 설정되어 있어야 한다. +- dev-corp 토큰은 있으면 아래 순서로 사용한다. + - `~/.claude/dev-corp-openai-key.sh` + - `~/.claude/dev-corp-openai-api-key` + - `~/.pi/agent/dev-corp-openai-api-key` + - 없으면 `~/.claude/anthropic_key.sh` 출력값을 fallback으로 사용한다. + +JWT/API key 원문은 이 폴더에 포함하지 않는다. diff --git a/agent-client/claude/install.sh b/agent-client/claude/install.sh new file mode 100755 index 0000000..84d346d --- /dev/null +++ b/agent-client/claude/install.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLAUDE_DIR="$HOME/.claude" +SETTINGS="$CLAUDE_DIR/settings.json" +GATEWAY="$CLAUDE_DIR/iop-claude-gateway.py" +DEV_CORP_KEY_HELPER="$CLAUDE_DIR/dev-corp-openai-key.sh" +LABEL="com.iop.agent-client.claude" +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +PYTHON_BIN="/usr/bin/python3" +UID_VALUE="$(id -u)" + +if [[ ! -x "$PYTHON_BIN" ]]; then + PYTHON_BIN="$(command -v python3 || true)" +fi + +if [[ -z "$PYTHON_BIN" || ! -x "$PYTHON_BIN" ]]; then + echo "python3 를 찾을 수 없습니다." + exit 1 +fi + +if [[ ! -x "$CLAUDE_DIR/anthropic_key.sh" ]]; then + echo "~/.claude/anthropic_key.sh 설정이 먼저 필요합니다." + exit 1 +fi + +mkdir -p "$CLAUDE_DIR/cache" "$HOME/Library/LaunchAgents" + +OLD_LABELS=( + "$LABEL" + "com.seulgivibe.claude.models" + "com.seulgivibe.claude.anthropic-proxy" + "com.leedongmyung.claude.seulgivibe-anthropic-proxy" +) + +for old_label in "${OLD_LABELS[@]}"; do + launchctl bootout "gui/$UID_VALUE/$old_label" >/dev/null 2>&1 || true +done + +OLD_PLISTS=( + "$PLIST" + "$HOME/Library/LaunchAgents/com.seulgivibe.claude.models.plist" + "$HOME/Library/LaunchAgents/com.seulgivibe.claude.anthropic-proxy.plist" + "$HOME/Library/LaunchAgents/com.leedongmyung.claude.seulgivibe-anthropic-proxy.plist" +) + +for old_plist in "${OLD_PLISTS[@]}"; do + rm -f "$old_plist" +done + +for old_process in \ + "$CLAUDE_DIR/iop-claude-gateway.py" \ + "$CLAUDE_DIR/seulgivibe-claude-gateway.py" \ + "$CLAUDE_DIR/dev-corp-claude-gateway.py" \ + "$CLAUDE_DIR/seulgivibe-anthropic-proxy.mjs" +do + pids="$(pgrep -f "$old_process" || true)" + if [[ -n "$pids" ]]; then + while IFS= read -r pid; do + [[ -n "$pid" && "$pid" != "$$" ]] && kill "$pid" >/dev/null 2>&1 || true + done <<< "$pids" + fi +done + +rm -f "$CLAUDE_DIR/seulgivibe-anthropic-proxy.mjs" +install -m 755 "$SCRIPT_DIR/iop-claude-gateway.py" "$GATEWAY" + +if [[ ! -x "$DEV_CORP_KEY_HELPER" ]]; then + cat > "$DEV_CORP_KEY_HELPER" <<'EOF' +#!/bin/sh +set -eu + +if [ -f "$HOME/.claude/dev-corp-openai-api-key" ]; then + tr -d '\n' < "$HOME/.claude/dev-corp-openai-api-key" + exit 0 +fi + +if [ -f "$HOME/.pi/agent/dev-corp-openai-api-key" ]; then + tr -d '\n' < "$HOME/.pi/agent/dev-corp-openai-api-key" + exit 0 +fi + +exec "$HOME/.claude/anthropic_key.sh" +EOF + chmod 755 "$DEV_CORP_KEY_HELPER" +fi + +"$PYTHON_BIN" - "$SETTINGS" "$CLAUDE_DIR/cache/gateway-models.json" <<'PY' +import json +import pathlib +import sys +import time + +settings_path = pathlib.Path(sys.argv[1]) +cache_path = pathlib.Path(sys.argv[2]) +settings_path.parent.mkdir(parents=True, exist_ok=True) +cache_path.parent.mkdir(parents=True, exist_ok=True) + +settings = {} +if settings_path.exists(): + backup = settings_path.with_name(f"{settings_path.name}.bak-{time.strftime('%Y%m%d%H%M%S')}") + backup.write_bytes(settings_path.read_bytes()) + settings = json.loads(settings_path.read_text() or "{}") + +models = [ + "claude-seulgivibe-sonnet-4-5", + "claude-seulgivibe-opus-4-8", + "claude-seulgivibe-mythos-5", + "claude-dev-corp-gemma4-26b", +] + +settings["apiKeyHelper"] = settings.get("apiKeyHelper") or "~/.claude/anthropic_key.sh" +env = settings.setdefault("env", {}) +env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:18443/anthropic" +env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0" +env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1" +for key in [ + "ANTHROPIC_CUSTOM_MODEL_OPTION", + "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME", + "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION", + "ANTHROPIC_DEFAULT_FABLE_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", + "ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION", +]: + env.pop(key, None) + +if settings.get("model") not in models: + settings["model"] = "claude-seulgivibe-mythos-5" +settings["availableModels"] = models +settings["enforceAvailableModels"] = True +settings.setdefault("effortLevel", "xhigh") +settings.setdefault("alwaysThinkingEnabled", True) + +settings_path.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n") + +cache = { + "baseUrl": "http://127.0.0.1:18443/anthropic", + "fetchedAt": int(time.time() * 1000), + "models": [ + {"id": "claude-seulgivibe-sonnet-4-5", "display_name": "Seulgivibe Sonnet 4.5"}, + {"id": "claude-seulgivibe-opus-4-8", "display_name": "Seulgivibe Opus 4.8"}, + {"id": "claude-seulgivibe-mythos-5", "display_name": "Seulgivibe Fable 5"}, + {"id": "claude-dev-corp-gemma4-26b", "display_name": "dev-corp gemma4:26b"}, + ], +} +cache_path.write_text(json.dumps(cache, ensure_ascii=False, separators=(",", ":")) + "\n") +PY + +cat > "$PLIST" < + + + + Label + $LABEL + ProgramArguments + + $PYTHON_BIN + $GATEWAY + + RunAtLoad + + KeepAlive + + StandardOutPath + $CLAUDE_DIR/iop-claude-gateway.out.log + StandardErrorPath + $CLAUDE_DIR/iop-claude-gateway.err.log + + +EOF + +launchctl bootstrap "gui/$UID_VALUE" "$PLIST" +launchctl kickstart -k "gui/$UID_VALUE/$LABEL" + +for _ in 1 2 3 4 5; do + curl -fsS "http://127.0.0.1:18443/health" >/dev/null 2>&1 && break + sleep 1 +done + +curl -fsS "http://127.0.0.1:18443/anthropic/v1/models" >/dev/null +echo "완료: Claude TUI를 새로 열고 /model 에서 모델을 선택하세요." diff --git a/agent-client/claude/iop-claude-gateway.py b/agent-client/claude/iop-claude-gateway.py new file mode 100755 index 0000000..3a0cd64 --- /dev/null +++ b/agent-client/claude/iop-claude-gateway.py @@ -0,0 +1,635 @@ +#!/usr/bin/env python3 +import http.client +import json +import os +import ssl +import subprocess +import time +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + + +HOST = "127.0.0.1" +PORT = int(os.environ.get("IOP_CLAUDE_GATEWAY_PORT", "18443")) + +SEULGIVIBE_HOST = os.environ.get("SEULGIVIBE_HOST", "seulgivibe.lgudax.cool") +SEULGIVIBE_PREFIX = os.environ.get("SEULGIVIBE_ANTHROPIC_PREFIX", "/anthropic") +SEULGIVIBE_KEY_HELPER = os.path.expanduser("~/.claude/anthropic_key.sh") + +DEV_CORP_OPENAI_BASE_URL = os.environ.get( + "DEV_CORP_OPENAI_BASE_URL", + "http://digitalplatform-iop.cloud:18086/v1", +).rstrip("/") +DEV_CORP_KEY_HELPER = os.path.expanduser("~/.claude/dev-corp-openai-key.sh") +DEV_CORP_TOKEN_FILES = [ + os.path.expanduser("~/.claude/dev-corp-openai-api-key"), + os.path.expanduser("~/.pi/agent/dev-corp-openai-api-key"), +] +DEV_CORP_MODEL = "claude-dev-corp-gemma4-26b" +DEV_CORP_UPSTREAM_MODEL = "gemma4:26b" +DEV_CORP_MIN_MAX_TOKENS = int(os.environ.get("DEV_CORP_UPSTREAM_MIN_MAX_TOKENS", "1024")) + +SEULGIVIBE_ALIASES = { + "claude-seulgivibe-sonnet-4-5": "claude-sonnet-4-5-20250929", + "claude-seulgivibe-opus-4-8": "claude-opus-4-8", + "claude-seulgivibe-mythos-5": "claude-fable-5", +} + +MODELS = [ + { + "id": "claude-seulgivibe-sonnet-4-5", + "type": "model", + "display_name": "Seulgivibe Sonnet 4.5", + "description": "Seulgivibe Sonnet 4.5", + "created_at": "2026-05-13T00:00:00Z", + }, + { + "id": "claude-seulgivibe-opus-4-8", + "type": "model", + "display_name": "Seulgivibe Opus 4.8", + "description": "Seulgivibe Opus 4.8", + "created_at": "2026-05-13T00:00:00Z", + }, + { + "id": "claude-seulgivibe-mythos-5", + "type": "model", + "display_name": "Seulgivibe Fable 5", + "description": "Fable 5 · Most capable for your hardest and longest-running tasks · $10/$50 per Mtok", + "created_at": "2026-05-13T00:00:00Z", + }, + { + "id": DEV_CORP_MODEL, + "type": "model", + "display_name": "dev-corp gemma4:26b", + "description": "dev-corp gemma4:26b via OpenAI-compatible gateway", + "created_at": "2026-07-10T00:00:00Z", + }, +] + + +def is_record(value): + return isinstance(value, dict) + + +def content_to_text(content): + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif is_record(item): + item_type = item.get("type") + if item_type in ("text", "input_text", "output_text"): + text = item.get("text") + if isinstance(text, str): + parts.append(text) + elif item_type == "tool_result": + text = content_to_text(item.get("content")) + if text: + parts.append(text) + return "\n".join(parts) + return "" + + +def normalize_system(system_value): + if isinstance(system_value, str): + return system_value + if isinstance(system_value, list): + return content_to_text(system_value) + return "" + + +def normalize_system_messages(payload): + messages = payload.get("messages") + if not isinstance(messages, list): + return + + system_parts = [] + regular_messages = [] + for message in messages: + if is_record(message) and message.get("role") == "system": + text = content_to_text(message.get("content")) + if text: + system_parts.append(text) + else: + regular_messages.append(message) + + if not system_parts: + return + + existing = normalize_system(payload.get("system")) + payload["system"] = "\n\n".join(part for part in [existing, *system_parts] if part) + payload["messages"] = regular_messages + + +def read_helper(path): + if not os.path.exists(path): + return "" + try: + result = subprocess.run( + [path], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=10, + ) + except Exception: + return "" + return result.stdout.strip() + + +def read_token_file(path): + try: + with open(path, "r", encoding="utf-8") as handle: + return handle.read().strip() + except Exception: + return "" + + +def inbound_token(headers): + for key in ("authorization", "x-api-key", "api-key"): + value = headers.get(key) + if not value: + continue + value = value.strip() + if value.lower().startswith("bearer "): + return value[7:].strip() + return value + return "" + + +def seulgivibe_token(headers): + return inbound_token(headers) or read_helper(SEULGIVIBE_KEY_HELPER) + + +def dev_corp_token(headers): + token = read_helper(DEV_CORP_KEY_HELPER) + if token: + return token + for path in DEV_CORP_TOKEN_FILES: + token = read_token_file(path) + if token: + return token + return inbound_token(headers) or read_helper(SEULGIVIBE_KEY_HELPER) + + +def seulgivibe_path(raw_path): + if raw_path.startswith(f"{SEULGIVIBE_PREFIX}/"): + return raw_path + if raw_path.startswith("/v1/"): + return f"{SEULGIVIBE_PREFIX}{raw_path}" + return raw_path + + +def call_seulgivibe(method, raw_path, payload, inbound_headers): + outbound_payload = dict(payload) + model = outbound_payload.get("model") + if model in SEULGIVIBE_ALIASES: + normalize_system_messages(outbound_payload) + outbound_payload["model"] = SEULGIVIBE_ALIASES[model] + + body = json.dumps(outbound_payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + headers = { + "content-type": "application/json", + "accept": inbound_headers.get("accept", "application/json, text/event-stream"), + "content-length": str(len(body)), + } + for key in ("anthropic-version", "anthropic-beta"): + if inbound_headers.get(key): + headers[key] = inbound_headers[key] + token = seulgivibe_token(inbound_headers) + if token: + headers["authorization"] = f"Bearer {token}" + + context = ssl._create_unverified_context() + conn = http.client.HTTPSConnection(SEULGIVIBE_HOST, 443, context=context, timeout=300) + try: + conn.request(method, seulgivibe_path(raw_path), body=body, headers=headers) + response = conn.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + conn.close() + + +def unwrap_json_string_body(body): + try: + value = json.loads(body.decode("utf-8")) + except Exception: + return body + if not isinstance(value, str): + return body + stripped = value.strip() + if not stripped.startswith(("{", "[")): + return body + return stripped.encode("utf-8") + + +def anthropic_messages_to_openai(payload): + messages = [] + system_text = normalize_system(payload.get("system")) + if system_text: + messages.append({"role": "system", "content": system_text}) + + tool_names = {} + for message in payload.get("messages") or []: + if not is_record(message): + continue + role = message.get("role") + content = message.get("content") + + if role == "assistant": + text_parts = [] + tool_calls = [] + blocks = content if isinstance(content, list) else [{"type": "text", "text": content_to_text(content)}] + for block in blocks: + if not is_record(block): + continue + block_type = block.get("type") + if block_type == "text": + text = block.get("text") + if isinstance(text, str) and text: + text_parts.append(text) + elif block_type == "tool_use": + tool_id = str(block.get("id") or f"call_{uuid.uuid4().hex[:16]}") + name = str(block.get("name") or "") + tool_names[tool_id] = name + tool_calls.append( + { + "id": tool_id, + "type": "function", + "function": { + "name": name, + "arguments": json.dumps(block.get("input") or {}, ensure_ascii=False), + }, + } + ) + openai_message = {"role": "assistant", "content": "\n".join(text_parts) or None} + if tool_calls: + openai_message["tool_calls"] = tool_calls + messages.append(openai_message) + continue + + if role == "user": + text_parts = [] + blocks = content if isinstance(content, list) else [{"type": "text", "text": content_to_text(content)}] + for block in blocks: + if is_record(block) and block.get("type") == "tool_result": + tool_call_id = str(block.get("tool_use_id") or "") + tool_message = { + "role": "tool", + "tool_call_id": tool_call_id, + "content": content_to_text(block.get("content")), + } + name = tool_names.get(tool_call_id) + if name: + tool_message["name"] = name + messages.append(tool_message) + else: + text = content_to_text([block]) + if text: + text_parts.append(text) + if text_parts: + messages.append({"role": "user", "content": "\n".join(text_parts)}) + continue + + if role in ("system", "developer"): + text = content_to_text(content) + if text: + messages.append({"role": "system", "content": text}) + + return messages + + +def anthropic_tools_to_openai(payload): + tools = [] + for tool in payload.get("tools") or []: + if not is_record(tool): + continue + name = tool.get("name") + if not isinstance(name, str) or not name: + continue + parameters = tool.get("input_schema") + if not is_record(parameters): + parameters = {"type": "object", "properties": {}} + tools.append( + { + "type": "function", + "function": { + "name": name, + "description": tool.get("description") or "", + "parameters": parameters, + }, + } + ) + return tools + + +def build_openai_payload(payload): + body = { + "model": DEV_CORP_UPSTREAM_MODEL, + "messages": anthropic_messages_to_openai(payload), + "temperature": payload.get("temperature", 0.2), + "top_p": payload.get("top_p", 0.95), + "stream": False, + } + max_tokens = payload.get("max_tokens") + if isinstance(max_tokens, int) and max_tokens > 0: + body["max_tokens"] = max(max_tokens, DEV_CORP_MIN_MAX_TOKENS) + else: + body["max_tokens"] = DEV_CORP_MIN_MAX_TOKENS + tools = anthropic_tools_to_openai(payload) + if tools: + body["tools"] = tools + return body + + +def parse_openai_url(path): + parsed = urlsplit(DEV_CORP_OPENAI_BASE_URL) + scheme = parsed.scheme or "http" + host = parsed.hostname or "" + port = parsed.port + prefix = parsed.path.rstrip("/") + return scheme, host, port, f"{prefix}{path}" + + +def call_dev_corp(openai_payload, inbound_headers): + scheme, host, port, path = parse_openai_url("/chat/completions") + body = json.dumps(openai_payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + headers = { + "content-type": "application/json", + "accept": "application/json", + "content-length": str(len(body)), + } + token = dev_corp_token(inbound_headers) + if token: + headers["authorization"] = f"Bearer {token}" + + if scheme == "https": + context = ssl._create_unverified_context() + conn = http.client.HTTPSConnection(host, port or 443, context=context, timeout=300) + else: + conn = http.client.HTTPConnection(host, port or 80, timeout=300) + try: + conn.request("POST", path, body=body, headers=headers) + response = conn.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + conn.close() + + +def parse_tool_arguments(raw): + if not isinstance(raw, str) or raw == "": + return {} + try: + value = json.loads(raw) + except Exception: + return {"raw": raw} + return value if is_record(value) else {"value": value} + + +def openai_to_anthropic(openai_payload, requested_model): + choice = (openai_payload.get("choices") or [{}])[0] + message = choice.get("message") or {} + finish_reason = choice.get("finish_reason") + content = [] + + text = message.get("content") + if isinstance(text, str) and text: + content.append({"type": "text", "text": text}) + + tool_calls = message.get("tool_calls") or [] + for tool_call in tool_calls: + if not is_record(tool_call): + continue + function = tool_call.get("function") or {} + content.append( + { + "type": "tool_use", + "id": str(tool_call.get("id") or f"call_{uuid.uuid4().hex[:16]}"), + "name": str(function.get("name") or ""), + "input": parse_tool_arguments(function.get("arguments")), + } + ) + + usage = openai_payload.get("usage") or {} + if tool_calls: + stop_reason = "tool_use" + elif finish_reason == "length": + stop_reason = "max_tokens" + else: + stop_reason = "end_turn" + + return { + "id": openai_payload.get("id") or f"msg_{uuid.uuid4().hex}", + "type": "message", + "role": "assistant", + "model": requested_model or DEV_CORP_MODEL, + "content": content or [{"type": "text", "text": ""}], + "stop_reason": stop_reason, + "stop_sequence": None, + "usage": { + "input_tokens": int(usage.get("prompt_tokens") or 0), + "output_tokens": int(usage.get("completion_tokens") or 0), + }, + } + + +def estimate_tokens(payload): + text = normalize_system(payload.get("system")) + for message in payload.get("messages") or []: + if is_record(message): + text += "\n" + content_to_text(message.get("content")) + return max(1, len(text) // 4) + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *_): + return + + def read_json(self): + length = int(self.headers.get("content-length", "0") or "0") + body = self.rfile.read(length) if length > 0 else b"{}" + return json.loads(body.decode("utf-8") or "{}") + + def write_json(self, status, payload): + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.send_header("connection", "close") + self.end_headers() + self.wfile.write(body) + self.close_connection = True + + def write_raw(self, status, headers, body): + self.send_response(status) + for key, value in headers.items(): + lower_key = key.lower() + if lower_key in ("connection", "transfer-encoding", "content-length"): + continue + self.send_header(key, value) + self.send_header("content-length", str(len(body))) + self.send_header("connection", "close") + self.end_headers() + self.wfile.write(body) + self.close_connection = True + + def write_sse(self, event, payload): + self.wfile.write(f"event: {event}\n".encode("utf-8")) + self.wfile.write(b"data: ") + self.wfile.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + self.wfile.write(b"\n\n") + self.wfile.flush() + + def write_anthropic_stream(self, message): + self.send_response(200) + self.send_header("content-type", "text/event-stream") + self.send_header("cache-control", "no-cache") + self.send_header("connection", "close") + self.end_headers() + + self.write_sse( + "message_start", + { + "type": "message_start", + "message": { + "id": message["id"], + "type": "message", + "role": "assistant", + "model": message["model"], + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": message["usage"]["input_tokens"], "output_tokens": 0}, + }, + }, + ) + for index, block in enumerate(message["content"]): + if block.get("type") == "tool_use": + start_block = { + "type": "tool_use", + "id": block.get("id"), + "name": block.get("name"), + "input": {}, + } + self.write_sse( + "content_block_start", + {"type": "content_block_start", "index": index, "content_block": start_block}, + ) + partial_json = json.dumps(block.get("input") or {}, ensure_ascii=False, separators=(",", ":")) + if partial_json: + self.write_sse( + "content_block_delta", + { + "type": "content_block_delta", + "index": index, + "delta": {"type": "input_json_delta", "partial_json": partial_json}, + }, + ) + else: + self.write_sse( + "content_block_start", + {"type": "content_block_start", "index": index, "content_block": {"type": "text", "text": ""}}, + ) + text = block.get("text") or "" + if text: + self.write_sse( + "content_block_delta", + { + "type": "content_block_delta", + "index": index, + "delta": {"type": "text_delta", "text": text}, + }, + ) + self.write_sse("content_block_stop", {"type": "content_block_stop", "index": index}) + + self.write_sse( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": message["stop_reason"], "stop_sequence": None}, + "usage": {"output_tokens": message["usage"]["output_tokens"]}, + }, + ) + self.write_sse("message_stop", {"type": "message_stop"}) + self.close_connection = True + + def do_GET(self): + path = urlsplit(self.path).path + if path == "/health": + self.write_json(200, {"ok": True, "models": [model["id"] for model in MODELS]}) + return + if path in ("/v1/models", "/anthropic/v1/models"): + self.write_json(200, {"data": MODELS, "first_id": MODELS[0]["id"], "last_id": MODELS[-1]["id"], "has_more": False}) + return + self.write_json(404, {"type": "error", "error": {"type": "not_found_error", "message": path}}) + + def do_POST(self): + path = urlsplit(self.path).path + is_count_tokens = path in ("/v1/messages/count_tokens", "/anthropic/v1/messages/count_tokens") + is_messages = path in ("/v1/messages", "/anthropic/v1/messages") + if not is_count_tokens and not is_messages: + self.write_json(404, {"type": "error", "error": {"type": "not_found_error", "message": path}}) + return + + try: + payload = self.read_json() + except Exception as exc: + self.write_json(400, {"type": "error", "error": {"type": "invalid_request_error", "message": str(exc)}}) + return + + headers = {key.lower(): value for key, value in self.headers.items()} + if payload.get("model") == DEV_CORP_MODEL: + if is_count_tokens: + self.write_json(200, {"input_tokens": estimate_tokens(payload)}) + return + openai_payload = build_openai_payload(payload) + status, _, body = call_dev_corp(openai_payload, headers) + if status < 200 or status >= 300: + try: + detail = json.loads(body.decode("utf-8")) + except Exception: + detail = body.decode("utf-8", "replace") + self.write_json( + 502, + { + "type": "error", + "error": { + "type": "upstream_error", + "message": f"dev-corp upstream returned HTTP {status}", + "detail": detail, + }, + }, + ) + return + try: + openai_response = json.loads(body.decode("utf-8")) + except Exception as exc: + self.write_json(502, {"type": "error", "error": {"type": "upstream_parse_error", "message": str(exc)}}) + return + message = openai_to_anthropic(openai_response, payload.get("model")) + if payload.get("stream") is True: + self.write_anthropic_stream(message) + else: + self.write_json(200, message) + return + + status, response_headers, body = call_seulgivibe(self.command, path, payload, headers) + if 200 <= status < 300: + body = unwrap_json_string_body(body) + self.write_raw(status, response_headers, body) + + +class Server(ThreadingHTTPServer): + allow_reuse_address = True + + +if __name__ == "__main__": + print(f"iop claude gateway listening on http://{HOST}:{PORT} at {time.strftime('%Y-%m-%d %H:%M:%S')}", flush=True) + Server((HOST, PORT), Handler).serve_forever() diff --git a/agent-client/pi/README.md b/agent-client/pi/README.md new file mode 100644 index 0000000..e5a4719 --- /dev/null +++ b/agent-client/pi/README.md @@ -0,0 +1,34 @@ +# Pi Agent Client + +Pi Coding Agent에 Seulgivibe Codex/OpenAI 모델과 Claude 모델을 함께 등록한다. + +등록 대상: + +```text +seulgivibe-codex/gpt-5.1:medium +seulgivibe-codex/gpt-5.5:xhigh +seulgivibe-claude/claude-sonnet-4-5 +seulgivibe-claude/claude-opus-4-8 +seulgivibe-claude/claude-fable-5 +``` + +## 설치 + +```bash +cd agent-client/pi +./install.sh +``` + +설치 후 이미 실행 중인 Pi session은 `/reload` 하거나 재시작한다. + +새 설정 파일을 만드는 환경에서는 기본 모델을 `seulgivibe-codex/gpt-5.5`로 둔다. 기존 기본값이 있으면 보존한다. + +## 전제 + +- Pi는 이미 설치되어 있어야 한다. +- 기존 `~/.pi/agent/settings.json`, `~/.pi/agent/models.json`의 개인 설정과 API key는 보존한다. +- Seulgivibe key가 없으면 `~/.claude/anthropic_key.sh`에서 읽어 넣는다. +- 기존 `seulgivibe-openai` 설정은 `seulgivibe-codex`로 이관한 뒤 제거한다. +- 기존 dev-corp 설정이 있으면 지우지 않고 보존한다. + +JWT/API key 원문은 이 폴더에 포함하지 않는다. diff --git a/agent-client/pi/install.sh b/agent-client/pi/install.sh new file mode 100755 index 0000000..25ad205 --- /dev/null +++ b/agent-client/pi/install.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -euo pipefail + +PI_DIR="$HOME/.pi/agent" +SETTINGS="$PI_DIR/settings.json" +MODELS="$PI_DIR/models.json" +PYTHON_BIN="/usr/bin/python3" + +if [[ ! -x "$PYTHON_BIN" ]]; then + PYTHON_BIN="$(command -v python3 || true)" +fi + +if [[ -z "$PYTHON_BIN" || ! -x "$PYTHON_BIN" ]]; then + echo "python3 를 찾을 수 없습니다." + exit 1 +fi + +mkdir -p "$PI_DIR" + +"$PYTHON_BIN" - "$SETTINGS" "$MODELS" <<'PY' +import json +import pathlib +import subprocess +import sys +import time + +settings_path = pathlib.Path(sys.argv[1]).expanduser() +models_path = pathlib.Path(sys.argv[2]).expanduser() + + +def read_json(path): + if not path.exists(): + return {} + return json.loads(path.read_text() or "{}") + + +def write_json(path, value): + if path.exists(): + backup = path.with_name(f"{path.name}.bak-{time.strftime('%Y%m%d%H%M%S')}") + backup.write_bytes(path.read_bytes()) + path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n") + + +def run_helper(path): + expanded = pathlib.Path(path).expanduser() + if not expanded.exists(): + return "" + try: + result = subprocess.run( + [str(expanded)], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=10, + ) + except Exception: + return "" + return result.stdout.strip() + + +def merge_desired_models(existing_models, desired_models): + existing_by_id = {} + for existing in existing_models: + if isinstance(existing, dict) and isinstance(existing.get("id"), str): + existing_by_id.setdefault(existing["id"], existing) + + merged_models = [] + for desired in desired_models: + merged = dict(existing_by_id.get(desired["id"], {})) + merged.update(desired) + merged_models.append(merged) + return merged_models + + +def zero_cost(): + return {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0} + + +settings = read_json(settings_path) +models_doc = read_json(models_path) + +providers = models_doc.setdefault("providers", {}) + +seulgivibe_provider = providers.setdefault("seulgivibe-claude", {}) +seulgivibe_api_key = seulgivibe_provider.get("apiKey") or run_helper("~/.claude/anthropic_key.sh") +seulgivibe_provider.update( + { + "baseUrl": "https://seulgivibe.lgudax.cool/anthropic", + "api": "anthropic-messages", + "authHeader": True, + "compat": {"supportsEagerToolInputStreaming": False}, + } +) +if seulgivibe_api_key: + seulgivibe_provider["apiKey"] = seulgivibe_api_key +seulgivibe_provider["models"] = merge_desired_models( + seulgivibe_provider.get("models", []), + [ + { + "id": "claude-sonnet-4-5", + "name": "Seulgivibe Claude Sonnet 4.5", + "reasoning": True, + "input": ["text"], + "contextWindow": 200000, + "maxTokens": 64000, + "cost": zero_cost(), + }, + { + "id": "claude-opus-4-8", + "name": "Seulgivibe Claude Opus 4.8", + "reasoning": True, + "thinkingLevelMap": {"xhigh": "xhigh"}, + "input": ["text"], + "contextWindow": 1000000, + "maxTokens": 128000, + "compat": {"forceAdaptiveThinking": True, "supportsTemperature": False}, + "cost": zero_cost(), + }, + { + "id": "claude-fable-5", + "name": "Seulgivibe Claude Fable 5", + "reasoning": True, + "thinkingLevelMap": {"xhigh": "xhigh"}, + "input": ["text"], + "contextWindow": 1000000, + "maxTokens": 128000, + "compat": {"forceAdaptiveThinking": True, "supportsTemperature": False}, + "cost": zero_cost(), + }, + ], +) + +legacy_openai_provider = providers.get("seulgivibe-openai") +if not isinstance(legacy_openai_provider, dict): + legacy_openai_provider = {} + +seulgivibe_codex_provider = providers.setdefault("seulgivibe-codex", {}) +seulgivibe_codex_api_key = ( + seulgivibe_codex_provider.get("apiKey") + or legacy_openai_provider.get("apiKey") + or run_helper("~/.claude/anthropic_key.sh") +) +seulgivibe_codex_provider.update( + { + "baseUrl": "https://seulgivibe.lgudax.cool/openai/v1", + "api": "openai-responses", + } +) +if seulgivibe_codex_api_key: + seulgivibe_codex_provider["apiKey"] = seulgivibe_codex_api_key +seulgivibe_codex_provider["models"] = merge_desired_models( + seulgivibe_codex_provider.get("models", []), + [ + { + "id": "gpt-5.1", + "name": "Seulgivibe Codex GPT-5.1", + "reasoning": True, + "thinkingLevelMap": {"off": "none"}, + "input": ["text"], + "contextWindow": 400000, + "maxTokens": 128000, + "cost": zero_cost(), + }, + { + "id": "gpt-5.5", + "name": "Seulgivibe Codex GPT-5.5", + "reasoning": True, + "thinkingLevelMap": {"off": "none", "xhigh": "xhigh", "minimal": None}, + "input": ["text"], + "contextWindow": 272000, + "maxTokens": 128000, + "cost": zero_cost(), + }, + ], +) +providers.pop("seulgivibe-openai", None) + +settings.setdefault("defaultProvider", "seulgivibe-codex") +settings.setdefault("defaultModel", "gpt-5.5") +settings.setdefault("defaultThinkingLevel", "high") +settings.setdefault("hideThinkingBlock", False) +settings["httpIdleTimeoutMs"] = 300000 +retry = settings.setdefault("retry", {}) +provider_retry = retry.setdefault("provider", {}) +provider_retry["timeoutMs"] = 300000 +provider_retry["maxRetries"] = 0 +provider_retry["maxRetryDelayMs"] = 60000 + +enabled = settings.setdefault("enabledModels", []) +if not isinstance(enabled, list): + enabled = [] +desired_enabled = [ + "seulgivibe-codex/gpt-5.1:medium", + "seulgivibe-codex/gpt-5.5:xhigh", + "seulgivibe-claude/claude-sonnet-4-5", + "seulgivibe-claude/claude-opus-4-8", + "seulgivibe-claude/claude-fable-5", +] +normalized_enabled = [] +seen_enabled = set() +for item in enabled: + if not isinstance(item, str): + continue + if item.startswith("seulgivibe-openai/"): + continue + if item.startswith("seulgivibe-codex/") or item.startswith("seulgivibe-claude/"): + continue + if item not in seen_enabled: + normalized_enabled.append(item) + seen_enabled.add(item) +for item in desired_enabled: + if item not in seen_enabled: + normalized_enabled.append(item) + seen_enabled.add(item) +settings["enabledModels"] = normalized_enabled + +write_json(settings_path, settings) +write_json(models_path, models_doc) +PY + +echo "완료: Pi session에서 /reload 하거나 Pi를 재시작하세요."