From 986d8f5c07a1610cd2442fa36ae61582bd88e594 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 26 May 2026 10:27:25 +0800 Subject: [PATCH] fix: route deepseek codex via moonbridge --- cmd/hwlab-deepseek-responses-bridge/main.mjs | 107 ++++++++++++- .../main.test.mjs | 12 ++ deploy/moonbridge/Dockerfile | 23 +++ docs/reference/code-agent-chat-readiness.md | 6 +- docs/reference/g14-gitops-cicd.md | 17 +- scripts/g14-gitops-render.mjs | 146 +++++++++++++++--- 6 files changed, 278 insertions(+), 33 deletions(-) create mode 100644 deploy/moonbridge/Dockerfile diff --git a/cmd/hwlab-deepseek-responses-bridge/main.mjs b/cmd/hwlab-deepseek-responses-bridge/main.mjs index dfc1b4e8..956891bc 100644 --- a/cmd/hwlab-deepseek-responses-bridge/main.mjs +++ b/cmd/hwlab-deepseek-responses-bridge/main.mjs @@ -36,6 +36,10 @@ const server = createServer(async (clientReq, clientRes) => { await sendCodexModels(clientReq, clientRes, url); return; } + if (clientReq.method === "POST" && url.pathname === "/v1/responses") { + await proxyResponsesRequest(clientReq, clientRes, url); + return; + } await proxyRequest(clientReq, clientRes, url); } catch (error) { sendJson(clientRes, 502, { @@ -53,18 +57,55 @@ server.listen(port, "0.0.0.0", () => { }); async function sendReadiness(res) { - const upstream = await fetchUpstreamJson("/health/readiness").catch((error) => ({ ok: false, status: 502, payload: { error: error.message } })); + const upstream = await fetchUpstreamReadiness(); sendJson(res, upstream.ok ? 200 : 503, { ...healthPayload(), status: upstream.ok ? "ready" : "degraded", upstream: { ok: upstream.ok, status: upstream.status, + source: upstream.source, payload: upstream.payload } }); } +async function fetchUpstreamReadiness() { + const health = await fetchUpstreamJson("/health/readiness") + .then((result) => ({ ...result, source: "/health/readiness" })) + .catch((error) => ({ ok: false, status: 502, source: "/health/readiness", payload: { error: error.message } })); + if (health.ok) return health; + + const models = await fetchUpstreamJson("/v1/models") + .then((result) => ({ ...result, source: "/v1/models" })) + .catch((error) => ({ ok: false, status: 502, source: "/v1/models", payload: { error: error.message } })); + const modelCount = codexModelsFromUpstream(models.payload).length; + if (models.ok && modelCount > 0) { + return { + ...models, + payload: { + modelCount, + fallbackFrom: { + source: health.source, + status: health.status + } + } + }; + } + + return { + ...health, + payload: { + ...health.payload, + modelProbe: { + ok: models.ok, + status: models.status, + modelCount + } + } + }; +} + async function sendCodexModels(clientReq, clientRes, url) { const upstream = await fetchUpstreamJson(`${url.pathname}${url.search}`, clientReq.headers).catch((error) => ({ ok: false, status: 502, payload: { error: error.message } })); const models = codexModelsFromUpstream(upstream.payload); @@ -73,7 +114,7 @@ async function sendCodexModels(clientReq, clientRes, url) { upstream: { ok: upstream.ok, status: upstream.status, - source: upstream.ok ? "litellm" : "fallback" + source: upstream.ok ? "upstream" : "fallback" } }); } @@ -126,6 +167,65 @@ async function proxyRequest(clientReq, clientRes, url) { upstreamReq.end(body); } +async function proxyResponsesRequest(clientReq, clientRes, url) { + const startedAt = Date.now(); + const originalBody = await readRequestBody(clientReq); + const { body, headers, decompressed, sanitizedToolTypes } = decodeRequestBody(originalBody, clientReq.headers, url.pathname); + await proxyPreparedBody({ + clientReq, + clientRes, + url, + body, + headers, + startedAt, + logExtra: { decompressed, sanitizedToolTypes } + }); +} + +async function proxyPreparedBody({ clientReq, clientRes, url, body, headers, startedAt, logExtra = {} }) { + const targetUrl = new URL(`${url.pathname}${url.search}`, upstreamBaseUrl); + const requestImpl = targetUrl.protocol === "https:" ? httpsRequest : httpRequest; + + const upstreamReq = requestImpl({ + protocol: targetUrl.protocol, + hostname: targetUrl.hostname, + port: targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80), + method: clientReq.method, + path: `${targetUrl.pathname}${targetUrl.search}`, + headers + }, (upstreamRes) => { + const responseHeaders = filterResponseHeaders(upstreamRes.headers); + clientRes.writeHead(upstreamRes.statusCode || 502, responseHeaders); + upstreamRes.pipe(clientRes); + upstreamRes.on("end", () => { + process.stderr.write(JSON.stringify({ + serviceId, + method: clientReq.method, + path: url.pathname, + status: upstreamRes.statusCode || null, + elapsedMs: Date.now() - startedAt, + ...logExtra + }) + "\n"); + }); + }); + + upstreamReq.on("error", (error) => { + if (!clientRes.headersSent) { + sendJson(clientRes, 502, { + error: { + code: "deepseek_bridge_upstream_error", + message: "DeepSeek bridge upstream request failed", + reason: error.message + } + }); + } else { + clientRes.end(); + } + }); + + upstreamReq.end(body); +} + function decodeRequestBody(body, headers, pathname = "") { const encoding = String(headers["content-encoding"] || "").trim().toLowerCase(); const decoded = encoding === "zstd" ? Buffer.from(decompress(new Uint8Array(body))) : body; @@ -255,7 +355,8 @@ function healthPayload() { status: "ok", upstream: upstreamBaseUrl.origin, model: defaultModel, - zstdRequestBody: "decode" + zstdRequestBody: "decode", + responsesTools: "drop-non-function" }; } diff --git a/cmd/hwlab-deepseek-responses-bridge/main.test.mjs b/cmd/hwlab-deepseek-responses-bridge/main.test.mjs index 12d7f9ec..648821e4 100644 --- a/cmd/hwlab-deepseek-responses-bridge/main.test.mjs +++ b/cmd/hwlab-deepseek-responses-bridge/main.test.mjs @@ -30,11 +30,18 @@ test("DeepSeek Responses bridge decompresses Codex zstd body and maps models cat body: zstdPayload }); assert.equal(response.status, 200); + assert.equal(upstream.captured.url, "/v1/responses"); assert.equal(upstream.captured.encoding, null); const forwarded = JSON.parse(upstream.captured.body); assert.equal(forwarded.model, "deepseek-chat"); assert.deepEqual(forwarded.tools.map((tool) => tool.type), ["function", "function"]); + const readinessResponse = await fetch(`http://127.0.0.1:${bridge.port}/health/readiness`); + assert.equal(readinessResponse.status, 200); + const readiness = await readinessResponse.json(); + assert.equal(readiness.upstream.source, "/v1/models"); + assert.equal(readiness.upstream.payload.modelCount, 1); + const modelsResponse = await fetch(`http://127.0.0.1:${bridge.port}/v1/models?client_version=test`); assert.equal(modelsResponse.status, 200); const models = await modelsResponse.json(); @@ -49,6 +56,11 @@ test("DeepSeek Responses bridge decompresses Codex zstd body and maps models cat async function startUpstream() { let captured = null; const server = createServer(async (request, response) => { + if ((request.url || "").startsWith("/health/readiness")) { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not implemented" })); + return; + } if ((request.url || "").startsWith("/v1/models")) { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ data: [{ id: "deepseek-chat" }] })); diff --git a/deploy/moonbridge/Dockerfile b/deploy/moonbridge/Dockerfile new file mode 100644 index 00000000..332a19e0 --- /dev/null +++ b/deploy/moonbridge/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.25-bookworm AS builder + +ARG MOONBRIDGE_REPO=https://github.com/ZhiYi-R/moon-bridge.git +ARG MOONBRIDGE_REF=1b99888d3dae889b79ee602cb875c7907f7e76f2 + +ENV GOPROXY=https://goproxy.cn,direct + +WORKDIR /src +RUN git clone --filter=blob:none "${MOONBRIDGE_REPO}" moon-bridge +WORKDIR /src/moon-bridge +RUN git checkout "${MOONBRIDGE_REF}" \ + && go mod download \ + && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/moonbridge ./cmd/moonbridge + +FROM scratch + +WORKDIR /app +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=builder /out/moonbridge /app/moonbridge + +EXPOSE 4001 +USER 65532:65532 +ENTRYPOINT ["/app/moonbridge"] diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index 85e32e7d..66efdbff 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -29,8 +29,10 @@ Codex token boundary 仍由授权路径把 `OPENAI_API_KEY` 注入到 DEV runtim 直接指向 public `api.openai.com`。G14 默认 `deepseek` profile 指向 `http://hwlab-deepseek-proxy..svc.cluster.local:4000/v1/responses`;该 Service 必须先进入 `hwlab-deepseek-responses-bridge`,由 bridge 解压 Codex -Responses 的 zstd request body、规范化 `/v1/models` 返回,再转发到同 Pod 内 -LiteLLM 4001。`codex-api` profile 仍可指向 +Responses 的 zstd request body、规范化 `/v1/models` 返回、丢弃非 `function` tool, +再转发到同 Pod 内 Moon Bridge 4001。Moon Bridge 是 DeepSeek profile 的真实 +Responses 转换和 prompt cache 保留层;不要在 HWLAB 中手写完整转换器替代它。 +`codex-api` profile 仍可指向 `http://172.26.26.227:17680/v1/responses` 作为旧 Responses 通道。 Runner 不得尝试修补、读取、回显或替换该 Secret。若 DEV runtime 缺少该授权凭证注入, diff --git a/docs/reference/g14-gitops-cicd.md b/docs/reference/g14-gitops-cicd.md index 44e501ae..0378d39e 100644 --- a/docs/reference/g14-gitops-cicd.md +++ b/docs/reference/g14-gitops-cicd.md @@ -45,10 +45,11 @@ HWLAB 是 monorepo,G14 CI/CD 加速必须按组件输入判断构建和滚动 - `scripts/g14-ci-plan.mjs` 是只读 planner,默认读取当前 `CI.json`、`deploy/deploy.json` 和 `deploy/artifact-catalog.dev.json`,输出 `affectedServices`、`reusedServices`、`componentCommitId`、`componentInputHash`、`dockerfileHash`、`baseImageDigest`、`buildArgsHash` 和原因;它不得修改 CI、deploy、catalog 或 GitOps 文件。 - 服务清单兼容顺序固定为:显式 `--services`、`deploy.services[]`、`deploy.k3s.serviceMappings[]`、`internal/protocol.SERVICE_IDS`。因此旧 `deploy/deploy.json` 形态和当前完整 `deploy.services[]` 形态都必须能被 planner 识别。 - `CI.json` 仍是 CI 命令与禁用项合同来源;可选新增 `g14CiPlan.components.` 覆盖 `componentPaths`、`sharedPaths`、`runtimeDeps`、`buildSystemPaths`,但不得要求删除或改写现有 `commands` / `forbidden` 字段。 -- `scripts/g14-artifact-publish.mjs` 的 publish report 可以携带 planner 的 per-service 元数据;`scripts/refresh-artifact-catalog.mjs` 可以把这些字段复制进 `deploy/artifact-catalog.dev.json` 的对应 service。旧字段 `commitId`、`image`、`imageTag`、`digest`、`publishState` 语义不变,旧检查脚本不得因为新增字段失败。 -- `scripts/g14-gitops-render.mjs` 默认保持 legacy 兼容:不传新开关时,仍按 `--source-revision` 把所有 workload image 渲染为同一个 source commit tag,现有 `npm run g14:gitops:check` 必须继续通过。 -- 混合 desired state 只能通过显式 `--use-deploy-images` 或 `HWLAB_G14_USE_DEPLOY_IMAGES=1` 启用。启用后 workload 的 container image、`HWLAB_IMAGE`、`HWLAB_IMAGE_TAG` 和 pod template `source-commit` 来自 `deploy.services[]` 的 per-service image/env;全局 GitOps metadata 仍记录本次 source commit。未启用时禁止读取旧 catalog/digest 去改变 rollout 行为。 -- 后续 Tekton 并发化只能以 planner 输出作为输入,先做到每个 changed service 一个构建任务;unchanged service 复用 catalog digest,且不能改 pod template,避免无意义 rollout。没有完整 per-service desired state 证据时,必须回退当前全量 source commit render,而不是猜测复用。 +- `scripts/g14-artifact-publish.mjs` 默认启用组件级 lazy build:先运行 planner,再只构建/推送 `affectedServices`,`reusedServices` 必须从 `deploy/artifact-catalog.dev.json` 复用已有 sha256 digest;如果 catalog 没有可验证 digest,必须结构化阻塞并提示先跑全量 publish。`--full-build` 是兼容回退开关,用于强制构建/发布每个 required service。 +- `scripts/g14-artifact-publish.mjs` 的 publish report 必须携带 planner 的 per-service 元数据;`scripts/refresh-artifact-catalog.mjs` 把这些字段复制进 `deploy/artifact-catalog.dev.json` 和 `deploy/deploy.json` 的对应 service。旧字段 `commitId`、`image`、`imageTag`、`digest`、`publishState` 语义不变,旧检查脚本不得因为新增字段失败。 +- `scripts/g14-gitops-render.mjs` 默认启用混合 desired state:workload 的 container image、`HWLAB_IMAGE`、`HWLAB_IMAGE_TAG` 和 pod template `source-commit` 来自 `deploy.services[]` 的 per-service image/env;全局 GitOps metadata 仍记录本次 source commit。`--legacy-source-images` 或 `HWLAB_G14_USE_DEPLOY_IMAGES=0` 是兼容回退开关,用于把所有 workload image 渲染为同一个 source commit tag。 +- G14 Tekton promotion 在推送 `G14-gitops` 前,必须用 publish report 刷新 workspace 内的 `deploy/deploy.json`、`deploy/artifact-catalog.dev.json` 和 `deploy/k8s/base/workloads.yaml`,并把该 per-service artifact truth 以 `chore: promote G14 GitOps source ` 提交回 `G14` source branch;poller 必须跳过这类生成提交,避免自触发循环。这样下一轮 planner 才能从最新 catalog 复用上一轮已构建服务,避免把未变服务回滚到旧 digest。 +- 后续 Tekton 并发化只能以 planner 输出作为输入,先做到每个 changed service 一个构建任务;unchanged service 复用 catalog digest,且不能改 pod template,避免无意义 rollout。没有完整 per-service desired state 证据时,必须使用 `--full-build` 或 `--legacy-source-images` 回退,而不是猜测复用。 ## Code Agent Provider Profiles @@ -57,14 +58,14 @@ G14 Code Agent 通过同一个 repo-owned Codex app-server stdio runner 承载 | Profile | 默认 | Model | Base URL | 说明 | | --- | --- | --- | --- | --- | -| `deepseek` | 是 | `HWLAB_CODE_AGENT_DEEPSEEK_MODEL`,默认 `deepseek-chat` | `HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL`,默认 `http://hwlab-deepseek-proxy..svc.cluster.local:4000/v1/responses` | G14 集群内 DeepSeek Responses bridge;Service 4000 先进入 `hwlab-deepseek-responses-bridge`,再转发到同 Pod 内 LiteLLM 4001。 | +| `deepseek` | 是 | `HWLAB_CODE_AGENT_DEEPSEEK_MODEL`,默认 `deepseek-chat` | `HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL`,默认 `http://hwlab-deepseek-proxy..svc.cluster.local:4000/v1/responses` | G14 集群内 DeepSeek Responses bridge;Service 4000 先进入 `hwlab-deepseek-responses-bridge`,再转发到同 Pod 内 Moon Bridge 4001。 | | `codex-api` | 否 | `HWLAB_CODE_AGENT_CODEX_API_MODEL`,默认 `gpt-5.5` | `HWLAB_CODE_AGENT_CODEX_API_BASE_URL`,默认 `http://172.26.26.227:17680/v1/responses` | 保留旧 Codex/OpenAI-compatible Responses API 通道。 | -`hwlab-cloud-api` 仍以 `HWLAB_CODE_AGENT_PROVIDER=codex-stdio` 运行。`HWLAB_CODE_AGENT_MODEL` 与 `HWLAB_CODE_AGENT_OPENAI_BASE_URL` 可作为 runtime-default 兜底,但前端默认 profile 是 `deepseek`,用户可以切到 `codex-api`。Codex app-server 启动参数必须同时设置 provider base URL、provider `name`、`model` 和 `review_model`,否则 Codex/LiteLLM 链路可能在模型目录或 `/v1/responses` 阶段退化成 `model=None`。旧 `codex-api` profile 指向 `172.26.26.227`,cloud-api 的 `NO_PROXY/no_proxy` 必须包含该 host 或私网段,避免旧通道被 G14 外网代理误拦截。 +`hwlab-cloud-api` 仍以 `HWLAB_CODE_AGENT_PROVIDER=codex-stdio` 运行。`HWLAB_CODE_AGENT_MODEL` 与 `HWLAB_CODE_AGENT_OPENAI_BASE_URL` 可作为 runtime-default 兜底,但前端默认 profile 是 `deepseek`,用户可以切到 `codex-api`。Codex app-server 启动参数必须同时设置 provider base URL、provider `name`、`model` 和 `review_model`,否则 Codex/DeepSeek 链路可能在模型目录或 `/v1/responses` 阶段退化成 `model=None`。旧 `codex-api` profile 指向 `172.26.26.227`,cloud-api 的 `NO_PROXY/no_proxy` 必须包含该 host 或私网段,避免旧通道被 G14 外网代理误拦截。 -Codex app-server 当前要求 provider `wire_api="responses"`,不得把 DeepSeek profile 切到旧 `chat` wire API。Codex 发往 `/v1/responses` 的请求体可能带 `Content-Encoding: zstd`,而 LiteLLM DeepSeek bridge 默认不能按 Codex 期望解压该请求体,也不能直接返回 Codex 期望的 `models` 目录格式。因此 GitOps 中的 `hwlab-deepseek-proxy` Pod 必须包含 repo-owned `hwlab-deepseek-responses-bridge` sidecar:Service 端口 4000 指向 bridge,bridge 对 zstd request body 解压、删除 `Content-Encoding`/重写 `Content-Length` 后转发给 4001 的 LiteLLM,并把 `GET /v1/models` 转成 Codex-style `{ "models": [...] }` 目录。DeepSeek API 只接受 `function` tools,bridge 必须在转发前丢弃 Codex Responses 请求里的非 `function` tool 类型(例如 `image_generation`),但不得移除 shell/apply-patch 等 function tools。模型、密钥和真实推理仍由 LiteLLM 管理;bridge 不新增业务 gate,也不得把兼容失败伪装成 SOURCE/legacy blocker。 +Codex app-server 当前要求 provider `wire_api="responses"`,不得把 DeepSeek profile 切到旧 `chat` wire API。DeepSeek profile 的真实 Responses 转换层固定使用 Moon Bridge;不要在 HWLAB 里手写完整 Responses-to-Chat/Anthropic 转换器,因为这会破坏 Moon Bridge 对 prompt cache、tool-result 顺序和模型目录的成熟处理。Codex 发往 `/v1/responses` 的请求体可能带 `Content-Encoding: zstd`,而 Moon Bridge 不负责解压 Codex zstd body。因此 GitOps 中的 `hwlab-deepseek-proxy` Pod 必须包含 repo-owned `hwlab-deepseek-responses-bridge` sidecar:Service 端口 4000 指向 bridge,bridge 只做 zstd request body 解压、删除 `Content-Encoding`/重写 `Content-Length`、丢弃非 `function` tool 类型和 Codex-style `GET /v1/models` 目录适配,再把请求转发给 4001 的 Moon Bridge。DeepSeek API 只接受 `function` tools,bridge 必须在转发前丢弃 Codex Responses 请求里的非 `function` tool 类型(例如 `web_search`、`image_generation`),但不得移除 shell/apply-patch 等 function tools。模型、cache、tool 调用顺序、密钥和真实推理仍由 Moon Bridge 管理;bridge 不新增业务 gate,也不得把兼容失败伪装成 SOURCE/legacy blocker。 -DeepSeek proxy manifest 是 GitOps desired state 的一部分,DEV/PROD 分别生成在 `runtime-dev/deepseek-proxy.yaml` 和 `runtime-prod/deepseek-proxy.yaml`。DeepSeek key 仍通过 `hwlab-code-agent-provider/openai-api-key` Secret 注入到 proxy 容器;文档、trace、health、issue 和日志不得打印 Secret 值。 +DeepSeek proxy manifest 是 GitOps desired state 的一部分,DEV/PROD 分别生成在 `runtime-dev/deepseek-proxy.yaml` 和 `runtime-prod/deepseek-proxy.yaml`。Moon Bridge 镜像由 `deploy/moonbridge/Dockerfile` 从上游 `ZhiYi-R/moon-bridge` 固定 commit 构建,默认镜像为 G14 本地 registry 的 `moonbridge:<上游短 commit>`;GitHub、Google 或 Docker base image 下载必须优先使用 G14 节点本地 proxy。DeepSeek key 仍通过 `hwlab-code-agent-provider/openai-api-key` Secret 注入到 init container 并写入 Pod 内 `emptyDir` 配置文件;ConfigMap、文档、trace、health、issue 和日志不得打印 Secret 值。 ## Polling 触发 diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index 14f7d924..9340a331 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -24,8 +24,8 @@ const defaultNoProxy = process.env.HWLAB_G14_NO_PROXY || "127.0.0.1,localhost,:: const ciJsonRunnerImage = process.env.HWLAB_G14_CI_JSON_IMAGE || "mcr.microsoft.com/playwright@sha256:b0ab6f3cb99aa7803adbc14d9027ec1785fc6e433b97e134e0f8fe61683b6b53"; const ciToolsRunnerImage = process.env.HWLAB_G14_CI_TOOLS_IMAGE || "127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-v1"; const defaultDevBaseImage = process.env.HWLAB_G14_DEV_BASE_IMAGE || "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim"; -const deepSeekProxyImage = process.env.HWLAB_G14_DEEPSEEK_PROXY_IMAGE || "ghcr.io/berriai/litellm:main-latest"; -const deepSeekLiteLlmModel = process.env.HWLAB_G14_DEEPSEEK_LITELLM_MODEL || "deepseek/deepseek-chat"; +const moonBridgeSourceRef = process.env.HWLAB_G14_MOONBRIDGE_REF || "1b99888d3dae889b79ee602cb875c7907f7e76f2"; +const moonBridgeImage = process.env.HWLAB_G14_MOONBRIDGE_IMAGE || `${defaultRegistryPrefix}/moonbridge:${moonBridgeSourceRef.slice(0, 12)}`; const deepSeekProfileModel = process.env.HWLAB_G14_DEEPSEEK_PROFILE_MODEL || "deepseek-chat"; const codexApiProfileModel = process.env.HWLAB_G14_CODEX_API_PROFILE_MODEL || "gpt-5.5"; const codexApiProfileBaseUrl = process.env.HWLAB_G14_CODEX_API_PROFILE_BASE_URL || "http://172.26.26.227:17680/v1/responses"; @@ -56,7 +56,7 @@ function parseArgs(argv) { webEndpoint: defaultWebEndpoint, prodRuntimeEndpoint: defaultProdRuntimeEndpoint, prodWebEndpoint: defaultProdWebEndpoint, - useDeployImages: process.env.HWLAB_G14_USE_DEPLOY_IMAGES === "1", + useDeployImages: process.env.HWLAB_G14_USE_DEPLOY_IMAGES !== "0", check: false, write: true, help: false @@ -74,6 +74,7 @@ function parseArgs(argv) { else if (arg === "--prod-runtime-endpoint") args.prodRuntimeEndpoint = readOption(argv, ++index, arg); else if (arg === "--prod-web-endpoint") args.prodWebEndpoint = readOption(argv, ++index, arg); else if (arg === "--use-deploy-images") args.useDeployImages = true; + else if (arg === "--legacy-source-images") args.useDeployImages = false; else if (arg === "--check") { args.check = true; args.write = false; @@ -107,7 +108,8 @@ function usage() { ` --web-endpoint URL default: ${defaultWebEndpoint}`, ` --prod-runtime-endpoint URL default: ${defaultProdRuntimeEndpoint}`, ` --prod-web-endpoint URL default: ${defaultProdWebEndpoint}`, - " --use-deploy-images render workload images/env from deploy/deploy.json per service; default keeps legacy source revision tags", + " --use-deploy-images default: render workload images/env from deploy/deploy.json per service", + " --legacy-source-images compatibility fallback: render every workload with the source revision tag", " --check verify generated files are current without writing" ].join("\n"); } @@ -596,23 +598,34 @@ test "$(git rev-parse HEAD)" = "$(params.revision)" check_source_head() { phase="$1" + expected="\${2:-$(params.revision)}" latest="$(git ls-remote "$(params.git-url)" "refs/heads/$(params.source-branch)" | cut -f1)" if [ -z "$latest" ]; then echo '{"status":"failed","reason":"source-head-unresolved","phase":"'"$phase"'","sourceBranch":"'"$(params.source-branch)"'","sourceRevision":"'"$(params.revision)"'"}' exit 1 fi - if [ "$latest" != "$(params.revision)" ]; then - echo '{"status":"skipped-stale-source","phase":"'"$phase"'","sourceBranch":"'"$(params.source-branch)"'","sourceRevision":"'"$(params.revision)"'","latestRevision":"'"$latest"'"}' + if [ "$latest" != "$expected" ]; then + echo '{"status":"skipped-stale-source","phase":"'"$phase"'","sourceBranch":"'"$(params.source-branch)"'","sourceRevision":"'"$(params.revision)"'","expectedRevision":"'"$expected"'","latestRevision":"'"$latest"'"}' exit 0 fi } check_source_head before-render -node scripts/g14-gitops-render.mjs --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" -node scripts/g14-gitops-render.mjs --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --check -check_source_head before-push git config --global user.name "HWLAB G14 GitOps Bot" git config --global user.email "hwlab-g14-gitops-bot@users.noreply.github.com" +if [ -s /workspace/source/dev-artifacts.json ]; then + node scripts/refresh-artifact-catalog.mjs --target-ref "$(params.revision)" --publish-report /workspace/source/dev-artifacts.json + if ! git diff --quiet -- deploy/deploy.json deploy/artifact-catalog.dev.json deploy/k8s/base/workloads.yaml; then + short="$(printf '%.7s' "$(params.revision)")" + git add deploy/deploy.json deploy/artifact-catalog.dev.json deploy/k8s/base/workloads.yaml + git commit -m "chore: promote G14 GitOps source $short" + git push origin "HEAD:$(params.source-branch)" + fi +fi +source_state_revision="$(git rev-parse HEAD)" +node scripts/g14-gitops-render.mjs --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --use-deploy-images +node scripts/g14-gitops-render.mjs --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --use-deploy-images --check +check_source_head before-push "$source_state_revision" workdir="$(mktemp -d)" git clone --no-checkout "$(params.git-url)" "$workdir/gitops" cd "$workdir/gitops" @@ -1410,19 +1423,30 @@ function deepSeekProxyManifest({ profile = "dev", source, registryPrefix = defau kind: "ConfigMap", metadata: { name: "hwlab-deepseek-proxy-config", namespace, labels }, data: { - "config.yaml": `model_list:\n - model_name: ${deepSeekProfileModel}\n litellm_params:\n model: ${deepSeekLiteLlmModel}\n api_key: os.environ/DEEPSEEK_API_KEY\n api_base: https://api.deepseek.com\nlitellm_settings:\n drop_params: true\n set_verbose: false\n` + "render-config.sh": moonBridgeConfigRenderScript() } }, { apiVersion: "apps/v1", kind: "Deployment", - metadata: { name: "hwlab-deepseek-proxy", namespace, labels }, + metadata: { name: "hwlab-deepseek-proxy", namespace, labels, annotations: { "hwlab.pikastech.local/moonbridge-source-ref": moonBridgeSourceRef } }, spec: { replicas: 1, selector: { matchLabels: { "app.kubernetes.io/name": "hwlab-deepseek-proxy" } }, template: { metadata: { labels }, spec: { + initContainers: [{ + name: "moonbridge-config", + image: bridgeImage, + imagePullPolicy: "IfNotPresent", + command: ["/bin/sh", "-eu", "/scripts/render-config.sh"], + env: [{ name: "DEEPSEEK_API_KEY", valueFrom: { secretKeyRef: { name: "hwlab-code-agent-provider", key: "openai-api-key", optional: true } } }], + volumeMounts: [ + { name: "moonbridge-scripts", mountPath: "/scripts", readOnly: true }, + { name: "moonbridge-config", mountPath: "/config" } + ] + }], containers: [{ name: "responses-bridge", image: bridgeImage, @@ -1437,17 +1461,23 @@ function deepSeekProxyManifest({ profile = "dev", source, registryPrefix = defau readinessProbe: { httpGet: { path: "/health/readiness", port: "http" }, initialDelaySeconds: 10, periodSeconds: 10 }, livenessProbe: { httpGet: { path: "/health/liveliness", port: "http" }, initialDelaySeconds: 20, periodSeconds: 20 } }, { - name: "litellm", - image: deepSeekProxyImage, + name: "moonbridge", + image: moonBridgeImage, imagePullPolicy: "IfNotPresent", - args: ["--config", "/etc/litellm/config.yaml", "--host", "0.0.0.0", "--port", "4001"], - env: [{ name: "DEEPSEEK_API_KEY", valueFrom: { secretKeyRef: { name: "hwlab-code-agent-provider", key: "openai-api-key", optional: true } } }], - ports: [{ name: "litellm-http", containerPort: 4001 }], - readinessProbe: { httpGet: { path: "/health/readiness", port: "litellm-http" }, initialDelaySeconds: 10, periodSeconds: 10 }, - livenessProbe: { httpGet: { path: "/health/liveliness", port: "litellm-http" }, initialDelaySeconds: 20, periodSeconds: 20 }, - volumeMounts: [{ name: "config", mountPath: "/etc/litellm", readOnly: true }] + args: ["-config", "/config/config.yml"], + ports: [{ name: "moonbridge-http", containerPort: 4001 }], + readinessProbe: { httpGet: { path: "/v1/models", port: "moonbridge-http" }, initialDelaySeconds: 10, periodSeconds: 10 }, + livenessProbe: { httpGet: { path: "/v1/models", port: "moonbridge-http" }, initialDelaySeconds: 20, periodSeconds: 20 }, + volumeMounts: [ + { name: "moonbridge-config", mountPath: "/config", readOnly: true }, + { name: "moonbridge-data", mountPath: "/data" } + ] }], - volumes: [{ name: "config", configMap: { name: "hwlab-deepseek-proxy-config" } }] + volumes: [ + { name: "moonbridge-scripts", configMap: { name: "hwlab-deepseek-proxy-config" } }, + { name: "moonbridge-config", emptyDir: {} }, + { name: "moonbridge-data", emptyDir: {} } + ] } } } @@ -1462,6 +1492,82 @@ function deepSeekProxyManifest({ profile = "dev", source, registryPrefix = defau }; } +function moonBridgeConfigRenderScript() { + return `#!/bin/sh +if [ -z "\${DEEPSEEK_API_KEY:-}" ]; then + echo "DEEPSEEK_API_KEY is required" >&2 + exit 1 +fi +cat > /config/config.yml <