Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
6a54d62837
10 changed files with 1191 additions and 118 deletions
|
|
@ -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 기반 완료<br>Ollama provider 기반 OpenAI-compatible chat completions 경로 안정화<br>Control Plane과 Flutter client를 통한 Edge/Node 운영 관찰 기반 구축<br>CLI Automation Runtime, persistent terminal, Codex/App Server 계열 실행 경로 정리<br>Domain Agent registry, bootstrap command, enrollment 경계 정리<br>OpenAI Responses input surface와 `metadata.workspace` 기반 workspace-bound agent execution contract 완료<br>Workspace port/env 표준화로 Control Plane, Edge, Node, Client, OpenAI-compatible, A2A, wire, metrics, DB/cache 포트 기준 정렬<br>Node 단일 연결에서 여러 target/provider/model candidate를 다루는 multi-target serving 기반 완료<br>Provider availability, capacity, admission queue snapshot 기반 완료<br>Lemonade provider의 모델 조회, non-streaming/streaming chat 경로 검증 완료<br>Edge model group queue scheduling과 model route queue 정책 정합화 완료 |
|
||||
| 완료된 기능 | Edge-Node 실행 기반<br>Ollama 서빙 안정화 기반<br>Control Plane과 Client 운영 기반<br>CLI Automation Runtime 안정화<br>Domain Agent Registry와 Bootstrap Command 발급<br>OpenAI-compatible Responses 입력 표면<br>OpenAI Workspace Agent Execution Contract<br>Workspace port/env 표준화<br>Node multi-target serving 기반<br>Provider availability와 capacity queue 기반<br>Lemonade provider serving 검증<br>Edge model group queue scheduling MVP<br>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 기반 완료<br>Edge model group queue scheduling과 같은 Edge 내부의 다중 Node 후보 순차 dispatch 기반 완료<br>OpenAI-compatible `model` route queue 정책 정합화 완료<br>vLLM provider serving validation 완료: provider boundary, DGX Spark vLLM container, config contract, 모델 조회/chat, streaming, split-host field smoke 검증 PASS 근거와 코드 레벨 완료 리뷰까지 정리하고 archive함<br>SGLang provider serving validation은 GX10 + Qwen3.6 35B NVFP4 field validation 결과 NVIDIA checkpoint 직접 로딩 실패와 vLLM 대비 낮은 throughput/KV 여유가 확인되어 현 조건에서 provider 채택을 폐기함<br>다음 활성 작업은 운영 관측 Phase의 `Model Alias Provider Pool과 Provider Catalog` 계획 마일스톤으로 전환됨 |
|
||||
| 주요 작업 | 완료: OpenAI-compatible `model` 값을 queue group key로 사용하는 Edge-owned FIFO 정리<br>완료: Node 후보별 capacity, in-flight, queued snapshot을 기준으로 dispatch 흐름 정리<br>완료: Model route Queue 정책 원천 정합화<br>완료: vLLM OpenAI-compatible provider의 config contract, 모델 조회, chat, streaming, split-host field smoke 검증<br>정리: SGLang provider는 현 field 조건에서 채택하지 않고 Provider Catalog/Qualification 후속 판단 후보로 분리<br>다음: `models[]` 기반 provider pool과 read-only Provider Catalog 계약 구현 계획 |
|
||||
| 완성 예정 기능 | vLLM provider 최소 serving 검증 완료<br>SGLang provider field 판단 결과 정리<br>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의 기본 상태 관찰 경로 구축<br>Provider availability와 capacity snapshot 기반 완료<br>`Model Alias Provider Pool과 Provider Catalog`가 현재 활성 계획 마일스톤이며 SDD는 승인됨/잠금 해제 상태<br>사용자/토큰/사용량/로그 추적과 Provider-Device-Model Qualification 리포트는 후속 스케치 상태 |
|
||||
| 주요 작업 | `models[]`를 OpenAI-compatible model key이자 provider pool canonical routing key로 정리<br>`nodes[].providers[]` 기반 provider 정의와 provider별 served model list 계약 정리<br>Edge-owned target rewrite와 `in_flight / capacity` load-ratio provider 선택 정책 구현 계획<br>API, CLI, local inference provider를 구분하는 read-only Provider Catalog 기준 정리<br>사용자 단위 API/CLI/local inference 사용량, token, request, execution 로그의 최소 수집 항목은 후속 MVP로 구체화 |
|
||||
| 완성 예정 기능 | Model Alias Provider Pool과 Provider Catalog MVP<br>Provider Catalog와 로컬 디바이스 상태 관리 MVP<br>사용량, 토큰, 로그 운영 추적 MVP<br>운영자가 확인할 수 있는 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 계열 기반 마련<br>OpenAI Workspace Agent Execution Contract 완료<br>알림 자체는 `nexo`의 messaging/notification 기반을 활용할 수 있는 상태<br>socket/protocol 기반은 `proto-socket`의 안정화 결과를 활용할 수 있는 상태<br>CLI Agent 알림/자동 이어받기, 원격 작업 환경, Update Plane, 단계 호출 검증 MVP는 스케치/계획 상태이며 사용자 검토 후 구체화 |
|
||||
| 주요 작업 | CLI Agent limit 도달 이벤트와 사용자 알림 trigger 기준 정리<br>자동 이어받기 허용 조건, 중단 조건, 사용자 승인 경계 정리<br>workspace-bound execution을 이용한 원격 코딩/유지보수 작업 환경의 최소 운영 흐름 정리<br>Control Plane release view, Edge/Node update protocol, host-local manager, rollout/recovery 정책 스케치<br>요청 의도 분석, 실제 작업, 검증/schema 강제, 오류 시 retry/fallback의 최소 단계 실행 흐름 정리<br>Nexo 알림 기반과 IOP 실행 이벤트의 연동 경계 정리 |
|
||||
| 완성 예정 기능 | CLI Agent 사용량 알림 MVP<br>CLI Agent 자동 이어받기 MVP<br>원격 코딩/유지보수 작업 환경 MVP<br>Update Plane 안정 프로토콜과 자체 업데이트 기반 스케치<br>단계 호출과 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 정리<br>Usage/log/provider status/CLI notification 흐름의 통합 검증<br>Control Plane/Client 운영 표면에서 확인해야 할 최소 상태 정리<br>실패/재시도/중단/복구 시나리오 점검<br>1차 범위와 2차 보류 범위의 문서 경계 정리<br>운영 전환 전 known limitation과 후속 보완 항목 정리 |
|
||||
| 완성 예정 기능 | 1차 KPI 범위 feature MVP 통합 검증 결과<br>운영자 확인용 usage/provider/queue/agent 상태 기준<br>E2E smoke와 회귀 확인 기준<br>10월 이후 고도화 후보와 2차 범위 분리 결과 |
|
||||
|
||||
---
|
||||
|
||||
## 10월 이후: 2차 고도화 후보
|
||||
|
||||
| 구분 | 내용 |
|
||||
| --- | --- |
|
||||
| 목표 | 1차 KPI 범위의 feature MVP를 완료한 뒤, 장기 기억/RAG, advisor, context hook, 원격 터널링, oto scheduler/CI-CD 같은 고도화 항목을 별도 검토합니다. |
|
||||
| 예상 투입인원 | 미정 |
|
||||
| 주요 작업 | 장기 기억과 RAG update cycle 검토<br>Advisor와 local LLM context compression hook 검토<br>특정 Node CLI Agent 원격 터널링 POC 검토<br>oto 자동화 scheduler와 CI-CD 연동 검토<br>Cross-Edge/cloud fallback, 품질 평가 feedback 고도화 검토 |
|
||||
| 완성 예정 기능 | 2차 고도화 후보별 검토 결과<br>1차 운영 결과를 반영한 우선순위 재정렬<br>구체화 가능한 항목의 별도 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차 고도화 후보를 별도로 검토합니다.
|
||||
30
agent-client/claude/README.md
Normal file
30
agent-client/claude/README.md
Normal file
|
|
@ -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 원문은 이 폴더에 포함하지 않는다.
|
||||
183
agent-client/claude/install.sh
Executable file
183
agent-client/claude/install.sh
Executable file
|
|
@ -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" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$PYTHON_BIN</string>
|
||||
<string>$GATEWAY</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$CLAUDE_DIR/iop-claude-gateway.out.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$CLAUDE_DIR/iop-claude-gateway.err.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
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 에서 모델을 선택하세요."
|
||||
635
agent-client/claude/iop-claude-gateway.py
Executable file
635
agent-client/claude/iop-claude-gateway.py
Executable file
|
|
@ -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()
|
||||
34
agent-client/pi/README.md
Normal file
34
agent-client/pi/README.md
Normal file
|
|
@ -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 원문은 이 폴더에 포함하지 않는다.
|
||||
222
agent-client/pi/install.sh
Executable file
222
agent-client/pi/install.sh
Executable file
|
|
@ -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를 재시작하세요."
|
||||
|
|
@ -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`에서 별도로 관리한다.
|
||||
|
||||
노드 후보:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ dev-corp `gemma4:26b` 모델을 OpenAI-compatible 방식으로 직접 호출할
|
|||
## 권장 요청
|
||||
|
||||
기본 권장 호출은 `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 \
|
||||
|
|
@ -30,6 +31,8 @@ curl -N http://digitalplatform-iop.cloud:18086/v1/chat/completions \
|
|||
"content": "간단히 응답해줘."
|
||||
}
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.95,
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
|
@ -40,7 +43,7 @@ Streaming을 처리하지 않는 client에서는 선택적으로 `stream:false`
|
|||
curl -fsS http://digitalplatform-iop.cloud:18086/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <token>' \
|
||||
-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}'
|
||||
```
|
||||
|
||||
## 파라미터
|
||||
|
|
@ -49,11 +52,19 @@ curl -fsS http://digitalplatform-iop.cloud:18086/v1/chat/completions \
|
|||
| --- | --- |
|
||||
| `model` | `gemma4:26b` |
|
||||
| `stream` | 기본 `true`, 필요하면 `false` |
|
||||
| `temperature` | 최소 기준 `0.2` |
|
||||
| `top_p` | 최소 기준 `0.95` |
|
||||
| `think` | 생략 |
|
||||
| `reasoning_effort` | 생략 |
|
||||
| `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를 렌더링 대상에서 제외하는 방식으로 처리할 수 있다.
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ pi --version
|
|||
디렉터리가 없으면 만든다.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.pi/agent
|
||||
mkdir -p ~/.pi/agent/extensions
|
||||
```
|
||||
|
||||
## settings.json
|
||||
|
|
@ -48,12 +48,56 @@ 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<string, unknown> {
|
||||
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에는 적용하지 않는다.
|
||||
|
||||
반복 출력 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`에는 실제 토큰을 넣는다. 토큰은 문서, 로그, 공유 화면에 남기지 않는다.
|
||||
|
|
@ -101,14 +145,18 @@ 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`를 주입한다.
|
||||
- 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 변경을 반영한다.
|
||||
|
||||
## 확인
|
||||
|
||||
```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 +176,7 @@ jq '.providers["dev-corp"] | {
|
|||
- Pi 설치
|
||||
- ~/.pi/agent/settings.json
|
||||
- ~/.pi/agent/models.json
|
||||
- ~/.pi/agent/extensions/dev-corp-temperature.ts
|
||||
|
||||
수정 금지:
|
||||
- 이 저장소 코드와 설정
|
||||
|
|
@ -149,6 +198,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 +227,5 @@ models.json:
|
|||
|
||||
think=false, reasoning_effort=none, thinking_token_budget 설정은 추가하지 마.
|
||||
검증 출력에는 apiKey 원문을 포함하지 말고 hasApiKey boolean만 보여줘.
|
||||
이미 실행 중인 Pi session에는 /reload 또는 재시작이 필요하다고 안내해줘.
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in a new issue