General API와 Coding Plan이 서로 다른 엔드포인트와 사용량 경계를 유지하도록 프로필, credential route, provider mapping, full-cycle 검증을 함께 반영한다.
365 lines
13 KiB
Bash
Executable file
365 lines
13 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# OpenAI-compatible GLM Coding Plan full-cycle smoke.
|
|
#
|
|
# Deterministic, credential-free Edge -> Node -> loopback-provider full-cycle for
|
|
# the built-in `profile: glm_coding` Coding Plan profile. A temporary Go fake
|
|
# provider stands in for the Z.AI Coding Plan endpoint and only ever accepts the
|
|
# Coding Plan paths:
|
|
# GET /api/coding/paas/v4/models
|
|
# POST /api/coding/paas/v4/chat/completions
|
|
# It requires `Authorization: Bearer fake-glm-coding-token`, upstream model
|
|
# `glm-5.1`, records non-streaming / streaming / tool-call variants, and fails
|
|
# any General API `/api/paas/v4` request. No SOPS credential and no external host
|
|
# are used. This script is a dedicated required diagnostic; it is intentionally
|
|
# not part of `make test-e2e`.
|
|
#
|
|
# The provider is declared Provider-First (`type: openai_api`, `profile:
|
|
# glm_coding`, root-only loopback `endpoint`), so profile normalization retains
|
|
# the documented `/api/coding/paas/v4` base path while the loopback origin
|
|
# replaces the upstream host.
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
|
|
# Repository-local ignored temp root under build/ so generated executables run
|
|
# on an executable filesystem (a noexec /tmp breaks `go run`). GOTMPDIR is
|
|
# relocated for the same reason; GOCACHE is inherited to keep builds warm.
|
|
TMP_ROOT="${IOP_GLM_CODING_TMP_ROOT:-$REPO_ROOT/build/e2e-openai-glm-coding}"
|
|
mkdir -p "$TMP_ROOT"
|
|
TMP_DIR=$(mktemp -d "$TMP_ROOT/run.XXXXXX")
|
|
mkdir -p "$TMP_DIR/go-tmp"
|
|
export GOTMPDIR="$TMP_DIR/go-tmp"
|
|
|
|
MODEL="glm-5.1"
|
|
CLIENT_MODEL="glm-coding-smoke"
|
|
PROVIDER_ID="glm-coding-smoke-provider"
|
|
FAKE_AUTHORIZATION="Bearer fake-glm-coding-token"
|
|
|
|
EDGE_PID=""
|
|
NODE_PID=""
|
|
GLM_PID=""
|
|
EDGE_FD_OPEN=0
|
|
|
|
cleanup() {
|
|
if [ "$EDGE_FD_OPEN" -eq 1 ]; then
|
|
{ echo "/exit" >&3; } 2>/dev/null || true
|
|
exec 3>&- 2>/dev/null || true
|
|
fi
|
|
if [ -n "$EDGE_PID" ]; then wait "$EDGE_PID" 2>/dev/null || true; fi
|
|
if [ -n "$NODE_PID" ]; then kill "$NODE_PID" 2>/dev/null || true; fi
|
|
if [ -n "$GLM_PID" ]; then kill "$GLM_PID" 2>/dev/null || true; fi
|
|
if [ "${IOP_GLM_CODING_KEEP_TMP:-0}" = "1" ]; then
|
|
echo "[openai-glm-coding] keeping temp dir: $TMP_DIR"
|
|
return
|
|
fi
|
|
rm -rf "$TMP_DIR" 2>/dev/null || true
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
PORT=$((31000 + RANDOM % 5000))
|
|
BOOTSTRAP_PORT=$((21000 + RANDOM % 5000))
|
|
OPENAI_PORT=$((36000 + RANDOM % 5000))
|
|
GLM_PORT=$((41000 + RANDOM % 5000))
|
|
EDGE_METRICS_PORT=$((46000 + RANDOM % 5000))
|
|
NODE_METRICS_PORT=$((51000 + RANDOM % 5000))
|
|
|
|
wait_port() {
|
|
local host="$1"
|
|
local port="$2"
|
|
local label="$3"
|
|
local deadline=$((SECONDS + 20))
|
|
while ! timeout 1 bash -c 'cat < /dev/null > /dev/tcp/"$1"/"$2"' _ "$host" "$port" 2>/dev/null; do
|
|
if (( SECONDS >= deadline )); then
|
|
echo "[openai-glm-coding] $label did not open on $host:$port"
|
|
return 1
|
|
fi
|
|
sleep 0.2
|
|
done
|
|
}
|
|
|
|
FAKE_GLM="$TMP_DIR/fake_glm_coding.go"
|
|
cat > "$FAKE_GLM" <<'EOF'
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"sync/atomic"
|
|
)
|
|
|
|
type chatRequest struct {
|
|
Model string `json:"model"`
|
|
Stream bool `json:"stream"`
|
|
Tools []json.RawMessage `json:"tools"`
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 4 {
|
|
log.Fatal("usage: fake_glm_coding <listen-addr> <model> <authorization>")
|
|
}
|
|
model := os.Args[2]
|
|
authorization := os.Args[3]
|
|
|
|
var codingModels atomic.Int64
|
|
var chatNonStream atomic.Int64
|
|
var chatStream atomic.Int64
|
|
var chatTool atomic.Int64
|
|
var generalAPIRequests atomic.Int64
|
|
var unexpectedRequests atomic.Int64
|
|
|
|
authorized := func(w http.ResponseWriter, r *http.Request) bool {
|
|
if r.Header.Get("Authorization") != authorization {
|
|
http.Error(w, "unexpected authorization", http.StatusUnauthorized)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// Coding Plan models endpoint.
|
|
mux.HandleFunc("/api/coding/paas/v4/models", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet || r.RequestURI != "/api/coding/paas/v4/models" {
|
|
unexpectedRequests.Add(1)
|
|
http.Error(w, "unexpected models request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !authorized(w, r) {
|
|
return
|
|
}
|
|
codingModels.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"` + model + `"}]}`))
|
|
})
|
|
|
|
// Coding Plan chat endpoint. The only chat path this fake accepts.
|
|
mux.HandleFunc("/api/coding/paas/v4/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.RequestURI != "/api/coding/paas/v4/chat/completions" {
|
|
unexpectedRequests.Add(1)
|
|
http.Error(w, "unexpected chat request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !authorized(w, r) {
|
|
return
|
|
}
|
|
var req chatRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Model != model {
|
|
http.Error(w, "unexpected model: "+req.Model, http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Tool-call variant: auto function-calling with the emit_marker tool.
|
|
if len(req.Tools) > 0 {
|
|
chatTool.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"chatcmpl-fake-tool","object":"chat.completion","model":"` + model + `","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_fake","type":"function","function":{"name":"emit_marker","arguments":"{\"marker\":\"IOP_GLM_CODING_TOOL_OK\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":6,"completion_tokens":3,"total_tokens":9}}`))
|
|
return
|
|
}
|
|
// Streaming variant.
|
|
if req.Stream {
|
|
chatStream.Add(1)
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
flush := func() {
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
_, _ = w.Write([]byte("data: " + `{"choices":[{"delta":{"content":"IOP_GLM_CODING_"},"finish_reason":null}]}` + "\n\n"))
|
|
flush()
|
|
_, _ = w.Write([]byte("data: " + `{"choices":[{"delta":{"content":"STREAM_OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":6,"completion_tokens":2,"total_tokens":8}}` + "\n\n"))
|
|
flush()
|
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
|
flush()
|
|
return
|
|
}
|
|
// Non-streaming variant.
|
|
chatNonStream.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"chatcmpl-fake","object":"chat.completion","model":"` + model + `","choices":[{"index":0,"message":{"role":"assistant","content":"IOP_GLM_CODING_NONSTREAM_OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`))
|
|
})
|
|
|
|
// General API paths must never be reached by the glm_coding profile.
|
|
mux.HandleFunc("/api/paas/v4/", func(w http.ResponseWriter, _ *http.Request) {
|
|
generalAPIRequests.Add(1)
|
|
http.Error(w, "general API path must not be used by glm_coding profile", http.StatusInternalServerError)
|
|
})
|
|
|
|
// Assertion endpoint: succeeds only when the expected variants ran exactly once
|
|
// over the Coding Plan path with no General API or unexpected upstream request.
|
|
mux.HandleFunc("/assert", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "unexpected assertion method", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
nonStream := chatNonStream.Load()
|
|
stream := chatStream.Load()
|
|
tool := chatTool.Load()
|
|
general := generalAPIRequests.Load()
|
|
unexpected := unexpectedRequests.Load()
|
|
models := codingModels.Load()
|
|
if nonStream != 1 || stream != 1 || tool != 1 || general != 0 || unexpected != 0 {
|
|
http.Error(w, fmt.Sprintf("unexpected upstream profile: nonstream=%d stream=%d tool=%d general=%d unexpected=%d models=%d", nonStream, stream, tool, general, unexpected, models), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = fmt.Fprintf(w, `{"nonstream":%d,"stream":%d,"tool":%d,"general":%d,"unexpected":%d,"models":%d}`, nonStream, stream, tool, general, unexpected, models)
|
|
})
|
|
|
|
// Catch-all: any other upstream path is unexpected for this profile.
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
unexpectedRequests.Add(1)
|
|
http.Error(w, "unexpected upstream path: "+r.URL.Path, http.StatusNotFound)
|
|
})
|
|
|
|
log.Fatal(http.ListenAndServe(os.Args[1], mux))
|
|
}
|
|
EOF
|
|
|
|
go run "$FAKE_GLM" "127.0.0.1:$GLM_PORT" "$MODEL" "$FAKE_AUTHORIZATION" > "$TMP_DIR/fake_glm_coding.out" 2>&1 &
|
|
GLM_PID=$!
|
|
wait_port 127.0.0.1 "$GLM_PORT" "fake glm coding"
|
|
|
|
# Edge config: Provider-First openai_api provider selecting the built-in
|
|
# glm_coding profile with a root-only loopback endpoint and the fake Bearer
|
|
# header. The client model glm-coding-smoke is rewritten to served model glm-5.1.
|
|
EDGE_CONFIG="$TMP_DIR/edge.yaml"
|
|
NODE_CONFIG="$TMP_DIR/node.yaml"
|
|
cat > "$EDGE_CONFIG" <<EOF
|
|
server:
|
|
listen: "127.0.0.1:$PORT"
|
|
metrics:
|
|
port: $EDGE_METRICS_PORT
|
|
bootstrap:
|
|
listen: "127.0.0.1:$BOOTSTRAP_PORT"
|
|
artifact_dir: "$TMP_DIR/artifacts"
|
|
openai:
|
|
enabled: true
|
|
listen: "127.0.0.1:$OPENAI_PORT"
|
|
provider_id: "$PROVIDER_ID"
|
|
session_id: "glm-coding"
|
|
timeout_sec: 30
|
|
strict_output: false
|
|
models:
|
|
- id: "$CLIENT_MODEL"
|
|
providers:
|
|
$PROVIDER_ID: "$MODEL"
|
|
console:
|
|
session_id: default
|
|
nodes:
|
|
- id: test-node
|
|
alias: test-node
|
|
token: test-token
|
|
providers:
|
|
- id: "$PROVIDER_ID"
|
|
type: "openai_api"
|
|
category: "api"
|
|
profile: "glm_coding"
|
|
endpoint: "http://127.0.0.1:$GLM_PORT"
|
|
headers:
|
|
Authorization: "$FAKE_AUTHORIZATION"
|
|
models:
|
|
- "$MODEL"
|
|
health: available
|
|
capacity: 4
|
|
EOF
|
|
|
|
cat > "$NODE_CONFIG" <<EOF
|
|
transport:
|
|
edge_addr: "127.0.0.1:$PORT"
|
|
token: test-token
|
|
reconnect_interval: 1s
|
|
metrics:
|
|
port: $NODE_METRICS_PORT
|
|
node:
|
|
id: test-node
|
|
alias: test-node
|
|
EOF
|
|
|
|
EDGE_OUT="$TMP_DIR/edge.out"
|
|
NODE_OUT="$TMP_DIR/node.out"
|
|
mkfifo "$TMP_DIR/edge_fifo"
|
|
IOP_EDGE_CONFIG="$EDGE_CONFIG" "$REPO_ROOT/scripts/dev/edge.sh" < "$TMP_DIR/edge_fifo" > "$EDGE_OUT" 2>&1 &
|
|
EDGE_PID=$!
|
|
exec 3> "$TMP_DIR/edge_fifo"
|
|
EDGE_FD_OPEN=1
|
|
|
|
wait_port 127.0.0.1 "$PORT" "edge node transport"
|
|
wait_port 127.0.0.1 "$OPENAI_PORT" "edge openai api"
|
|
|
|
IOP_NODE_CONFIG="$NODE_CONFIG" "$REPO_ROOT/scripts/dev/node.sh" > "$NODE_OUT" 2>&1 &
|
|
NODE_PID=$!
|
|
|
|
deadline=$((SECONDS + 30))
|
|
while ! grep -q '\[node0-evt\] connected reason="registered"' "$EDGE_OUT"; do
|
|
if (( SECONDS >= deadline )); then
|
|
echo "[openai-glm-coding] node registration timed out"
|
|
echo "=== EDGE OUTPUT ==="; cat "$EDGE_OUT"
|
|
echo "=== NODE OUTPUT ==="; cat "$NODE_OUT"
|
|
exit 1
|
|
fi
|
|
sleep 0.2
|
|
done
|
|
|
|
# Edge health.
|
|
curl -fsS "http://127.0.0.1:$OPENAI_PORT/healthz" > /dev/null
|
|
|
|
# /v1/models must surface the client-facing model id from the catalog.
|
|
MODELS_OUT="$TMP_DIR/models.json"
|
|
curl -fsS "http://127.0.0.1:$OPENAI_PORT/v1/models" > "$MODELS_OUT"
|
|
grep -q "$CLIENT_MODEL" "$MODELS_OUT"
|
|
|
|
# Non-streaming chat completion.
|
|
CHAT_OUT="$TMP_DIR/chat.json"
|
|
curl -fsS \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"model\":\"$CLIENT_MODEL\",\"max_tokens\":32,\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly: smoke token\"}]}" \
|
|
"http://127.0.0.1:$OPENAI_PORT/v1/chat/completions" > "$CHAT_OUT"
|
|
grep -q "IOP_GLM_CODING_NONSTREAM_OK" "$CHAT_OUT"
|
|
grep -q '"total_tokens":7' "$CHAT_OUT"
|
|
|
|
# Streaming chat completion: SSE content chunks and a terminating [DONE].
|
|
STREAM_OUT="$TMP_DIR/stream.txt"
|
|
curl -fsS -N \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"model\":\"$CLIENT_MODEL\",\"stream\":true,\"max_tokens\":32,\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly: stream token\"}]}" \
|
|
"http://127.0.0.1:$OPENAI_PORT/v1/chat/completions" > "$STREAM_OUT"
|
|
grep -q '"content":"IOP_GLM_CODING_"' "$STREAM_OUT"
|
|
grep -q 'data: \[DONE\]' "$STREAM_OUT"
|
|
|
|
# Auto function-calling chat completion: the emit_marker tool must be forwarded
|
|
# and its tool_call must survive the round trip.
|
|
TOOL_OUT="$TMP_DIR/tool.json"
|
|
curl -fsS \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"model\":\"$CLIENT_MODEL\",\"max_tokens\":32,\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"emit_marker\",\"description\":\"emit a marker\",\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"]}}}],\"messages\":[{\"role\":\"user\",\"content\":\"Call emit_marker with marker set to done\"}]}" \
|
|
"http://127.0.0.1:$OPENAI_PORT/v1/chat/completions" > "$TOOL_OUT"
|
|
grep -q '"tool_calls"' "$TOOL_OUT"
|
|
grep -q '"emit_marker"' "$TOOL_OUT"
|
|
|
|
# The fake provider must have observed exactly the Coding Plan path, the fake
|
|
# Bearer header, the served model, and each variant once, with no General API
|
|
# path and no unexpected upstream request.
|
|
FAKE_ASSERT_OUT="$TMP_DIR/fake_assert.json"
|
|
curl -fsS "http://127.0.0.1:$GLM_PORT/assert" > "$FAKE_ASSERT_OUT"
|
|
grep -q '"nonstream":1' "$FAKE_ASSERT_OUT"
|
|
grep -q '"stream":1' "$FAKE_ASSERT_OUT"
|
|
grep -q '"tool":1' "$FAKE_ASSERT_OUT"
|
|
grep -q '"general":0' "$FAKE_ASSERT_OUT"
|
|
grep -q '"unexpected":0' "$FAKE_ASSERT_OUT"
|
|
|
|
if grep -i -E "node reported error|error run_id=|\[[^]]+-evt\] error|panic:" "$EDGE_OUT" "$NODE_OUT" >/dev/null; then
|
|
echo "[openai-glm-coding] detected failure marker"
|
|
echo "=== EDGE OUTPUT ==="; cat "$EDGE_OUT"
|
|
echo "=== NODE OUTPUT ==="; cat "$NODE_OUT"
|
|
exit 1
|
|
fi
|
|
|
|
echo "[openai-glm-coding] glm_coding Edge-Node-provider full-cycle PASSED."
|